dirless-backend

dirless-backend

License

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 token
  • 403 — tenant is suspended
  • 422 — 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 — missing X-Dirless-Recipient
  • 409 — age key mismatch or AWS account ID conflict
  • 413 — payload too large

GET /v1/snapshot/aws-identity-center

Returns the stored encrypted snapshot blob (application/octet-stream).

  • 200 — encrypted blob
  • 404 — 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
Repository

dirless-backend

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 4
  • about 7 hours ago
  • March 1, 2026
License

Other

Links
Synced at

Sat, 04 Jul 2026 02:14:21 GMT

Languages