7e9a271090
JOIN KEYS v1.29.0 required an admin to pre-register every machine before its agent would be spoken to. The security model was right; the workflow was not -- installing the agent should be enough to add a host. 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 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. DIRECTORY Collapsing the tree did nothing. applyTreeCollapse found the caret with `.tree-caret i` and returned early when absent -- Font Awesome's SVG mode rewrites <i> to <svg>, so that selector matched nothing and the early return skipped setting hideBelowDepth. State now lives on the caret button and is rotated by CSS. The Discovery Plugins delete button called deleteDiscoveryPlugin(), which was never defined. The pane also had no .actionMessage, and confirmations render into one -- without it the promise never settles, so an awaited confirmation hangs forever and the action silently never happens. Plugin instances can now be edited. DISCOVERY A fresh install presented its own five containers as unmanaged discoveries. The Docker plugin now recognises the stack's compose project and attaches each container to the service it implements. Container slugs came from the container id, which changes on recreate, so every deploy minted a new resource and orphaned the old one. DOCS /docs/discovery 404'd (no slug entry) and `agents` pointed at plugins.md, leaving docs/agents.md unreachable. Adds docs/discovery.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
6.8 KiB
JavaScript
183 lines
6.8 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
const { Model } = require('@simpleworkjs/orm');
|
|
|
|
// A theta-agent enrolled against this SSO.
|
|
//
|
|
// Before this model existed the "agent token" was generated in the browser and
|
|
// never recorded anywhere, so the server had no way to tell an agent it issued
|
|
// from one someone invented -- /api/agent/ws accepted any string, and there was
|
|
// no way to revoke a token or to know that an agent existed while it was
|
|
// offline. The row is now the authority: an agent is only real if it is here.
|
|
//
|
|
// The raw token is shown exactly once, at enrollment. Only its SHA-256 lands in
|
|
// the database, so a database disclosure does not hand over working agent
|
|
// credentials. `tokenPrefix` is the first 8 characters, kept in the clear so the
|
|
// UI and logs can identify an agent without holding the secret.
|
|
class Agent extends Model {
|
|
// Tokens are compared by hash on every WebSocket connect. SHA-256 (not
|
|
// bcrypt) is deliberate: this runs on the connection path and the token is a
|
|
// 256-bit random value, not a human-chosen password, so there is nothing for
|
|
// a slow KDF to protect against here.
|
|
static hashToken(raw) {
|
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
|
}
|
|
|
|
static generateToken() {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
// Resolve a presented token to its (non-revoked) agent, or null. Every
|
|
// caller that authenticates an agent must go through here.
|
|
static async authenticate(rawToken) {
|
|
if (!rawToken || typeof rawToken !== 'string') return null;
|
|
const tokenHash = this.hashToken(rawToken);
|
|
const matches = await this.list({ where: { tokenHash } });
|
|
const agent = matches && matches[0];
|
|
if (!agent) return null;
|
|
if (agent.revoked) return null;
|
|
return agent;
|
|
}
|
|
|
|
// Enroll a new agent and return { agent, token }. The caller is responsible
|
|
// for showing `token` to the operator once and never storing it.
|
|
static async enroll({ name, resourceId, enrolledBy, description }) {
|
|
const token = this.generateToken();
|
|
const agent = await this.create({
|
|
id: crypto.randomUUID(),
|
|
name: name || 'theta-agent',
|
|
description: description || null,
|
|
tokenHash: this.hashToken(token),
|
|
tokenPrefix: token.slice(0, 8),
|
|
resourceId: resourceId || null,
|
|
revoked: false,
|
|
enrolled_by: enrolledBy || null,
|
|
enrolled_on: Math.floor(Date.now() / 1000)
|
|
});
|
|
return { agent, token };
|
|
}
|
|
|
|
// Issue a fresh token for an existing agent, invalidating the old one.
|
|
async rotateToken() {
|
|
const token = Agent.generateToken();
|
|
await this.update({
|
|
tokenHash: Agent.hashToken(token),
|
|
tokenPrefix: token.slice(0, 8),
|
|
revoked: false
|
|
});
|
|
return token;
|
|
}
|
|
|
|
static fields = {
|
|
id: { type: 'uuid', primaryKey: true },
|
|
name: { type: 'string', isRequired: true },
|
|
description: { type: 'text' },
|
|
// Never the raw token. See hashToken above.
|
|
tokenHash: { type: 'string', isRequired: true },
|
|
tokenPrefix: { type: 'string' },
|
|
// The host this agent runs on. Nullable so an agent can be enrolled
|
|
// before its host exists in the Directory, but the UI pushes for it:
|
|
// without this link there is nothing to hang resource control off, and
|
|
// the old code had to guess by matching hostnames to slugs.
|
|
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
|
|
revoked: { type: 'boolean', default: false },
|
|
enrolled_by: { type: 'string' },
|
|
enrolled_on: { type: 'integer' },
|
|
// Survives a restart, which the in-memory map did not: an agent that is
|
|
// installed but currently down is now distinguishable from one that was
|
|
// never enrolled.
|
|
last_seen: { type: 'integer' },
|
|
last_ip: { type: 'string' },
|
|
lastDiscovery: { type: 'json', default: {} },
|
|
lastTelemetry: { type: 'json', default: {} }
|
|
};
|
|
|
|
// The shape the admin API returns. Never includes tokenHash.
|
|
toPublic(liveState) {
|
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
|
delete data.tokenHash;
|
|
return {
|
|
...data,
|
|
connected: !!(liveState && liveState.connected),
|
|
// "Online" is a live-connection fact, not a stored one. A row with a
|
|
// last_seen from an hour ago is an installed agent that is down.
|
|
isOnline: !!(liveState && liveState.connected),
|
|
lastResponse: (liveState && liveState.lastResponse) || null
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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 };
|