Skip to content

HTTP API ​

STRUBS exposes one HTTP server on port 80, on all interfaces (not configurable), plus a read-only FUSE mount.

There is no authentication ​

Not on the object API, not on the management API, not on the UI. Anyone who can reach port 80 can read, overwrite, and delete your data — and can also call POST /$/volumes to repartition and format a disk. The only protection is where you put it on the network. Put it behind a reverse proxy that authenticates, or on a network where everything is trusted, and never on the public internet.

Routing is simple: any path starting with /$/ is the management API; everything else is an object path.


Object API ​

Addressing ​

Objects live in a path namespace. Containers are implicit — PUT /photos/2024/cat.jpg creates the photos and photos/2024 containers as needed.

Two ways to name an object:

FormExample
By path/photos/2024/cat.jpg
By id/$65f0a1b2c3d4e5f60718293a — a $ followed by exactly 24 hex characters

(The id form doesn't collide with the management API, which requires /$/.)

PUT /{path} — store an object ​

bash
curl -X PUT --data-binary @cat.jpg \
     -H 'Content-Type: image/jpeg' \
     http://strubs/photos/2024/cat.jpg
  • Content-Length is required. Streaming uploads of unknown length are not supported.
  • Content-MD5 (hex) is optional. If given, it's checked against the received bytes.
  • Content-Type is stored as the object's mime type.
  • Objects are immutable. A PUT to an existing path returns 409 Conflict. Delete it first.
  • The path may not begin with $.

201 Created on success, with X-Object-Id, X-Container-Id, and Content-MD5.

Two non-standard failure codes, both of which delete the partial object:

455 Length MismatchBytes received ≠ Content-Length. Returns X-Received-Bytes, X-Expected-Bytes.
456 MD5 MismatchThe supplied Content-MD5 didn't match. Returns X-Received-MD5.

GET /{path} — read an object ​

bash
curl http://strubs/photos/2024/cat.jpg
curl -H 'Range: bytes=0-1023' http://strubs/photos/2024/cat.jpg
curl 'http://strubs/photos/2024/cat.jpg?download_as=cat.jpg'

Ranges: a single range only — bytes=start-end (inclusive) or bytes=start-. Multi-range and suffix ranges (bytes=-500) are not supported and return 416. A valid range returns 206 with Content-Range.

?download_as=<name> sets Content-Disposition: attachment.

Every response carries the object's identity and layout, which is genuinely useful for debugging:

X-Object-Id, X-Container-Id, Content-MD5, Content-Type,
X-Data-Slice-Count, X-Data-Slice-Volumes,      # e.g. 4  /  47,52,51,57
X-Parity-Slice-Count, X-Parity-Slice-Volumes,  # e.g. 2  /  50,30
X-Chunk-Size, Accept-Ranges: bytes

If a read cannot be satisfied — more slices bad than parity can cover — it fails. It does not return unverified bytes.

HEAD /{path} — metadata only ​

Same headers as GET, no body.

DELETE /{path} — destroy an object ​

Immediate and irreversible. Deletes every data and parity slice and the record. No soft delete, no confirmation.

If the object's bucket has delete protection enabled, the delete is refused with 403 (bucket is delete-protected) and nothing is touched — a per-bucket lock that blocks every object delete in the bucket. It's independent of the read/write auth flags (it still applies on a dark, unauthenticated array), though an unauthorized caller may hit the auth check first. Toggle it with the bucket policy endpoint (below).

OPTIONS /{path} ​

Returns 204 with Allow and CORS headers. Note it requires the object to exist (a non-existent path 404s), and CORS headers are emitted only here — actual GET/PUT responses carry no Access-Control-Allow-Origin, so browser cross-origin use doesn't currently work.

Not supported ​

  • POST — returns 400.
  • Listing. There is no HTTP endpoint that enumerates a container. GET on a container path returns 404. (The FUSE mount can list — see below.)

Management API ​

All under /$/. Bodies are JSON; an empty body is treated as {}.

Fleet and status ​

GET /$/volumesEvery volume: flags, capacity, SMART summary, bus group, labels. ?includeDeleted=true to include tombstones.
GET /$/volumes/{id}One volume, plus full SMART attribute detail.
GET /$/statusVolume ids by state, plus GB stored / capacity / free.
GET /$/storage-statsObject and byte counters, system-wide and per volume. (If no snapshot exists yet this triggers a full reconciliation scan — expensive.)
GET /$/blockDevicesBlock devices as discovered, with their partitions and which volume each maps to. ?sort=name|sysfsPath|size|volumeId|volumeLabel.
POST /$/blockDevices/reloadRescan block devices.
GET /$/faultsOutstanding slice faults and their repair state.
GET /$/debugPer-volume I/O priority stats and verify status.
GET /$/fileinfo/{path}An object's slice layout and the absolute on-disk path of every slice. Accepts a path or the $<id> form. Very useful for forensics; note it exposes filesystem paths.

Volumes ​

POST /$/volumesProvision a disk. This can destroy data. See below.
PUT /$/volumes/{id}Set isEnabled, isReadOnly, isHealthy, isDraining, isDeleted, label, comment.
DELETE /$/volumes/{id}Soft-delete. Refused while the volume still holds live slices.
POST /$/volumes/{id}/drainMark read-only + draining and start relocating every slice off it.
DELETE /$/volumes/{id}/drainCancel the drain. Leaves the volume read-only — clear that separately.
POST /$/volumes/{id}/identifyFlash the drive's activity LED so you can find its bay.
DELETE /$/volumes/{id}/identifyStop.

POST /$/volumes — the dangerous one ​

json
{ "blockPath": "/dev/sdx", "wipe": 1783794000000, "encrypt": true }

wipe is a timestamp in milliseconds, not a boolean, and it must be within 10 seconds of now. That freshness window is the only guard against a replayed request repartitioning a disk — there is no other confirmation step. Get the device path right.

encrypt is optional (defaults to the fleet's encryptNewVolumes setting); you do not pass the recovery passphrase — STRUBS holds it sealed. There is no replace option: a new disk gets a new id, re-provisioning a registered disk means deleting its volume first, and re-encrypting a volume in place goes through the drain → POST /$/volumes/{id}/encrypt → rebalance flow. Passing replace is rejected.

DELETE and soft-delete are refused while a volume still holds live object slices:

volume 13 still holds 41027 live object slice(s); drain it first: POST /$/volumes/13/drain

Identify uses a heartbeat ​

POST …/identify starts continuous raw reads and sets a ~3 second deadline. The caller must keep re-POSTing (the UI does so about once a second) or the reads stop by themselves. So a closed browser tab or a lost cancel can't leave a drive spinning forever. DELETE stops it immediately.

Verification ​

POST /$/verify-volumesStart a scrub. Body: volumeIds (array, optional — omit for the whole fleet), mode (light or full, default full).
GET /$/verify-volumesProgress, error counts, scope, and whether it's waiting on a rebalance.
DELETE /$/verify-volumesStop.
POST /$/verify-file/{objectId}Verify one object. Body: mode. Returns a per-slice result map.

POST /$/verify-volumes returns {startedAt, accepted, deferred?}. Note the quiet cases:

  • accepted: false — a maintenance freeze is active, or a run is already going that doesn't cover your request. This is a 200, not an error.
  • deferred: true — a rebalance is running. Your request is persisted and queued, and will start when the rebalance finishes.

Rebalance ​

POST /$/rebalanceStart. Body: deadband (0–0.5), maxMoves, concurrency.
GET /$/rebalanceLive progress — see below.
PUT /$/rebalanceRetune concurrency (integer 1–64) on a running job. Applied at the next batch.
DELETE /$/rebalanceCancel.

GET /$/rebalance returns everything you need to watch a multi-day job:

json
{
  "running": true, "concurrency": 8,
  "targetFill": 0.474, "deadband": 0.05,
  "bytesToMove": 23410622879670, "bytesMoved": 47008182272,
  "bytesPerSec": 62914560, "etaSeconds": 372089,
  "sourceVolumeIds": [4, 10, 11, 13, 15, 23, 29, 36, 38, 40, 44, 45, 51],
  "currentSourceVolumeId": 4, "currentMinObjectSize": 268435456,
  "startedAt": "2026-07-11T19:58:11.163Z",
  "moves": 331, "copied": 160, "reconstructed": 171,
  "noDest": 0, "unrecoverable": 0, "sourceDeleteFailed": 0, "duplicateRefs": 0
}

bytesToMove is recomputed from live volume fills rather than remembered, so it stays honest across restarts and doubles as a progress denominator. currentMinObjectSize is the size tier being shed — the rebalance works biggest-objects-first.

Buckets ​

A bucket is a top-level container (the first path segment). These endpoints manage its policy flags.

GET /$/bucketsList buckets with policy (publicRead, publicWrite, deleteProtected) and request activity. Object counts are not here — they're a $group over millions of documents, so the UI fetches them separately from /$/buckets/stats.
PUT /$/buckets/{id}/policySet any of publicRead, publicWrite, deleteProtected (booleans).
GET /$/buckets/statsPer-bucket object count and logical size (cached; the expensive aggregation).

deleteProtected: true blocks every object delete in the bucket — a DELETE /{path} on a contained object returns 403 — independent of the read/write flags. There is no bucket-delete endpoint at all (delete acts on objects), so this protects the objects inside it. Like the public flags it rides the durability rails: it's journalled, snapshotted, and restored. On recovery the two paths differ — journal replay only ever adds protection (it refuses to clear a flag the live bucket has set), while a snapshot restore rewrites policy to match the snapshot exactly, so it can also clear it.

Maintenance freeze ​

GET /$/maintenance-freeze{ "frozen": bool }
PUT /$/maintenance-freeze{ "frozen": true } — the global kill switch for all background maintenance.

Persisted, and enforced across restarts. See Configuration.

Other ​

POST /$/notify/testActually sends a notification. Body: severity (info/warning/critical, default warning), title, body.
GET /$/ui, GET /$/ui/*The web UI bundle.

FUSE mount ​

Mounted at /run/strubs/data (hardcoded), started alongside the HTTP server.

It is read-only. Writes return EROFS; create, unlink, mkdir, rename, truncate, chmod and friends are not implemented. Supported: getattr, readdir, open (read-only), read, release.

The path namespace is the same as the object API, so:

bash
ls  /run/strubs/data/photos/2024/
cp  /run/strubs/data/photos/2024/cat.jpg .
cat /run/strubs/data/\$65f0a1b2c3d4e5f60718293a     # by id

This is the practical way to list a container, since the HTTP API has no enumeration endpoint. It also means every ordinary tool — rsync, find, a media server — can read from STRUBS without knowing anything about it.

Timestamps are derived from the object id's embedded creation time. Access control is whatever the kernel enforces on the mount point; the FUSE layer checks nothing itself.

AGPL-3.0-only. In production since 2017.