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:
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
config/
|
||||||
|
data/
|
||||||
|
secrets.js
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
@@ -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`.
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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 "$@"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
conf/secrets.js
|
||||||
|
*.log
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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: {},
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ssh: {
|
||||||
|
hostKeyPath: './data/keys',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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');
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
Generated
+1607
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 } };
|
||||||
@@ -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 };
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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')));
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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') %>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
</main>
|
||||||
|
<footer class="foot">
|
||||||
|
<% if (typeof buildInfo !== 'undefined') { %><span>v<%= buildInfo.version %> · <%= buildInfo.commit %></span><% } %>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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') %>
|
||||||
@@ -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>
|
||||||
@@ -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') %>
|
||||||
@@ -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
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user