Merge pull request #175 from theta42/release-v1.32.0

Release v1.32.0 - Subtype Drivers Engine & Explicit Secret Inheritance
This commit is contained in:
2026-08-08 16:12:35 -04:00
committed by GitHub
39 changed files with 1546 additions and 194 deletions
+72
View File
@@ -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:
+14
View File
@@ -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:<parentSlug>:<parentKey>`) 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/<slug>/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
+1
View File
@@ -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
+1
View File
@@ -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
+3 -2
View File
@@ -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
+32
View File
@@ -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`)
+19 -6
View File
@@ -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://<sso-host>/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://<sso-host>/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.
---
+26
View File
@@ -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/<slug>/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:<parentSlug>:<parentKey>`) 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`):
+9 -37
View File
@@ -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/<name>/*`.
- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources/<slug>/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/<name>/*`.
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/<name>/*` (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/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
The token is strictly scoped to `secret/apps/<name>/*` (policy `app-<name>`), so a compromised token can't touch any other secret.
## Shared tab
Binary file not shown.
+61
View File
@@ -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<Object>}
*/
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<Object>}
*/
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<string>}
*/
async getLogs(resource, lines = 100) {
return `[${this.name}] Logs not supported for this resource type.`;
}
}
module.exports = BaseDriver;
+82
View File
@@ -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;
+62
View File
@@ -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;
+67
View File
@@ -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;
+81
View File
@@ -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;
+90
View File
@@ -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;
+125
View File
@@ -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;
+1
View File
@@ -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.
+3 -2
View File
@@ -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;
+1 -1
View File
@@ -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": [
{
+3 -3
View File
@@ -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_<hostname>' }
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' },
{ 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) => {
+3 -1
View File
@@ -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) => {
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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);
+1 -1
View File
@@ -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
+82 -20
View File
@@ -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;
+1 -13
View File
@@ -88,19 +88,7 @@ router.get('/plugins', function(req, res, next) {
});
router.get('/vault', function(req, res) {
// Personal per-user secrets (secret/users/<uid>/*) 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
+74 -1
View File
@@ -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.`);
}
+115
View File
@@ -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;
+1 -1
View File
@@ -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) {
+86
View File
@@ -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');
});
});
+1 -1
View File
@@ -46,7 +46,7 @@ describe('plugin_registry', () => {
expect(registry.secretKeys('proxmox')).toEqual(['tokenSecret']);
expect(registry.secretKeys('unifi')).toEqual(['password']);
expect(registry.secretKeys('nmap')).toEqual([]);
expect(registry.publicKeys('nmap')).toEqual(['targetRange']);
expect(registry.publicKeys('nmap').sort()).toEqual(['autoPromote', 'location', 'targetRange']);
});
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
+2 -2
View File
@@ -25,7 +25,7 @@ describe('DiscoveryReconciler', () => {
await DiscoveryReconciler.reconcile('test-plugin', payload);
const all = await Resource.list();
const all = (await Resource.list()).filter(r => r.kind !== 'site');
expect(all).toHaveLength(1);
expect(all[0].name).toBe('New Host');
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
@@ -57,7 +57,7 @@ describe('DiscoveryReconciler', () => {
}]
});
const all = await Resource.list();
const all = (await Resource.list()).filter(r => r.kind !== 'site');
expect(all).toHaveLength(1); // Should have merged, not created a new one
const merged = all[0];
+9
View File
@@ -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();
-2
View File
@@ -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/<uid>/*.
{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']},
],
};
+12 -8
View File
@@ -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
+111
View File
@@ -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 = '<div class="text-muted small p-2">Loading apps…</div>';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small p-2">Failed to load app tokens.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small p-2">No external app tokens minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group list-group-flush">' + apps.map(a => {
const ok = !a.lastError;
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
return `<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
</div>
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
</div>`;
}).join('') + '</div>';
} catch (err) {
$list.innerHTML = '<div class="text-danger small p-2">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied to clipboard', 'success'));
}
</script>
<div class="container mt-4">
@@ -331,6 +393,11 @@
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="apps-tab" data-bs-toggle="tab" data-bs-target="#pane-apps" type="button" role="tab">
<i class="fas fa-key text-warning me-1"></i> App Tokens
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
@@ -499,6 +566,50 @@
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div>
<!-- External App Tokens Tab -->
<div class="tab-pane fade" id="pane-apps" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-warning me-2"></i> External App Tokens (OpenBao)</h5>
<p class="text-muted small">Mint scoped OpenBao tokens for external microservices, scripts, and third-party tools (scoped to <code>secret/apps/&lt;name&gt;/*</code>).</p>
<div class="row g-4">
<div class="col-md-5">
<div class="card border shadow-sm">
<div class="card-header bg-light py-2"><h6 class="mb-0 fw-bold"><i class="fas fa-plus me-1"></i> Mint New App Token</h6></div>
<div class="card-body p-3">
<p class="text-muted small">Mints a periodic OpenBao token. The token will be displayed <strong>once</strong>.</p>
<div class="mb-3">
<label class="form-label fw-semibold">App Name</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. build-agent">
<div class="form-text">Use lowercase letters, numbers, and hyphens.</div>
</div>
<button class="btn btn-primary btn-sm" onclick="mintApp()"><i class="fas fa-key me-1"></i> Mint Token</button>
<div class="alert alert-danger d-none mt-3 mb-0" id="app-error"></div>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card border shadow-sm d-none mb-3" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold text-success"><i class="fas fa-check-circle me-1"></i> Generated Token</h6>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy me-1"></i> Copy</button>
</div>
<div class="card-body p-3">
<p class="small text-muted mb-2">Include this token in HTTP header <code>X-Vault-Token</code>:</p>
<pre id="app-token" class="bg-dark text-light p-3 rounded small font-monospace mb-0 select-all"></pre>
</div>
</div>
<div class="card border shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-list me-1"></i> Active App Tokens</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadApps()"><i class="fas fa-rotate me-1"></i> Refresh</button>
</div>
<div class="card-body p-0" id="apps-list">
<div class="text-muted small p-3">Loading apps…</div>
</div>
</div>
</div>
</div>
</div>
<!-- Terms of Service Tab -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
+252 -81
View File
@@ -581,7 +581,7 @@
</div>
<!-- Inherit Parent Secret Card -->
<div class="card bg-light border-0 shadow-sm p-3" id="inherit-secret-card" style="display:none">
<div class="card bg-light border-0 shadow-sm p-3" id="inherit-secret-card">
<h6 class="card-title text-dark mb-2"><i class="fa-solid fa-diagram-project text-info me-1"></i> Inherit Secret from Parent Resource</h6>
<div class="row g-2 align-items-center">
<div class="col-md-4">
@@ -593,7 +593,7 @@
<select class="form-select form-select-sm" id="inherit-parent-select"></select>
</div>
<div class="col-md-3 pt-3">
<button class="btn btn-sm btn-outline-info w-100" onclick="inheritParentSecret()"><i class="fa-solid fa-link me-1"></i> Inherit Secret</button>
<button class="btn btn-sm btn-outline-info w-100" id="btn-inherit-secret" onclick="inheritParentSecret()"><i class="fa-solid fa-link me-1"></i> Inherit Secret</button>
</div>
</div>
</div>
@@ -613,7 +613,7 @@
{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)},
{id: 'agent', label: 'Agent', bodyHtml: agentTabHtml(resourcesById[id] && resourcesById[id].agent)},
],
footer: {
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
@@ -647,10 +647,7 @@
var allGroups = [];
var allEdges = [];
var rawResources = [];
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
var accessSummary = {};
// When set, the resource modal's Save promotes this discovered slug (review
// the pre-filled form, then confirm) instead of a normal resource save.
var promoteSlug = null;
$(document).ready(async function() {
@@ -662,16 +659,9 @@
}
});
// Connected theta-agent join: hostname->agent and token->agent (case-insensitive
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
var agentsByHost = {};
var agentsByResource = {};
var agentsById = {};
// True when the agent/nodes endpoint itself was unreachable (network, or an
// older app without the agent route). When set we cannot tell "this host has
// no agent" apart from "the agent service is down", so we must NOT paint every
// host red as if it lacked an agent.
var agentsUnavailable = false;
async function loadResources() {
@@ -680,12 +670,7 @@
app.api.get('directory-admin/resources'),
app.api.get('directory-admin/groups'),
app.api.get('directory-admin/edges'),
// Access counts are a nicety, not load-bearing: if the LDAP join fails
// the table still renders, just without the Access column populated.
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
// Agents are a nicety too: never block the directory on them. Track
// whether the endpoint itself is reachable so host rows can tell "no
// agent on this host" from "agent service is down" (see attachAgentStatus).
app.api.get('agent/nodes')
.then(function(res){ agentsUnavailable = false; return res; })
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
@@ -706,7 +691,6 @@
rawResources = [];
for (const r of resResources.results) {
// Compute hostName from edges
r.hostName = '—';
r.parentId = null;
const parentEdge = allEdges.find(e => e.childId === r.id);
@@ -720,27 +704,17 @@
renderTable();
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
// input accepts a free-typed uid whether or not the list ever arrives.
loadDirectoryUsers().then(function(users) {
$('#access-uid-list').html(users.map(function(u) {
return '<option value="' + u.uid + '">' + (u.cn || u.uid) + '</option>';
}).join(''));
}).catch(function(){ /* datalist is a convenience only */ });
}).catch(function(){});
} catch (err) {
console.error(err);
app.messages.toast('Failed to load data', 'danger');
}
}
// Index agents from /api/agent/nodes. `agentsByResource` is the real link --
// an agent row now carries the id of the host it was enrolled against, so a
// resource's agent is a lookup, not a guess.
//
// agentsByHost survives only as a fallback for agents enrolled without a
// resource binding. It used to be the ONLY mechanism, which meant a host
// whose directory name differed from its OS hostname silently showed "no
// agent", and two hosts sharing a hostname aliased onto each other.
function indexAgents(agents) {
agentsByHost = {};
agentsByResource = {};
@@ -756,31 +730,20 @@
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
// Green (online, healthy) / Yellow (online, high load) / Red (not connected
// or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
// stores the agent on resourcesById so the Metrics tab can find it.
function attachAgentStatus(n) {
n.isHost = true;
// Bound agent first; hostname match only for agents with no binding yet.
const name = (n.name || '').toLowerCase();
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
const a = agentsByResource[n.id] || agentsByHost[name] || (slug && agentsByHost[slug]);
n.agent = a || null;
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
if (!a) {
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
// false red alarm across every host.
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
// No agent enrolled at all is a neutral fact about most hosts, not a
// fault -- red here made a directory of ordinary hosts look like an
// outage. Red is reserved for "enrolled, and not connected".
n.agentColor = '#adb5bd'; n.agentStatusTitle = 'No theta-agent enrolled'; return;
}
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
if (!a.isOnline) {
// Now distinguishable from "never existed", because the enrollment row
// outlives the connection.
const seen = a.last_seen ? ' — last seen ' + timeAgo(new Date(a.last_seen * 1000).toISOString()) : '';
const seen = (a.lastSeen || a.last_seen) ? ' — last seen ' + timeAgo(a.lastSeen || new Date(a.last_seen * 1000).toISOString()) : '';
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
}
const t = a.lastTelemetry || {};
@@ -789,29 +752,154 @@
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
}
// Metrics tab body for the resource modal (snapshot of the joined agent).
function metricsTabHtml(agent) {
// Agent tab body for the resource modal.
function agentTabHtml(agent) {
if (!agent) {
return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live metrics.</p></div>';
return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live telemetry and issue control commands.</p></div>';
}
const d = agent.lastDiscovery || {};
const t = agent.lastTelemetry || {};
const fmtNum = (n) => (n == null || isNaN(n)) ? '0.00' : Number(n).toFixed(2);
const fmtSize = (bytes) => {
if (!bytes || bytes <= 0) return '0.00 B';
const gib = bytes / (1024 * 1024 * 1024);
if (gib >= 1) return fmtNum(gib) + ' GiB';
const mib = bytes / (1024 * 1024);
return fmtNum(mib) + ' MiB';
};
const bar = (val) => `<div class="progress" style="height:8px"><div class="progress-bar" style="width:${Math.max(0, Math.min(100, val || 0))}%"></div></div>`;
const online = agent.isOnline ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>';
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? fmtNum(t.gpu_usage_percent) + '%' : 'N/A';
const lastSeenIso = agent.lastSeen || (agent.last_seen ? new Date(agent.last_seen * 1000).toISOString() : '');
const lastSeenStr = timeAgo(lastSeenIso) || 'never';
// RAM details
const ram = t.ram_details || d.ram_details || {};
const totalRamBytes = ram.total_bytes || (d.ram_total_gb ? d.ram_total_gb * 1024 * 1024 * 1024 : 0);
const usedRamBytes = ram.used_bytes || (totalRamBytes * (t.ram_usage_percent || 0) / 100);
const bufRamBytes = ram.buffers_cache_bytes || 0;
const freeRamBytes = ram.free_bytes || Math.max(0, totalRamBytes - usedRamBytes - bufRamBytes);
const usedRamPct = ram.used_percent != null ? ram.used_percent : (t.ram_usage_percent || 0);
const bufRamPct = ram.buffers_cache_percent != null ? ram.buffers_cache_percent : 0;
const freeRamPct = ram.free_percent != null ? ram.free_percent : Math.max(0, 100 - usedRamPct - bufRamPct);
// CPU details
const cpuDet = t.cpu_details || d.cpu_details || {};
const cpuModel = cpuDet.model || d.cpu || 'Unknown CPU';
const cpuCores = cpuDet.cores || 'N/A';
const cpuThreads = cpuDet.threads || 'N/A';
const cpuSpeed = cpuDet.mhz ? (cpuDet.mhz >= 1000 ? (cpuDet.mhz / 1000).toFixed(2) + ' GHz' : cpuDet.mhz.toFixed(0) + ' MHz') : '';
// Disks
const disks = t.disks || d.disks || [];
let disksHtml = '';
if (disks.length > 0) {
disksHtml = `<div class="table-responsive"><table class="table table-sm text-center small mb-0"><thead><tr><th>Mount</th><th>Type</th><th>FS</th><th>Usage</th><th>Total</th></tr></thead><tbody>` +
disks.map(dk => `<tr>
<td><code>${esc(dk.mountpoint)}</code></td>
<td><span class="badge bg-outline-secondary border text-dark">${esc(dk.drivetype || 'Disk')}</span></td>
<td><span class="badge bg-light text-dark border">${esc(dk.fstype || 'N/A')}</span></td>
<td>${fmtNum(dk.usage_percent)}%</td>
<td>${fmtSize(dk.total_bytes)}</td>
</tr>`).join('') + `</tbody></table></div>`;
} else {
disksHtml = `<div class="small">Disk Usage: <strong>${fmtNum(t.disk_usage_percent ?? 0)}%</strong> ${bar(t.disk_usage_percent)}</div>`;
}
return `<div class="p-3">
<div class="mb-3 d-flex justify-content-between align-items-center">
<h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
<small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
<h5 class="mb-0">${esc(agent.name || agent.hostname || 'unknown')} ${online}</h5>
<small class="text-muted">Last seen ${lastSeenStr}</small>
</div>
<div class="row g-3">
<div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
<div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
<div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
<!-- Memory Card (Matching user screenshot design) -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-memory me-1"></i> Memory</h6>
</div>
<div class="card-body p-2">
<div class="p-2 mb-1 rounded fw-bold" style="background-color: #d0e1fd; color: #084298;">
Total: <span>${fmtSize(totalRamBytes)}</span>
</div>
<div class="p-2 mb-1 rounded" style="background-color: #f8d7da; color: #842029;">
Used: <strong>${fmtSize(usedRamBytes)}</strong> <em>${fmtNum(usedRamPct)}%</em>
</div>
<div class="p-2 mb-1 rounded" style="background-color: #e2e3e5; color: #41464b;">
Buffer+Cache: <strong>${fmtSize(bufRamBytes)}</strong> <em>${fmtNum(bufRamPct)}%</em>
</div>
<div class="p-2 mb-1 rounded" style="background-color: #d1e7dd; color: #0f5132;">
Free: <strong>${fmtSize(freeRamBytes)}</strong> <em>${fmtNum(freeRamPct)}%</em>
</div>
<div class="progress mt-2" style="height: 14px; border-radius: 6px; overflow: hidden;">
<div class="progress-bar bg-danger" role="progressbar" style="width: ${Math.max(0, Math.min(100, usedRamPct))}%"></div>
<div class="progress-bar bg-secondary" role="progressbar" style="width: ${Math.max(0, Math.min(100, bufRamPct))}%"></div>
<div class="progress-bar bg-success" role="progressbar" style="width: ${Math.max(0, Math.min(100, freeRamPct))}%"></div>
</div>
</div>
</div>
<!-- CPU Card -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-microchip me-1"></i> CPU</h6>
</div>
<div class="card-body p-3">
<div class="mb-2"><strong>Model:</strong> ${esc(cpuModel)}</div>
<div class="mb-2"><strong>Specs:</strong> ${esc(cpuCores)} Cores / ${esc(cpuThreads)} Threads ${cpuSpeed ? '@ ' + esc(cpuSpeed) : ''}</div>
<div>Usage: <strong>${fmtNum(t.cpu_usage_percent ?? 0)}%</strong> ${bar(t.cpu_usage_percent)}</div>
</div>
</div>
<!-- Disks Card -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-hard-drive me-1"></i> Disks & Storage</h6>
</div>
<div class="card-body p-2">
${disksHtml}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">GPU <strong>${gpu}</strong></div>
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
</div>
<hr><h6>Discovery</h6>
<!-- Host Power Controls -->
<div class="card mb-3 border-danger shadow-sm">
<div class="card-header bg-danger text-white py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-power-off me-1"></i> Host Power Operations</h6>
</div>
<div class="card-body p-3 d-flex gap-2">
<button class="btn btn-outline-danger btn-sm" onclick="agentReboot('${agent.id}')"><i class="fa-solid fa-arrows-rotate me-1"></i> Reboot Host</button>
<button class="btn btn-danger btn-sm" onclick="agentShutdown('${agent.id}')"><i class="fa-solid fa-power-off me-1"></i> Shutdown Host</button>
</div>
</div>
<!-- Systemd Service Manager -->
<div class="card mb-3 border-primary shadow-sm">
<div class="card-header bg-primary text-white py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-gear me-1"></i> Systemd Service Manager</h6>
</div>
<div class="card-body p-3">
<div class="input-group input-group-sm mb-2">
<span class="input-group-text">Service Name</span>
<input type="text" class="form-control" id="sysd-service-${agent.id}" placeholder="e.g. nginx, sshd, docker" value="sshd">
<button class="btn btn-outline-primary" onclick="manageService('${agent.id}', 'status')">Status</button>
<button class="btn btn-outline-success" onclick="manageService('${agent.id}', 'start')">Start</button>
<button class="btn btn-outline-warning" onclick="manageService('${agent.id}', 'restart')">Restart</button>
<button class="btn btn-outline-danger" onclick="manageService('${agent.id}', 'stop')">Stop</button>
</div>
<div id="sysd-output-${agent.id}" class="d-none mt-2">
<pre class="bg-dark text-light p-2 rounded small mb-0" style="max-height: 200px; overflow-y: auto;" id="sysd-text-${agent.id}"></pre>
</div>
</div>
</div>
<hr><h6>Discovery & Metadata</h6>
<div class="row small text-muted">
<div class="col-6">OS: ${esc(d.os || '')}</div>
<div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
@@ -844,6 +932,44 @@
return bools.map(([n, on]) => badge(n, !!on)).join('') + scLine;
}
async function agentReboot(agentId) {
const ok = await app.messages.confirm('Are you sure you want to REBOOT this host?');
if (!ok) return;
app.api.post(`agent/nodes/${agentId}/command`, { command: 'reboot', isHighRisk: true }, function(err, res) {
if (err) return app.messages.toast('Reboot failed: ' + (err.message || err), 'danger');
app.messages.toast('Reboot command sent to host', 'success');
});
}
async function agentShutdown(agentId) {
const ok = await app.messages.confirm('Are you sure you want to SHUTDOWN this host?');
if (!ok) return;
app.api.post(`agent/nodes/${agentId}/command`, { command: 'shutdown', isHighRisk: true }, function(err, res) {
if (err) return app.messages.toast('Shutdown failed: ' + (err.message || err), 'danger');
app.messages.toast('Shutdown command sent to host', 'success');
});
}
function manageService(agentId, action) {
const service = ($(`#sysd-service-${agentId}`).val() || '').trim();
if (!service) return app.messages.toast('Service name required', 'warning');
const outBox = $(`#sysd-output-${agentId}`);
const outText = $(`#sysd-text-${agentId}`);
outBox.removeClass('d-none');
outText.text(`Executing systemctl ${action} ${service}...`);
app.api.post(`agent/nodes/${agentId}/command`, {
command: 'systemd_action',
payload: { action, service },
isHighRisk: action !== 'status'
}, function(err, res) {
if (err) {
outText.text('Error: ' + (err.message || JSON.stringify(err)));
return;
}
outText.text(`Command '${action}' sent for service '${service}'. Check output or logs.`);
});
}
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
async function refreshAgents() {
try {
@@ -1532,18 +1658,23 @@
function populateParentSecretsDropdown() {
const $card = $('#inherit-secret-card');
const $select = $('#inherit-parent-select').empty();
const $btn = $('#btn-inherit-secret');
$card.show();
if (!currentParentSecretsList || currentParentSecretsList.length === 0) {
$card.hide();
$select.append('<option value="">(No parent/ancestor secrets available in OpenBao)</option>');
$btn.prop('disabled', true);
return;
}
$btn.prop('disabled', false);
$select.append('<option value="">-- Select Parent Resource Secret --</option>');
currentParentSecretsList.forEach(p => {
const valStr = `INHERIT:${p.parentSlug}:${p.key}`;
const labelStr = `${p.parentName || p.parentSlug} → ${p.key}`;
$select.append(`<option value="${esc(valStr)}">${esc(labelStr)}</option>`);
});
$card.show();
}
function renderSecretsTable() {
@@ -1596,6 +1727,7 @@
const keyEl = $('#new-secret-key')[0];
const key = $('#new-secret-key').val().trim();
const val = $('#new-secret-val').val();
const resourceId = $('#res-id').val();
if (!key) {
app.messages.action('Please enter a secret key name (e.g. DB_PASSWORD).', $('#secrets-tab-container'), 'warning');
@@ -1605,17 +1737,25 @@
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;
}
if (!resourceId) return;
rawResourceSecretsMap[key] = val || '';
$('#new-secret-key').val('');
$('#new-secret-val').val('');
$('#gen-secret-notice').hide();
await saveResourceSecretsMap();
try {
app.messages.action('Saving secret to OpenBao...', $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: { [key]: val || '' } });
app.messages.action(`Secret '${key}' saved to OpenBao successfully!`, $('#secrets-tab-container'), 'success');
$('#new-secret-key').val('');
$('#new-secret-val').val('');
$('#gen-secret-notice').hide();
loadResourceSecrets(resourceId);
} catch (err) {
app.messages.action(err.message || 'Failed to save secret to OpenBao', $('#secrets-tab-container'), 'danger');
}
}
async function inheritParentSecret() {
const childKey = $('#inherit-child-key').val().trim();
const inheritVal = $('#inherit-parent-select').val();
const resourceId = $('#res-id').val();
if (!childKey) {
app.messages.action('Please enter a child secret key name (e.g. DB_HOST).', $('#secrets-tab-container'), 'warning');
@@ -1629,30 +1769,32 @@
app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning');
return;
}
if (!resourceId) return;
rawResourceSecretsMap[childKey] = inheritVal;
$('#inherit-child-key').val('');
await saveResourceSecretsMap();
try {
app.messages.action('Saving inherited secret to OpenBao...', $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { secrets: { [childKey]: inheritVal } });
app.messages.action(`Inherited secret '${childKey}' saved successfully!`, $('#secrets-tab-container'), 'success');
$('#inherit-child-key').val('');
loadResourceSecrets(resourceId);
} catch (err) {
app.messages.action(err.message || 'Failed to save inherited secret', $('#secrets-tab-container'), 'danger');
}
}
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');
app.messages.action(`Deleting secret '${key}' from OpenBao...`, $('#secrets-tab-container'), 'info');
await app.api.post(`directory-admin/resources/${resourceId}/secrets`, { action: 'delete', key });
app.messages.action(`Secret '${key}' deleted successfully from OpenBao.`, $('#secrets-tab-container'), 'success');
loadResourceSecrets(resourceId);
} catch (err) {
app.messages.action(err.message || 'Failed to save secrets to OpenBao', $('#secrets-tab-container'), 'danger');
app.messages.action(err.message || 'Failed to delete secret from OpenBao', $('#secrets-tab-container'), 'danger');
}
}
@@ -2644,15 +2786,35 @@
values = values || {};
var html = '';
schema.forEach(function(f) {
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = (f.required && !f.secret) ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
var val = '';
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
var isChecked = values[f.key] === true || values[f.key] === 'true' || values[f.key] === 1 || values[f.key] === '1' || (values[f.key] === undefined && f.default !== false);
html += '<div class="mb-3 form-check">' +
'<input type="checkbox" class="form-check-input" id="' + prefix + f.key + '"' + (isChecked ? ' checked' : '') + '>' +
'<label class="form-check-label fw-bold" for="' + prefix + f.key + '">' + label + '</label>' +
'</div>';
} else if (f.type === 'site_select' || f.key === 'location') {
var selectedVal = String(values[f.key] != null ? values[f.key] : (f.default || '')).trim();
var sites = rawResources.filter(r => r.kind === 'site');
var siteOpts = '<option value="">(Default Site)</option>';
sites.forEach(function(s) {
var sel = (s.name === selectedVal || s.slug === selectedVal || (!selectedVal && s.slug === 'site-default')) ? ' selected' : '';
siteOpts += '<option value="' + esc(s.name) + '"' + sel + '>' + esc(s.name) + ' (' + esc(s.slug) + ')</option>';
});
html += '<div class="mb-3">' +
'<label class="form-label fw-bold">' + label + '</label>' +
'<select class="form-select" id="' + prefix + f.key + '">' + siteOpts + '</select>' +
'</div>';
} else {
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = (f.required && !f.secret) ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
var val = '';
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
html += '<div class="mb-3"><label class="form-label fw-bold">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
}
});
return html;
}
@@ -2661,7 +2823,16 @@
var schema = t && t.configSchema;
var out = {};
if (!schema) return out;
schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; });
schema.forEach(function(f) {
var el = document.getElementById(prefix + f.key);
if (el) {
if (f.type === 'boolean' || f.type === 'checkbox' || el.type === 'checkbox') {
out[f.key] = el.checked;
} else {
out[f.key] = el.value;
}
}
});
return out;
}
function dpRenderFields() {
+37 -10
View File
@@ -142,16 +142,37 @@
if (!includeSecrets && f.secret) return;
var val = v[f.key];
if (f.secret) val = '';
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3">' +
'<label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
var isChecked = val === true || val === 'true' || val === 1 || val === '1' || (val === undefined && f.default !== false);
html += '<div class="mb-3 form-check">' +
'<input type="checkbox" class="form-check-input" id="' + prefix + f.key + '"' + (isChecked ? ' checked' : '') + '>' +
'<label class="form-check-label fw-bold" for="' + prefix + f.key + '">' + label + '</label>' +
'</div>';
} else if (f.type === 'site_select' || f.key === 'location') {
var selectedVal = String(val || f.default || '').trim();
var sites = window.availableSites || [];
var siteOpts = '<option value="">(Default Site)</option>';
sites.forEach(function(s) {
var sel = (s.name === selectedVal || s.slug === selectedVal || (!selectedVal && s.slug === 'site-default')) ? ' selected' : '';
siteOpts += '<option value="' + app.util.escapeHtml(s.name) + '"' + sel + '>' + app.util.escapeHtml(s.name) + ' (' + app.util.escapeHtml(s.slug) + ')</option>';
});
html += '<div class="mb-3">' +
'<label class="form-label fw-bold">' + label + '</label>' +
'<select class="form-select" id="' + prefix + f.key + '">' + siteOpts + '</select>' +
'</div>';
} else {
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
html += '<div class="mb-3">' +
'<label class="form-label fw-bold">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
}
});
return html;
}
@@ -210,7 +231,13 @@
if (!schema) return out;
schema.forEach(function(f) {
var el = document.getElementById(prefix + f.key);
if (el) out[f.key] = el.value;
if (el) {
if (f.type === 'boolean' || f.type === 'checkbox' || el.type === 'checkbox') {
out[f.key] = el.checked;
} else {
out[f.key] = el.value;
}
}
});
return out;
}