Compare commits

..

43 Commits

Author SHA1 Message Date
wmantly 5aad6c13bf Merge pull request #161 from theta42/fix/vault-403-shared-secrets
fix vault 403 + shared secrets (v1.21.0)
2026-08-03 22:23:44 -04:00
wmantly b46b3bed80 fix: use app.messages.confirm instead of native confirm() in vault Shared tab
The no_native_dialogs regression test forbids native alert/confirm/prompt in
views (they block browser events). Replace the native confirm() in deleteShared
with app.messages.confirm().
2026-08-03 22:19:38 -04:00
wmantly 948fef4adc fix vault 403 + shared secrets (v1.21.0)
- vault_broker: always reconcile policy content before serving a cached
  token (compare-and-skip), so stale stored policies can't cause a recurring
  403 'permission denied'; policy content is parsed live by OpenBao, so edits
  apply to existing tokens immediately.
- Shared secrets: publish to secret/shared/<owner>/<slug>; grant read to users
  and apps by editing the grantee's policy content (live-applied). New
  SharedSecret/SharedSecretGrant ORM models, /api/shared-secrets router, and a
  Shared tab in the vault UI.
- package.json + lockfile bumped to 1.21.0 to match the tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 22:13:58 -04:00
wmantly 2612b0e3ab Merge pull request #159 from theta42/fix/sync-version-v1.20.2
fix: sync package version to v1.20.2 tag
2026-08-03 21:35:14 -04:00
wmantly bf471c2e19 fix: sync package version to v1.20.2 tag
The v1.20.2 release tag was created but nodejs/package.json (and the
lockfile) were left at 1.20.1, so the deployed app's buildVersion lagged
its own release tag and the update-check banner falsely reported a newer
version. Bump the version fields to match the tag.
2026-08-03 21:26:55 -04:00
wmantly d802c399a3 Merge pull request #157 from theta42/fix/sso-vault-conf-directory-v1.20.2
fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
2026-08-03 15:30:49 -04:00
wmantly d8242b1d53 fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
Pull Request Tests / Run Tests (18.x) (push) Failing after 56s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 15:26:59 -04:00
wmantly 0c5159c49b Merge pull request #155 from theta42/fix/sso-directory-conf-vault-v1.20.1
fix(sso): Directory, Configuration, Vault Broker & Plugins migration v1.20.1
2026-08-03 14:01:55 -04:00
wmantly 70b76c6ed5 fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m23s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 5s
2026-08-03 13:54:53 -04:00
wmantly 8143ef8ca8 Merge pull request #154 from theta42/feature/v1.20.0-package-version-and-docs
release: v1.20.0 package version & docs update
2026-08-03 02:41:47 -04:00
wmantly 2b8b7a96e0 release: v1.20.0 - bump package.json version and update docs 2026-08-03 02:37:52 -04:00
wmantly 7a364bfb8e Merge pull request #151 from theta42/feature/v1.20.0-theta-agent-c2
feat: Protocol v1.1.0 theta-agent C2 integration & install wizard
2026-08-03 02:21:50 -04:00
wmantly b7aac2d2ba feat: Protocol v1.1.0 theta-agent C2 integration, agent install wizard, OpenBao 403 fix, nmap discovery fix, and restored documentation 2026-08-03 02:18:09 -04:00
wmantly 4945dec9c2 Merge pull request #150 from theta42/release/v1.19.6
Release v1.19.6
2026-08-02 22:49:52 -04:00
wmantly 59d68c0269 chore: release v1.19.6 - UI nav auth, SMTP UI-only, test messages, directory.md
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m6s
Pull Request Tests / Run Tests (20.x) (push) Failing after 29s
Pull Request Tests / Run Tests (22.x) (push) Failing after 29s
Pull Request Tests / Test Summary (push) Failing after 4s
### Fixed
- **Navbar shows Catalog/Vault for unauthenticated users** — Changed nav
  gating from `groups: []` (always visible) to `groups: ['login']` and
  added synthetic 'login' group handling in app-base.js.
- **500 ENOENT: no such file or directory, open '/docs/directory.md'** —
  Created the missing documentation file.

### Changed
- **SMTP configuration UI-only** — Removed SMTP from static config files
  (conf/base.js, sso-secrets.js, setup.env.example). SMTP is now only
  configurable via the runtime UI at /conf.

### Added
- **Test email/SMS capability** — Added POST /api/conf/test-email and
  POST /api/conf/test-sms endpoints with UI buttons in the Configuration
  page. Saves config first, then sends test message to verify settings.

### theta-env setup.sh
- **Non-interactive theta-agent configuration** — Added CFG_THETA_AGENT_ENABLE,
  CFG_THETA_AGENT_LDAP_AUTH, and CFG_THETA_AGENT_FULL_CONTROL variables to
  setup.env (all default to 1/enabled).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 21:30:47 -04:00
wmantly ef2207ed72 fix(vault): correctly rewrite paths for vault API proxy (#149) 2026-08-02 19:47:07 -04:00
wmantly 7782cf8973 Merge pull request #148 from theta42/bump-1.19.5
chore: bump version to 1.19.5
2026-08-02 19:07:19 -04:00
wmantly 80317d1b7e chore: bump version to 1.19.5
Pull Request Tests / Run Tests (18.x) (push) Failing after 6m7s
Pull Request Tests / Run Tests (20.x) (push) Failing after 24s
Pull Request Tests / Run Tests (22.x) (push) Failing after 23s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 19:02:17 -04:00
wmantly ded6a1b0d5 Merge pull request #147 from theta42/fix-68
fix: enforce pwdAccountLockedTime check in app and LDAP
2026-08-02 19:01:43 -04:00
wmantly a6c24850d4 chore: update sqlite test fixture schema
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m32s
Pull Request Tests / Run Tests (20.x) (push) Failing after 27s
Pull Request Tests / Run Tests (22.x) (push) Failing after 23s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 18:57:13 -04:00
wmantly 7da5050ce3 fix: correct path-to-regexp syntax 2026-08-02 18:50:31 -04:00
wmantly 1cb693a1eb fix: resolve discovery, plugins, and vault issues 2026-08-02 18:45:16 -04:00
wmantly 5c3a8cefe1 fix: enforce pwdAccountLockedTime check in app and LDAP (#68) 2026-08-02 18:15:28 -04:00
wmantly 15b3a424bc Merge pull request #146 from theta42/bump-1.19.4
chore: bump version to 1.19.4
2026-08-02 14:10:40 -04:00
wmantly 6cb309b6d9 chore: bump version to 1.19.4 2026-08-02 14:06:45 -04:00
wmantly f0ceb750a8 Merge pull request #145 from theta42/bump-version
chore: bump version to 1.19.3
2026-08-02 14:06:20 -04:00
wmantly 6e95defcf5 chore: bump version to 1.19.3 2026-08-02 14:02:00 -04:00
wmantly 92c2e8a03b Merge pull request #144 from theta42/fix/discovery-edges
fix: resolve discovery and UI bugs
2026-08-02 13:23:56 -04:00
wmantly 230e5be2fd fix: regex syntax error in proxmox plugin 2026-08-02 13:20:15 -04:00
wmantly 0331cb976a fix: resolve discovery and UI bugs 2026-08-02 12:55:19 -04:00
wmantly 90cf65e920 Merge pull request #143 from theta42/fix/discovery-edges
fix: process edges during discovery reconciliation
2026-08-02 12:10:04 -04:00
wmantly 2d202b4979 chore: release v1.19.2 2026-08-02 12:06:09 -04:00
wmantly 8f04c20cd7 fix: process edges during discovery reconciliation to correctly link merged resources 2026-08-02 12:05:46 -04:00
wmantly df330c6c0f Merge pull request #141 from theta42/release-v1.19.0
Release v1.19.0
2026-08-02 11:20:49 -04:00
wmantly 522093e898 fix: remove missing documentation files from Docker build context
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m36s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 02:14:30 -04:00
wmantly 7b84a10420 fix: regex syntax error in docker discovery plugin 2026-08-02 02:09:19 -04:00
wmantly c461723ec7 chore: release v1.19.0 2026-08-02 01:54:00 -04:00
wmantly b948cd8625 feat: add websocket server endpoint for theta-agent 2026-08-02 01:39:45 -04:00
wmantly 36aa114d7c docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly fbce59b1be feat: integrate proxy config with OpenBao for secure secret storage 2026-08-02 00:37:48 -04:00
wmantly 554a0999ab test: add tests for Proxy OpenBao configuration endpoint 2026-08-02 00:34:56 -04:00
wmantly 3ca221d64d chore: release v1.18.0 2026-08-02 00:16:18 -04:00
wmantly 75b133f610 feat: Add messaging plugins, Docker discovery, fix reconciliation 2026-08-02 00:16:17 -04:00
50 changed files with 3002 additions and 378 deletions
+18
View File
@@ -1,3 +1,17 @@
# v1.21.0
- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied"
- feat: shared secrets — users can publish secrets to secret/shared/<owner>/<slug> and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live)
- feat: shared-secrets API + Shared tab in the vault UI
# v1.20.0
- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal)
## v1.19.0
- Added WebSocket endpoint for theta-agent C2
# v1.18.0
- feat: Add messaging plugins, Docker discovery, fix reconciliation
# Changelog
All notable changes to this project are documented here. Format loosely
@@ -638,3 +652,7 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
## [1.19.6] - 2026-08-02
### Fixed
- Fixed Vault API returning 403 on the Secrets List due to `http-proxy-middleware` v2 rewriting the path incorrectly (it previously appended the `/api/vault/` mount path to the proxied Vault request).
-2
View File
@@ -182,10 +182,8 @@ COPY tos.md /tos.md
# without internet access. Same flattened-path convention as tos.md above.
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit
-2
View File
@@ -36,10 +36,8 @@ RUN mkdir -p /app/config
COPY tos.md /tos.md
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Seed script and utility
COPY test_seed.js ./test_seed.js
+71 -67
View File
@@ -1,88 +1,92 @@
---
layout: default
title: Discovery Agents
title: Theta Agent & Endpoint Management
nav_order: 5
---
# Discovery Agents
# Theta Agent & Endpoint Management
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
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.
## Writing a Custom Agent
---
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
## Core Functionality
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
### 1. Host Discovery & Inventory
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
### Agent Skeleton
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
```javascript
// nodejs/agents/discovery/my_custom_agent.js
module.exports = {
discover: async (config) => {
const { url, apiKey } = config; // Provided by your configuration
const resources = [];
const edges = [];
### 2. Real-Time Telemetry Streaming
Every 30 seconds, the agent streams real-time performance metrics:
- **CPU Load**: System-wide CPU utilization percentage.
- **Memory Utilization**: RAM usage percentage and available memory.
- **Disk Utilization**: Root filesystem usage percentage.
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
// 1. Fetch your data from an API
// const data = await fetch(...);
---
// 2. Map data to Resources
resources.push({
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
name: 'My Switch',
slug: 'my-switch-01',
metadata: {
make: 'Vendor',
model: 'Model X',
interfaces: [
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
]
}
});
## Local-First Security & Capability Matrix
// 3. Map relations to Edges (optional)
edges.push({
parentSlug: 'my-switch-01',
childSlug: 'some-connected-client-slug',
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
});
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
return { resources, edges };
}
};
| Capability | Config Key | Risk Level | Description & Impact |
| :--- | :--- | :--- | :--- |
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
---
## High-Risk Command Verification (Protocol v1.1.0)
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace).
2. The payload is signed with the SSO Manager's Ed25519 private key.
3. The Base64 signature is appended to the message payload.
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
---
## Installation & Deployment
### Quick One-Liner Install
Run the following command as `root` on the target Linux host:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- --url "https://<SSO_HOST>" --token "<HOST_TOKEN>"
```
## Configuration
### Custom Config Wizard
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
```javascript
module.exports = {
// ... existing config ...
discovery: {
agents: {
my_custom_agent: {
enabled: true,
cron: '*/30 * * * *', // Run every 30 minutes
url: 'https://api.example.com',
apiKey: 'secret-key'
},
nmap: {
enabled: true,
cron: '0 * * * *',
targetRange: '192.168.1.0/24'
}
}
}
};
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
```
## The Reconciliation Engine
---
## Configuration File Example (`/etc/theta42/agent.yml`)
```yaml
# /etc/theta42/agent.yml
server_url: "wss://sso.example.com"
auth_token: "your-unique-host-token"
location: "dc-01-rack-12"
public_key: "MCowBQYDK2VwAyEA..."
capabilities:
telemetry: true
configure_ldap: true
reboot: false
service_control: ["nginx", "docker", "sssd"]
arbitrary_bash: false
```
When your agent returns its graph, the Reconciliation Engine takes over:
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
+1
View File
@@ -60,6 +60,7 @@ backend, that's the niche.
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
## Get it
+132
View File
@@ -0,0 +1,132 @@
# Plugins
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
**type** is an installed module; a plugin **instance** is a configured, loadable
copy of a type. You can create, edit, load/unload, run, and delete instances
from the **Plugins** page (or the `/api/plugins` API), and you can run several
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
and token on its own schedule.
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
ever shows them masked (`********`); the plugin reads them at run time. This
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
## Plugin types
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
filename basename (without `.js`) is the `type`; the parent directory is the
`category`. The built-ins ship under `plugins/discovery/`:
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
A module exports a **manifest**:
```javascript
module.exports = {
// Identity — `type`/`category` default to the file/dir name but can be set
// explicitly. `name`/`description` show up in the UI.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
// Drives the admin UI form, API validation, and secret masking. Fields with
// `secret: true` are stored in OpenBao; the rest live in the DB row.
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
],
// "Test" button: validate the config (don't do the work). Return
// { ok: true } or { ok: false, error: '...' }. Optional.
validate: async (config) => { },
// The work. `run` is the generalized contract name; the discovery plugins
// also keep `discover` as an alias for back-compat. For `category:
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
run: async (config) => { return { resources, edges }; },
discover: async (config) => { return { resources, edges }; }
};
```
`run(config)` receives the merged non-secret config + secret values as one flat
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
returns `{ resources, edges }`; the reconciler upserts them into the resource
graph attributed to the instance's **slug** (the `discovery_sources` name).
### Writing a custom plugin type
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
following the manifest above. New types are picked up at boot, so restart the
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
adding a new type still needs a restart.
## The Plugins page
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
/ `app_super_admin`):
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
source name + the URL the resource graph attributes results to), set a cron
schedule, and fill in the config form (secret fields are password inputs).
Creating it schedules it and kicks one immediate run.
- **Edit** — name, cron, and non-secret config.
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
field blank to keep its current value.
- **Test** (vial icon) — runs the plugin's `validate`.
- **Run now** (play icon) — enqueues one immediate run regardless of state.
- **Load / Unload** — enable/disable the schedule without deleting the instance.
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
## API
All endpoints are mounted at `/api/plugins`, require an authenticated admin
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
secret values masked.
| Method + path | Purpose |
|---|---|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
| `GET /api/plugins/:id` | one instance |
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
| `POST /api/plugins/:id/test` | run `validate``{ ok }` or `{ ok:false, error }` |
| `POST /api/plugins/:id/load` | enable + schedule + run now |
| `POST /api/plugins/:id/unload` | unschedule + disable |
| `POST /api/plugins/:id/run` | enqueue one immediate run |
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
## Scheduler internals
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
that one schedule without disturbing the others. A daily `garbage_collect` job
prunes discovery resources not seen in > 7 days.
### Legacy migration
Before this system, plugins were configured statically in `sso-secrets.js`:
```javascript
module.exports = {
discovery: {
plugins: {
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
}
}
};
```
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
empty **and** `conf.discovery.plugins` has entries, one instance per configured
type is seeded automatically (secret fields copied into OpenBao). After that the
table is non-empty and the static config is ignored — manage plugins from the
UI/API instead. The migration is idempotent (guarded by the empty-table check).
+26 -33
View File
@@ -1,45 +1,38 @@
---
layout: default
title: Secrets Vault
nav_order: 6
---
# Vault Secrets Management
# Secrets Vault
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.
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
## Usage
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
You can access the Vault UI from the application's top navigation bar.
## Architecture
### Creating Secrets
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
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**.
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
### Reading and Editing Secrets
## Accessing the Vault
* 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.
The SSO Manager Vault can be accessed in two ways:
### OpenBao Integration
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
### API Example
## API Access
To read secrets from the default key-value store, issue a `GET` request to:
`/api/vault/v1/secret/data/sso-manager/conf`
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
## Namespaces and Paths
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
## Plugin Integration
Plugin instances store their per-instance secrets in OpenBao at
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
in-process, so the SSO Manager reads/writes those secrets server-side through
the `sso-broker` token; the admin UI only ever sees masked values, and external
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
consistently enforced instead of hardcoding them.
```bash
# Example: Read a secret via the API
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
+7 -1
View File
@@ -43,6 +43,9 @@ app.onListen.push(function(){
// socket.broadcast.emit('P2PSub', msg);
});
});
// Initialize Theta Agent WebSockets
require('./routes/api_agent')(app);
});
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
@@ -69,7 +72,8 @@ app.locals.ui = require('./utils/ui');
// Have express server static content( images, CSS, browser JS) from the public
// local folder. maxAge is short since this is the app's own JS/CSS, which
// changes on every deploy and isn't cache-busted/fingerprinted.
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}))
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}));
app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'}));
// Routes for front end content.
app.use('/', require('./routes/index'));
@@ -122,6 +126,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
const vaultBroker = require('./utils/vault_broker');
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
// Shared secrets (metadata + grants; data reads go through /api/vault proxy).
app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets'));
// Catch 404 and forward to error handler. If none of the above routes are
// used, this is what will be called.
+13
View File
@@ -25,6 +25,19 @@ var server = http.createServer(app);
var io = require('socket.io')(server);
app.io = io;
const WebSocket = require('ws');
const wss = new WebSocket.Server({ noServer: true });
server.on('upgrade', (request, socket, head) => {
// We only handle upgrade for /api/agent/ws.
// Socket.IO handles its own upgrades natively because it attaches directly to `server`.
if (request.url.startsWith('/api/agent/ws')) {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
}
});
app.wss = wss;
const models = require('../models');
/**
-8
View File
@@ -57,14 +57,6 @@ module.exports = {
password: '__in secrets file__',
did: '__in secrets file__',
},
smtp: {
host: 'localhost',
port: 587,
secure: false,
user: 'noreply@example.com',
pass: '__in secrets file__',
from: 'SSO Manager <noreply@example.com>',
},
directory: {
// Public SSH jump host fronting the lab, if there is one (the jump-host
// component). When set, a host card in the catalog shows the real
Binary file not shown.
+3
View File
@@ -17,6 +17,8 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
const { AccessRequest } = require('./access_request');
const { Webhook } = require('./webhook');
const { PluginInstance } = require('./plugin_instance');
const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -31,6 +33,7 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
+1
View File
@@ -58,6 +58,7 @@ class PluginInstance extends Model {
lastRunAt: { type: 'integer' },
lastStatus: { type: 'string' },
lastError: { type: 'text' },
lastLog: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
+3 -1
View File
@@ -96,11 +96,13 @@ class Resource extends Model {
return false;
}
let maxUpdated = 0;
resObjs.forEach(r => {
r.metadata.isProduction = checkProd(r.id);
if (r.updated_on && r.updated_on > maxUpdated) maxUpdated = r.updated_on;
});
return { resources: resObjs, edges };
return { resources: resObjs, edges, updated_on: maxUpdated || Date.now() };
}
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// SharedSecret — a secret the owner has published to the shared namespace so it
// can be shared with other users and/or downstream apps.
//
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
// never in the DB. This row is metadata only (owner + slug + description) and is
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
// edited to add `read` on the exact shared path (see vault_broker.js — policy
// content is parsed live at token use, so a grant takes effect immediately with
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
//
// `slug` is unique and immutable in practice — it is embedded in the shared path
// and in grantee policy rules, so changing it would require rewriting policies.
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
// ORM on create.
const { Model } = require('@simpleworkjs/orm');
class SharedSecret extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
// Unique so two owners can't collide on the same shared path.
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// The publishing user's uid — also the shared path's namespace segment.
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
// Optional human description shown in the Shared tab.
description: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
static pathFor(ownerUid, slug) {
return `shared/${ownerUid}/${slug}`;
}
path() {
return SharedSecret.pathFor(this.ownerUid, this.slug);
}
// Look up by slug (unique). Returns the row or null.
static async getBySlug(slug) {
const rows = await this.list({ where: { slug } });
return rows[0] || null;
}
}
module.exports = { SharedSecret };
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
// <secretId>".
//
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
// content live at token use, the grant applies to the grantee's existing token
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
//
// granteeType distinguishes the two principal kinds:
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
// capability is currently always 'read' (grantees are read-only); the column is
// a string so later capabilities could be added without a migration.
//
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
const { Model } = require('@simpleworkjs/orm');
const GRANTEE_TYPES = ['user', 'app'];
const CAPABILITIES = ['read'];
class SharedSecretGrant extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// FK to SharedSecret.id.
secretId: { type: 'string', isRequired: true, min: 1 },
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
granteeType: { type: 'string', isRequired: true, min: 1 },
// The grantee's uid (for 'user') or app name (for 'app').
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
// Access level — 'read' today.
capability: { type: 'string', isRequired: true, default: 'read' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// All grants for a given grantee (user uid or app name). Used to rebuild the
// grantee's policy content so every granted shared path is present/absent.
static async listForGrantee(granteeType, granteeId) {
return this.list({ where: { granteeType, granteeId } });
}
}
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };
+15
View File
@@ -10,6 +10,21 @@ function toE164Digits(number) {
}
async function send(to, message) {
const { PluginInstance } = require('./plugin_instance');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
if (instances.length > 0) {
const inst = instances[0];
const manifest = registry.getManifest(inst.pluginType);
if (manifest && manifest.sendMessage) {
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
const config = { ...inst.config, ...secrets };
return manifest.sendMessage(config, { to, message });
}
}
const params = new URLSearchParams({
api_username: conf.username,
api_password: conf.password,
+9 -2
View File
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
]);
} else {
await client.modify(this.dn, [
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['00000101000000Z'] }) }),
]);
}
});
@@ -788,7 +788,7 @@ User.setActive = async function(active) {
throw e;
}
}
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
this.isActive = active ? 'active' : '';
this.isInactive = active ? '' : 'inactive';
cache.clear();
@@ -907,6 +907,13 @@ User.login = async function(data){
}
let user = await this.get(data.uid || data.username);
if (user.pwdAccountLockedTime) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
const loginClient = makeClient();
try {
await loginClient.bind(user.dn, data.password);
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.16.0",
"version": "1.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.16.0",
"version": "1.21.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
@@ -42,6 +42,7 @@
"nodemailer": "^9.0.0",
"p2psub": "^0.2.0",
"socket.io": "^4.8.3",
"ws": "^8.21.1",
"xss": "^1.0.15"
},
"devDependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.17.2",
"version": "1.21.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
@@ -54,6 +54,7 @@
"nodemailer": "^9.0.0",
"p2psub": "^0.2.0",
"socket.io": "^4.8.3",
"ws": "^8.21.1",
"xss": "^1.0.15"
},
"license": "MIT",
+81
View File
@@ -0,0 +1,81 @@
const http = require('http');
module.exports = {
type: 'docker',
category: 'discovery',
name: 'Docker Daemon',
description: 'Discover running containers and networks from a local or remote Docker daemon.',
configSchema: [
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
],
validate: async (config) => {
if (!config.socketPath && !config.tcpHost) {
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
}
return { ok: true };
},
discover: async (config) => {
const isTcp = !!config.tcpHost;
const requestOptions = {
path: '/containers/json',
method: 'GET'
};
if (isTcp) {
const url = new URL(config.tcpHost);
requestOptions.host = url.hostname;
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
requestOptions.protocol = url.protocol;
} else {
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
}
return new Promise((resolve, reject) => {
const req = http.request(requestOptions, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
}
try {
const containers = JSON.parse(body);
const resources = [];
const edges = [];
for (const c of containers) {
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
resources.push({
kind: 'container',
name: name,
slug: slug,
metadata: {
image: c.Image,
state: c.State,
status: c.Status,
ports: ports
}
});
}
resolve({ resources, edges });
} catch (e) {
reject(new Error(`Failed to parse Docker response: ${e.message}`));
}
});
});
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
req.end();
});
}
};
+17 -5
View File
@@ -31,16 +31,28 @@ module.exports = {
if (!targetRange) throw new Error("Missing targetRange for Nmap");
return new Promise((resolve, reject) => {
const scan = new nmap.OsAndPortScan(targetRange);
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
// Pass custom arguments in constructor so node-nmap includes them before spawning nmap process.
// -Pn: treat all hosts as online (skip ping/ARP host discovery which fails inside Docker containers NAT/bridge)
// -sT: TCP connect scan (unprivileged scan compatible with container environments)
// -F: fast scan (100 top ports)
// --min-rate 100: speed up scan rate
const customFlags = ['-Pn', '-sT', '-F', '--min-rate', '100'];
const scan = new nmap.NmapScan(targetRange, customFlags);
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
scan.on('complete', function(data) {
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
const resources = [];
const edges = [];
for (const host of data) {
if (!host.mac || !host.ip) continue;
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`;
if (!host.ip) continue;
const hostId = host.mac ? host.mac.replace(/:/g, '') : host.ip.replace(/\\./g, '_');
const hostSlug = `nmap-host-${hostId}`;
const interfaces = [{ mac: host.mac, ip: host.ip }];
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
resources.push({
kind: 'host',
@@ -51,7 +63,7 @@ module.exports = {
if (host.openPorts && host.openPorts.length > 0) {
for (const port of host.openPorts) {
const svcSlug = `nmap-svc-${host.mac.replace(/:/g, '')}-${port.port}`;
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
resources.push({
kind: 'service',
name: `${port.service} on ${port.port}`,
+8 -2
View File
@@ -35,7 +35,7 @@ module.exports = {
},
discover: async (config) => {
const { url, tokenId, tokenSecret } = config;
let { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) {
throw new Error("Missing Proxmox config");
}
@@ -43,13 +43,19 @@ module.exports = {
const headers = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
};
// Ensure URL has no trailing slash
url = url.endsWith('/') ? url.slice(0, -1) : url;
const resources = [];
const edges = [];
// 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
if(!resNodes.ok) throw new Error("Proxmox API error on nodes");
if(!resNodes.ok) {
const errText = await resNodes.text();
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
}
const nodes = (await resNodes.json()).data;
for (const node of nodes) {
+61
View File
@@ -0,0 +1,61 @@
const https = require('https');
module.exports = {
type: 'twilio',
category: 'messaging',
name: 'Twilio SMS',
description: 'Send SMS messages (like 2FA codes) via Twilio.',
configSchema: [
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
],
validate: async (config) => {
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
const data = new URLSearchParams();
data.append('To', to);
data.append('From', config.fromNumber);
data.append('Body', message);
const postData = data.toString();
const options = {
hostname: 'api.twilio.com',
port: 443,
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
};
+79
View File
@@ -0,0 +1,79 @@
const https = require('https');
const http = require('http');
module.exports = {
type: 'webhook',
category: 'messaging',
name: 'Universal REST Webhook',
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
configSchema: [
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
],
validate: async (config) => {
if (!config.url) return { ok: false, error: 'URL is required' };
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
try {
if (config.headers) JSON.parse(config.headers);
} catch (e) {
return { ok: false, error: 'Headers must be valid JSON' };
}
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
let payloadStr = config.payloadTemplate || '{}';
// Replace template variables safely
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
// If there is an API secret, replace {{secret}} in the headers or url
let headersObj = {};
if (config.headers) {
try {
const parsed = JSON.parse(config.headers);
for (const [k, v] of Object.entries(parsed)) {
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
}
} catch(e) {}
}
if (!headersObj['Content-Type']) {
headersObj['Content-Type'] = 'application/json';
}
const urlObj = new URL(config.url);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: config.method || 'POST',
headers: headersObj
};
const client = urlObj.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = client.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body });
} else {
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(payloadStr);
req.end();
});
}
};
+15 -1
View File
@@ -615,9 +615,10 @@ app.util = (function(app){
// Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
// The synthetic 'login' group is special: it's true for any authenticated user.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
var isLoggedIn = !!user;
var style = document.getElementById('group-required-rules');
if(!style){
@@ -636,6 +637,19 @@ app.auth.applyGroupVisibility = function(user){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
// The 'login' group is synthetic — it means "any authenticated user".
// Reveal .group-required-login for any logged-in user.
if(isLoggedIn){
try{
style.sheet.insertRule(
`.group-required-login { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// Ignore CSS escape errors.
}
}
};
$( document ).ready(async function(){
@@ -0,0 +1,130 @@
#!/bin/bash
set -e
# --- Configuration ---
# In a real environment, these would be derived from the script's download URL
# or passed as additional arguments. For now, we use the most recent release.
BINARY_URL="${BINARY_URL:-}"
CONFIG_DIR="/etc/theta42"
CONFIG_FILE="$CONFIG_DIR/agent.yml"
BIN_PATH="/usr/local/bin/theta-agent"
SERVICE_FILE="/etc/systemd/system/theta-agent.service"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root."
fi
# 2. Argument Parsing
URL=""
TOKEN=""
B64_CONFIG=""
while [[ $# -gt 0 ]]; do
case $1 in
--url)
URL="$2"
shift 2
;;
--token)
TOKEN="$2"
shift 2
;;
*)
B64_CONFIG="$1"
shift
;;
esac
done
# Validation
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\""
exit 1
fi
# 3. Resolve binary URL dynamically if not specified
if [ -z "$BINARY_URL" ]; then
if [ -n "$URL" ]; then
BINARY_URL="${URL%/}/resources/theta-agent/theta-agent-linux-amd64"
elif [ -n "$B64_CONFIG" ]; then
EXTRACTED_URL=$(echo "$B64_CONFIG" | base64 -d 2>/dev/null | grep -E '^\s*server_url:' | awk -F'"' '{print $2}' | tr -d ' ' || true)
if [ -n "$EXTRACTED_URL" ]; then
HTTP_URL=$(echo "$EXTRACTED_URL" | sed -e 's/^wss:\/\//https:\/\//' -e 's/^ws:\/\//http:\/\//')
BINARY_URL="${HTTP_URL%/}/resources/theta-agent/theta-agent-linux-amd64"
fi
fi
fi
if [ -z "$BINARY_URL" ]; then
BINARY_URL="https://sso.example.com/resources/theta-agent/theta-agent-linux-amd64"
fi
log "Downloading binary from $BINARY_URL..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
chmod +x "$BIN_PATH"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
if [ -n "$B64_CONFIG" ]; then
log "Decoding and writing configuration from base64..."
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
else
log "Generating minimal configuration from arguments..."
# Create a minimal yaml with the provided URL and Token
cat <<EOF > "$CONFIG_FILE"
server_url: "$URL"
auth_token: "$TOKEN"
location: "unknown"
capabilities:
telemetry: true
configure_ldap: false
reboot: false
service_control: []
arbitrary_bash: false
EOF
fi
chmod 600 "$CONFIG_FILE"
# 5. Setup systemd service
log "Creating systemd service unit..."
cat <<EOF > "$SERVICE_FILE"
[Unit]
Description=Theta Agent Unified Endpoint Management
After=network.target
[Service]
Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
WantedBy=multi-user.target
EOF
# 6. Start the agent
log "Enabling and starting Theta Agent..."
systemctl daemon-reload
systemctl enable theta-agent
systemctl start theta-agent
log "Theta Agent installation complete!"
log "Verify status with: systemctl status theta-agent"
log "Check logs with: journalctl -u theta-agent -f"
Binary file not shown.
+105
View File
@@ -0,0 +1,105 @@
'use strict';
const express = require('express');
const agentManager = require('../utils/agent_manager');
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
return;
}
app.wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization'];
if (!token) {
ws.close(4001, 'Unauthorized: Missing token');
return;
}
const remoteAddr = req.socket.remoteAddress;
console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`);
agentManager.registerAgent(token, ws, remoteAddr);
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
if (!data || typeof data.type !== 'string') return;
const payload = data.payload || {};
switch (data.type) {
case 'discovery':
agentManager.handleDiscovery(token, payload);
if (app.io) app.io.emit('agent.discovery', { token, payload });
break;
case 'telemetry':
agentManager.handleTelemetry(token, payload);
if (app.io) app.io.emit('agent.telemetry', { token, payload });
break;
case 'heartbeat':
agentManager.handleHeartbeat(token, payload, ws);
break;
case 'response':
agentManager.handleResponse(token, payload);
if (app.io) app.io.emit('agent.response', { token, payload });
break;
default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${token}`);
}
} catch (err) {
console.error("[Theta Agent] Error parsing message:", err);
}
});
ws.on('close', () => {
console.log(`[Theta Agent] Agent disconnected (${token})`);
agentManager.unregisterAgent(token, ws);
});
// Send initial welcome/config payload
try {
ws.send(JSON.stringify({
type: 'config',
payload: {
message: 'Connected to SSO Manager C2',
protocol_version: '1.1.0'
}
}));
} catch (e) {}
});
// REST API routes for Agent Management (mounted under /api/agent)
const router = express.Router();
router.get('/nodes', (req, res) => {
res.json({
status: 'ok',
agents: agentManager.getConnectedAgents(),
publicKey: agentManager.publicKeyPem
});
});
router.post('/nodes/:token/command', (req, res) => {
const { token } = req.params;
const { command, payload, isHighRisk } = req.body;
if (!command) {
return res.status(400).json({ status: 'error', message: 'Command type is required' });
}
try {
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning);
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
res.status(400).json({ status: 'error', message: err.message });
}
});
app.use('/api/agent', router);
};
+100
View File
@@ -90,5 +90,105 @@ router.post('/', async (req, res, next) => {
next(err);
}
});
router.get('/proxy', async (req, res, next) => {
try {
const proxyConf = await baoConf.get('proxy/conf') || {};
const editable = JSON.parse(JSON.stringify(proxyConf));
if (editable.oidc && editable.oidc.clientSecret) editable.oidc.clientSecret = MASK;
if (editable.ldap && editable.ldap.bindPassword) editable.ldap.bindPassword = MASK;
res.json(editable);
} catch(err) {
next(err);
}
});
router.post('/proxy', async (req, res, next) => {
try {
const existing = await baoConf.get('proxy/conf') || {};
const incoming = req.body || {};
if (incoming.oidc && incoming.oidc.clientSecret !== undefined) {
if (incoming.oidc.clientSecret === '' || incoming.oidc.clientSecret === MASK) delete incoming.oidc.clientSecret;
}
if (incoming.ldap && incoming.ldap.bindPassword !== undefined) {
if (incoming.ldap.bindPassword === '' || incoming.ldap.bindPassword === MASK) delete incoming.ldap.bindPassword;
}
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
} else {
existing[key] = incoming[key];
}
}
await baoConf.set('proxy/conf', existing);
res.json({ success: true });
} catch(err) {
next(err);
}
});
// Send a test email to verify SMTP configuration
router.post('/test-email', async (req, res, next) => {
try {
const { to, subject, body } = req.body || {};
if (!to) {
return res.status(400).json({ error: 'Recipient email address is required' });
}
// Use the email model to send the test message
const Email = require('../models/email');
const testSubject = subject || 'SSO Manager Test Email';
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
await Email.send(to, testSubject, testBody);
res.json({ success: true, message: `Test email sent to ${to}` });
} catch(err) {
next(err);
}
});
// Send a test SMS to verify VoIP.ms configuration
router.post('/test-sms', async (req, res, next) => {
try {
const { to, message } = req.body || {};
if (!to) {
return res.status(400).json({ error: 'Recipient phone number is required' });
}
const voipmsConf = conf.voipms || {};
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
}
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
// VoIP.ms SMS API endpoint
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
method: 'POST',
headers: {
'Authorization': `Basic ${authHeader}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
did: voipmsConf.did,
to: to,
message: testMessage
})
});
const result = await response.json();
if (result.status === 'success') {
res.json({ success: true, message: `Test SMS sent to ${to}` });
} else {
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
}
} catch(err) {
next(err);
}
});
module.exports = router;
+10
View File
@@ -201,6 +201,16 @@ router.post('/resources/:id/rotate-secret', async (req, res, next) => {
}
});
router.post('/resources/:id/service-token', async (req, res, next) => {
try {
const { ServiceToken } = require('../models/token');
const token = await ServiceToken.issue(req.params.id, req.user.uid);
res.json({ results: { token: token.token } });
} catch (err) {
next(err);
}
});
router.delete('/resources/:id', async (req, res, next) => {
try {
const r = await Resource.get(req.params.id);
+1 -1
View File
@@ -274,7 +274,7 @@ router.get('/:id/runs', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError } });
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError, lastLog: inst.lastLog } });
} catch (err) { next(err); }
});
+208
View File
@@ -0,0 +1,208 @@
'use strict';
// Shared-secrets API.
//
// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with
// its DATA in OpenBao at secret/shared/<ownerUid>/<slug> (KV-v2). The owner has
// full R/W/list on their own secret/shared/<ownerUid>/* subtree; each grantee's
// OpenBao policy content is edited to add read on the exact shared path (see
// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely
// the OpenBao ACL — the broker's policy reconciliation makes a grant effective
// immediately, with no token re-mint.
//
// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches
// them through the existing /api/vault proxy using the requester's own session
// token, so OpenBao ACL enforces read access per-request. This router handles
// metadata CRUD + grant management; KV writes (create/update/delete) are made
// server-side using the acting user's scoped token.
const express = require('express');
const baoConf = require('@simpleworkjs/bao-conf');
const permission = require('../utils/permission');
const { SharedSecret } = require('../models/shared_secret');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const vaultBroker = require('../utils/vault_broker');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
const router = express.Router();
// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the
// /api/vault proxy — personal, per-user secret management only).
router.use((req, res, next) => {
if (req.user && req.user.isMachine) {
return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' });
}
next();
});
async function isAdmin(user) {
try { await permission.byGroup(user, ADMIN_GROUPS); return true; }
catch (e) { return false; }
}
// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses
// their own token (R/W on secret/shared/<ownerUid>/*); an admin uses the
// sso-admin token (R/W on secret/*).
async function actorToken(user, ownerUid) {
if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid);
if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid);
return null;
}
// Does this user manage the given shared secret? Owner or admin.
async function canManage(user, secret) {
if (user.uid === secret.ownerUid) return true;
return isAdmin(user);
}
async function loadSecret(req, res) {
const secret = await SharedSecret.get(req.params.id);
if (!secret) { res.status(404).json({ error: 'not found' }); return null; }
return secret;
}
// ── List: mine + shared-with-me ─────────────────────────────────────────────
router.get('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const mine = await SharedSecret.list({ where: { ownerUid: uid } });
const grants = await SharedSecretGrant.listForGrantee('user', uid);
const granteeSecretIds = [...new Set(grants.map(g => g.secretId))];
const granted = granteeSecretIds.length
? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : [];
const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }]));
for (const g of granted) {
if (byId.has(g.id)) continue; // already owner
byId.set(g.id, { role: 'grantee', ...g });
}
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
} catch (e) { next(e); }
});
// ── Create ──────────────────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const slug = String(req.body.slug || '').trim().toLowerCase();
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' });
const description = String(req.body.description || '').trim();
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
if (await SharedSecret.getBySlug(slug)) {
return res.status(409).json({ error: `a shared secret named '${slug}' already exists` });
}
const token = await actorToken(req.user, uid);
if (!token) return res.status(403).json({ error: 'not allowed' });
const path = SharedSecret.pathFor(uid, slug);
await baoConf.set(path, data, { token });
const secret = await SharedSecret.create({
slug, ownerUid: uid, description,
created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(),
});
res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' });
} catch (e) { next(e); }
});
// ── Detail (metadata; data is read via /api/vault proxy) ────────────────────
router.get('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
const uid = req.user.uid;
const admin = await isAdmin(req.user);
const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id);
if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' });
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
} catch (e) { next(e); }
});
// ── Update data / description ───────────────────────────────────────────────
router.put('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' });
const token = await actorToken(req.user, secret.ownerUid);
const update = {};
if (req.body && typeof req.body.data === 'object') {
await baoConf.set(secret.path(), req.body.data, { token });
}
if (req.body && req.body.description !== undefined) {
update.description = String(req.body.description).trim();
}
if (Object.keys(update).length) {
update.updated_by = req.user.uid;
update.updated_on = Date.now();
await secret.update(update);
}
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() });
} catch (e) { next(e); }
});
// ── Delete (KV + DB row + all grants) ───────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' });
const token = await actorToken(req.user, secret.ownerUid);
// Revoke all grants first so grantees' policies drop the path.
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid);
// Delete the KV data (metadata delete removes all versions), then the row.
try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ }
await secret.delete();
res.status(204).end();
} catch (e) { next(e); }
});
// ── Grants: list ────────────────────────────────────────────────────────────
router.get('/:id/grants', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
} catch (e) { next(e); }
});
// ── Grants: create ──────────────────────────────────────────────────────────
router.post('/:id/grants', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const granteeType = String(req.body.granteeType || '').trim();
const granteeId = String(req.body.granteeId || '').trim();
if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' });
if (!granteeId) return res.status(400).json({ error: 'granteeId is required' });
if (granteeId === secret.ownerUid && granteeType === 'user') {
return res.status(400).json({ error: 'the owner already has access' });
}
// Idempotent: skip if the grant already exists.
const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0];
if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability });
const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid);
res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability });
} catch (e) { next(e); }
});
// ── Grants: revoke ──────────────────────────────────────────────────────────
router.delete('/:id/grants/:grantId', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const grant = await SharedSecretGrant.get(req.params.grantId);
if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' });
await vaultBroker.revokeSharedSecret(grant.id, req.user.uid);
res.status(204).end();
} catch (e) { next(e); }
});
module.exports = router;
+38 -1
View File
@@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => {
res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata }),
edges: graph.edges,
updated_on: graph.updated_on
}));
} catch (err) { next(err); }
});
@@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/discovery/access/:uid[/:slug]
// Answers per-user access for a machine caller (e.g. jump-host).
router.get(['/access/:uid', '/access/:uid/:slug'], async (req, res, next) => {
try {
const { fullMetadata } = await callerView(req);
if (!req.user || (!req.user.isMachine && !fullMetadata)) {
return res.status(403).json(envelope({ error: 'Only machine identities or admins may query access for other users.' }));
}
const { User } = require('../models/user_ldap');
const { groupCns } = require('../utils/user_groups');
const targetUser = await User.get(req.params.uid).catch(() => null);
if (!targetUser) return res.status(404).json(envelope({ error: 'User not found' }));
const groups = await groupCns(targetUser);
const ids = new Set();
if (groups.length) {
const rgs = await ResourceGroup.list({ where: { groupCn: { in: groups } } });
for (const rg of rgs) ids.add(rg.resourceId);
}
let all = await Resource.list();
if (req.params.slug) all = all.filter(r => r.slug === req.params.slug);
let accessible = all.filter(r => {
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
const isManaged = r.metadata?.managed === true;
if (isAuto && !isManaged) return false;
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
});
accessible = await Resource.withResolvedAddress(accessible);
res.json(envelope(projectResources(accessible, { fullMetadata })));
} catch (err) { next(err); }
});
// POST /api/discovery/sync
// Used by external agents (e.g. ldap-client) to push discovery data.
router.post('/sync', async (req, res, next) => {
@@ -149,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {};
meta.managed = true;
await resource.update({ metadata: meta });
await Resource.update(resource.id, { metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
+1 -7
View File
@@ -84,13 +84,7 @@ router.get('/discovery', function(req, res, next) {
});
router.get('/plugins', function(req, res, next) {
// Plugin instances page — loadable/unloadable, configurable plugin copies
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
// client gates with app.auth.forceLogin(['app_sso_admin',
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
// the same server-side. Same header-vs-navigation auth model as /conf and
// /vault (auth-token is a client-set header, not a cookie).
res.render('plugins', {...values});
res.redirect('/directory');
});
router.get('/vault', function(req, res) {
+80 -23
View File
@@ -9,34 +9,42 @@ class DiscoveryReconciler {
for (const res of resources) {
if (!res.metadata) res.metadata = {};
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
// Attempt matching by MAC if available
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
const allRes = await Resource.list();
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => i.mac).filter(m => !!m);
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r =>
r.metadata && r.metadata.interfaces &&
r.metadata.interfaces.some(i => macs.includes(i.mac))
r.metadata && (
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
)
);
}
}
// Fallback matching by IP if no MAC match (weaker)
// 2. Fallback matching by IP address
let ipsToMatch = [];
if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
}
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
}
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r => {
if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true;
@@ -45,14 +53,17 @@ class DiscoveryReconciler {
return false;
});
}
// Fallback matching by Slug or Name
// 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) {
const allRes = await Resource.list();
existing = allRes.find(r =>
(res.slug && r.slug === res.slug) ||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
);
const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => {
if (res.slug && r.slug === res.slug) return true;
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
return false;
});
}
if (existing) {
@@ -65,7 +76,10 @@ class DiscoveryReconciler {
const newIntfs = res.metadata.interfaces;
// Simple union based on mac or ip
for (const ni of newIntfs) {
const idx = existingIntfs.findIndex(ei => (ni.mac && ei.mac === ni.mac) || (ni.ip && ei.ip === ni.ip));
const idx = existingIntfs.findIndex(ei =>
(ni.mac && ei.mac && ei.mac.toLowerCase() === ni.mac.toLowerCase()) ||
(ni.ip && ei.ip && ei.ip === ni.ip)
);
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
else existingIntfs.push(ni);
}
@@ -79,16 +93,23 @@ class DiscoveryReconciler {
mergedMeta.last_seen = Date.now();
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
let bestName = existing.name;
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
bestName = res.name;
}
await existing.update({
name: res.name || existing.name,
name: bestName,
description: res.description || existing.description,
metadata: mergedMeta,
updated_on: Math.floor(Date.now() / 1000)
});
res._actualId = existing.id;
} else {
// Create new
const sources = [sourceName];
res.metadata.discovery_sources = sources;
const sources = new Set([sourceName]);
res.metadata.discovery_sources = [...sources];
res.metadata.last_seen = Date.now();
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
@@ -103,12 +124,48 @@ class DiscoveryReconciler {
});
newDevices++;
res._actualId = created.id; // Map original slug to actual ID
WebhookEmitter.emit('discovery.new_device', created.toJSON());
}
}
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
// or we just trust the plugins to give us explicit parent-child mappings by slug.
// Now process edges
const allRes = await Resource.list();
const existingEdges = await ResourceEdge.list();
for (const edge of edges) {
// Find parent ID. It might be in the current payload (mapped to _actualId) or in DB by slug
let parentId = null;
const parentResInPayload = resources.find(r => r._originalSlug === edge.parentSlug);
if (parentResInPayload && parentResInPayload._actualId) {
parentId = parentResInPayload._actualId;
} else {
const parentResInDb = allRes.find(r => r.slug === edge.parentSlug);
if (parentResInDb) parentId = parentResInDb.id;
}
// Find child ID
let childId = null;
const childResInPayload = resources.find(r => r._originalSlug === edge.childSlug);
if (childResInPayload && childResInPayload._actualId) {
childId = childResInPayload._actualId;
} else {
const childResInDb = allRes.find(r => r.slug === edge.childSlug);
if (childResInDb) childId = childResInDb.id;
}
if (parentId && childId) {
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
if (!edgeExists) {
await ResourceEdge.create({
id: crypto.randomUUID(),
parentId,
childId,
relation: edge.relation
});
}
}
}
if (newDevices > 0) {
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
+9 -3
View File
@@ -71,17 +71,23 @@ async function runPluginJob(instanceId) {
}
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null });
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null, lastLog: null });
let logs = [];
try {
const cfg = await pluginSecrets.mergeForRun(instance);
cfg.log = (msg) => {
logs.push(`[${new Date().toISOString()}] ${msg}`);
console.log(`[Plugin ${instance.slug}] ${msg}`);
if (logs.length > 1000) logs.shift();
};
const payload = await runFn(cfg);
if (instance.category === 'discovery') {
await DiscoveryReconciler.reconcile(instance.slug, payload);
}
await instance.update({ lastStatus: STATUS.OK, lastError: null });
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
} catch (err) {
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err) });
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err), lastLog: logs.join('\n') });
}
}
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
app.use('/', createProxyMiddleware({
target: 'http://localhost:8080',
on: {
proxyRes: (proxyRes, req, res) => {
delete proxyRes.headers['x-frame-options'];
}
}
}));
app.listen(3004);
+100
View File
@@ -0,0 +1,100 @@
'use strict';
const crypto = require('crypto');
const agentManager = require('../utils/agent_manager');
describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
let mockWs;
let sentMessages;
beforeEach(() => {
sentMessages = [];
mockWs = {
readyState: 1, // OPEN
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
close: jest.fn()
};
});
test('registers agent and tracks initial connection state', () => {
const record = agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
expect(record.token).toBe('test-token-123');
expect(record.ipAddress).toBe('192.168.1.100');
const agents = agentManager.getConnectedAgents();
const found = agents.find(a => a.token === 'test-token-123');
expect(found).toBeDefined();
expect(found.isOnline).toBe(true);
});
test('processes discovery payload per PROTOCOL.md v1.1.0 Section 3.1', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
const discoveryPayload = {
hostname: 'node-01.local',
ip_addresses: ['192.168.1.100', '10.0.0.5'],
os: 'Ubuntu 24.04 LTS',
kernel: '6.8.0-31-generic',
cpu: 'AMD EPYC 7763',
ram_total_gb: 32.0,
disk_total_gb: 500.0,
location: 'dc-chicago-rack-4'
};
agentManager.handleDiscovery('test-token-123', discoveryPayload);
const agents = agentManager.getConnectedAgents();
const agent = agents.find(a => a.token === 'test-token-123');
expect(agent.hostname).toBe('node-01.local');
expect(agent.discovery.os).toBe('Ubuntu 24.04 LTS');
expect(agent.discovery.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
});
test('processes telemetry payload per PROTOCOL.md v1.1.0 Section 3.2', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
const telemetryPayload = {
cpu_usage_percent: 14.5,
ram_usage_percent: 42.1,
disk_usage_percent: 68.0,
zfs_health: 'ONLINE',
gpu_usage_percent: -1.0,
timestamp: new Date().toISOString()
};
agentManager.handleTelemetry('test-token-123', telemetryPayload);
const agents = agentManager.getConnectedAgents();
const agent = agents.find(a => a.token === 'test-token-123');
expect(agent.telemetry.cpu_usage_percent).toBe(14.5);
expect(agent.telemetry.zfs_health).toBe('ONLINE');
});
test('responds to heartbeat with heartbeat_ack per Section 3.3', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
agentManager.handleHeartbeat('test-token-123', { timestamp: new Date().toISOString() }, mockWs);
expect(mockWs.send).toHaveBeenCalled();
const lastMsg = sentMessages[sentMessages.length - 1];
expect(lastMsg.type).toBe('heartbeat_ack');
expect(lastMsg.payload.timestamp).toBeDefined();
});
test('canonicalizes payload and signs high-risk commands using Ed25519 per Section 5', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
const rawPayload = { script: 'uptime', location: 'datacenter' };
const msg = agentManager.sendCommand('test-token-123', 'arbitrary_bash', rawPayload, true);
expect(msg.type).toBe('arbitrary_bash');
expect(msg.payload.signature).toBeDefined();
expect(typeof msg.payload.signature).toBe('string');
// Verify signature with public key
const signatureBuffer = Buffer.from(msg.payload.signature, 'base64');
const canonicalStr = agentManager.canonicalize(rawPayload);
const isValid = crypto.verify(null, Buffer.from(canonicalStr, 'utf8'), agentManager.publicKeyPem, signatureBuffer);
expect(isValid).toBe(true);
});
});
+69
View File
@@ -0,0 +1,69 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies before requiring the route
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(),
set: jest.fn(),
}));
jest.mock('../utils/permission', () => ({
byGroup: jest.fn().mockResolvedValue(true),
}));
jest.mock('@simpleworkjs/conf', () => ({}));
const baoConf = require('@simpleworkjs/bao-conf');
const apiConf = require('../routes/api_conf');
const app = express();
app.use(express.json());
// Add a mock user for the permission check
app.use((req, res, next) => {
req.user = { uid: 'testadmin' };
next();
});
app.use('/api/conf', apiConf);
describe('Proxy Conf API (Vault Integration)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('GET /api/conf/proxy returns proxy conf with masked secrets', async () => {
baoConf.get.mockResolvedValueOnce({
oidc: { issuer: 'https://test', clientId: 'cid', clientSecret: 'real_secret' },
ldap: { bindPassword: 'real_ldap_password' }
});
const res = await request(app).get('/api/conf/proxy');
expect(res.status).toBe(200);
expect(res.body.oidc.issuer).toBe('https://test');
expect(res.body.oidc.clientSecret).toBe('********'); // MASKED
expect(res.body.ldap.bindPassword).toBe('********'); // MASKED
expect(baoConf.get).toHaveBeenCalledWith('proxy/conf');
});
it('POST /api/conf/proxy merges configuration securely to OpenBao', async () => {
baoConf.get.mockResolvedValueOnce({
oidc: { clientSecret: 'old_secret' },
ldap: { bindPassword: 'old_ldap' }
});
const payload = {
oidc: { issuer: 'https://new', clientSecret: '********' }, // Admin left it unchanged
ldap: { bindPassword: 'new_password' }
};
const res = await request(app)
.post('/api/conf/proxy')
.send(payload);
expect(res.status).toBe(200);
expect(baoConf.set).toHaveBeenCalledTimes(1);
const saved = baoConf.set.mock.calls[0][1];
expect(saved.oidc.issuer).toBe('https://new');
expect(saved.oidc.clientSecret).toBe('old_secret'); // Preserved because incoming was mask
expect(saved.ldap.bindPassword).toBe('new_password'); // Overwritten because incoming was new
});
});
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const nmapPlugin = require('../plugins/discovery/nmap');
jest.mock('node-nmap', () => {
const EventEmitter = require('events');
class MockNmapScan extends EventEmitter {
constructor(targetRange, customFlags) {
super();
this.targetRange = targetRange;
this.customFlags = customFlags;
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
}
startScan() {
setImmediate(() => {
this.emit('complete', [
{ ip: '192.168.1.10', hostname: 'host-10', openPorts: [{ port: 80, protocol: 'tcp', service: 'http' }] }
]);
});
}
}
return {
NmapScan: MockNmapScan,
nmapLocation: 'nmap'
};
});
describe('nmap discovery plugin', () => {
test('discover passes custom flags (-Pn, -sT, -F, --min-rate) to constructor', async () => {
const logs = [];
const result = await nmapPlugin.discover({
targetRange: '192.168.1.0/24',
log: (msg) => { logs.push(msg); }
});
const startLog = logs.find(l => l.startsWith('Starting nmap scan'));
expect(startLog).toBeDefined();
expect(startLog).toContain('-Pn');
expect(startLog).toContain('-sT');
expect(startLog).toContain('-F');
expect(startLog).toContain('--min-rate 100');
expect(result.resources).toHaveLength(2); // host + service
expect(result.resources[0].name).toBe('host-10');
expect(result.edges).toHaveLength(1);
});
});
+51
View File
@@ -0,0 +1,51 @@
'use strict';
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(),
set: jest.fn(),
request: jest.fn(),
}));
jest.mock('redis', () => ({
createClient: () => ({
on: jest.fn(),
connect: jest.fn().mockResolvedValue(),
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(),
})
}));
const baoConf = require('@simpleworkjs/bao-conf');
const vaultBroker = require('../utils/vault_broker');
describe('vault_broker admin policy', () => {
beforeEach(() => {
baoConf.request.mockReset();
});
test('getOrCreateAdminToken ensures sso-admin policy with list capabilities on metadata', async () => {
baoConf.request.mockImplementation(async (method, path, body) => {
if (method === 'GET' && path === 'sys/policies/acl/sso-admin') {
return { status: 404, text: async () => '' };
}
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
return { status: 204, ok: true };
}
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
return {
ok: true,
json: async () => ({ auth: { client_token: 'test-admin-token', lease_duration: 3600 } })
};
}
return { status: 200, ok: true, json: async () => ({}) };
});
const token = await vaultBroker.getOrCreateAdminToken('adminuser');
expect(token).toBe('test-admin-token');
expect(baoConf.request).toHaveBeenCalledWith('PUT', 'sys/policies/acl/sso-admin', expect.objectContaining({
policy: expect.stringContaining('path "secret/metadata/"')
}));
});
});
+180
View File
@@ -0,0 +1,180 @@
'use strict';
const crypto = require('crypto');
class AgentManager {
constructor() {
this.agents = new Map(); // token -> agentRecord
this.privateKeyPem = null;
this.publicKeyPem = null;
this.initKeyPair();
}
initKeyPair() {
try {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
this.privateKeyPem = privateKey;
this.publicKeyPem = publicKey;
} catch (err) {
console.error('[AgentManager] Failed to generate Ed25519 key pair:', err);
}
}
/**
* Canonicalize payload for signing per PROTOCOL.md v1.1.0 section 5:
* Sort keys alphabetically, remove whitespace, omit 'signature' key.
*/
canonicalize(payload) {
const cleanObj = {};
const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort();
for (const key of sortedKeys) {
cleanObj[key] = payload[key];
}
return JSON.stringify(cleanObj);
}
/**
* Sign payload using Ed25519 private key.
* Returns base64 encoded signature.
*/
signPayload(payload) {
if (!this.privateKeyPem) {
throw new Error('Ed25519 private key is not initialized');
}
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
const signature = crypto.sign(null, canonicalBytes, this.privateKeyPem);
return signature.toString('base64');
}
registerAgent(token, ws, remoteAddress) {
const existing = this.agents.get(token);
if (existing && existing.ws && existing.ws !== ws) {
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
}
const agentRecord = {
token,
ws,
ipAddress: remoteAddress,
hostname: 'unknown',
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
discovery: {},
telemetry: {},
pendingResponses: new Map()
};
this.agents.set(token, agentRecord);
return agentRecord;
}
unregisterAgent(token, ws) {
const record = this.agents.get(token);
if (record && record.ws === ws) {
this.agents.delete(token);
}
}
handleDiscovery(token, payload) {
const agent = this.agents.get(token);
if (!agent) return;
agent.lastSeen = new Date().toISOString();
agent.hostname = payload.hostname || agent.hostname;
agent.discovery = {
hostname: payload.hostname || '',
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
os: payload.os || '',
kernel: payload.kernel || '',
cpu: payload.cpu || '',
ram_total_gb: payload.ram_total_gb || 0,
disk_total_gb: payload.disk_total_gb || 0,
location: payload.location || 'default'
};
}
handleTelemetry(token, payload) {
const agent = this.agents.get(token);
if (!agent) return;
agent.lastSeen = new Date().toISOString();
agent.telemetry = {
cpu_usage_percent: payload.cpu_usage_percent || 0,
ram_usage_percent: payload.ram_usage_percent || 0,
disk_usage_percent: payload.disk_usage_percent || 0,
zfs_health: payload.zfs_health || 'N/A',
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
timestamp: payload.timestamp || new Date().toISOString()
};
}
handleHeartbeat(token, payload, ws) {
const agent = this.agents.get(token);
if (agent) {
agent.lastSeen = new Date().toISOString();
}
try {
ws.send(JSON.stringify({
type: 'heartbeat_ack',
payload: { timestamp: new Date().toISOString() }
}));
} catch (e) {}
}
handleResponse(token, payload) {
const agent = this.agents.get(token);
if (agent) {
agent.lastSeen = new Date().toISOString();
agent.lastResponse = {
status: payload.status || 'ok',
message: payload.message || '',
output: payload.output || '',
timestamp: new Date().toISOString()
};
}
}
sendCommand(token, commandType, payload = {}, isHighRisk = false) {
const agent = this.agents.get(token);
if (!agent || !agent.ws || agent.ws.readyState !== 1) {
throw new Error(`Agent with token "${token}" is not connected`);
}
const finalPayload = { ...payload };
if (isHighRisk) {
finalPayload.signature = this.signPayload(finalPayload);
}
const message = {
type: commandType,
payload: finalPayload
};
agent.ws.send(JSON.stringify(message));
return message;
}
getConnectedAgents() {
const list = [];
const now = new Date();
for (const [token, agent] of this.agents.entries()) {
list.push({
token,
hostname: agent.hostname,
ipAddress: agent.ipAddress,
connectedAt: agent.connectedAt,
lastSeen: agent.lastSeen,
discovery: agent.discovery,
telemetry: agent.telemetry,
lastResponse: agent.lastResponse || null,
isOnline: (now - new Date(agent.lastSeen)) < 90000
});
}
return list;
}
}
module.exports = new AgentManager();
+4 -6
View File
@@ -38,16 +38,14 @@ module.exports = {
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
// Ungated on purpose: the catalog is the one page that exists for
// ordinary users. Before this, every nav item was admin-only and a
// non-admin had no signposted destination at all.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
// Catalog requires login - it's the end-user view of their accessible resources.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_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']},
{href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []},
// 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']},
],
};
+137 -39
View File
@@ -4,15 +4,25 @@
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
// `sso-broker` token role created by theta-env/setup.sh.
//
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
// secret/* admin UI sessions (sso-admin policy)
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
// secret/shared/<uid>/* user-owned shared KV (user-<uid> policy)
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
// secret/shared/<owner>/<slug> granted read (added to grantee's policy)
// secret/* admin UI sessions (sso-admin policy)
//
// The sso-broker policy grants update on auth/token/create/sso-broker and on
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
// create the per-subject policies and mint their tokens. Per-user/admin tokens
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
//
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
// live at token use (only the SET of policy names on a token is fixed at mint),
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
// — cached or freshly minted. That way a stale cached token immediately gains
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
// an existing grantee token with no re-mint. The Redis cache only short-circuits
// token MINTING, never policy reconciliation.
const baoConf = require('@simpleworkjs/bao-conf');
const { createClient } = require('redis');
@@ -20,6 +30,8 @@ const express = require('express');
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
const conf = require('@simpleworkjs/conf');
const permission = require('./permission');
const { SharedSecret } = require('../models/shared_secret');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const ROLE = 'sso-broker';
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
@@ -54,17 +66,20 @@ async function bao(method, path, body) {
return res;
}
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
// a list grant on a directory path) propagate on the next vault-page visit
// without an operator re-running setup.sh. Skipping on an existing policy
// would strand the old, narrower HCL forever.
// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
// content and only PUT when it differs. `bao policy write` is an idempotent
// 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}`);
}
if (existing.status === 200) {
const body = await existing.json().catch(() => null);
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
}
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
}
@@ -79,33 +94,73 @@ async function mintToken(policies) {
return { token, ttl };
}
// ── Per-user token ──────────────────────────────────────────────────────────
function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
// into a policy path, so reject anything but a safe charset.
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
// contents of the namespace: `.../*` covers nested paths but NOT the
// directory itself, so without it the /vault secrets list 403s.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
// ── Shared-secret policy rules ───────────────────────────────────────────────
// Returns the HCL rules granting `read` on every shared secret the given
// grantee (a user uid or an app name) has been granted. Enforcement is
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
// effect for the grantee's existing token immediately (no re-mint).
async function sharedPolicyRules(granteeType, granteeId) {
const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
if (!grants.length) return '';
const secretIds = [...new Set(grants.map(g => g.secretId))];
const secrets = secretIds.length
? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
const byId = new Map(secrets.map(s => [s.id, s]));
const rules = [];
for (const g of grants) {
const sec = byId.get(g.secretId);
if (!sec) continue;
const p = sec.path(); // shared/<ownerUid>/<slug>
rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
}
return rules.join('\n');
}
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
// Re-minted when the cache entry expires (a little before the token's own TTL).
// ── Per-user token ──────────────────────────────────────────────────────────
async function userPolicyHcl(uid) {
const granted = await sharedPolicyRules('user', uid);
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
// (compare-and-skip) before the cache is consulted, so a cached token can never
// outlive a policy change; the cache only short-circuits re-minting. Re-minted
// when the cache entry expires (a little before the token's own TTL).
async function getOrCreateUserToken(uid) {
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
const cacheKey = `vault_token:${uid}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
const { token, ttl } = await mintToken([`user-${uid}`]);
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
return token;
}
// ── Admin token (read/write all of secret/) ─────────────────────────────────
function adminPolicyHcl() {
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
}
async function getOrCreateAdminToken(uid) {
await ensurePolicy('sso-admin', adminPolicyHcl());
const cacheKey = `vault_token:admin:${uid || 'global'}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
@@ -115,27 +170,65 @@ async function getOrCreateAdminToken(uid) {
}
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) {
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
async function appPolicyHcl(name) {
const granted = await sharedPolicyRules('app', name);
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
// (the admin UI shows it with a copy button); it is not stored retrievably, so
// a later compromise of an admin session cannot recover previously-minted app
// tokens. The caller must record it in the external app immediately.
// tokens. The caller must record it in the external app immediately. Later
// grants to the app edit app-<name> policy content (live-applied to this token).
async function mintAppToken(name) {
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
}
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
const { token, ttl } = await mintToken([`app-${name}`]);
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
}
// ── Grant / revoke shared-secret access ─────────────────────────────────────
// Creating a grant writes the DB row and then edits the grantee's policy content
// to add read on the shared path; revoking removes both. Because OpenBao parses
// policy content live, the change applies to the grantee's existing token
// immediately — no token re-mint, no cache invalidation needed.
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
const grant = await SharedSecretGrant.create({
secretId, granteeType, granteeId, capability: 'read',
created_by: actorUid, created_on: Date.now(),
updated_by: actorUid, updated_on: Date.now(),
});
await reconcileGrantee(granteeType, granteeId);
return grant;
}
async function revokeSharedSecret(grantId, actorUid) {
const grant = await SharedSecretGrant.get(grantId);
if (!grant) return null;
const { granteeType, granteeId } = grant;
await grant.delete();
await reconcileGrantee(granteeType, granteeId);
return grant;
}
// Recompute and rewrite a grantee's policy content after a grant/revoke.
async function reconcileGrantee(granteeType, granteeId) {
if (granteeType === 'user') {
await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
} else if (granteeType === 'app') {
await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
} else {
throw new Error(`invalid granteeType: ${granteeType}`);
}
}
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
// nothing). The guard mints a server-side token for the user (per-user or
@@ -144,11 +237,12 @@ async function mintAppToken(name) {
// client's sso auth headers so OpenBao never sees them.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const ADMIN_GROUP = 'app_sso_admin';
async function isAdmin(user) {
try {
await permission.byGroup(user, [ADMIN_GROUP]);
await permission.byGroup(user, ADMIN_GROUPS);
return true;
} catch (e) {
return false;
@@ -177,17 +271,13 @@ async function scopeGuard(req, res, next) {
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
}
// Defense-in-depth: confirm the requested path is within the subject's
// namespace. Admins roam all of secret/; users are confined to
// secret/users/<uid>/. (The token's own policy enforces the same at the
// OpenBao layer; this catches a buggy/malicious client early with a clear
// 403 instead of an opaque OpenBao denial.)
const norm = normalizeVaultPath(req.path);
if (norm === null) {
return res.status(403).json({ error: 'vault paths must be under /secret/' });
}
const base = `/secret/users/${uid}`;
const allowed = admin || norm === base || norm.startsWith(base + '/');
const userBase = `/secret/users/${uid}`;
const sharedBase = `/secret/shared`;
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
if (!allowed) {
return res.status(403).json({ error: 'path outside your vault namespace' });
}
@@ -201,7 +291,7 @@ function vaultProxy() {
return createProxyMiddleware({
target: VAULT_ADDR,
changeOrigin: true,
pathRewrite: { '^/': '/v1/' },
pathRewrite: { '^/api/vault': '/v1' },
on: {
proxyReq(proxyReq, req, res, options) {
fixRequestBody(proxyReq, req, res, options);
@@ -240,4 +330,12 @@ module.exports = {
scopeGuard,
vaultProxy,
mintAppRouter,
};
// sharing
SharedSecret,
SharedSecretGrant,
userPolicyHcl,
appPolicyHcl,
grantSharedSecret,
revokeSharedSecret,
reconcileGrantee,
};
+390 -139
View File
@@ -1,10 +1,16 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
var messagingTypes = {};
var messagingPlugins = [];
$(document).ready(function() {
loadConf();
loadProxyConf();
loadTos();
loadMessagingPlugins();
});
async function loadConf() {
@@ -43,8 +49,8 @@
async function saveConf() {
const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
smtp: {
host: $('#smtp-host').val(),
@@ -71,14 +77,77 @@
try {
await app.api.post('conf', payload);
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
app.messages.toast('Configuration saved successfully!', 'success');
} catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
}
}
async function sendTestEmail() {
const to = $('#test-email-to').val().trim();
if (!to) {
app.messages.toast('Please enter a recipient email address', 'warning');
return;
}
const btn = $('#btn-test-email');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
smtp: {
host: $('#smtp-host').val(),
port: parseInt($('#smtp-port').val(), 10) || 587,
user: $('#smtp-user').val(),
pass: $('#smtp-pass').val(),
from: $('#smtp-from').val(),
secure: $('#smtp-secure').is(':checked')
}
};
await app.api.post('conf', payload);
const result = await app.api.post('conf/test-email', { to });
app.messages.toast(result.message || 'Test email sent!', 'success');
$('#test-email-to').val('');
} catch (error) {
app.messages.toast('Failed to send test email: ' + (error.message || 'Unknown error'), 'danger');
} finally {
btn.prop('disabled', false).html(originalHtml);
}
}
async function sendTestSms() {
const to = $('#test-sms-to').val().trim();
if (!to) {
app.messages.toast('Please enter a recipient phone number', 'warning');
return;
}
const btn = $('#btn-test-sms');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
await app.api.post('conf', payload);
const result = await app.api.post('conf/test-sms', { to });
app.messages.toast(result.message || 'Test SMS sent!', 'success');
$('#test-sms-to').val('');
} catch (error) {
app.messages.toast('Failed to send test SMS: ' + (error.message || 'Unknown error'), 'danger');
} finally {
btn.prop('disabled', false).html(originalHtml);
}
}
function togglePassword(id) {
const el = document.getElementById(id);
if (el.type === 'password') {
@@ -88,18 +157,55 @@
}
}
// ── Terms of Service editor ──────────────────────────────────────────
// Moved here from the admin Overview dashboard — it's a configuration
// control, so it belongs on the System Configuration page. The API is
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
// matches this page's gate). app.tos.get/update are the shared frontend
// helpers (@simpleworkjs/frontend).
async function loadProxyConf() {
try {
const data = await app.api.get('conf/proxy');
if (data.oidc) {
$('#proxy-issuer').val(data.oidc.issuer || '');
$('#proxy-client-id').val(data.oidc.clientId || '');
$('#proxy-client-secret').val(data.oidc.clientSecret || '');
}
if (data.ldap) {
$('#proxy-ldap-bindpass').val(data.ldap.bindPassword || '');
}
} catch (error) {
console.error('Failed to load Proxy conf:', error);
}
}
async function saveProxyConf() {
const btn = $('#btn-save-proxy');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
oidc: {
issuer: $('#proxy-issuer').val(),
clientId: $('#proxy-client-id').val(),
clientSecret: $('#proxy-client-secret').val()
},
ldap: {
bindPassword: $('#proxy-ldap-bindpass').val()
}
};
try {
await app.api.post('conf/proxy', payload);
app.messages.toast('Proxy configuration saved securely to OpenBao!', 'success');
} catch (error) {
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
}
}
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
if (tos && tos.content) {
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
}
} catch(e) {
console.error('Failed to load ToS:', e);
}
@@ -131,142 +237,287 @@
loadTos();
});
}
// ── Messaging Plugins ──────────────────────────────────────────────
function loadMessagingPlugins() {
app.api.get('plugins/types', function(err, res) {
if (!err && res && res.results) {
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
}
app.api.get('plugins', function(err, res) {
if (err) return;
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
renderMessagingPlugins();
});
});
}
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function togglePlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function deletePlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
</script>
<div class="container py-4">
<div class="row mb-4">
<div class="col d-flex justify-content-between align-items-center">
<div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0">
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
settings. These are stored securely in OpenBao and take effect immediately.
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
are masked — leave them unchanged to keep the stored value.
</p>
</div>
<div>
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
</div>
</div>
</div>
<div class="container mt-4">
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Host</label>
<input type="text" class="form-control" id="smtp-host">
</div>
<div class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
<input type="text" class="form-control" id="smtp-from">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
<div class="col-12">
<div class="card shadow">
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
<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="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
</button>
</li>
</ul>
<div>
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer">
</div>
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
<div class="card-body p-4">
<div class="tab-content" id="confTabContent">
<!-- OAuth & JWT Tab -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
</div>
</div>
</div>
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
<div class="mb-3">
<label class="form-label">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access">
</div>
<div class="mb-3">
<label class="form-label">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh">
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
</div>
<div class="card-body">
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
<div class="mb-3">
<label class="form-label">API Username</label>
<input type="text" class="form-control" id="voipms-username">
</div>
<div class="mb-3">
<label class="form-label">DID (sender number)</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
<div class="mb-3">
<label class="form-label">API Password</label>
<div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
<!-- SMTP Tab -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Port</label>
<input type="number" class="form-control" id="smtp-port" placeholder="587">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">From Address</label>
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
</div>
<div class="p-3 bg-light rounded border">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane me-1"></i> Send Test Email
</button>
</div>
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
<!-- SMS & Messaging Tab -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
<input type="text" class="form-control" id="voipms-username">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">DID Sender Number</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">API Password</label>
<div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="p-3 bg-light rounded border mb-4">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
</button>
</div>
</div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div id="messaging-plugins-list"></div>
</div>
<!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div>
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Proxy Bind Password</label>
<div class="input-group">
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
</div>
</div>
<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>
<!-- 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">
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
</div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control" id="tos-content" rows="8"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label>
</div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
<div id="tos-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
+392 -7
View File
@@ -13,7 +13,12 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
<i class="fa-solid fa-network-wired"></i> Discovery
<i class="fa-solid fa-network-wired"></i> Discovered Inventory
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
<i class="fa-solid fa-plug"></i> Discovery Plugins
</button>
</li>
</ul>
@@ -40,6 +45,9 @@
<datalist id="access-uid-list"></datalist>
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
</div>
<button class="btn btn-sm btn-outline-primary ms-1 shadow-sm" onclick="openAgentInstallModal()">
<i class="fa-solid fa-shield-halved me-1"></i> Install Agent
</button>
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Resource
</button>
@@ -184,6 +192,23 @@
</div>
</div>
</div>
<!-- Discovery Plugins Tab Pane -->
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
<div class="p-4 bg-white border-top">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
</div>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
</div>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
@@ -1185,6 +1210,7 @@
allEdges.push(res.results);
refreshEdgesUI(resourceId);
$('#new-edge-target').val('');
await loadData();
} catch (err) {
console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
@@ -1196,6 +1222,7 @@
await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val());
await loadData();
} catch (err) {
console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
@@ -1235,9 +1262,10 @@
function renderDiscoveryTable() {
const search = $('#discovery-search-filter').val().toLowerCase();
const filtered = allDiscoveryResources.filter(r => {
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
const isManaged = !!(r.metadata && r.metadata.managed);
if(isManaged) return false;
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
return true;
});
@@ -1272,12 +1300,369 @@
});
}
// Plugin scheduling moved to the dedicated /plugins page (the Agents &
// Scheduler tab here was its old home). Discovery inventory + the discovery
// results table remain on this page.
// --- THETA AGENT INSTALL MODAL & WIZARD ---
function generateRandomHexToken(byteLen) {
const arr = new Uint8Array(byteLen || 16);
(window.crypto || window.msCrypto).getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}
function regenerateAgentToken(inputId) {
const newToken = generateRandomHexToken(16);
$('#' + inputId).val(newToken);
if (inputId === 'agent-quick-token') $('#agent-custom-token').val(newToken);
else $('#agent-quick-token').val(newToken);
updateAgentCommands();
}
function updateAgentCommands() {
const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
const quickToken = $('#agent-quick-token').val() || '';
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`;
$('#agent-quick-command').text(quickCmd);
const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, '');
const customToken = $('#agent-custom-token').val() || '';
const customLocation = $('#agent-custom-location').val() || 'default';
const telemetry = $('#cap-telemetry').is(':checked');
const configureLdap = $('#cap-configure-ldap').is(':checked');
const reboot = $('#cap-reboot').is(':checked');
const arbitraryBash = $('#cap-arbitrary-bash').is(':checked');
const servicesRaw = $('#cap-services').val() || '';
const servicesList = servicesRaw.split(',').map(s => s.trim()).filter(Boolean);
const servicesYaml = servicesList.length > 0
? '[' + servicesList.map(s => `"${s}"`).join(', ') + ']'
: '[]';
const yamlStr = [
`server_url: "${customUrl}"`,
`auth_token: "${customToken}"`,
`location: "${customLocation}"`,
`capabilities:`,
` telemetry: ${telemetry}`,
` configure_ldap: ${configureLdap}`,
` reboot: ${reboot}`,
` service_control: ${servicesYaml}`,
` arbitrary_bash: ${arbitraryBash}`
].join('\n');
$('#agent-yaml-preview').text(yamlStr);
try {
const b64Config = btoa(yamlStr);
const customCmd = `curl -fsSL ${customUrl}/resources/theta-agent/install.sh | sh -s -- "${b64Config}"`;
$('#agent-custom-command').text(customCmd);
} catch (e) {
$('#agent-custom-command').text('Error encoding config to Base64');
}
}
function copyAgentCommand(elementId, btnId) {
const text = $('#' + elementId).text();
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const $btn = $('#' + btnId);
const origHtml = $btn.html();
$btn.html('<i class="fa-solid fa-check me-1"></i> Copied!').removeClass('btn-success').addClass('btn-outline-success');
setTimeout(() => {
$btn.html(origHtml).removeClass('btn-outline-success').addClass('btn-success');
}, 2000);
}).catch(err => {
app.messages.toast('Failed to copy: ' + err, 'danger');
});
}
function openAgentInstallModal() {
const currentOrigin = window.location.origin;
const initialToken = generateRandomHexToken(16);
const bodyHtml = `
<div class="mb-3 p-3 bg-light rounded border">
<div class="d-flex align-items-center">
<i class="fa-solid fa-shield-halved fa-2x text-primary me-3"></i>
<div>
<h6 class="mb-0 fw-bold">Theta Agent Endpoint Management Daemon</h6>
<small class="text-muted">A 2-way Command & Control (C2) daemon that streams real-time telemetry and enables secure, capability-controlled management operations on Linux hosts.</small>
</div>
</div>
</div>
<ul class="nav nav-pills mb-3" id="agent-install-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-quick-btn" data-bs-toggle="pill" data-bs-target="#tab-quick-pane" type="button" role="tab">
<i class="fa-solid fa-bolt me-1"></i> Quick Install
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-custom-btn" data-bs-toggle="pill" data-bs-target="#tab-custom-pane" type="button" role="tab">
<i class="fa-solid fa-sliders me-1"></i> Custom Config Wizard
</button>
</li>
</ul>
<div class="tab-content" id="agent-install-tab-content">
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label small fw-bold mb-1">SSO Server URL</label>
<input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold mb-1">Host Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-quick-token')" title="Regenerate Token">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
</div>
</div>
<label class="form-label small fw-bold mb-1">Run this command on the target host (as root):</label>
<div class="position-relative mb-2">
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-quick-command" style="user-select: all;"></pre>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-sm btn-success" id="btn-copy-quick" onclick="copyAgentCommand('agent-quick-command', 'btn-copy-quick')">
<i class="fa-solid fa-copy me-1"></i> Copy Quick Install Command
</button>
</div>
</div>
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
<div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
<div class="row g-2 mb-3">
<div class="col-md-5">
<label class="form-label small fw-bold mb-1">SSO Server URL</label>
<input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div>
<div class="col-md-4">
<label class="form-label small fw-bold mb-1">Host Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-custom-token')" title="Regenerate Token">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">Location Identifier</label>
<input type="text" id="agent-custom-location" class="form-control form-control-sm" placeholder="e.g. dc-01-rack-12" value="default" oninput="updateAgentCommands()">
</div>
</div>
<div class="card bg-light border mb-3">
<div class="card-header py-2 bg-light fw-bold small"><i class="fa-solid fa-key me-1"></i> Capability Matrix (Local-First Security Controls)</div>
<div class="card-body py-2">
<div class="row g-2">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-telemetry" checked onchange="updateAgentCommands()">
<label class="form-check-label small" for="cap-telemetry"><strong>Telemetry</strong> <span class="text-muted">(CPU, RAM, Disk, ZFS stats)</span></label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-configure-ldap" checked onchange="updateAgentCommands()">
<label class="form-check-label small" for="cap-configure-ldap"><strong>Configure LDAP</strong> <span class="text-muted">(SSSD config & SSH keys)</span></label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-reboot" onchange="updateAgentCommands()">
<label class="form-check-label small" for="cap-reboot"><strong>Reboot</strong> <span class="text-muted">(remote system reboot)</span></label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cap-arbitrary-bash" onchange="updateAgentCommands()">
<label class="form-check-label small text-danger" for="cap-arbitrary-bash"><strong>Arbitrary Bash</strong> <span class="text-muted">(remote root execution)</span></label>
</div>
</div>
<div class="col-12 mt-2">
<label class="form-label small fw-bold mb-1">Service Control Allowlist <span class="text-muted font-normal">(comma-separated services, e.g. nginx, gitea, sssd)</span></label>
<input type="text" id="cap-services" class="form-control form-control-sm" placeholder="nginx, docker, sssd" oninput="updateAgentCommands()">
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs nav-tabs-sm mb-2" id="preview-sub-tabs" role="tablist">
<li class="nav-item">
<button class="nav-link active py-1 px-3 small" id="subtab-cmd-btn" data-bs-toggle="tab" data-bs-target="#subtab-cmd-pane" type="button">Base64 Install Command</button>
</li>
<li class="nav-item">
<button class="nav-link py-1 px-3 small" id="subtab-yaml-btn" data-bs-toggle="tab" data-bs-target="#subtab-yaml-pane" type="button">Generated agent.yml</button>
</li>
</ul>
<div class="tab-content mb-2">
<div class="tab-pane fade show active" id="subtab-cmd-pane" role="tabpanel">
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-custom-command" style="user-select: all;"></pre>
</div>
<div class="tab-pane fade" id="subtab-yaml-pane" role="tabpanel">
<pre class="bg-light text-dark p-3 rounded border font-monospace small mb-0" id="agent-yaml-preview"></pre>
</div>
</div>
<div class="d-flex justify-content-end">
<button class="btn btn-sm btn-success" id="btn-copy-custom" onclick="copyAgentCommand('agent-custom-command', 'btn-copy-custom')">
<i class="fa-solid fa-copy me-1"></i> Copy Base64 Command
</button>
</div>
</div>
</div>
`;
app.modal.open({
title: 'Install Theta Agent',
bodyHtml: bodyHtml,
size: 'lg'
});
updateAgentCommands();
}
var discoveryPlugins = [];
function loadDiscoveryPlugins() {
app.api.get('plugins', function(err, res) {
if (err) return;
discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery');
renderDiscoveryPlugins();
});
}
function renderDiscoveryPlugins() {
const $list = $('#discovery-plugins-list').empty();
if (discoveryPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fa-solid fa-plug fs-2 mb-2 text-black-50"></i><br>No discovery plugins configured.</div>');
return;
}
discoveryPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function toggleDiscoveryPlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function runDiscoveryPluginNow(id) {
try {
await app.api.post(`plugins/${id}/run`, {});
app.messages.toast('Enqueued discovery plugin run', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error running plugin: ' + e.message, 'danger');
}
}
var discoveryPluginTypes = [];
function openNewDiscoveryPluginModal() {
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
if (discoveryPluginTypes.length === 0) {
app.messages.toast('No discovery plugin types available', 'warning');
return;
}
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
const bodyHtml = `
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<select id="new-plugin-type" class="form-select shadow-sm">${options}</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Slug</label>
<input type="text" id="new-plugin-slug" class="form-control shadow-sm font-monospace" placeholder="e.g. local-subnet-scanner">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Cron Schedule</label>
<input type="text" id="new-plugin-cron" class="form-control shadow-sm font-monospace" value="*/15 * * * *">
<div class="form-text">Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
</div>
`;
app.modal.open({
title: 'Configure New Discovery Plugin',
bodyHtml: bodyHtml,
size: 'md'
});
});
}
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();
const slug = $('#new-plugin-slug').val().trim() || name.toLowerCase().replace(/[^a-z0-9]/g, '-');
const cron = $('#new-plugin-cron').val().trim() || '*/15 * * * *';
const enabled = $('#new-plugin-enabled').is(':checked');
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
try {
await app.api.post('plugins', {
pluginType: type,
name,
slug,
cron,
enabled,
config: {}
});
app.messages.toast('Discovery plugin created successfully!', 'success');
app.modal.close();
loadDiscoveryPlugins();
} catch (e) {
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
}
}
$(document).ready(function(){
loadDiscoveryResources();
loadDiscoveryPlugins();
});
</script>
+6 -9
View File
@@ -21,11 +21,6 @@
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="all">All Resources</option>
<option value="unmanaged" selected>Unmanaged Only</option>
<option value="managed">Managed Only</option>
</select>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
@@ -132,10 +127,12 @@
// Name search
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Managed filter
// Always hide items that have been committed to the catalog (managed)
const isManaged = !!(r.metadata && r.metadata.managed);
if(managedFilter === 'managed' && !isManaged) return false;
if(managedFilter === 'unmanaged' && isManaged) return false;
if(isManaged) return false;
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
if(!isAuto) return false;
return true;
});
@@ -161,7 +158,7 @@
return;
}
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
renderTable();
loadResources();
});
}
+20 -2
View File
@@ -57,6 +57,7 @@
<button class="btn btn-sm btn-warning" title="Edit Secrets" onclick="openSecretsModal('{{id}}')"><i class="fa-solid fa-key"></i></button>
<button class="btn btn-sm btn-info" title="Test" onclick="testPlugin('{{id}}')"><i class="fa-solid fa-vial"></i></button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runNow('{{id}}')"><i class="fa-solid fa-play"></i></button>
{{#lastRunAt}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastRunAt}}
{{#enabled}}<button class="btn btn-sm btn-outline-danger" title="Unload" onclick="togglePlugin('{{id}}', false)">Unload</button>{{/enabled}}
{{^enabled}}<button class="btn btn-sm btn-outline-success" title="Load" onclick="togglePlugin('{{id}}', true)">Load</button>{{/enabled}}
<button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
@@ -140,6 +141,7 @@
schema.forEach(function(f) {
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' : '';
@@ -279,7 +281,7 @@
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
footer: {
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit(\'' + id + '\')', saveLabel: 'Save' })
}
});
}
@@ -323,7 +325,7 @@
app.modal.open({
title: 'Edit Secrets — ' + p.name,
bodyHtml: html,
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets("' + id + '")', saveLabel: 'Save Secrets' }) }
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets(\'' + id + '\')', saveLabel: 'Save Secrets' }) }
});
}
@@ -368,6 +370,22 @@
}
}
async function showLogs(id) {
var p = pluginsById[id];
if (!p) return;
try {
const res = await app.api.get('plugins/' + id + '/runs');
const logText = (res.results && res.results.lastLog) || (res.results && res.results.lastError) || 'No logs available.';
app.modal.open({
title: 'Logs — ' + p.name,
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(logText).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>',
footer: { buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' }
});
} catch (err) {
app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger');
}
}
async function togglePlugin(id, enable) {
try {
await app.api.post('plugins/' + id + (enable ? '/load' : '/unload'), {});
+251 -2
View File
@@ -6,6 +6,7 @@
<ul class="nav nav-pills" id="vault-tabs">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-shared" type="button">Shared</button></li>
</ul>
</div>
@@ -83,6 +84,101 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
</div>
</div>
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="tab-shared">
<div class="row">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">My shared secrets</h5>
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
</div>
<div class="list-group list-group-flush" id="shared-mine-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Shared with me</h5></div>
<div class="list-group list-group-flush" id="shared-granted-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Shared Secret Modal -->
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Shared Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name (slug)</label>
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
</div>
<div class="mb-3">
<label class="form-label">Secret Data (JSON)</label>
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
"key": "value"
}</textarea>
</div>
<div class="alert alert-danger d-none" id="shared-create-error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
</div>
</div>
</div>
</div>
<!-- Manage Grants Modal -->
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Share</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
</div>
<div class="alert alert-danger d-none" id="grants-error"></div>
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- View Shared Secret Modal -->
<div class="modal fade" id="sharedViewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="shared-view-title">Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
</div>
</div>
</div>
@@ -134,7 +230,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
// key relative to the subject's namespace (so 'foo' for a user means
// secret/data/users/<uid>/foo).
function vpath(kind, key) {
return `secret/${kind}/${VAULT_BASE}${key}`;
let cleanKey = key || '';
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
if (VAULT_BASE) {
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
}
return `secret/${kind}/${cleanKey}`;
}
function apiCall(method, path, body = null) {
@@ -156,7 +257,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
async function loadSecrets() {
try {
const res = await apiCall('GET', vpath('metadata', '?list=true'));
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
const res = await apiCall('GET', listPath);
const listEl = document.getElementById('secrets-list');
listEl.innerHTML = '';
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
@@ -301,6 +403,152 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
}
// ── Shared secrets tab ──────────────────────────────────────────────
let currentShared = null;
const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal'));
const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal'));
const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal'));
function sharedApi(path, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } };
if (body) opts.body = JSON.stringify(body);
return fetch('/api/shared-secrets' + path, opts).then(async res => {
if (res.status === 404) return null;
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); }
if (res.status === 204) return null;
return res.json();
});
}
async function loadShared() {
try {
const res = await sharedApi('/');
const items = (res && res.items) || [];
renderSharedMine(items.filter(i => i.role === 'owner'));
renderSharedGranted(items.filter(i => i.role === 'grantee'));
} catch (err) {
document.getElementById('shared-mine-list').innerHTML =
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
}
}
function renderSharedMine(items) {
const el = document.getElementById('shared-mine-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
</div>`;
el.appendChild(row);
});
}
function renderSharedGranted(items) {
const el = document.getElementById('shared-granted-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('a');
row.href = '#';
row.className = 'list-group-item list-group-item-action d-flex align-items-center';
row.innerHTML = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
row.onclick = (e) => { e.preventDefault(); viewShared(s); };
el.appendChild(row);
});
}
function showCreateSharedModal() {
currentShared = null;
document.getElementById('shared-slug-input').value = '';
document.getElementById('shared-desc-input').value = '';
document.getElementById('shared-data-input').value = '{\n "key": "value"\n}';
document.getElementById('shared-create-error').classList.add('d-none');
sharedCreateModal.show();
}
async function saveSharedSecret() {
const err = document.getElementById('shared-create-error');
err.classList.add('d-none');
let data;
try { data = JSON.parse(document.getElementById('shared-data-input').value); }
catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; }
try {
await sharedApi('/', 'POST', {
slug: document.getElementById('shared-slug-input').value.trim(),
description: document.getElementById('shared-desc-input').value.trim(),
data
});
sharedCreateModal.hide();
await loadShared();
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function viewShared(s) {
document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')';
document.getElementById('shared-view-content').textContent = 'Loading...';
sharedViewModal.show();
try {
const res = await apiCall('GET', 'secret/data/' + s.path);
document.getElementById('shared-view-content').textContent =
(res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.';
} catch (e) {
document.getElementById('shared-view-content').textContent = 'Error: ' + e.message;
}
}
async function openGrants(id) {
currentShared = id;
document.getElementById('grants-error').classList.add('d-none');
document.getElementById('grant-id-input').value = '';
sharedGrantsModal.show();
try {
const res = await sharedApi('/' + id + '/grants');
const grants = (res && res.grants) || [];
const el = document.getElementById('grants-list');
el.innerHTML = '';
if (!grants.length) el.innerHTML = '<div class="list-group-item text-muted">No grants yet.</div>';
grants.forEach(g => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
el.appendChild(row);
});
} catch (e) {
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
}
}
async function addGrant() {
const err = document.getElementById('grants-error');
err.classList.add('d-none');
try {
await sharedApi('/' + currentShared + '/grants', 'POST', {
granteeType: document.getElementById('grant-type-input').value,
granteeId: document.getElementById('grant-id-input').value.trim()
});
document.getElementById('grant-id-input').value = '';
openGrants(currentShared);
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function revokeGrant(grantId) {
try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); }
catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); }
}
async function deleteShared(id) {
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
if (!confirmed) return;
try { await sharedApi('/' + id, 'DELETE'); await loadShared(); }
catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); }
}
(async function init() {
const user = await app.auth.forceLogin();
if (!user) return; // not logged in — forceLogin redirected to /login
@@ -314,6 +562,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
}
loadSecrets();
loadShared();
})();
</script>