Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62cdaa2cdd | |||
| 13a02e6850 | |||
| baba3a414f | |||
| d049b2de49 | |||
| b0e8104790 | |||
| 3cc769cc3c | |||
| 94ff5c76eb | |||
| d74e3168ed | |||
| 9029de825c | |||
| b50a1de76f | |||
| c419249e98 | |||
| 4aa994121a | |||
| 2e92f58750 | |||
| aeccbcbbe9 | |||
| 15b154fc8d |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(){
|
||||
|
||||
Generated
+15
-2
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user