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:
+37
-28
@@ -7,13 +7,13 @@ use crate::cli::{
|
||||
};
|
||||
use crate::commands::Ctx;
|
||||
use crate::output::{CliError, CliResult, Exit, Out};
|
||||
use crate::ui::launch_main_tui;
|
||||
use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress};
|
||||
use std::io::{BufRead, IsTerminal};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{env, io};
|
||||
use crate::ui::launch_main_tui;
|
||||
|
||||
pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
let config_path = config_path(&cli);
|
||||
@@ -25,6 +25,15 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
let resolved = crate::persist::config::resolve(&cli, &file_config);
|
||||
|
||||
let Some(command) = cli.command.take() else {
|
||||
// Without a terminal the TUI would panic on its first draw. A script
|
||||
// that reached this point wanted a subcommand, so say that instead of
|
||||
// crashing.
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"the interactive interface needs a terminal; run a subcommand \
|
||||
for scripted use (see --help)",
|
||||
));
|
||||
}
|
||||
// A running daemon already holds a session; the TUI attaches to it
|
||||
// rather than logging in a second time, which the server would answer
|
||||
// by cutting the daemon off.
|
||||
@@ -62,7 +71,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
return crate::commands::settings::config_get(out, &store, key);
|
||||
}
|
||||
Commands::Config(ConfigCommand::Set { ref key, ref value }) => {
|
||||
return crate::commands::settings::config_set(out, &store, key, value);
|
||||
return crate::commands::settings::config_set(
|
||||
out, &store, key, value,
|
||||
);
|
||||
}
|
||||
Commands::Shares(SharesCommand::List) => {
|
||||
return crate::commands::settings::shares_list(
|
||||
@@ -72,10 +83,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
);
|
||||
}
|
||||
Commands::Shares(SharesCommand::Add { ref directory }) => {
|
||||
return crate::commands::settings::shares_add(out, &store, directory);
|
||||
return crate::commands::settings::shares_add(
|
||||
out, &store, directory,
|
||||
);
|
||||
}
|
||||
Commands::Shares(SharesCommand::Remove { ref directory }) => {
|
||||
return crate::commands::settings::shares_remove(out, &store, directory);
|
||||
return crate::commands::settings::shares_remove(
|
||||
out, &store, directory,
|
||||
);
|
||||
}
|
||||
Commands::Wish(WishCommand::Add { ref query }) => {
|
||||
return crate::commands::wish::add(out, &store, query);
|
||||
@@ -97,9 +112,11 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
let token = cli.daemon_token.clone();
|
||||
return match command {
|
||||
DaemonCommand::Token => crate::commands::daemon::token(out),
|
||||
DaemonCommand::Status => {
|
||||
crate::commands::daemon::status(out, endpoint.as_ref(), token)
|
||||
}
|
||||
DaemonCommand::Status => crate::commands::daemon::status(
|
||||
out,
|
||||
endpoint.as_ref(),
|
||||
token,
|
||||
),
|
||||
DaemonCommand::Stop => {
|
||||
crate::commands::daemon::stop(out, endpoint.as_ref(), token)
|
||||
}
|
||||
@@ -125,7 +142,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
||||
Commands::Serve(ref args) => {
|
||||
let sweeper = args
|
||||
.wishlist
|
||||
.then(|| crate::commands::wish::Sweeper::new(store.config.wishes()))
|
||||
.then(|| {
|
||||
crate::commands::wish::Sweeper::new(store.config.wishes())
|
||||
})
|
||||
.flatten();
|
||||
if args.wishlist && sweeper.is_none() {
|
||||
out.warn(
|
||||
@@ -145,7 +164,9 @@ fn config_path(cli: &Cli) -> Option<PathBuf> {
|
||||
if cli.no_config {
|
||||
return None;
|
||||
}
|
||||
cli.config.clone().or_else(crate::persist::paths::config_file)
|
||||
cli.config
|
||||
.clone()
|
||||
.or_else(crate::persist::paths::config_file)
|
||||
}
|
||||
|
||||
/// Assemble everything a one-shot command needs, demanding the credentials
|
||||
@@ -338,15 +359,6 @@ fn run_default_tui(
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
// Without a terminal the TUI would panic on its first draw. A script that
|
||||
// reached this point wanted a subcommand, so say that instead of crashing.
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"the interactive interface needs a terminal; run a subcommand for \
|
||||
scripted use (see --help)",
|
||||
));
|
||||
}
|
||||
|
||||
let out = Out::new(false, false);
|
||||
let (server_host, server_port) =
|
||||
parse_server_address(&resolved.server).map_err(CliError::usage)?;
|
||||
@@ -418,8 +430,8 @@ fn run_default_tui(
|
||||
.listen_port()
|
||||
.map(crate::port_mapping::PortMapper::spawn);
|
||||
|
||||
let store =
|
||||
crate::persist::paths::state_dir().map(crate::persist::state::StateStore::new);
|
||||
let store = crate::persist::paths::state_dir()
|
||||
.map(crate::persist::state::StateStore::new);
|
||||
|
||||
launch_main_tui(
|
||||
terminal,
|
||||
@@ -448,19 +460,16 @@ fn run_attached_tui(
|
||||
terminal::{Clear, ClearType},
|
||||
};
|
||||
|
||||
if !std::io::stdout().is_terminal() {
|
||||
return Err(CliError::usage(
|
||||
"the interactive interface needs a terminal; run a subcommand for \
|
||||
scripted use (see --help)",
|
||||
));
|
||||
}
|
||||
|
||||
let token = cli
|
||||
.daemon_token
|
||||
.clone()
|
||||
.or_else(crate::daemon::stored_token);
|
||||
let session = crate::remote::RemoteSession::connect(endpoint, token)
|
||||
.map_err(|e| CliError::connection(format!("cannot use the daemon at {endpoint}: {e}")))?;
|
||||
.map_err(|e| {
|
||||
CliError::connection(format!(
|
||||
"cannot use the daemon at {endpoint}: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
soulseek_rs::utils::logger::enable_buffering();
|
||||
let _ =
|
||||
|
||||
Reference in New Issue
Block a user