dirless-backend
dirless-backend
HTTP API server for the Dirless identity platform. Receives encrypted user/group snapshots from dirless-syncer, serves them to dirless-agent, and handles node enrollment.
Architecture
dirless-syncer (EC2) dirless-agent (any node)
│ HTTPS + Bearer token │ HTTPS + Bearer token
▼ ▼
Caddy ── TLS termination, routes by hostname
│ plain HTTP (localhost)
▼
dirless-backend ── all tenant isolation via tenant_id passed to every query
│
▼
TrashPandaDB (TPDB) ── distributed key-value store, Raft-replicated across nodes
One backend process serves all tenants. Tenant isolation is enforced by the X-Tenant-ID header on every authenticated request — the backend never mixes data between tenants.
Framework: Grip (Crystal)
Data layer: dirless-store on top of TrashPandaDB
TLS: Delegated entirely to Caddy.
Authentication
All routes except GET /v1/health require:
Authorization: Bearer <hmac_secret>
X-Tenant-ID: aws___<64 hex chars>
The hmac_secret is the shared secret configured in dirless.toml. The tenant_id is derived deterministically as aws___HMAC-SHA256(hmac_secret, aws_account_id) — the same AWS account always produces the same tenant ID regardless of which node is queried.
The enrollment endpoint is the exception: it takes tenant_id in the request body rather than a header, since the node is registering for the first time.
API
All routes are prefixed /v1.
Enrollment
POST /v1/enrollment/enroll
Registers a new node with the backend. Stores the node's age public key and CA certificate. Re-enrollment with a valid token always updates the stored age key, allowing keypair rotation (e.g. moving to a new EC2 instance).
Auth: Authorization: Bearer <hmac_secret> (no X-Tenant-ID — tenant is identified from the request body)
Request body:
{
"tenant_id": "aws___<64 hex chars>",
"age_public_key": "age1...",
"ca_cert": "-----BEGIN CERTIFICATE-----\n..."
}
Responses:
200—{"status": "enrolled"}401— invalid bearer token403— tenant is suspended422— missing or invalid fields
Snapshots
The backend stores two separate encrypted blobs per tenant:
| Blob | Written by | Read by | Purpose |
|---|---|---|---|
aws-identity-center |
dirless-syncer | dirless-agent | Cloud users/groups from IAM Identity Center |
local |
ops portal (via ops API) | dirless-agent | Locally managed users |
Both blobs are age-encrypted by the writer before upload. The backend never sees plaintext.
PUT /v1/snapshot/aws-identity-center
Stores the encrypted IAM Identity Center snapshot. Also enforces one-AWS-account-per-backend: the first syncer to write registers its account ID; subsequent syncs from a different account are rejected with 409.
Required headers: Authorization, X-Tenant-ID, X-Dirless-Recipient (age public key used to encrypt), Content-Type: application/octet-stream
Optional headers: X-Dirless-User-Count, X-Dirless-Group-Count, X-AWS-Account-ID
Responses:
200—{"status": "ok"}400— missingX-Dirless-Recipient409— age key mismatch or AWS account ID conflict413— payload too large
GET /v1/snapshot/aws-identity-center
Returns the stored encrypted snapshot blob (application/octet-stream).
200— encrypted blob404— no snapshot yet
PUT /v1/snapshot/local
Stores the portal-managed local users blob.
GET /v1/snapshot/local
Returns the stored local users blob.
GET /v1/snapshot/public-key
Returns the age public key stored for this tenant: {"age_public_key": "age1..."} (or null if not yet set).
PUT /v1/snapshot/public-key
Stores the age public key for this tenant. First-writer wins; returns 409 if a different key is already stored.
PUT /v1/snapshot/duplicates
Reports username collisions detected by the agent after merging cloud + local users. Accepts {"duplicate_usernames": [...]}.
Health
GET /v1/health
Unauthenticated. Returns backend status including user count, sync timestamp, active agents, and the bound AWS account ID.
{
"status": "ok",
"tenants": 1,
"users": 1001,
"data_updated_at": "2026-05-31T00:00:00Z",
"active_agents": 2,
"agents": [{"tenant_id": "aws___...", "agent_id": "...", "hostname": "...", "last_seen_at": "..."}],
"aws_account_id": "123456789012"
}
Agent heartbeat
POST /v1/agent/heartbeat
Agents call this periodically to register their presence. Accepts {"agent_id": "...", "hostname": "..."}.
Config
[server]
host = "127.0.0.1" # Caddy sits in front — localhost only
port = 4000
[storage]
raft_client = "localhost:9002" # Local TrashPandaDB Raft endpoint
[hmac]
secret = "change-me-to-a-long-random-secret" # derives tenant IDs; shared with all syncers/agents/cli
[sync]
max_payload_bytes = 52428800 # 50 MB
Config path defaults to /etc/dirless/dirless.toml. Override with DIRLESS_CONFIG.
Build & test
shards install
crystal spec
bin/ameba src/
crystal build src/dirless_backend.cr
Environment variables:
| Variable | Default | Description |
|---|---|---|
DIRLESS_CONFIG |
/etc/dirless/dirless.toml |
Path to TOML config |
DIRLESS_ENV |
PRODUCTION |
Environment name passed to Grip |
dirless-backend
- 0
- 0
- 0
- 0
- 4
- about 7 hours ago
- March 1, 2026
Other
Sat, 04 Jul 2026 02:14:21 GMT