Compare commits

..

1 Commits

Author SHA1 Message Date
wmantly 3d32fb3044 Convert Permissions/Users/Groups pages to table layouts
- Permissions: table with Subject, Scope, Domain, Role, Actions columns
- Users: table with Username, Auth Type, Password, Actions columns; per-field validation errors
- Groups: auto-refresh after create/remove operations
2026-07-31 14:25:05 -04:00
12 changed files with 361 additions and 411 deletions
-45
View File
@@ -1,9 +1,3 @@
## v1.13.3
- fix: remove missing DEPLOYMENT.md and docs/ from Docker build context
## v1.13.2
- chore: Update CI pipeline integration
# Changelog
All notable changes to this project are documented here. Format loosely
@@ -12,45 +6,6 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
## [1.13.1] - 2026-08-01
### Fixed
- **Bumped `@simpleworkjs/bao-conf` to 1.0.1** so standalone/no-OpenBao boots
don't crash. bao-conf 1.0.0's `init()` threw when `VAULT_TOKEN` was unset,
which — combined with `bin/www`'s `.catch(() => process.exit(1))` — made the
proxy exit at boot in any deployment without an OpenBao sidecar (standalone
Docker, bare metal). 1.0.1 makes `init()` fail-soft on a missing token (warn
+ continue from `CONF_SECRETS`), matching the documented contract. The
theta-env stack is unaffected (it always sets a scoped `VAULT_TOKEN`).
## [1.13.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/proxy/conf` over the file-loaded config. The proxy
authenticates to OpenBao with a scoped `VAULT_TOKEN` (policy `proxy`
read-only on its own path), never the root token. Because the OIDC
`clientSecret` is captured at require time inside `createOidcClient` (during
`require('../models')`, which `require('../app')` triggers transitively),
`bin/www` now defers `require('../app')` until after `bao-conf.init()`
resolves. Fail-soft: if OpenBao is unreachable, boot continues from
`CONF_SECRETS`. The `config/proxy-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.12.1] - 2026-08-01
### Changed
- Bumped `body-parser` 2.2.2 → 2.3.0 (Dependabot #175).
- Bumped `ejs` and `brace-expansion` (Dependabot #179, security maintenance).
## [1.12.0] - 2026-08-01
### Fixed
- Changed UNIX socket permission in `unix_socket_json.js` to `666` so OpenResty Nginx workers running as `nobody` can resolve targets properly.
## [1.9.0] - 2026-07-30
### Added
+2
View File
@@ -115,6 +115,8 @@ COPY nodejs/api.md ./api.md
# docs/ mirrors the repo's own top-level docs/ folder.
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit
-17
View File
@@ -162,23 +162,6 @@ docker compose exec proxy tail -f /var/log/nginx/error.log
docker compose logs --tail=200 --since=10m proxy
```
## Secrets
Secrets are loaded from **OpenBao** at boot via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/proxy/conf` over the file-loaded config. The proxy's OIDC
`clientSecret` is captured at require time (inside `createOidcClient` during
`require('../models')`), so `bin/www` runs `bao-conf.init()` **before**
`require('../app')` (which transitively loads models). Fail-soft: if OpenBao is
unreachable, boot continues from `CONF_SECRETS`. The proxy authenticates to
OpenBao with the scoped `VAULT_TOKEN` (env, policy `proxy` — read only
`secret/proxy/conf`), never the root token.
The `config/proxy-secrets.js` file is an operator-edit seed artifact
(gitignored); the bootstrap writes the generated OAuth client creds into
OpenBao, which is authoritative. For the full architecture see theta-env's
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
## Manual Installation
For manual installation or other distributions, see the detailed steps below.
+86
View File
@@ -0,0 +1,86 @@
'use strict';
// Example secrets configuration for the theta42/proxy.
//
// The proxy is an OIDC client of an SSO Manager (or any OIDC provider) AND a
// direct LDAP client for user lookups. This file supplies that wiring.
//
// Docker / unified stack: place at ./config/proxy-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points the
// CONF_SECRETS env var at it so @simpleworkjs/conf reads it. No app_* env
// should be passed — app_* env beats this file in @simpleworkjs/conf, so the
// file is authoritative only if the matching app_* env is absent.
//
// Bare-metal: ops/install.sh seeds this file at /etc/proxy/secrets.js on first
// run (with placeholders for the values it can't guess) and points the
// systemd unit's CONF_SECRETS env var at it. Fill in your values, then
// `sudo systemctl restart proxy`. Values here override conf/base.js and win
// over <environment>.js.
//
// Only the keys the app reads are listed below. The `stack` key is read by the
// theta-env orchestrator (setup.sh) and ignored by the app.
module.exports = {
name: 'Dynamic Proxy', // shown in the UI
logo: '/static/img/theta42.svg', // nav image; point at your own file under public/ to white-label
// OpenID Connect — point at your SSO Manager. Issuer + authorization/
// endSession are browser-facing URLs; token/userinfo can be the internal
// URL if the SSO is on the same docker network (avoids a TLS hairpin).
oidc: {
enabled: true,
issuer: 'https://sso.example.com',
authorizationEndpoint: 'https://sso.example.com/oauth/authorize',
tokenEndpoint: 'http://sso-manager:3001/oauth/token',
userinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo',
endSessionEndpoint: 'https://sso.example.com/oauth/logout',
clientId: '391136c8-9631-47c4-aac6-d6b760b7a9ae', // registered on the SSO
clientSecret: 'b29cce9c-de0c-4acc-b76a-494168f0381d', // from the SSO client record
redirectUri: 'https://proxy.example.com/api/auth/oidc/callback',
scopes: ['openid', 'profile', 'email', 'groups'],
groupsClaim: 'groups',
usernameClaim: 'preferred_username',
},
// Direct LDAP user lookups. ldaps:// + rejectUnauthorized:false for a
// self-signed cert (the SSO's default), or set tlsOptions.ca to a CA path
// for strict verification. bindPassword MUST match the
// serviceAccountPass in the SSO's sso-secrets.js (the proxy binds as that
// service account).
ldap: {
url: 'ldaps://sso-manager:636',
bindDN: 'cn=ldapclient,ou=people,dc=example,dc=com',
bindPassword: 'proxy-service-pass',
searchBase: 'ou=people,dc=example,dc=com',
userFilter: '(objectClass=inetOrgPerson)',
userNameAttribute: 'uid',
tlsOptions: {
rejectUnauthorized: false, // true + ca for a CA-signed cert
},
},
// Authorization. adminUsers is the local anti-lockout admin (matches
// auth.adminUsers in conf/base.js). adminGroups: SSO/LDAP groups whose
// members are always global admins.
auth: {
adminGroups: [],
adminUsers: ['proxyadmin'],
groupRoleMap: {},
// Optional: the local anti-lockout admin's initial password, used
// ONLY the first time that account is created. Leave unset and it
// defaults to the username itself ("proxyadmin2") — fine for a quick
// local test, but change it (or set this) before exposing the proxy
// publicly. Once the account exists, this key is never read again;
// change the password via the app itself (or delete the Redis user
// to force it to be re-bootstrapped with a new value here).
localAdminPass: 'proxyadmin-test-pass',
},
// ── Orchestrator-only (ignored by the app) ───────────────────────────────
// Read by the theta-env setup.sh (e.g. to seed the OAuth client). Omit for
// bare-metal use.
stack: {
ssoHost: 'sso.example.com', // public SSO hostname
proxyHost: 'proxy.example.com', // public proxy hostname
},
};
+65 -78
View File
@@ -4,91 +4,34 @@
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('proxy-api:server');
var http = require('http');
const conf = require('@simpleworkjs/conf');
const debug = require('debug')('proxy-api:server');
const http = require('http');
// @simpleworkjs/conf loads ./config/proxy-secrets.js synchronously, then
// @simpleworkjs/bao-conf deep-merges secret/proxy/conf from OpenBao over it.
// The OIDC clientSecret is captured at require time inside models (via
// createOidcClient), and require('../app') transitively loads models, so the
// OpenBao fetch MUST resolve before require('../app'). Fail-soft: if OpenBao
// is unreachable, init() leaves conf as the file-loaded fallback and boot
// continues from ./config/proxy-secrets.js.
require('@simpleworkjs/bao-conf').init({ path: 'proxy', conf }).then(() => {
var app = require('../app'); // models + createOidcClient now see merged conf
/**
* Get port from environment and store in Express.
*/
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
app.set('port', port);
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
/**
* Create HTTP server.
*/
var server = http.createServer(app);
var server = http.createServer(app);
var io = require('socket.io')(server);
app.io = io;
var io = require('socket.io')(server);
app.io = io;
/**
* Listen on provided port, on all network interfaces.
*/
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
console.log('Listening on ' + bind);
for(let listener of app.onListen){
listener()
}
}
}).catch(err => {
console.error('boot failed:', err);
process.exit(1);
});
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
@@ -108,4 +51,48 @@ function normalizePort(val) {
}
return false;
}
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
console.log('Listening on ' + bind);
for(let listener of app.onListen){
listener()
}
}
+1 -73
View File
@@ -3,7 +3,6 @@
const crypto = require("crypto");
const conf = require('@simpleworkjs/conf');
const baoConf = require('@simpleworkjs/bao-conf');
const Table = require('.');
const ModelPs = require('../utils/model_pubsub');
@@ -140,26 +139,11 @@ class DnsProvider extends Table{
let __intraModel = this.__intraModel(data.dnsProvider);
Provider = __intraModel.Provider;
if (!data.id) data.id = crypto.randomBytes(8).toString("hex");
let secrets = {};
for (let key in Provider._keyMap) {
if (Provider._keyMap[key].isPrivate && data[key] !== undefined) {
secrets[key] = data[key];
}
}
// This is here test if the given API key is valid
let provider = new __intraModel.Provider(data, ...args);
let domains = await provider.listDomains();
for (let key in secrets) data[key] = '********';
let instance = await super.create.call(__intraModel, data, ...args);
if (Object.keys(secrets).length > 0) {
await baoConf.set(`proxy/dns-providers/${instance.id}`, secrets);
}
try{
await instance.updateDomains(domains);
}catch(updateError){
@@ -205,63 +189,7 @@ class DnsProvider extends Table{
let instance = await super.get(data, ...args);
let __intraModel = this.__intraModel(instance.dnsProvider);
let resolved = await super.get.call(__intraModel, data, ...args);
try {
let secrets = await baoConf.get(`proxy/dns-providers/${resolved.id}`);
if (secrets) Object.assign(resolved, secrets);
} catch(e) {}
return resolved;
}
static async findall(...args){
let instances = await super.findall(...args);
for (let inst of instances) {
try {
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
if (secrets) Object.assign(inst, secrets);
} catch(e) {}
}
return instances;
}
static async find(...args){
let instances = await super.find(...args);
for (let inst of instances) {
try {
let secrets = await baoConf.get(`proxy/dns-providers/${inst.id}`);
if (secrets) Object.assign(inst, secrets);
} catch(e) {}
}
return instances;
}
async update(data){
let Provider = this.constructor.Provider || providers[this.dnsProvider];
let secrets = {};
if (Provider) {
for (let key in Provider._keyMap) {
if (Provider._keyMap[key].isPrivate && data[key] !== undefined && data[key] !== '********') {
secrets[key] = data[key];
data[key] = '********';
} else if (Provider._keyMap[key].isPrivate && data[key] === '********') {
delete data[key]; // Do not update the masked value if it's sent back
}
}
}
let res = await super.update(data);
if (Object.keys(secrets).length > 0) {
let existing = await baoConf.get(`proxy/dns-providers/${this.id}`) || {};
await baoConf.set(`proxy/dns-providers/${this.id}`, { ...existing, ...secrets });
Object.assign(this, secrets);
}
return res;
}
async remove(...args){
await baoConf.request('DELETE', `proxy/dns-providers/${this.id}`).catch(()=>{});
return await super.remove(...args);
return await super.get.call(__intraModel, data, ...args);
}
static listProviders(){
+93 -68
View File
@@ -1,18 +1,17 @@
{
"name": "proxy-api",
"version": "1.13.3",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.13.3",
"version": "1.7.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
@@ -22,7 +21,7 @@
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^6.0.1",
"ejs": "^3.1.10",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
@@ -298,18 +297,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
@@ -459,6 +446,12 @@
"node": ">=12.0.0"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -524,20 +517,20 @@
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
@@ -547,19 +540,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bootstrap": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
@@ -580,16 +560,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -868,15 +848,18 @@
"license": "MIT"
},
"node_modules/ejs": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.12.18"
"node": ">=0.10.0"
}
},
"node_modules/encodeurl": {
@@ -1088,6 +1071,42 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1464,6 +1483,23 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jq-repeat": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
@@ -1770,6 +1806,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@@ -2271,34 +2313,17 @@
"license": "0BSD"
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.6"
}
},
"node_modules/undefsafe": {
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "proxy-api",
"version": "1.14.3",
"version": "1.9.0",
"author": [
{
"name": "William Mantly",
@@ -22,7 +22,6 @@
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/frontend": "^0.2.7",
"@simpleworkjs/ldap": "^1.0.0",
@@ -32,7 +31,7 @@
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
"compression": "^1.8.1",
"ejs": "^6.0.1",
"ejs": "^3.1.10",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"extend": "^3.0.2",
@@ -1,67 +0,0 @@
const { describe, test, beforeEach, afterEach, after, mock } = require('node:test');
const assert = require('node:assert');
const crypto = require('crypto');
const baoConf = require('@simpleworkjs/bao-conf');
const Table = require('../../models/index');
const DnsProvider = Table.models.DnsProvider;
const DuckDns = require('../../models/dns_provider/duckdns');
describe('DnsProvider Vault Integration', () => {
let originalSet, originalGet, originalRequest;
after(async () => {
if (Table._redis && Table._redis.quit) {
await Table._redis.quit();
}
});
beforeEach(() => {
// Mock baoConf
originalSet = baoConf.set;
originalGet = baoConf.get;
originalRequest = baoConf.request;
const vaultStore = {};
baoConf.set = mock.fn(async (path, data) => { vaultStore[path] = data; return true; });
baoConf.get = mock.fn(async (path) => vaultStore[path] || {});
baoConf.request = mock.fn(async () => ({}));
mock.method(DuckDns.prototype, 'listDomains', async () => []);
mock.method(DnsProvider.prototype, 'updateDomains', async () => {});
});
afterEach(() => {
baoConf.set = originalSet;
baoConf.get = originalGet;
baoConf.request = originalRequest;
mock.restoreAll();
});
test('create() writes isPrivate keys to OpenBao and get() retrieves them', async () => {
const payload = {
name: 'My Duck',
dnsProvider: 'DuckDns',
token: 'super-secret-vault-token',
subdomains: 'myduck',
created_by: 'admin'
};
const instance = await DnsProvider.create(payload);
// 1. Should have called OpenBao set
assert.strictEqual(baoConf.set.mock.callCount(), 1);
const [path, secrets] = baoConf.set.mock.calls[0].arguments;
assert.strictEqual(path, `proxy/dns-providers/${instance.id}`);
assert.deepStrictEqual(secrets, { token: 'super-secret-vault-token' });
// 2. The returned instance should have the secret injected back
assert.strictEqual(instance.token, 'super-secret-vault-token');
// 3. get() should fetch public data from Redis and merge secrets from OpenBao
// (baoConf.get is already mocked to return from vaultStore)
const fetched = await DnsProvider.get(instance.id);
assert.strictEqual(fetched.token, 'super-secret-vault-token');
});
});
+15 -3
View File
@@ -33,6 +33,14 @@
});
}
function refreshGroups(){
$.scope.LocalGroup.empty();
app.group.list(function(error, data){
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
for(let g of data.results) $.scope.LocalGroup.push(g);
});
}
function removeMember(group, username){
app.group.removeMember(group, username, function(error, data){
if(error) return app.messages.action(error, $.scope.LocalGroup.$this, 'danger');
@@ -68,12 +76,16 @@
app.subscribe(/^model:LocalGroup:create/, function(data){
$.scope.LocalGroup.remove(data.name);
$.scope.LocalGroup.unshift(data);
});
app.subscribe(/^model:LocalGroup:update/, function(data, topic){
$.scope.LocalGroup.update(topic.split(':')[3], data);
// Also refresh to ensure the full list is up to date
setTimeout(refreshGroups, 500);
});
app.subscribe(/^model:LocalGroup:remove/, function(data, topic){
$.scope.LocalGroup.remove(topic.split(':')[3]);
// Also refresh to ensure the full list is up to date
setTimeout(refreshGroups, 500);
});
app.subscribe(/^model:LocalGroup:update/, function(data, topic){
$.scope.LocalGroup.update(topic.split(':')[3], data);
});
});
</script>
+32 -26
View File
@@ -9,12 +9,9 @@
font-weight: bold;
margin-bottom: 1px;
}
.card-title{
font-weight: bold;
}
.field-hint{
font-size: .8rem;
}
.card-title{ font-weight: bold; }
.member-pill{ cursor: default; }
.member-pill i{ cursor: pointer; }
</style>
<script type="text/javascript">
@@ -142,28 +139,37 @@
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="permission-cards">
<div class="col" jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="mb-2">
<span class="badge text-bg-secondary">{{ subjectType }}</span>
{{ subject }}
</h6>
<dl class="row mb-2 small">
<dt class="col-4">Scope</dt><dd class="col-8">{{ scope }}</dd>
<dt class="col-4">Domain</dt><dd class="col-8">{{ domain }}</dd>
<dt class="col-4">Role</dt><dd class="col-8">{{ role }}</dd>
</dl>
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Subject</th>
<th>Scope</th>
<th>Domain</th>
<th>Role</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="permission-cards">
<tr jq-repeat="Permission" jq-repeat-index="id" id="permission-row-{{id}}" style="display:none">
<td>
<span class="badge text-bg-secondary">{{ subjectType }}</span>
{{ subject }}
</td>
<td>{{ scope }}</td>
<td>{{ domain }}</td>
<td>{{ role }}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-danger" onclick="removePermission('{{id}}')">
<i class="fa-solid fa-trash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
+65 -31
View File
@@ -58,18 +58,38 @@
+ '<div class="form-group">'
+ '<label class="control-label">User-name</label>'
+ '<input type="text" class="form-control" name="username" placeholder="Letter, numbers, -, _, . and @ only" validate="user:3" />'
+ '<div class="invalid-feedback d-none" data-field-error="username"></div>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Password</label>'
+ '<input type="password" class="form-control" name="password" placeholder="8+ chars; mix upper/lower/number/symbol (or 12+)" validate="password"/>'
+ '<div class="invalid-feedback d-none" data-field-error="password"></div>'
+ '</div>'
+ '<div class="form-group">'
+ '<label class="control-label">Again</label>'
+ '<input type="password" class="form-control" name="passwordMatch" placeholder="Retype password" validate="eq:password"/>'
+ '<div class="invalid-feedback d-none" data-field-error="passwordMatch"></div>'
+ '</div>'
+ '<hr />'
+ '<button type="submit" class="btn btn-info">Add</button>'
+ '</form>',
onValidationError: function(errors){
// Clear all field errors first
$('[data-field-error]').addClass('d-none').text('');
$('.form-control.is-invalid').removeClass('is-invalid');
// Show action message at top
let errorMsg = 'Please fix the following errors:';
for(let field in errors){
let $field = $('[name="' + field + '"]');
$field.addClass('is-invalid');
$field.siblings('[data-field-error]').removeClass('d-none').text(errors[field]);
errorMsg += ' ' + field + ': ' + errors[field] + ';';
}
$('.modal-body .actionMessage').first().length ?
$('.modal-body .actionMessage').first().text(errorMsg).removeClass('d-none').addClass('alert alert-danger') :
app.messages.action(errorMsg, $('.modal-body'), 'danger');
}
});
}
@@ -108,39 +128,53 @@
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<div class="row row-cols-1 row-cols-lg-2 g-3" id="user-cards">
<div class="col" jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
<div class="card shadow-sm h-100">
<div class="card-body">
<h6 class="d-flex align-items-center mb-2">
<i class="fa-solid fa-user me-2"></i>
{{ username }}
{{#isExternal}}
<span class="badge text-bg-secondary ms-2" title="Provisioned via SSO login; no local password to manage here.">
<i class="fa-solid fa-cloud"></i> External (SSO)
</span>
{{/isExternal}}
</h6>
{{^isExternal}}
<form class="input-group input-group-sm mb-2" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
<button class="btn btn-warning" type="submit">Change</button>
</form>
{{/isExternal}}
{{#isExternal}}
<p class="text-muted small mb-2">Authenticates via SSO -- cannot be edited here.</p>
{{/isExternal}}
<button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
<i class="fa-solid fa-user-slash"></i>
Delete
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Username</th>
<th>Auth Type</th>
<th>Password</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="user-cards">
<tr jq-repeat="users" jq-repeat-index="username" id="user-row-{{username}}" style="display:none">
<td>
<strong>{{ username }}</strong>
</td>
<td>
{{#isExternal}}
<span class="badge text-bg-secondary" title="Provisioned via SSO login; no local password to manage here.">
<i class="fa-solid fa-cloud"></i> External (SSO)
</span>
{{/isExternal}}
{{^isExternal}}
<span class="badge text-bg-primary">Local</span>
{{/isExternal}}
</td>
<td>
{{^isExternal}}
<form class="input-group input-group-sm" style="max-width: 350px;" action="user/password/{{ username }}" method="put" onsubmit="formAJAX(this)">
<input type="password" name="password" class="form-control" placeholder="Change password" aria-label="Update password">
<button class="btn btn-warning" type="submit">Change</button>
</form>
{{/isExternal}}
{{#isExternal}}
<span class="text-muted">Authenticates via SSO — cannot be edited here.</span>
{{/isExternal}}
</td>
<td>
<button type="button" class="btn btn-sm btn-danger" onclick="removeUser('{{username}}')">
<i class="fa-solid fa-user-slash"></i>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>