feat: release v1.31.0 with Zero-View secrets engine, generator, multi-level inheritance, and SSSD key mappings

This commit is contained in:
2026-08-07 23:26:39 -04:00
parent 181ca8c9cb
commit 15d9ce1078
10 changed files with 740 additions and 52 deletions
+13
View File
@@ -1,3 +1,16 @@
# v1.31.0 - 2026-08-07
### Added
- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources (`secret/data/resources/<slug>/conf`). Zero-View UI & API model — secret values are never returned to admin browsers or UI templates, and delivered exclusively to authenticated `theta-agent` instances.
- **Strict Secret Key Regex Validation.** Secret keys are validated against `^[A-Za-z0-9_]+$` (Standard Environment Variable format, e.g. `DB_PASSWORD`).
- **Field-Populating Password Generator.** Cryptographic secret generator (`window.crypto.getRandomValues`) with length selector dropdown (8128 chars) populating input fields with security notices.
- **Multi-Level Secret Inheritance.** Dynamic secret resolution across any depth of the resource tree (`Services / Apps -> Hosts / Nodes -> Global Sites`).
- **Non-Blocking UI Confirmations.** Replaced browser blocking dialogs with async `app.messages.confirm()` banners.
- **UI Directory Layout Improvements.** Fixed Directory table resource name and badge order for enhanced readability.
### Fixed
- **SSSD `sshPublicKey` Mapping.** Included `ldap_user_ssh_public_key = sshPublicKey` in generated agent `sssd.conf` template.
# Unreleased — LDAP-over-HTTPS API + agent LDAP byte-pump relay
### Added
+18
View File
@@ -191,6 +191,24 @@ class Resource extends Model {
}
return null;
}
// Walk all parent ResourceEdges upwards recursively to find all ancestor
// resources (Host, Cluster, Site, etc.).
static async findAllAncestors(resourceId, visited = new Set()) {
if (visited.has(resourceId)) return [];
visited.add(resourceId);
const ancestors = [];
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }).catch(() => []);
for (const edge of parentEdges) {
const parent = await this.get(edge.parentId).catch(() => null);
if (!parent) continue;
ancestors.push(parent);
const higher = await this.findAllAncestors(parent.id, visited);
ancestors.push(...higher);
}
return ancestors;
}
}
class ResourceEdge extends Model {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.30.2",
"version": "1.31.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+65 -20
View File
@@ -1,9 +1,7 @@
#!/bin/bash
#!/bin/sh
set -e
# --- Configuration ---
# In a real environment, these would be derived from the script's download URL
# or passed as additional arguments. For now, we use the most recent release.
BINARY_URL="${BINARY_URL:-}"
CONFIG_DIR="/etc/theta42"
CONFIG_FILE="$CONFIG_DIR/agent.yml"
@@ -15,20 +13,50 @@ RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
log() { echo "${GREEN}[+]${NC} $1"; }
error() { echo "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check
if [ "$EUID" -ne 0 ]; then
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
error "This script must be run as root."
fi
# Install SSSD and PAM integration packages if missing
install_sssd_deps() {
if ! command -v sssd >/dev/null 2>&1; then
log "Installing SSSD and PAM integration dependencies..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
if command -v pam-auth-update >/dev/null 2>&1; then
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
fi
elif command -v dnf >/dev/null 2>&1; then
dnf install -y sssd sssd-ldap sssd-tools || true
elif command -v yum >/dev/null 2>&1; then
yum install -y sssd sssd-ldap sssd-tools || true
elif command -v pacman >/dev/null 2>&1; then
pacman -S --noconfirm sssd || true
elif command -v zypper >/dev/null 2>&1; then
zypper in -y sssd || true
fi
else
log "SSSD is already installed."
fi
mkdir -p /etc/sssd
chmod 755 /etc/sssd
}
# 2. Argument Parsing
URL=""
TOKEN=""
JOIN_KEY=""
PUBLIC_KEY=""
B64_CONFIG=""
INSTALL_SSSD=0
while [[ $# -gt 0 ]]; do
while [ $# -gt 0 ]; do
case $1 in
--url)
URL="$2"
@@ -38,6 +66,18 @@ while [[ $# -gt 0 ]]; do
TOKEN="$2"
shift 2
;;
--public-key)
PUBLIC_KEY="$2"
shift 2
;;
--join-key)
JOIN_KEY="$2"
shift 2
;;
--install-sssd|--ldap)
INSTALL_SSSD=1
shift
;;
*)
B64_CONFIG="$1"
shift
@@ -45,12 +85,9 @@ while [[ $# -gt 0 ]]; do
esac
done
# Validation
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\""
# Validation: require credentials ONLY if config file does not already exist
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
exit 1
fi
@@ -71,8 +108,9 @@ if [ -z "$BINARY_URL" ]; then
fi
log "Downloading binary from $BINARY_URL..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
chmod +x "$BIN_PATH"
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary."
chmod +x "$BIN_PATH.tmp"
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
@@ -82,23 +120,32 @@ chmod 755 "$CONFIG_DIR"
if [ -n "$B64_CONFIG" ]; then
log "Decoding and writing configuration from base64..."
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
else
elif [ ! -f "$CONFIG_FILE" ]; then
log "Generating minimal configuration from arguments..."
# Create a minimal yaml with the provided URL and Token
cat <<EOF > "$CONFIG_FILE"
server_url: "$URL"
auth_token: "$TOKEN"
join_key: "$JOIN_KEY"
public_key: "$PUBLIC_KEY"
location: "unknown"
capabilities:
telemetry: true
configure_ldap: false
configure_ldap: true
ldap_tunnel: true
reboot: false
service_control: []
arbitrary_bash: false
EOF
else
log "Preserving existing configuration at $CONFIG_FILE"
fi
chmod 600 "$CONFIG_FILE"
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
install_sssd_deps
fi
# 5. Setup systemd service
log "Creating systemd service unit..."
cat <<EOF > "$SERVICE_FILE"
@@ -111,8 +158,6 @@ Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
+94 -7
View File
@@ -306,13 +306,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
const joinKey = await AgentJoinKey.authenticate(token);
if (joinKey) {
const hostname = (url.searchParams.get('hostname') || '').trim();
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
let existingAgent = null;
if (hostname) {
const matches = await Agent.list({ where: { name: hostname } });
existingAgent = matches && matches.find(a => !a.revoked);
}
if (existingAgent) {
const newToken = await existingAgent.rotateToken();
agent = existingAgent;
issuedToken = newToken;
} else {
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
}
await joinKey.update({
use_count: (joinKey.use_count || 0) + 1,
last_used_on: Math.floor(Date.now() / 1000)
@@ -345,6 +356,23 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
// `ws` discards messages emitted while no listener is attached.
agentManager.registerAgent(agent, ws, remoteAddr);
if (issuedToken) {
const publicKey = await agentManager.publicKeyBase64();
try {
ws.send(JSON.stringify({
type: 'config',
payload: {
enrolled: true,
auth_token: issuedToken,
public_key: publicKey
}
}));
console.log(`[Theta Agent] Sent auto-enrollment credentials to "${agent.name}"`);
} catch (err) {
console.error(`[Theta Agent] Failed to send auto-enrollment config to "${agent.name}":`, err.message);
}
}
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
@@ -364,6 +392,65 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
case 'discovery':
await agentManager.handleDiscovery(current, payload);
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
if (payload.capabilities && payload.capabilities.configure_ldap) {
const conf = require('@simpleworkjs/conf');
const os = require('os');
const ssoHost = (conf.stack && conf.stack.ssoHost) || 'sso.laptop-dev.vm42.us';
const ldapBaseDn = (conf.stack && conf.stack.ldapBaseDn) || 'dc=laptop-dev,dc=vm42,dc=us';
const lanIps = [];
const ifaces = os.networkInterfaces();
for (const dev in ifaces) {
for (const details of ifaces[dev]) {
if (!details.internal && details.family === 'IPv4') lanIps.push(details.address);
}
}
const uriList = [
`ldapi://%2frun%2ftheta%2fldap.sock`,
`ldap://127.0.0.1:3890`,
`ldap://127.0.0.1:389`,
`ldap://${ssoHost}:389`,
`ldaps://${ssoHost}:636`,
...lanIps.map(ip => `ldap://${ip}:389`)
];
const ldapUris = [...new Set(uriList)].join(', ');
const sssdConfig = `[sssd]
config_file_version = 2
domains = default
[domain/default]
id_provider = ldap
auth_provider = ldap
chpass_provider = ldap
sudo_provider = ldap
ldap_uri = ${ldapUris}
ldap_search_base = ${ldapBaseDn}
ldap_user_search_base = ou=people,${ldapBaseDn}
ldap_group_search_base = ou=groups,${ldapBaseDn}
ldap_sudo_search_base = ou=people,${ldapBaseDn}
ldap_schema = rfc2307bis
ldap_user_object_class = posixAccount
ldap_user_name = uid
ldap_user_ssh_public_key = sshPublicKey
ldap_group_object_class = groupOfNames
ldap_group_member = member
ldap_id_mapping = false
ldap_id_use_start_tls = false
ldap_tls_reqcert = never
cache_credentials = true
entry_cache_timeout = 600
entry_cache_user_timeout = 600
entry_cache_group_timeout = 600
entry_cache_sudo_timeout = 600
refresh_expired_interval = 300
`;
agentManager.sendCommand(current, 'configure_ldap', { config: sssdConfig }, true).then(() => {
console.log(`[Theta Agent] Pushed auto configure_ldap to "${current.name}"`);
}).catch(err => {
console.error(`[Theta Agent] Auto push configure_ldap to "${current.name}" failed:`, err.message);
});
}
break;
case 'telemetry':
await agentManager.handleTelemetry(current, payload);
+73 -11
View File
@@ -19,29 +19,91 @@ const router = express.Router();
// so a compromised agent cannot reach other nodes' or shared secrets. The SSO
// fetches with its own OpenBao access (SSO_VAULT_TOKEN); the agent never holds a
// Vault token.
const { Resource } = require('../models/resource');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
router.post('/secrets', async (req, res, next) => {
try {
const agent = await authenticateAgent(req);
if (!agent) return res.status(401).json({ status: 'error', message: 'unauthorized' });
const { paths } = req.body || {};
if (!Array.isArray(paths) || paths.length === 0) {
return res.status(400).json({ status: 'error', message: 'paths (array) is required' });
let { paths } = req.body || {};
let boundResource = null;
if (agent.resourceId) {
boundResource = await Resource.get(agent.resourceId).catch(() => null);
}
const nodeScope = `secret/data/nodes/${agent.id}/`;
const secrets = {};
for (const p of paths) {
if (typeof p !== 'string' || !p.startsWith(nodeScope)) {
return res.status(403).json({ status: 'error', message: `path outside node scope: ${p}` });
if (!Array.isArray(paths) || paths.length === 0) {
paths = [`secret/data/nodes/${agent.id}/conf`];
if (boundResource && boundResource.slug) {
paths.push(`secret/data/resources/${boundResource.slug}/conf`);
}
}
// Allowed prefixes for this agent:
// 1. Node scope: secret/data/nodes/<agent.id>/
// 2. Bound Resource scope: secret/data/resources/<resource.slug>/
// 3. Shared Resource Grants: secret/data/resources/<grantee-slug>/
const allowedPrefixes = [`secret/data/nodes/${agent.id}/`];
if (boundResource && boundResource.slug) {
allowedPrefixes.push(`secret/data/resources/${boundResource.slug}/`);
}
// Add granted shared resources
if (boundResource) {
const grants = await SharedSecretGrant.listForGrantee('resource', boundResource.id).catch(() => []);
for (const g of grants) {
const sharedSec = await SharedSecret.get(g.secretId).catch(() => null);
if (sharedSec && sharedSec.slug) {
allowedPrefixes.push(`secret/data/resources/${sharedSec.slug}/`);
allowedPrefixes.push(`secret/data/shared/${sharedSec.ownerUid}/${sharedSec.slug}/`);
}
}
}
const secrets = {};
for (let p of paths) {
if (typeof p !== 'string') continue;
// Normalize human shorthand "resources/foo/bar" -> "secret/data/resources/foo/bar"
if (p.startsWith('resources/')) {
p = `secret/data/resources/${p.slice('resources/'.length)}`;
}
const isAllowed = allowedPrefixes.some(prefix => p.startsWith(prefix));
if (!isAllowed) {
return res.status(403).json({ status: 'error', message: `path outside authorized scope: ${p}` });
}
const r = await baoConf.request('GET', p);
if (r.ok) {
const body = await r.json().catch(() => ({}));
secrets[p] = (body.data && body.data.data) || {};
const rawMap = (body.data && body.data.data) || {};
const resolvedMap = {};
for (const [k, v] of Object.entries(rawMap)) {
const strV = String(v || '');
if (strV.startsWith('INHERIT:')) {
const parts = strV.split(':');
if (parts.length >= 3) {
const targetSlug = parts[1];
const targetKey = parts[2];
const parentR = await baoConf.request('GET', `secret/data/resources/${targetSlug}/conf`);
if (parentR.ok) {
const parentBody = await parentR.json().catch(() => ({}));
const parentMap = (parentBody.data && parentBody.data.data) || {};
resolvedMap[k] = parentMap[targetKey] || '';
} else {
resolvedMap[k] = '';
}
} else {
resolvedMap[k] = '';
}
} else {
resolvedMap[k] = v;
}
}
secrets[p] = resolvedMap;
} else {
// Missing secret: return an empty object for that path rather than
// failing the whole batch; the agent renders what it can.
secrets[p] = {};
}
}
+137
View File
@@ -199,6 +199,7 @@ router.get('/resources', async (req, res, next) => {
try {
let resources = await Resource.list();
resources = resources.filter(r => {
if (r.kind === 'host' || r.kind === 'site') return true;
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
const isManaged = r.metadata?.managed === true;
return !isAuto || isManaged;
@@ -600,4 +601,140 @@ router.get('/audit-logs', async (req, res, next) => {
} catch (err) { next(err); }
});
// ── Resource Secrets API (OpenBao KV-v2 under secret/data/resources/<slug>/conf) ──
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
router.get('/resources/:id/secrets', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const baoConf = require('@simpleworkjs/bao-conf');
// Read resource secrets from OpenBao
const path = `secret/data/resources/${resource.slug}/conf`;
const r = await baoConf.request('GET', path);
let secretsMap = {};
if (r.ok) {
const body = await r.json().catch(() => ({}));
secretsMap = (body.data && body.data.data) || {};
}
// Zero-View Security: Return metadata only, NEVER return raw secret values
const secrets = Object.keys(secretsMap).map(key => {
const val = String(secretsMap[key] || '');
let isInherited = false;
let parentSlug = null;
let parentKey = null;
if (val.startsWith('INHERIT:')) {
isInherited = true;
const parts = val.split(':');
if (parts.length >= 3) {
parentSlug = parts[1];
parentKey = parts[2];
} else if (parts.length === 2) {
parentKey = parts[1];
}
}
return {
key,
hasValue: val.length > 0,
isInherited,
parentSlug,
parentKey
};
});
// Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites
const parentSecrets = [];
const seenAncestors = new Set();
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []);
const allAncestors = [...ancestors, ...sites];
for (const parent of allAncestors) {
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
seenAncestors.add(parent.id);
const parentPath = `secret/data/resources/${parent.slug}/conf`;
const parentR = await baoConf.request('GET', parentPath);
if (parentR.ok) {
const parentBody = await parentR.json().catch(() => ({}));
const pMap = (parentBody.data && parentBody.data.data) || {};
for (const pKey of Object.keys(pMap)) {
parentSecrets.push({
parentSlug: parent.slug,
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`,
key: pKey
});
}
}
}
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
} catch (err) { next(err); }
});
router.post('/resources/:id/secrets', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const secrets = (req.body.secrets && typeof req.body.secrets === 'object') ? req.body.secrets : {};
// Validate key names (Standard Env Var format: A-Z, 0-9, underscores)
for (const key of Object.keys(secrets)) {
if (!SECRET_KEY_REGEX.test(key)) {
return res.status(400).json({
status: 'error',
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
});
}
}
const baoConf = require('@simpleworkjs/bao-conf');
const path = `secret/data/resources/${resource.slug}/conf`;
const r = await baoConf.request('POST', path, { data: secrets });
if (!r.ok) {
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
}
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.get('/resources/:id/grants', async (req, res, next) => {
try {
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const grants = await SharedSecretGrant.listForGrantee('resource', resource.id);
const sharedSecretIds = grants.map(g => g.secretId);
const secrets = sharedSecretIds.length ? await SharedSecret.list({ where: { id: { in: sharedSecretIds } } }) : [];
res.json({ status: 'ok', grants: secrets.map(s => ({ id: s.id, slug: s.slug, description: s.description })) });
} catch (err) { next(err); }
});
router.post('/resources/:id/grants', async (req, res, next) => {
try {
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const { secretSlug, action } = req.body || {};
const secret = await SharedSecret.getBySlug(secretSlug);
if (!secret) return res.status(404).json({ status: 'error', message: `shared secret '${secretSlug}' not found` });
if (action === 'revoke') {
const existing = await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType: 'resource', granteeId: resource.id } });
for (const g of existing) await g.delete();
return res.json({ status: 'ok', message: 'grant revoked' });
} else {
await SharedSecretGrant.grant({ secretId: secret.id, granteeType: 'resource', granteeId: resource.id, grantedBy: req.user.uid });
return res.json({ status: 'ok', message: 'grant created' });
}
} catch (err) { next(err); }
});
module.exports = router;
+53 -8
View File
@@ -21,12 +21,17 @@ class AgentManager {
* Sort keys alphabetically, remove whitespace, omit 'signature' key.
*/
canonicalize(payload) {
const cleanObj = {};
const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort();
for (const key of sortedKeys) {
cleanObj[key] = payload[key];
}
return JSON.stringify(cleanObj);
const sortObj = (val) => {
if (val === null || typeof val !== 'object') return val;
if (Array.isArray(val)) return val.map(sortObj);
const sorted = {};
const keys = Object.keys(val).filter(k => k !== 'signature').sort();
for (const k of keys) {
sorted[k] = sortObj(val[k]);
}
return sorted;
};
return JSON.stringify(sortObj(payload));
}
/**
@@ -148,6 +153,7 @@ class AgentManager {
ram_total_gb: discovery.ram_total_gb || undefined,
disk_total_gb: discovery.disk_total_gb || undefined,
ip: (discovery.ip_addresses || [])[0] || undefined,
public_ip: discovery.public_ip || undefined,
agentId: agent.id,
last_seen: Date.now()
};
@@ -168,15 +174,54 @@ class AgentManager {
if (!discovery.hostname) return;
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
const { ResourceEdge } = require('../models/resource');
const hostSlug = `host-${discovery.hostname.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`;
await DiscoveryReconciler.reconcile('theta-agent', {
resources: [{
kind: 'host',
name: discovery.hostname,
slug: `agent-${agent.id.slice(0, 8)}`,
metadata: { ...metadata, subType: 'linux' }
slug: hostSlug,
metadata: { ...metadata, subType: 'linux', managed: true }
}],
edges: []
});
// Find the matched or created host resource
const allHosts = await Resource.list({ where: { kind: 'host' } });
const hostRes = allHosts.find(r =>
r.name.toLowerCase() === discovery.hostname.toLowerCase() ||
r.slug === hostSlug ||
r.metadata?.agentId === agent.id
);
if (hostRes) {
// Bind the agent to its Host resource
await agent.update({ resourceId: hostRes.id }).catch(() => {});
// Attach host to matching Site by Public IP if not already parented
const existingEdges = await ResourceEdge.list({ where: { childId: hostRes.id } });
if (existingEdges.length === 0) {
const sites = await Resource.list({ where: { kind: 'site' } });
let targetSite = null;
if (discovery.public_ip) {
targetSite = sites.find(s => {
const siteIp = (s.metadata?.public_ip || s.metadata?.ip || s.metadata?.address || '').trim();
return siteIp && (siteIp === discovery.public_ip || siteIp.includes(discovery.public_ip));
});
}
if (!targetSite) targetSite = sites[0];
if (targetSite) {
await ResourceEdge.create({
id: crypto.randomUUID(),
parentId: targetSite.id,
childId: hostRes.id,
relation: 'hosts'
}).catch(() => {});
}
}
}
} catch (err) {
// Never let a directory write break the agent connection.
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
+286 -5
View File
@@ -48,6 +48,10 @@
<button type="button" class="btn btn-outline-secondary" onclick="expandAllTree()" title="Expand all"><i class="fa-solid fa-angles-down"></i></button>
<button type="button" class="btn btn-outline-secondary" onclick="collapseAllTree()" title="Collapse all"><i class="fa-solid fa-angles-up"></i></button>
</div>
<div class="form-check form-switch form-check-inline ms-1 me-1">
<input class="form-check-input" type="checkbox" id="toggle-plumbing" onchange="renderTable()">
<label class="form-check-label small text-muted" for="toggle-plumbing" title="Show containers, oauth clients, and sidecars">Plumbing</label>
</div>
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="name">Name (A-Z)</option>
@@ -90,12 +94,12 @@
{{{indentHtml}}}
{{{caretHtml}}}
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
<a href="#" class="text-reset text-decoration-none ms-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<a href="#" class="text-reset text-decoration-none me-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong>
</a>
<span class="badge bg-secondary me-1">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger me-1">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info me-1">Dev</span>{{/metadata.isProduction}}
</td>
<td>
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
@@ -507,6 +511,95 @@
</div>
`;
var secretsTabHtml = `
<div class="mb-3" id="secrets-tab-container">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-0"><i class="fa-solid fa-vault text-warning me-2"></i>Resource Secrets (OpenBao KV Engine)</h6>
<small class="text-muted">Encrypted key-value secrets stored in OpenBao under <code>secret/data/resources/&lt;slug&gt;/conf</code></small>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="refreshResourceSecrets()"><i class="fa-solid fa-sync me-1"></i> Refresh</button>
</div>
<div class="secrets-action-msg mb-2" style="display:none"></div>
<div class="table-responsive shadow-sm rounded border mb-3">
<table class="table table-hover align-middle mb-0" id="secrets-table">
<thead class="table-dark">
<tr>
<th style="width: 35%;">Secret Key</th>
<th>Status / Security</th>
<th style="width: 240px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody id="secrets-table-body">
<tr><td colspan="3" class="text-center text-muted py-3">Loading secrets...</td></tr>
</tbody>
</table>
</div>
<!-- Add / Generate Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3 mb-3">
<h6 class="card-title text-dark mb-2"><i class="fa-solid fa-plus-circle text-primary me-1"></i> Add or Generate Secret Key</h6>
<div class="row g-2 align-items-center mb-2">
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Secret Key Name (e.g. <code>DB_PASSWORD</code>)</label>
<input type="text" class="form-control form-control-sm font-monospace" id="new-secret-key" placeholder="DB_PASSWORD" onkeyup="validateSecretKeyInput(this)">
<div class="invalid-feedback small">Only letters, numbers, and underscores allowed (e.g. DB_PASSWORD).</div>
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Secret Value</label>
<input type="text" class="form-control form-control-sm font-monospace" id="new-secret-val" placeholder="Enter value or click Generate">
</div>
<div class="col-md-3 pt-3">
<button class="btn btn-sm btn-primary w-100" id="btn-add-secret" onclick="addSecretRow()"><i class="fa-solid fa-save me-1"></i> Save Secret</button>
</div>
</div>
<div class="row g-2 align-items-center mt-1">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Generator Length</label>
<select class="form-select form-select-sm" id="gen-secret-length">
<option value="8">8 characters</option>
<option value="12">12 characters</option>
<option value="16">16 characters</option>
<option value="24">24 characters</option>
<option value="32" selected>32 characters (Default)</option>
<option value="48">48 characters</option>
<option value="64">64 characters</option>
<option value="128">128 characters</option>
</select>
</div>
<div class="col-md-8 text-end pt-3">
<button class="btn btn-sm btn-outline-success" onclick="generateSecretValue()"><i class="fa-solid fa-bolt me-1"></i> Generate Secret into Field</button>
</div>
</div>
<div class="alert alert-warning border-0 shadow-sm p-2 small mt-2 mb-0" id="gen-secret-notice" style="display:none">
<i class="fa-solid fa-shield-halved text-warning me-1"></i> Generated secret is shown in the field above. Click <strong>Save Secret</strong> to store in OpenBao — secret values will not be displayed again once saved.
</div>
</div>
<!-- Inherit Parent Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3" id="inherit-secret-card" style="display:none">
<h6 class="card-title text-dark mb-2"><i class="fa-solid fa-diagram-project text-info me-1"></i> Inherit Secret from Parent Resource</h6>
<div class="row g-2 align-items-center">
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Child Secret Key Name</label>
<input type="text" class="form-control form-control-sm font-monospace" id="inherit-child-key" placeholder="DB_HOST">
</div>
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Parent Resource Secret</label>
<select class="form-select form-select-sm" id="inherit-parent-select"></select>
</div>
<div class="col-md-3 pt-3">
<button class="btn btn-sm btn-outline-info w-100" onclick="inheritParentSecret()"><i class="fa-solid fa-link me-1"></i> Inherit Secret</button>
</div>
</div>
</div>
</div>
`;
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
// the Groups/Children tabs in add-mode (no resource id to scope them to).
@@ -519,6 +612,7 @@
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
{id: 'secrets', label: 'Secrets & OpenBao', bodyHtml: secretsTabHtml},
{id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)},
],
footer: {
@@ -527,7 +621,7 @@
},
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
});
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn, #sw-modal-tab-secrets-btn').closest('li').toggle(!!id);
}
function refreshChildrenUI(resourceId) {
@@ -821,8 +915,15 @@
function renderTable() {
const filter = $('#search-filter').val().toLowerCase();
const sort = $('#sort-by').val();
const showPlumbing = $('#toggle-plumbing').is(':checked');
let filtered = rawResources.filter(r => {
if (!showPlumbing && !filter) {
const sub = (r.metadata?.subType || '').toLowerCase();
if (r.kind === 'container' || r.kind === 'oauth' || sub === 'sidecar' || sub === 'container' || sub === 'openresty') {
return false;
}
}
if (!filter) return true;
return (r.name || '').toLowerCase().includes(filter) ||
(r.slug || '').toLowerCase().includes(filter) ||
@@ -1377,8 +1478,188 @@
refreshGroupsUI(r.id);
refreshEdgesUI(r.id);
refreshChildrenUI(r.id);
loadResourceSecrets(r.id);
await loadLdapGroups();
}
var currentResourceSecretsList = [];
var currentParentSecretsList = [];
var rawResourceSecretsMap = {};
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
function validateSecretKeyInput(el) {
const $el = $(el);
const val = $el.val().trim();
if (val && !SECRET_KEY_REGEX.test(val)) {
$el.addClass('is-invalid');
return false;
} else {
$el.removeClass('is-invalid');
return true;
}
}
function generateRandomString(len) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?';
const bytes = new Uint8Array(len);
window.crypto.getRandomValues(bytes);
let str = '';
for (let i = 0; i < len; i++) {
str += chars[bytes[i] % chars.length];
}
return str;
}
async function loadResourceSecrets(id) {
const $tbody = $('#secrets-table-body').empty();
$tbody.append('<tr><td colspan="3" class="text-center text-muted py-3"><i class="fa-solid fa-spinner fa-spin me-2"></i>Loading secrets from OpenBao...</td></tr>');
currentResourceSecretsList = [];
currentParentSecretsList = [];
rawResourceSecretsMap = {};
try {
const res = await app.api.get(`directory-admin/resources/${id}/secrets`);
currentResourceSecretsList = (res && res.secrets) || [];
currentParentSecretsList = (res && res.parentSecrets) || [];
renderSecretsTable();
populateParentSecretsDropdown();
} catch (err) {
$tbody.empty().append(`<tr><td colspan="3" class="text-center text-danger py-3"><i class="fa-solid fa-triangle-exclamation me-2"></i>Failed to load secrets: ${esc(err.message || 'Unknown error')}</td></tr>`);
}
}
function populateParentSecretsDropdown() {
const $card = $('#inherit-secret-card');
const $select = $('#inherit-parent-select').empty();
if (!currentParentSecretsList || currentParentSecretsList.length === 0) {
$card.hide();
return;
}
currentParentSecretsList.forEach(p => {
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
});
$card.show();
}
function renderSecretsTable() {
const $tbody = $('#secrets-table-body').empty();
if (currentResourceSecretsList.length === 0) {
$tbody.append('<tr><td colspan="3" class="text-center text-muted py-3">No secrets configured for this resource yet.</td></tr>');
return;
}
currentResourceSecretsList.forEach((s, idx) => {
const $row = $(`
<tr class="secret-row" data-key="${esc(s.key)}">
<td><code class="fw-bold fs-6">${esc(s.key)}</code></td>
<td>
${s.isInherited
? `<span class="badge bg-info text-dark shadow-sm"><i class="fa-solid fa-link me-1"></i>Inherited from ${esc(s.parentSlug || 'Parent')}</span> <small class="text-muted ms-1">(${esc(s.parentKey || s.key)})</small>`
: `<span class="badge bg-success shadow-sm"><i class="fa-solid fa-lock me-1"></i>Configured in OpenBao</span> <span class="badge bg-secondary ms-1"><i class="fa-solid fa-eye-slash me-1"></i>Secret Value Hidden</span>`
}
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" onclick="editSecretKey('${esc(s.key)}')" title="Set / Overwrite Value"><i class="fa-solid fa-pen me-1"></i> Edit Value</button>
<button class="btn btn-sm btn-outline-danger ms-1" onclick="deleteSecretKey('${esc(s.key)}')" title="Delete Secret"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`);
$tbody.append($row);
});
}
function generateSecretValue() {
let key = $('#new-secret-key').val().trim();
if (!key) {
key = 'SECRET_KEY';
$('#new-secret-key').val(key);
}
const len = parseInt($('#gen-secret-length').val(), 10) || 32;
const randomSecret = generateRandomString(len);
$('#new-secret-val').val(randomSecret);
$('#gen-secret-notice').show();
}
function editSecretKey(key) {
$('#new-secret-key').val(key);
$('#new-secret-val').val('').focus();
$('#gen-secret-notice').hide();
}
async function addSecretRow() {
const keyEl = $('#new-secret-key')[0];
const key = $('#new-secret-key').val().trim();
const val = $('#new-secret-val').val();
if (!key) {
app.messages.action('Please enter a secret key name (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'warning');
return;
}
if (!validateSecretKeyInput(keyEl)) {
app.messages.action('Invalid secret key format. Only uppercase/lowercase letters, numbers, and underscores are allowed (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'danger');
return;
}
rawResourceSecretsMap[key] = val || '';
$('#new-secret-key').val('');
$('#new-secret-val').val('');
$('#gen-secret-notice').hide();
await saveResourceSecretsMap();
}
async function inheritParentSecret() {
const childKey = $('#inherit-child-key').val().trim();
const inheritVal = $('#inherit-parent-select').val();
if (!childKey) {
app.messages.action('Please enter a child secret key name (e.g. DB_HOST).', $('#secrets-tab-container'), 'warning');
return;
}
if (!SECRET_KEY_REGEX.test(childKey)) {
app.messages.action('Invalid child key name. Only letters, numbers, and underscores allowed.', $('#secrets-tab-container'), 'danger');
return;
}
if (!inheritVal) {
app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning');
return;
}
rawResourceSecretsMap[childKey] = inheritVal;
$('#inherit-child-key').val('');
await saveResourceSecretsMap();
}
async function deleteSecretKey(key) {
const confirmed = await app.messages.confirm(`Delete secret '${key}' from OpenBao?`, $('#secrets-tab-container'), 'danger');
if (!confirmed) return;
delete rawResourceSecretsMap[key];
await saveResourceSecretsMap();
}
async function saveResourceSecretsMap() {
const resourceId = $('#res-id').val();
if (!resourceId) return;
try {
app.messages.action('Saving secrets to OpenBao...', $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: rawResourceSecretsMap });
app.messages.action('Secret saved to OpenBao successfully!', $('#secrets-tab-container'), 'success');
loadResourceSecrets(resourceId);
} catch (err) {
app.messages.action(err.message || 'Failed to save secrets to OpenBao', $('#secrets-tab-container'), 'danger');
}
}
function refreshResourceSecrets() {
const resourceId = $('#res-id').val();
if (resourceId) loadResourceSecrets(resourceId);
}
async function saveResource() {
// Promote path: the modal was opened from a discovered inventory row, so