REST API
Sentinel's wire protocol is a single documented contract with two directions. Both use the same bearer token, the same response envelope, and the same event model. Everything is JSON.
- Node-facing panel API — implemented by the panel addon, called by the agent:
{panel_url}/api/sentinel/node/* - Panel-facing node API — implemented by the agent, called by the panel:
http(s)://<node_api_url>/api/v1/*
Authentication
- Node → panel:
Authorization: Bearer <64-hex token>. A dedicated middleware (sentinel.node.auth, onroutes/api-sentinel.php— not the Sanctumapigroup) resolves the token via its indexed 16-char prefix, then bcrypt-verifies. The route file also enforcesIsValidJsonand a 120 req/min throttle. - Panel → node: the same bearer token, compared in constant time against
/etc/sentinel/tokenon the node. GET /api/v1/healthon the node is public and cheap (monitoring probes).
The node holds no other secret. There is no panel Application API key on nodes — a compromised node yields one scoped, revocable pairing token and nothing else.
Response envelope
Every response in both directions, including /health:
{
"success": true,
"data": {},
"error": null,
"meta": { "version": "1.0.0" }
}Errors: success: false, error is a human-readable message, meta.code is an ERR_* code. Validation failures return HTTP 422 with ERR_VALIDATION.
Event model
The core object, generated by nodes and stored by the panel:
{
"uuid": "node-generated-uuid-v4",
"occurred_at": "RFC3339",
"category": "miner|portscan|ddos|zipbomb|exploit|abuse|malware|fim|vuln|scan",
"detector": "miner|portscan|ddos|zipbomb|exploit|abuse|yara|fim|onaccess|trivy|containerscan|volumescan",
"severity": "low|medium|high|critical",
"title": "short human summary",
"server_uuid": "pterodactyl-server-uuid-or-empty",
"container_id": "docker-id-or-empty",
"process": "cmdline-or-empty",
"pid": 0,
"path": "file-path-or-empty",
"evidence": { "arbitrary": "key/value detail" },
"actions_taken": ["quarantine_file", "pause_container"],
"dry_run": false
}The panel dedupes on uuid (unique index) — re-POSTed batches are idempotent and safe to retry. Events whose server_uuid does not belong to the authenticated node are stored with server_id = null: never silently dropped, never cross-attributed.
Node-facing panel endpoints
Base: {panel_url}/api/sentinel/node — JSON only, throttled to 120/min, bearer auth.
POST /register
First call after install; retried with backoff until it succeeds.
Body:
{ "version": "1.0.0", "hostname": "node1", "port": 8481, "api_url": "" }Response data:
{ "node_id": 3, "heartbeat_interval_seconds": 30, "config_version": 5 }Effects: upserts the node's reachable API URL and agent version, sets is_online = true, stamps last_seen_at. If the node's config is stale, it pulls GET /config next.
POST /heartbeat
Every 30 s in steady state.
Body:
{
"version": "1.0.0", "containers": 12, "config_version": 5,
"events_queued": 0, "detectors": ["miner", "abuse"],
"events_24h": 3, "critical_24h": 0, "dry_run": false
}Response data:
{ "config_version": 5, "commands": [] }commands carries pending one-shot commands the node must execute and ack:
[{ "id": 17, "type": "rescan_intel|push_config", "payload": {} }]The node acks via POST /commands/{id}/ack with { "ok": true, "message": "" }. If the returned config_version differs from the node's, the node must GET /config and apply.
GET /config
Full-replace config + intel payload. The node validates it, writes atomically, reloads and persists it as last-known-good.
Response data:
{
"config_version": 5,
"config": { "general": {}, "detectors": {}, "whitelist": {}, "rules": [], "intel": {}, "limits": {} },
"intel": {
"hashes": ["sha256..."],
"hashes_version": 12,
"yara_rules": "rule text bundle",
"yara_version": 3
}
}The config object is the complete tree documented in the Configuration Reference. Alert channel settings are not part of it.
POST /events
Event batch upload. Max 200 events per call; the node batches and retries.
Body:
{ "events": [ { "uuid": "...", "category": "miner", "...": "..." } ] }Response data:
{ "accepted": 3, "duplicates": 0 }Panel-side effects per event: upsert sentinel_flagged_servers, run the matching enforcement policy (suspend_server when a rule says so), and dispatch alerts (Discord/SMTP/webhook) subject to cooldown and rate limit.
POST /intel/submit
Suspicious-file hash submissions.
Body:
{
"hashes": [
{
"hash": "sha256hex",
"file_name": "x",
"detection_type": "miner",
"server_uuid": "...",
"metadata": {}
}
]
}The panel upserts sentinel_hashes, maintaining the per-hash set of reporting node IDs; reaching confirm_threshold distinct nodes (or an admin confirm) flips confirmed = true.
Response data:
{ "accepted": 1, "confirmed": 0 }POST /scans/{scan_id}/result
Async scan completion, posted when a panel-triggered scan finishes.
Body:
{
"status": "completed|partial|failed",
"stats": { "files_scanned": 0, "duration_seconds": 0 },
"findings": [ { "path": "...", "reason": "...", "hash": "", "severity": "medium" } ],
"error": ""
}Idempotent — last write wins on the same scan. Updates the sentinel_scans row shown in the Scans tab.
POST /quarantine/report
Quarantine ledger updates from the node.
Body:
{
"items": [
{
"event_uuid": "...",
"server_uuid": "...",
"original_path": "...",
"quarantine_path": "...",
"file_hash": "...",
"status": "quarantined|restored|deleted"
}
]
}Response data: { "accepted": 1 }
Panel-facing node endpoints
Base: http(s)://<node_api_url>/api/v1 — bearer auth everywhere except /health.
GET /health
Public, cheap, monitoring-safe.
{
"status": "ok", "version": "1.0.0", "containers": 12,
"uptime_seconds": 3600, "config_version": 5, "dry_run": false,
"detectors": ["miner"]
}GET /status
Everything /health has, plus per-detector state (last tick duration, events emitted, errors), spool depth, intel versions, and the last config-apply result. This is what the Nodes tab proxies for its health view.
GET /stats
Live per-container metrics, polled by the panel for the Servers views.
{
"servers": [
{
"uuid": "...", "container_id": "abc", "online": true,
"cpu_percent": 12.3, "memory_bytes": 123,
"rx_rate_bps": 100, "tx_rate_bps": 200,
"connections": 40, "flagged": false
}
]
}GET /events?limit=100&severity=high
The node's local event ring buffer (500 entries), same event model as above. Useful for debugging before events reach the panel.
POST /config
Panel config push. Body is the same shape as the GET /config response data. The node validates, applies atomically, and persists.
Response: { "applied": true, "config_version": 6 }
Idempotent: re-applying the same version is a no-op success.
POST /scans
Trigger an on-demand scan.
Body:
{
"scan_id": 42,
"server_uuid": "|-empty-for-whole-node",
"type": "quick|full",
"patterns": { "...": "optional override, else config" }
}Async: returns { "started": true } immediately; the node POSTs the result to /api/sentinel/node/scans/{scan_id}/result when done. Re-POSTing the same scan_id while it runs returns { "started": false, "already_running": true }.
GET /scans/
{ "status": "running|completed|failed", "progress": { "files_scanned": 0 } }POST /quarantine/{id}/restore and /quarantine/{id}/delete
Admin-initiated restore/destroy of a quarantined file (from the Quarantine tab). Response { "done": true }; unknown ID returns 404.
POST /servers/{uuid}/action
Manual containment from the Servers tab.
Body:
{ "action": "pause|unpause|stop|kill_process", "pid": 0 }Response: { "done": true }
cURL examples
Probe a node's health (no auth):
curl -s http://node1.example.com:8481/api/v1/health | jqPull a node's live stats as the panel would:
TOKEN=$(sudo cat /etc/sentinel/token)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8481/api/v1/stats | jqSimulate a heartbeat against the panel:
curl -s -X POST https://panel.example.com/api/sentinel/node/heartbeat \
-H "Authorization: Bearer <64-hex-token>" \
-H "Content-Type: application/json" \
-d '{"version":"1.0.0","containers":12,"config_version":5,"events_queued":0,"detectors":["miner"],"events_24h":0,"critical_24h":0,"dry_run":true}' | jq