feat: Protocol v1.1.0 theta-agent C2 integration, agent install wizard, OpenBao 403 fix, nmap discovery fix, and restored documentation
This commit is contained in:
+2
-1
@@ -72,7 +72,8 @@ app.locals.ui = require('./utils/ui');
|
||||
// Have express server static content( images, CSS, browser JS) from the public
|
||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}))
|
||||
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}));
|
||||
app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'}));
|
||||
|
||||
// Routes for front end content.
|
||||
app.use('/', require('./routes/index'));
|
||||
|
||||
@@ -32,10 +32,13 @@ module.exports = {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
||||
const scan = new nmap.NmapScan(targetRange);
|
||||
scan.command.push('-Pn');
|
||||
scan.command.push('-F'); // fast scan, 100 top ports
|
||||
scan.command.push('--min-rate', '100'); // speed up the scan
|
||||
// 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(' ')}`);
|
||||
|
||||
|
||||
@@ -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.
+96
-44
@@ -1,53 +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;
|
||||
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;
|
||||
}
|
||||
|
||||
app.wss.on('connection', (ws, req) => {
|
||||
// Parse the token from query param or header (e.g. ?token=XYZ)
|
||||
// For the beta, we will just accept it if a token is present.
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||
const remoteAddr = req.socket.remoteAddress;
|
||||
console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`);
|
||||
|
||||
if (!token) {
|
||||
ws.close(4001, 'Unauthorized: Missing token');
|
||||
return;
|
||||
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}`);
|
||||
}
|
||||
|
||||
console.log(`[Theta Agent] Agent connected from ${req.socket.remoteAddress}`);
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
|
||||
// Example handling incoming telemetry
|
||||
if (data.type === 'telemetry') {
|
||||
// Send to discovery service or log
|
||||
// console.log(`[Theta Agent] Received telemetry from ${data.host}`);
|
||||
|
||||
// We can publish it to the event bus for the UI
|
||||
if(app.contoller && app.contoller.ps) {
|
||||
app.contoller.ps.publish('agent.telemetry', data);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Theta Agent] Error parsing message:", err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`[Theta Agent] Agent disconnected`);
|
||||
});
|
||||
|
||||
// Example: Send a welcome config payload to the agent
|
||||
ws.send(JSON.stringify({
|
||||
type: 'config',
|
||||
payload: {
|
||||
message: 'Welcome to SSO Manager C2'
|
||||
}
|
||||
}));
|
||||
} 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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/"')
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -106,10 +106,21 @@ async function getOrCreateUserToken(uid) {
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||
const cached = await cacheGet(cacheKey);
|
||||
if (cached) return cached;
|
||||
await ensurePolicy('sso-admin', adminPolicyHcl());
|
||||
const { token, ttl } = await mintToken(['sso-admin']);
|
||||
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||
return token;
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
<datalist id="access-uid-list"></datalist>
|
||||
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary ms-1 shadow-sm" onclick="openAgentInstallModal()">
|
||||
<i class="fa-solid fa-shield-halved me-1"></i> Install Agent
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
||||
<i class="fas fa-plus"></i> Add Resource
|
||||
</button>
|
||||
@@ -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 &
|
||||
// Scheduler tab here was its old home). Discovery inventory + the discovery
|
||||
// results table remain on this page.
|
||||
|
||||
Reference in New Issue
Block a user