mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
90e4ded7c9
- ha-poller: replace recursive main() retry with while loop (stack overflow fix) - ha-poller: advance last_push on empty HA response (log spam fix) - ha-poller: use datetime.now(timezone.utc) instead of deprecated utcnow() - ping-probe: always call update_status() unconditionally so offline devices register as offline - agent.php: heartbeat reads status from payload instead of hardcoding 'online' - phone-probe: delegate JSON building to python3 (bash concatenation injection fix) - netscan + phone-probe: read registration key from /etc/jarvis-agent/reg-key - admin/index.php: sync ha_list skipDomains with ha.php (14 missing domains added) - facts_collector: self-check JARVIS via 127.0.0.1 instead of Cloudflare hairpin
262 lines
12 KiB
PHP
262 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* JARVIS Agent API
|
|
* Handles registration, heartbeats, metrics, HA state pushes, and command polling
|
|
* from remote jarvis-agent clients on external networks.
|
|
*
|
|
* Auth: POST /api/agent/register → uses AGENT_REGISTRATION_KEY
|
|
* All other actions → uses X-Agent-Key header (per-agent key)
|
|
*/
|
|
|
|
header('Content-Type: application/json');
|
|
|
|
$agentAction = $action ?? ($parts[1] ?? '');
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function agent_error(int $code, string $msg): void {
|
|
http_response_code($code);
|
|
echo json_encode(['error' => $msg]);
|
|
exit;
|
|
}
|
|
|
|
function agent_ok(array $payload = []): void {
|
|
echo json_encode(array_merge(['ok' => true], $payload));
|
|
exit;
|
|
}
|
|
|
|
function generate_api_key(): string {
|
|
return bin2hex(random_bytes(24));
|
|
}
|
|
|
|
function get_agent_by_key(string $key): ?array {
|
|
$rows = JarvisDB::query(
|
|
'SELECT * FROM registered_agents WHERE api_key = ? LIMIT 1',
|
|
[$key]
|
|
);
|
|
return $rows[0] ?? null;
|
|
}
|
|
|
|
function update_agent_seen(string $agentId, string $status = 'online', ?string $version = null): void {
|
|
if ($version !== null) {
|
|
JarvisDB::query(
|
|
'UPDATE registered_agents SET last_seen = NOW(), status = ?, version = ? WHERE agent_id = ?',
|
|
[$status, $version, $agentId]
|
|
);
|
|
} else {
|
|
JarvisDB::query(
|
|
'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?',
|
|
[$status, $agentId]
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Auth (all actions except register) ───────────────────────────────────────
|
|
|
|
$agentKey = $_SERVER['HTTP_X_AGENT_KEY'] ?? '';
|
|
$browserActions = ['list', 'status', 'myip'];
|
|
|
|
if ($agentAction !== 'register') {
|
|
if (in_array($agentAction, $browserActions)) {
|
|
$token = $_SESSION['jarvis_token'] ?? '';
|
|
$localIP = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
if (empty($token) && !in_array($localIP, ['127.0.0.1', '::1'])) {
|
|
agent_error(401, 'Unauthorized');
|
|
}
|
|
$agent = null;
|
|
} else {
|
|
if (empty($agentKey)) agent_error(401, 'X-Agent-Key header required');
|
|
$agent = get_agent_by_key($agentKey);
|
|
if (!$agent) agent_error(401, 'Invalid agent key');
|
|
}
|
|
}
|
|
|
|
// ── Route ─────────────────────────────────────────────────────────────────────
|
|
|
|
switch ($agentAction) {
|
|
|
|
// ── REGISTER ─────────────────────────────────────────────────────────────
|
|
case 'register':
|
|
if ($method !== 'POST') agent_error(405, 'POST only');
|
|
$regKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? ($data['registration_key'] ?? '');
|
|
if (!hash_equals(AGENT_REGISTRATION_KEY, $regKey)) agent_error(403, 'Invalid registration key');
|
|
|
|
$hostname = trim($data['hostname'] ?? '');
|
|
$agentType = $data['agent_type'] ?? 'linux';
|
|
$ipAddress = $data['ip_address'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
|
|
$capabilities = $data['capabilities'] ?? [];
|
|
$agentId = $data['agent_id'] ?? ($hostname . '_' . substr(md5($hostname . $ipAddress), 0, 8));
|
|
$version = trim($data['version'] ?? '');
|
|
|
|
if (!$hostname) agent_error(400, 'hostname required');
|
|
if (!in_array($agentType, ['linux', 'homeassistant', 'proxmox', 'windows', 'macos'])) agent_error(400, 'Invalid agent_type');
|
|
|
|
// Upsert agent
|
|
$existing = JarvisDB::query('SELECT api_key FROM registered_agents WHERE agent_id = ?', [$agentId]);
|
|
if ($existing) {
|
|
$apiKey = $existing[0]['api_key'];
|
|
JarvisDB::query(
|
|
'UPDATE registered_agents SET hostname=?, agent_type=?, ip_address=?, capabilities=?, version=?, last_seen=NOW(), status="online" WHERE agent_id=?',
|
|
[$hostname, $agentType, $ipAddress, json_encode($capabilities), $version ?: null, $agentId]
|
|
);
|
|
} else {
|
|
$apiKey = generate_api_key();
|
|
JarvisDB::query(
|
|
'INSERT INTO registered_agents (agent_id, hostname, agent_type, ip_address, api_key, capabilities, version, last_seen, status) VALUES (?,?,?,?,?,?,?,NOW(),"online")',
|
|
[$agentId, $hostname, $agentType, $ipAddress, $apiKey, json_encode($capabilities), $version ?: null]
|
|
);
|
|
}
|
|
|
|
agent_ok(['agent_id' => $agentId, 'api_key' => $apiKey]);
|
|
|
|
// ── HEARTBEAT ────────────────────────────────────────────────────────────
|
|
case 'heartbeat':
|
|
$hbStatus = in_array($data['status'] ?? '', ['online','offline']) ? $data['status'] : 'online';
|
|
update_agent_seen($agent['agent_id'], $hbStatus, trim($data['version'] ?? '') ?: null);
|
|
|
|
// Return any pending commands for this agent
|
|
$commands = JarvisDB::query(
|
|
'SELECT id, command_type, command_data FROM agent_commands WHERE agent_id = ? AND status = "pending" ORDER BY created_at ASC LIMIT 10',
|
|
[$agent['agent_id']]
|
|
);
|
|
|
|
// Mark as delivered
|
|
if ($commands) {
|
|
$cmdIds = array_column($commands, 'id');
|
|
$placeholders = implode(',', array_fill(0, count($cmdIds), '?'));
|
|
JarvisDB::query("UPDATE agent_commands SET status='delivered', delivered_at=NOW() WHERE id IN ($placeholders)", $cmdIds);
|
|
foreach ($commands as &$cmd) {
|
|
$cmd['command_data'] = json_decode($cmd['command_data'] ?? '{}', true);
|
|
}
|
|
}
|
|
|
|
agent_ok(['commands' => $commands ?: []]);
|
|
|
|
// ── METRICS ──────────────────────────────────────────────────────────────
|
|
case 'metrics':
|
|
if ($method !== 'POST') agent_error(405, 'POST only');
|
|
update_agent_seen($agent['agent_id']);
|
|
|
|
$metricType = $data['type'] ?? 'system';
|
|
$metricData = $data['data'] ?? [];
|
|
|
|
JarvisDB::query(
|
|
'INSERT INTO agent_metrics (agent_id, metric_type, metric_data) VALUES (?,?,?)',
|
|
[$agent['agent_id'], $metricType, json_encode($metricData)]
|
|
);
|
|
|
|
// Prune old metrics (keep 24h)
|
|
JarvisDB::query(
|
|
'DELETE FROM agent_metrics WHERE agent_id = ? AND recorded_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)',
|
|
[$agent['agent_id']]
|
|
);
|
|
|
|
agent_ok();
|
|
|
|
// ── HA STATE PUSH ────────────────────────────────────────────────────────
|
|
case 'ha_state':
|
|
if ($method !== 'POST') agent_error(405, 'POST only');
|
|
update_agent_seen($agent['agent_id']);
|
|
|
|
$entities = $data['entities'] ?? [];
|
|
if (empty($entities)) agent_error(400, 'entities array required');
|
|
|
|
foreach ($entities as $e) {
|
|
$entityId = $e['entity_id'] ?? '';
|
|
$entityName = $e['name'] ?? $e['friendly_name'] ?? $entityId;
|
|
$domain = explode('.', $entityId)[0] ?? 'unknown';
|
|
$state = $e['state'] ?? '';
|
|
$attrs = $e['attributes'] ?? [];
|
|
$lastChanged = $e['last_changed'] ?? date('Y-m-d H:i:s');
|
|
|
|
if (!$entityId) continue;
|
|
|
|
JarvisDB::query(
|
|
'INSERT INTO ha_entities (agent_id, entity_id, entity_name, domain, state, attributes, last_changed, updated_at)
|
|
VALUES (?,?,?,?,?,?,?,NOW())
|
|
ON DUPLICATE KEY UPDATE entity_name=VALUES(entity_name), state=VALUES(state), attributes=VALUES(attributes), last_changed=VALUES(last_changed), updated_at=NOW()',
|
|
[$agent['agent_id'], $entityId, $entityName, $domain, $state, json_encode($attrs), $lastChanged]
|
|
);
|
|
}
|
|
|
|
// Also update kb_facts for compatibility with existing KB engine
|
|
$entityMap = [];
|
|
$all = JarvisDB::query('SELECT entity_id, entity_name, domain, state, attributes FROM ha_entities WHERE agent_id = ?', [$agent['agent_id']]);
|
|
foreach ($all as $row) {
|
|
$entityMap[$row['entity_id']] = [
|
|
'entity_id' => $row['entity_id'],
|
|
'name' => $row['entity_name'],
|
|
'domain' => $row['domain'],
|
|
'state' => $row['state'],
|
|
'attributes'=> json_decode($row['attributes'] ?? '{}', true),
|
|
];
|
|
}
|
|
JarvisDB::query(
|
|
'INSERT INTO kb_facts (category, fact_key, fact_value) VALUES ("ha_entities", "ha/entity_map", ?)
|
|
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()',
|
|
[json_encode($entityMap)]
|
|
);
|
|
|
|
agent_ok(['accepted' => count($entities)]);
|
|
|
|
// ── COMMAND RESULT ───────────────────────────────────────────────────────
|
|
case 'command_result':
|
|
if ($method !== 'POST') agent_error(405, 'POST only');
|
|
$cmdId = (int)($data['command_id'] ?? 0);
|
|
$result = $data['result'] ?? [];
|
|
$status = ($data['success'] ?? true) ? 'executed' : 'failed';
|
|
|
|
JarvisDB::query(
|
|
'UPDATE agent_commands SET status=?, executed_at=NOW(), result=? WHERE id=? AND agent_id=?',
|
|
[$status, json_encode($result), $cmdId, $agent['agent_id']]
|
|
);
|
|
agent_ok();
|
|
|
|
// ── LIST (admin: get all agents status) ──────────────────────────────────
|
|
case 'list':
|
|
// Mark agents offline if last_seen > 2 minutes ago
|
|
JarvisDB::query(
|
|
'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"'
|
|
);
|
|
$agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, capabilities FROM registered_agents ORDER BY agent_type, hostname');
|
|
foreach ($agents as &$a) {
|
|
$a['capabilities'] = json_decode($a['capabilities'] ?? '[]', true);
|
|
}
|
|
$realIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
|
$realIp = trim(explode(',', $realIp)[0]);
|
|
agent_ok(['agents' => $agents, 'my_ip' => $realIp]);
|
|
|
|
// ── LATEST METRICS (for dashboard display) ───────────────────────────────
|
|
case 'status':
|
|
$agentIdReq = $data['agent_id'] ?? ($parts[2] ?? '');
|
|
$whereAgent = $agentIdReq ? 'AND agent_id = ?' : '';
|
|
$params = $agentIdReq ? [$agentIdReq] : [];
|
|
|
|
$latest = JarvisDB::query(
|
|
"SELECT agent_id, metric_type, metric_data, recorded_at
|
|
FROM agent_metrics
|
|
WHERE (agent_id, metric_type, recorded_at) IN (
|
|
SELECT agent_id, metric_type, MAX(recorded_at)
|
|
FROM agent_metrics $whereAgent
|
|
GROUP BY agent_id, metric_type
|
|
)
|
|
ORDER BY agent_id, metric_type",
|
|
$params
|
|
);
|
|
|
|
$grouped = [];
|
|
foreach ($latest as $row) {
|
|
$grouped[$row['agent_id']][$row['metric_type']] = json_decode($row['metric_data'], true);
|
|
$grouped[$row['agent_id']]['recorded_at'] = $row['recorded_at'];
|
|
}
|
|
|
|
agent_ok(['metrics' => $grouped]);
|
|
|
|
// ── MY IP (browser client IP detection) ──────────────────────────────────
|
|
case 'myip':
|
|
agent_ok(['ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
|
|
|
|
default:
|
|
agent_error(404, 'Unknown agent action: ' . $agentAction);
|
|
}
|