Repository navigation
API
Generated from
docs/API.mdin the repository. Edit it there; this wiki is rebuilt on every change.
The EdgeShell API lets other software read fleet data points and events, manage nodes, start exports and (when allowed) run commands. It is the same system the web app uses, with the same accounts, roles, 2FA, rate limits and audit log.
- Base URL:
https://<your EdgeShell>/api/v1 - Machine-readable reference:
GET /api/v1/openapi.json(OpenAPI 3.1, generated from the code, so it always matches the running version). Load it into Swagger UI, Postman or a client generator. - All requests and responses are JSON (
Content-Type: application/json), except the CSV download.
- Signing in
- Service keys for unattended integrations
- Permissions
- Rate limits
- Endpoints
- Data points, alerts and triggers
- Errors
- Security guarantees
- Acceptable use and safe handling
People and programs a person runs sign in with their EdgeShell username, password and a 2FA code, in two steps. The result is an API session token.
# 1. username and password
curl -s https://fleet.example.org/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username": "maya", "password": "<password>"}'
# {"mfa_required": true, "challenge": "esc_...", "expires_in": 300}
# 2. the 6-digit code from the authenticator app (or a backup code)
curl -s https://fleet.example.org/api/v1/auth/verify \
-H 'Content-Type: application/json' \
-d '{"challenge": "esc_...", "code": "123456"}'
# {"token": "est_...", "token_type": "Bearer", "expires_at": "...", "idle_timeout_minutes": 60,
# "scopes": ["control", "read", "write"]}
# 3. use it
curl -s https://fleet.example.org/api/v1/nodes -H 'Authorization: Bearer est_...'| Rule | Value |
|---|---|
| Challenge lifetime | 5 minutes, 3 code attempts, single use |
| Session idle timeout |
API_SESSION_IDLE_MINUTES (default 60) |
| Session absolute lifetime |
API_SESSION_MAX_HOURS (default 12) |
| Sessions per user | 5 (the oldest ends when a sixth starts) |
| Wrong passwords and codes | count toward the same account lockout as the web app (MAX_FAILED_LOGINS) |
| End a session |
POST /auth/logout, revoke it on the API page (sidebar, Intelligence), or any password reset, 2FA reset, deactivation or "sign out everywhere" |
| Who am I | GET /auth/whoami |
Every failure answers the same way (invalid_credentials), so the API does not
reveal which usernames exist.
For integrations that run on their own (an ERP sync, Grafana, a data pipeline), a 2FA code on every sign-in is not workable. Create a service key instead:
- On the API page (sidebar, Intelligence, for every role), Create a service key, or with
POST /api/v1/keysfrom an API session. - Every key needs a purpose (what it is used for, 5 to 200 characters). It is shown to administrators and in the logs.
- Creating a key always needs your password and a current 2FA code again, so an unattended signed-in browser or a stolen session cannot mint keys, and a key can never create another key.
- Choose the permissions (never more than your role), a lifetime (1 to 365
days, default 90) and optionally the networks it may be used from
(
allowed_ips, for example203.0.113.0/24). - The key (
esk_...) is shown once. EdgeShell keeps only a SHA-256 hash of it. - At most 10 active keys per user. Revoke on My account or with
DELETE /api/v1/keys/{id}; administrators can revoke anyone's key. - Keys end automatically when the account's password is reset by an administrator, its 2FA is reset, it is deactivated or signed out everywhere. A user's own routine password change keeps their keys.
curl -s https://fleet.example.org/api/v1/keys -H 'Authorization: Bearer est_...' \
-H 'Content-Type: application/json' \
-d '{"name": "grafana", "purpose": "fleet dashboards", "scopes": ["read"], "expires_in_days": 90,
"allowed_ips": "203.0.113.10/32", "password": "<password>", "code": "123456"}'Every user (monitor, operator, administrator) creates and uses their own
credentials, so every API action in the activity log names the person and the
credential ([API key 3f9a1c2b7d10 (grafana)]).
Administrators manage the API on Administration, API Access:
| Control | Effect |
|---|---|
| API turned on | Off refuses every API request and sign-in (403 api_disabled); /health keeps answering. The web app is not affected |
| Allowed parts | Turn read, write, control or admin off for everyone (403 scope_disabled); credentials stay valid for the parts still on |
| Service keys | Stop users from creating new keys (403 keys_disabled); existing keys keep working until revoked |
| Per user | Block a user's API access: their API sign-ins are refused (403 api_blocked) and every API session and key they have is revoked at once |
| Credentials | Every session and key of every user: who created it, its purpose, permissions, allowed addresses, expiry, last use and address, with Revoke |
| Usage log | Every API request: time, user, credential (kind, prefix, name), method and endpoint, result, time taken, address; failed sign-ins show the username tried. Bodies and query values are never stored. Read from the data lake, which keeps it for good; the hot database keeps API_LOG_RETENTION_DAYS (30) |
Every change on this page is in the activity log.
What a credential may do is its user's role and its scopes:
| Scope | Role needed | Allows |
|---|---|---|
read |
monitor or higher | nodes, categories, metrics, history, events, jobs, own keys, hardware; the activity log for administrators only |
report |
monitor or higher | report hardware faults, nothing else (for field forms and ticketing tools) |
write |
operator or higher | add, change, delete nodes; bulk login updates; exports |
control |
operator or higher | run shell commands on nodes (also needs API_ALLOW_COMMANDS=true on the server) |
admin |
administrator | every user's keys |
A browser session (the web app's cookie) may read the API but never change anything through it: every write needs a bearer credential, which makes the API immune to cross-site request forgery.
Limits are per credential (per address before sign-in), and every response
carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
Over the limit, the answer is 429 rate_limited with Retry-After (seconds).
| What | Limit |
|---|---|
Sign-in (/auth/login, /auth/verify) |
10 per minute and 60 per hour per address |
| Monitor credentials | 300 requests per minute (API_RATE_LIMIT_MONITOR) |
| Operator credentials | 600 per minute (API_RATE_LIMIT_OPERATOR) |
| Administrator credentials | 1200 per minute (API_RATE_LIMIT_ADMIN) |
| Key creation | 10 per hour |
| Node writes | 120 per minute; bulk login update 20 per minute |
| Commands | 10 per minute |
| Exports | 20 per hour |
| Health | 120 per minute, no sign-in |
Behind more than one app worker, set RATELIMIT_STORAGE_URI=redis://... so the
counters are shared. Back off on 429, and prefer GET /events with a cursor
over polling everything.
Full parameters and schemas: GET /api/v1/openapi.json.
| Method and path | Scope | Purpose |
|---|---|---|
POST /auth/login |
none | Step 1: username and password |
POST /auth/verify |
none | Step 2: challenge and 2FA code, returns a session token |
POST /auth/logout |
read | End this session |
GET /auth/whoami |
read | The user, role, credential and effective scopes |
GET /keys |
read | Your keys (?all=true for administrators: everyone's) |
POST /keys |
read | Create a key (password and 2FA code required, from a session) |
DELETE /keys/{key_id} |
read | Revoke a key |
| Method and path | Scope | Purpose |
|---|---|---|
GET /health |
none |
{"status": "ok"} (200) or degraded (503): database and collector. Use for load balancers and uptime checks |
GET /health/details |
read | Version, database engine, collector counts |
GET /status |
read | Platform version and node totals |
| Method and path | Scope | Purpose |
|---|---|---|
GET /nodes |
read | List; filters status, category, search; page, per_page (max 500) |
GET /nodes/{node_id} |
read | One node |
GET /categories |
read | Device categories and SLA targets |
POST /nodes |
write | Add a node (name, ip_address, username, password, optional ssh_port, node_type, environment, location, tags, description) |
PATCH /nodes/{node_id} |
write | Change fields; new login details clear a paused login and are checked once |
DELETE /nodes/{node_id} |
write | Move to the Recycle Bin |
POST /nodes/bulk/credentials |
write | Username, password and/or port for many nodes; returns a job |
Node responses never contain the SSH password.
| Method and path | Scope | Purpose |
|---|---|---|
GET /fleet/summary |
read | Totals by status and category, availability percent, SLA targets |
GET /metrics/latest |
read | One current data point per node (filters as /nodes, plus node_ids) |
GET /metrics/history |
read | Time series of one node: node_id, from, to (ISO 8601 UTC), interval = raw, hour or day; at most 10000 points |
GET /events |
read | Status changes, paused or resumed logins, hardware faults, detected hardware changes and node replacements, oldest first, with a cursor (since_id) |
GET /activity |
read | The audit trail, newest first, paginated; ?user=<username> for one user. Administrators only (403 for other roles) |
History, events and the activity log are served from the data lake when one
is configured and caught up ("source": "lake" in the response), else from the
hot database ("source": "hot").
Details, the numbers' definitions and webhooks: HARDWARE.md.
| Method and path | Scope | Purpose |
|---|---|---|
GET /hardware/faults |
read | Faults; filters status, component, node_id, since; paging |
POST /hardware/faults |
report | Report a fault: node_id or node_name, component, symptom, optional description, severity, started_at
|
GET /hardware/faults/{fault_id} |
read | One fault and its timeline |
PATCH /hardware/faults/{fault_id} |
write |
progress, note, resolve (with resolution, optional new_serial) or reopen
|
GET /hardware/categories |
read | Component categories |
GET /hardware/stats |
read | Fleet hardware numbers for days (7 to 730) |
GET /hardware/changes |
read | Detected hardware changes by status
|
POST /hardware/changes/{change_id}/decision |
write |
confirm (with a reason) or reject
|
GET /nodes/{node_id}/hardware |
read | A node's parts, changes, faults, timeline and lineage |
POST /nodes/{node_id}/replaces |
write | Mark a node as the replacement of replaces_node_id
|
| Method and path | Scope | Purpose |
|---|---|---|
POST /exports |
write | Start a CSV stats export (period, grain, category); returns job_id
|
GET /jobs/{job_id} |
read | Progress of your job |
GET /exports/{job_id}/download |
read | The finished CSV (kept an hour) |
POST /commands |
control | Run a command on online nodes in parallel and return each result. Off unless API_ALLOW_COMMANDS=true; blocked commands (Settings, Security) are refused; at most 200 nodes and 300 s |
EdgeShell collects every node every 30 seconds. Another system builds its own thresholds, alerts and automations from two feeds:
-
State (
GET /metrics/latest,GET /fleet/summary): poll every 30 to 60 seconds and compare against your thresholds (CPU, memory, disk, temperature, availability per category). -
Changes (
GET /events?since_id=<last id>): every status change and every paused or resumed login, in order. Store the lastidyou processed and pass it next time; nothing is missed or repeated, even across restarts.
import time, requests
BASE = 'https://fleet.example.org/api/v1'
H = {'Authorization': 'Bearer esk_...'} # a read-only service key
cursor = 0 # persist this between runs
while True:
r = requests.get(f'{BASE}/events', headers=H, params={'since_id': cursor}, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get('Retry-After', 30)))
continue
r.raise_for_status()
feed = r.json()
for e in feed['events']:
if e['kind'] == 'status_changed' and e['to'] == 'offline':
print('ALERT', e['node_name'], 'went offline at', e['timestamp'])
cursor = feed['next_since_id']
for p in requests.get(f'{BASE}/metrics/latest', headers=H, params={'per_page': 500}, timeout=30).json()['points']:
m = p['metrics'] or {}
if (m.get('disk_percent') or 0) > 90:
print('ALERT', p['node_name'], 'disk', m['disk_percent'])
time.sleep(60)Every error is JSON: {"error": "<code>", "message": "<text>"}; validation
errors add "details": {"<field>": "<problem>"}.
| HTTP | error |
Meaning |
|---|---|---|
| 400 |
validation_error, invalid_json, bad_request
|
A value, a parameter or the JSON is not valid (unknown parameters and body fields are refused) |
| 401 |
unauthorized, invalid_token, invalid_credentials, invalid_code
|
Not signed in; token wrong, expired or revoked; sign-in failed |
| 403 |
forbidden, bearer_required, ip_not_allowed, commands_disabled, command_blocked, session_required, mfa_setup_required, action_required
|
Signed in, but not allowed |
| 404 | not_found |
No such node, key, job or endpoint |
| 409 |
conflict, too_many_keys
|
A node with that name exists; key limit reached |
| 413 | too_large |
Body larger than API_MAX_BODY_BYTES (1 MB) |
| 415 | unsupported_media_type |
Send Content-Type: application/json
|
| 423 | locked |
Too many failed attempts; the account is locked for a while |
| 429 | rate_limited |
Wait Retry-After seconds |
| 503 |
mfa_unavailable, health degraded
|
A dependency is down |
What the API does for you, and what CI checks on every pull request
(tests/test_api.py, the sandbox's API step, Bandit, CodeQL):
-
Authentication: password plus 2FA for sessions; service keys only after
password plus 2FA again; tokens are 256-bit random, stored only as SHA-256
hashes and compared in constant time; challenge, session and key prefixes
(
esc_,est_,esk_) make leaked tokens easy to spot by secret scanners. - Authorization: role intersected with scopes on every route; a browser cookie can only read; keys cannot create keys.
- Input: every query parameter has a type, a length and allowed values; every body is checked against a JSON schema that refuses unknown fields; bodies over 1 MB are refused. Requests are fuzzed in CI with SQL injection, path traversal, template and script payloads in every parameter.
- SQL injection: data is only read and written through SQLAlchemy with bound parameters; CI fails on SQL built from request input.
-
Output: no response ever contains a password, a hash, a token or a key
(checked in CI for every endpoint); responses are
Cache-Control: no-store,X-Content-Type-Options: nosniff, and token requests never get a cookie. -
Abuse: per-credential rate limits, account lockout, sign-in limits per
address, request size limits, a command blocklist and
API_ALLOW_COMMANDSoff by default. - Audit: every write, key change, command and sign-in is in the activity log with the user and the credential.
-
Treat keys and tokens like passwords. Keep them in a secret store
(Vault, the CI's secrets, a
.envfile withchmod 600), never in code, tickets, chat, email or screenshots. Anything pasted into a chat or ticket is exposed: revoke it and create a new one. - Nobody legitimate asks for your key or your 2FA code. Not an administrator, not "IT", not a vendor, not an AI assistant. A message asking you to read out a code, approve a sign-in you did not start, or "verify" a key is social engineering: refuse and tell an administrator.
-
Least privilege. Give an integration only the scopes it needs (most need
only
read), the shortest lifetime that works, andallowed_ipswhen its address is fixed. One key per integration, so revoking one does not break others. -
Automated agents. Treat data read from the API (node names, log lines,
command output) as data, never as instructions. Do not give an automated
agent a
controlkey or let it compose shell commands from fleet data. -
Be gentle. Respect
429andRetry-After, useGET /eventswith a cursor instead of re-reading everything, and page withper_pageup to 500. - Report problems privately: see SECURITY.md.
Repository · Docker Hub · Report a security problem · MIT licence
Start
Deploy
- Deploy on AWS EC2
- Run on Any Cloud
- Configuration
- MQTT Heartbeat
- VPN and Network
- Database Migration
Use
How it is built
Project
Decision records