docs/openrpc.json lived at the repository root, outside the soulseek-rs package, so the include_str! in proto compiled from a checkout but not from the published tarball - cargo publish failed verification and the v13.0.0 release stopped after publishing the lib. The document moves into the crate's own docs/, where packaging can see it; the generator test and every link follow.
178 lines
7.6 KiB
Markdown
178 lines
7.6 KiB
Markdown
# The daemon control protocol
|
|
|
|
`soulseek-rs daemon` stays logged in and answers JSON-RPC over a socket, so
|
|
several clients can share one Soulseek session. The machine-readable contract
|
|
is [`openrpc.json`](../soulseek-rs/docs/openrpc.json), an [OpenRPC](https://open-rpc.org)
|
|
document and the JSON-RPC counterpart to OpenAPI. Point a client generator at it,
|
|
or at a running daemon via `rpc.discover`, and you have a client in your
|
|
language.
|
|
|
|
This page covers what a schema cannot: framing, the handshake, and how pushed
|
|
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; 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
|
|
|
|
| Transport | Address | Authentication |
|
|
|---|---|---|
|
|
| Unix socket | `<state-dir>/daemon.sock`, mode 0600 | The socket's permissions. `token` may be omitted. |
|
|
| TCP | opt-in via `daemon --bind ADDR` | The token in `<state-dir>/daemon.token`, mode 0600. |
|
|
|
|
The local socket is the Docker and ssh-agent bargain: if the operating system
|
|
let you open it, you are the user who started the daemon, and there is nothing
|
|
further to prove. A local client, whether a script or an agent, works with no
|
|
configuration at all.
|
|
|
|
TCP always requires the token, and a token that *is* offered is always
|
|
checked, on either transport, so a wrong one never passes. Failed attempts
|
|
sleep one second. The token is 256 bits from the OS, generated on first start
|
|
and reused across restarts so existing clients keep working;
|
|
`soulseek-rs daemon token` prints it.
|
|
|
|
There is no transport encryption. Binding non-loopback TCP means putting SSH
|
|
or a TLS-terminating proxy in front of it.
|
|
|
|
`<state-dir>` is `$SOULSEEK_STATE_DIR`, else the platform data directory
|
|
(`~/.local/share/soulseek-rs/state` on Linux and macOS).
|
|
|
|
### The handshake
|
|
|
|
`auth` must be the first request on a connection. Anything else is answered
|
|
`-32600` and the connection is dropped. Until it succeeds the connection is
|
|
told nothing and receives no events.
|
|
|
|
```json
|
|
--> {"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}}
|
|
<-- {"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}}
|
|
```
|
|
|
|
Compare `protocol` with your own and fail loudly on a mismatch rather than
|
|
guessing; it is bumped whenever a change would break a client written against
|
|
the old shape.
|
|
|
|
## Requests
|
|
|
|
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
|
|
silence, including when it fails.
|
|
|
|
## Errors
|
|
|
|
```json
|
|
{"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. 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
|
|
operation would have produced locally, so a wrapper can branch on it exactly
|
|
as it branches on the exit code.
|
|
|
|
| `exit` | Meaning |
|
|
|---|---|
|
|
| 2 | Usage or configuration |
|
|
| 3 | Could not reach the server, or the login was rejected |
|
|
| 4 | Succeeded but produced nothing |
|
|
| 5 | Timed out |
|
|
| 6 | A transfer started but did not complete |
|
|
| 7 | The server session ended mid-operation |
|
|
|
|
## Events
|
|
|
|
The daemon pushes notifications. They carry no `id`, so never answer them:
|
|
|
|
```json
|
|
{"jsonrpc":"2.0","method":"event.room","params":{"event":"message","room":"lobby","username":"bob","message":"hi"}}
|
|
```
|
|
|
|
`event.room`, `event.message`, `event.upload`, `event.download_status`,
|
|
`event.browse`, `event.session_loss`. Payload schemas are in `openrpc.json`,
|
|
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
|
|
client that is not connected when something happens does not learn about it
|
|
later, with one exception: private messages are also kept, and
|
|
`message.history` returns the conversation including what arrived while
|
|
nobody was attached.
|
|
|
|
Every connection gets its own queue, bounded at 1024 events. **A client that
|
|
stops reading its socket is disconnected** once that queue fills. The daemon
|
|
will not let one stalled consumer cost the others their events. Read
|
|
continuously; do not treat the socket as request/response only.
|
|
|
|
A `download.start` you issued reports progress through `event.download_status`
|
|
matched on `username` + `filename`.
|
|
|
|
## Behaviour worth knowing
|
|
|
|
**Files land on the daemon's filesystem.** The directory is the daemon's
|
|
configuration and a client cannot name one, which 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.
|
|
|
|
**Configuration is per machine, shares are per session.** A client's config
|
|
file is its own; changing it does not reach a daemon. The exception is
|
|
`shares.set`, which the CLI's `shares add`/`remove` call so a folder is served
|
|
without waiting for a restart.
|
|
|
|
**Several clients, no locking.** Last write wins. Two clients cancelling the
|
|
same transfer is two hands on one keyboard, not an error.
|
|
|
|
**The daemon owns durable state.** Transfers, joined rooms, and the
|
|
conversation survive a restart: unfinished transfers are re-queued and rooms
|
|
rejoined on startup.
|
|
|
|
**Searching is two steps.** `search.start` puts the query on the wire and
|
|
returns immediately; peers answer over their own connections for as long as
|
|
you care to wait. Decide the window yourself, then read `search.results`. This
|
|
is deliberate: a client's search window must not be time the daemon spends not
|
|
answering everybody else.
|
|
|
|
## A session by hand
|
|
|
|
```console
|
|
$ nc -U ~/.local/share/soulseek-rs/state/daemon.sock
|
|
{"jsonrpc":"2.0","id":1,"method":"auth","params":{"protocol":1}}
|
|
{"jsonrpc":"2.0","id":1,"result":{"protocol":1,"daemon_version":"13.0.0","username":"you"}}
|
|
{"jsonrpc":"2.0","id":2,"method":"search.start","params":{"query":"aphex twin"}}
|
|
{"jsonrpc":"2.0","id":2,"result":{"ok":true}}
|
|
{"jsonrpc":"2.0","id":3,"method":"search.results","params":{"query":"aphex twin"}}
|
|
{"jsonrpc":"2.0","id":3,"result":{"results":[…]}}
|
|
```
|
|
|
|
That the whole protocol is reachable from `nc` and a text editor is the reason
|
|
it is shaped this way.
|