fix(daemon): correct the published contract and harden the control socket

A review pass over the diff found seventeen confirmed defects. The worst was
the contract itself: every method's parameters were published as a single
descriptor named `params`, so a client generated from openrpc.json sent
{"params":{"params":{…}}} and every call failed -32602. Parameters are now
published per field, with a test asserting the published names are the keys
the daemon actually deserializes.

Security and lifecycle:
- download.start no longer lets a caller name a directory, which was an
  arbitrary write on the daemon's host.
- The Unix socket is bound under a temporary name and renamed into place; it
  was briefly connectable by any local user between bind and chmod, and on
  that transport connecting is authenticating.
- Pre-auth reads are capped at 1 MiB with a 30s handshake timeout, and
  connections are capped, so an unauthenticated peer cannot exhaust memory or
  threads.
- A read error no longer returns past the hub unsubscribe, which leaked a
  subscriber and two threads per occurrence.
- A client whose event queue fills is now actually disconnected. It used to be
  dropped from the fan-out and left connected, working on but blind to every
  event after — the documentation already promised otherwise.
- A client whose daemon vanishes mid-transfer is told at once instead of
  waiting out its full timeout and reporting a misleading "gave up".

Contract accuracy: errors carry an explicit null id (a missing one reads as a
notification and gets dropped), a second auth answers with the published
shape, and nullable fields are sent rather than omitted so a generated client
sees one shape.

Also splits proto.rs and server.rs, whose concerns had diverged, and applies
the review's simplifications.
This commit is contained in:
Michel de Graaf
2026-07-27 11:38:46 +02:00
parent a69f52c657
commit 41307d0510
22 changed files with 2528 additions and 1942 deletions
+29 -9
View File
@@ -13,8 +13,13 @@ events behave.
## Framing
Newline-delimited JSON-RPC 2.0. One JSON value per line, in both directions,
UTF-8, no length prefix. A line that does not parse gets a `-32700` reply and
the connection stays open.
UTF-8, no length prefix. A line that does not parse gets a `-32700` reply; on
an established connection it stays open, but the *first* line has to be a valid
`auth` request, so a parse error there ends the connection.
Lines are capped at 1 MiB and a connection that has not authenticated within
30 seconds is closed. The daemon serves at most 64 control connections at
once.
## Transports and authentication
@@ -57,9 +62,14 @@ the old shape.
## Requests
Parameters are by name — a JSON object, never an array. Every method and its
parameter and result schemas are in `openrpc.json`; `rpc.discover` returns
that same document from the running daemon.
Parameters are by name — a JSON object, never an array. Each method's fields
are published as individual OpenRPC content descriptors, so a generated client
sends exactly the object the daemon deserializes. `rpc.discover` returns that
same document from the running daemon.
An event is the exception: its `params` object *is* the payload, published as
a single `payload` descriptor because several payloads are tagged unions with
no fixed field set.
Requests may be pipelined and are answered out of order, so match replies by
`id`. A request without an `id` is a notification and is answered with
@@ -71,6 +81,10 @@ silence, including when it fails.
{"jsonrpc":"2.0","id":4,"error":{"code":-32000,"message":"no results","data":{"exit":4}}}
```
An error always carries an `id`, explicitly `null` when the request was too
malformed to have one — so do not read a missing `id` as "this is a
notification" and drop it.
Standard codes (`-32700` parse, `-32600` invalid request, `-32601` unknown
method, `-32602` bad parameters) mean what they always mean. Application
failures use `-32000` and carry `data.exit`: the CLI exit status the same
@@ -100,6 +114,12 @@ where they appear as entries marked `x-notification` — OpenRPC has no
first-class notion of a server-pushed message, so that flag is how they are
distinguished from methods you may call.
`message.history` and `event.message` describe the same conversation in
different shapes, deliberately: a live event is the message as the server
delivered it (`id`, `timestamp`, `username`, `message`), while history is a
chat log and carries a direction (`peer`, `outgoing`, `text`, `at`) because it
includes what this account sent.
**Events are the only way to observe these things, and they are not
replayable.** The underlying library hands over its event buffer and empties
it, so the daemon drains once and copies to every attached connection. A
@@ -118,10 +138,10 @@ matched on `username` + `filename`.
## Behaviour worth knowing
**Files land on the daemon's filesystem.** `download_directory` is the
daemon's configuration; a remote client cannot see that filesystem and does
not choose. `daemon.status` reports the directory in use. There is no method
to fetch the bytes over the control socket.
**Files land on the daemon's filesystem.** The directory is the daemon's
configuration and a client cannot name one — that would be an arbitrary write
on the daemon's host. `daemon.status` reports the directory in use. There is
no method to fetch the bytes over the control socket.
**Several clients, no locking.** Last write wins. Two clients cancelling the
same transfer is two hands on one keyboard, not an error.
+172 -60
View File
@@ -115,6 +115,7 @@
"type": "string"
},
"listen_port": {
"default": null,
"format": "uint16",
"maximum": 65535,
"minimum": 0,
@@ -139,7 +140,8 @@
{
"type": "null"
}
]
],
"default": null
},
"shared_files": {
"format": "uint32",
@@ -197,9 +199,15 @@
},
"metadata": {
"$ref": "#/components/schemas/DownloadMetadataDto",
"default": {}
"default": {
"bitrate": null,
"length_seconds": null,
"peer_free_slots": null,
"peer_upload_speed": null
}
},
"queue_position": {
"default": null,
"format": "uint32",
"minimum": 0,
"type": [
@@ -237,6 +245,7 @@
"DownloadMetadataDto": {
"properties": {
"bitrate": {
"default": null,
"format": "uint32",
"minimum": 0,
"type": [
@@ -245,6 +254,7 @@
]
},
"length_seconds": {
"default": null,
"format": "uint32",
"minimum": 0,
"type": [
@@ -253,6 +263,7 @@
]
},
"peer_free_slots": {
"default": null,
"format": "uint8",
"maximum": 255,
"minimum": 0,
@@ -262,6 +273,7 @@
]
},
"peer_upload_speed": {
"default": null,
"format": "uint32",
"minimum": 0,
"type": [
@@ -274,19 +286,17 @@
},
"DownloadStartParams": {
"properties": {
"download_directory": {
"description": "Where the file lands *on the daemon's* filesystem. Defaults to the\ndaemon's configured download directory, which is the only sensible\nchoice for a remote caller that cannot see that filesystem.",
"type": [
"string",
"null"
]
},
"filename": {
"type": "string"
},
"metadata": {
"$ref": "#/components/schemas/DownloadMetadataDto",
"default": {}
"default": {
"bitrate": null,
"length_seconds": null,
"peer_free_slots": null,
"peer_upload_speed": null
}
},
"size": {
"default": 0,
@@ -780,6 +790,7 @@
"Seconds": {
"properties": {
"seconds": {
"default": null,
"format": "uint32",
"minimum": 0,
"type": [
@@ -1031,7 +1042,8 @@
{
"type": "null"
}
]
],
"default": null
},
"stats": {
"anyOf": [
@@ -1041,7 +1053,8 @@
{
"type": "null"
}
]
],
"default": null
},
"username": {
"type": "string"
@@ -1120,7 +1133,8 @@
{
"type": "null"
}
]
],
"default": null
}
},
"type": "object"
@@ -1166,10 +1180,24 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"required": true,
"name": "protocol",
"required": false,
"schema": {
"$ref": "#/components/schemas/AuthParams"
"default": 0,
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
{
"name": "token",
"required": false,
"schema": {
"description": "Required on TCP. Omitted on a Unix socket, where the socket's own\npermissions already prove the caller is this user.",
"type": [
"string",
"null"
]
}
}
],
@@ -1222,10 +1250,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "query",
"required": true,
"schema": {
"$ref": "#/components/schemas/QueryParams"
"type": "string"
}
}
],
@@ -1242,10 +1270,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "query",
"required": true,
"schema": {
"$ref": "#/components/schemas/QueryParams"
"type": "string"
}
}
],
@@ -1262,10 +1290,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "query",
"required": true,
"schema": {
"$ref": "#/components/schemas/QueryParams"
"type": "string"
}
}
],
@@ -1294,10 +1322,40 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "filename",
"required": true,
"schema": {
"$ref": "#/components/schemas/DownloadStartParams"
"type": "string"
}
},
{
"name": "metadata",
"required": false,
"schema": {
"$ref": "#/components/schemas/DownloadMetadataDto",
"default": {
"bitrate": null,
"length_seconds": null,
"peer_free_slots": null,
"peer_upload_speed": null
}
}
},
{
"name": "size",
"required": false,
"schema": {
"default": 0,
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1326,10 +1384,17 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "filename",
"required": true,
"schema": {
"$ref": "#/components/schemas/TransferRef"
"type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1346,10 +1411,17 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "filename",
"required": true,
"schema": {
"$ref": "#/components/schemas/TransferRef"
"type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1366,10 +1438,17 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "filename",
"required": true,
"schema": {
"$ref": "#/components/schemas/TransferRef"
"type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1386,10 +1465,17 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "filename",
"required": true,
"schema": {
"$ref": "#/components/schemas/TransferRef"
"type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1418,10 +1504,17 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "filename",
"required": true,
"schema": {
"$ref": "#/components/schemas/TransferRef"
"type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1438,10 +1531,12 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "slots",
"required": true,
"schema": {
"$ref": "#/components/schemas/SlotsParams"
"format": "uint",
"minimum": 0,
"type": "integer"
}
}
],
@@ -1494,10 +1589,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "room",
"required": true,
"schema": {
"$ref": "#/components/schemas/RoomRef"
"type": "string"
}
}
],
@@ -1514,10 +1609,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "room",
"required": true,
"schema": {
"$ref": "#/components/schemas/RoomRef"
"type": "string"
}
}
],
@@ -1534,10 +1629,17 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "message",
"required": true,
"schema": {
"$ref": "#/components/schemas/SayParams"
"type": "string"
}
},
{
"name": "room",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1554,10 +1656,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "room",
"required": true,
"schema": {
"$ref": "#/components/schemas/RoomRef"
"type": "string"
}
}
],
@@ -1574,10 +1676,17 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "message",
"required": true,
"schema": {
"$ref": "#/components/schemas/MessageParams"
"type": "string"
}
},
{
"name": "username",
"required": true,
"schema": {
"type": "string"
}
}
],
@@ -1606,10 +1715,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "username",
"required": true,
"schema": {
"$ref": "#/components/schemas/UserRef"
"type": "string"
}
}
],
@@ -1626,10 +1735,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "username",
"required": true,
"schema": {
"$ref": "#/components/schemas/UserRef"
"type": "string"
}
}
],
@@ -1646,10 +1755,10 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "username",
"required": true,
"schema": {
"$ref": "#/components/schemas/UserRef"
"type": "string"
}
}
],
@@ -1678,10 +1787,13 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "directories",
"required": true,
"schema": {
"$ref": "#/components/schemas/DirectoriesParams"
"items": {
"type": "string"
},
"type": "array"
}
}
],
@@ -1710,7 +1822,7 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "payload",
"required": true,
"schema": {
"$ref": "#/components/schemas/RoomEventDto"
@@ -1731,7 +1843,7 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "payload",
"required": true,
"schema": {
"$ref": "#/components/schemas/UserMessageDto"
@@ -1752,7 +1864,7 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "payload",
"required": true,
"schema": {
"$ref": "#/components/schemas/UploadInfoDto"
@@ -1773,7 +1885,7 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "payload",
"required": true,
"schema": {
"$ref": "#/components/schemas/DownloadStatusEvent"
@@ -1794,7 +1906,7 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "payload",
"required": true,
"schema": {
"$ref": "#/components/schemas/BrowseEvent"
@@ -1815,7 +1927,7 @@
"paramStructure": "by-name",
"params": [
{
"name": "params",
"name": "payload",
"required": true,
"schema": {
"$ref": "#/components/schemas/SessionLossEvent"