Compare commits

...

15 Commits

Author SHA1 Message Date
wmantly 62cdaa2cdd Merge pull request #206 from theta42/fix/bump-version-1.14.3
chore: bump package.json version to 1.14.3
2026-08-03 02:36:46 -04:00
wmantly 13a02e6850 chore: bump package.json version to 1.14.3 2026-08-03 02:35:21 -04:00
wmantly baba3a414f Merge pull request #205 from theta42/feature/v1.14.3-docs-restoration
docs: restore proxy documentation and deployment guide
2026-08-03 02:19:25 -04:00
wmantly d049b2de49 docs: restore full proxy documentation site and DEPLOYMENT.md 2026-08-03 02:18:42 -04:00
wmantly b0e8104790 Merge pull request #203 from theta42/fix/remove-missing-docs
fix: remove DEPLOYMENT.md and docs/ from Docker build context
2026-08-02 12:09:53 -04:00
wmantly 3cc769cc3c fix: remove DEPLOYMENT.md and docs/ from Docker build context
Pull Request Tests / Run Tests (18.x) (push) Successful in 27s
Pull Request Tests / Run Tests (20.x) (push) Successful in 27s
Pull Request Tests / Run Tests (22.x) (push) Successful in 29s
Pull Request Tests / Test Summary (push) Successful in 4s
2026-08-02 11:53:48 -04:00
wmantly 94ff5c76eb docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly d74e3168ed Merge pull request #202 from theta42/release-v1.14.0
Release v1.14.0
2026-08-02 00:39:12 -04:00
wmantly 9029de825c test: add tests for DNS API Keys in OpenBao 2026-08-02 00:34:56 -04:00
wmantly b50a1de76f feat: securely store DNS API keys in OpenBao instead of Redis 2026-08-02 00:26:22 -04:00
wmantly c419249e98 chore: release v1.13.2 2026-08-02 00:16:19 -04:00
wmantly 4aa994121a Merge pull request #201 from theta42/fix/bao-conf-1.0.1
v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1 (fix standalone boot crash)
2026-08-01 12:50:47 -04:00
wmantly 2e92f58750 v1.13.1: bump @simpleworkjs/bao-conf to 1.0.1
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing boot
(.catch -> process.exit(1)) 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). The theta-env stack
is unaffected (it always sets a scoped VAULT_TOKEN).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:48:10 -04:00
wmantly aeccbcbbe9 Merge pull request #200 from theta42/feature/openbao-secrets
v1.13.0: load secrets from OpenBao at boot
2026-08-01 12:34:55 -04:00
wmantly 15b154fc8d v1.13.0: load secrets from OpenBao at boot via @simpleworkjs/bao-conf
bin/www now defers require('../app') until bao-conf.init({ path: 'proxy' })
resolves, so models + createOidcClient see the OpenBao-merged config (the
OIDC clientSecret is captured at require time). Authenticates to OpenBao
with a scoped VAULT_TOKEN (policy proxy), never the root token; fail-soft
to CONF_SECRETS if OpenBao is unreachable. config/proxy-secrets.js becomes
an operator-edit seed artifact (OpenBao authoritative). README gains a
Secrets section.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:29:22 -04:00
8 changed files with 286 additions and 71 deletions
+34
View File
@@ -1,3 +1,9 @@
## 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
@@ -6,6 +12,34 @@ 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
-2
View File
@@ -115,8 +115,6 @@ 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,6 +162,23 @@ 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.
+78 -65
View File
@@ -4,34 +4,91 @@
* 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');
/**
* Get port from environment and store in Express.
*/
// @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
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
app.set('port', port);
/**
* Get port from environment and store in Express.
*/
/**
* Create HTTP server.
*/
var port = normalizePort(process.env.NODE_PORT || conf.port || '3000');
app.set('port', port);
var server = http.createServer(app);
/**
* Create HTTP server.
*/
var io = require('socket.io')(server);
app.io = io;
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
var io = require('socket.io')(server);
app.io = io;
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* 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);
});
/**
* Normalize a port into a number, string, or false.
@@ -51,48 +108,4 @@ 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()
}
}
}
+73 -1
View File
@@ -3,6 +3,7 @@
const crypto = require("crypto");
const conf = require('@simpleworkjs/conf');
const baoConf = require('@simpleworkjs/bao-conf');
const Table = require('.');
const ModelPs = require('../utils/model_pubsub');
@@ -139,11 +140,26 @@ 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){
@@ -189,7 +205,63 @@ class DnsProvider extends Table{
let instance = await super.get(data, ...args);
let __intraModel = this.__intraModel(instance.dnsProvider);
return await super.get.call(__intraModel, data, ...args);
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);
}
static listProviders(){
+15 -2
View File
@@ -1,17 +1,18 @@
{
"name": "proxy-api",
"version": "1.9.0",
"version": "1.13.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxy-api",
"version": "1.9.0",
"version": "1.13.3",
"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",
@@ -297,6 +298,18 @@
"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",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxy-api",
"version": "1.9.0",
"version": "1.14.3",
"author": [
{
"name": "William Mantly",
@@ -22,6 +22,7 @@
"@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",
@@ -0,0 +1,67 @@
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');
});
});