Merge pull request #171 from theta42/feat/agent-join-keys-directory-fixes
feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
This commit is contained in:
@@ -1,3 +1,31 @@
|
|||||||
|
# v1.30.0
|
||||||
|
|
||||||
|
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
|
||||||
|
|
||||||
|
### theta-agent — enrollment without pre-registering
|
||||||
|
|
||||||
|
- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not.
|
||||||
|
- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
|
||||||
|
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited.
|
||||||
|
|
||||||
|
### Directory
|
||||||
|
|
||||||
|
- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `<i class="fa-…">` into an `<svg>`, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace.
|
||||||
|
- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`.
|
||||||
|
- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it.
|
||||||
|
- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret.
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion.
|
||||||
|
- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name.
|
||||||
|
- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection.
|
||||||
|
- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app.
|
||||||
|
|
||||||
# v1.29.0
|
# v1.29.0
|
||||||
|
|
||||||
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
|
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
|
||||||
|
|||||||
+53
-8
@@ -10,20 +10,59 @@ The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) e
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Enrollment (required)
|
## Enrollment
|
||||||
|
|
||||||
An agent is only real if the SSO issued its token. **Tokens the server did not
|
An agent is only real if the SSO issued its credential. **Tokens the server did
|
||||||
issue are rejected** at the WebSocket handshake.
|
not issue are rejected** at the WebSocket handshake.
|
||||||
|
|
||||||
Enroll from **Directory → Install Agent**:
|
There are two ways to get a host enrolled, and the first is the normal one.
|
||||||
|
|
||||||
1. Give the agent a name and, ideally, **bind it to a host resource**. The
|
### Join key — install the agent and the host appears
|
||||||
binding is what links telemetry, status and commands to a Directory entry.
|
|
||||||
|
Hand the machine a **join key** and nothing else. On first connect the SSO
|
||||||
|
enrolls the host, issues it its own per-agent token plus the public key it must
|
||||||
|
pin, and the agent **writes both into its own `agent.yml`** and blanks the join
|
||||||
|
key. From then on it authenticates as itself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||||
|
--url "https://<SSO_HOST>" --join-key "tjk_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole procedure — no pre-registering the machine, no copying a
|
||||||
|
public key by hand. `setup.sh` mints a key and configures the stack's own host
|
||||||
|
this way automatically.
|
||||||
|
|
||||||
|
The join key is a *bootstrap* credential, not the host's identity. That
|
||||||
|
distinction is what keeps one key convenient without making it a fleet-wide
|
||||||
|
skeleton key: every host still ends up individually revocable, and a compromised
|
||||||
|
host does not yield a credential that works anywhere else.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
|
| `POST /api/agent/join-keys` | Mint one — returned **once** |
|
||||||
|
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
|
||||||
|
| `DELETE /api/agent/join-keys/:id` | Remove it |
|
||||||
|
|
||||||
|
Revoking a join key does **not** disconnect hosts that already joined; they hold
|
||||||
|
their own tokens by then. Revoke the agent itself to cut a specific host off.
|
||||||
|
|
||||||
|
### Pre-registering a host
|
||||||
|
|
||||||
|
When you want the agent bound to a specific Directory host up front, enroll it
|
||||||
|
from **Directory → Install Agent**:
|
||||||
|
|
||||||
|
1. Give the agent a name and **bind it to a host resource**. The binding is what
|
||||||
|
links telemetry, status and commands to a Directory entry.
|
||||||
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
|
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
|
||||||
SHA-256, and shows the raw value **once**.
|
SHA-256, and shows the raw value **once**.
|
||||||
3. Copy the generated install command — it already carries the token and the
|
3. Copy the generated install command — it already carries the token and the
|
||||||
server's public key.
|
server's public key.
|
||||||
|
|
||||||
|
A host that self-enrolls with a join key arrives unbound; bind it afterwards with
|
||||||
|
`PUT /api/agent/nodes/:id` or from the Directory.
|
||||||
|
|
||||||
Or via the API:
|
Or via the API:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -186,8 +225,12 @@ curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE
|
|||||||
```yaml
|
```yaml
|
||||||
# /etc/theta42/agent.yml
|
# /etc/theta42/agent.yml
|
||||||
server_url: "wss://sso.example.com"
|
server_url: "wss://sso.example.com"
|
||||||
# Issued by the SSO at enrollment. A token the server did not issue is rejected.
|
# Issued by the SSO. Left empty when installing with a join key -- the agent
|
||||||
|
# fills it in itself once the server enrolls it.
|
||||||
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
|
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
|
||||||
|
# Bootstrap credential. Used only while auth_token is empty, and blanked by the
|
||||||
|
# agent once it has its own token.
|
||||||
|
join_key: ""
|
||||||
location: "dc-01-rack-12"
|
location: "dc-01-rack-12"
|
||||||
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
|
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
|
||||||
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
|
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
|
||||||
@@ -224,7 +267,9 @@ the SSO only floods its audit log.
|
|||||||
|
|
||||||
An agent installed before protocol v1.2.0 carries a token generated in the
|
An agent installed before protocol v1.2.0 carries a token generated in the
|
||||||
browser that the server never recorded, so it will be rejected with `4001` until
|
browser that the server never recorded, so it will be rejected with `4001` until
|
||||||
re-enrolled.
|
re-enrolled. The quickest fix is to put a **join key** in its `agent.yml` as
|
||||||
|
`join_key` and blank `auth_token` — it will re-enroll itself on the next
|
||||||
|
reconnect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Discovery & Inventory
|
||||||
|
nav_order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery & Inventory
|
||||||
|
|
||||||
|
The Directory holds two different kinds of thing, and the distinction matters
|
||||||
|
for every consumer of the directory:
|
||||||
|
|
||||||
|
- **Catalog resources** — what you have declared. Created by hand, seeded by
|
||||||
|
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
|
||||||
|
groups, appear in the Catalog, and are the only hosts the
|
||||||
|
[jump host](https://github.com/theta42/jump-host) will connect you to.
|
||||||
|
- **Discovered resources** — what the network reports. Produced by
|
||||||
|
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
|
||||||
|
tab. They are a queue of "this exists, do you want to manage it?", not
|
||||||
|
infrastructure you have committed to.
|
||||||
|
|
||||||
|
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
|
||||||
|
and it has never been promoted. Promoting sets `metadata.managed = true`, at
|
||||||
|
which point it becomes catalog content like any other resource.
|
||||||
|
|
||||||
|
> Nothing grants access to a discovered resource. It carries no groups until it
|
||||||
|
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
|
||||||
|
> guest is not a jump target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where discovered data comes from
|
||||||
|
|
||||||
|
| Source | What it reports |
|
||||||
|
| :--- | :--- |
|
||||||
|
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
|
||||||
|
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
|
||||||
|
| [nmap](plugins.html) | Hosts and open ports on a target range |
|
||||||
|
| [Docker](plugins.html) | Containers on a local or remote daemon |
|
||||||
|
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
|
||||||
|
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
|
||||||
|
|
||||||
|
An agent is the most authoritative of these: it runs *on* the machine it
|
||||||
|
describes. A network scan is the least — it only knows what answered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How results are matched to existing resources
|
||||||
|
|
||||||
|
Every source runs through one reconciler, so two sources seeing the same
|
||||||
|
machine converge on one resource instead of creating duplicates. Matching is
|
||||||
|
tried in order of precision:
|
||||||
|
|
||||||
|
1. **MAC address** — the strongest signal, compared across every interface.
|
||||||
|
2. **IP address** — any address on any interface, plus `metadata.address`.
|
||||||
|
3. **Slug, name, or base hostname** — last resort.
|
||||||
|
|
||||||
|
A candidate must also be **the same kind**. Without that guard a discovered VM
|
||||||
|
named `gitea-runner` would match a hand-created *service* of the same name on
|
||||||
|
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
|
||||||
|
template is the same machine.)
|
||||||
|
|
||||||
|
When a match is found the metadata is merged, interfaces are unioned by MAC, and
|
||||||
|
the source is added to `discovery_sources` — so a resource can legitimately read
|
||||||
|
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
Sources disagree about names, so the most human one wins: a **hostname** beats
|
||||||
|
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
|
||||||
|
tie-break within a rank. This is why a device UniFi knows only as
|
||||||
|
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
Plugins emit edges as well as resources (a Proxmox node under its cluster
|
||||||
|
endpoint, a guest under its node). The reconciler refuses any edge that would
|
||||||
|
make a resource its own parent, or that would close a loop — a cycle renders as
|
||||||
|
an infinitely nested tree and breaks every ancestor walk in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promoting a discovered resource
|
||||||
|
|
||||||
|
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
|
||||||
|
pre-filled with what was discovered — name, kind, address, subtype — so you can
|
||||||
|
correct it before committing. Saving marks it managed and provisions its
|
||||||
|
[LDAP groups](groups.html).
|
||||||
|
|
||||||
|
Each row shows what the directory knows about the device: its source(s), its
|
||||||
|
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
|
||||||
|
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
|
||||||
|
looks wrong, that detail is where to start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stale results
|
||||||
|
|
||||||
|
Resources that are *only* auto-discovered are garbage-collected: if a source
|
||||||
|
stops reporting one for long enough it is marked
|
||||||
|
`lifecycle_state: "archived"` rather than deleted. Anything you created or
|
||||||
|
promoted is never touched — `manual` in `discovery_sources` exempts it.
|
||||||
|
|
||||||
|
A Proxmox node that is powered off is still reported (with its `status`), so
|
||||||
|
downtime does not look like decommissioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the stack discovers about itself
|
||||||
|
|
||||||
|
`setup.sh` seeds its own components as catalog resources — the site, the stack
|
||||||
|
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
|
||||||
|
discovery plugin then finds the containers backing them. Containers belonging to
|
||||||
|
the theta-suite compose project are recognised and attached to the service they
|
||||||
|
implement rather than appearing as unmanaged strangers, so a fresh install has an
|
||||||
|
empty Discovered Inventory rather than five things demanding attention.
|
||||||
+72
-1
@@ -108,4 +108,75 @@ class Agent extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { Agent };
|
// A join key: the one credential an operator hands out so a host can enroll
|
||||||
|
// itself. Requiring an admin to pre-register every machine before the agent
|
||||||
|
// would talk to them made adding a host a two-system chore -- installing the
|
||||||
|
// agent should be enough.
|
||||||
|
//
|
||||||
|
// A join key is NOT the agent's long-term credential. On first connect the
|
||||||
|
// server auto-enrolls the host and issues it a unique per-agent token, which
|
||||||
|
// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the
|
||||||
|
// operator experience to "one key" while still giving every host its own
|
||||||
|
// revocable identity -- revoking a single agent means something, and a host
|
||||||
|
// that is compromised does not hand over the credential for the whole fleet.
|
||||||
|
class AgentJoinKey extends Model {
|
||||||
|
static hashKey(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateKey() {
|
||||||
|
// `tjk_` so an operator can tell a join key from an agent token at a
|
||||||
|
// glance -- they are handled very differently.
|
||||||
|
return 'tjk_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented key to a usable join key, or null. Expiry and
|
||||||
|
// revocation are both enforced here so no caller can forget one.
|
||||||
|
static async authenticate(rawKey) {
|
||||||
|
if (!rawKey || typeof rawKey !== 'string') return null;
|
||||||
|
const keyHash = this.hashKey(rawKey);
|
||||||
|
const matches = await this.list({ where: { keyHash } });
|
||||||
|
const key = matches && matches[0];
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.revoked) return null;
|
||||||
|
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issue({ label, createdBy, expiresInDays }) {
|
||||||
|
const raw = this.generateKey();
|
||||||
|
const key = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: label || 'default',
|
||||||
|
keyHash: this.hashKey(raw),
|
||||||
|
keyPrefix: raw.slice(0, 12),
|
||||||
|
revoked: false,
|
||||||
|
created_by: createdBy || null,
|
||||||
|
created_on: Math.floor(Date.now() / 1000),
|
||||||
|
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
|
||||||
|
use_count: 0
|
||||||
|
});
|
||||||
|
return { key, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
label: { type: 'string', isRequired: true },
|
||||||
|
keyHash: { type: 'string', isRequired: true },
|
||||||
|
keyPrefix: { type: 'string' },
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
expires_on: { type: 'integer' },
|
||||||
|
use_count: { type: 'integer', default: 0 },
|
||||||
|
last_used_on: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.keyHash;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Agent, AgentJoinKey };
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const { PluginInstance } = require('./plugin_instance');
|
|||||||
const { SharedSecret } = require('./shared_secret');
|
const { SharedSecret } = require('./shared_secret');
|
||||||
const { SharedSecretGrant } = require('./shared_secret_grant');
|
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||||
const { VaultAppToken } = require('./vault_app_token');
|
const { VaultAppToken } = require('./vault_app_token');
|
||||||
const { Agent } = require('./agent');
|
const { Agent, AgentJoinKey } = require('./agent');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -35,7 +35,7 @@ async function initORM() {
|
|||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
SharedSecret, SharedSecretGrant, VaultAppToken, Agent,
|
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.29.0",
|
"version": "1.30.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.29.0",
|
"version": "1.30.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.29.0",
|
"version": "1.30.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ module.exports = {
|
|||||||
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
|
{ 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: '' }
|
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
|
||||||
|
// Containers in this compose project are the stack's own. They are already
|
||||||
|
// represented in the catalog as services, so they are recorded as managed
|
||||||
|
// and linked to the service they implement instead of arriving as
|
||||||
|
// unmanaged strangers a fresh install has to triage.
|
||||||
|
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
|
||||||
|
// The catalog host these containers run on, so they land in the tree
|
||||||
|
// instead of as roots.
|
||||||
|
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
|
||||||
],
|
],
|
||||||
|
|
||||||
validate: async (config) => {
|
validate: async (config) => {
|
||||||
@@ -48,23 +56,57 @@ module.exports = {
|
|||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
|
|
||||||
|
const stackProject = (config.stackProject || '').trim();
|
||||||
|
const hostSlug = (config.hostSlug || '').trim();
|
||||||
|
|
||||||
for (const c of containers) {
|
for (const c of containers) {
|
||||||
|
const labels = c.Labels || {};
|
||||||
|
const composeProject = labels['com.docker.compose.project'] || '';
|
||||||
|
const composeService = labels['com.docker.compose.service'] || '';
|
||||||
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
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)}`;
|
|
||||||
|
// A container id changes every time the container is recreated,
|
||||||
|
// so an id-derived slug made `docker compose up` mint a brand-new
|
||||||
|
// resource on every deploy and orphan the previous one. Prefer
|
||||||
|
// identifiers that survive a recreate: the compose project+service
|
||||||
|
// it belongs to, else its name.
|
||||||
|
const stableKey = composeProject && composeService
|
||||||
|
? `${composeProject}-${composeService}`
|
||||||
|
: (name || c.Id.substring(0, 12));
|
||||||
|
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
|
||||||
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||||
|
const isOwnStack = !!(stackProject && composeProject === stackProject);
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: 'container',
|
kind: 'container',
|
||||||
name: name,
|
name: composeService || name,
|
||||||
slug: slug,
|
slug: slug,
|
||||||
metadata: {
|
metadata: {
|
||||||
image: c.Image,
|
image: c.Image,
|
||||||
state: c.State,
|
state: c.State,
|
||||||
status: c.Status,
|
status: c.Status,
|
||||||
ports: ports
|
ports: ports,
|
||||||
|
composeProject: composeProject || undefined,
|
||||||
|
composeService: composeService || undefined,
|
||||||
|
containerName: name,
|
||||||
|
sourceId: stableKey,
|
||||||
|
// Part of the deployment we are running inside: already
|
||||||
|
// accounted for, not something to promote.
|
||||||
|
managed: isOwnStack ? true : undefined
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach the container to the service it implements when the
|
||||||
|
// catalog already has one under that slug (the bootstrap seeds
|
||||||
|
// `sso-manager`, `proxy`, `jump-host`, … using the same names
|
||||||
|
// compose uses). The reconciler drops an edge whose parent does
|
||||||
|
// not resolve, so an unmatched name is simply not linked.
|
||||||
|
if (isOwnStack && composeService) {
|
||||||
|
edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' });
|
||||||
|
} else if (hostSlug) {
|
||||||
|
edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({ resources, edges });
|
resolve({ resources, edges });
|
||||||
|
|||||||
+95
-10
@@ -5,7 +5,7 @@ const middleware = require('../middleware/auth');
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const agentManager = require('../utils/agent_manager');
|
const agentManager = require('../utils/agent_manager');
|
||||||
const agentKeys = require('../utils/agent_keys');
|
const agentKeys = require('../utils/agent_keys');
|
||||||
const { Agent } = require('../models/agent');
|
const { Agent, AgentJoinKey } = require('../models/agent');
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
@@ -172,6 +172,54 @@ router.delete('/nodes/:id', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Join keys ---
|
||||||
|
// One key an operator hands out; hosts that present it enroll themselves and
|
||||||
|
// are immediately issued their own per-agent token. Listing never returns the
|
||||||
|
// key itself -- only its prefix and usage.
|
||||||
|
router.get('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = await AgentJoinKey.list();
|
||||||
|
res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { label, expiresInDays } = req.body || {};
|
||||||
|
const { key, raw } = await AgentJoinKey.issue({
|
||||||
|
label: (label && String(label).trim()) || 'default',
|
||||||
|
createdBy: req.user.uid,
|
||||||
|
expiresInDays: expiresInDays ? Number(expiresInDays) : null
|
||||||
|
});
|
||||||
|
logAgentAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Shown once; only the hash is stored.
|
||||||
|
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys/:id/revoke', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.update({ revoked: true });
|
||||||
|
logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Agents already enrolled keep working -- they hold their own tokens now,
|
||||||
|
// which is the whole point of exchanging the join key rather than using it
|
||||||
|
// as the long-term credential.
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/join-keys/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.delete();
|
||||||
|
logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// --- Commands ---
|
// --- Commands ---
|
||||||
// Addressed by agent id, not by token: a token is a credential and has no
|
// Addressed by agent id, not by token: a token is a credential and has no
|
||||||
// business travelling in a URL, being logged, or sitting in browser history.
|
// business travelling in a URL, being logged, or sitting in browser history.
|
||||||
@@ -227,8 +275,37 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
|||||||
// the peer is an anonymous stranger, and the old code treated it as a
|
// the peer is an anonymous stranger, and the old code treated it as a
|
||||||
// trusted node purely for presenting a non-empty string.
|
// trusted node purely for presenting a non-empty string.
|
||||||
let agent = null;
|
let agent = null;
|
||||||
|
let issuedToken = null; // set when this connection auto-enrolled
|
||||||
try {
|
try {
|
||||||
agent = await Agent.authenticate(token);
|
agent = await Agent.authenticate(token);
|
||||||
|
|
||||||
|
// Not a known agent token -- try it as a join key. This is what makes
|
||||||
|
// "install the agent with a key and the host appears" work without an
|
||||||
|
// admin pre-registering every machine. The join key is exchanged for a
|
||||||
|
// per-agent token below, so it never becomes the host's long-term
|
||||||
|
// credential.
|
||||||
|
if (!agent) {
|
||||||
|
const joinKey = await AgentJoinKey.authenticate(token);
|
||||||
|
if (joinKey) {
|
||||||
|
const hostname = (url.searchParams.get('hostname') || '').trim();
|
||||||
|
const enrolled = await Agent.enroll({
|
||||||
|
name: hostname || `agent-${Date.now().toString(36)}`,
|
||||||
|
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
|
||||||
|
enrolledBy: `join-key:${joinKey.label}`
|
||||||
|
});
|
||||||
|
agent = enrolled.agent;
|
||||||
|
issuedToken = enrolled.token;
|
||||||
|
await joinKey.update({
|
||||||
|
use_count: (joinKey.use_count || 0) + 1,
|
||||||
|
last_used_on: Math.floor(Date.now() / 1000)
|
||||||
|
}).catch(() => {});
|
||||||
|
logAgentAudit('join', {
|
||||||
|
agentId: agent.id, agentName: agent.name, remoteAddr,
|
||||||
|
joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix
|
||||||
|
});
|
||||||
|
console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Theta Agent] authentication lookup failed:', err.message);
|
console.error('[Theta Agent] authentication lookup failed:', err.message);
|
||||||
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
|
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
|
||||||
@@ -294,16 +371,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
|||||||
agentManager.unregisterAgent(agent.id, ws);
|
agentManager.unregisterAgent(agent.id, ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send initial welcome/config payload
|
// Send initial welcome/config payload. When this connection enrolled via a
|
||||||
|
// join key it also carries the credentials the agent should persist and use
|
||||||
|
// from now on: its own token, and the public key it must pin to verify
|
||||||
|
// signed commands. Handing the public key over here is what removes the
|
||||||
|
// last manual step -- an agent installed with only a join key ends up fully
|
||||||
|
// configured without anyone copying values between two machines.
|
||||||
try {
|
try {
|
||||||
ws.send(JSON.stringify({
|
const payload = {
|
||||||
type: 'config',
|
message: 'Connected to SSO Manager C2',
|
||||||
payload: {
|
protocol_version: '1.2.0',
|
||||||
message: 'Connected to SSO Manager C2',
|
agent_id: agent.id
|
||||||
protocol_version: '1.2.0',
|
};
|
||||||
agent_id: agent.id
|
if (issuedToken) {
|
||||||
}
|
payload.enrolled = true;
|
||||||
}));
|
payload.auth_token = issuedToken;
|
||||||
|
payload.public_key = await agentManager.publicKeyBase64();
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ type: 'config', payload }));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ const DOCS = {
|
|||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
// `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent
|
||||||
|
// guide the Directory links to -- was unreachable in the app.
|
||||||
|
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
|
||||||
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||||
|
// The Discovery tab's help icon links here; without an entry it 404'd.
|
||||||
|
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||||
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||||
|
|
||||||
|
|||||||
+142
-8
@@ -1,5 +1,16 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* The caret's rotation is driven by a class on the BUTTON, not by swapping
|
||||||
|
icon classes on its child: Font Awesome's SVG-with-JS mode replaces the
|
||||||
|
<i> with an <svg>, so anything keyed to the child element stops working
|
||||||
|
the moment its observer runs. Targeting both covers either state. */
|
||||||
|
.tree-caret > i,
|
||||||
|
.tree-caret > svg { transition: transform .12s ease-in-out; }
|
||||||
|
.tree-caret.tree-caret-collapsed > i,
|
||||||
|
.tree-caret.tree-caret-collapsed > svg { transform: rotate(-90deg); }
|
||||||
|
</style>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
@@ -230,6 +241,13 @@
|
|||||||
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
|
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- app.messages confirmations render into a `.actionMessage` inside
|
||||||
|
the target and do NOTHING without one: the returned promise never
|
||||||
|
settles, so an awaited confirmation hangs forever and the action
|
||||||
|
it gates silently never happens. This pane had no such element,
|
||||||
|
which is why Delete appeared dead. Any pane that asks the
|
||||||
|
operator to confirm something needs this. -->
|
||||||
|
<div class="actionMessage" style="display:none"></div>
|
||||||
<div id="discovery-plugins-list" class="mt-3"></div>
|
<div id="discovery-plugins-list" class="mt-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -914,13 +932,23 @@
|
|||||||
hideBelowDepth = null;
|
hideBelowDepth = null;
|
||||||
$row.show();
|
$row.show();
|
||||||
|
|
||||||
const $icon = $row.find('.tree-caret i');
|
// Visual state lives on the .tree-caret BUTTON, rotated by CSS, and the
|
||||||
if (!$icon.length) return;
|
// hide decision is made from `collapsed` alone.
|
||||||
|
//
|
||||||
|
// This used to read `.tree-caret i` and bail out when it found nothing.
|
||||||
|
// Font Awesome runs in SVG-with-JS mode here: its mutation observer
|
||||||
|
// rewrites every <i class="fa-..."> into an <svg>, so moments after a
|
||||||
|
// render that selector matches nothing, the function returned early
|
||||||
|
// WITHOUT setting hideBelowDepth, and collapsing silently did nothing at
|
||||||
|
// all. Never make the collapse logic depend on an element another library
|
||||||
|
// is free to replace.
|
||||||
|
const $caret = $row.find('.tree-caret');
|
||||||
|
if (!$caret.length) return; // leaf row: nothing to collapse
|
||||||
if (collapsed.has(id)) {
|
if (collapsed.has(id)) {
|
||||||
$icon.removeClass('fa-chevron-down').addClass('fa-chevron-right');
|
$caret.addClass('tree-caret-collapsed');
|
||||||
hideBelowDepth = depth;
|
hideBelowDepth = depth;
|
||||||
} else {
|
} else {
|
||||||
$icon.removeClass('fa-chevron-right').addClass('fa-chevron-down');
|
$caret.removeClass('tree-caret-collapsed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1963,6 +1991,7 @@
|
|||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||||
${logsBtn}
|
${logsBtn}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" title="Edit" onclick="openEditDiscoveryPluginModal('${p.id}')"><i class="fa-solid fa-pen"></i> Edit</button>
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||||
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
|
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||||
@@ -2066,18 +2095,27 @@
|
|||||||
var raw = document.getElementById(prefix + 'cron');
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
return (raw && raw.value.trim()) || '0 * * * *';
|
return (raw && raw.value.trim()) || '0 * * * *';
|
||||||
}
|
}
|
||||||
function dpConfigFormHtml(type, prefix) {
|
// `values` pre-fills the form for edit mode. Secret fields are never returned
|
||||||
|
// by the API in the clear (they live in OpenBao and come back masked), so
|
||||||
|
// they are rendered EMPTY with a "leave blank to keep" hint rather than
|
||||||
|
// prefilled with `********` -- submitting the mask back would otherwise store
|
||||||
|
// the literal asterisks as the secret.
|
||||||
|
function dpConfigFormHtml(type, prefix, values) {
|
||||||
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
||||||
var schema = t && t.configSchema;
|
var schema = t && t.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>';
|
||||||
|
values = values || {};
|
||||||
var html = '';
|
var html = '';
|
||||||
schema.forEach(function(f) {
|
schema.forEach(function(f) {
|
||||||
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 && !f.secret) ? ' required' : '';
|
||||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
|
||||||
|
var val = '';
|
||||||
|
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
|
||||||
|
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
|
||||||
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||||
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
|
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
|
||||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + '></div>';
|
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
|
||||||
});
|
});
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -2137,6 +2175,102 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit an existing instance. Non-secret config goes to PUT /plugins/:id;
|
||||||
|
// secrets go to PUT /plugins/:id/secrets and only when the operator actually
|
||||||
|
// typed a new value -- they are two endpoints because the DB row must never
|
||||||
|
// hold a secret (see routes/api_plugins.js).
|
||||||
|
function openEditDiscoveryPluginModal(id) {
|
||||||
|
const p = discoveryPlugins.find(x => x.id === id);
|
||||||
|
if (!p) return;
|
||||||
|
app.api.get('plugins/types', function(err, res) {
|
||||||
|
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
|
||||||
|
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
|
||||||
|
const values = Object.assign({}, p.config || {}, { __isEdit: true });
|
||||||
|
const bodyHtml = `
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Plugin Type</label>
|
||||||
|
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled>
|
||||||
|
<div class="form-text">The type is fixed once an instance exists — create a new instance to use a different one.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Instance Name</label>
|
||||||
|
<input type="text" id="edit-plugin-name" class="form-control shadow-sm" value="${esc(p.name)}">
|
||||||
|
<div class="form-text">Slug <code>${esc(p.slug)}</code> is stable and does not change.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">Schedule</label>
|
||||||
|
${dpCronSelectHtml('ep-', p.cron)}
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="edit-plugin-enabled" ${p.enabled ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label fw-semibold" for="edit-plugin-enabled">Loaded (runs on its schedule)</label>
|
||||||
|
</div>
|
||||||
|
<hr><h6 class="fw-bold">Configuration</h6>
|
||||||
|
<div id="edit-plugin-config-fields">${dpConfigFormHtml(p.pluginType, 'ep-', values)}</div>
|
||||||
|
<div class="d-flex justify-content-end gap-2">
|
||||||
|
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditedDiscoveryPlugin(id) {
|
||||||
|
const p = discoveryPlugins.find(x => x.id === id);
|
||||||
|
if (!p) return;
|
||||||
|
const name = ($('#edit-plugin-name').val() || '').trim();
|
||||||
|
if (!name) { app.messages.toast('Name is required', 'warning'); return; }
|
||||||
|
|
||||||
|
const flat = dpCollectConfig(p.pluginType, 'ep-');
|
||||||
|
const type = discoveryPluginTypes.find(t => t.type === p.pluginType);
|
||||||
|
const schema = (type && type.configSchema) || [];
|
||||||
|
|
||||||
|
// Split by the schema so a secret never rides along in the DB payload, and
|
||||||
|
// an untouched secret field is not sent at all.
|
||||||
|
const config = {};
|
||||||
|
const secrets = {};
|
||||||
|
schema.forEach(f => {
|
||||||
|
const v = flat[f.key];
|
||||||
|
if (f.secret) { if (v) secrets[f.key] = v; }
|
||||||
|
else config[f.key] = v;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.api.put(`plugins/${id}`, {
|
||||||
|
name,
|
||||||
|
cron: dpCronFromForm('ep-'),
|
||||||
|
enabled: $('#edit-plugin-enabled').is(':checked'),
|
||||||
|
config
|
||||||
|
});
|
||||||
|
if (Object.keys(secrets).length) await app.api.put(`plugins/${id}/secrets`, secrets);
|
||||||
|
app.modal.close();
|
||||||
|
app.messages.toast('Plugin updated', 'success');
|
||||||
|
loadDiscoveryPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error saving plugin: ' + (e.message || e), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was referenced by the card's trash button but never defined, so clicking it
|
||||||
|
// only threw a ReferenceError -- delete appeared to do nothing.
|
||||||
|
async function deleteDiscoveryPlugin(id) {
|
||||||
|
const p = discoveryPlugins.find(x => x.id === id);
|
||||||
|
const label = p ? (p.name || p.slug) : 'this plugin';
|
||||||
|
const $card = $('#plugins-tab-pane');
|
||||||
|
const confirmed = await app.messages.confirm(
|
||||||
|
`Delete discovery plugin "${label}"? Its schedule stops and its stored secrets are removed. Resources it already discovered stay in the Directory.`,
|
||||||
|
$card, 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await app.api.delete(`plugins/${id}`);
|
||||||
|
app.messages.toast('Plugin deleted', 'success');
|
||||||
|
loadDiscoveryPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error deleting plugin: ' + (e.message || e), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveNewDiscoveryPlugin() {
|
async function saveNewDiscoveryPlugin() {
|
||||||
const type = $('#new-plugin-type').val();
|
const type = $('#new-plugin-type').val();
|
||||||
const name = $('#new-plugin-name').val().trim();
|
const name = $('#new-plugin-name').val().trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user