Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e748bfa66 | |||
| a78db906e8 | |||
| e7e3eeb6cd | |||
| f178f1a972 |
@@ -1,3 +1,34 @@
|
||||
# v1.30.2
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Outbound mail (test email, invites, password resets, OTP-by-email, notifications) could be rejected by the SMTP relay with `554 5.7.1 ... Sender is not same as SMTP authenticate username`.** Many authenticated relays require the `From` address to match the authenticated account or they refuse the send outright. `models/email.js` fell back to a hardcoded `noreply@theta42.com` when `smtp.from` wasn't set, which no relay ever authorized this account to send as. It now falls back to `smtp.user` first — the address the account can actually prove it owns — before the hardcoded placeholder.
|
||||
- **Catalog page card titles read icon-then-name.** Swapped to name-then-icon so the resource name leads.
|
||||
|
||||
### Docs
|
||||
|
||||
- `docs/configuration.md` didn't mention that OpenBao + the live Configuration UI sit above the four file/env config layers and win the merge — added.
|
||||
- `docs/plugins.md` listed 3 of 4 discovery plugin types (missing `docker`) and didn't mention the `messaging` plugin category (`twilio`, `webhook`) at all — added both.
|
||||
- `docs/vault.md` had no navigation (no frontmatter, no back-link, unreachable from the docs index) and described OpenBao as running in dev mode with API access via the root token — both wrong for a real deployment. Fixed navigation and corrected to describe the actual production setup (unsealed OpenBao, server-side scoped-token injection, personal API tokens for programmatic access).
|
||||
- `docs/discovery.md` was unreachable from the docs index and missing its back-link — both fixed.
|
||||
- `README.md`'s required-groups list was missing `app_sso_directory_admin` (gates Directory/Plugins/Agent admin).
|
||||
|
||||
# v1.30.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Test Email always failed with `Email.send is not a function`.** `models/email.js` exports `{Mail}`; the handler required the module and called `.send` on it directly. Every other caller destructures it. The button could never have worked.
|
||||
- **Test SMS failed with `Unexpected token '<', "<!DOCTYPE "...`.** It POSTed to `https://api.voip.ms/v1.0/sms/send` with Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET against `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw. It could never have sent anything.
|
||||
- **All SMS delivery was broken, not just the test button.** `models/sms.js` called `PluginInstance.find({…})`, but @simpleworkjs/orm has no `find` — the query method is `list({where})`. It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
|
||||
- Both test endpoints now send through the **same senders every real message uses** (`Mail.send`, `SMS.send`). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed.
|
||||
- The SMS credential check no longer demands `conf.voipms` when a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself.
|
||||
- Both endpoints report a failure as a `400` with the underlying reason (`VoIP.ms error: invalid_credentials`, `connect ECONNREFUSED …:587`) instead of an opaque `500`. A misconfiguration is the operator's to fix and the UI should be able to show it.
|
||||
- test: a guard suite that fails the build on any call to a non-existent ORM static (`find`/`findOne`/`findAll`/`where`), on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
|
||||
|
||||
### Added
|
||||
|
||||
- **Install Agent offers the join-key flow.** The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
|
||||
|
||||
# v1.30.0
|
||||
|
||||
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
|
||||
|
||||
@@ -200,7 +200,8 @@ If you are pointing the app at your own existing LDAP server, see
|
||||
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
||||
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
||||
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
||||
OAuth clients only), `app_sso_invite` (invitation management) — see
|
||||
OAuth clients only), `app_sso_invite` (invitation management),
|
||||
`app_sso_directory_admin` (Directory/Plugins/Agent admin) — see
|
||||
DEPLOYMENT.md for the full setup.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -16,13 +16,27 @@ deep-merges, in order (later wins):
|
||||
`localhost`, `SSO Manager`).
|
||||
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
||||
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
||||
4. **`app_*` environment variables** — the highest-precedence layer.
|
||||
4. **`app_*` environment variables** — the highest-precedence layer among these
|
||||
four.
|
||||
|
||||
Any env var whose name starts with `app_` overrides the merged config. The rest
|
||||
of the name splits on **double-underscore** (`__`) into a nested path. Values are
|
||||
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
|
||||
raw strings otherwise.
|
||||
|
||||
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
|
||||
|
||||
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
|
||||
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
|
||||
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
|
||||
everything else here. On top of that, the admin **Configuration** page in the
|
||||
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
|
||||
and applies the change to the live `conf` object immediately
|
||||
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
|
||||
If a value isn't behaving the way `conf/secrets.js` says it should, check the
|
||||
Configuration UI / OpenBao before assuming a file edit didn't take — it's
|
||||
almost certainly OpenBao (or a live UI edit) winning the merge.
|
||||
|
||||
## Examples
|
||||
|
||||
| Env var | Sets | Type |
|
||||
|
||||
@@ -6,6 +6,8 @@ nav_order: 6
|
||||
|
||||
# Discovery & Inventory
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
The Directory holds two different kinds of thing, and the distinction matters
|
||||
for every consumer of the directory:
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 320 KiB |
@@ -60,7 +60,10 @@ backend, that's the niche.
|
||||
run the pieces separately via `app_*` env config.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
|
||||
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
|
||||
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
|
||||
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
|
||||
|
||||
## Get it
|
||||
|
||||
|
||||
@@ -17,11 +17,25 @@ needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||
|
||||
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||
`category`. The built-ins ship under `plugins/discovery/`:
|
||||
`category`. Two built-in categories ship today:
|
||||
|
||||
**`discovery`** — scheduled scans that sync external assets into the
|
||||
directory catalog:
|
||||
|
||||
- `proxmox` — Proxmox VE (URL + API token)
|
||||
- `unifi` — UniFi Network controller (URL + username/password)
|
||||
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||
- `docker` — Docker daemon discovery (containers as directory resources)
|
||||
|
||||
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
|
||||
notifications:
|
||||
|
||||
- `twilio` — Twilio SMS
|
||||
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
|
||||
Discord, or any HTTP endpoint)
|
||||
|
||||
If no messaging plugin instance is enabled, the system falls back to the
|
||||
legacy `voipms` integration configured directly in the SSO secrets.
|
||||
|
||||
### What the Proxmox plugin produces
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
---
|
||||
layout: default
|
||||
title: Vault Secrets
|
||||
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
|
||||
---
|
||||
|
||||
# Vault Secrets Management
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||
|
||||
## Usage
|
||||
@@ -26,7 +34,14 @@ You can access the Vault UI from the application's top navigation bar.
|
||||
|
||||
### OpenBao Integration
|
||||
|
||||
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||
The secrets are stored in a real, initialized-and-unsealed OpenBao backend
|
||||
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
|
||||
mode, which auto-unseals with an in-memory store and loses everything on
|
||||
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
|
||||
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
|
||||
way as the rest of the app (session cookie or a personal API token) — the
|
||||
server resolves your OpenBao access itself and injects the right scoped
|
||||
token; you never see or handle a raw OpenBao token as a UI user.
|
||||
|
||||
## Apps tab (admin)
|
||||
|
||||
@@ -48,9 +63,24 @@ The **Shared** tab lets you share a secret with another user (or app) without co
|
||||
|
||||
## API Access
|
||||
|
||||
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||
To read your own secrets programmatically, call the `/api/vault` proxy with
|
||||
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
|
||||
token. The server authenticates the request, resolves your own scoped
|
||||
OpenBao access, and injects the real `X-Vault-Token` itself:
|
||||
|
||||
```bash
|
||||
# Example: Read a secret via the API
|
||||
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
|
||||
curl -H "Authorization: Bearer sso_<id>_<secret>" \
|
||||
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||
```
|
||||
|
||||
An **external app** reading its own config uses the scoped token minted for
|
||||
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
|
||||
above for how that token is minted and what it's confined to.
|
||||
|
||||
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
|
||||
never the intended path for day-to-day secret access — it's an
|
||||
operator/maintenance credential (seeding, disaster recovery), kept in
|
||||
`setup.env` and never passed to a service container. See
|
||||
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
|
||||
for the full token/policy model.
|
||||
|
||||
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
|
||||
|
||||
var transporter = nodemailer.createTransport(transportOpts);
|
||||
|
||||
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
|
||||
// ...: Sender is not same as SMTP authenticate username") require the
|
||||
// envelope/header From to equal the authenticated user, or reject the
|
||||
// send outright. If the operator hasn't set an explicit smtp.from,
|
||||
// defaulting to the SMTP username is far more likely to actually send
|
||||
// than a made-up noreply@theta42.com address that no relay authorized
|
||||
// this account to send as.
|
||||
var mailOpts = {
|
||||
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: message
|
||||
|
||||
@@ -14,7 +14,12 @@ async function send(to, message) {
|
||||
const registry = require('../services/plugin_registry');
|
||||
const pluginSecrets = require('../utils/plugin_secrets');
|
||||
|
||||
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
|
||||
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
|
||||
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
|
||||
// this sender, so SMS delivery never worked at all: not the test button, not
|
||||
// OTP-by-SMS, not notifications. It failed before it could even fall back to
|
||||
// the direct VoIP.ms path below.
|
||||
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
|
||||
if (instances.length > 0) {
|
||||
const inst = instances[0];
|
||||
const manifest = registry.getManifest(inst.pluginType);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"version": "1.30.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"version": "1.30.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"version": "1.30.2",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -136,15 +136,25 @@ router.post('/test-email', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Recipient email address is required' });
|
||||
}
|
||||
|
||||
// Use the email model to send the test message
|
||||
const Email = require('../models/email');
|
||||
// Send through the SAME sender every other feature uses (password reset,
|
||||
// invites, OTP-by-email, notifications). A "test" that reimplements
|
||||
// delivery proves nothing about whether real mail works.
|
||||
//
|
||||
// models/email.js exports `{Mail}`; requiring the module and calling
|
||||
// `.send` on it directly -- as this did -- always threw
|
||||
// "Email.send is not a function", so the button could never succeed.
|
||||
const { Mail } = require('../models/email');
|
||||
const testSubject = subject || 'SSO Manager Test Email';
|
||||
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
|
||||
|
||||
await Email.send(to, testSubject, testBody);
|
||||
await Mail.send(to, testSubject, testBody);
|
||||
res.json({ success: true, message: `Test email sent to ${to}` });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
// A failed test is almost always a misconfiguration (wrong host, refused
|
||||
// connection, bad credentials) -- the operator's to fix, and something the
|
||||
// UI should be able to show them. Surfacing it as a 400 with the reason
|
||||
// beats an opaque 500 carrying a raw stack-trace name.
|
||||
return res.status(400).json({ error: err.message || 'Failed to send test email' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,38 +166,37 @@ router.post('/test-sms', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Recipient phone number is required' });
|
||||
}
|
||||
|
||||
// Send through models/sms.js -- the same path every real SMS takes. It
|
||||
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
|
||||
// normalizes the destination to E.164 digits.
|
||||
//
|
||||
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
|
||||
// No such endpoint exists: VoIP.ms's REST API is a GET against
|
||||
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
|
||||
// `method=sendSMS`. The fabricated URL returned an HTML page, so
|
||||
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
|
||||
// button reported that as the failure. It could never have sent anything.
|
||||
const { SMS } = require('../models/sms');
|
||||
const { PluginInstance } = require('../models/plugin_instance');
|
||||
|
||||
// A messaging plugin, when present, supplies its own credentials -- so
|
||||
// requiring conf.voipms unconditionally would block a perfectly working
|
||||
// setup from testing itself.
|
||||
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
|
||||
const voipmsConf = conf.voipms || {};
|
||||
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
|
||||
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
|
||||
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
|
||||
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
|
||||
}
|
||||
|
||||
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
|
||||
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
|
||||
|
||||
// VoIP.ms SMS API endpoint
|
||||
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
|
||||
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
|
||||
|
||||
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${authHeader}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
did: voipmsConf.did,
|
||||
to: to,
|
||||
message: testMessage
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status === 'success') {
|
||||
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||
} else {
|
||||
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
|
||||
}
|
||||
await SMS.send(to, testMessage);
|
||||
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
|
||||
// plugin's own error). Surface it as a 400 the UI can display rather than
|
||||
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
|
||||
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// @simpleworkjs/orm models expose `list`/`get`/`count`/`create` -- there is no
|
||||
// `find`, `findOne`, `findAll` or `where`. Calling one is not a syntax error and
|
||||
// nothing catches it until the line actually runs, so it can sit in a rarely
|
||||
// exercised path indefinitely.
|
||||
//
|
||||
// It did: `models/sms.js` called `PluginInstance.find({...})`, which threw
|
||||
// "is not a function" on EVERY SMS send -- the test button, OTP-by-SMS and
|
||||
// notifications alike -- before it could even reach the VoIP.ms fallback. SMS
|
||||
// delivery had simply never worked.
|
||||
const ORM_MODELS = [
|
||||
'Resource', 'ResourceEdge', 'ResourceGroup', 'AccessRequest', 'Webhook',
|
||||
'PluginInstance', 'SharedSecret', 'SharedSecretGrant', 'VaultAppToken',
|
||||
'Agent', 'AgentJoinKey',
|
||||
];
|
||||
const MISSING_STATICS = ['find', 'findOne', 'findAll', 'findAndCountAll', 'where'];
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCAN_DIRS = ['models', 'routes', 'services', 'utils', 'plugins', 'controller', 'middleware'];
|
||||
|
||||
function walk(dir, out = []) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules') continue;
|
||||
walk(full, out);
|
||||
} else if (entry.name.endsWith('.js')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip comments so a line *describing* the bug (like the one in models/sms.js)
|
||||
// isn't reported as the bug.
|
||||
function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
}
|
||||
|
||||
test('no source file calls an ORM static that does not exist', () => {
|
||||
const pattern = new RegExp(
|
||||
`\\b(${ORM_MODELS.join('|')})\\s*\\.\\s*(${MISSING_STATICS.join('|')})\\s*\\(`,
|
||||
'g'
|
||||
);
|
||||
|
||||
const offenders = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walk(path.join(ROOT, dir))) {
|
||||
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||
src.split('\n').forEach((line, i) => {
|
||||
const m = line.match(pattern);
|
||||
if (m) offenders.push(`${path.relative(ROOT, file)}:${i + 1} — ${m.join(', ')}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// models/email.js exports `{Mail}`, not a bare sender. Requiring the module and
|
||||
// calling `.send` on it -- as routes/api_conf.js's test-email did -- always
|
||||
// threw "Email.send is not a function", so the Test Email button could never
|
||||
// have worked.
|
||||
test('the email module exports Mail.send and callers destructure it', () => {
|
||||
const mod = require('../models/email');
|
||||
expect(typeof mod.Mail).toBe('object');
|
||||
expect(typeof mod.Mail.send).toBe('function');
|
||||
// The bare module has no send() -- this is exactly the mistake to catch.
|
||||
expect(mod.send).toBeUndefined();
|
||||
|
||||
const offenders = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walk(path.join(ROOT, dir))) {
|
||||
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||
// `X = require('...email')` followed by `X.send(` where X was not
|
||||
// destructured.
|
||||
const assigned = [...src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*require\([^)]*models\/email[^)]*\)/g)]
|
||||
.map(m => m[1]);
|
||||
for (const name of assigned) {
|
||||
if (new RegExp(`\\b${name}\\s*\\.\\s*send\\s*\\(`).test(src)) {
|
||||
offenders.push(`${path.relative(ROOT, file)} — ${name}.send(), but the module exports {Mail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// The VoIP.ms REST API is a GET against voip.ms/api/v1/rest.php with
|
||||
// api_username/api_password and method=sendSMS. `api.voip.ms/v1.0/sms/send`
|
||||
// (which test-sms used to POST to with Basic auth) does not exist -- it
|
||||
// returned an HTML page, so response.json() threw
|
||||
// `Unexpected token '<', "<!DOCTYPE "...` and the button reported that.
|
||||
test('nothing targets the non-existent api.voip.ms host', () => {
|
||||
const offenders = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walk(path.join(ROOT, dir))) {
|
||||
// Comments stripped: the note in routes/api_conf.js explaining this
|
||||
// very bug names the bad host, and describing a mistake is not
|
||||
// making it.
|
||||
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||
src.split('\n').forEach((line, i) => {
|
||||
if (line.includes('api.voip.ms')) {
|
||||
offenders.push(`${path.relative(ROOT, file)}:${i + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
@@ -1640,6 +1640,21 @@
|
||||
// public_key must reach the host: without it the agent refuses every
|
||||
// high-risk command. It was never emitted before, which is why signed
|
||||
// commands only ever "worked" while verification was being skipped.
|
||||
// Join-key command. Only a key we just minted can appear here -- the list
|
||||
// endpoint deliberately never returns key values.
|
||||
const joinUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
|
||||
const selectedKeyId = $('#agent-join-key-select').val();
|
||||
let joinCmd;
|
||||
if (mintedJoinKey) {
|
||||
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
|
||||
} else if (selectedKeyId) {
|
||||
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
|
||||
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
|
||||
} else {
|
||||
joinCmd = '# Create a join key above, or select one you already have the value for.';
|
||||
}
|
||||
$('#agent-join-command').text(joinCmd);
|
||||
|
||||
const pubKey = (pendingEnrollment && pendingEnrollment.publicKey) || '';
|
||||
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`
|
||||
+ (pubKey ? ` --public-key "${pubKey}"` : '');
|
||||
@@ -1719,14 +1734,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="agent-mode-join-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-join" type="button" role="tab">
|
||||
<i class="fa-solid fa-key me-1"></i> Join key <span class="badge bg-success ms-1">easiest</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="agent-mode-pre-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-pre" type="button" role="tab">
|
||||
<i class="fa-solid fa-id-badge me-1"></i> Pre-register this host
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content mb-3">
|
||||
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
|
||||
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
|
||||
<div class="card border-success">
|
||||
<div class="card-header py-2 fw-bold small bg-success-subtle">
|
||||
<i class="fa-solid fa-key me-1"></i> Install with a join key
|
||||
</div>
|
||||
<div class="card-body py-3">
|
||||
<p class="small text-muted mb-3">
|
||||
Run this on any host and it enrolls itself. The SSO issues that host its own
|
||||
token and public key on first connect, and the agent writes both into its
|
||||
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
|
||||
many hosts as you like; each still gets its own revocable identity.
|
||||
</p>
|
||||
<div class="d-flex gap-2 align-items-end mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<label class="form-label small fw-bold mb-1">Existing join keys</label>
|
||||
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
|
||||
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
|
||||
<i class="fa-solid fa-plus me-1"></i> New join key
|
||||
</button>
|
||||
</div>
|
||||
<div id="agent-join-key-result" style="display:none"></div>
|
||||
|
||||
<label class="form-label small fw-bold mb-1">Run on the target host (as root):</label>
|
||||
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-2 text-wrap text-break" id="agent-join-command" style="user-select: all;"></pre>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-sm btn-success" id="btn-copy-join" onclick="copyAgentCommand('agent-join-command', 'btn-copy-join')">
|
||||
<i class="fa-solid fa-copy me-1"></i> Copy install command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
|
||||
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
|
||||
|
||||
<div class="card border-primary mb-3" id="agent-enroll-card">
|
||||
<div class="card-header py-2 fw-bold small bg-primary-subtle">
|
||||
<i class="fa-solid fa-id-badge me-1"></i> 1. Enroll this host
|
||||
</div>
|
||||
<div class="card-body py-3">
|
||||
<p class="small text-muted mb-3">
|
||||
The SSO issues the agent's token and records it. Tokens it did not issue are rejected,
|
||||
so enroll the host first — the install command below is built from the result.
|
||||
Use this when you want the agent bound to a specific Directory host from the start.
|
||||
The SSO issues the token here and you copy it onto the machine yourself.
|
||||
</p>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
@@ -1870,6 +1938,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /pre-register pane -->
|
||||
</div><!-- /tab-content -->
|
||||
`;
|
||||
|
||||
app.modal.open({
|
||||
@@ -1878,6 +1948,8 @@
|
||||
size: 'lg'
|
||||
});
|
||||
|
||||
loadAgentJoinKeys();
|
||||
|
||||
// Only hosts can carry an agent -- the API rejects anything else, so don't
|
||||
// offer it here.
|
||||
const $sel = $('#agent-enroll-resource').empty();
|
||||
@@ -1898,6 +1970,55 @@
|
||||
updateAgentCommands();
|
||||
}
|
||||
|
||||
// Join keys the operator can reuse. Values are never returned by the list
|
||||
// endpoint -- only a prefix -- so the dropdown identifies a key without being
|
||||
// able to rebuild an install command from it. Minting is the only way to see
|
||||
// a key's value, and only once.
|
||||
var agentJoinKeys = [];
|
||||
var mintedJoinKey = null; // in-memory, for the command shown right now
|
||||
|
||||
function loadAgentJoinKeys() {
|
||||
app.api.get('agent/join-keys', function(err, res) {
|
||||
agentJoinKeys = (res && res.joinKeys ? res.joinKeys : []).filter(k => !k.revoked);
|
||||
const $sel = $('#agent-join-key-select').empty();
|
||||
if (!agentJoinKeys.length) {
|
||||
$sel.append('<option value="">No join keys yet — create one</option>');
|
||||
} else {
|
||||
$sel.append('<option value="">Select a key…</option>');
|
||||
agentJoinKeys.forEach(k => {
|
||||
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
|
||||
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
|
||||
});
|
||||
}
|
||||
updateAgentCommands();
|
||||
});
|
||||
}
|
||||
|
||||
async function mintAgentJoinKey() {
|
||||
try {
|
||||
const res = await app.api.post('agent/join-keys', { label: 'ui' });
|
||||
const body = (res && (res.results || res)) || {};
|
||||
if (!body.key) throw new Error(body.message || 'no key returned');
|
||||
mintedJoinKey = body.key;
|
||||
$('#agent-join-key-result').show().html(
|
||||
'<div class="alert alert-success py-2 small mb-3">'
|
||||
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
|
||||
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
|
||||
+ '</div>'
|
||||
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
|
||||
+ '<div class="input-group input-group-sm mb-3">'
|
||||
+ '<input type="text" class="form-control font-monospace" readonly value="' + esc(body.key) + '">'
|
||||
+ '<button class="btn btn-outline-secondary" type="button" id="btn-copy-jk" onclick="copyAgentCommand(\'agent-jk-copy\', \'btn-copy-jk\')"><i class="fa-solid fa-copy"></i></button>'
|
||||
+ '</div>'
|
||||
+ '<span id="agent-jk-copy" class="d-none">' + esc(body.key) + '</span>'
|
||||
);
|
||||
loadAgentJoinKeys();
|
||||
updateAgentCommands();
|
||||
} catch (err) {
|
||||
app.messages.toast('Could not create a join key: ' + (err.message || err), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Mint the token server-side, then reveal the install steps built from it.
|
||||
async function enrollAgent() {
|
||||
const name = ($('#agent-enroll-name').val() || '').trim();
|
||||
|
||||
@@ -206,8 +206,8 @@
|
||||
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||
+ '<div class="card-body">'
|
||||
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||
+ iconHtml
|
||||
+ '<span>' + esc(r.name) + '</span>'
|
||||
+ iconHtml
|
||||
+ '</h5>'
|
||||
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||
|
||||