Compare commits

..

2 Commits

Author SHA1 Message Date
wmantly 1092031a9f v1.14.0: load secrets from OpenBao at boot via @simpleworkjs/bao-conf
bin/www now runs bao-conf.init({ path: 'jump-host' }) before
require('../models'), so the OIDC clientSecret captured at require time
inside createOidcClient sees the OpenBao-merged config. Authenticates to
OpenBao with a scoped VAULT_TOKEN (policy jump-host), never the root
token; fail-soft to CONF_SECRETS if OpenBao is unreachable.
config/jump-secrets.js becomes an operator-edit seed artifact (OpenBao
authoritative). README gains a Secrets section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:28:46 -04:00
wmantly f386a5f9c3 Add ANSI colors to TUI picker
- Box-drawing header with cyan/magenta/green color treatment
- Per-row coloring with alternating cyan shades
- Environment badges (PROD in red, DEV in dim)
- Green inverse selection with '◄ SELECTED ►' indicator
- Yellow filter text and footer separator
- Title changed to 'SSO Manager'
2026-07-31 14:24:54 -04:00
7 changed files with 136 additions and 35 deletions
+16
View File
@@ -4,6 +4,22 @@ 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.14.0] - 2026-08-01
### Changed
- **Secrets now load from OpenBao at boot** via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/jump-host/conf` over the file-loaded config. The jump
host authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy
`jump-host` — read-only on its own path), never the root token. Because the
OIDC `clientSecret` is captured at require time inside `createOidcClient`
(during `require('../models')`), `bin/www` now runs `bao-conf.init()`
**before** `require('../models')`. Fail-soft: if OpenBao is unreachable,
boot continues from `CONF_SECRETS`. The `config/jump-secrets.js` file is now
an operator-edit seed artifact (gitignored); OpenBao is authoritative. See
theta-env's [Secrets docs](https://theta42.github.io/theta-env/secrets/).
- Bumped package version to track the release tag.
## [1.11.0] - 2026-07-30
### Added
+16
View File
@@ -159,6 +159,22 @@ Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simplework
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
See `secrets.js.example` for every key.
## Secrets
At boot, [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
deep-merges `secret/jump-host/conf` from **OpenBao** over the file-loaded
config. The jump host's OIDC `clientSecret` is captured at require time
(inside `createOidcClient` during `require('../models')`), so `bin/www` runs
`bao-conf.init()` **before** `require('../models')`. Fail-soft: if OpenBao is
unreachable, boot continues from `CONF_SECRETS`. The jump host authenticates to
OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
`secret/jump-host/conf`), never the root token.
The `config/jump-secrets.js` file is an operator-edit seed artifact
(gitignored); the bootstrap writes the generated API token + OAuth client
into OpenBao, which is authoritative. For the full architecture see
theta-env's **[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
## Development
```
+34 -23
View File
@@ -9,32 +9,43 @@ const http = require('http');
const conf = require('@simpleworkjs/conf');
const { Server } = require('socket.io');
require('../models');
// @simpleworkjs/conf loads ./config/jump-secrets.js synchronously, then
// @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from OpenBao over
// it. The OIDC clientSecret is captured at require time inside models (via
// createOidcClient), so the fetch MUST resolve before require('../models').
// Fail-soft: if OpenBao is unreachable, init() leaves conf as the file-loaded
// fallback and boot continues from ./config/jump-secrets.js.
require('@simpleworkjs/bao-conf').init({ path: 'jump-host', conf }).then(() => {
require('../models');
const app = require('../app');
const middleware = require('../middleware/auth');
const sshServer = require('../services/ssh_server');
const app = require('../app');
const middleware = require('../middleware/auth');
const sshServer = require('../services/ssh_server');
const webPort = (conf.web && conf.web.port) || 3002;
const server = http.createServer(app);
const webPort = (conf.web && conf.web.port) || 3002;
const server = http.createServer(app);
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
// We don't push anything yet, but serving /socket.io keeps the shared front-end
// working exactly as it does in the sibling apps.
const io = new Server(server);
io.use(middleware.authIO);
app.io = io;
// Socket.IO — the client framework (app-base.js) opens an authenticated socket.
// We don't push anything yet, but serving /socket.io keeps the shared front-end
// working exactly as it does in the sibling apps.
const io = new Server(server);
io.use(middleware.authIO);
app.io = io;
server.listen(webPort, () => {
console.log(`[web] jump-host UI/API on :${server.address().port}`);
});
server.listen(webPort, () => {
console.log(`[web] jump-host UI/API on :${server.address().port}`);
});
sshServer.start();
sshServer.start();
function shutdown() {
console.log('[jump-host] shutting down');
server.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
function shutdown() {
console.log('[jump-host] shutting down');
server.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
}).catch(err => {
console.error('boot failed:', err);
process.exit(1);
});
+1 -1
View File
@@ -6,7 +6,7 @@
// values (LDAP creds, SSO API token) belong in the secrets file.
module.exports = {
name: 'Jump Host',
name: 'SSO Manager',
logo: '/static/img/theta42.svg',
// LDAP directory the users live in (same directory the SSO manages).
+15 -2
View File
@@ -1,16 +1,17 @@
{
"name": "t42-jump-host",
"version": "1.9.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-jump-host",
"version": "1.9.0",
"version": "1.11.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.6",
@@ -156,6 +157,18 @@
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.0.tgz",
"integrity": "sha512-HxB2ohFuDKbwTfNh5dXCot0dd6qoP+3Ebz1xKH0eOhKNVNMjHU1p7XZv2VTe+VnHn+DV22Eh8lAgWxnX/pkXUw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-jump-host",
"version": "1.11.0",
"version": "1.14.0",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
@@ -21,6 +21,7 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.6",
+52 -8
View File
@@ -9,10 +9,29 @@ const ESC = '\x1b';
const CLEAR = `${ESC}[2J${ESC}[H`;
const HIDE_CUR = `${ESC}[?25l`;
const SHOW_CUR = `${ESC}[?25h`;
const INV = `${ESC}[7m`;
// Basic styles
const RST = `${ESC}[0m`;
const DIM = `${ESC}[2m`;
const BOLD = `${ESC}[1m`;
const DIM = `${ESC}[2m`;
// Colors (30-37: standard, 90-97: bright)
const RED = `${ESC}[31m`;
const BRIGHT_RED = `${ESC}[91m`;
const CYAN = `${ESC}[36m`;
const BRIGHT_CYAN = `${ESC}[96m`;
const GREEN = `${ESC}[32m`;
const BRIGHT_GREEN = `${ESC}[92m`;
const YELLOW = `${ESC}[33m`;
const BRIGHT_YELLOW = `${ESC}[93m`;
const MAGENTA = `${ESC}[35m`;
const BRIGHT_MAGENTA = `${ESC}[95m`;
const BLUE = `${ESC}[34m`;
const BRIGHT_BLUE = `${ESC}[94m`;
// Inverted selection with color
const INV_GREEN = `${ESC}[42m${ESC}[30m`; // Green bg, black text
const INV = `${ESC}[7m`;
function pickHost(channel, uid, hosts) {
return new Promise((resolve) => {
@@ -35,18 +54,43 @@ function pickHost(channel, uid, hosts) {
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`;
// Header with gradient-style color
out += `\r\n ${BOLD}${BRIGHT_CYAN}╔════════════════════════════════════════════════════════╗${RST}\r\n`;
out += ` ${BOLD}${BRIGHT_CYAN}${RST} ${BOLD}${BRIGHT_MAGENTA}Theta42 Jump${RST} ${DIM}·${RST} ${BRIGHT_GREEN}hosts for ${uid}${RST} ${BOLD}${BRIGHT_CYAN}${RST}\r\n`;
out += ` ${BOLD}${BRIGHT_CYAN}╚════════════════════════════════════════════════════════╝${RST}\r\n`;
out += `\r\n`;
out += ` ${DIM}↑/↓ move · Enter connect · type to filter · q quit${RST}\r\n`;
out += `\r\n`;
if (!list.length) {
out += ` ${DIM}(no match for "${filter}")${RST}\r\n`;
out += ` ${YELLOW}${RST} ${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';
const isProd = h.metadata && h.metadata.isProduction;
const envBadge = isProd ? `${BOLD}${RED}PROD${RST} ` : `${DIM}DEV${RST} `;
if (i === selected) {
// Selected row with green inverse background
const selRow = `${INV_GREEN} ${h.name} ${DIM}(${h.slug})${RST}${ip ? ` ${CYAN}${ip}${RST}` : ''} ${envBadge} ${BOLD}${BRIGHT_GREEN}◄ SELECTED ►${RST}${INV_GREEN}${RST}`;
out += selRow + '\r\n';
} else {
// Normal row with subtle coloring
const nameColor = i % 2 === 0 ? BRIGHT_CYAN : CYAN;
out += ` ${nameColor}${h.name}${RST} ${DIM}(${h.slug})${RST}${ip ? ` ${BLUE}${ip}${RST}` : ''} ${envBadge}\r\n`;
}
});
}
if (filter) out += `\r\n ${DIM}filter:${RST} ${filter}`;
if (filter) {
out += `\r\n ${DIM}filter: ${BRIGHT_YELLOW}${filter}${RST}`;
}
// Footer
out += `\r\n\r\n ${DIM}────────────────────────────────────────────────────────${RST}\r\n`;
out += ` ${DIM}Press${RST} ${BOLD}1-9${RST} ${DIM}to quick-select · ${BOLD}q${RST} ${DIM}to quit${RST}\r\n`;
channel.write(out);
};