feat: initial jump-host — SSH jump host for the theta42 stack

An SSH jump host that authenticates users against the shared LDAP
directory, authorizes them from the SSO Manager's inventory graph, and
bridges them to downstream hosts — auditing everything.

- Username-grammar routing (uid_-_target@jump) + interactive TUI picker
- Inbound LDAP auth (publickey / password with off|local|all policy)
- Directory-driven access (LDAP groups x /api/discovery/resources?group=)
- Per-user key injection into sshPublicKey, connects downstream as the user
- Shell / exec / SFTP-subsystem bridging (WinSCP works)
- Web UI + HTTP API (:3002) for audit + metrics; LDAP-admin gated
- Packaged like proxy: ops/install.sh + systemd, all-in-one Docker, compose
- Tests: 23 unit + 3 integration (node --test), all green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:46:19 -04:00
commit 36e9d5b0b3
51 changed files with 4291 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# .git is intentionally NOT excluded — the gitinfo build stage reads it to
# bake the commit hash, then it's discarded before the final stage.
.gitignore
# Docs not needed in the image (README/CHANGELOG/DEPLOYMENT are copied explicitly).
docs/
*.md
!README.md
!CHANGELOG.md
!DEPLOYMENT.md
# Tests
nodejs/test/
# Host dependency tree — let the image run a clean npm ci.
nodejs/node_modules
# Local dev state
nodejs/data
data/
# Secrets (mount at runtime instead)
nodejs/conf/secrets.js
secrets.js
config/
# Docker (prevent recursive copy)
Dockerfile*
docker-compose.yml
.dockerignore
# Ops (systemd/install run on the host, not in the image)
ops/
# IDE / OS
.vscode
.idea
*.swp
.DS_Store
+6
View File
@@ -0,0 +1,6 @@
config/
data/
secrets.js
*.log
.DS_Store
node_modules/
+32
View File
@@ -0,0 +1,32 @@
# Changelog
All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.0.0] - 2026-07-23
### Added
- Initial release. An SSH jump host for the theta42 stack:
- **Username-grammar routing**: `ssh {uid}_-_{target}@jumphost` bridges
straight to the downstream host (`target` = a directory host slug, bare
hostname, or IP). Shell, exec, and the **SFTP subsystem** all pass through,
so WinSCP/`sftp` work.
- **Interactive TUI picker**: plain `ssh {uid}@jumphost` lists the hosts the
user can reach (from the SSO directory) and bridges to the chosen one.
- **LDAP auth** of the inbound user (publickey against the user's
`sshPublicKey`, or password via LDAP bind — password policy is
off/local/all).
- **Directory-driven access**: reachable hosts are the union of the user's
LDAP groups × the SSO directory (`/api/discovery/resources?group=`).
- **Per-user key injection**: the jump host appends its own public key to the
user's `sshPublicKey` on first use, then connects downstream as that user
(downstream hosts already serve LDAP keys via ldap-client's
AuthorizedKeysCommand).
- **Web UI + HTTP API** (`:3002`) for auditing and metrics: active sessions,
paged audit log, per-user/per-host counters. Admin login gated by LDAP
group membership.
- **Audit logging** of every connection attempt/session (user, target,
method, result, bytes, duration, downstream host-key fingerprint).
- Packaged like theta42/proxy: idempotent `ops/install.sh` + systemd unit,
all-in-one Docker image, standalone `docker-compose.yml`.
+83
View File
@@ -0,0 +1,83 @@
# 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.
+54
View File
@@ -0,0 +1,54 @@
# Theta42 jump host — all-in-one image (Node app + Redis), mirroring the
# proxy/sso-manager packaging (dumb-init PID 1, gitinfo stage baking the commit).
ARG GIT_COMMIT=""
FROM node:22-bookworm-slim AS gitinfo
ARG GIT_COMMIT
WORKDIR /repo
COPY .git ./.git
RUN if [ -n "$GIT_COMMIT" ]; then \
echo "$GIT_COMMIT" > /commit.txt; \
else \
{ apt-get update && apt-get install -y --no-install-recommends git \
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
fi
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
redis-server dumb-init ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install production deps first for layer caching (.dockerignore excludes
# nodejs/node_modules so npm ci builds a clean tree).
COPY nodejs/package*.json ./
RUN npm ci --omit=dev
COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY README.md CHANGELOG.md DEPLOYMENT.md /
COPY --from=gitinfo /commit.txt ./.build_commit
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
&& mkdir -p /var/lib/jump-host/keys && chmod 700 /var/lib/jump-host /var/lib/jump-host/keys
# 2222: SSH front door. 3002: web UI/API.
EXPOSE 2222 3002
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "require('http').get('http://localhost:3002/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["dumb-init", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "bin/www"]
Executable
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 theta42
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+119
View File
@@ -0,0 +1,119 @@
# Theta42 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://github.com/theta42/sso-manager-node)'s inventory
graph, audited end to end.
## Two ways to connect
**Direct (WinSCP/SFTP-friendly):**
```
ssh alice_-_web01@jump.example.com # -> host slug 'web01' / 'host_web01'
sftp -P 2222 alice_-_web01@jump.example.com # SFTP passes through unchanged
```
The username grammar is `{uid}_-_{target}`. `target` is a directory host slug
(with or without the `host_` prefix), a bare hostname, or an IP.
**Interactive picker:**
```
ssh alice@jump.example.com
```
Plain login shows a TUI list of the hosts you can reach; pick one and you're
bridged straight in.
## How it works
1. **Inbound auth** — LDAP. Public key (matched against your `sshPublicKey`, the
jump host's own injected key excluded) or password (LDAP bind; the
`ssh.passwordAuth` policy can restrict passwords to local clients or disable
them — keys-only is recommended for a public host).
2. **Authorization** — the hosts you may reach are the union of your LDAP groups
× the SSO directory (`/api/discovery/resources?group=<cn>`). No directory
entry, no access.
3. **Key injection** — on first use the jump host appends its own public key to
your `sshPublicKey` in LDAP (comment-marked), then connects downstream **as
you** using its private key. Downstream hosts already serve keys from LDAP
via [ldap-client](https://github.com/theta42/ldap-client)'s
`AuthorizedKeysCommand`, so nothing downstream needs changing.
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
downstream sshd. Every session is audited.
## Requirements
- The SSO Manager (OpenLDAP directory + `/api/discovery`).
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
user entries (see the ACL note in `secrets.js.example`).
- An SSO API token (`sso_…`) for the directory queries.
## Install
### Unified theta-env stack (recommended)
Enable it in `theta-env/setup.env` (`CFG_JUMP_HOST_ENABLED=true`) and re-run
`./setup.sh`. The stack wires the LDAP bind account, the write-ACL, the API
token, and a directory entry automatically.
### Standalone Docker
```
cp secrets.js.example config/jump-secrets.js # then edit it
docker compose up -d --build
```
### Bare metal
```
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
sudo $EDITOR /etc/jump-host/secrets.js # fill in LDAP + SSO
sudo systemctl restart jump-host
```
Installs to `/opt/theta42/jump-host`; idempotent (re-run to update).
## Ports
| Port | Purpose |
|------|---------|
| 2222 | SSH front door (default; see below for :22) |
| 3002 | Web UI + HTTP API (audit, metrics) |
The default SSH port is **2222** so the service needs no privilege. To listen on
22, set `ssh.listenPort: 22` in your secrets and either uncomment
`AmbientCapabilities=CAP_NET_BIND_SERVICE` in the systemd unit, or DNAT
22 → 2222 at the firewall.
## Web UI / API
`https://jump.example.com/` (behind the proxy) — admin login uses your LDAP
credentials and requires membership in `auth.adminGroups` (default
`app_sso_admin`).
- `GET /health` — open; `{status, activeSessions, version}`
- `GET /api/sessions` — active sessions
- `GET /api/audit?page=&uid=&target=&status=` — paged audit log
- `GET /api/metrics` — counters (total, failures, top users/hosts)
## Configuration
Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf):
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
See `secrets.js.example` for every key.
## Development
```
cd nodejs && npm install
npm test # unit + integration (node --test)
NODE_ENV=development npm run dev
```
## License
MIT
+26
View File
@@ -0,0 +1,26 @@
# Standalone jump-host deployment. For the unified stack, see theta42/theta-env
# (this same service, gated behind the `jump-host` compose profile).
#
# Supply config either by bind-mounting a secrets file at
# /config/jump-secrets.js (the entrypoint picks it up) or via app_* env vars.
services:
jump-host:
build:
context: .
dockerfile: Dockerfile
args:
GIT_COMMIT: ${JUMP_GIT_COMMIT:-}
container_name: jump-host
restart: unless-stopped
ports:
- "${JUMP_SSH_PORT:-2222}:2222" # SSH front door
- "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API
environment:
- NODE_ENV=production
volumes:
- ./config:/config:ro # optional: jump-secrets.js
- jump-data:/var/lib/jump-host # host keys persist across restarts
volumes:
jump-data:
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Start the theta42/jump-host all-in-one container: Redis (background) + the
# Node app (foreground, PID 2 under dumb-init so it gets SIGTERM).
set -e
info() { echo "[INFO] $*"; }
# When the unified theta-env stack (or any deployment) bind-mounts
# ./config/jump-secrets.js at /config, point CONF_SECRETS at it.
if [[ -f /config/jump-secrets.js ]]; then
export CONF_SECRETS=/config/jump-secrets.js
info "Loaded config from /config/jump-secrets.js"
fi
# Redis for audit/metrics/session storage (app connects to 127.0.0.1:6379).
info "Starting redis..."
redis-server --daemonize yes --save '' --appendonly no
# Wait for redis to answer before starting the app.
for _ in $(seq 1 20); do
if redis-cli ping >/dev/null 2>&1; then break; fi
sleep 0.2
done
export NODE_ENV="${NODE_ENV:-production}"
info "Starting jump-host (SSH :${JUMP_SSH_PORT:-2222}, web :3002)..."
exec "$@"
+4
View File
@@ -0,0 +1,4 @@
node_modules/
data/
conf/secrets.js
*.log
+37
View File
@@ -0,0 +1,37 @@
'use strict';
const path = require('path');
const express = require('express');
const conf = require('@simpleworkjs/conf');
const registry = require('./services/session_registry');
const { requireAdmin } = require('./middleware/auth');
const buildInfo = require('./models/build_info');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use('/public', express.static(path.join(__dirname, 'public')));
// Open health check — no auth (used by Docker/compose + the proxy).
app.get('/health', (req, res) => {
res.json({ status: 'ok', activeSessions: registry.count(), version: buildInfo.version, commit: buildInfo.commit });
});
// Login routes (no session required).
app.use('/', require('./routes/auth'));
// Everything else requires an admin session.
app.use(requireAdmin);
app.use('/api', require('./routes/api'));
app.use('/', require('./routes/index'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error(err);
if (req.path.startsWith('/api/')) return res.status(500).json({ error: err.message });
res.status(500).render('login', { error: 'Internal error.', name: conf.name });
});
module.exports = app;
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
'use strict';
// Boots BOTH faces of the jump host: the SSH front door (services/ssh_server)
// and the web UI/API (app.js). One process, one redis, shared audit store.
const http = require('http');
const conf = require('@simpleworkjs/conf');
require('../models'); // wire model-redis + register models
const app = require('../app');
const sshServer = require('../services/ssh_server');
// Web server
const webPort = (conf.web && conf.web.port) || 3002;
const server = http.createServer(app);
server.listen(webPort, () => {
console.log(`[web] jump-host UI/API on :${server.address().port}`);
});
// SSH server
sshServer.start();
function shutdown() {
console.log('[jump-host] shutting down');
server.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Base configuration. Deep-merged (by @simpleworkjs/conf) with
// conf/<NODE_ENV>.js, then the CONF_SECRETS file, then app_* env vars —
// later sources win. Everything here is a safe default; deployment-specific
// values (LDAP creds, SSO API token) belong in the secrets file.
module.exports = {
name: 'Jump Host',
// LDAP directory the users live in (same directory the SSO manages).
// bindDN needs: read on ou=people (users + sshPublicKey) and ou=groups,
// and WRITE on the sshPublicKey attribute (for upstream key injection —
// see utils/key_inject.js and the README's ACL section).
ldap: {
url: 'ldap://localhost:389',
bindDN: '__in secrets file__',
bindPassword: '__in secrets file__',
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
userNameAttribute: 'uid',
tlsOptions: { rejectUnauthorized: false },
},
// SSO Manager — the directory (inventory) API. apiToken is a personal
// access token (sso_<id>_<secret>) of a user that can read
// /api/discovery/* (any authenticated user can).
sso: {
url: 'http://localhost:3001',
apiToken: '__in secrets file__',
},
ssh: {
listenHost: '0.0.0.0',
listenPort: 2222,
// Directory the generated host keys live in (created on first boot).
hostKeyPath: '/var/lib/jump-host/keys',
banner: '',
// Password auth policy: 'off' (keys only), 'local' (passwords allowed
// only from loopback/RFC1918 client addresses — keys-only from the
// public internet), or 'all'. Default 'local'.
passwordAuth: 'local',
// Allow bridging to a raw IP that is NOT a directory host the user
// has access to. Off by default: the directory is the authority.
allowRawIPs: false,
connectTimeoutMs: 10000,
// 0 disables the idle timeout.
idleTimeoutMs: 0,
maxSessions: 100,
// Comment appended to the injected public key in LDAP. Also used to
// EXCLUDE that key from inbound auth (only the jump host may hold
// that private key). theta-env sets this to jump-host@<siteName>.
keyComment: 'jump-host@local',
// metadata key on directory hosts for a nonstandard sshd port.
defaultPort: 22,
},
web: {
port: 3002,
},
auth: {
// LDAP groups whose members may use the web UI/API.
adminGroups: ['app_sso_admin'],
// Web session lifetime (ms).
sessionTTLms: 12 * 60 * 60 * 1000,
},
redis: {
prefix: 'jump_host_',
redisConf: {},
},
audit: {
// Keep at most this many audit events (oldest trimmed).
maxEvents: 50000,
},
// Orchestrator-only keys (ignored by the app, read by theta-env).
stack: {},
};
+7
View File
@@ -0,0 +1,7 @@
'use strict';
module.exports = {
ssh: {
hostKeyPath: './data/keys',
},
};
+14
View File
@@ -0,0 +1,14 @@
'use strict';
module.exports = {
redis: {
prefix: 'jump_host_test_',
},
ssh: {
listenPort: 0, // ephemeral in tests
hostKeyPath: '/tmp/jump-host-test-keys',
},
web: {
port: 0,
},
};
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// Web UI/API auth: a signed-in admin session (cookie) whose LDAP groups
// intersect conf.auth.adminGroups. /health and the login routes are exempt
// (mounted before this middleware).
const conf = require('@simpleworkjs/conf');
const Session = require('../models/session');
function parseCookies(header) {
const out = {};
(header || '').split(';').forEach((p) => {
const i = p.indexOf('=');
if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
});
return out;
}
async function requireAdmin(req, res, next) {
const token = parseCookies(req.headers.cookie).jump_session;
const session = await Session.verify(token);
if (!session) {
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' });
return res.redirect('/login');
}
const groups = JSON.parse(session.groups || '[]');
const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g));
if (!admin) {
if (req.path.startsWith('/api/')) return res.status(403).json({ error: 'forbidden' });
return res.status(403).render('login', { error: 'Your account is not a jump-host admin.', name: conf.name });
}
req.jumpUser = { uid: session.uid, groups };
next();
}
module.exports = { requireAdmin, parseCookies };
+102
View File
@@ -0,0 +1,102 @@
'use strict';
// Audit trail of every connection attempt/session. Stored as one redis hash
// per event plus a sorted-set index (score = timestamp) for paged reads,
// trimmed to conf.audit.maxEvents. Raw redis (not the Table model) because we
// want the zset index and cheap range reads.
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const P = () => conf.redis.prefix;
const idxKey = () => `${P()}audit_index`;
const evtKey = (id) => `${P()}audit_${id}`;
// A live event: create() returns a handle you finish() when the session ends.
async function create(fields) {
const id = crypto.randomUUID();
const ts = Date.now();
const event = {
id, ts,
uid: '', authMethod: '', mode: '',
targetSlug: '', targetAddr: '', targetPort: '',
channel: '', clientIp: '',
success: false, failReason: '',
hostKeyFp: '', startedAt: ts, endedAt: '', durationMs: '',
bytesIn: 0, bytesOut: 0,
...fields,
};
await write(event);
return {
id,
event,
async patch(update) {
Object.assign(event, update);
await write(event);
},
async finish(update = {}) {
Object.assign(event, update, {
endedAt: Date.now(),
durationMs: Date.now() - event.startedAt,
});
await write(event);
},
};
}
async function write(event) {
const redis = await getRedis();
await redis.hSet(evtKey(event.id), serialize(event));
await redis.zAdd(idxKey(), { score: event.ts, value: event.id });
// Trim oldest beyond the cap.
const max = (conf.audit && conf.audit.maxEvents) || 50000;
const count = await redis.zCard(idxKey());
if (count > max) {
const stale = await redis.zRange(idxKey(), 0, count - max - 1);
if (stale.length) {
await redis.zRem(idxKey(), stale);
await redis.del(stale.map(evtKey));
}
}
}
function serialize(event) {
const out = {};
for (const [k, v] of Object.entries(event)) {
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
}
return out;
}
function deserialize(h) {
if (!h || !h.id) return null;
return {
...h,
ts: Number(h.ts),
success: h.success === '1',
bytesIn: Number(h.bytesIn || 0),
bytesOut: Number(h.bytesOut || 0),
durationMs: h.durationMs === '' ? null : Number(h.durationMs),
};
}
// Newest-first paged read with optional filters.
async function list({ page = 0, pageSize = 50, uid, target, status } = {}) {
const redis = await getRedis();
const ids = await redis.zRange(idxKey(), 0, -1, { REV: true });
const events = [];
for (const id of ids) {
const e = deserialize(await redis.hGetAll(evtKey(id)));
if (!e) continue;
if (uid && e.uid !== uid) continue;
if (target && e.targetSlug !== target && e.targetAddr !== target) continue;
if (status === 'success' && !e.success) continue;
if (status === 'fail' && e.success) continue;
events.push(e);
}
const start = page * pageSize;
return { total: events.length, page, pageSize, results: events.slice(start, start + pageSize) };
}
module.exports = { create, list };
+24
View File
@@ -0,0 +1,24 @@
'use strict';
// Short git commit, baked into /app/.build_commit at image build time (see
// Dockerfile gitinfo stage) or resolved from git on bare metal.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function resolve() {
try {
const baked = path.join(__dirname, '../../.build_commit');
if (fs.existsSync(baked)) return fs.readFileSync(baked, 'utf8').trim();
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
} catch (_) {}
return 'unknown';
}
let version = 'unknown';
try { version = require('../package.json').version; } catch (_) {}
module.exports = { commit: resolve(), version };
+35
View File
@@ -0,0 +1,35 @@
'use strict';
// model-redis backing (same store the other stack apps use). Table is the
// base class; getRedis() exposes the underlying node-redis client for the
// counters and sorted-set index in models/metrics.js and models/audit_event.js.
const conf = require('@simpleworkjs/conf');
const { setUpTable } = require('model-redis');
const Table = setUpTable(conf.redis);
module.exports = Table;
// The raw node-redis client (created + connecting inside model-redis) — used
// for the INCR counters and the sorted-set audit index. model-redis connects
// it asynchronously; ensure it's open before first use.
let readyPromise;
async function getRedis() {
const client = Table.redisClient;
if (!readyPromise) {
readyPromise = (async () => {
if (!client.isOpen) {
try { await client.connect(); } catch (_) { /* already connecting */ }
}
return client;
})();
}
await readyPromise;
return client;
}
module.exports.getRedis = getRedis;
require('./session');
require('./audit_event');
+40
View File
@@ -0,0 +1,40 @@
'use strict';
// Cheap counters for the dashboard. redis INCR — no history, just totals.
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const P = () => `${conf.redis.prefix}m_`;
async function bump({ uid, hostSlug, success }) {
const redis = await getRedis();
const day = new Date().toISOString().slice(0, 10);
const ops = [redis.incr(`${P()}total`), redis.incr(`${P()}day_${day}`)];
if (!success) ops.push(redis.incr(`${P()}fail`));
if (uid) ops.push(redis.incr(`${P()}user_${uid}`));
if (hostSlug) ops.push(redis.incr(`${P()}host_${hostSlug}`));
await Promise.all(ops);
}
async function summary() {
const redis = await getRedis();
const [total, fail] = await Promise.all([
redis.get(`${P()}total`),
redis.get(`${P()}fail`),
]);
const userKeys = await redis.keys(`${P()}user_*`);
const hostKeys = await redis.keys(`${P()}host_*`);
const topN = async (keys, strip) => {
const entries = await Promise.all(keys.map(async (k) => [k.slice(strip.length), Number(await redis.get(k))]));
return entries.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([name, count]) => ({ name, count }));
};
return {
total: Number(total || 0),
fail: Number(fail || 0),
topUsers: await topN(userKeys, `${P()}user_`),
topHosts: await topN(hostKeys, `${P()}host_`),
};
}
module.exports = { bump, summary };
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Web UI sessions — a signed-in admin's browser token. model-redis Table with
// a TTL so entries expire and survive restarts.
const crypto = require('crypto');
const Table = require('.');
class Session extends Table {
static _key = 'token';
static _keyMap = {
'token': {default: function(){ return crypto.randomUUID() }, type: 'string'},
'uid': {isRequired: true, type: 'string'},
'groups': {default: '[]', type: 'string'},
'created_on': {default: function(){ return (new Date).getTime() }},
'expires_at': {default: 0, type: 'number'},
}
}
Session.register();
Session.start = async function (uid, groups, ttlMs) {
return Session.create({
uid,
groups: JSON.stringify(groups || []),
expires_at: Date.now() + ttlMs,
}, { ttl: Math.ceil(ttlMs / 1000) });
};
Session.verify = async function (token) {
if (!token) return null;
let session;
try {
session = await Session.get(token);
} catch (_) {
return null;
}
if (!session || session.expires_at < Date.now()) return null;
return session;
};
module.exports = Session;
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// Thin LDAP helpers — the jump host's entire LDAP surface:
// getUser(uid) -> { dn, uid, sshPublicKeys: [] } or null
// getGroups(dn) -> [cn, ...] (groupOfNames membership)
// checkPassword(dn, pw) -> bool (simple bind as the user)
// addSshKey(dn, keyLine) -> void (idempotent multi-value add)
//
// Mirrors the patterns in sso-manager-node/nodejs/models/user_ldap.js and
// group_ldap.js (ldapts, admin-bound search, bind-as-user password check,
// TypeOrValueExists treated as success on key add).
const { Client, Change, Attribute } = require('ldapts');
const conf = require('@simpleworkjs/conf');
function ldapConf() {
return conf.ldap || {};
}
function makeClient() {
const c = ldapConf();
return new Client({
url: c.url,
tlsOptions: c.tlsOptions || { rejectUnauthorized: false },
});
}
// Escape a value being interpolated into an LDAP filter (RFC 4515).
function escapeFilter(value) {
return String(value).replace(/[\\*()\0]/g, (ch) => ({
'\\': '\\5c', '*': '\\2a', '(': '\\28', ')': '\\29', '\0': '\\00',
}[ch]));
}
async function withClient(fn) {
const c = ldapConf();
const client = makeClient();
try {
await client.bind(c.bindDN, c.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
async function getUser(uid) {
const c = ldapConf();
const attr = c.userNameAttribute || 'uid';
return withClient(async (client) => {
const { searchEntries } = await client.search(c.userBase, {
scope: 'sub',
filter: `(&(objectClass=posixAccount)(${attr}=${escapeFilter(uid)}))`,
attributes: ['dn', attr, 'cn', 'sshPublicKey'],
});
if (!searchEntries.length) return null;
const e = searchEntries[0];
let keys = e.sshPublicKey || [];
if (!Array.isArray(keys)) keys = [keys];
return {
dn: e.dn,
uid: String(e[attr]),
sshPublicKeys: keys.map(String),
};
});
}
async function getGroups(dn) {
const c = ldapConf();
return withClient(async (client) => {
const { searchEntries } = await client.search(c.groupBase, {
scope: 'sub',
filter: `(&(objectClass=groupOfNames)(member=${escapeFilter(dn)}))`,
attributes: ['cn'],
});
return searchEntries.map((e) => String(e.cn));
});
}
async function checkPassword(dn, password) {
if (!password) return false;
const client = makeClient();
try {
await client.bind(dn, password);
return true;
} catch (_) {
return false;
} finally {
await client.unbind().catch(() => {});
}
}
async function addSshKey(dn, keyLine) {
return withClient(async (client) => {
try {
await client.modify(dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'sshPublicKey', values: [keyLine] }),
}),
]);
} catch (error) {
// Same de-dup semantics as the SSO's User.addSSHkey.
if (error.name === 'TypeOrValueExistsError') return;
throw error;
}
});
}
module.exports = { getUser, getGroups, checkPassword, addSshKey, escapeFilter, makeClient };
+1607
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "t42-jump-host",
"version": "1.0.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
"name": "William Mantly",
"email": "wmantly@gmail.com"
}
],
"engines": {
"node": ">=18"
},
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "NODE_ENV=test node --test --test-force-exit 'test/**/*.test.js'",
"test:unit": "NODE_ENV=test node --test --test-force-exit 'test/unit/**/*.test.js'",
"test:integration": "NODE_ENV=test node --test --test-force-exit 'test/integration/**/*.test.js'"
},
"dependencies": {
"@simpleworkjs/conf": "^1.2.0",
"ejs": "^3.1.10",
"express": "^5.2.1",
"ldapts": "^8.1.2",
"model-redis": "^1.6.0",
"redis": "^4.7.0",
"ssh2": "^1.16.0"
},
"devDependencies": {
"nodemon": "^3.1.11"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/theta42/jump-host.git"
}
}
+31
View File
@@ -0,0 +1,31 @@
:root { --bg:#0f1115; --panel:#181b22; --line:#272b34; --fg:#e6e8ec; --mut:#8b93a1; --acc:#4f9cf9; --bad:#ff6b6b; --ok:#4ec9a5; }
* { box-sizing: border-box; }
body { margin:0; font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
a { color:var(--acc); text-decoration:none; } a:hover { text-decoration:underline; }
.nav { display:flex; align-items:center; gap:16px; padding:12px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
.brand { font-weight:600; } .brand small { color:var(--mut); font-weight:400; }
.nav .spacer { flex:1; } .nav .who { color:var(--mut); }
.wrap { max-width:1100px; margin:0 auto; padding:24px 20px; }
h1 { font-size:20px; margin:0 0 16px; } h2 { font-size:15px; margin:24px 0 8px; }
.tiles { display:flex; gap:16px; flex-wrap:wrap; }
.tile { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px 20px; min-width:150px; }
.tile .n { display:block; font-size:28px; font-weight:600; } .tile .l { color:var(--mut); }
.cols { display:grid; grid-template-columns:2fr 1fr; gap:24px; }
@media (max-width:800px){ .cols { grid-template-columns:1fr; } }
table { width:100%; border-collapse:collapse; margin-top:8px; }
th,td { text-align:left; padding:7px 10px; border-bottom:1px solid var(--line); }
th { color:var(--mut); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.03em; }
td.r,th.r { text-align:right; }
tr.bad td { color:var(--bad); }
.muted { color:var(--mut); }
.more { font-size:12px; font-weight:400; margin-left:8px; }
.foot { max-width:1100px; margin:0 auto; padding:16px 20px; color:var(--mut); font-size:12px; }
.filters { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.filters input,.filters select,.login input { background:#0c0e12; border:1px solid var(--line); color:var(--fg); border-radius:7px; padding:7px 10px; }
button { background:var(--acc); color:#fff; border:0; border-radius:7px; padding:8px 14px; cursor:pointer; font:inherit; }
button.link { background:none; color:var(--acc); padding:0; }
.inline { display:inline; } .pager { display:flex; gap:16px; align-items:center; margin-top:16px; color:var(--mut); }
.center { display:grid; place-items:center; min-height:100vh; }
.card.login { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:28px; width:320px; display:flex; flex-direction:column; gap:12px; }
.card.login h1 { margin:0 0 8px; } .card.login label { display:flex; flex-direction:column; gap:4px; font-size:13px; color:var(--mut); }
.card.login .hint { color:var(--mut); font-size:12px; margin:4px 0 0; } .err { color:var(--bad); margin:0; }
+36
View File
@@ -0,0 +1,36 @@
'use strict';
// Auditing + metrics API (admin-gated by middleware/auth in app.js).
const express = require('express');
const audit = require('../models/audit_event');
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
const router = express.Router();
router.get('/sessions', (req, res) => {
res.json({ results: registry.list(), active: registry.count() });
});
router.get('/audit', async (req, res, next) => {
try {
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
const data = await audit.list({
page,
pageSize: Math.min(200, parseInt(req.query.pageSize, 10) || 50),
uid: req.query.uid || undefined,
target: req.query.target || undefined,
status: req.query.status || undefined,
});
res.json(data);
} catch (err) { next(err); }
});
router.get('/metrics', async (req, res, next) => {
try {
res.json({ ...(await metrics.summary()), active: registry.count() });
} catch (err) { next(err); }
});
module.exports = router;
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Web login: LDAP bind as the user, require an adminGroups membership, mint a
// session cookie. (OIDC against the SSO is a follow-up.)
const express = require('express');
const conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const Session = require('../models/session');
const router = express.Router();
router.get('/login', (req, res) => {
res.render('login', { error: null, name: conf.name });
});
router.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
const { uid, password } = req.body || {};
const fail = (msg) => res.status(401).render('login', { error: msg, name: conf.name });
try {
const user = await userLdap.getUser(uid);
if (!user) return fail('Invalid credentials.');
const ok = await userLdap.checkPassword(user.dn, password);
if (!ok) return fail('Invalid credentials.');
const groups = await userLdap.getGroups(user.dn);
const admin = (conf.auth.adminGroups || []).some((g) => groups.includes(g));
if (!admin) return fail('Your account is not a jump-host admin.');
const session = await Session.start(user.uid, groups, conf.auth.sessionTTLms);
res.setHeader('Set-Cookie', `jump_session=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${Math.floor(conf.auth.sessionTTLms / 1000)}`);
res.redirect('/');
} catch (err) {
return fail('Login failed.');
}
});
router.post('/logout', (req, res) => {
res.setHeader('Set-Cookie', 'jump_session=; HttpOnly; Path=/; Max-Age=0');
res.redirect('/login');
});
module.exports = router;
+39
View File
@@ -0,0 +1,39 @@
'use strict';
const express = require('express');
const audit = require('../models/audit_event');
const metrics = require('../models/metrics');
const registry = require('../services/session_registry');
const buildInfo = require('../models/build_info');
const conf = require('@simpleworkjs/conf');
const router = express.Router();
router.get('/', async (req, res, next) => {
try {
const [m, recent] = await Promise.all([
metrics.summary(),
audit.list({ page: 0, pageSize: 10 }),
]);
res.render('dashboard', {
name: conf.name, buildInfo, user: req.jumpUser,
metrics: { ...m, active: registry.count() },
active: registry.list(),
recent: recent.results,
});
} catch (err) { next(err); }
});
router.get('/sessions', (req, res) => {
res.render('sessions', { name: conf.name, buildInfo, user: req.jumpUser, active: registry.list() });
});
router.get('/audit', async (req, res, next) => {
try {
const page = Math.max(0, parseInt(req.query.page, 10) || 0);
const data = await audit.list({ page, pageSize: 50, uid: req.query.uid, target: req.query.target, status: req.query.status });
res.render('audit', { name: conf.name, buildInfo, user: req.jumpUser, data, query: req.query });
} catch (err) { next(err); }
});
module.exports = router;
+150
View File
@@ -0,0 +1,150 @@
'use strict';
// Bridge one authenticated inbound SSH session to a downstream host: open an
// ssh2.Client to the target as the real user (jump host's own private key —
// already injected into the user's sshPublicKey), then splice each inbound
// channel (shell / exec / sftp subsystem) to a matching upstream channel.
const { Client } = require('ssh2');
const crypto = require('crypto');
const { Transform } = require('stream');
const conf = require('@simpleworkjs/conf');
const registry = require('./session_registry');
const metrics = require('../models/metrics');
const { clearInjectedFlag } = require('../utils/key_inject');
// A pass-through that tallies bytes (cheap; one per direction per channel).
function counter(onBytes) {
return new Transform({
transform(chunk, _enc, cb) { onBytes(chunk.length); cb(null, chunk); },
});
}
// 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-time key injection).
function connectUpstream({ host, port, username, privateKey, onHostKey, uid, justInjected }) {
return new Promise((resolve, reject) => {
let attempted = false;
const dial = (allowRetry) => {
const client = new Client();
client
.on('ready', () => resolve(client))
.on('error', async (err) => {
const authish = /authentication|All configured authentication methods failed/i.test(err.message || '');
if (authish && allowRetry) {
attempted = true;
await clearInjectedFlag(uid).catch(() => {});
setTimeout(() => dial(false), 2000);
return;
}
reject(err);
})
.connect({
host, port, username, privateKey,
readyTimeout: (conf.ssh && conf.ssh.connectTimeoutMs) || 10000,
keepaliveInterval: 15000,
hostVerifier: (key) => {
const fp = 'SHA256:' + crypto.createHash('sha256').update(key).digest('base64').replace(/=+$/, '');
if (onHostKey) onHostKey(fp);
return true; // v1: trust-on-use, fingerprint audited. Pinning = follow-up.
},
});
};
dial(justInjected); // only bother retrying if we just wrote the key
});
}
// Wire an inbound session (the ssh2 Server 'session' accept() result) to the
// upstream client. Session handlers are attached SYNCHRONOUSLY (call this the
// moment the session is accepted) so channel requests the client sends before
// the upstream connection is ready aren't auto-rejected: pty/env/window-change
// are buffered, and shell/exec/subsystem accept the inbound channel then wait
// on `upstreamPromise` before opening the matching upstream channel.
//
// upstreamPromise resolves to the ready ssh2.Client, or rejects (target
// unreachable) — in which case pending channels get a friendly message.
function attachSession(session, upstreamPromise, audit) {
let ptyInfo = null;
const env = {};
let bytesIn = 0, bytesOut = 0;
let upstreamStream = null;
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('env', (accept, _reject, info) => { env[info.key] = info.val; accept && accept(); });
session.on('window-change', (accept, _reject, info) => {
if (upstreamStream) upstreamStream.setWindow(info.rows, info.cols, info.height, info.width);
accept && accept();
});
const pipeStreams = (inbound, up, channel) => {
audit.patch({ channel });
upstreamStream = up;
up.pipe(counter((n) => { bytesOut += n; })).pipe(inbound);
inbound.pipe(counter((n) => { bytesIn += n; })).pipe(up);
up.on('exit', (code, signal) => {
if (!signal && inbound.exit) inbound.exit(code == null ? 0 : code);
});
up.on('close', () => { audit.event.bytesIn = bytesIn; audit.event.bytesOut = bytesOut; inbound.close && inbound.close(); });
inbound.on('close', () => { up.end && up.end(); });
};
const withUpstream = (inbound, open) => {
upstreamPromise.then((up) => open(up)).catch((err) => {
try { inbound.stderr && inbound.stderr.write(`jump-host: ${err.message}\r\n`); } catch (_) {}
try { inbound.exit && inbound.exit(1); inbound.close(); } catch (_) {}
});
};
session.on('shell', (accept) => {
const inbound = accept();
withUpstream(inbound, (upstream) => {
upstream.shell(ptyInfo || false, { env }, (err, up) => {
if (err) { try { inbound.stderr.write(`jump-host: upstream shell failed: ${err.message}\r\n`); inbound.exit(1); inbound.close(); } catch (_) {} return; }
pipeStreams(inbound, up, 'shell');
});
});
});
session.on('exec', (accept, _reject, info) => {
const inbound = accept();
withUpstream(inbound, (upstream) => {
upstream.exec(info.command, { pty: ptyInfo || undefined, env }, (err, up) => {
if (err) { try { inbound.stderr.write(`jump-host: upstream exec failed: ${err.message}\r\n`); inbound.exit(1); inbound.close(); } catch (_) {} return; }
pipeStreams(inbound, up, 'exec');
});
});
});
session.on('subsystem', (accept, reject, info) => {
if (info.name !== 'sftp') return reject && reject();
const inbound = accept();
withUpstream(inbound, (upstream) => {
upstream.subsys('sftp', (err, up) => {
if (err) { try { inbound.close(); } catch (_) {} return; }
pipeStreams(inbound, up, 'sftp');
});
});
});
}
// TUI mode: the picker already opened one inbound shell channel. Bridge THAT
// channel directly to an upstream shell (no waiting for further channel
// requests). window-change from the client is forwarded via the session.
function bridgeShellChannel(inbound, upstream, ptyInfo, audit) {
return new Promise((resolve, reject) => {
upstream.shell(ptyInfo || false, {}, (err, up) => {
if (err) return reject(err);
audit.patch({ channel: 'shell' });
let bytesIn = 0, bytesOut = 0;
up.pipe(counter((n) => { bytesOut += n; })).pipe(inbound);
inbound.pipe(counter((n) => { bytesIn += n; })).pipe(up);
up.on('exit', (code) => { try { inbound.exit(code == null ? 0 : code); } catch (_) {} });
up.on('close', () => { audit.event.bytesIn = bytesIn; audit.event.bytesOut = bytesOut; try { inbound.close(); } catch (_) {} });
inbound.on('close', () => { try { up.end(); } catch (_) {} });
resolve({ upstreamStream: up, counters: () => ({ bytesIn, bytesOut }) });
});
});
}
module.exports = { connectUpstream, attachSession, bridgeShellChannel, counter, registry, metrics };
+24
View File
@@ -0,0 +1,24 @@
'use strict';
// In-memory registry of live SSH sessions — feeds GET /api/sessions and the
// maxSessions cap. Ephemeral by design (a restart drops every bridge anyway).
const sessions = new Map(); // id -> descriptor
function add(id, desc) {
sessions.set(id, { id, startedAt: Date.now(), ...desc });
}
function remove(id) {
sessions.delete(id);
}
function list() {
return [...sessions.values()];
}
function count() {
return sessions.size;
}
module.exports = { add, remove, list, count };
+291
View File
@@ -0,0 +1,291 @@
'use strict';
// The public SSH front door. Authenticates the inbound user against LDAP,
// parses the username grammar, resolves the target from the directory (or runs
// the TUI picker), injects the jump host's key for the user, bridges to the
// downstream host, and audits everything.
const { Server, utils: { parseKey } } = require('ssh2');
const conf = require('@simpleworkjs/conf');
const { ensureKeys } = require('../utils/host_keys');
const { parseUsername } = require('../utils/username_grammar');
const { matchTarget, hostEndpoint } = require('../utils/target_match');
const { accessibleHosts } = require('../utils/access');
const { ensureKeyInjected } = require('../utils/key_inject');
const userLdap = require('../models/user_ldap');
const audit = require('../models/audit_event');
const metrics = require('../models/metrics');
const registry = require('./session_registry');
const { pickHost } = require('./tui_picker');
const { connectUpstream, attachSession, bridgeShellChannel } = require('./bridge');
let JUMP_KEYS; // { hostKeys, clientKey, publicLine }
// Is a client address "local" (loopback or RFC1918)? Governs passwordAuth:'local'.
function isLocalAddr(ip) {
if (!ip) return false;
const a = ip.replace(/^::ffff:/, '');
return a === '127.0.0.1' || a === '::1'
|| /^10\./.test(a) || /^192\.168\./.test(a)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(a);
}
function passwordAllowed(clientIp) {
const mode = (conf.ssh && conf.ssh.passwordAuth) || 'local';
if (mode === 'all') return true;
if (mode === 'off') return false;
return isLocalAddr(clientIp); // 'local'
}
// Compare an inbound publickey to the user's LDAP keys, EXCLUDING the jump
// host's own injected key (only the jump host may hold that private half).
function userKeyMatches(user, ctxKey) {
const marker = conf.ssh.keyComment;
for (const line of user.sshPublicKeys || []) {
if (marker && line.trim().endsWith(marker)) continue;
const parsed = parseKey(line);
if (parsed instanceof Error) continue;
const key = Array.isArray(parsed) ? parsed[0] : parsed;
if (key.type === ctxKey.algo && key.getPublicSSH().equals(ctxKey.data)) return key;
}
return null;
}
function handleAuth(ctx, state) {
(async () => {
let parsed;
try {
parsed = parseUsername(ctx.username);
} catch (_) {
return ctx.reject(['publickey', 'password']);
}
state.uid = parsed.uid;
state.target = parsed.target;
const user = await userLdap.getUser(parsed.uid).catch(() => null);
if (!user) return ctx.reject(['publickey', 'password']);
state.user = user;
if (ctx.method === 'publickey') {
const key = userKeyMatches(user, ctx.key);
if (!key) return ctx.reject(['publickey', 'password']);
// Two-phase: probe (no signature) then verify.
if (ctx.signature) {
const ok = key.verify(ctx.blob, ctx.signature, ctx.hashAlgo);
if (ok !== true) return ctx.reject();
}
state.authMethod = 'publickey';
return ctx.accept();
}
if (ctx.method === 'password') {
if (!passwordAllowed(state.clientIp)) return ctx.reject(['publickey']);
const ok = await userLdap.checkPassword(user.dn, ctx.password);
if (!ok) return ctx.reject(['publickey', 'password']);
state.authMethod = 'password';
return ctx.accept();
}
return ctx.reject(['publickey', 'password']);
})().catch(() => ctx.reject());
}
// After auth: resolve target (grammar or TUI), inject key, bridge.
async function onReady(client, state) {
if (registry.count() >= ((conf.ssh && conf.ssh.maxSessions) || 100)) {
client.end();
return;
}
client.once('session', (accept) => {
const session = accept();
runSession(session, client, state).catch(() => {
try { client.end(); } catch (_) {}
});
});
}
async function runSession(session, client, state) {
// Grammar mode: the client opens its own channels (shell/exec/sftp) right
// after the session — attach the buffering bridge SYNCHRONOUSLY so no
// channel request is dropped while we resolve+connect asynchronously.
if (state.target) return runGrammar(session, client, state);
return runTuiSession(session, client, state);
}
// Shared: resolve target -> inject key -> connect upstream. Returns
// { upstream, host, endpoint, record } or throws { reason }.
async function resolveAndConnect(state, record, { onHostKey } = {}) {
const hosts = await accessibleHosts(state.user).catch(() => { throw fail('directory-unreachable'); });
let host = null, raw = null;
const m = matchTarget(state.target, hosts, { allowRawIPs: conf.ssh.allowRawIPs });
host = m.host; raw = m.raw;
const endpoint = host ? hostEndpoint(host, conf.ssh.defaultPort) : { address: raw, port: conf.ssh.defaultPort };
if (!endpoint.address) throw fail('no-address');
await record.patch({ targetSlug: host ? host.slug : 'raw-ip', targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { throw fail('key-inject-failed'); }
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey,
});
} catch (_) { throw fail('upstream-unreachable'); }
return { upstream, host, endpoint };
}
function fail(reason) { const e = new Error(reason); e.reason = reason; return e; }
async function runGrammar(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' });
// Deferred upstream — attach the bridge NOW, resolve/reject after connect.
let resolveUp, rejectUp;
const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; });
attachSession(session, upstreamPromise, record);
try {
const { upstream, host, endpoint } = await resolveAndConnect(state, record, {
onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
});
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: host ? host.slug : 'raw-ip' });
await record.patch({ success: true });
await metrics.bump({ uid: state.uid, hostSlug: host ? host.slug : undefined, success: true });
resolveUp(upstream);
wireTeardown(session, client, upstream, record);
} catch (err) {
const reason = err.reason || 'error';
rejectUp(new Error(reasonMessage(reason)));
await record.finish({ success: false, failReason: reason });
await metrics.bump({ uid: state.uid, success: false });
}
}
async function runTuiSession(session, client, state) {
const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'tui' });
const finishFail = async (reason) => {
await record.finish({ success: false, failReason: reason });
await metrics.bump({ uid: state.uid, success: false });
try { client.end(); } catch (_) {}
};
let hosts;
try { hosts = await accessibleHosts(state.user); }
catch (_) { return finishFail('directory-unreachable'); }
const tui = await runTui(session, state.uid, hosts);
if (!tui.host) return finishFail('cancelled');
state.target = tui.host.slug;
const endpoint = hostEndpoint(tui.host, conf.ssh.defaultPort);
await record.patch({ targetSlug: tui.host.slug, targetAddr: endpoint.address, targetPort: endpoint.port });
let justInjected = false;
try { justInjected = await ensureKeyInjected(state.user, JUMP_KEYS.publicLine); }
catch (_) { return finishFail('key-inject-failed'); }
let upstream;
try {
upstream = await connectUpstream({
host: endpoint.address, port: endpoint.port,
username: state.uid, privateKey: JUMP_KEYS.clientKey,
uid: state.uid, justInjected, onHostKey: (fp) => record.patch({ hostKeyFp: fp }),
});
} catch (_) {
try { tui.channel.write(`\r\n Could not reach ${endpoint.address}.\r\n`); tui.channel.close(); } catch (_) {}
return finishFail('upstream-unreachable');
}
registry.add(record.id, { uid: state.uid, target: endpoint.address, slug: tui.host.slug });
await record.patch({ success: true });
await metrics.bump({ uid: state.uid, hostSlug: tui.host.slug, success: true });
let upstreamStream;
session.on('window-change', (accept, _reject, info) => {
if (upstreamStream) upstreamStream.setWindow(info.rows, info.cols, info.height, info.width);
accept && accept();
});
try {
const r = await bridgeShellChannel(tui.channel, upstream, tui.ptyInfo, record);
upstreamStream = r.upstreamStream;
} catch (err) {
try { tui.channel.write(`\r\n Upstream shell failed: ${err.message}\r\n`); tui.channel.close(); } catch (_) {}
}
wireTeardown(session, client, upstream, record);
}
function wireTeardown(session, client, upstream, record) {
upstream.on('close', async () => {
registry.remove(record.id);
await record.finish({ success: true });
});
client.on('close', () => { try { upstream.end(); } catch (_) {} });
}
function reasonMessage(reason) {
return {
'no-such-target': 'no host you can access matches that target',
'no-access': 'you do not have access to that host',
'directory-unreachable': 'directory service unavailable',
'upstream-unreachable': 'could not reach the target host',
'key-inject-failed': 'could not provision your access key',
}[reason] || reason;
}
// Run the TUI picker over a shell channel; returns { host, channel, ptyInfo }.
// host is null if the user quit. exec/subsystem in picker mode are rejected.
function runTui(session, uid, hosts) {
return new Promise((resolve) => {
let ptyInfo = null;
let settled = false;
const finish = (v) => { if (!settled) { settled = true; resolve(v); } };
session.on('pty', (accept, _reject, info) => { ptyInfo = info; accept && accept(); });
session.on('shell', (accept) => {
const channel = accept();
pickHost(channel, uid, hosts).then((host) => {
if (!host) { try { channel.write('\r\n Bye.\r\n'); channel.close(); } catch (_) {} }
finish({ host, channel, ptyInfo });
});
});
session.on('exec', (accept) => {
const c = accept();
try { c.stderr.write('jump-host: interactive login required to pick a host (or use uid_-_target)\r\n'); c.exit(1); c.close(); } catch (_) {}
finish({ host: null });
});
session.on('subsystem', (accept, reject) => { reject && reject(); finish({ host: null }); });
});
}
function start() {
JUMP_KEYS = ensureKeys();
const server = new Server(
{ hostKeys: JUMP_KEYS.hostKeys, banner: (conf.ssh && conf.ssh.banner) || undefined },
(client, info) => {
const state = { clientIp: (info && info.ip) || null };
client.on('authentication', (ctx) => handleAuth(ctx, state));
client.on('ready', () => onReady(client, state));
client.on('error', () => {});
}
);
const port = (conf.ssh && conf.ssh.listenPort) || 2222;
const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0';
server.listen(port, host, () => {
console.log(`[ssh] jump host listening on ${host}:${server.address().port}`);
});
return server;
}
module.exports = { start, _internal: { isLocalAddr, passwordAllowed, userKeyMatches } };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
// Hand-rolled ANSI host picker rendered over an inbound SSH shell channel.
// (blessed/inquirer/ink want a real TTY object; an ssh2 server channel isn't
// one, so we parse raw keystrokes ourselves.) Resolves to the chosen host
// resource, or null if the user quits.
const ESC = '\x1b';
const CLEAR = `${ESC}[2J${ESC}[H`;
const HIDE_CUR = `${ESC}[?25l`;
const SHOW_CUR = `${ESC}[?25h`;
const INV = `${ESC}[7m`;
const RST = `${ESC}[0m`;
const DIM = `${ESC}[2m`;
const BOLD = `${ESC}[1m`;
function pickHost(channel, uid, hosts) {
return new Promise((resolve) => {
if (!hosts.length) {
channel.write(`\r\n No hosts available for ${uid}.\r\n (You have no directory access to any SSH host.)\r\n\r\n`);
setTimeout(() => resolve(null), 50);
return;
}
let filter = '';
let selected = 0;
const visible = () => hosts.filter((h) => {
if (!filter) return true;
const hay = `${h.name} ${h.slug} ${(h.metadata && h.metadata.ip) || ''}`.toLowerCase();
return hay.includes(filter.toLowerCase());
});
const render = () => {
const list = visible();
if (selected >= list.length) selected = Math.max(0, list.length - 1);
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`;
if (!list.length) {
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
} else {
list.forEach((h, i) => {
const ip = (h.metadata && h.metadata.ip) || (h.metadata && h.metadata.address) || '';
const row = ` ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${ip}` : ''}`;
out += (i === selected ? `${INV}> ${h.name} (${h.slug})${ip ? ` ${ip}` : ''}${RST}` : row) + '\r\n';
});
}
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
channel.write(out);
};
const done = (host) => {
channel.removeListener('data', onData);
channel.write(SHOW_CUR);
resolve(host);
};
const onData = (buf) => {
const s = buf.toString('utf8');
const list = visible();
if (s === '\x03' || s === 'q') return done(null); // Ctrl-C / q
if (s === '\x0c') return render(); // Ctrl-L
if (s === `${ESC}[A`) { selected = Math.max(0, selected - 1); return render(); }
if (s === `${ESC}[B`) { selected = Math.min(list.length - 1, selected + 1); return render(); }
if (s === '\r' || s === '\n') { if (list[selected]) return done(list[selected]); return; }
if (s === '\x7f' || s === '\b') { filter = filter.slice(0, -1); selected = 0; return render(); }
if (/^[0-9]$/.test(s)) { const i = Number(s) - 1; if (list[i]) return done(list[i]); return; }
if (s.length === 1 && s >= ' ') { filter += s; selected = 0; return render(); }
};
channel.on('data', onData);
render();
});
}
module.exports = { pickHost };
+176
View File
@@ -0,0 +1,176 @@
'use strict';
// End-to-end bridge test with NO external services: a tiny in-process ssh2
// "downstream" server (echo shell + exec + sftp-subsystem byte echo) and the
// jump host's own bridge, driven by an ssh2 client as `test_-_stub`.
//
// The jump host's LDAP/directory/redis dependencies are stubbed via injected
// modules so the test needs only ssh2 + generated keys.
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const { Server, Client, utils } = require('ssh2');
const { connectUpstream, attachSession } = require('../../services/bridge');
let downstream, downstreamPort, jump, jumpPort, jumpKey;
// --- A minimal downstream sshd: accepts any key, echoes shell/exec, and
// echoes bytes on the sftp subsystem (enough to prove pass-through). ---
function startDownstream() {
return new Promise((resolve) => {
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
client.on('authentication', (ctx) => ctx.accept());
client.on('ready', () => {
client.on('session', (accept) => {
const session = accept();
session.on('pty', (a) => a && a());
session.on('shell', (a) => {
const ch = a();
ch.write('downstream-shell-ready\n');
ch.on('data', (d) => ch.write('echo:' + d)); // echo back
});
session.on('exec', (a, r, info) => {
const ch = a();
ch.write(`ran:${info.command}`);
ch.exit(0);
ch.end();
});
session.on('subsystem', (a, r, info) => {
if (info.name !== 'sftp') return r && r();
const ch = a();
ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d])));
});
});
});
});
srv.listen(0, '127.0.0.1', () => resolve(srv));
});
}
// --- The jump host, wired to bridge every session straight to the downstream
// (target resolution stubbed to the downstream endpoint). ---
function startJump() {
return new Promise((resolve) => {
const { private: hostKey } = utils.generateKeyPairSync('ed25519');
const gen = utils.generateKeyPairSync('ed25519');
jumpKey = gen.private;
const clientAuthKey = utils.parseKey(gen.private); // user authenticates with the SAME key for the test
const srv = new Server({ hostKeys: [hostKey] }, (client) => {
client.on('authentication', (ctx) => {
if (ctx.method === 'publickey') {
const k = clientAuthKey;
if (ctx.key.algo === k.type && k.getPublicSSH().equals(ctx.key.data)) {
if (ctx.signature) {
return k.verify(ctx.blob, ctx.signature, ctx.hashAlgo) === true ? ctx.accept() : ctx.reject();
}
return ctx.accept();
}
}
return ctx.reject(['publickey']);
});
client.on('ready', () => {
client.once('session', (accept) => {
const session = accept();
const audit = { patch() {}, finish() {}, event: {} };
// Attach synchronously with a deferred upstream — exactly how
// runGrammar wires it — so pre-connect channel requests buffer.
const upstreamPromise = connectUpstream({
host: '127.0.0.1', port: downstreamPort,
username: 'test', privateKey: jumpKey, uid: 'test', justInjected: false,
});
attachSession(session, upstreamPromise, audit);
upstreamPromise.catch(() => client.end());
});
});
});
srv.listen(0, '127.0.0.1', () => resolve({ srv, key: gen.private }));
});
}
before(async () => {
downstream = await startDownstream();
downstreamPort = downstream.address().port;
const j = await startJump();
jump = j.srv;
jumpPort = jump.address().port;
});
after(() => {
downstream && downstream.close();
jump && jump.close();
// bridge.js pulls in model-redis (via key_inject/metrics), which eagerly
// opens a redis client. This test never touches redis (audit is stubbed),
// so drop the connection so the process can exit.
try { require('../../models').redisClient.destroy(); } catch (_) {}
});
// The eager model-redis connect has no server in this hermetic test; ignore it.
process.on('unhandledRejection', () => {});
function connectJump() {
const conn = new Client();
return { conn, ready: new Promise((res, rej) => {
conn.on('ready', res).on('error', rej).connect({
host: '127.0.0.1', port: jumpPort, username: 'test_-_stub',
privateKey: jumpKey, // same key stubbed as the user's inbound key
});
}) };
}
test('exec bridges through to the downstream', async () => {
const { conn, ready } = connectJump();
await ready;
const out = await new Promise((resolve, reject) => {
conn.exec('hello-world', (err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf));
});
});
conn.end();
assert.match(out, /ran:hello-world/);
});
test('shell bridges and echoes', async () => {
const { conn, ready } = connectJump();
await ready;
const out = await new Promise((resolve, reject) => {
conn.shell((err, stream) => {
if (err) return reject(err);
let buf = '';
stream.on('data', (d) => {
buf += d;
if (buf.includes('echo:ping')) { resolve(buf); }
});
setTimeout(() => stream.write('ping'), 100);
setTimeout(() => resolve(buf), 1500);
});
});
conn.end();
assert.match(out, /downstream-shell-ready/);
assert.match(out, /echo:ping/);
});
test('sftp subsystem bytes pass through', async () => {
const { conn, ready } = connectJump();
await ready;
const got = await new Promise((resolve, reject) => {
conn.subsys('sftp', (err, stream) => {
if (err) return reject(err);
let buf = Buffer.alloc(0);
stream.on('data', (d) => {
buf = Buffer.concat([buf, d]);
if (buf.includes('sftp:')) resolve(buf.toString());
});
stream.write(Buffer.from('PKT'));
setTimeout(() => resolve(buf.toString()), 1500);
});
});
conn.end();
assert.match(got, /sftp:PKT/);
});
+55
View File
@@ -0,0 +1,55 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const { accessibleHosts, clearCache } = require('../../utils/access');
function stubLdap(groups) {
return { getGroups: async () => groups };
}
function stubFetch(byGroup) {
return async (url) => {
const cn = decodeURIComponent(url.split('group=')[1]);
return { ok: true, json: async () => ({ results: byGroup[cn] || [] }) };
};
}
test('unions hosts across groups, dedupes, drops non-hosts', async () => {
clearCache();
const user = { uid: 'alice', dn: 'uid=alice,ou=people,dc=x' };
const fetchImpl = stubFetch({
host_web01_access: [
{ id: '1', kind: 'host', slug: 'host_web01' },
{ 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']) });
assert.deepStrictEqual(hosts.map((h) => h.id).sort(), ['1', '2']);
});
test('a failing group query does not sink the rest', async () => {
clearCache();
const user = { uid: 'bob', dn: 'uid=bob,ou=people,dc=x' };
const fetchImpl = async (url) => {
if (url.includes('bad')) return { ok: false, status: 500 };
return { ok: true, json: async () => ({ results: [{ id: '3', kind: 'host', slug: 'host_ok' }] }) };
};
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 () => {
clearCache();
let calls = 0;
const user = { uid: 'cara', dn: 'd' };
const fetchImpl = async () => { calls++; return { ok: true, json: async () => ({ results: [] }) }; };
const ldap = { getGroups: async () => ['g1'] };
await accessibleHosts(user, { fetchImpl, ldap });
await accessibleHosts(user, { fetchImpl, ldap });
assert.strictEqual(calls, 1);
});
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ensureKeys, pubLine, generatePair } = require('../../utils/host_keys');
process.env.NODE_ENV = 'test';
test('generates a parseable ed25519 public line', () => {
const pem = generatePair('ed25519');
const line = pubLine(pem, 'jump-host@test');
assert.match(line, /^ssh-ed25519 [A-Za-z0-9+/=]+ jump-host@test$/);
});
test('ensureKeys writes and reloads a stable keypair', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jh-keys-'));
const conf = require('@simpleworkjs/conf');
conf.ssh = { ...conf.ssh, hostKeyPath: dir, keyComment: 'jump-host@test' };
const first = ensureKeys(dir);
assert.strictEqual(first.hostKeys.length, 2);
assert.match(first.publicLine, /jump-host@test$/);
const second = ensureKeys(dir);
assert.strictEqual(second.publicLine, first.publicLine); // stable, not regenerated
assert.ok(fs.existsSync(path.join(dir, 'id_ed25519')));
assert.ok(fs.existsSync(path.join(dir, 'id_rsa')));
});
+49
View File
@@ -0,0 +1,49 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const { matchTarget, hostEndpoint } = require('../../utils/target_match');
const hosts = [
{ id: '1', slug: 'host_web01', name: 'Web 01', metadata: { ip: '10.0.0.10', sshPort: 2200 } },
{ id: '2', slug: 'host_db', name: 'Database', metadata: { address: 'ssh://db.internal:22' } },
];
test('exact slug', () => {
assert.strictEqual(matchTarget('host_web01', hosts).host.id, '1');
});
test('host_-prefixed shorthand', () => {
assert.strictEqual(matchTarget('web01', hosts).host.id, '1');
});
test('by display name (case-insensitive)', () => {
assert.strictEqual(matchTarget('database', hosts).host.id, '2');
});
test('by ip', () => {
assert.strictEqual(matchTarget('10.0.0.10', hosts).host.id, '1');
});
test('by address hostname', () => {
assert.strictEqual(matchTarget('db.internal', hosts).host.id, '2');
});
test('raw IP denied by default', () => {
assert.throws(() => matchTarget('8.8.8.8', hosts), (e) => e.code === 'no-such-target');
});
test('raw IP allowed when configured', () => {
const m = matchTarget('8.8.8.8', hosts, { allowRawIPs: true });
assert.strictEqual(m.host, null);
assert.strictEqual(m.raw, '8.8.8.8');
});
test('unknown slug denied', () => {
assert.throws(() => matchTarget('nope', hosts), (e) => e.code === 'no-such-target');
});
test('hostEndpoint uses sshPort then default', () => {
assert.deepStrictEqual(hostEndpoint(hosts[0], 22), { address: '10.0.0.10', port: 2200 });
assert.deepStrictEqual(hostEndpoint(hosts[1], 22), { address: 'db.internal', port: 22 });
});
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const { parseUsername, isIPv4 } = require('../../utils/username_grammar');
test('plain uid → picker mode', () => {
assert.deepStrictEqual(parseUsername('alice'), { uid: 'alice', target: null });
});
test('uid_-_slug → grammar mode', () => {
assert.deepStrictEqual(parseUsername('alice_-_web01'), { uid: 'alice', target: 'web01' });
});
test('uid_-_host_slug (prefixed target)', () => {
assert.deepStrictEqual(parseUsername('bob_-_host_pve1'), { uid: 'bob', target: 'host_pve1' });
});
test('uid_-_ipv4', () => {
assert.deepStrictEqual(parseUsername('bob_-_10.0.0.5'), { uid: 'bob', target: '10.0.0.5' });
});
test('splits on first _-_ only', () => {
// target may legitimately contain a dash; the separator is the first _-_
assert.deepStrictEqual(parseUsername('carol_-_web-01'), { uid: 'carol', target: 'web-01' });
});
test('rejects invalid uid', () => {
assert.throws(() => parseUsername('Bad Uid'));
assert.throws(() => parseUsername('1abc'));
});
test('rejects empty target', () => {
assert.throws(() => parseUsername('alice_-_'));
});
test('rejects empty username', () => {
assert.throws(() => parseUsername(''));
});
test('isIPv4', () => {
assert.ok(isIPv4('192.168.1.1'));
assert.ok(!isIPv4('999.1.1.1'));
assert.ok(!isIPv4('web01'));
});
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Which directory hosts may a user reach, and how do we dial them?
//
// v1 resolution (see directory_spec.md §9.2 in sso-manager-node): the SSO's
// /api/discovery/me only answers for the API token's own user, and /graph
// omits ResourceGroup links — so we combine the user's LDAP groups (queried
// directly) with per-group resource lookups:
//
// 1. LDAP: groups the user's DN is a member of
// 2. SSO: GET /api/discovery/resources?group=<cn> per group (ApiToken)
// 3. union, keep kind === 'host'
//
// Results are cached per-uid for a short TTL — the TUI picker and the
// username-grammar path share the cache. Dependency-injected fetch/ldap for
// unit testing.
const conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const CACHE_TTL_MS = 30 * 1000;
const cache = new Map(); // uid -> {at, hosts}
async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) {
const sso = conf.sso || {};
const url = `${sso.url}/api/discovery/resources?group=${encodeURIComponent(group)}`;
const res = await fetchImpl(url, {
headers: { Authorization: `Bearer ${sso.apiToken}` },
});
if (!res.ok) throw new Error(`directory query failed (${res.status}) for group ${group}`);
const data = await res.json();
return (data && data.results) || [];
}
async function accessibleHosts(user, { fetchImpl = fetch, ldap = userLdap } = {}) {
const hit = cache.get(user.uid);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.hosts;
const groups = await ldap.getGroups(user.dn);
const seen = new Map();
for (const cn of groups) {
let resources;
try {
resources = await fetchResourcesByGroup(cn, { fetchImpl });
} catch (error) {
// One bad group must not hide the rest; the SSO being down
// surfaces as an empty list + log line, not a crash.
console.error(`[access] ${error.message}`);
continue;
}
for (const r of resources) {
if (r.kind === 'host' && !seen.has(r.id)) seen.set(r.id, r);
}
}
const hosts = [...seen.values()];
cache.set(user.uid, { at: Date.now(), hosts });
return hosts;
}
function clearCache(uid) {
if (uid) cache.delete(uid);
else cache.clear();
}
module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup };
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// Jump host SSH identity: one keypair used both as the server host key and
// as the client key for upstream connections (the public half is what gets
// injected into users' sshPublicKey — see utils/key_inject.js).
//
// Generated on first boot into conf.ssh.hostKeyPath:
// ed25519 (id_ed25519 / id_ed25519.pub) — primary
// rsa-3072 (id_rsa / id_rsa.pub) — compatibility host key
//
// Node's crypto generates the keys; ssh2's parseKey consumes the PEMs and
// renders the OpenSSH-format public lines.
const fs = require('fs');
const path = require('path');
const { utils: { parseKey, generateKeyPairSync } } = require('ssh2');
const conf = require('@simpleworkjs/conf');
// ssh2's parseKey wants OpenSSH-format private keys (Node's crypto PKCS8 export
// isn't accepted for ed25519), so use ssh2's own generator.
function generatePair(type) {
const { private: priv } = generateKeyPairSync(type === 'ed25519' ? 'ed25519' : 'rsa',
type === 'ed25519' ? undefined : { bits: 3072 });
return priv;
}
function pubLine(privPem, comment) {
const parsed = parseKey(privPem);
if (parsed instanceof Error) throw parsed;
const key = Array.isArray(parsed) ? parsed[0] : parsed;
return `${key.type} ${key.getPublicSSH().toString('base64')} ${comment}`;
}
function ensureKeys(dir) {
dir = dir || (conf.ssh && conf.ssh.hostKeyPath);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const out = {};
for (const [type, name] of [['ed25519', 'id_ed25519'], ['rsa', 'id_rsa']]) {
const priv = path.join(dir, name);
if (!fs.existsSync(priv)) {
const pem = generatePair(type);
fs.writeFileSync(priv, pem, { mode: 0o600 });
fs.writeFileSync(`${priv}.pub`, pubLine(pem, conf.ssh.keyComment) + '\n', { mode: 0o644 });
}
out[type] = fs.readFileSync(priv, 'utf8');
}
return {
hostKeys: [out.ed25519, out.rsa],
clientKey: out.ed25519,
publicLine: pubLine(out.ed25519, conf.ssh.keyComment),
};
}
module.exports = { ensureKeys, pubLine, generatePair };
+46
View File
@@ -0,0 +1,46 @@
'use strict';
// Upstream auth: the jump host connects to downstream hosts as the real user
// with the jump host's OWN private key. For downstream sshd to accept it, the
// jump host's public key must be one of the user's sshPublicKey values in
// LDAP (downstream hosts serve keys from LDAP via ldap-client's
// AuthorizedKeysCommand / SSSD).
//
// So: before the first upstream connect for a user, append the jump host's
// public line (comment-marked, e.g. "... jump-host@local") to their
// sshPublicKey attribute. Idempotent: exact-value duplicates are a no-op
// (TypeOrValueExists handled in models/user_ldap.addSshKey). The redis flag
// jump_host_injected_<uid> skips the LDAP round-trip on later connects; a
// failed upstream auth clears it so a manually-removed key gets re-injected
// once (see services/bridge.js).
//
// The bind DN therefore needs WRITE access to sshPublicKey on ou=people —
// documented in the README (OpenLDAP ACL) and granted by theta-env's
// bootstrap for the bundled deployment.
const conf = require('@simpleworkjs/conf');
const userLdap = require('../models/user_ldap');
const { getRedis } = require('../models');
function flagKey(uid) {
return `${conf.redis.prefix}injected_${uid}`;
}
async function ensureKeyInjected(user, publicLine, { ldap = userLdap } = {}) {
const redis = await getRedis();
if (await redis.get(flagKey(user.uid))) return false;
const already = (user.sshPublicKeys || []).includes(publicLine);
if (!already) {
await ldap.addSshKey(user.dn, publicLine);
}
await redis.set(flagKey(user.uid), '1');
return !already; // true if we actually wrote (caller may pause for SSSD cache)
}
async function clearInjectedFlag(uid) {
const redis = await getRedis();
await redis.del(flagKey(uid));
}
module.exports = { ensureKeyInjected, clearInjectedFlag };
+69
View File
@@ -0,0 +1,69 @@
'use strict';
// Match a requested target string against the list of directory host
// resources the user may access (from utils/access.js). Matching order:
//
// 1. exact slug (host_web01)
// 2. host_-prefixed slug (web01 -> host_web01)
// 3. exact name (the directory display name, case-insensitive)
// 4. metadata.ip exact
// 5. metadata.address hostname exact (with or without scheme)
//
// A raw IPv4 target that matches no accessible host is allowed through only
// when allowRawIPs is set (the caller audits it as such); anything else that
// doesn't match is a no-access/no-such-target denial — the caller cannot
// tell those apart (by design: don't leak the inventory to unauthorized
// users).
//
// Returns { host, raw } — `host` is the matched resource (null for a
// permitted raw IP), `raw` is the literal address to dial when host is null.
// Throws { code: 'no-such-target' } when nothing matches.
const { isIPv4 } = require('./username_grammar');
function addrHost(address) {
if (!address) return null;
try {
return new URL(address.includes('://') ? address : `ssh://${address}`).hostname;
} catch (_) {
return address;
}
}
function matchTarget(target, hosts, { allowRawIPs = false } = {}) {
const t = String(target).toLowerCase();
const bySlug = hosts.find((h) => h.slug && h.slug.toLowerCase() === t);
if (bySlug) return { host: bySlug, raw: null };
const byPrefixed = hosts.find((h) => h.slug && h.slug.toLowerCase() === `host_${t}`);
if (byPrefixed) return { host: byPrefixed, raw: null };
const byName = hosts.find((h) => h.name && h.name.toLowerCase() === t);
if (byName) return { host: byName, raw: null };
const byIp = hosts.find((h) => h.metadata && h.metadata.ip === target);
if (byIp) return { host: byIp, raw: null };
const byAddr = hosts.find((h) => {
const a = addrHost(h.metadata && h.metadata.address);
return a && a.toLowerCase() === t;
});
if (byAddr) return { host: byAddr, raw: null };
if (isIPv4(target) && allowRawIPs) return { host: null, raw: target };
const err = new Error(`No accessible host matches '${target}'`);
err.code = 'no-such-target';
throw err;
}
// Resolve the address/port to dial for a matched host resource.
function hostEndpoint(host, defaultPort = 22) {
const md = host.metadata || {};
const address = md.ip || addrHost(md.address) || null;
const port = Number(md.sshPort) || defaultPort;
return { address, port };
}
module.exports = { matchTarget, hostEndpoint };
+57
View File
@@ -0,0 +1,57 @@
'use strict';
// Parse the jump host's SSH username grammar:
//
// {uid} -> interactive TUI picker
// {uid}_-_{target} -> bridge straight to <target>
//
// where <target> is a directory host slug (with or without the host_ prefix),
// a bare hostname, or an IPv4 address. The separator `_-_` was chosen because
// it is legal in an SSH username everywhere (WinSCP included) and cannot
// appear in a POSIX uid. We split on the FIRST `_-_`: uids cannot contain it
// (POSIX uids don't allow the sequence in practice and the SSO's invite flow
// never generates one), while a target could in theory contain a later `-`
// sequence.
//
// Returns { uid, target } — target is null in picker mode.
// Throws on a syntactically invalid uid or target.
const SEP = '_-_';
// POSIX-ish uid: same shape the SSO enforces.
const UID_RE = /^[a-z_][a-z0-9._-]{0,31}$/;
// Directory slug chars (slugify output) or a hostname label string.
const TARGET_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
function isIPv4(s) {
const m = IPV4_RE.exec(s);
if (!m) return false;
return m.slice(1).every((o) => Number(o) <= 255);
}
function parseUsername(username) {
if (typeof username !== 'string' || !username.length) {
throw new Error('Empty username');
}
const idx = username.indexOf(SEP);
if (idx === -1) {
if (!UID_RE.test(username)) throw new Error(`Invalid username: ${username}`);
return { uid: username, target: null };
}
const uid = username.slice(0, idx);
const target = username.slice(idx + SEP.length);
if (!UID_RE.test(uid)) throw new Error(`Invalid uid in username: ${uid}`);
if (!target.length || (!TARGET_RE.test(target) && !isIPv4(target))) {
throw new Error(`Invalid target in username: ${target}`);
}
return { uid, target };
}
module.exports = { parseUsername, isIPv4, SEP };
+39
View File
@@ -0,0 +1,39 @@
<%- include('top') %>
<h1>Audit log</h1>
<form class="filters" method="get">
<input name="uid" placeholder="user" value="<%= query.uid || '' %>">
<input name="target" placeholder="target" value="<%= query.target || '' %>">
<select name="status">
<option value="">any</option>
<option value="success" <%= query.status === 'success' ? 'selected' : '' %>>success</option>
<option value="fail" <%= query.status === 'fail' ? 'selected' : '' %>>fail</option>
</select>
<button>Filter</button>
</form>
<table>
<thead><tr><th>Time</th><th>User</th><th>Method</th><th>Mode</th><th>Target</th><th>Chan</th><th>Client</th><th>Result</th><th>Bytes</th></tr></thead>
<tbody>
<% data.results.forEach(e => { %>
<tr class="<%= e.success ? '' : 'bad' %>">
<td><%= new Date(e.ts).toLocaleString() %></td>
<td><%= e.uid %></td>
<td><%= e.authMethod %></td>
<td><%= e.mode %></td>
<td><%= e.targetSlug || e.targetAddr || '—' %></td>
<td><%= e.channel || '—' %></td>
<td><%= e.clientIp %></td>
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td>
<td class="r"><%= (e.bytesIn + e.bytesOut) || 0 %></td>
</tr>
<% }) %>
</tbody>
</table>
<div class="pager">
<% const p = data.page; %>
<% if (p > 0) { %><a href="?page=<%= p-1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">← prev</a><% } %>
<span><%= data.total %> events</span>
<% if ((p+1) * data.pageSize < data.total) { %><a href="?page=<%= p+1 %>&uid=<%= query.uid||'' %>&target=<%= query.target||'' %>&status=<%= query.status||'' %>">next →</a><% } %>
</div>
<%- include('bottom') %>
+6
View File
@@ -0,0 +1,6 @@
</main>
<footer class="foot">
<% if (typeof buildInfo !== 'undefined') { %><span>v<%= buildInfo.version %> · <%= buildInfo.commit %></span><% } %>
</footer>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<%- include('top') %>
<h1>Dashboard</h1>
<div class="tiles">
<div class="tile"><span class="n"><%= metrics.active %></span><span class="l">active sessions</span></div>
<div class="tile"><span class="n"><%= metrics.total %></span><span class="l">total connections</span></div>
<div class="tile"><span class="n"><%= metrics.fail %></span><span class="l">failed</span></div>
</div>
<div class="cols">
<section>
<h2>Active sessions</h2>
<% if (!active.length) { %><p class="muted">None right now.</p><% } else { %>
<table>
<thead><tr><th>User</th><th>Target</th><th>Since</th></tr></thead>
<tbody>
<% active.forEach(s => { %>
<tr><td><%= s.uid %></td><td><%= s.slug || s.target %></td><td><%= new Date(s.startedAt).toLocaleTimeString() %></td></tr>
<% }) %>
</tbody>
</table>
<% } %>
</section>
<section>
<h2>Top hosts</h2>
<% if (!metrics.topHosts.length) { %><p class="muted">No data.</p><% } else { %>
<table><tbody>
<% metrics.topHosts.forEach(h => { %><tr><td><%= h.name %></td><td class="r"><%= h.count %></td></tr><% }) %>
</tbody></table>
<% } %>
</section>
</div>
<section>
<h2>Recent connections <a class="more" href="/audit">view all →</a></h2>
<table>
<thead><tr><th>Time</th><th>User</th><th>Target</th><th>Method</th><th>Result</th></tr></thead>
<tbody>
<% recent.forEach(e => { %>
<tr>
<td><%= new Date(e.ts).toLocaleString() %></td>
<td><%= e.uid %></td>
<td><%= e.targetSlug || e.targetAddr || '—' %></td>
<td><%= e.authMethod %> / <%= e.mode %></td>
<td><%= e.success ? '✓' : '✗ ' + e.failReason %></td>
</tr>
<% }) %>
</tbody>
</table>
</section>
<%- include('bottom') %>
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= name %> — Jump Host login</title>
<link rel="stylesheet" href="/public/css/app.css">
</head>
<body class="center">
<form method="post" action="/login" class="card login">
<h1><%= name %> <small>jump host</small></h1>
<% if (error) { %><p class="err"><%= error %></p><% } %>
<label>Username <input name="uid" autofocus autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
<button type="submit">Sign in</button>
<p class="hint">Admin group required. Uses your directory (LDAP) credentials.</p>
</form>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<%- include('top') %>
<h1>Active sessions</h1>
<% if (!active.length) { %><p class="muted">No active sessions.</p><% } else { %>
<table>
<thead><tr><th>User</th><th>Target host</th><th>Address</th><th>Started</th></tr></thead>
<tbody>
<% active.forEach(s => { %>
<tr><td><%= s.uid %></td><td><%= s.slug || '—' %></td><td><%= s.target %></td><td><%= new Date(s.startedAt).toLocaleString() %></td></tr>
<% }) %>
</tbody>
</table>
<% } %>
<%- include('bottom') %>
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= name %> — Jump Host</title>
<link rel="stylesheet" href="/public/css/app.css">
</head>
<body>
<nav class="nav">
<span class="brand"><%= name %> <small>jump host</small></span>
<% if (typeof user !== 'undefined' && user) { %>
<span class="spacer"></span>
<a href="/">Dashboard</a>
<a href="/sessions">Sessions</a>
<a href="/audit">Audit</a>
<span class="who"><%= user.uid %></span>
<form method="post" action="/logout" class="inline"><button class="link">logout</button></form>
<% } %>
</nav>
<main class="wrap">
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
#
# Install / update the Theta42 jump host on a fresh or existing host.
#
# Idempotent: run to install, re-run to update. Installs system dependencies
# (Node, Redis), force-syncs the repo at $REPO_DIR to its remote branch, and
# symlinks the systemd unit straight from the repo so an update is just
# "sync repo + restart".
#
# Secrets live at $SECRETS_FILE (/etc/jump-host/secrets.js by default), outside
# the checkout so they survive the hard reset. First run seeds it from
# secrets.js.example (placeholders you must fill in); later runs never touch it.
#
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
# SECRETS_FILE=)
set -euo pipefail
export GIT_TERMINAL_PROMPT=0
export DEBIAN_FRONTEND=noninteractive
REPO_URL="${REPO_URL:-https://github.com/theta42/jump-host.git}"
REPO_DIR="${REPO_DIR:-/opt/theta42/jump-host}"
BRANCH="${BRANCH:-master}"
NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/jump-host/secrets.js}"
DATA_DIR="${DATA_DIR:-/var/lib/jump-host}"
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (try: sudo $0)" >&2
exit 1
fi
link(){ ln -sfn "$1" "$2"; echo "linked $2 -> $1"; }
pkg_version(){
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
CURRENT_VERSION=""
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
fi
echo "==> Base packages"
apt-get update -qq
apt-get install -y -qq ca-certificates curl git gnupg redis-server >/dev/null
echo "==> Node.js ${NODE_MAJOR}.x"
if ! command -v node >/dev/null 2>&1 || [ "$(node -v | sed 's/v\([0-9]*\).*/\1/')" -lt "$NODE_MAJOR" ]; then
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
apt-get update -qq
apt-get install -y -qq nodejs >/dev/null
fi
echo " node $(node -v)"
echo "==> Redis (enable + start)"
systemctl enable --now redis-server >/dev/null 2>&1 || systemctl enable --now redis >/dev/null 2>&1 || true
echo "==> Repo at $REPO_DIR"
if [ -d "$REPO_DIR/.git" ]; then
git -C "$REPO_DIR" fetch --prune origin
git -C "$REPO_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
git -C "$REPO_DIR" reset --hard "origin/$BRANCH"
git -C "$REPO_DIR" clean -fd
else
mkdir -p "$(dirname "$REPO_DIR")"
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi
NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
echo "==> Data dir $DATA_DIR (host keys + state)"
mkdir -p "$DATA_DIR/keys"
chmod 700 "$DATA_DIR" "$DATA_DIR/keys"
echo "==> Secrets at $SECRETS_FILE"
if [ ! -f "$SECRETS_FILE" ]; then
mkdir -p "$(dirname "$SECRETS_FILE")"
cp "$REPO_DIR/secrets.js.example" "$SECRETS_FILE"
chmod 600 "$SECRETS_FILE"
echo " seeded from secrets.js.example — EDIT IT before the service will work:"
echo " $SECRETS_FILE"
else
echo " exists — left untouched"
fi
echo "==> systemd unit"
link "$REPO_DIR/ops/jump-host.service" /etc/systemd/system/jump-host.service
echo "==> npm install (production deps)"
( cd "$REPO_DIR/nodejs" && (npm ci --omit=dev 2>/dev/null || npm install --omit=dev) )
echo "==> Start service"
systemctl daemon-reload
systemctl enable --now jump-host.service
systemctl restart jump-host.service
echo
if [ -z "$CURRENT_VERSION" ]; then
echo "Installed jump-host v${NEW_VERSION}."
elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
echo "Already up to date (v${NEW_VERSION})."
else
echo "Updated jump-host v${CURRENT_VERSION} -> v${NEW_VERSION}."
fi
echo "Re-run this script any time to update: sudo $0"
echo "Logs: journalctl -u jump-host -f"
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Theta42 SSH jump host
After=network.target redis-server.service
Wants=redis-server.service
StartLimitIntervalSec=0
[Service]
Type=simple
Restart=always
RestartSec=1
User=root
WorkingDirectory=/opt/theta42/jump-host/nodejs
Environment="NODE_ENV=production"
Environment="CONF_SECRETS=/etc/jump-host/secrets.js"
ExecStart=/usr/bin/env node /opt/theta42/jump-host/nodejs/bin/www
# The default listen port is 2222 (no privilege needed). To run on 22, set
# ssh.listenPort in secrets.js AND uncomment the next line:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
+69
View File
@@ -0,0 +1,69 @@
'use strict';
//
// jump-host secrets. Copy to your secrets file and fill in.
// Bare metal: /etc/jump-host/secrets.js (install.sh seeds this)
// Docker: mount at /config/jump-secrets.js (the entrypoint points
// CONF_SECRETS at it); or pass the same values as app_* env.
//
// Read by @simpleworkjs/conf via CONF_SECRETS. Precedence (later wins):
// conf/base.js < conf/<NODE_ENV>.js < this file < app_* env vars.
//
module.exports = {
name: 'My Org',
// The directory the users live in (the SSO Manager's OpenLDAP).
//
// IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on
// the sshPublicKey attribute of user entries — the jump host injects its
// own public key into each user's sshPublicKey on first use so it can
// connect downstream AS that user. Grant it with an OpenLDAP ACL, e.g.:
//
// access to attrs=sshPublicKey
// by dn.exact="cn=jumphost,ou=people,dc=example,dc=com" write
// by self write
// by * read
//
// (In the theta-env bundle this ACL is added by the bootstrap for the
// shared cn=ldapclient service account.)
ldap: {
url: 'ldaps://sso.example.com:636',
bindDN: 'cn=ldapclient,ou=people,dc=example,dc=com',
bindPassword: 'CHANGE-ME',
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
tlsOptions: { rejectUnauthorized: false },
},
// SSO Manager directory (inventory) API. apiToken is a personal access
// token (sso_<id>_<secret>) of any user that can read /api/discovery/*.
sso: {
url: 'https://sso.example.com',
apiToken: 'sso_CHANGE_ME',
},
ssh: {
listenPort: 2222,
hostKeyPath: '/var/lib/jump-host/keys',
banner: 'Theta42 Jump Host — authorized use only.\n',
// 'off' = keys only (recommended for a public host); 'local' = passwords
// only from loopback/RFC1918 clients, keys-only from the internet;
// 'all' = passwords from anywhere.
passwordAuth: 'off',
allowRawIPs: false,
connectTimeoutMs: 10000,
idleTimeoutMs: 0,
maxSessions: 100,
// Comment on the injected key; also excludes that key from inbound auth.
keyComment: 'jump-host@my-org',
},
web: { port: 3002 },
// LDAP groups whose members may use the web UI/API.
auth: { adminGroups: ['app_sso_admin'] },
redis: {
prefix: 'jump_host_',
redisConf: { url: 'redis://127.0.0.1:6379' },
},
};