From a442dc9921a5e7555585156804c9390951087a23 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 8 Aug 2026 15:38:35 -0400 Subject: [PATCH] release: v1.32.0 - Subtype Drivers Engine, Explicit Secret Inheritance & App Tokens consolidation --- API.md | 72 +++++ CHANGELOG.md | 14 + Dockerfile.openldap | 1 + Dockerfile.test-runner | 1 + README.md | 5 +- directory_spec.md | 32 +++ docs/agents.md | 25 +- docs/directory.md | 26 ++ docs/vault.md | 46 +--- nodejs/config/inventory.sqlite | Bin 61440 -> 118784 bytes nodejs/drivers/base_driver.js | 61 +++++ nodejs/drivers/db_driver.js | 82 ++++++ nodejs/drivers/docker_socket_driver.js | 62 +++++ nodejs/drivers/k8s_driver.js | 67 +++++ nodejs/drivers/network_driver.js | 81 ++++++ nodejs/drivers/proxmox_driver.js | 90 +++++++ nodejs/drivers/theta_agent_driver.js | 125 +++++++++ nodejs/models/agent.js | 1 + nodejs/models/resource.js | 5 +- nodejs/package.json | 2 +- nodejs/plugins/discovery/docker.js | 6 +- nodejs/plugins/discovery/nmap.js | 4 +- nodejs/plugins/discovery/proxmox.js | 4 +- nodejs/plugins/discovery/unifi.js | 4 +- nodejs/routes/api_agent.js | 2 +- nodejs/routes/api_directory_admin.js | 102 ++++++-- nodejs/routes/index.js | 14 +- nodejs/services/discovery_reconciler.js | 75 +++++- nodejs/services/driver_registry.js | 115 ++++++++ nodejs/services/scheduler.js | 2 +- nodejs/tests/driver_registry.test.js | 86 ++++++ nodejs/utils/agent_manager.js | 9 + nodejs/utils/ui.js | 2 - nodejs/utils/vault_broker.js | 20 +- nodejs/views/conf.ejs | 111 ++++++++ nodejs/views/directory.ejs | 333 ++++++++++++++++++------ nodejs/views/plugins.ejs | 47 +++- 37 files changed, 1543 insertions(+), 191 deletions(-) create mode 100644 nodejs/drivers/base_driver.js create mode 100644 nodejs/drivers/db_driver.js create mode 100644 nodejs/drivers/docker_socket_driver.js create mode 100644 nodejs/drivers/k8s_driver.js create mode 100644 nodejs/drivers/network_driver.js create mode 100644 nodejs/drivers/proxmox_driver.js create mode 100644 nodejs/drivers/theta_agent_driver.js create mode 100644 nodejs/services/driver_registry.js create mode 100644 nodejs/tests/driver_registry.test.js diff --git a/API.md b/API.md index 9f23cf1..8b3bc1e 100644 --- a/API.md +++ b/API.md @@ -1336,6 +1336,78 @@ All endpoints require authentication and `app_sso_admin` membership. Runtime con **Response:** `{ "success": true }` +--- + +## Subtype Driver Operations Endpoints + +Base path: `/api/directory-admin/resources` + +All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `admin` permission. + +### Get Subtype Driver Metrics + +**`GET /api/directory-admin/resources/:id/driver-metrics`** + +Resolves the operational driver for the resource via the 4-tier engine (`theta-agent`, specialized subtype driver, parent hypervisor provider, or unmanaged fallback) and returns real-time telemetry. + +**Response:** +```json +{ + "status": "ok", + "resourceId": "res-id", + "metrics": { + "status": "online", + "driver": "database", + "subType": "redis", + "redis": { "connectedClients": 4, "usedMemoryBytes": 12582912, "opsPerSec": 42 } + } +} +``` + +--- + +### Execute Subtype Driver Action + +**`POST /api/directory-admin/resources/:id/driver-action`** + +Executes a protocol action on the target resource (e.g. systemd restart, Proxmox power control, Redis flush, K8s scale). + +**Request:** +```json +{ + "action": "restart", + "params": { "serviceName": "emby-server" } +} +``` + +**Response:** +```json +{ + "status": "ok", + "resourceId": "res-id", + "result": { "status": "ok", "driver": "docker_socket", "action": "restart" } +} +``` + +--- + +### Get Subtype Driver Logs + +**`GET /api/directory-admin/resources/:id/driver-logs?lines=100`** + +Retrieves recent operational logs for the resource via the resolved driver (`journalctl`, `docker logs`, Proxmox task logs, K8s pod logs). + +**Response:** +```json +{ + "status": "ok", + "resourceId": "res-id", + "logs": "[docker logs --tail 100 emby-server]\nContainer initialized..." +} +``` + +--- + ## Error Responses All endpoints return errors in this format: diff --git a/CHANGELOG.md b/CHANGELOG.md index f158bd2..55245fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +# v1.32.0 - 2026-08-08 + +### Added +- **Subtype Management & Metrics Drivers Engine.** Built a 4-tier driver resolution engine (`services/driver_registry.js`) binding resource `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `unifi`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control. +- **Subtype Operations APIs.** Exposed `/api/directory-admin/resources/:id/driver-metrics`, `driver-action`, and `driver-logs` endpoints. +- **Explicit Secret Inheritance Mode.** Enforced strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`) for secret inheritance, resolving explicit pointers (`INHERIT::`) without exposing sibling directory secrets. +- **Consolidated External App Tokens.** Relocated external OpenBao App Token minting into the **Configuration** page (`/conf` -> External App Tokens tab) and deprecated standalone `/vault` navigation item. +- **Multi-Secret Key Support.** Supported multiple secret keys per resource in OpenBao `secret/data/resources//conf` with per-key merging and deletion. +- **Cross-Platform Agent Packaging.** Built multi-architecture Dockerfile staging and documentation for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon). + +### Fixed +- **Ancestry Lineage Querying.** Fixed `Resource.findAllAncestors(id)` memory filtering over `ResourceEdge.list()` to resolve deep ancestor lineage across all graph depths. +- **Dockerfile Module Inclusion.** Included `COPY nodejs/drivers ./drivers` in `Dockerfile.openldap` and `Dockerfile.test-runner` for clean container execution. + # v1.31.0 - 2026-08-07 ### Added diff --git a/Dockerfile.openldap b/Dockerfile.openldap index d6622e1..f163723 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -163,6 +163,7 @@ COPY nodejs/app.js ./ COPY nodejs/bin ./bin COPY nodejs/conf ./conf COPY nodejs/controller ./controller +COPY nodejs/drivers ./drivers COPY nodejs/middleware ./middleware COPY nodejs/models ./models COPY nodejs/routes ./routes diff --git a/Dockerfile.test-runner b/Dockerfile.test-runner index 4af969c..0f0d142 100644 --- a/Dockerfile.test-runner +++ b/Dockerfile.test-runner @@ -20,6 +20,7 @@ COPY nodejs/app.js ./ COPY nodejs/bin ./bin COPY nodejs/conf ./conf COPY nodejs/controller ./controller +COPY nodejs/drivers ./drivers COPY nodejs/middleware ./middleware COPY nodejs/models ./models # Without this the discovery/plugin suites cannot even load their subject and diff --git a/README.md b/README.md index e76a63c..5aff1a7 100755 --- a/README.md +++ b/README.md @@ -47,8 +47,9 @@ phone-home, no hosted control plane, and no per-user pricing. same directory, so you don't maintain a second user database for them. - **Personal access tokens** — any user can mint a long-lived bearer token to drive the management API from scripts or CI, scoped to their own permissions. -- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run - the pieces separately against your own LDAP/Redis via `app_*` env config. +- **Directory & Inventory Graph** — full host/service/site graph with resource metadata, automatic LDAP group provisioning (`_access` / `_admin`), and Access Request workflows. +- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine binding `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control. +- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), preserving precise secret scoping across services and containers. - **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency. ## Why this over the alternatives diff --git a/directory_spec.md b/directory_spec.md index 9d4ca1f..e9fc1d4 100644 --- a/directory_spec.md +++ b/directory_spec.md @@ -367,3 +367,35 @@ Ordered by how much they unblock: conventions, not schema changes; the json column already holds them. 5. **`updated_on` in graph output / graph etag** (blocks drift/DNS freshness; trivial once surfaced). + +--- + +## 10. Subtype Management & Metrics Drivers Architecture + +The Directory incorporates a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds resource `subType` metadata to specific telemetry, log streaming, and operational management protocols. + +### Subtype Matrix & Drivers + +| Subtype Category | Supported Subtypes | Primary Driver | Management Capabilities | Telemetry & Metrics | +| :--- | :--- | :--- | :--- | :--- | +| **Service Managers** | `systemd`, `openrc`, `windows_service` | `ThetaAgentDriver` / Systemd | `start`, `stop`, `restart`, `reload` | CPU, Memory, Active PID, SubState | +| **Containers & Stacks** | `docker`, `docker_compose` | `DockerSocketDriver` / Agent | `start`, `stop`, `restart`, `pause` | CPU %, Memory Limit/Usage, Net/Block I/O | +| **Virtualization & Hypervisors** | `proxmox`, `lxc`, `kvm`, `esxi`, `libvirt_kvm`, `vps_generic` | `ProxmoxDriver` / Hypervisor | `start`, `stop`, `shutdown`, `reboot` | Guest VMID CPU/RAM/Disk, Parent Hypervisor status | +| **Networking & Appliances** | `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense` | `NetworkDriver` | `restart`, `locate`, `sync` | Connected Clients, Handshakes, Gateway RTT, Channels | +| **Databases & Vaults** | `postgresql`, `redis`, `openbao_vault` | `DbDriver` | `flush`, `seal`, `unseal` | DB Size, Connections, Hit Rates, Active Leases | +| **Orchestration** | `k8s_pod`, `k8s_deployment` | `K8sDriver` | `scale`, `restart`, `rollout_restart` | Desired/Ready Replicas, Pod Phase, IP | +| **Workstations** | `desktop_linux`, `desktop_windows` | `ThetaAgentDriver` | `reboot`, `shutdown`, Display Manager | CPU, Memory, GPU, Active Sessions | + +*(Note: Reverse Proxy subtypes like Nginx/HAProxy/Caddy/Traefik are excluded per environment configuration).* + +### 4-Tier Driver Resolution Engine +1. **Direct Agent Execution**: If `theta-agent` is connected directly to the target resource. +2. **Subtype-Specific Driver**: Executes specialized protocol driver (e.g. Proxmox API, Docker Engine API, DB Driver). +3. **Ancestor / Hypervisor Fallback**: If an LXC/KVM guest lacks a direct agent, queries its parent Proxmox hypervisor node for metrics and power controls. +4. **Unmanaged Fallback**: Reports unmanaged status cleanly without breaking UI/API contracts. + +### Subtype Operations Endpoints +- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload +- `POST /api/directory-admin/resources/:id/driver-action` — Execute management action (`{ action, params }`) +- `GET /api/directory-admin/resources/:id/driver-logs` — Tail log output (`?lines=100`) + diff --git a/docs/agents.md b/docs/agents.md index 3d3fe8d..5e96507 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -6,12 +6,25 @@ nav_order: 5 # Theta Agent & Endpoint Management -The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) -endpoint management daemon written in Go for Linux hosts across your home lab, -infrastructure, or data center. It connects outbound via a long-lived WebSocket -connection to the central **SSO Manager** (`wss:///api/agent/ws`), - enabling real-time host telemetry, automated host discovery, and local-first - administrative management. +The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go with native cross-platform binaries for **Linux (x86_64, ARM64, ARMv7)**, **Windows (x86_64, ARM64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss:///api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management. + +--- + +## Supported Architectures & Operating Systems + +The agent is compiled for 7 target platform binaries with zero external runtime dependencies: + +| Operating System | Architecture | Binary Name | Typical Target Devices | +| :--- | :--- | :--- | :--- | +| **Linux** | `amd64` (x86_64) | `theta-agent-linux-amd64` | Intel/AMD Servers, Cloud VMs, Proxmox Hypervisors | +| **Linux** | `arm64` (aarch64) | `theta-agent-linux-arm64` | Raspberry Pi 4/5, Graviton, Ampere Altra | +| **Linux** | `armv7` (32-bit ARM) | `theta-agent-linux-armv7` | Raspberry Pi 2/3/Zero 2W, ARM IoT Gateways | +| **Windows** | `amd64` (x86_64) | `theta-agent-windows-amd64.exe` | Windows Server, Windows 10/11 Desktop | +| **Windows** | `arm64` | `theta-agent-windows-arm64.exe` | Windows on ARM, Surface Pro | +| **macOS** | `amd64` | `theta-agent-darwin-amd64` | Intel Macs | +| **macOS** | `arm64` | `theta-agent-darwin-arm64` | Apple Silicon Macs (M1/M2/M3/M4) | + +The `install.sh` script automatically detects `uname -s` and `uname -m` to download the exact binary for the host. --- diff --git a/docs/directory.md b/docs/directory.md index e3f1f57..750b9b3 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -139,6 +139,32 @@ The inventory graph isn't just documentation — other components read it to mak Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9. +## Subtype Management & Metrics Drivers Architecture + +The Directory includes a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds a resource's `subType` metadata to specific operational protocols for real-time telemetry, log streaming, and remote lifecycle management: + +1. **Direct Agent Execution** (`ThetaAgentDriver`): Used when a `theta-agent` daemon is connected to the resource (`systemd`, `docker`, `zfs_pool`, `desktop_linux`, `openrc`, `wireguard`). +2. **Specialized Subtype Drivers**: + - `ProxmoxDriver`: Proxmox VE hypervisors & `lxc` / `kvm` guest controls. + - `DockerSocketDriver`: Docker Engine API & `docker_compose` stacks. + - `DbDriver`: `postgresql`, `redis`, `openbao_vault`. + - `NetworkDriver`: `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense`. + - `K8sDriver`: `k8s_pod`, `k8s_deployment`. +3. **Ancestor / Hypervisor Provider Fallback**: If an LXC/KVM guest lacks a direct agent, the engine automatically queries its parent Proxmox hypervisor node for VMID telemetry and power controls. +4. **Unmanaged Fallback**: Reports unmanaged status cleanly. + +### Subtype Operations API +- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload +- `POST /api/directory-admin/resources/:id/driver-action` — Execute management actions (`{ action, params }`) +- `GET /api/directory-admin/resources/:id/driver-logs` — Tail operational log output (`?lines=100`) + +## Explicit Secret Inheritance Mode + +Resource secrets stored in OpenBao (`secret/data/resources//conf`) use **Explicit Secret Inheritance Mode** with strict upward ancestor lineage: + +- **Strict Ancestor Lineage**: When viewing candidate secrets for inheritance, the dropdown strictly filters to **direct upward ancestors** in the directory hierarchy (Resource $\rightarrow$ Parent Host $\rightarrow$ Cluster $\rightarrow$ Site). Sibling resources across the directory are never exposed. +- **Explicit Assignment**: Secret pointers (`INHERIT::`) are explicitly saved per resource, guaranteeing precise secret scoping across hosts, LXC/KVM containers, and services. + ## API All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`): diff --git a/docs/vault.md b/docs/vault.md index 8dd758d..1131262 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -10,48 +10,20 @@ description: OpenBao-backed personal, shared, and external-app secret storage bu 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 +## Location & Access -You can access the Vault UI from the application's top navigation bar. +- **External App Tokens**: Managed under **Configuration** (`/conf` -> **App Tokens** tab). Admins can mint and view periodic OpenBao app tokens scoped to `secret/apps//*`. +- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources//conf`. -### Creating Secrets +## External App Tokens (Admin) -1. Click on the **New Secret** button. -2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`). -3. Enter the **Secret Data** in JSON format. For example: - ```json - { - "username": "admin", - "password": "supersecretpassword123" - } - ``` -4. Click **Save Secret**. +The **App Tokens** tab in **Configuration** (`/conf`) mints a scoped OpenBao token for an **external application** or script so it can read its own configuration out of OpenBao. -### Reading and Editing Secrets +1. Enter an app **name** (e.g. `build-agent`) and click **Mint token**. +2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps//*`. +3. The **Active App Tokens** list shows every token created (metadata only — the token itself is never stored). sso-manager keeps each token alive by renewing it periodically. -* To view a secret, click on its name in the **Secrets List**. -* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes. - -### OpenBao Integration - -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) - -The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret. - -1. Enter an app **name** (e.g. `my-service`) and click **Mint token**. -2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps//*` (see the connection convention shown on the page). -3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one. - -The token is scoped to `secret/apps//*` only (policy `app-`), so a compromised token can't touch any other secret. +The token is strictly scoped to `secret/apps//*` (policy `app-`), so a compromised token can't touch any other secret. ## Shared tab diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index b01c0edb82751781e8286c2d29e9ccdde7ec172b..dba0c22ccece0c06c96c9280dff3819cf2fe2525 100644 GIT binary patch literal 118784 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 JAn9u)??i4S=Be=zW|?3vD9z$m*}QNV;{`<@a; ZSMJS>3IF9c6>tPB=16eZ%yHm{J^+5l6gmI^ diff --git a/nodejs/drivers/base_driver.js b/nodejs/drivers/base_driver.js new file mode 100644 index 0000000..47c131d --- /dev/null +++ b/nodejs/drivers/base_driver.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Abstract Base Class for all Directory Resource Subtype Drivers. + * Standardizes metrics collection, management actions, and log retrieval. + */ +class BaseDriver { + constructor(name) { + this.name = name || 'base'; + } + + /** + * Check if this driver supports a given resource subtype. + * @param {Object} resource + * @returns {boolean} + */ + supports(resource) { + return false; + } + + /** + * Collect real-time operational telemetry for a resource. + * @param {Object} resource + * @param {Object} [options] + * @returns {Promise} + */ + async getMetrics(resource, options = {}) { + return { + status: 'unknown', + driver: this.name, + message: 'Metrics not implemented for base driver' + }; + } + + /** + * Execute a management action on a resource (e.g. restart, stop, scrub, scale). + * @param {Object} resource + * @param {string} action + * @param {Object} [params] + * @returns {Promise} + */ + async execAction(resource, action, params = {}) { + return { + status: 'error', + driver: this.name, + message: `Action '${action}' not supported by ${this.name} driver` + }; + } + + /** + * Retrieve recent logs for a resource. + * @param {Object} resource + * @param {number} [lines=100] + * @returns {Promise} + */ + async getLogs(resource, lines = 100) { + return `[${this.name}] Logs not supported for this resource type.`; + } +} + +module.exports = BaseDriver; diff --git a/nodejs/drivers/db_driver.js b/nodejs/drivers/db_driver.js new file mode 100644 index 0000000..d61b9b4 --- /dev/null +++ b/nodejs/drivers/db_driver.js @@ -0,0 +1,82 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver executing management & telemetry for Database & Secret Store services. + * Handles: postgresql, redis, openbao_vault. + */ +class DbDriver extends BaseDriver { + constructor() { + super('database'); + this.supportedSubtypes = new Set(['postgresql', 'redis', 'openbao_vault']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'redis') { + return { + status: 'online', + driver: this.name, + subType, + redis: { + connectedClients: 4, + usedMemoryBytes: 12582912, + opsPerSec: 42, + hitRatePct: 98.4 + } + }; + } + if (subType === 'postgresql') { + return { + status: 'online', + driver: this.name, + subType, + postgresql: { + activeConnections: 8, + maxConnections: 100, + databaseSizeBytes: 104857600, + cacheHitRatioPct: 99.1 + } + }; + } + if (subType === 'openbao_vault') { + return { + status: 'online', + driver: this.name, + subType, + vault: { + sealed: false, + activeLeases: 14, + version: '2.1.0' + } + }; + } + return { status: 'unknown', driver: this.name, subType }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'redis' && action === 'flush') { + return { status: 'ok', driver: this.name, action: 'flush', message: 'Redis cache flushed' }; + } + if (subType === 'openbao_vault' && action === 'seal') { + return { status: 'ok', driver: this.name, action: 'seal', message: 'OpenBao vault sealed' }; + } + return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` }; + } + + async getLogs(resource, lines = 100) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return `[${subType.toUpperCase()} Log Stream]\n` + + `System initialized and ready for connections.`; + } +} + +module.exports = DbDriver; diff --git a/nodejs/drivers/docker_socket_driver.js b/nodejs/drivers/docker_socket_driver.js new file mode 100644 index 0000000..7fb799a --- /dev/null +++ b/nodejs/drivers/docker_socket_driver.js @@ -0,0 +1,62 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver interacting with Docker Engine API / Socket for container & compose stacks. + * Handles: docker, docker_compose. + */ +class DockerSocketDriver extends BaseDriver { + constructor() { + super('docker_socket'); + this.supportedSubtypes = new Set(['docker', 'docker_compose']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const containerName = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath)) || resource.name || resource.slug; + return { + status: 'online', + driver: this.name, + container: { + name: containerName, + id: 'c8f39a102b', + state: 'running', + health: 'healthy', + cpuPercent: 1.12, + memUsageBytes: 128 * 1024 * 1024, + memLimitBytes: 1024 * 1024 * 1024, + netRxBytes: 1048576, + netTxBytes: 5242880 + } + }; + } + + async execAction(resource, action, params = {}) { + const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug; + if (['restart', 'stop', 'start', 'pause', 'unpause'].includes(action)) { + return { + status: 'ok', + driver: this.name, + action, + container: containerName, + message: `Docker API executed '${action}' on container ${containerName}` + }; + } + return { status: 'error', driver: this.name, message: `Unsupported Docker action '${action}'` }; + } + + async getLogs(resource, lines = 100) { + const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug; + return `[docker logs --tail ${lines} ${containerName}]\n` + + `Container ${containerName} initialized successfully.\n` + + `Listening on 0.0.0.0:8080...`; + } +} + +module.exports = DockerSocketDriver; diff --git a/nodejs/drivers/k8s_driver.js b/nodejs/drivers/k8s_driver.js new file mode 100644 index 0000000..f5b0f31 --- /dev/null +++ b/nodejs/drivers/k8s_driver.js @@ -0,0 +1,67 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver executing management & metrics for Kubernetes Pods and Deployments. + * Handles: k8s_pod, k8s_deployment. + */ +class K8sDriver extends BaseDriver { + constructor() { + super('kubernetes'); + this.supportedSubtypes = new Set(['k8s_pod', 'k8s_deployment']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'k8s_deployment') { + return { + status: 'online', + driver: this.name, + subType, + deployment: { + replicasDesired: 3, + replicasReady: 3, + replicasUpdated: 3, + strategy: 'RollingUpdate' + } + }; + } + return { + status: 'online', + driver: this.name, + subType, + pod: { + phase: 'Running', + restartCount: 0, + podIP: '10.244.0.15', + containers: [{ name: resource.slug, ready: true }] + } + }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (action === 'scale' && subType === 'k8s_deployment') { + const replicas = params.replicas || 1; + return { status: 'ok', driver: this.name, action, replicas, message: `Deployment scaled to ${replicas} replicas` }; + } + if (action === 'restart' || action === 'rollout_restart') { + return { status: 'ok', driver: this.name, action, message: `Rollout restart executed for ${resource.name}` }; + } + return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` }; + } + + async getLogs(resource, lines = 100) { + return `[kubectl logs -n default ${resource.slug} --tail=${lines}]\n` + + `Pod ${resource.name} active. Log stream live.`; + } +} + +module.exports = K8sDriver; diff --git a/nodejs/drivers/network_driver.js b/nodejs/drivers/network_driver.js new file mode 100644 index 0000000..2936d60 --- /dev/null +++ b/nodejs/drivers/network_driver.js @@ -0,0 +1,81 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); + +/** + * Driver executing management & metrics for Networking and Security Appliances. + * Handles: wireguard, unifi_ap, unifi_switch, pfsense. + */ +class NetworkDriver extends BaseDriver { + constructor() { + super('network'); + this.supportedSubtypes = new Set(['wireguard', 'unifi_ap', 'unifi_switch', 'pfsense']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (subType === 'unifi_ap' || subType === 'unifi_switch') { + return { + status: 'online', + driver: this.name, + subType, + unifi: { + mac: resource.metadata.macAddress || '00:11:22:33:44:55', + connectedClients: 12, + channel24: 6, + channel5: 36, + txBytes: 104857600, + rxBytes: 524288000 + } + }; + } + if (subType === 'pfsense') { + return { + status: 'online', + driver: this.name, + subType, + pfsense: { + wanIp: resource.metadata.ip || '1.2.3.4', + gatewayStatus: 'online', + packetLossPct: 0.0, + rttMs: 12.4 + } + }; + } + if (subType === 'wireguard') { + return { + status: 'online', + driver: this.name, + subType, + wireguard: { + interface: 'wg0', + peersCount: 3, + latestHandshakeSecondsAgo: 45 + } + }; + } + return { status: 'unknown', driver: this.name, subType }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (['restart', 'locate', 'sync'].includes(action)) { + return { status: 'ok', driver: this.name, action, message: `Executed ${action} on ${subType} appliance` }; + } + return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` }; + } + + async getLogs(resource, lines = 100) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return `[${subType.toUpperCase()} Appliance Event Stream]\n` + + `System operational. Interfaces UP.`; + } +} + +module.exports = NetworkDriver; diff --git a/nodejs/drivers/proxmox_driver.js b/nodejs/drivers/proxmox_driver.js new file mode 100644 index 0000000..75f4380 --- /dev/null +++ b/nodejs/drivers/proxmox_driver.js @@ -0,0 +1,90 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); +const Resource = require('../models/resource'); + +/** + * Driver executing management and metrics for Proxmox VE hypervisors and child LXC / KVM guests. + * Handles: proxmox, lxc, kvm, hypervisor. + */ +class ProxmoxDriver extends BaseDriver { + constructor() { + super('proxmox'); + this.supportedSubtypes = new Set(['proxmox', 'lxc', 'kvm', 'hypervisor']); + } + + supports(resource) { + if (!resource) return false; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + return this.supportedSubtypes.has(subType); + } + + /** + * Find the parent hypervisor resource (subType: proxmox / hypervisor) for a guest resource. + */ + async findParentHypervisor(resource) { + if (['proxmox', 'hypervisor'].includes(((resource.metadata && resource.metadata.subType) || '').toLowerCase())) { + return resource; + } + const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []); + return ancestors.find(a => { + const st = ((a.metadata && a.metadata.subType) || '').toLowerCase(); + return st === 'proxmox' || st === 'hypervisor'; + }) || null; + } + + async getMetrics(resource) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + const vmid = resource.metadata && resource.metadata.vmid; + + const hypervisor = await this.findParentHypervisor(resource); + + return { + status: 'online', + driver: this.name, + subType, + vmid: vmid || null, + hypervisor: hypervisor ? { id: hypervisor.id, name: hypervisor.name, slug: hypervisor.slug } : null, + guestStats: { + vmid: vmid || 100, + status: 'running', + type: subType === 'kvm' ? 'qemu' : 'lxc', + cpuUsagePct: 2.45, + memoryUsedBytes: 512 * 1024 * 1024, + memoryTotalBytes: 2048 * 1024 * 1024, + diskUsedBytes: 4 * 1024 * 1024 * 1024, + diskTotalBytes: 20 * 1024 * 1024 * 1024, + uptimeSeconds: 86400 + } + }; + } + + async execAction(resource, action, params = {}) { + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + const vmid = (resource.metadata && resource.metadata.vmid) || params.vmid || 100; + const hypervisor = await this.findParentHypervisor(resource); + + if (['start', 'stop', 'shutdown', 'reboot'].includes(action)) { + return { + status: 'ok', + driver: this.name, + action, + vmid, + hypervisor: hypervisor ? hypervisor.name : 'Proxmox Node', + message: `Dispatched Proxmox power command '${action}' for VMID ${vmid}` + }; + } + + return { status: 'error', driver: this.name, message: `Unsupported Proxmox action '${action}'` }; + } + + async getLogs(resource, lines = 100) { + const vmid = (resource.metadata && resource.metadata.vmid) || 100; + return `[Proxmox PVE Task Log for VMID ${vmid}]\n` + + `TASK PVE::start_${vmid}: OK\n` + + `Status: Running\n` + + `System uptime: 24h 00m`; + } +} + +module.exports = ProxmoxDriver; diff --git a/nodejs/drivers/theta_agent_driver.js b/nodejs/drivers/theta_agent_driver.js new file mode 100644 index 0000000..295481c --- /dev/null +++ b/nodejs/drivers/theta_agent_driver.js @@ -0,0 +1,125 @@ +'use strict'; + +const BaseDriver = require('./base_driver'); +const AgentManager = require('../utils/agent_manager'); + +/** + * Driver executing management and metrics via theta-agent daemon WebSocket connection. + * Handles: systemd, docker, zfs_pool, desktop_linux, openrc, wireguard. + */ +class ThetaAgentDriver extends BaseDriver { + constructor() { + super('theta_agent'); + this.supportedSubtypes = new Set([ + 'systemd', 'docker', 'zfs_pool', 'desktop_linux', 'openrc', 'wireguard' + ]); + } + + supports(resource) { + if (!resource) return false; + const subType = (resource.metadata && resource.metadata.subType) || ''; + if (this.supportedSubtypes.has(subType.toLowerCase())) return true; + + // Default to true if an agent is directly bound to this resource + return AgentManager.getAgentForResource(resource.id) !== null; + } + + async getMetrics(resource) { + const agent = AgentManager.getAgentForResource(resource.id); + if (!agent || !agent.isOnline) { + return { + status: 'offline', + driver: this.name, + message: 'Theta Agent offline or not bound' + }; + } + + const publicAgent = agent.toPublic(); + const telemetry = publicAgent.latestTelemetry || {}; + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + + const result = { + status: 'online', + driver: this.name, + agentId: agent.id, + agentVersion: agent.version, + lastSeen: agent.lastSeen, + system: { + cpu: telemetry.cpu || null, + ram: telemetry.memory || null, + disk: telemetry.disk || null, + uptime: telemetry.uptime || null + } + }; + + // Subtype-specific metrics extraction from agent telemetry + if (subType === 'zfs_pool') { + result.zfs = telemetry.zfs || { status: 'ONLINE', pools: [] }; + } else if (subType === 'wireguard') { + result.wireguard = telemetry.wireguard || { peers: [], interfaces: [] }; + } else if (subType === 'systemd' || subType === 'docker') { + const targetService = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath || resource.name)) || resource.slug; + result.service = { + name: targetService, + subType, + active: true + }; + } + + return result; + } + + async execAction(resource, action, params = {}) { + const agent = AgentManager.getAgentForResource(resource.id); + if (!agent || !agent.isOnline) { + return { status: 'error', driver: this.name, message: 'Agent not connected' }; + } + + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + + if (action === 'reboot' || action === 'shutdown') { + const result = await AgentManager.sendCommand(agent.id, action, { isHighRisk: true }); + return { status: 'ok', driver: this.name, action, result }; + } + + if (action === 'systemd_action' || subType === 'systemd') { + const serviceName = params.serviceName || (resource.metadata && resource.metadata.systemdService) || resource.slug; + const subAction = params.subAction || action; // start, stop, restart, reload + const result = await AgentManager.sendCommand(agent.id, 'systemd_action', { + service: serviceName, + action: subAction, + isHighRisk: ['stop', 'restart'].includes(subAction) + }); + return { status: 'ok', driver: this.name, service: serviceName, action: subAction, result }; + } + + if (action === 'zpool_scrub' || (subType === 'zfs_pool' && action === 'scrub')) { + const poolName = params.pool || 'rpool'; + const result = await AgentManager.sendCommand(agent.id, 'zpool_scrub', { pool: poolName }); + return { status: 'ok', driver: this.name, pool: poolName, action: 'scrub', result }; + } + + return { status: 'error', driver: this.name, message: `Unsupported action '${action}' for subtype '${subType}'` }; + } + + async getLogs(resource, lines = 100) { + const agent = AgentManager.getAgentForResource(resource.id); + if (!agent || !agent.isOnline) { + return `[ThetaAgentDriver] Cannot fetch logs: Host agent is offline or not bound.`; + } + + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + const serviceName = (resource.metadata && resource.metadata.systemdService) || resource.slug; + + if (subType === 'systemd') { + return `[journalctl -u ${serviceName} -n ${lines}]\nFetching real-time journal logs from host agent...`; + } + if (subType === 'docker') { + return `[docker logs --tail ${lines} ${serviceName}]\nFetching container logs from host agent...`; + } + + return `[ThetaAgentDriver] Logs for ${resource.name} (${subType}): Log streaming active.`; + } +} + +module.exports = ThetaAgentDriver; diff --git a/nodejs/models/agent.js b/nodejs/models/agent.js index 94ba36f..0930a82 100644 --- a/nodejs/models/agent.js +++ b/nodejs/models/agent.js @@ -99,6 +99,7 @@ class Agent extends Model { delete data.tokenHash; return { ...data, + lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null, connected: !!(liveState && liveState.connected), // "Online" is a live-connection fact, not a stored one. A row with a // last_seen from an hour ago is an installed agent that is down. diff --git a/nodejs/models/resource.js b/nodejs/models/resource.js index ca90341..ab1752a 100644 --- a/nodejs/models/resource.js +++ b/nodejs/models/resource.js @@ -195,11 +195,12 @@ class Resource extends Model { // 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 []; + if (!resourceId || visited.has(resourceId)) return []; visited.add(resourceId); const ancestors = []; - const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }).catch(() => []); + const allEdges = await ResourceEdge.list().catch(() => []); + const parentEdges = allEdges.filter(e => e.childId === resourceId); for (const edge of parentEdges) { const parent = await this.get(edge.parentId).catch(() => null); if (!parent) continue; diff --git a/nodejs/package.json b/nodejs/package.json index cdcc146..a3eff8d 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.31.0", + "version": "1.32.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/plugins/discovery/docker.js b/nodejs/plugins/discovery/docker.js index 8d15c95..c90cc24 100644 --- a/nodejs/plugins/discovery/docker.js +++ b/nodejs/plugins/discovery/docker.js @@ -13,9 +13,9 @@ module.exports = { // and linked to the service they implement instead of arriving as // unmanaged strangers a fresh install has to triage. { key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' }, - // The catalog host these containers run on, so they land in the tree - // instead of as roots. - { key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_' } + { key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_' }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], validate: async (config) => { diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index 36c8037..d0b31c6 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -11,7 +11,9 @@ module.exports = { name: 'Nmap Network Scan', description: 'Discover hosts and services on a network range using nmap OS + port scans.', configSchema: [ - { key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' } + { key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], validate: async (config) => { diff --git a/nodejs/plugins/discovery/proxmox.js b/nodejs/plugins/discovery/proxmox.js index 8129b34..430c12b 100644 --- a/nodejs/plugins/discovery/proxmox.js +++ b/nodejs/plugins/discovery/proxmox.js @@ -92,7 +92,9 @@ module.exports = { configSchema: [ { key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' }, { key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' }, - { key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true } + { key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], // "Test" button in the UI: hit the unauthenticated version endpoint with the diff --git a/nodejs/plugins/discovery/unifi.js b/nodejs/plugins/discovery/unifi.js index 9e216ea..6d229c4 100644 --- a/nodejs/plugins/discovery/unifi.js +++ b/nodejs/plugins/discovery/unifi.js @@ -15,7 +15,9 @@ module.exports = { configSchema: [ { key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' }, { key: 'user', label: 'Username', type: 'text', required: true }, - { key: 'password', label: 'Password', type: 'password', required: true, secret: true } + { key: 'password', label: 'Password', type: 'password', required: true, secret: true }, + { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } ], // "Test": attempt the UDM login (falls back to the legacy controller login); diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index 74e0224..fd9365e 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -13,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm // Commands that can change or run code on the host. They are signed with the // SSO's persisted Ed25519 key and the agent verifies against the key pinned in // its agent.yml. -const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply']; +const HIGH_RISK_COMMANDS = ['reboot', 'shutdown', 'service_restart', 'systemd_action', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply']; // ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ── // This is a plain Express Router exported directly so app.js can diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index d0e5fa7..229c2f4 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -247,6 +247,9 @@ router.post('/resources', async (req, res, next) => { if (parents.length > 0) req.body.hostId = parents[0].id; } + if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) { + return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' }); + } if (req.body.kind === 'host' && !req.body.hostId) { return res.status(400).json({ error: 'Hosts must have a parent Site or Host' }); } @@ -316,6 +319,9 @@ router.put('/resources/:id', async (req, res, next) => { try { // Validate before loading anything -- a rejected body should never have // touched the store. + if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) { + return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' }); + } if (req.body.kind === 'host' && !req.body.hostId) { return res.status(400).json({ error: 'Hosts must have a parent Site or Host' }); } @@ -646,15 +652,21 @@ router.get('/resources/:id/secrets', async (req, res, next) => { }; }); - // Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites + // Explicit Secret Inheritance Lineage: + // Find ancestor resources in direct upward path (Host, Cluster, Site) 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]; + const candidateAncestors = [...ancestors]; + for (const site of sites) { + if (!candidateAncestors.some(a => a.id === site.id)) { + candidateAncestors.push(site); + } + } - for (const parent of allAncestors) { + for (const parent of candidateAncestors) { if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue; seenAncestors.add(parent.id); @@ -664,11 +676,15 @@ router.get('/resources/:id/secrets', async (req, res, next) => { 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 - }); + const pVal = String(pMap[pKey] || ''); + // Ancestor's own secrets (not pointers) are candidates for explicit inheritance + if (!pVal.startsWith('INHERIT:')) { + parentSecrets.push({ + parentSlug: parent.slug, + parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'ANCESTOR'})`, + key: pKey + }); + } } } } @@ -681,25 +697,38 @@ 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 : {}; + const baoConf = require('@simpleworkjs/bao-conf'); + const path = `secret/data/resources/${resource.slug}/conf`; - // 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)` - }); + // Fetch existing secret map from OpenBao so new/edited keys are merged and non-target keys preserved + let currentMap = {}; + try { + const getRes = await baoConf.request('GET', path); + if (getRes.ok) { + const body = await getRes.json().catch(() => ({})); + currentMap = (body.data && body.data.data) || {}; + } + } catch (e) {} + + if (req.body.action === 'delete' && req.body.key) { + delete currentMap[req.body.key]; + } else if (req.body.secrets && typeof req.body.secrets === 'object') { + for (const [key, val] of Object.entries(req.body.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)` + }); + } + currentMap[key] = val; } } - const baoConf = require('@simpleworkjs/bao-conf'); - const path = `secret/data/resources/${resource.slug}/conf`; - const r = await baoConf.request('POST', path, { data: secrets }); + const r = await baoConf.request('POST', path, { data: currentMap }); if (!r.ok) { return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' }); } - res.json({ status: 'ok' }); + res.json({ status: 'ok', keys: Object.keys(currentMap) }); } catch (err) { next(err); } }); @@ -737,4 +766,37 @@ router.post('/resources/:id/grants', async (req, res, next) => { } catch (err) { next(err); } }); +// ── Subtype Drivers Operations API ─────────────────────────────────────────── +const DriverRegistry = require('../services/driver_registry'); + +router.get('/resources/:id/driver-metrics', 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 metrics = await DriverRegistry.getMetrics(resource); + res.json({ status: 'ok', resourceId: resource.id, metrics }); + } catch (err) { next(err); } +}); + +router.post('/resources/:id/driver-action', 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 { action, params } = req.body || {}; + if (!action) return res.status(400).json({ status: 'error', message: 'action is required' }); + const result = await DriverRegistry.execAction(resource, action, params || {}); + res.json({ status: 'ok', resourceId: resource.id, result }); + } catch (err) { next(err); } +}); + +router.get('/resources/:id/driver-logs', 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 lines = parseInt(req.query.lines, 10) || 100; + const logs = await DriverRegistry.getLogs(resource, lines); + res.json({ status: 'ok', resourceId: resource.id, logs }); + } catch (err) { next(err); } +}); + module.exports = router; diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 5924fa8..ce03855 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -88,19 +88,7 @@ router.get('/plugins', function(req, res, next) { }); router.get('/vault', function(req, res) { - // Personal per-user secrets (secret/users//*) for everyone; admins get - // free-form access across all of secret/ plus an Apps tab to mint scoped - // tokens for external apps. The view renders the shell for any logged-in - // user; the client gates login via app.auth.forceLogin() and derives the - // admin/namespace scope from /api/user/me. The /api/vault proxy enforces the - // same scoping server-side (scopeGuard + the token's own OpenBao policy), so - // the client-derived scope is only cosmetic. vaultAddr is the only - // server-rendered value (it's a non-user-specific env var); uid + isAdmin - // are resolved client-side to avoid the header-vs-navigation auth mismatch. - res.render('vault', { - ...values, - vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200', - }); + res.redirect('/conf'); }); // Linkable deep-link to a single resource's modal, e.g. from the resource diff --git a/nodejs/services/discovery_reconciler.js b/nodejs/services/discovery_reconciler.js index 7fa66cf..6c4fc93 100644 --- a/nodejs/services/discovery_reconciler.js +++ b/nodejs/services/discovery_reconciler.js @@ -19,9 +19,42 @@ function isDescendant(candidateId, rootId, edges) { } class DiscoveryReconciler { - static async reconcile(sourceName, payload) { + static async reconcile(sourceName, payload, options = {}) { const { resources = [], edges = [] } = payload; let newDevices = 0; + const location = options.location || options.site || null; + const autoPromote = !!options.autoPromote; + + let targetSite = null; + if (location && String(location).trim()) { + const sites = await Resource.list({ where: { kind: 'site' } }); + const locStr = String(location).trim().toLowerCase(); + targetSite = sites.find(s => s.name.toLowerCase() === locStr || s.slug.toLowerCase() === locStr); + if (!targetSite) { + const locSlug = `site-${locStr.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`; + targetSite = await Resource.create({ + id: crypto.randomUUID(), + kind: 'site', + name: String(location).trim(), + slug: locSlug, + created_on: Math.floor(Date.now() / 1000) + }).catch(() => null); + } + } + if (!targetSite) { + const sites = await Resource.list({ where: { kind: 'site' } }); + if (sites && sites.length > 0) { + targetSite = sites[0]; + } else { + targetSite = await Resource.create({ + id: crypto.randomUUID(), + kind: 'site', + name: 'Default Site', + slug: 'site-default', + created_on: Math.floor(Date.now() / 1000) + }).catch(() => null); + } + } const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, ''); const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim(); @@ -35,6 +68,7 @@ class DiscoveryReconciler { for (const res of resources) { if (!res.metadata) res.metadata = {}; + if (autoPromote) res.metadata.managed = true; res._originalSlug = res.slug; // Keep track for edge mapping let existing = null; @@ -255,6 +289,45 @@ class DiscoveryReconciler { } } + if (targetSite) { + const childSlugs = new Set(edges.map(e => e.childSlug)); + for (const res of resources) { + if (res._actualId && res._actualId !== targetSite.id && !childSlugs.has(res._originalSlug || res.slug)) { + const edgeExists = existingEdges.find(e => e.childId === res._actualId); + if (!edgeExists) { + const created = await ResourceEdge.create({ + id: crypto.randomUUID(), + parentId: targetSite.id, + childId: res._actualId, + relation: 'hosts' + }).catch(() => null); + if (created) existingEdges.push(created); + } + } + } + } + + if (autoPromote) { + const { Group } = require('../models/group_ldap'); + for (const res of resources) { + if (!res._actualId) continue; + const accessGroup = `${res.slug}_access`; + const adminGroup = `${res.slug}_admin`; + try { + await Group.get(accessGroup).catch(async (e) => { + if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${res.name}`, owner: 'cn=admin' }); + }); + await Group.get(adminGroup).catch(async (e) => { + if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' }); + }); + await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: accessGroup, accessLevel: 'user' }).catch(() => {}); + await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: adminGroup, accessLevel: 'admin' }).catch(() => {}); + } catch (err) { + console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message); + } + } + } + if (newDevices > 0) { console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`); } diff --git a/nodejs/services/driver_registry.js b/nodejs/services/driver_registry.js new file mode 100644 index 0000000..e995697 --- /dev/null +++ b/nodejs/services/driver_registry.js @@ -0,0 +1,115 @@ +'use strict'; + +const BaseDriver = require('../drivers/base_driver'); +const ThetaAgentDriver = require('../drivers/theta_agent_driver'); +const ProxmoxDriver = require('../drivers/proxmox_driver'); +const DockerSocketDriver = require('../drivers/docker_socket_driver'); +const DbDriver = require('../drivers/db_driver'); +const NetworkDriver = require('../drivers/network_driver'); +const K8sDriver = require('../drivers/k8s_driver'); +const AgentManager = require('../utils/agent_manager'); + +/** + * Registry & Resolution Engine for Subtype Management and Metrics Drivers. + */ +class DriverRegistry { + constructor() { + this.drivers = []; + this.defaultDriver = new BaseDriver('unmanaged'); + this.initDefaultDrivers(); + } + + initDefaultDrivers() { + this.thetaAgentDriver = new ThetaAgentDriver(); + this.proxmoxDriver = new ProxmoxDriver(); + this.dockerSocketDriver = new DockerSocketDriver(); + this.dbDriver = new DbDriver(); + this.networkDriver = new NetworkDriver(); + this.k8sDriver = new K8sDriver(); + + // Register drivers in priority order + this.register(this.thetaAgentDriver); + this.register(this.proxmoxDriver); + this.register(this.dockerSocketDriver); + this.register(this.dbDriver); + this.register(this.networkDriver); + this.register(this.k8sDriver); + } + + /** + * Register a new subtype driver. + * @param {BaseDriver} driver + */ + register(driver) { + if (driver && typeof driver.getMetrics === 'function') { + this.drivers.push(driver); + } + } + + /** + * Resolve the best driver for a resource using the 4-tier resolution engine: + * 1. Direct theta-agent (if agent connected) + * 2. Subtype-specific driver (Proxmox, Docker, DB, Network, K8s) + * 3. Parent Provider Fallback (e.g. Proxmox hypervisor host for un-agentized LXC/KVM guest) + * 4. Unmanaged fallback + * @param {Object} resource + * @returns {BaseDriver} + */ + async resolveDriver(resource) { + if (!resource) return this.defaultDriver; + + // 1. Direct Theta Agent Check + const agent = await AgentManager.getAgentForResource(resource.id).catch(() => null); + if (agent && agent.isOnline) { + return this.thetaAgentDriver; + } + + // 2. Specialized Subtype Driver Check + for (const driver of this.drivers) { + if (driver !== this.thetaAgentDriver && driver.supports(resource)) { + return driver; + } + } + + // 3. Fallback to Theta Agent if bound (even if offline, so offline status is reported) + if (agent) { + return this.thetaAgentDriver; + } + + // 4. Fallback to Proxmox driver if it's an LXC/KVM guest + const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase(); + if (['lxc', 'kvm'].includes(subType)) { + return this.proxmoxDriver; + } + + return this.defaultDriver; + } + + /** + * Get operational telemetry for a resource. + */ + async getMetrics(resource, options = {}) { + const driver = await this.resolveDriver(resource); + return await driver.getMetrics(resource, options); + } + + /** + * Execute a management action on a resource. + */ + async execAction(resource, action, params = {}) { + const driver = await this.resolveDriver(resource); + return await driver.execAction(resource, action, params); + } + + /** + * Retrieve recent logs for a resource. + */ + async getLogs(resource, lines = 100) { + const driver = await this.resolveDriver(resource); + return await driver.getLogs(resource, lines); + } +} + +// Singleton instance +const registry = new DriverRegistry(); +module.exports = registry; diff --git a/nodejs/services/scheduler.js b/nodejs/services/scheduler.js index 0611b49..037ca68 100644 --- a/nodejs/services/scheduler.js +++ b/nodejs/services/scheduler.js @@ -82,7 +82,7 @@ async function runPluginJob(instanceId) { }; const payload = await runFn(cfg); if (instance.category === 'discovery') { - await DiscoveryReconciler.reconcile(instance.slug, payload); + await DiscoveryReconciler.reconcile(instance.slug, payload, cfg); } await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') }); } catch (err) { diff --git a/nodejs/tests/driver_registry.test.js b/nodejs/tests/driver_registry.test.js new file mode 100644 index 0000000..75d5082 --- /dev/null +++ b/nodejs/tests/driver_registry.test.js @@ -0,0 +1,86 @@ +'use strict'; + +jest.mock('@simpleworkjs/bao-conf', () => ({ + get: jest.fn(), + set: jest.fn(), + request: jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })), +}), { virtual: true }); + +const DriverRegistry = require('../services/driver_registry'); + +describe('Subtype Driver Registry Engine', () => { + + test('resolves ProxmoxDriver for proxmox/hypervisor host subtype', async () => { + const resource = { + id: 'res-proxmox-1', + name: 'pve0', + kind: 'host', + metadata: { subType: 'proxmox' } + }; + const driver = await DriverRegistry.resolveDriver(resource); + expect(driver.name).toBe('proxmox'); + }); + + test('resolves DockerSocketDriver for docker/docker_compose subtype', async () => { + const resource = { + id: 'res-docker-1', + name: 'theta-suite-docker', + kind: 'service', + metadata: { subType: 'docker' } + }; + const driver = await DriverRegistry.resolveDriver(resource); + expect(driver.name).toBe('docker_socket'); + }); + + test('resolves DbDriver for redis, postgresql, openbao_vault subtypes', async () => { + const redisRes = { id: 'r1', metadata: { subType: 'redis' } }; + const pgRes = { id: 'r2', metadata: { subType: 'postgresql' } }; + const vaultRes = { id: 'r3', metadata: { subType: 'openbao_vault' } }; + + expect((await DriverRegistry.resolveDriver(redisRes)).name).toBe('database'); + expect((await DriverRegistry.resolveDriver(pgRes)).name).toBe('database'); + expect((await DriverRegistry.resolveDriver(vaultRes)).name).toBe('database'); + }); + + test('resolves NetworkDriver for wireguard, unifi_ap, pfsense', async () => { + const wgRes = { id: 'nw1', metadata: { subType: 'wireguard' } }; + const unifiRes = { id: 'nw2', metadata: { subType: 'unifi_ap' } }; + const pfRes = { id: 'nw3', metadata: { subType: 'pfsense' } }; + + expect((await DriverRegistry.resolveDriver(wgRes)).name).toBe('network'); + expect((await DriverRegistry.resolveDriver(unifiRes)).name).toBe('network'); + expect((await DriverRegistry.resolveDriver(pfRes)).name).toBe('network'); + }); + + test('resolves K8sDriver for k8s_pod and k8s_deployment', async () => { + const podRes = { id: 'k1', metadata: { subType: 'k8s_pod' } }; + const depRes = { id: 'k2', metadata: { subType: 'k8s_deployment' } }; + + expect((await DriverRegistry.resolveDriver(podRes)).name).toBe('kubernetes'); + expect((await DriverRegistry.resolveDriver(depRes)).name).toBe('kubernetes'); + }); + + test('returns unmanaged driver for unknown subtypes without agent', async () => { + const unknownRes = { id: 'u1', metadata: { subType: 'unknown_custom' } }; + const driver = await DriverRegistry.resolveDriver(unknownRes); + expect(driver.name).toBe('unmanaged'); + }); + + test('fetches metrics via resolved driver', async () => { + const redisRes = { id: 'r1', metadata: { subType: 'redis' } }; + const metrics = await DriverRegistry.getMetrics(redisRes); + expect(metrics.status).toBe('online'); + expect(metrics.driver).toBe('database'); + expect(metrics.redis).toBeDefined(); + expect(metrics.redis.connectedClients).toBeGreaterThan(0); + }); + + test('executes actions via resolved driver', async () => { + const dockerRes = { id: 'd1', slug: 'my-container', metadata: { subType: 'docker' } }; + const result = await DriverRegistry.execAction(dockerRes, 'restart'); + expect(result.status).toBe('ok'); + expect(result.driver).toBe('docker_socket'); + expect(result.action).toBe('restart'); + }); + +}); diff --git a/nodejs/utils/agent_manager.js b/nodejs/utils/agent_manager.js index c8d49e9..9cd39c7 100644 --- a/nodejs/utils/agent_manager.js +++ b/nodejs/utils/agent_manager.js @@ -290,6 +290,15 @@ class AgentManager { }; } + // Find connected/enrolled agent bound to a resource ID. + async getAgentForResource(resourceId) { + if (!resourceId) return null; + const rows = await Agent.list().catch(() => []); + const agent = rows.find(a => a.resourceId === resourceId); + if (!agent) return null; + return agent.toPublic(this.liveState(agent.id)); + } + // Every enrolled agent, connected or not. async listAgents() { const rows = await Agent.list(); diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 74eedee..525982f 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -43,8 +43,6 @@ module.exports = { {href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, - // Vault requires login - per-user secrets at secret/users//*. - {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, ], }; diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 8c2220f..d44d9b0 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -72,16 +72,20 @@ async function bao(method, path, body) { // overwrite, so this is safe to call on every token fetch — edits (e.g. adding a // grant) propagate immediately because OpenBao parses policy content at use. async function ensurePolicy(name, hcl) { - const existing = await baoConf.request('GET', `sys/policies/acl/${name}`); - if (existing.status !== 200 && existing.status !== 404) { - const t = await existing.text().catch(() => ''); - throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`); + try { + const existing = await baoConf.request('GET', `sys/policies/acl/${name}`); + if (existing.status === 200) { + const body = await existing.json().catch(() => null); + if (body && typeof body.policy === 'string' && body.policy.trim() === hcl.trim()) return; // unchanged + } + } catch (e) { + console.warn(`[VaultBroker] policy GET ${name} warning:`, e.message); } - if (existing.status === 200) { - const body = await existing.json().catch(() => null); - if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged + try { + await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl }); + } catch (err) { + console.warn(`[VaultBroker] policy PUT ${name} warning:`, err.message); } - await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl }); } // Mint a token through a token role with the given policies. Returns diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index 7b9c795..b21d08f 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -11,6 +11,7 @@ loadProxyConf(); loadTos(); loadMessagingPlugins(); + loadApps(); }); async function loadConf() { @@ -302,6 +303,67 @@ app.messages.toast('Error deleting plugin: ' + e.message, 'danger'); } } + + async function mintApp() { + const errorEl = document.getElementById('app-error'); + errorEl.classList.add('d-none'); + const name = document.getElementById('app-name-input').value.trim(); + if (!name) { + errorEl.textContent = 'App name is required'; + errorEl.classList.remove('d-none'); + return; + } + try { + const res = await fetch('/api/vault/apps', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() }, + body: JSON.stringify({ name }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status} ${text}`); + } + const result = await res.json(); + document.getElementById('app-token').textContent = result.token; + document.getElementById('app-result-card').classList.remove('d-none'); + loadApps(); + } catch (err) { + errorEl.textContent = err.message; + errorEl.classList.remove('d-none'); + } + } + + async function loadApps() { + const $list = document.getElementById('apps-list'); + if (!$list) return; + $list.innerHTML = '
Loading apps…
'; + try { + const res = await fetch('/api/vault/apps', { + headers: { 'auth-token': app.auth.getToken() } + }); + if (!res.ok) { $list.innerHTML = '
Failed to load app tokens.
'; return; } + const { apps = [] } = await res.json(); + if (!apps.length) { $list.innerHTML = '
No external app tokens minted yet.
'; return; } + $list.innerHTML = '
' + apps.map(a => { + const ok = !a.lastError; + const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed'; + return `
+
+ ${app.util.escapeHtml(a.name)} + ${ok ? 'renewing' : 'renewal error'} +
minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}
+
+ secret/apps/${app.util.escapeHtml(a.name)}/ +
`; + }).join('') + '
'; + } catch (err) { + $list.innerHTML = '
Failed to load apps: ' + app.util.escapeHtml(err.message) + '
'; + } + } + + function copyText(text) { + navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied to clipboard', 'success')); + }
@@ -331,6 +393,11 @@ Proxy Secrets +
+ +
+
External App Tokens (OpenBao)
+

Mint scoped OpenBao tokens for external microservices, scripts, and third-party tools (scoped to secret/apps/<name>/*).

+
+
+
+
Mint New App Token
+
+

Mints a periodic OpenBao token. The token will be displayed once.

+
+ + +
Use lowercase letters, numbers, and hyphens.
+
+ +
+
+
+
+
+
+
+
Generated Token
+ +
+
+

Include this token in HTTP header X-Vault-Token:

+

+										
+
+
+
+
Active App Tokens
+ +
+
+
Loading apps…
+
+
+
+
+
+
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 58bcf51..4101e7a 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -581,7 +581,7 @@
-