Skip to content

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, on routes/api-sentinel.phpnot the Sanctum api group) resolves the token via its indexed 16-char prefix, then bcrypt-verifies. The route file also enforces IsValidJson and a 120 req/min throttle.
  • Panel → node: the same bearer token, compared in constant time against /etc/sentinel/token on the node.
  • GET /api/v1/health on 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:

json
{
  "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:

json
{
  "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:

json
{ "version": "1.0.0", "hostname": "node1", "port": 8481, "api_url": "" }

Response data:

json
{ "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:

json
{
  "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:

json
{ "config_version": 5, "commands": [] }

commands carries pending one-shot commands the node must execute and ack:

json
[{ "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:

json
{
  "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:

json
{ "events": [ { "uuid": "...", "category": "miner", "...": "..." } ] }

Response data:

json
{ "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:

json
{
  "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:

json
{ "accepted": 1, "confirmed": 0 }

POST /scans/{scan_id}/result

Async scan completion, posted when a panel-triggered scan finishes.

Body:

json
{
  "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:

json
{
  "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.

json
{
  "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.

json
{
  "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:

json
{
  "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/

json
{ "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:

json
{ "action": "pause|unpause|stop|kill_process", "pid": 0 }

Response: { "done": true }


cURL examples

Probe a node's health (no auth):

bash
curl -s http://node1.example.com:8481/api/v1/health | jq

Pull a node's live stats as the panel would:

bash
TOKEN=$(sudo cat /etc/sentinel/token)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8481/api/v1/stats | jq

Simulate a heartbeat against the panel:

bash
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