diff --git a/CHANGELOG.md b/CHANGELOG.md index c4fe2bb..8b00630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ 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.4.0] - 2026-07-26 + +### Added +- **Standalone mode** — run the jump host with no LDAP directory and no SSO Manager at all. Set `standalone.enabled: true` and user authentication and host discovery switch to `@simpleworkjs/orm`-backed stores (Sequelize; SQLite by default, any Sequelize-supported dialect via `conf.orm`) instead of the directory services. `models/user_ldap.js` and `utils/access.js` become conditional facades that pick their backend at require time — `ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, and the web UI are unchanged either way. +- New ORM models: `StandaloneUser` (`uid`, `passwordHash`, `sshPublicKeys`, `groups`) and `StandaloneHost` (`slug`, `displayName`, `kind`, `metadata`), plus `models/user_file.js` and `utils/hosts_file.js`, which implement the same interfaces as the LDAP client and `accessibleHosts()` respectively. There's no admin UI for standalone users/hosts yet — see the README's "Standalone mode" section for the ORM-model seeding snippet. In standalone mode every stored host is reachable by every stored user; there's no group-based authorization yet. +- 47 tests pass (24 existing + 15 new unit + 3 existing integration + 5 new standalone integration). + +### Fixed +- **`services/ssh_server.js` used `|| 2222` for the listen port**, so an explicit `listenPort: 0` (ephemeral port, used by the test suite) was silently overridden back to 2222. Changed to `?? 2222`. +- **`services/ssh_server.js` awaited `audit.create()` before registering session listeners.** A client that sends `exec`/`shell` immediately after connecting could have its request dropped because nothing was listening yet. Listener registration now happens first. + ## [1.3.0] - 2026-07-26 ### Changed diff --git a/README.md b/README.md index 550fdbf..9bb2c02 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ 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. +entitled to — audited end to end. + +Two backends, same SSH front door and audit trail: the default mode +authenticates against the shared LDAP directory and authorizes from the +[SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory graph; +**standalone mode** (below) runs with no LDAP or SSO at all, storing users and +hosts in a local SQL database instead. ## Two ways to connect @@ -44,8 +48,53 @@ bridged straight in. 4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the downstream sshd. Every session is audited. +## Standalone mode + +Run without LDAP or the SSO Manager at all. Set `standalone.enabled: true` and +the jump host stores users and hosts itself, via +[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm) +(Sequelize under the hood — defaults to a local SQLite file, but any +Sequelize-supported dialect works via `conf.orm`): + +```js +standalone: { enabled: true }, +orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false }, +``` + +Everything else — the SSH front door, key injection, bridging, the web UI and +audit trail — is unchanged; only where users/hosts live and how passwords are +checked differs. There's no admin UI for standalone users/hosts yet — add them +with the ORM models directly: + +```js +const StandaloneUser = require('./models/standalone_user'); +const StandaloneHost = require('./models/standalone_host'); +const bcrypt = require('bcrypt'); + +await StandaloneUser.create({ + uid: 'alice', + passwordHash: await bcrypt.hash('a real password', 10), + sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'], + groups: [], +}); + +await StandaloneHost.create({ + slug: 'host_web01', + displayName: 'web01', + kind: 'host', + metadata: { ip: '10.0.0.5', sshPort: 22 }, +}); +``` + +In standalone mode every stored host is reachable by every stored user — there +is no group-based authorization (the `groups` field on `StandaloneUser` is +accepted for interface parity but not yet enforced). + ## Requirements +*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip +all of this)* + - 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 diff --git a/docs/_config.yml b/docs/_config.yml index 1216495..eb44233 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,5 +1,5 @@ title: Jump Host -description: An SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics. +description: An SSH jump host for the theta42 stack — directory-driven host bridging with audit and metrics; LDAP + SSO Manager by default, or fully standalone. url: "https://theta42.github.io" baseurl: "/jump-host" logo: /assets/img/theta42.svg diff --git a/docs/architecture.md b/docs/architecture.md index c5dfbf1..8104ee1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,6 +112,28 @@ Audit events and counters live in redis. Each event captures: user, auth method, mode (grammar/picker), target slug/address/port, channel type, client IP, success + failure reason, downstream host-key fingerprint, timing, and bytes in/out. +## Standalone mode + +Everything above describes the default backend. Set `standalone.enabled: true` +and two modules become conditional facades, swapping their entire +implementation at `require` time based on that flag — nothing else in the +codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the +web UI) changes or even knows which mode it's running in: + +- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an + [@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed + store implementing the same `getUser` / `getGroups` / `checkPassword` / + `addSshKey` interface). +- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or + `utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface). + In standalone mode there's no group-based authorization: every stored host + is accessible to every stored user. + +The ORM is Sequelize underneath, defaulting to a local SQLite file but +accepting any Sequelize-supported dialect via `conf.orm`. See +[Installation](installation.html#standalone-mode) for config and how to add +users/hosts (there's no admin UI for standalone data yet). + ## Where it sits in the stack - **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the diff --git a/docs/connecting.md b/docs/connecting.md index 19eedfe..966ba87 100644 --- a/docs/connecting.md +++ b/docs/connecting.md @@ -74,6 +74,10 @@ changes. Targets that don't resolve to a host you're allowed to reach are refused (and audited). Raw IPs that aren't a known directory host are denied by default. +> On a [standalone](architecture.html#standalone-mode) jump host (no LDAP/SSO), +> every registered host is reachable by every registered user — there's no +> group-based restriction to ask an admin about. + ## Authentication The jump host authenticates **you** against the directory: diff --git a/docs/index.md b/docs/index.md index 58e36ff..b7d73bc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- layout: default title: Home -description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to. +description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to; LDAP by default, or fully standalone. --- # Jump Host @@ -30,6 +30,12 @@ Part of the theta42 self-hosted identity stack, alongside *(click any screenshot to view full size)* +Don't want to run LDAP or the SSO Manager? **Standalone mode** stores users +and hosts in a local SQL database instead (SQLite by default, any +Sequelize-supported dialect if you want something else) — same SSH front door, +key injection, and audit trail. See +[Installation](installation.html#standalone-mode) to get started. + ## Two ways to connect **Direct (WinSCP/SFTP-friendly):** @@ -88,6 +94,8 @@ This jump host answers both from your directory: audit log, per-user/per-host counters - **Full audit trail** — who, target, method, result, bytes, duration, and the downstream host-key fingerprint +- **Standalone mode** — no LDAP, no SSO Manager; users and hosts live in a + local SQL database (Sequelize, any dialect — SQLite by default) - Packaged like the rest of the stack: one-command Docker, idempotent bare-metal installer, or bundled in theta-env diff --git a/docs/installation.md b/docs/installation.md index 23de338..6a5f37b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,7 +1,7 @@ --- layout: default title: Installation -description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options. +description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus standalone mode (no LDAP/SSO), the LDAP write-ACL, and port-22 options. --- # Installation @@ -10,6 +10,51 @@ Three ways to run the jump host, in increasing manual effort. All read their config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf) (`conf/base.js` < `conf/.js` < the `CONF_SECRETS` file < `app_*` env). +## Standalone mode (no LDAP/SSO) {#standalone-mode} + +Skip LDAP and the SSO Manager entirely. Not to be confused with "Standalone +Docker" below, which is still LDAP + SSO, just run outside theta-env. Set in your secrets/config: + +```js +standalone: { enabled: true }, +orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false }, +``` + +`orm` is passed straight to Sequelize, so any supported dialect works — SQLite +is just the zero-dependency default. Everything downstream of auth (bridging, +key injection, the web UI, audit) is unchanged. + +There's no admin UI for standalone users/hosts yet, so add them directly with +the ORM models: + +```js +const StandaloneUser = require('./models/standalone_user'); +const StandaloneHost = require('./models/standalone_host'); +const bcrypt = require('bcrypt'); + +await StandaloneUser.create({ + uid: 'alice', + passwordHash: await bcrypt.hash('a real password', 10), + sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'], + groups: [], +}); + +await StandaloneHost.create({ + slug: 'host_web01', + displayName: 'web01', + kind: 'host', + metadata: { ip: '10.0.0.5', sshPort: 22 }, +}); +``` + +Every host in the standalone inventory is reachable by every standalone user — +there's no group-based authorization yet (`groups` on `StandaloneUser` is +accepted for interface parity with the LDAP path, not enforced). + +The rest of this page (requirements, the LDAP write-ACL, the three install +paths) describes the default LDAP + SSO mode — skip it if you're running +standalone. + ## Requirements - The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index a88f615..ee2d3ea 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -98,6 +98,21 @@ module.exports = { maxEvents: 50000, }, + // Standalone mode: run without LDAP or SSO Manager. When enabled, user + // authentication and host discovery use @simpleworkjs/orm-backed stores + // (Sequelize, defaulting to SQLite) instead of the directory services. + standalone: { + enabled: false, + }, + + // ORM config for standalone mode. Passed through to Sequelize — any dialect + // works. Defaults to SQLite for zero-dependency local dev. + orm: { + dialect: 'sqlite', + storage: './data/standalone.sqlite', + logging: false, + }, + // Orchestrator-only keys (ignored by the app, read by theta-env). stack: {}, }; diff --git a/nodejs/conf/development.js b/nodejs/conf/development.js index c20c0ed..195929a 100644 --- a/nodejs/conf/development.js +++ b/nodejs/conf/development.js @@ -4,4 +4,12 @@ module.exports = { ssh: { hostKeyPath: './data/keys', }, + standalone: { + enabled: true, + }, + orm: { + dialect: 'sqlite', + storage: './data/standalone.sqlite', + logging: false, + }, }; diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 143e794..3706733 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -49,4 +49,18 @@ module.exports.authRouter = oidcClient.router; require('./audit_event'); // Idempotent anti-lockout local admin (was the IIFE in user_redis.js). -bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' }); \ No newline at end of file +bootstrapLocalAdmin(Table.models.User, { defaultName: 'jumpadmin' }); + +// Standalone mode: initialize @simpleworkjs/orm for local user/host stores. +// The ORM must be loaded before any code calls user_ldap or access — both of +// which check conf.standalone.enabled at require time and may delegate to the +// ORM-backed wrappers. Model registration is synchronous; table sync is async +// but the first query will implicitly wait (Sequelize.sync is in-flight). +// Export the promise so integration tests can await it before seeding data. +let ormReady = Promise.resolve(); +if (conf.standalone && conf.standalone.enabled) { + const { init } = require('@simpleworkjs/orm'); + const ormConf = conf.orm || { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false }; + ormReady = init({ conf: { orm: ormConf }, models: [require('./standalone_user'), require('./standalone_host')] }); +} +module.exports.ormReady = ormReady; \ No newline at end of file diff --git a/nodejs/models/standalone_host.js b/nodejs/models/standalone_host.js new file mode 100644 index 0000000..03efa80 --- /dev/null +++ b/nodejs/models/standalone_host.js @@ -0,0 +1,30 @@ +'use strict'; + +// ORM model for standalone-mode hosts. Stored in the configured SQL database +// (default SQLite) when conf.standalone.enabled is true. The hosts_file.js +// wrapper translates between this model and the accessibleHosts() interface +// that ssh_server.js expects. + +const { Model, fields } = require('@simpleworkjs/orm'); + +// Patch StringField.toSequelize() to pass through primaryKey (same fix as in +// standalone_user.js — see that file for details). +if (!fields.StringField.prototype.toSequelize.toString().includes('primaryKey')) { + const orig = fields.StringField.prototype.toSequelize; + fields.StringField.prototype.toSequelize = function () { + const def = orig.call(this); + if (this.primaryKey) def.primaryKey = true; + return def; + }; +} + +class StandaloneHost extends Model { + static fields = { + slug: { type: 'string', primaryKey: true }, + displayName: { type: 'string' }, + kind: { type: 'string', default: 'host' }, + metadata: { type: 'json', default: {} }, + }; +} + +module.exports = StandaloneHost; diff --git a/nodejs/models/standalone_user.js b/nodejs/models/standalone_user.js new file mode 100644 index 0000000..c4ba42a --- /dev/null +++ b/nodejs/models/standalone_user.js @@ -0,0 +1,36 @@ +'use strict'; + +// ORM model for standalone-mode users. Stored in the configured SQL database +// (default SQLite) when conf.standalone.enabled is true. The user_file.js +// wrapper translates between this model and the LDAP-client interface that +// ssh_server.js and key_inject.js expect. + +const { Model, fields } = require('@simpleworkjs/orm'); + +// Patch: StringField.toSequelize() and IntegerField.toSequelize() don't pass +// through primaryKey / autoIncrement (unlike UUIDField which does). Fix them +// so string and int primary keys work. +const origStringToSeq = fields.StringField.prototype.toSequelize; +fields.StringField.prototype.toSequelize = function () { + const def = origStringToSeq.call(this); + if (this.primaryKey) def.primaryKey = true; + return def; +}; +const origIntToSeq = fields.IntegerField.prototype.toSequelize; +fields.IntegerField.prototype.toSequelize = function () { + const def = origIntToSeq.call(this); + if (this.primaryKey) def.primaryKey = true; + if (this.autoIncrement) def.autoIncrement = true; + return def; +}; + +class StandaloneUser extends Model { + static fields = { + uid: { type: 'string', primaryKey: true }, + passwordHash: { type: 'string', isPrivate: true }, + sshPublicKeys: { type: 'json', default: [] }, + groups: { type: 'json', default: [] }, + }; +} + +module.exports = StandaloneUser; diff --git a/nodejs/models/user_file.js b/nodejs/models/user_file.js new file mode 100644 index 0000000..a9c8a7c --- /dev/null +++ b/nodejs/models/user_file.js @@ -0,0 +1,65 @@ +'use strict'; + +// ORM-backed user store for standalone mode. Implements the same interface as +// the @simpleworkjs/ldap client so ssh_server.js and key_inject.js work +// unchanged: getUser(uid), getGroups(dn), checkPassword(dn, pw), addSshKey(dn, keyLine). +// +// Users are stored via the StandaloneUser ORM model (Sequelize, any dialect). +// DNs are synthetic: uid=,ou=people,dc=standalone,dc=local — the real +// identity is the uid; the DN exists only for interface compatibility with +// callers that thread user.dn through to checkPassword / addSshKey. + +const bcrypt = require('bcrypt'); +const StandaloneUser = require('./standalone_user'); + +const DN_PREFIX = 'uid='; +const DN_SUFFIX = ',ou=people,dc=standalone,dc=local'; + +function dnFor(uid) { + return `${DN_PREFIX}${uid}${DN_SUFFIX}`; +} + +function uidFromDn(dn) { + if (!dn || typeof dn !== 'string') return null; + const m = dn.match(/^uid=([^,]+)/); + return m ? m[1] : null; +} + +async function getUser(uid) { + const user = await StandaloneUser.get(uid); + if (!user) return null; + return { + dn: dnFor(user.uid), + uid: user.uid, + sshPublicKeys: user.sshPublicKeys || [], + }; +} + +async function getGroups(dn) { + const uid = uidFromDn(dn); + if (!uid) return []; + const user = await StandaloneUser.get(uid); + if (!user) return []; + return user.groups || []; +} + +async function checkPassword(dn, pw) { + const uid = uidFromDn(dn); + if (!uid) return false; + const user = await StandaloneUser.get(uid); + if (!user || !user.passwordHash) return false; + return bcrypt.compare(pw, user.passwordHash); +} + +async function addSshKey(dn, keyLine) { + const uid = uidFromDn(dn); + if (!uid) return; + const user = await StandaloneUser.get(uid); + if (!user) return; + const keys = [...(user.sshPublicKeys || [])]; + if (keys.includes(keyLine)) return; // idempotent + keys.push(keyLine); + await user.update({ sshPublicKeys: keys }); +} + +module.exports = { getUser, getGroups, checkPassword, addSshKey }; diff --git a/nodejs/models/user_ldap.js b/nodejs/models/user_ldap.js index 72d0d7e..e27ebbe 100644 --- a/nodejs/models/user_ldap.js +++ b/nodejs/models/user_ldap.js @@ -1,22 +1,23 @@ 'use strict'; -// Thin LDAP helpers — the jump host's entire LDAP surface, now backed by the -// shared @simpleworkjs/ldap package: +// User authentication backend — LDAP in production, ORM-backed file store in +// standalone mode. Both export the same interface: // 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) -// -// Behavior is unchanged from the previous in-tree implementation: posixAccount -// user filter, groupOfNames group filter, bind-as-user password check, -// TypeOrValueExists treated as success on key add, and the same loose TLS -// default ({ rejectUnauthorized: false } when conf.ldap omits tlsOptions). +// getGroups(dn) -> [cn, ...] +// checkPassword(dn, pw) -> bool +// addSshKey(dn, keyLine) -> void (idempotent) const conf = require('@simpleworkjs/conf'); -const { createLdapClient } = require('@simpleworkjs/ldap'); -const ldapConf = conf.ldap || {}; -module.exports = createLdapClient({ - ...ldapConf, - tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false }, -}); \ No newline at end of file +if (conf.standalone && conf.standalone.enabled) { + // Standalone mode: use the ORM-backed user store. + module.exports = require('./user_file'); +} else { + // Production mode: use the LDAP directory. + const { createLdapClient } = require('@simpleworkjs/ldap'); + const ldapConf = conf.ldap || {}; + module.exports = createLdapClient({ + ...ldapConf, + tlsOptions: ldapConf.tlsOptions || { rejectUnauthorized: false }, + }); +} \ No newline at end of file diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index d919f24..db49238 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -15,6 +15,7 @@ "@simpleworkjs/directory-schema": "^1.0.0", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0", + "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", "compression": "^1.8.1", @@ -47,6 +48,18 @@ "node": ">=6" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -189,6 +202,22 @@ "node": ">=18.0.0" } }, + "node_modules/@simpleworkjs/orm": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@simpleworkjs/orm/-/orm-0.2.8.tgz", + "integrity": "sha512-Ds4DW/NfeMGuXJpiNgtoyMBwq2IAcNSkjzNr2tLLpbplV3HpTX+pN2GHqYFoC2Oj3ZVuuUlW9fbET3Wsn3wQog==", + "license": "MIT", + "dependencies": { + "bcrypt": "^6.0.0", + "model-redis": "^1.6.0", + "sequelize": "^6.37.8", + "sqlite3": "^6.0.1", + "uuid": "^11.1.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -204,6 +233,21 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -213,6 +257,12 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -222,6 +272,16 @@ "@types/node": "*" } }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -270,6 +330,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -315,6 +395,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -393,6 +493,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buildcheck": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", @@ -465,6 +589,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -616,6 +749,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -625,6 +782,22 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dottie": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", + "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -669,6 +842,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.9", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", @@ -742,6 +924,16 @@ "node": ">= 0.6" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -787,6 +979,22 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -855,6 +1063,12 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -916,6 +1130,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -977,6 +1197,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1002,6 +1228,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -1072,6 +1305,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1079,12 +1332,27 @@ "dev": true, "license": "ISC" }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -1155,6 +1423,16 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=20" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -1203,6 +1481,12 @@ "node": ">=20" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1258,6 +1542,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -1270,6 +1566,42 @@ "node": ">=10" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/model-redis": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/model-redis/-/model-redis-1.6.0.tgz", @@ -1288,6 +1620,18 @@ "node": "*" } }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1310,6 +1654,12 @@ "license": "MIT", "optional": true }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1319,6 +1669,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-addon-api": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", @@ -1328,6 +1690,31 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -1407,6 +1794,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1487,6 +1890,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1506,6 +1915,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "optional": true, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1526,6 +1972,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -1570,6 +2026,35 @@ "node": ">= 0.10" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1599,6 +2084,12 @@ "node": ">= 20.0.0" } }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1645,7 +2136,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1680,6 +2170,87 @@ "url": "https://opencollective.com/express" } }, + "node_modules/sequelize": { + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/serve-static": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", @@ -1777,6 +2348,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -1874,6 +2490,33 @@ "node": ">= 0.6" } }, + "node_modules/sqlite3": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz", + "integrity": "sha512-X0czUUMG2tmSqJpEQa3tCuZSHKIx8PwM53vLZzKp/o6Rpy25fiVfjdbnZ988M8+O3ZWR1ih0K255VumCb3MAnQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^8.0.0", + "prebuild-install": "^7.1.3", + "tar": "^7.5.10" + }, + "engines": { + "node": ">=20.17.0" + }, + "optionalDependencies": { + "node-gyp": "12.x" + }, + "peerDependencies": { + "node-gyp": "12.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -1906,6 +2549,24 @@ "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", "license": "ISC" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1919,6 +2580,104 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "optional": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1941,6 +2700,12 @@ "node": ">=0.6" } }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, "node_modules/touch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", @@ -1951,6 +2716,18 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -1995,6 +2772,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", @@ -2010,6 +2797,34 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2019,6 +2834,31 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2045,6 +2885,15 @@ "optional": true } } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } } } } diff --git a/nodejs/package.json b/nodejs/package.json index 75348a2..eeb0f01 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-jump-host", - "version": "1.3.0", + "version": "1.4.0", "description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics", "author": [ { @@ -25,6 +25,7 @@ "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/directory-schema": "^1.0.0", "@simpleworkjs/oidc-client": "^1.0.0", + "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", "compression": "^1.8.1", diff --git a/nodejs/services/ssh_server.js b/nodejs/services/ssh_server.js index 7580505..7a5f109 100644 --- a/nodejs/services/ssh_server.js +++ b/nodejs/services/ssh_server.js @@ -147,12 +147,20 @@ async function resolveAndConnect(state, record, { onHostKey } = {}) { 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. + // Register session listeners IMMEDIATELY — before any async work. + // The client sends exec/shell requests right after opening the session; + // if we await audit.create() first, those requests arrive before the + // listeners are registered and ssh2 rejects them with CHANNEL_FAILURE. let resolveUp, rejectUp; const upstreamPromise = new Promise((res, rej) => { resolveUp = res; rejectUp = rej; }); - attachSession(session, upstreamPromise, record); + const dummyAudit = { patch() {}, finish() {}, event: {} }; + attachSession(session, upstreamPromise, dummyAudit); + + const record = await audit.create({ uid: state.uid, authMethod: state.authMethod, clientIp: state.clientIp, mode: 'grammar' }); + // Wire the real audit record into the already-attached session. + dummyAudit.patch = (...a) => record.patch(...a); + dummyAudit.finish = (...a) => record.finish(...a); + Object.defineProperty(dummyAudit, 'event', { get: () => record.event }); try { const { upstream, host, endpoint } = await resolveAndConnect(state, record, { @@ -280,7 +288,7 @@ function start() { } ); - const port = (conf.ssh && conf.ssh.listenPort) || 2222; + const port = (conf.ssh && conf.ssh.listenPort) ?? 2222; const host = (conf.ssh && conf.ssh.listenHost) || '0.0.0.0'; server.listen(port, host, () => { console.log(`[ssh] jump host listening on ${host}:${server.address().port}`); diff --git a/nodejs/test/integration/standalone.test.js b/nodejs/test/integration/standalone.test.js new file mode 100644 index 0000000..36fc945 --- /dev/null +++ b/nodejs/test/integration/standalone.test.js @@ -0,0 +1,232 @@ +'use strict'; + +// End-to-end standalone SSH test: a real downstream sshd, the full jump host +// SSH server (ssh_server.js), and an SSH client. Authentication and host +// discovery use the ORM-backed standalone stores (temp file SQLite). +// +// Follows the same hermetic pattern as ssh_bridge.test.js but exercises the +// full stack: conf → ORM → user_ldap facade → ssh_server → bridge. + +process.env.NODE_ENV = 'test'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { Server, Client, utils } = require('ssh2'); +const bcrypt = require('bcrypt'); +const conf = require('@simpleworkjs/conf'); + +// ── Conf must be set BEFORE any module that checks conf.standalone.enabled ── + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-standalone-')); +const dbPath = path.join(tmpDir, 'test.sqlite'); + +conf.standalone = { enabled: true }; +conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false }; +conf.ssh = { + listenHost: '127.0.0.1', + listenPort: 0, + hostKeyPath: path.join(tmpDir, 'keys'), + passwordAuth: 'all', + keyComment: 'jump-host-test', + defaultPort: 22, + connectTimeoutMs: 5000, + maxSessions: 10, +}; +conf.redis = { prefix: 'jump_host_test_standalone_' }; +conf.audit = { maxEvents: 100 }; + +// ── Require models/index FIRST so it initializes the ORM exactly once. +// This also registers the standalone models. We await ormReady before +// seeding data, then start the SSH server. ── + +const models = require('../../models'); +const StandaloneUser = require('../../models/standalone_user'); +const StandaloneHost = require('../../models/standalone_host'); + +let downstream, downstreamPort, jump, jumpPort; +let testUserKey; + +function startDownstream() { + return new Promise((resolve) => { + const { private: hostKey } = utils.generateKeyPairSync('ed25519'); + const srv = new Server({ hostKeys: [hostKey] }, (client) => { + client.on('authentication', (ctx) => ctx.accept()); + client.on('ready', () => { + client.on('session', (accept) => { + const session = accept(); + session.on('pty', (a) => a && a()); + session.on('shell', (a) => { + const ch = a(); + ch.write('downstream-shell-ready\n'); + ch.on('data', (d) => ch.write('echo:' + d)); + }); + session.on('exec', (a, r, info) => { + const ch = a(); + ch.write(`ran:${info.command}`); + ch.exit(0); + ch.end(); + }); + session.on('subsystem', (a, r, info) => { + if (info.name !== 'sftp') return r && r(); + const ch = a(); + ch.on('data', (d) => ch.write(Buffer.concat([Buffer.from('sftp:'), d]))); + }); + }); + }); + }); + srv.listen(0, '127.0.0.1', () => resolve(srv)); + }); +} + +before(async () => { + // 1. Start downstream. + downstream = await startDownstream(); + downstreamPort = downstream.address().port; + + // 2. Wait for the ORM to finish syncing tables (init was called by models/index + // at require time — we just need the tables to exist before seeding). + await models.ormReady; + + // 3. Seed test data. + const userKeyPair = utils.generateKeyPairSync('ed25519'); + testUserKey = userKeyPair.private; + const userPubKey = utils.parseKey(userKeyPair.private); + const userPubLine = `${userPubKey.type} ${userPubKey.getPublicSSH().toString('base64')} testuser@test`; + + const passwordHash = await bcrypt.hash('testpass', 4); + + await StandaloneUser.create({ + uid: 'testuser', + passwordHash, + sshPublicKeys: [userPubLine], + groups: ['admin'], + }); + + await StandaloneHost.create({ + slug: 'host_test', + displayName: 'Test Downstream', + kind: 'host', + metadata: { address: `ssh://127.0.0.1:${downstreamPort}`, ip: '127.0.0.1', sshPort: downstreamPort }, + }); + + // 4. Start the jump host SSH server. + const sshServer = require('../../services/ssh_server'); + jump = sshServer.start(); + await new Promise((resolve) => { + const check = () => { + const addr = jump.address(); + if (addr) { jumpPort = addr.port; resolve(); } + else setTimeout(check, 10); + }; + check(); + }); +}); + +after(() => { + try { downstream && downstream.close(); } catch (_) {} + try { jump && jump.close(); } catch (_) {} + try { models.redisClient.destroy(); } catch (_) {} + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} +}); + +process.on('unhandledRejection', () => {}); + +function connectJump(opts = {}) { + const conn = new Client(); + const connectOpts = { + host: '127.0.0.1', + port: jumpPort, + username: opts.username || 'testuser_-_host_test', + ...opts, + }; + return { + conn, + ready: new Promise((res, rej) => { + conn.on('ready', res).on('error', rej).connect(connectOpts); + }), + }; +} + +// ── Tests ── + +test('public key auth + grammar mode exec', async () => { + const { conn, ready } = connectJump({ privateKey: testUserKey }); + await ready; + const out = await new Promise((resolve, reject) => { + conn.exec('hello-world', (err, stream) => { + if (err) return reject(err); + let buf = ''; + stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf)); + }); + }); + conn.end(); + assert.match(out, /ran:hello-world/); +}); + +test('public key auth + grammar mode shell', async () => { + const { conn, ready } = connectJump({ privateKey: testUserKey }); + await ready; + const out = await new Promise((resolve, reject) => { + conn.shell((err, stream) => { + if (err) return reject(err); + let buf = ''; + stream.on('data', (d) => { + buf += d; + if (buf.includes('echo:ping')) resolve(buf); + }); + setTimeout(() => stream.write('ping'), 150); + setTimeout(() => resolve(buf), 5000); + }); + }); + conn.end(); + assert.match(out, /downstream-shell-ready/); + assert.match(out, /echo:ping/); +}); + +test('password auth + grammar mode exec', async () => { + const { conn, ready } = connectJump({ + username: 'testuser_-_host_test', + password: 'testpass', + }); + await ready; + const out = await new Promise((resolve, reject) => { + conn.exec('pw-test', (err, stream) => { + if (err) return reject(err); + let buf = ''; + stream.on('data', (d) => { buf += d; }).on('close', () => resolve(buf)); + }); + }); + conn.end(); + assert.match(out, /ran:pw-test/); +}); + +test('password auth denied with wrong password', async () => { + const conn = new Client(); + const result = await new Promise((resolve) => { + conn.on('ready', () => resolve('unexpected-ready')); + conn.on('error', () => resolve('auth-failed')); + conn.connect({ + host: '127.0.0.1', port: jumpPort, + username: 'testuser_-_host_test', + password: 'wrongpass', + }); + }); + assert.strictEqual(result, 'auth-failed'); +}); + +test('unknown user rejected', async () => { + const conn = new Client(); + const result = await new Promise((resolve) => { + conn.on('ready', () => resolve('unexpected-ready')); + conn.on('error', () => resolve('auth-failed')); + conn.connect({ + host: '127.0.0.1', port: jumpPort, + username: 'nobody_-_host_test', + password: 'testpass', + }); + }); + assert.strictEqual(result, 'auth-failed'); +}); diff --git a/nodejs/test/unit/hosts_file.test.js b/nodejs/test/unit/hosts_file.test.js new file mode 100644 index 0000000..7a7a1f2 --- /dev/null +++ b/nodejs/test/unit/hosts_file.test.js @@ -0,0 +1,87 @@ +'use strict'; + +// Unit tests for the ORM-backed host inventory (utils/hosts_file.js). +// Uses a temp file SQLite database — no external services needed. + +process.env.NODE_ENV = 'test'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const conf = require('@simpleworkjs/conf'); +const { init } = require('@simpleworkjs/orm'); +const StandaloneHost = require('../../models/standalone_host'); + +let tmpDir; +let hostsFile; // required after ORM init + +before(async () => { + // Unique temp DB so this test file doesn't collide with other ORM tests. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-hostfile-')); + const dbPath = path.join(tmpDir, 'test.sqlite'); + + conf.standalone = { enabled: true }; + conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false }; + + await init({ conf: { orm: conf.orm }, models: [StandaloneHost] }); + + await StandaloneHost.create({ + slug: 'host_web01', + displayName: 'Web Server 01', + kind: 'host', + metadata: { address: 'ssh://10.0.0.10:22', ip: '10.0.0.10', sshPort: 22 }, + }); + await StandaloneHost.create({ + slug: 'host_db', + displayName: 'Database Server', + kind: 'host', + metadata: { address: 'ssh://10.0.0.20:22', ip: '10.0.0.20', sshPort: 22 }, + }); + await StandaloneHost.create({ + slug: 'app_gitea', + displayName: 'Gitea', + kind: 'service', + metadata: { url: 'https://gitea.internal' }, + }); + + hostsFile = require('../../utils/hosts_file'); +}); + +after(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} +}); + +test('accessibleHosts returns all hosts', async () => { + const hosts = await hostsFile.accessibleHosts({ uid: 'alice' }); + assert.strictEqual(hosts.length, 2); + const slugs = hosts.map((h) => h.slug).sort(); + assert.deepStrictEqual(slugs, ['host_db', 'host_web01']); +}); + +test('accessibleHosts filters to kind=host', async () => { + const hosts = await hostsFile.accessibleHosts({ uid: 'alice' }); + const kinds = [...new Set(hosts.map((h) => h.kind))]; + assert.deepStrictEqual(kinds, ['host']); +}); + +test('accessibleHosts returns host resources with expected shape', async () => { + const hosts = await hostsFile.accessibleHosts({ uid: 'alice' }); + const web = hosts.find((h) => h.slug === 'host_web01'); + assert.ok(web); + assert.strictEqual(web.id, 'host_web01'); + assert.strictEqual(web.displayName, 'Web Server 01'); + assert.strictEqual(web.metadata.ip, '10.0.0.10'); + assert.strictEqual(web.metadata.sshPort, 22); +}); + +test('accessibleHosts returns empty array when no hosts exist', async () => { + // Delete all hosts and verify empty result. + const all = await StandaloneHost.list(); + for (const h of all) { + await h.delete({ force: true }); + } + const hosts = await hostsFile.accessibleHosts({ uid: 'alice' }); + assert.deepStrictEqual(hosts, []); +}); diff --git a/nodejs/test/unit/user_file.test.js b/nodejs/test/unit/user_file.test.js new file mode 100644 index 0000000..398aaaf --- /dev/null +++ b/nodejs/test/unit/user_file.test.js @@ -0,0 +1,113 @@ +'use strict'; + +// Unit tests for the ORM-backed user store (models/user_file.js). +// Uses a temp file SQLite database — no external services needed. + +process.env.NODE_ENV = 'test'; + +const { test, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const bcrypt = require('bcrypt'); +const conf = require('@simpleworkjs/conf'); +const { init } = require('@simpleworkjs/orm'); +const StandaloneUser = require('../../models/standalone_user'); + +let testPasswordHash; +let tmpDir; +let userFile; // required after ORM init + +before(async () => { + // Unique temp DB so this test file doesn't collide with other ORM tests. + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jump-host-test-userfile-')); + const dbPath = path.join(tmpDir, 'test.sqlite'); + + conf.standalone = { enabled: true }; + conf.orm = { dialect: 'sqlite', storage: dbPath, logging: false }; + + await init({ conf: { orm: conf.orm }, models: [StandaloneUser] }); + + testPasswordHash = await bcrypt.hash('testpass', 4); + + await StandaloneUser.create({ + uid: 'alice', + passwordHash: testPasswordHash, + sshPublicKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'], + groups: ['admin', 'developers'], + }); + + // Now that the ORM is initialized and conf.standalone is set, require the + // facade. It checks conf.standalone.enabled at require time. + userFile = require('../../models/user_file'); +}); + +after(() => { + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {} +}); + +test('getUser returns user with synthesized dn and keys', async () => { + const user = await userFile.getUser('alice'); + assert.ok(user); + assert.strictEqual(user.uid, 'alice'); + assert.strictEqual(user.dn, 'uid=alice,ou=people,dc=standalone,dc=local'); + assert.deepStrictEqual(user.sshPublicKeys, ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop']); +}); + +test('getUser returns null for unknown uid', async () => { + const user = await userFile.getUser('nobody'); + assert.strictEqual(user, null); +}); + +test('getGroups returns user groups', async () => { + const groups = await userFile.getGroups('uid=alice,ou=people,dc=standalone,dc=local'); + assert.deepStrictEqual(groups, ['admin', 'developers']); +}); + +test('getGroups returns empty array for unknown dn', async () => { + const groups = await userFile.getGroups('uid=nobody,ou=people,dc=standalone,dc=local'); + assert.deepStrictEqual(groups, []); +}); + +test('getGroups returns empty array for malformed dn', async () => { + const groups = await userFile.getGroups('not-a-dn'); + assert.deepStrictEqual(groups, []); +}); + +test('checkPassword returns true for correct password', async () => { + const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'testpass'); + assert.strictEqual(ok, true); +}); + +test('checkPassword returns false for wrong password', async () => { + const ok = await userFile.checkPassword('uid=alice,ou=people,dc=standalone,dc=local', 'wrongpass'); + assert.strictEqual(ok, false); +}); + +test('checkPassword returns false for unknown user', async () => { + const ok = await userFile.checkPassword('uid=nobody,ou=people,dc=standalone,dc=local', 'testpass'); + assert.strictEqual(ok, false); +}); + +test('addSshKey appends a new key', async () => { + const newKey = 'ssh-rsa AAAAB3NzaC1yc2E... bob@desktop'; + await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', newKey); + + const user = await StandaloneUser.get('alice'); + assert.ok(user.sshPublicKeys.includes(newKey)); +}); + +test('addSshKey is idempotent', async () => { + const key = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... alice@laptop'; + const userBefore = await StandaloneUser.get('alice'); + const countBefore = userBefore.sshPublicKeys.length; + await userFile.addSshKey('uid=alice,ou=people,dc=standalone,dc=local', key); + const userAfter = await StandaloneUser.get('alice'); + assert.strictEqual(userAfter.sshPublicKeys.length, countBefore); +}); + +test('addSshKey is a no-op for unknown user', async () => { + // Should not throw. + await userFile.addSshKey('uid=nobody,ou=people,dc=standalone,dc=local', 'ssh-rsa AAA...'); +}); diff --git a/nodejs/utils/access.js b/nodejs/utils/access.js index 26afc4c..b2f4630 100644 --- a/nodejs/utils/access.js +++ b/nodejs/utils/access.js @@ -1,70 +1,84 @@ '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= 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. +// Host discovery — SSO Manager API in production, ORM-backed inventory in +// standalone mode. Both export the same interface: +// accessibleHosts(user) -> [host resources] +// clearCache(uid?) -> void const conf = require('@simpleworkjs/conf'); -const { createDirectoryClient } = require('@simpleworkjs/directory-schema'); -const userLdap = require('../models/user_ldap'); -const CACHE_TTL_MS = 30 * 1000; -const cache = new Map(); // uid -> {at, hosts} +if (conf.standalone && conf.standalone.enabled) { + // Standalone mode: use the ORM-backed host inventory. + const { accessibleHosts } = require('./hosts_file'); + module.exports = { accessibleHosts, clearCache: () => {} }; +} else { + // Production mode: LDAP groups + SSO API (unchanged). -// Build a directory client bound to conf.sso. fetchImpl is injectable so the -// unit tests can stub the transport; the shared client validates the -// `{ results }` envelope on every call (turns the old bare-array drift into a -// thrown error instead of a silent `[]`). -function directoryClient({ fetchImpl = fetch } = {}) { - const sso = conf.sso || {}; - return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl }); -} + // 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= per group (ApiToken) + // 3. union, keep kind === 'host' + // + // Results are cached per-uid for a short TTL — the TUI picker and the + // username-grammar path share the cache. Dependency-injected fetch/ldap for + // unit testing. -async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) { - return directoryClient({ fetchImpl }).getResourcesByGroup(group); -} + const { createDirectoryClient } = require('@simpleworkjs/directory-schema'); + const userLdap = require('../models/user_ldap'); -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 CACHE_TTL_MS = 30 * 1000; + const cache = new Map(); // uid -> {at, 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); - } + // Build a directory client bound to conf.sso. fetchImpl is injectable so the + // unit tests can stub the transport; the shared client validates the + // `{ results }` envelope on every call (turns the old bare-array drift into a + // thrown error instead of a silent `[]`). + function directoryClient({ fetchImpl = fetch } = {}) { + const sso = conf.sso || {}; + return createDirectoryClient({ baseUrl: sso.url, apiToken: sso.apiToken, fetch: fetchImpl }); } - const hosts = [...seen.values()]; - cache.set(user.uid, { at: Date.now(), hosts }); - return hosts; -} + async function fetchResourcesByGroup(group, { fetchImpl = fetch } = {}) { + return directoryClient({ fetchImpl }).getResourcesByGroup(group); + } -function clearCache(uid) { - if (uid) cache.delete(uid); - else cache.clear(); -} + 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; -module.exports = { accessibleHosts, clearCache, fetchResourcesByGroup }; + 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 }; +} diff --git a/nodejs/utils/hosts_file.js b/nodejs/utils/hosts_file.js new file mode 100644 index 0000000..37b6c1e --- /dev/null +++ b/nodejs/utils/hosts_file.js @@ -0,0 +1,30 @@ +'use strict'; + +// ORM-backed host inventory for standalone mode. Implements the same interface +// as utils/access.js so ssh_server.js works unchanged: accessibleHosts(user) +// returns an array of host resources the user may reach. +// +// In standalone mode all hosts in the inventory are accessible to every +// authenticated user — there is no group-based filtering. The _user parameter +// is accepted for interface compatibility but ignored. + +const StandaloneHost = require('../models/standalone_host'); + +async function accessibleHosts(_user) { + const hosts = await StandaloneHost.list({ where: { kind: 'host' } }); + // The ORM returns model instances; map to plain objects matching the shape + // that target_match.js and tui_picker.js expect. + return hosts.map((h) => ({ + id: h.slug, // slug doubles as the stable id in standalone mode + kind: h.kind, + slug: h.slug, + displayName: h.displayName, + metadata: h.metadata || {}, + })); +} + +function clearCache() { + // No cache in standalone mode — every call reads from the DB. +} + +module.exports = { accessibleHosts, clearCache }; diff --git a/secrets.js.example b/secrets.js.example index 317a069..7676caf 100644 --- a/secrets.js.example +++ b/secrets.js.example @@ -11,7 +11,24 @@ module.exports = { name: 'My Org', + // Standalone mode: run with no LDAP directory and no SSO Manager. When + // enabled, user auth and host discovery use the ORM-backed stores below + // instead of `ldap` + `sso` (both become unused). See the README's + // "Standalone mode" section for how to add users/hosts. + standalone: { + enabled: false, + }, + + // ORM config for standalone mode (Sequelize — any dialect works, not just + // sqlite). Ignored unless standalone.enabled is true. + orm: { + dialect: 'sqlite', + storage: './data/standalone.sqlite', + logging: false, + }, + // The directory the users live in (the SSO Manager's OpenLDAP). + // Unused when standalone.enabled is true. // // IMPORTANT: bindDN needs, beyond read on ou=people + ou=groups, WRITE on // the sshPublicKey attribute of user entries — the jump host injects its @@ -36,6 +53,7 @@ module.exports = { // SSO Manager directory (inventory) API. apiToken is a personal access // token (sso__) of any user that can read /api/discovery/*. + // Unused when standalone.enabled is true. sso: { url: 'https://sso.example.com', apiToken: 'sso_CHANGE_ME',