diff --git a/CHANGELOG.md b/CHANGELOG.md index cbbb606..f158bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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 (8–128 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 diff --git a/nodejs/models/resource.js b/nodejs/models/resource.js index 92f296e..ca90341 100644 --- a/nodejs/models/resource.js +++ b/nodejs/models/resource.js @@ -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 { diff --git a/nodejs/package.json b/nodejs/package.json index 7fc1e0e..cdcc146 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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": [ { diff --git a/nodejs/public/resources/theta-agent/install.sh b/nodejs/public/resources/theta-agent/install.sh index 7a782cf..4e3fcfe 100644 --- a/nodejs/public/resources/theta-agent/install.sh +++ b/nodejs/public/resources/theta-agent/install.sh @@ -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 < "$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 < "$SERVICE_FILE" @@ -111,8 +158,6 @@ Type=simple ExecStart=$BIN_PATH Restart=always RestartSec=5 -StandardOutput=syslog -StandardError=syslog SyslogIdentifier=theta-agent [Install] diff --git a/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 b/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 index cb9c281..92c74b7 100755 Binary files a/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 and b/nodejs/public/resources/theta-agent/theta-agent-linux-amd64 differ diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index b05e2eb..74e0224 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -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); diff --git a/nodejs/routes/api_agent_ops.js b/nodejs/routes/api_agent_ops.js index 505fdc9..3aecdce 100644 --- a/nodejs/routes/api_agent_ops.js +++ b/nodejs/routes/api_agent_ops.js @@ -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// + // 2. Bound Resource scope: secret/data/resources// + // 3. Shared Resource Grants: secret/data/resources// + 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] = {}; } } diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index c994d1e..d0e5fa7 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -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//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; diff --git a/nodejs/utils/agent_manager.js b/nodejs/utils/agent_manager.js index 8084774..c8d49e9 100644 --- a/nodejs/utils/agent_manager.js +++ b/nodejs/utils/agent_manager.js @@ -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); diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 5c1111d..58bcf51 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -48,6 +48,10 @@ +
+ + +
+
Only letters, numbers, and underscores allowed (e.g. DB_PASSWORD).
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+ +
+
+ + + + + + + + `; + // 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('Loading secrets from OpenBao...'); + 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(`Failed to load secrets: ${esc(err.message || 'Unknown error')}`); + } + } + + 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(``); + }); + $card.show(); + } + + function renderSecretsTable() { + const $tbody = $('#secrets-table-body').empty(); + + if (currentResourceSecretsList.length === 0) { + $tbody.append('No secrets configured for this resource yet.'); + return; + } + + currentResourceSecretsList.forEach((s, idx) => { + const $row = $(` + + ${esc(s.key)} + + ${s.isInherited + ? `Inherited from ${esc(s.parentSlug || 'Parent')} (${esc(s.parentKey || s.key)})` + : `Configured in OpenBao Secret Value Hidden` + } + + + + + + + `); + $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