Compare commits

...

37 Commits

Author SHA1 Message Date
wmantly f9fb80c3b2 docs: update agents.md and index.md for Theta Agent C2 & Protocol v1.1.0
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m28s
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 3s
2026-08-03 02:26:37 -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
wmantly f1d52601de Merge pull request #140 from theta42/feature/v1.17.2-fixes
v1.17.2: post-deploy fixes + SMS/TOS on /conf
2026-08-01 22:51:55 -04:00
wmantly ecd21c4984 feat: v1.17.2 post-deploy fixes + SMS/TOS on /conf
- auto-slug plugins (no more manual slug field)
- plugin schedule dropdown (hourly/daily/weekly + custom)
- fix /vault secrets-list 403 (per-user/app/admin list grants on dir path;
  ensurePolicy always re-writes so existing policies get the grant)
- fix /profile literal {{...}} tags (header uid span, members label id,
  admin-actions moved inside jq-repeat=user scope)
- fix plugin editing (Edit modal non-secret only; secrets have own modal)
- nmap: apk add nmap in Dockerfile.openldap + clearer missing-binary error
- add SMS (VoIP.ms) config card to /conf (password masked, leave-blank-to-keep)
- move Terms of Service editor from Overview to /conf

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 22:47:37 -04:00
wmantly 5ba2ace835 Merge pull request #139 from theta42/feature/conf-secret-masking
v1.17.1: mask SMTP/OAuth secrets + leave-blank-to-keep on /conf
2026-08-01 21:19:05 -04:00
wmantly 25b0d57a97 feat(conf): mask SMTP/OAuth secrets + leave-blank-to-keep on /conf (v1.17.1)
GET /api/conf no longer returns smtp.pass / oauth.jwtSecret in cleartext
(masked to ********). POST treats a blank or ******** secret submission as
"keep the stored value," so editing the From address or token lifetimes no
longer requires re-entering or leaks the SMTP password / JWT secret. The /conf
form fields carry a leave-unchanged hint. Storage stays in OpenBao at
secret/sso-manager/conf (unchanged); no theta-suite policy change needed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 21:15:16 -04:00
48 changed files with 2379 additions and 295 deletions
+33
View File
@@ -1303,6 +1303,39 @@ Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, o
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row. **`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
## Configuration Endpoints
Base path: `/api/conf`
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
### Get Configuration
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
**Response:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"discovery": { },
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
### Save Configuration
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
**Request:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
**Response:** `{ "success": true }`
## Error Responses ## Error Responses
All endpoints return errors in this format: All endpoints return errors in this format:
+106
View File
@@ -1,9 +1,111 @@
## v1.19.0
- Added WebSocket endpoint for theta-agent C2
# v1.18.0
- feat: Add messaging plugins, Docker discovery, fix reconciliation
# Changelog # Changelog
All notable changes to this project are documented here. Format loosely All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.17.2] - 2026-08-01
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
### Fixed
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
modal no longer asks for a Slug (it derived a stable, unique handle from the
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
`views/plugins.ejs`)
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
(nested paths), never on the directory path itself — so listing a directory's
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
already-created `user-<uid>` policies pick up the new grant on the next
vault-page visit. The matching `sso-admin` mount-root grant ships in
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
always-write so re-running `./setup.sh` applies policy edits.
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
buttons. The header/label are now populated by JS (the `Members` label
already had a setter pointing at a missing id); the Admin Actions block is
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
masked secret values and rendered them as fields, but `PUT /:id` only saves
non-secret config — so an edited secret was silently dropped. The Edit modal
now shows **non-secret fields only** (secrets have their own Edit-Secrets
modal), removing the confusion. (`views/plugins.ejs`)
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
binary was not installed in the app image. `Dockerfile.openldap` now `apk
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
the opaque node-nmap spawn-missing error into an actionable `lastError`.
### Added
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
config files. It now has an SMS card on `/conf` (API username, DID, API
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
the API password masked (`********`) and leave-blank-to-keep — mirroring the
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
saved change takes effect live without a restart. (`routes/api_conf.js`,
`views/conf.ejs`)
- **Terms of Service editor moved to `/conf`** from the admin Overview
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
### Notes
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
policies self-heal on the next vault-page visit.
## [1.17.1] - 2026-08-01
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
(unchanged) — only how they're surfaced to the admin changes.
### Changed
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
(was: returned in cleartext). Non-secret fields (host, port, user, from,
secure, issuer, token lifetimes) are returned as before.
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
as "keep the current stored value" — so an admin editing the From address or
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
secret. Only a genuinely new, non-blank value overwrites. The preserved values
are re-applied to live `conf` immediately, as before.
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
copy notes secret fields are masked. No JSON-textarea editing is involved —
SMTP is and remains configured through structured form fields.
### Notes
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
this release (via `POST /api/conf``baoConf.set('sso-manager/conf')`, and
overlaid back at boot by `bao-conf.init`). This release closes the
cleartext-exposure gap; it does not move the storage path.
- No theta-suite policy change required — `secret/sso-manager/conf` was already
granted to the `sso-broker` policy.
## [1.17.0] - 2026-08-01 ## [1.17.0] - 2026-08-01
A real **plugin system**: the half-built discovery plugins (statically A real **plugin system**: the half-built discovery plugins (statically
@@ -542,3 +644,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.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.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.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).
+1 -2
View File
@@ -122,6 +122,7 @@ RUN apk add --no-cache \
dumb-init \ dumb-init \
bash \ bash \
redis \ redis \
nmap \
&& rm -rf /var/cache/apk/* && rm -rf /var/cache/apk/*
COPY --from=ldapbuild /opt/openldap /opt/openldap COPY --from=ldapbuild /opt/openldap /opt/openldap
@@ -181,10 +182,8 @@ COPY tos.md /tos.md
# without internet access. Same flattened-path convention as tos.md above. # without internet access. Same flattened-path convention as tos.md above.
COPY README.md /README.md COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md COPY API.md /API.md
COPY directory_spec.md /directory_spec.md COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js). # Baked commit hash from the gitinfo stage (see build_info.js).
COPY --from=gitinfo /commit.txt ./.build_commit 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 tos.md /tos.md
COPY README.md /README.md COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md COPY API.md /API.md
COPY directory_spec.md /directory_spec.md COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Seed script and utility # Seed script and utility
COPY test_seed.js ./test_seed.js COPY test_seed.js ./test_seed.js
+70 -66
View File
@@ -1,88 +1,92 @@
--- ---
layout: default layout: default
title: Discovery Agents title: Theta Agent & Endpoint Management
nav_order: 5 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 ### 2. Real-Time Telemetry Streaming
// nodejs/agents/discovery/my_custom_agent.js Every 30 seconds, the agent streams real-time performance metrics:
module.exports = { - **CPU Load**: System-wide CPU utilization percentage.
discover: async (config) => { - **Memory Utilization**: RAM usage percentage and available memory.
const { url, apiKey } = config; // Provided by your configuration - **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`).
const resources = []; ---
const edges = [];
// 1. Fetch your data from an API ## Local-First Security & Capability Matrix
// const data = await fetch(...);
// 2. Map data to Resources 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.
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' }
]
}
});
// 3. Map relations to Edges (optional) | Capability | Config Key | Risk Level | Description & Impact |
edges.push({ | :--- | :--- | :--- | :--- |
parentSlug: 'my-switch-01', | **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
childSlug: 'some-connected-client-slug', | **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to' | **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). |
return { resources, edges }; ---
}
}; ## 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`: ```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
```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'
}
}
}
};
``` ```
## 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. 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. - **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/). - **[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 ## 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 @@
--- # Vault Secrets Management
layout: default
title: Secrets Vault
nav_order: 6
---
# 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. 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.
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
### API Example ## API Access
To read secrets from the default key-value store, issue a `GET` request to: If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
`/api/vault/v1/secret/data/sso-manager/conf`
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints. ```bash
# Example: Read a secret via the API
## Namespaces and Paths curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
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.
+5 -1
View File
@@ -43,6 +43,9 @@ app.onListen.push(function(){
// socket.broadcast.emit('P2PSub', msg); // 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, // 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 // 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 // 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. // 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. // Routes for front end content.
app.use('/', require('./routes/index')); app.use('/', require('./routes/index'));
+13
View File
@@ -25,6 +25,19 @@ var server = http.createServer(app);
var io = require('socket.io')(server); var io = require('socket.io')(server);
app.io = io; 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'); const models = require('../models');
/** /**
-8
View File
@@ -57,14 +57,6 @@ module.exports = {
password: '__in secrets file__', password: '__in secrets file__',
did: '__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: { directory: {
// Public SSH jump host fronting the lab, if there is one (the jump-host // 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 // component). When set, a host card in the catalog shows the real
Binary file not shown.
+1
View File
@@ -58,6 +58,7 @@ class PluginInstance extends Model {
lastRunAt: { type: 'integer' }, lastRunAt: { type: 'integer' },
lastStatus: { type: 'string' }, lastStatus: { type: 'string' },
lastError: { type: 'text' }, lastError: { type: 'text' },
lastLog: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook). // Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' }, created_by: { type: 'string' },
created_on: { type: 'integer' }, created_on: { type: 'integer' },
+3 -1
View File
@@ -96,11 +96,13 @@ class Resource extends Model {
return false; return false;
} }
let maxUpdated = 0;
resObjs.forEach(r => { resObjs.forEach(r => {
r.metadata.isProduction = checkProd(r.id); 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, // Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
+15
View File
@@ -10,6 +10,21 @@ function toE164Digits(number) {
} }
async function send(to, message) { 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({ const params = new URLSearchParams({
api_username: conf.username, api_username: conf.username,
api_password: conf.password, api_password: conf.password,
+9 -2
View File
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
]); ]);
} else { } else {
await client.modify(this.dn, [ 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; throw e;
} }
} }
this.pwdAccountLockedTime = active ? undefined : '000001010000Z'; this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
this.isActive = active ? 'active' : ''; this.isActive = active ? 'active' : '';
this.isInactive = active ? '' : 'inactive'; this.isInactive = active ? '' : 'inactive';
cache.clear(); cache.clear();
@@ -907,6 +907,13 @@ User.login = async function(data){
} }
let user = await this.get(data.uid || data.username); 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(); const loginClient = makeClient();
try { try {
await loginClient.bind(user.dn, data.password); await loginClient.bind(user.dn, data.password);
+3 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.16.0", "version": "1.19.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.16.0", "version": "1.19.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
@@ -42,6 +42,7 @@
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"ws": "^8.21.1",
"xss": "^1.0.15" "xss": "^1.0.15"
}, },
"devDependencies": { "devDependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.17.0", "version": "1.19.6",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
@@ -54,6 +54,7 @@
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"ws": "^8.21.1",
"xss": "^1.0.15" "xss": "^1.0.15"
}, },
"license": "MIT", "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();
});
}
};
+28 -6
View File
@@ -31,16 +31,28 @@ module.exports = {
if (!targetRange) throw new Error("Missing targetRange for Nmap"); if (!targetRange) throw new Error("Missing targetRange for Nmap");
return new Promise((resolve, reject) => { 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) { scan.on('complete', function(data) {
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
const resources = []; const resources = [];
const edges = []; const edges = [];
for (const host of data) { for (const host of data) {
if (!host.mac || !host.ip) continue; if (!host.ip) continue;
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`; 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({ resources.push({
kind: 'host', kind: 'host',
@@ -51,7 +63,7 @@ module.exports = {
if (host.openPorts && host.openPorts.length > 0) { if (host.openPorts && host.openPorts.length > 0) {
for (const port of host.openPorts) { 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({ resources.push({
kind: 'service', kind: 'service',
name: `${port.service} on ${port.port}`, name: `${port.service} on ${port.port}`,
@@ -66,7 +78,17 @@ module.exports = {
}); });
scan.on('error', function(error) { scan.on('error', function(error) {
reject(error); // node-nmap's spawn-missing-binary message ("NMAP not found at command
// location: nmap") is opaque to an admin reading lastError. Translate
// it into something actionable. (The Dockerfile installs nmap in the
// app image; this only fires if someone runs outside the container or
// strips the package.)
var msg = (error && error.message) || String(error);
if (/nmap.*not found|command location/i.test(msg)) {
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
} else {
reject(error);
}
}); });
scan.startScan(); scan.startScan();
+8 -2
View File
@@ -35,7 +35,7 @@ module.exports = {
}, },
discover: async (config) => { discover: async (config) => {
const { url, tokenId, tokenSecret } = config; let { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) { if (!url || !tokenId || !tokenSecret) {
throw new Error("Missing Proxmox config"); throw new Error("Missing Proxmox config");
} }
@@ -44,12 +44,18 @@ module.exports = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
}; };
// Ensure URL has no trailing slash
url = url.endsWith('/') ? url.slice(0, -1) : url;
const resources = []; const resources = [];
const edges = []; const edges = [];
// 1. Get Nodes // 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent }); 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; const nodes = (await resNodes.json()).data;
for (const node of nodes) { 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 // Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a // 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. // 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){ app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user); var groups = app.auth.groupCNs(user);
if(!groups.length) return; var isLoggedIn = !!user;
var style = document.getElementById('group-required-rules'); var style = document.getElementById('group-required-rules');
if(!style){ if(!style){
@@ -636,6 +637,19 @@ app.auth.applyGroupVisibility = function(user){
// A group whose CN isn't a usable CSS identifier just gates nothing. // 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(){ $( 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);
};
+143 -9
View File
@@ -12,12 +12,33 @@ router.use(async (req, res, next) => {
} }
}); });
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
// mask-valued submission preserves the stored value so an admin editing an
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
const MASK = '********';
const SECRET_PATHS = [
['smtp', 'pass'],
['oauth', 'jwtSecret'],
['voipms', 'password'],
];
function maskSecrets(obj) {
const out = JSON.parse(JSON.stringify(obj));
for (const [grp, key] of SECRET_PATHS) {
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
}
return out;
}
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const editable = { const editable = maskSecrets({
smtp: conf.smtp || {}, smtp: conf.smtp || {},
discovery: conf.discovery || {}, discovery: conf.discovery || {},
oauth: conf.oauth || {} oauth: conf.oauth || {},
}; voipms: conf.voipms || {}
});
res.json(editable); res.json(editable);
}); });
@@ -38,23 +59,136 @@ function applyToLiveConf(src) {
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const existing = await baoConf.get('sso-manager/conf') || {}; const existing = await baoConf.get('sso-manager/conf') || {};
// Deep merge req.body into existing const incoming = req.body || {};
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) { // Preserve secret fields the admin left blank (or left showing the mask):
existing[key] = { ...(existing[key] || {}), ...req.body[key] }; // drop them from the incoming merge so the stored value survives. Only a
// genuinely new, non-blank, non-mask value overwrites.
for (const [grp, key] of SECRET_PATHS) {
if (incoming[grp] && incoming[grp][key] !== undefined) {
const submitted = incoming[grp][key];
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
}
}
// Deep merge incoming into existing
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 { } else {
existing[key] = req.body[key]; existing[key] = incoming[key];
} }
} }
await baoConf.set('sso-manager/conf', existing); await baoConf.set('sso-manager/conf', existing);
// Reflect the saved values in the live conf immediately (the next boot's // Reflect the saved values in the live conf immediately (the next boot's
// bao-conf.init() would pick them up too, but this keeps running readers // bao-conf.init() would pick them up too, but this keeps running readers
// current without a restart, as the old conf_manager did). // current without a restart, as the old conf_manager did). `existing`
// carries the preserved secret values, so live conf keeps them too.
applyToLiveConf(existing); applyToLiveConf(existing);
res.json({ success: true }); res.json({ success: true });
} catch(err) { } catch(err) {
next(err); 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; 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) => { router.delete('/resources/:id', async (req, res, next) => {
try { try {
const r = await Resource.get(req.params.id); const r = await Resource.get(req.params.id);
+34 -3
View File
@@ -20,6 +20,29 @@ const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../ser
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
// Derive a stable, unique slug from an instance name when the caller didn't
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
// and prefixes `plugin-` if the result would otherwise start with a character
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
function slugify(name) {
let s = String(name || '').toLowerCase().trim();
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!s) s = 'plugin';
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
return s.slice(0, 64);
}
async function makeSlug(name, isTaken) {
const base = slugify(name);
if (!await isTaken(base)) return base;
for (let i = 2; i <= 16; i++) {
const cand = `${base}-${i}`.slice(0, 64);
if (!await isTaken(cand)) return cand;
}
return null; // exhausted
}
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin // Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
// (app_super_admin is always allowed by permission.byGroup). // (app_super_admin is always allowed by permission.byGroup).
router.use(async (req, res, next) => { router.use(async (req, res, next) => {
@@ -82,7 +105,15 @@ router.post('/', async (req, res, next) => {
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' }); if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` }); if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
if (!name) return res.status(400).json({ error: 'name is required' }); if (!name) return res.status(400).json({ error: 'name is required' });
if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' }); // Slug is optional: derive it from the name when absent. When supplied,
// validate it (admins editing via API may still pass one explicitly).
let finalSlug = slug;
if (finalSlug) {
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
} else {
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
}
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' }); if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
// `config` from the client is a flat object of all field values (secret + // `config` from the client is a flat object of all field values (secret +
@@ -100,7 +131,7 @@ router.post('/', async (req, res, next) => {
pluginType, pluginType,
category: manifest.category, category: manifest.category,
name, name,
slug, slug: finalSlug,
enabled, enabled,
cron: cron || '0 * * * *', cron: cron || '0 * * * *',
config, config,
@@ -243,7 +274,7 @@ router.get('/:id/runs', async (req, res, next) => {
try { try {
const inst = await PluginInstance.get(req.params.id); const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' }); 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); } } catch (err) { next(err); }
}); });
+37
View File
@@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => {
res.json(envelope({ res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata }), resources: projectResources(graph.resources, { fullMetadata }),
edges: graph.edges, edges: graph.edges,
updated_on: graph.updated_on
})); }));
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => {
} catch (err) { next(err); } } 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 // POST /api/discovery/sync
// Used by external agents (e.g. ldap-client) to push discovery data. // Used by external agents (e.g. ldap-client) to push discovery data.
router.post('/sync', async (req, res, next) => { router.post('/sync', async (req, res, next) => {
+2 -1
View File
@@ -90,7 +90,8 @@ router.get('/plugins', function(req, res, next) {
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce // 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
// the same server-side. Same header-vs-navigation auth model as /conf and // the same server-side. Same header-vs-navigation auth model as /conf and
// /vault (auth-token is a client-set header, not a cookie). // /vault (auth-token is a client-set header, not a cookie).
res.render('plugins', {...values}); const registry = require('../services/plugin_registry');
res.render('plugins', {...values, pluginTypes: registry.types });
}); });
router.get('/vault', function(req, res) { router.get('/vault', function(req, res) {
+56 -9
View File
@@ -9,17 +9,18 @@ class DiscoveryReconciler {
for (const res of resources) { for (const res of resources) {
if (!res.metadata) res.metadata = {}; if (!res.metadata) res.metadata = {};
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null; let existing = null;
// Attempt matching by MAC if available // Attempt matching by MAC if available (case-insensitive)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) { 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 => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m);
if (macs.length > 0) { if (macs.length > 0) {
const allRes = await Resource.list(); const allRes = await Resource.list();
existing = allRes.find(r => existing = allRes.find(r =>
r.metadata && r.metadata.interfaces && r.metadata && r.metadata.interfaces &&
r.metadata.interfaces.some(i => macs.includes(i.mac)) r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
); );
} }
} }
@@ -65,7 +66,10 @@ class DiscoveryReconciler {
const newIntfs = res.metadata.interfaces; const newIntfs = res.metadata.interfaces;
// Simple union based on mac or ip // Simple union based on mac or ip
for (const ni of newIntfs) { 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 }; if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
else existingIntfs.push(ni); else existingIntfs.push(ni);
} }
@@ -79,16 +83,23 @@ class DiscoveryReconciler {
mergedMeta.last_seen = Date.now(); 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({ await existing.update({
name: res.name || existing.name, name: bestName,
description: res.description || existing.description, description: res.description || existing.description,
metadata: mergedMeta, metadata: mergedMeta,
updated_on: Math.floor(Date.now() / 1000) updated_on: Math.floor(Date.now() / 1000)
}); });
res._actualId = existing.id;
} else { } else {
// Create new // Create new
const sources = [sourceName]; const sources = new Set([sourceName]);
res.metadata.discovery_sources = sources; res.metadata.discovery_sources = [...sources];
res.metadata.last_seen = Date.now(); res.metadata.last_seen = Date.now();
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`; const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
@@ -103,12 +114,48 @@ class DiscoveryReconciler {
}); });
newDevices++; newDevices++;
res._actualId = created.id; // Map original slug to actual ID
WebhookEmitter.emit('discovery.new_device', created.toJSON()); WebhookEmitter.emit('discovery.new_device', created.toJSON());
} }
} }
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere // Now process edges
// or we just trust the plugins to give us explicit parent-child mappings by slug. 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) { if (newDevices > 0) {
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`); 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})`); 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 { try {
const cfg = await pluginSecrets.mergeForRun(instance); 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); const payload = await runFn(cfg);
if (instance.category === 'discovery') { if (instance.category === 'discovery') {
await DiscoveryReconciler.reconcile(instance.slug, payload); 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) { } catch (err) {
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message); 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 = ["list", "read", "delete"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["list", "read", "delete"] }');
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 -5
View File
@@ -38,16 +38,15 @@ module.exports = {
// app-base.js, which reveals .group-required-<cn> for each group the user is // 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). // in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [ nav: [
// Ungated on purpose: the catalog is the one page that exists for // Catalog requires login - it's the end-user view of their accessible resources.
// ordinary users. Before this, every nav item was admin-only and a {href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
// non-admin had no signposted destination at all.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']}, {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: '/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: '/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: '/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: '/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']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
], ],
}; };
+26 -4
View File
@@ -54,11 +54,14 @@ async function bao(method, path, body) {
return res; return res;
} }
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create. // 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.
async function ensurePolicy(name, hcl) { async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`); const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status === 200) return; if (existing.status !== 200 && existing.status !== 404) {
if (existing.status !== 404) {
const t = await existing.text().catch(() => ''); const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`); throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
} }
@@ -80,7 +83,12 @@ async function mintToken(policies) {
function userPolicyHcl(uid) { function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated // uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
// into a policy path, so reject anything but a safe charset. // 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"] } 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"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`; path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
} }
@@ -98,10 +106,21 @@ async function getOrCreateUserToken(uid) {
} }
// ── Admin token (read/write all of secret/) ───────────────────────────────── // ── Admin token (read/write all of secret/) ─────────────────────────────────
function adminPolicyHcl() {
// The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST
// the KV mount root (the top-level dirs); `secret/metadata/*` covers nested
// paths but NOT the root itself, so without it the /vault secrets list 403s.
return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`;
}
async function getOrCreateAdminToken(uid) { async function getOrCreateAdminToken(uid) {
const cacheKey = `vault_token:admin:${uid || 'global'}`; const cacheKey = `vault_token:admin:${uid || 'global'}`;
const cached = await cacheGet(cacheKey); const cached = await cacheGet(cacheKey);
if (cached) return cached; if (cached) return cached;
await ensurePolicy('sso-admin', adminPolicyHcl());
const { token, ttl } = await mintToken(['sso-admin']); const { token, ttl } = await mintToken(['sso-admin']);
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60)); await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
return token; return token;
@@ -109,7 +128,10 @@ async function getOrCreateAdminToken(uid) {
// ── Per-app token (minted ONCE, returned to the caller, never cached) ─────── // ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) { 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"] } 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"] }`; path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
} }
@@ -191,7 +213,7 @@ function vaultProxy() {
return createProxyMiddleware({ return createProxyMiddleware({
target: VAULT_ADDR, target: VAULT_ADDR,
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^/': '/v1/' }, pathRewrite: { '^/api/vault': '/v1' },
on: { on: {
proxyReq(proxyReq, req, res, options) { proxyReq(proxyReq, req, res, options) {
fixRequestBody(proxyReq, req, res, options); fixRequestBody(proxyReq, req, res, options);
+341 -9
View File
@@ -4,6 +4,8 @@
$(document).ready(function() { $(document).ready(function() {
loadConf(); loadConf();
loadProxyConf();
loadTos();
}); });
async function loadConf() { async function loadConf() {
@@ -28,6 +30,13 @@
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000); $('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
} }
} }
// Populate SMS (VoIP.ms)
if (data.voipms) {
$('#voipms-username').val(data.voipms.username || '');
$('#voipms-did').val(data.voipms.did || '');
$('#voipms-password').val(data.voipms.password || '');
}
} catch (error) { } catch (error) {
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger'); app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
} }
@@ -53,6 +62,11 @@
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600, access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000 refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
} }
},
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
} }
}; };
@@ -66,6 +80,81 @@
} }
} }
async function sendTestEmail() {
const to = $('#test-email-to').val().trim();
if (!to) {
app.messages.toast('Please enter a recipient email address', 'warning');
return;
}
const $inputGroup = $('#test-email-to').closest('.input-group');
const btn = $inputGroup.find('button');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try {
// First save the SMTP config, then send test email
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')
}
};
// Save config first
await app.api.post('conf', payload);
// Then send test email
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 $inputGroup = $('#test-sms-to').closest('.input-group');
const btn = $inputGroup.find('button');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try {
// First save the VoIP.ms config, then send test SMS
const payload = {
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
// Save config first
await app.api.post('conf', payload);
// Then send test SMS
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) { function togglePassword(id) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el.type === 'password') { if (el.type === 'password') {
@@ -74,6 +163,91 @@
el.type = 'password'; el.type = 'password';
} }
} }
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"></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"></i> Save Proxy Secrets');
}
}
// ── 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 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;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
</script> </script>
<div class="container py-4"> <div class="container py-4">
@@ -82,8 +256,10 @@
<div> <div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2> <h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0"> <p class="text-muted mb-0">
Manage runtime configuration such as SMTP settings and OAuth parameters. Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
These secrets are stored securely in OpenBao Vault. 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> </p>
</div> </div>
<div> <div>
@@ -93,9 +269,28 @@
</div> </div>
</div> </div>
<div class="row"> <ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
<div class="col-md-6 mb-4"> <li class="nav-item" role="presentation">
<div class="card shadow-sm border-0 h-100"> <button class="nav-link active" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#smtp" type="button" role="tab">SMTP Settings</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button>
</li>
</ul>
<div class="tab-content" id="confTabsContent">
<!-- SMTP Tab -->
<div class="tab-pane fade show active" id="smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <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> <h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
</div> </div>
@@ -115,9 +310,32 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">Password</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="smtp-pass"> <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> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</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>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">From Address</label> <label class="form-label">From Address</label>
@@ -127,12 +345,24 @@
<input class="form-check-input" type="checkbox" id="smtp-secure"> <input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label> <label class="form-check-label">Use Secure (TLS)</label>
</div> </div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test Email</label>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane"></i> Send Test Email
</button>
</div>
<div class="form-text">Send a test email to verify your SMTP configuration is working.</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 mb-4"> <!-- OAuth Tab -->
<div class="card shadow-sm border-0 h-100"> <div class="tab-pane fade" id="oauth" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <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> <h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div> </div>
@@ -144,9 +374,10 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">JWT Secret</label> <label class="form-label">JWT Secret</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret"> <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> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</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>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Access Token Lifetime (seconds)</label> <label class="form-label">Access Token Lifetime (seconds)</label>
@@ -159,6 +390,107 @@
</div> </div>
</div> </div>
</div> </div>
<!-- SMS Tab -->
<div class="tab-pane fade" id="sms" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<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>
</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>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div>
</div>
</div>
<!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="proxy" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5>
</div>
<div class="card-body">
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p>
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6>
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div>
<div class="mb-3">
<label class="form-label">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id">
</div>
<div class="mb-3">
<label class="form-label">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>
<h6 class="mt-4 mb-2">LDAP Integration</h6>
<div class="mb-3">
<label class="form-label">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 class="form-text">Password for the Proxy's LDAP service account.</div>
</div>
<button id="btn-save-proxy" class="btn btn-warning mt-2" onclick="saveProxyConf()"><i class="fas fa-save"></i> Save Proxy Secrets</button>
</div>
</div>
</div>
<!-- ToS Tab -->
<div class="tab-pane fade" id="tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<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>
</div> </div>
</div> </div>
+229
View File
@@ -40,6 +40,9 @@
<datalist id="access-uid-list"></datalist> <datalist id="access-uid-list"></datalist>
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button> <button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
</div> </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()"> <button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Resource <i class="fas fa-plus"></i> Add Resource
</button> </button>
@@ -1272,6 +1275,232 @@
}); });
} }
// --- 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: '<i class="fa-solid fa-shield-halved text-primary me-2"></i> Install Theta Agent',
bodyHtml: bodyHtml,
size: 'lg'
});
updateAgentCommands();
}
// Plugin scheduling moved to the dedicated /plugins page (the Agents & // Plugin scheduling moved to the dedicated /plugins page (the Agents &
// Scheduler tab here was its old home). Discovery inventory + the discovery // Scheduler tab here was its old home). Discovery inventory + the discovery
// results table remain on this page. // results table remain on this page.
+6 -9
View File
@@ -21,11 +21,6 @@
</div> </div>
<div class="d-flex flex-wrap gap-2 align-items-center"> <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;"> <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> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
@@ -132,10 +127,12 @@
// Name search // Name search
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false; 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); const isManaged = !!(r.metadata && r.metadata.managed);
if(managedFilter === 'managed' && !isManaged) return false; if(isManaged) return false;
if(managedFilter === 'unmanaged' && 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; return true;
}); });
@@ -161,7 +158,7 @@
return; 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(); $('.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();
}); });
} }
-64
View File
@@ -162,50 +162,10 @@
} }
} }
// ── Terms of Service ──────────────────────────────────────────────────
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;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() { $(document).ready(function() {
loadDashboard(); loadDashboard();
loadHistory(); loadHistory();
toggleFilterInputs(); toggleFilterInputs();
loadTos();
loadMetrics(); loadMetrics();
}); });
</script> </script>
@@ -385,30 +345,6 @@
</div> </div>
</div> </div>
<!-- TOS Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
<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 shadow-sm" id="tos-content" rows="12"></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 shadow-sm" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
<!-- Actionable Metrics Card --> <!-- Actionable Metrics Card -->
<div class="card shadow mb-5"> <div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
+83 -15
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-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-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> <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-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}} {{^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> <button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
@@ -128,13 +129,19 @@
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces // Build an HTML form fragment for a type's configSchema. `prefix` namespaces
// the field ids so the New and Edit modals don't collide. `values` (optional) // the field ids so the New and Edit modals don't collide. `values` (optional)
// pre-fills fields (masked secrets stay masked; non-secret values are shown). // pre-fills fields (masked secrets stay masked; non-secret values are shown).
function configFormHtml(type, prefix, values) { // `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
// secret fields are never shown there (secrets have their own modal); the New
// modal passes true so initial secrets can be set at create time.
function configFormHtml(type, prefix, values, includeSecrets) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema; var schema = pluginTypes[type] && pluginTypes[type].configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>'; if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
if (includeSecrets === undefined) includeSecrets = true;
var v = values || {}; var v = values || {};
var html = ''; var html = '';
schema.forEach(function(f) { schema.forEach(function(f) {
if (!includeSecrets && f.secret) return;
var val = v[f.key]; var val = v[f.key];
if (f.secret) val = '';
if (val === undefined || val === null) val = ''; if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text'); var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : ''; var req = f.required ? ' required' : '';
@@ -149,6 +156,53 @@
return html; return html;
} }
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
// The stored value is always a 5-field cron string. A `<select>` picks a
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
// element ids (np-/ed-) so the two modals don't collide.
var CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function cronKeyFor(cron) {
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function cronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = cronKeyFor(current);
var opts = CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
var rawVal = key === 'custom' ? current : current;
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
}
function onCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') {
raw.style.display = '';
} else {
raw.style.display = 'none';
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function cronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
// Collect a flat {field: value} object from the rendered config form. // Collect a flat {field: value} object from the rendered config form.
function collectConfig(type, prefix) { function collectConfig(type, prefix) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema; var schema = pluginTypes[type] && pluginTypes[type].configSchema;
@@ -179,10 +233,9 @@
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' + '<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' + '<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' + '<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
'<div class="mb-3"><label class="form-label">Slug <span class="text-danger">*</span></label>' + '<div class="mb-3"><label class="form-label">Schedule</label>' +
'<input type="text" class="form-control font-monospace" id="np-slug" placeholder="proxmox-homelab"></div>' + cronSelectHtml('np-', '0 * * * *') +
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' + '<div class="form-text">A slug is derived automatically from the name.</div></div>' +
'<input type="text" class="form-control font-monospace" id="np-cron" value="0 * * * *"></div>' +
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>', '<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) } footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
}); });
@@ -197,13 +250,11 @@
var type = document.getElementById('np-type').value; var type = document.getElementById('np-type').value;
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger'); if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
var name = document.getElementById('np-name').value.trim(); var name = document.getElementById('np-name').value.trim();
var slug = document.getElementById('np-slug').value.trim(); var cron = cronFromForm('np-');
var cron = document.getElementById('np-cron').value.trim() || '0 * * * *';
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger'); if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return app.messages.action('Slug must be lowercase letters/digits/_/- (max 64).', app.modal.body(), 'danger');
var config = collectConfig(type, 'np-'); var config = collectConfig(type, 'np-');
try { try {
await app.api.post('plugins', { pluginType: type, name: name, slug: slug, cron: cron, config: config }); await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
app.modal.close(); app.modal.close();
app.messages.toast('Plugin created and scheduled.', 'success'); app.messages.toast('Plugin created and scheduled.', 'success');
loadPlugins(); loadPlugins();
@@ -224,12 +275,13 @@
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '&quot;') + '"></div>' + '<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '&quot;') + '"></div>' +
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' + '<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' + '<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' + '<div class="mb-3"><label class="form-label">Schedule</label>' +
'<input type="text" class="form-control font-monospace" id="ed-cron" value="' + (p.cron || '0 * * * *') + '"></div>' + cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', Object.assign({}, p.config, p.secrets)) + '</div>', '<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
footer: { footer: {
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '', 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' })
} }
}); });
} }
@@ -238,7 +290,7 @@
var p = pluginsById[id]; var p = pluginsById[id];
if (!p) return; if (!p) return;
var name = document.getElementById('ed-name').value.trim(); var name = document.getElementById('ed-name').value.trim();
var cron = document.getElementById('ed-cron').value.trim() || '0 * * * *'; var cron = cronFromForm('ed-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger'); if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(p.pluginType, 'ed-'); var config = collectConfig(p.pluginType, 'ed-');
try { try {
@@ -273,7 +325,7 @@
app.modal.open({ app.modal.open({
title: 'Edit Secrets — ' + p.name, title: 'Edit Secrets — ' + p.name,
bodyHtml: html, bodyHtml: html,
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets("' + id + '")', saveLabel: 'Save Secrets' }) } footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets(\'' + id + '\')', saveLabel: 'Save Secrets' }) }
}); });
} }
@@ -318,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) { async function togglePlugin(id, enable) {
try { try {
await app.api.post('plugins/' + id + (enable ? '/load' : '/unload'), {}); await app.api.post('plugins/' + id + (enable ? '/load' : '/unload'), {});
+23 -22
View File
@@ -9,6 +9,7 @@
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid); user.managerUids = (user.manager || []).map(app.user.dnToUid);
$('#profile-uid-header').text(user.uid);
$.scope.user.update(user); $.scope.user.update(user);
}; };
@@ -241,7 +242,7 @@
<div class="card-header shadow d-flex justify-content-between align-items-center"> <div class="card-header shadow d-flex justify-content-between align-items-center">
<div> <div>
<i class="fa-regular fa-id-card"></i> <i class="fa-regular fa-id-card"></i>
Profile: <strong>{{user.uid}}</strong> Profile: <strong id="profile-uid-header"></strong>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm"> <button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
@@ -278,7 +279,7 @@
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab"> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group <i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
</button> </button>
</li> </li>
</ul> </ul>
@@ -329,27 +330,27 @@
<p class="text-muted small mb-0"> <p class="text-muted small mb-0">
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b> <i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
</p> </p>
</div>
<div class="mt-3 border-top pt-3"> <div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6> <h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin"> <div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}} {{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)"> <button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate <i class="fa-solid fa-lock"></i> Deactivate
</button> </button>
{{/isActive}} {{/isActive}}
{{#isInactive}} {{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)"> <button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate <i class="fa-solid fa-lock-open"></i> Activate
</button> </button>
{{/isInactive}} {{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')"> <button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate <i class="fa-solid fa-user-secret"></i> Impersonate
</button> </button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)"> <button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User <i class="fa-solid fa-user-slash"></i> Delete User
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>