API Access Control
Every REST endpoint on the metadata, admin, and free-tier servers is reachable by default from any host that can open a connection to it. Tokens decide who a caller is; an access file decides where a caller may call from. The two are independent layers — a request has to pass the whitelist before the handler ever looks at its Authorization header.
Access rules live in one small TOML file per server, named by --accessFile. The server re-reads it every two seconds, so tightening or widening access takes effect within seconds and never needs a restart.
| Server | Default --accessFile |
|---|---|
meta.flexfs | ~/.flexfs/meta/access |
admin.flexfs | ~/.flexfs/admin/access |
free.flexfs | ~/.flexfs/free/access |
With no access file, every endpoint is unrestricted. That is the default and it is what these servers did before the feature existed; nothing creates the file for you. The file only ever takes access away.
The file
Section titled “The file”# Applies to every endpoint that has no rule of its own.allow = ["10.0.0.0/8", "192.168.4.7", "127.0.0.1", "::1"]
# One endpoint, keyed by the path as the server declares it.[endpoint."/locks"]allow = ["10.1.0.0/24"]
# One endpoint and method. "METHOD path" beats "path".[endpoint."DELETE /locks"]allow = ["10.1.2.3"]
# Reopen an endpoint the global rule would have closed.[endpoint."/status"]allow = ["*"]
# Close one to everybody.[endpoint."POST /backup"]allow = ["none"]An entry in an allow list is a bare IP address, a CIDR block, "*" (any source) or "none" (no source). "*" and "none" must each be the only entry in their list.
Rules are decided most-specific-first:
"METHOD /path"— one method on one endpoint."/path"— every method on one endpoint.- a prefix key, longest first — the subtree beneath it, in either form (
"METHOD /path/"before"/path/"). See Endpoint keys. - the top-level
allow— every endpoint that has no rule of its own. - no rule anywhere — allowed.
A blocked request is answered 403 before it reaches the handler, with the same body shape that server uses for every other refusal: plain text on meta.flexfs, {"code":403,"text":"forbidden"} on admin.flexfs and free.flexfs.
Endpoint keys
Section titled “Endpoint keys”An endpoint key is the path as the router declares it, which means the bare path with no version slug and with path variables spelled literally:
[endpoint."/configure/{table}/{id}"]allow = ["10.9.9.9"]One rule governs every spelling of an endpoint — each version slug and the unversioned form — so a caller cannot sidestep it by changing or dropping the slug. Writing the slugged form ([endpoint."/v1/locks"]) is an error, and the message tells you the bare form to use instead.
A key ending in a slash is a prefix: it governs everything beneath it, including endpoints registered separately in front of it. On the admin and free-tier servers this is how the deploy tree is served, so one rule covers all of it:
# Covers /deploy/install-mount.sh and every binary under /deploy/.[endpoint."/deploy/"]allow = ["10.0.0.0/8"]An exact key still beats the prefix it sits under, so the installer can be treated differently from the rest of the tree. "/" is not a prefix — it is the metadata server’s WebSocket root, an exact path, and a rule on it does not become a rule on everything.
A key naming an endpoint the server does not serve is refused rather than ignored: at startup the server will not start, and on a reload the rules already in force stay in force. A typo that was quietly accepted would leave the endpoint it was meant to restrict governed by the global rule, or by nothing at all — a silent failure that is easy to miss.
What the global rule does not cover
Section titled “What the global rule does not cover”A few endpoints are reached only by an explicit rule naming them, never by the top-level allow:
| Server | Not covered by the global rule | Why |
|---|---|---|
meta.flexfs | / | The WebSocket root is the mount data path, not part of the REST API. A rule aimed at /backup must not disconnect every mounted client. |
admin.flexfs, free.flexfs | /deploy/ | The install URLs published for mount clients, the whole tree. The hosts fetching them are, by definition, hosts not yet in anybody’s whitelist. |
Nothing else is exempt. /metrics in particular is not: it reports volume names and ids alongside file and byte counts, so a global rule written to lock a server down covers it too. Whitelist the Prometheus host along with everything else, or open it back up with [endpoint."/metrics"]. A scrape that starts failing is loud and immediate; metrics served to anyone who can connect are silent.
An exempt path can still be gated deliberately, by naming it:
allow = ["10.0.0.0/8"]
# Only this subnet may open new mounts.[endpoint."/"]allow = ["10.4.0.0/16"]Editing the file
Section titled “Editing the file”Rules take effect within a few seconds of the file changing. Nothing needs to be signaled or restarted.
A change is applied once the file has stopped changing — the server reads it twice, one interval apart, and enforces it only when the two reads agree. That is what keeps a half-written file from being enforced: editing in place is not atomic, and the fragment left mid-write is the global list without the endpoint sections that were about to narrow it, which would briefly open the very endpoints the edit was closing.
- To lift all restrictions, delete the file. Emptying it in place does not: an empty file is read as a write in progress, because editing in place truncates before it writes and a zero-byte read taken at face value would open the server for as long as it took the next poll to notice. A server starting on an empty file refuses to start, for the same reason — a restart inside that window must not come up unfiltered.
- A file that cannot be parsed changes nothing. The rules already in force stay in force and the reason is logged, quoting the offending line. Access control never fails open on a bad edit.
- A file that cannot be read — wrong permissions, a path that is a directory, or anything larger than 64 KiB — is an error, not an absence. The server refuses to start on one, and a running server keeps its current rules. The size cap is there so that
--accessFilepointed by mistake at a database or a log is refused rather than re-read from disk every couple of seconds. - Protect the file. Anyone who can write it can open the server;
0600and owned by the user the server runs as. A group- or world-writable access file is loaded, with a warning.
Whether the file has been read is visible on each server’s /status, which needs no token and carries no version slug, so the same request works against any of them:
curl -s https://meta.example.com/status | jq .access{ "state": "active", "endpointRules": 2, "loadedAt": "2026-08-26T11:20:29Z"}state is unrestricted (no file, or a file with no rules), active (rules in force), or stale (the file on disk is not what is being enforced — it is unparsable, unreadable, or empty). A stale state clears itself once the file agrees with what is loaded again, including when a save writes back identical content. The reason for a stale state is in the log rather than the response, since a parse error quotes the file and the file is a list of addresses.
What the log tells you
Section titled “What the log tells you”Every load says what it did. Given this file:
allow = ["10.0.0.0/8"]
[endpoint."DELETE /locks"]allow = ["10.1.2.3"]meta.flexfs logs:
Access rules loaded from "/root/.flexfs/meta/access": global 10.0.0.0/8, 1 endpoint rule(s) access | DELETE /locks 10.1.2.3 access | global rule gates: /analyze/files, /analyze/folders, /analyze/users, /backup, /compact, /duplicates, /find, /find-old, /locks, /metrics, ... access | not gated by the global rule: / (add an [endpoint."..."] section to gate one)The global rule gates: line is the one to read: it names every endpoint the save just put behind the global list. On the admin or free-tier server it is where you find out that a rule aimed at /configure also gated the endpoints your mount clients and metadata server call.
Addresses are logged in canonical form, so a mistyped prefix is visible — 192.168.4.7/24 is reported as 192.168.4.0/24, because that is what it means.
A refused request logs once per source, method and endpoint every ten seconds:
Denied "DELETE" "/locks" from "203.0.113.9" (access rules)Denied "GET" "/nope-17" from "203.0.113.9" (access rules; 49 further denial(s) not logged)Requests to paths the server does not serve share a single entry per source and method, so a scanner walking URLs produces one line rather than one per request; the line still names the path that was sent. Anything dropped by the throttle is accounted for on the next line that is written.
Denials are logged whether or not the server was started with --verbose, and the 403 is counted in the metadata server’s flexfs_meta_rest_ops_total{status="403"}.
Addresses
Section titled “Addresses”- List both loopback forms where loopback matters.
curl https://localhost/...arrives as127.0.0.1or as::1, depending on how the name resolved. - IPv4 and IPv6 match where they mean the same host. A
10.0.0.0/8rule admits a client arriving as::ffff:10.1.2.3. - A CIDR is masked to its network.
192.168.4.7/24and192.168.4.0/24are the same rule, and the log reports the masked form. - An IPv4-in-IPv6 CIDR must be
/96or longer to name IPv4 addresses.::ffff:10.0.0.0/8is refused, because masking it discards the mapping that made it IPv4 and it would then match nothing; write10.0.0.0/8, or::ffff:10.0.0.0/104.
Next steps
Section titled “Next steps”- Authentication — the token layer this sits underneath
- TLS Certificates — securing the transport
- Access Control — POSIX permissions, squashing and ACLs inside the filesystem