fieldmesh

server/ — the reference implementation#

A read-only SensorThings 1.1 HTTP service over derived state (spec §10). AGPL-3.0-or-later — see LICENSING.md for why this directory carries a different license from sdk/.

make serve            # two simulated days, on this machine's :8080, no token needed
DAYS=14 make serve
curl -s 'http://localhost:8080/v1.1/Datastreams?$top=2'

# A real farm's log, with tokens:
python3 server/access.py issue --log farm.jsonl --role viewer
python3 server/access.py issue --log farm.jsonl --grantee "state university soil lab"
python3 server/serve.py --log farm.jsonl                       # 127.0.0.1, behind a proxy
python3 server/serve.py --log farm.jsonl --host 0.0.0.0 --tls-cert c.pem --tls-key k.pem
curl -s -H "Authorization: Bearer $TOKEN" 'https://farm.example:8080/v1.1/Datastreams'
file what it is
odata.py the $filter parser and evaluator, plus the other query options. No HTTP.
app.py routing and query application. Takes a method, a path and a query string; returns a status, headers and a body. No socket.
access.py who may ask: tokens for farm users and grantees, issued and revoked as log records (only their hashes), and a rate limiter
serve.py the socket, and the decisions a socket brings: authentication, TLS, a thread per connection with a timeout, a cap on concurrent work, rate limits, an access log. Standard library only

Three decisions worth knowing before reading the code#

An unsupported query option is an error, never silence. A client that sends $filter=result gt 0.4 and receives every reading has been told something false — it cannot distinguish "no filtering happened" from "nothing matched". Anything the evaluator cannot handle is a 400 (malformed) or a 501 (valid OData we do not implement), with the offending text quoted back in the error's target. $top beyond the cap is refused rather than clamped, for the same reason.

It is read-only on purpose, not pending. §7.1 makes the append-only log the write path, so a POST here would write to a projection and be discarded the next time state was rebuilt. Mutating methods are 405 with Allow: GET, HEAD — the honest answer rather than 501, which would imply it is coming.

Every link it emits must resolve. test_app.py::TestEverySelfLinkResolves walks every entity set, collects every @iot.selfLink and @iot.navigationLink, and fetches each one. A service that advertises a link it cannot answer is worse than one that advertises none, because the client only finds out after following it. That test exists because a real client found two such links; see notes/qgis-validation.md.

What it does not do#

$apply, lambda operators, geospatial filter functions, arithmetic in $filter, $ref association links, MQTT, and anything that writes. Each refuses by name rather than by silence, and serverSettings.conformance at the service root claims only the nine data-model requirements actually met. $metadata answers 501; it is an OData document that SensorThings 1.1 does not require. HistoricalLocations is served and always empty: Things here do not move, and devices that do are §4.1 Installations, which 1.1 has no entity for.

Who may ask, and what a client can do to the others#

Every request carries a bearer token standing for a farm user (any §8.2 role reads everything; the service writes nothing) or a sharing grantee (§7.6). A grantee is answered from sharing.view — the granted series, the readings their grant covers, the quality verdicts about those, and the names behind them — so no path, by id, by navigation link or by $filter, reaches anything else; what is not shared is a 404, which does not say that it exists. A revoked grant leaves the grantee's token working and their view empty; a revoked or expired token is a 401. Tokens are log records holding only a hash, and the server follows the log file, so one issued or revoked from another process counts within seconds. --open serves without tokens, for the simulator and a GIS client on the same machine, and only on loopback.

Nothing leaves the machine in the clear: binding anything but loopback needs a certificate (TLS 1.2 or later), and behind a reverse proxy it binds loopback.

One client cannot stall the others. A thread per connection with a timeout, so a client that never finishes its request is dropped after twenty seconds while sixteen others are answered; TLS negotiated in that thread, not the accept loop; at most sixteen requests worked at once, the rest told 503 with Retry-After; and each principal held to ten requests a second with bursts of forty, then 429. Requests read the state under the lock whatever folds new records into it holds, so nobody sees a half-folded state, and each request carries its own language and view, so two at once never cross.

Every answer says nosniff, no-store, no-referrer, a deny-all content policy, and Server: fieldmesh/0 without the Python version. The access log is one line per request — address, who, method, path, status, size — and never a token.

deploy/systemd/fieldmesh-server.service runs it sandboxed. It has not faced the internet yet; tested on loopback with real sockets (test_serve.py), not by anyone trying to break it.