Closes #54. A download can now be cancelled whether it is queued, in progress, or paused. Cancelled is a terminal status: the store refuses to overwrite it, the status channel relays nothing after it, the partial file is deleted, and the transfer thread releases its socket at its next one-second poll instead of the 30s read deadline. A queued download that is cancelled declines the peer's later offer with TransferResponse "Cancelled", and an inbound file connection for it is refused. A fresh download of the same file evicts the dead row so it is not shadowed, and a cancelled transfer is not re-queued after a restart. Surfaces: Client::cancel_download, download.cancel over RPC, `transfer cancel <user> <path>` on the CLI (which also stops an upload by that name), and x in the transfers pane, where d dismisses the row.
595 lines
19 KiB
Rust
595 lines
19 KiB
Rust
//! The resident service: one logged-in session, controlled over a socket.
|
|
//!
|
|
//! Where a one-shot command logs in, does its job and exits, the daemon logs in
|
|
//! once and stays. That is what makes cross-invocation work possible — a queue
|
|
//! that survives the command that filled it, a chat window that was open while
|
|
//! nobody was watching — and what lets several clients share one session
|
|
//! instead of fighting the server's one-login-per-account rule.
|
|
//!
|
|
//! Two transports, two answers to "who is this":
|
|
//!
|
|
//! * A Unix socket at `<state-dir>/daemon.sock`, mode 0600. The socket's own
|
|
//! permissions are the authentication, the same bargain Docker and ssh-agent
|
|
//! make, so a local client — an agent especially — needs no flags and no
|
|
//! token.
|
|
//! * TCP, opt-in, always requiring the token in `<state-dir>/daemon.token`.
|
|
|
|
pub mod hub;
|
|
/// Generates `docs/openrpc.json` from the wire types and checks it is current.
|
|
/// Test-only: the published document is served at runtime by `include_str!`.
|
|
#[cfg(test)]
|
|
mod openrpc;
|
|
pub mod proto;
|
|
pub mod server;
|
|
|
|
use crate::api::SessionApi;
|
|
use crate::commands::{Ctx, Session};
|
|
use crate::output::{CliError, CliResult, Out};
|
|
use crate::persist::state::{PersistedDownload, PersistedMessage, StateStore};
|
|
use hub::{Hub, PendingBrowses, PendingDownload};
|
|
use proto::ChatMessageDto;
|
|
use server::Daemon;
|
|
use soulseek_rs::SessionLoss;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// The control socket, where a local client looks without being told to.
|
|
pub const SOCKET_NAME: &str = "daemon.sock";
|
|
/// The shared secret for TCP clients — the cookie-file convention, so a local
|
|
/// client can pick it up without the operator handling it at all.
|
|
pub const TOKEN_NAME: &str = "daemon.token";
|
|
|
|
/// How often daemon-owned state is written back. Frequent enough that a kill
|
|
/// loses little, rare enough not to churn the disk on a busy queue.
|
|
const SAVE_INTERVAL: Duration = Duration::from_secs(5);
|
|
|
|
const RECONNECT_BACKOFF_CAP: Duration = Duration::from_mins(1);
|
|
|
|
#[must_use]
|
|
pub fn socket_path() -> Option<PathBuf> {
|
|
crate::persist::paths::state_dir().map(|dir| dir.join(SOCKET_NAME))
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn token_path() -> Option<PathBuf> {
|
|
crate::persist::paths::state_dir().map(|dir| dir.join(TOKEN_NAME))
|
|
}
|
|
|
|
/// The token a local client should present, when there is one to find.
|
|
#[must_use]
|
|
pub fn stored_token() -> Option<String> {
|
|
let token = std::fs::read_to_string(token_path()?).ok()?;
|
|
let token = token.trim().to_string();
|
|
(!token.is_empty()).then_some(token)
|
|
}
|
|
|
|
/// Read the token, or mint one on first start.
|
|
///
|
|
/// Written 0600 and never printed to stdout, so `daemon` output stays usable in
|
|
/// a pipe while the secret is still one `daemon token` away.
|
|
pub fn load_or_create_token(path: &Path) -> std::io::Result<String> {
|
|
if let Some(existing) = std::fs::read_to_string(path)
|
|
.ok()
|
|
.map(|token| token.trim().to_string())
|
|
.filter(|token| !token.is_empty())
|
|
{
|
|
return Ok(existing);
|
|
}
|
|
|
|
let token = mint_token();
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::write(path, format!("{token}\n"))?;
|
|
restrict(path)?;
|
|
Ok(token)
|
|
}
|
|
|
|
/// 256 bits from the OS, hex-encoded. Long enough that the one-second penalty
|
|
/// on a wrong answer is the only rate limit the daemon needs.
|
|
fn mint_token() -> String {
|
|
use std::fmt::Write;
|
|
|
|
let mut bytes = [0u8; 32];
|
|
getrandom::fill(&mut bytes).expect("the OS always has randomness");
|
|
bytes
|
|
.iter()
|
|
.fold(String::with_capacity(64), |mut token, byte| {
|
|
let _ = write!(token, "{byte:02x}");
|
|
token
|
|
})
|
|
}
|
|
|
|
/// Make a secret readable by nobody else. A no-op where the platform has no
|
|
/// such notion, which is why TCP is opt-in rather than the default.
|
|
#[cfg(unix)]
|
|
fn restrict(path: &Path) -> std::io::Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
const fn restrict(_path: &Path) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether a daemon is listening on the local socket right now.
|
|
///
|
|
/// This is what makes daemon mode seamless: a client that finds a live socket
|
|
/// uses it, and one that does not behaves exactly as it always has. A socket
|
|
/// file left by a crash answers `None`, so a stale file cannot strand a client.
|
|
#[must_use]
|
|
pub fn local_daemon() -> Option<PathBuf> {
|
|
let path = socket_path()?;
|
|
#[cfg(unix)]
|
|
{
|
|
std::os::unix::net::UnixStream::connect(&path)
|
|
.ok()
|
|
.map(|_| path)
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = path;
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Run the daemon until it is asked to stop.
|
|
pub fn run(
|
|
ctx: &Ctx,
|
|
bind: Option<&str>,
|
|
upload_slots: Option<u16>,
|
|
) -> CliResult {
|
|
let state_dir = crate::persist::paths::state_dir().ok_or_else(|| {
|
|
CliError::usage("no state directory: set SOULSEEK_STATE_DIR")
|
|
})?;
|
|
std::fs::create_dir_all(&state_dir).map_err(|e| {
|
|
CliError::usage(format!(
|
|
"cannot use state directory {}: {e}",
|
|
state_dir.display()
|
|
))
|
|
})?;
|
|
|
|
let token = load_or_create_token(&state_dir.join(TOKEN_NAME))
|
|
.map_err(|e| CliError::usage(format!("cannot read the token: {e}")))?;
|
|
|
|
// Bind before logging in: a second daemon should fail in a millisecond,
|
|
// not after a full login handshake against the server.
|
|
let mut listeners = Vec::new();
|
|
#[cfg(unix)]
|
|
{
|
|
let path = state_dir.join(SOCKET_NAME);
|
|
listeners.push(server::bind_unix(&path).map_err(|e| {
|
|
CliError::usage(format!("cannot listen on {}: {e}", path.display()))
|
|
})?);
|
|
ctx.out.status(&format!("listening on {}", path.display()));
|
|
}
|
|
if let Some(address) = bind {
|
|
listeners.push(server::bind_tcp(address).map_err(|e| {
|
|
CliError::connection(format!("cannot listen on {address}: {e}"))
|
|
})?);
|
|
ctx.out.status(&format!(
|
|
"listening on {address} (remote clients need the token from \
|
|
`soulseek-rs daemon token`)"
|
|
));
|
|
}
|
|
if listeners.is_empty() {
|
|
return Err(CliError::usage(
|
|
"nothing to listen on: pass --bind on a platform without Unix \
|
|
sockets",
|
|
));
|
|
}
|
|
|
|
let session = Session::open(ctx)?;
|
|
if let Some(slots) = upload_slots {
|
|
session.client.set_upload_slots(usize::from(slots));
|
|
}
|
|
|
|
let store = StateStore::new(state_dir);
|
|
let hub = Arc::new(Hub::new());
|
|
let browses = Arc::new(PendingBrowses::default());
|
|
let downloads = Arc::new(Mutex::new(Vec::new()));
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
|
|
let rooms = restore(ctx, &store, session.client.as_ref(), &hub, &downloads);
|
|
|
|
let daemon = Arc::new(Daemon {
|
|
session: Arc::clone(&session.client),
|
|
hub: Arc::clone(&hub),
|
|
browses: Arc::clone(&browses),
|
|
downloads: Arc::clone(&downloads),
|
|
download_dir: Mutex::new(ctx.download_dir.clone()),
|
|
server: ctx.settings.server_address.to_string(),
|
|
token: token.clone(),
|
|
started: Instant::now(),
|
|
stop: Arc::clone(&stop),
|
|
open: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
|
rooms: Mutex::new(rooms),
|
|
searches: Mutex::new(std::collections::HashMap::new()),
|
|
});
|
|
|
|
let drainer = {
|
|
let session = Arc::clone(&session.client);
|
|
let hub = Arc::clone(&hub);
|
|
let stop = Arc::clone(&stop);
|
|
std::thread::spawn(move || {
|
|
hub::run(session, hub, browses, downloads, stop);
|
|
})
|
|
};
|
|
|
|
{
|
|
let session = Arc::clone(&session.client);
|
|
let daemon = Arc::clone(&daemon);
|
|
let stop = Arc::clone(&stop);
|
|
let out = ctx.out.clone();
|
|
let address = ctx.settings.server_address.to_string();
|
|
std::thread::spawn(move || {
|
|
reconnect_loop(&session, &daemon, &out, &address, &stop);
|
|
});
|
|
}
|
|
|
|
for listener in listeners {
|
|
let daemon = Arc::clone(&daemon);
|
|
std::thread::spawn(move || listener.serve(daemon));
|
|
}
|
|
|
|
let (folders, files) = session.client.shared_counts();
|
|
ctx.out.status(&format!(
|
|
"logged in as {}, sharing {files} files in {folders} folders",
|
|
session.client.username()
|
|
));
|
|
ready(ctx, bind, &token);
|
|
|
|
// The listeners run on their own threads; this one only has to notice a
|
|
// `daemon.stop` and write state back on the way past.
|
|
let mut last_save = Instant::now();
|
|
while !stop.load(Ordering::Relaxed) {
|
|
std::thread::sleep(hub::POLL);
|
|
if last_save.elapsed() >= SAVE_INTERVAL {
|
|
last_save = Instant::now();
|
|
save(&store, session.client.as_ref(), &hub, &daemon);
|
|
}
|
|
}
|
|
|
|
save(&store, session.client.as_ref(), &hub, &daemon);
|
|
ctx.out.status("stopping");
|
|
// The drainer watches the same flag; joining keeps a half-written state
|
|
// file from racing the process exit.
|
|
let _ = drainer.join();
|
|
cleanup();
|
|
Ok(())
|
|
}
|
|
|
|
/// Say what is now possible, once, on stderr.
|
|
///
|
|
/// The token goes here rather than only into a file because the moment you
|
|
/// need it is the moment you bind a port, and hunting for `daemon token`
|
|
/// afterwards is a poor first experience. stdout stays empty either way, so
|
|
/// `daemon` still composes in a pipeline.
|
|
fn ready(ctx: &Ctx, bind: Option<&str>, token: &str) {
|
|
const BANNER: &str = "
|
|
ready — leave this running.
|
|
|
|
On this machine, nothing else to do. Other commands find
|
|
it on their own and share this one login:
|
|
|
|
soulseek-rs search 'aphex twin'
|
|
soulseek-rs (the TUI attaches)
|
|
soulseek-rs daemon status
|
|
soulseek-rs daemon stop
|
|
";
|
|
const LOCAL_ONLY: &str =
|
|
" To drive it from another machine, restart with --bind ADDR;
|
|
`soulseek-rs daemon token` prints the secret it needs.
|
|
";
|
|
|
|
ctx.out.status(BANNER);
|
|
|
|
let Some(address) = bind else {
|
|
ctx.out.status(LOCAL_ONLY);
|
|
return;
|
|
};
|
|
|
|
ctx.out.status(&format!(
|
|
" From another machine, point a client at {address} with
|
|
this token:
|
|
|
|
{token}
|
|
|
|
soulseek-rs config set daemon <this-host>:PORT
|
|
soulseek-rs config set daemon_token <token above>
|
|
|
|
That port has no encryption. Keep it off the open internet and \
|
|
reach it over SSH instead.
|
|
"
|
|
));
|
|
}
|
|
|
|
/// Remove the socket file so the next start does not have to reason about
|
|
/// whether it is stale.
|
|
fn cleanup() {
|
|
#[cfg(unix)]
|
|
if let Some(path) = socket_path() {
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
}
|
|
|
|
const fn should_reconnect(loss: Option<SessionLoss>, owned: bool) -> bool {
|
|
owned && matches!(loss, Some(SessionLoss::Disconnected))
|
|
}
|
|
|
|
fn reconnect_loop(
|
|
session: &Arc<dyn SessionApi>,
|
|
daemon: &Daemon,
|
|
out: &Out,
|
|
address: &str,
|
|
stop: &AtomicBool,
|
|
) {
|
|
let owned = session.daemon_endpoint().is_none();
|
|
let mut failures = 0u32;
|
|
let mut announced = false;
|
|
while !stop.load(Ordering::Relaxed) {
|
|
std::thread::sleep(hub::POLL);
|
|
if !should_reconnect(session.session_loss(), owned) {
|
|
failures = 0;
|
|
announced = false;
|
|
continue;
|
|
}
|
|
if !announced {
|
|
out.status(&format!(
|
|
"the connection to {address} dropped; logging back in…"
|
|
));
|
|
announced = true;
|
|
}
|
|
match session.relogin() {
|
|
Ok(true) => {
|
|
out.status("logged back in");
|
|
rejoin(out, session.as_ref(), daemon);
|
|
failures = 0;
|
|
announced = false;
|
|
}
|
|
outcome => {
|
|
let wait = backoff(failures);
|
|
failures = failures.saturating_add(1);
|
|
out.warn(&format!(
|
|
"cannot log back in: {}; trying again in {}s",
|
|
relogin_error(outcome),
|
|
wait.as_secs()
|
|
));
|
|
wait_or_stop(wait, stop);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn relogin_error(outcome: soulseek_rs::Result<bool>) -> String {
|
|
match outcome {
|
|
Ok(_) => "login rejected by the server".to_string(),
|
|
Err(e) => format!("login failed: {e}"),
|
|
}
|
|
}
|
|
|
|
fn backoff(failures: u32) -> Duration {
|
|
Duration::from_secs(1 << failures.min(6)).min(RECONNECT_BACKOFF_CAP)
|
|
}
|
|
|
|
fn wait_or_stop(total: Duration, stop: &AtomicBool) {
|
|
let deadline = Instant::now() + total;
|
|
while Instant::now() < deadline && !stop.load(Ordering::Relaxed) {
|
|
std::thread::sleep(hub::POLL);
|
|
}
|
|
}
|
|
|
|
fn rejoin(out: &Out, session: &dyn SessionApi, daemon: &Daemon) {
|
|
join_rooms(out, session, &daemon.rooms());
|
|
for user in session.watched_users() {
|
|
if let Err(e) = session.watch_user(&user) {
|
|
out.warn(&format!("cannot watch {user} again: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn join_rooms(out: &Out, session: &dyn SessionApi, rooms: &[String]) {
|
|
for room in rooms {
|
|
if let Err(e) = session.join_room(room) {
|
|
out.warn(&format!("cannot rejoin {room}: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bring back what the last run left: re-queue unfinished transfers, rejoin
|
|
/// rooms, and reload the conversation.
|
|
fn restore(
|
|
ctx: &Ctx,
|
|
store: &StateStore,
|
|
session: &dyn SessionApi,
|
|
hub: &Hub,
|
|
downloads: &Mutex<Vec<PendingDownload>>,
|
|
) -> Vec<String> {
|
|
hub.restore(
|
|
store
|
|
.load_messages()
|
|
.into_iter()
|
|
.map(|message| ChatMessageDto {
|
|
peer: message.peer,
|
|
outgoing: message.outgoing,
|
|
text: message.text,
|
|
at: message.at.timestamp(),
|
|
})
|
|
.collect(),
|
|
);
|
|
|
|
let rooms = store.load_rooms();
|
|
join_rooms(&ctx.out, session, &rooms);
|
|
|
|
let unfinished: Vec<PersistedDownload> = store
|
|
.load_downloads()
|
|
.into_iter()
|
|
.filter(|download| !download.completed)
|
|
.collect();
|
|
if unfinished.is_empty() {
|
|
return rooms;
|
|
}
|
|
ctx.out
|
|
.status(&format!("resuming {} transfers", unfinished.len()));
|
|
for download in unfinished {
|
|
match session.download(
|
|
download.filename.clone(),
|
|
download.username.clone(),
|
|
download.size,
|
|
download.download_directory.clone(),
|
|
) {
|
|
Ok((_, updates)) => {
|
|
if let Ok(mut pending) = downloads.lock() {
|
|
pending.push(PendingDownload {
|
|
username: download.username,
|
|
filename: download.filename,
|
|
updates,
|
|
});
|
|
}
|
|
}
|
|
Err(e) => ctx
|
|
.out
|
|
.warn(&format!("cannot resume {}: {e}", download.filename)),
|
|
}
|
|
}
|
|
rooms
|
|
}
|
|
|
|
/// Write daemon-owned state back. Best-effort: a state file that cannot be
|
|
/// written is not a reason to drop a working session.
|
|
fn save(
|
|
store: &StateStore,
|
|
session: &dyn SessionApi,
|
|
hub: &Hub,
|
|
daemon: &Daemon,
|
|
) {
|
|
let downloads: Vec<PersistedDownload> = session
|
|
.get_all_downloads()
|
|
.iter()
|
|
.filter_map(PersistedDownload::capture)
|
|
.collect();
|
|
let _ = store.save_downloads(&downloads);
|
|
|
|
let messages: Vec<PersistedMessage> = hub
|
|
.history()
|
|
.into_iter()
|
|
.map(|message| PersistedMessage {
|
|
peer: message.peer,
|
|
outgoing: message.outgoing,
|
|
text: message.text,
|
|
at: chrono::DateTime::from_timestamp(message.at, 0)
|
|
.unwrap_or_default()
|
|
.into(),
|
|
})
|
|
.collect();
|
|
let _ = store.save_messages(&messages);
|
|
let _ = store.save_rooms(&daemon.rooms());
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_token_is_minted_once_and_then_reused() {
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let path = dir.path().join(TOKEN_NAME);
|
|
|
|
let first = load_or_create_token(&path).expect("a token is minted");
|
|
let second = load_or_create_token(&path).expect("the token is reused");
|
|
|
|
assert_eq!(
|
|
first, second,
|
|
"restarting must not lock existing clients out"
|
|
);
|
|
assert_eq!(first.len(), 64, "256 bits, hex encoded");
|
|
assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
|
|
}
|
|
|
|
#[test]
|
|
fn only_an_owned_dropped_session_reconnects() {
|
|
assert!(should_reconnect(Some(SessionLoss::Disconnected), true));
|
|
assert!(
|
|
!should_reconnect(Some(SessionLoss::Displaced), true),
|
|
"reconnecting a displaced name starts a login war with whoever \
|
|
took it"
|
|
);
|
|
assert!(
|
|
!should_reconnect(None, true),
|
|
"a live session is not retried"
|
|
);
|
|
assert!(
|
|
!should_reconnect(Some(SessionLoss::Disconnected), false),
|
|
"a borrowed session is the daemon it came from to mend"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_backoff_doubles_and_then_holds_at_the_cap() {
|
|
let seconds: Vec<u64> = (0..8).map(|n| backoff(n).as_secs()).collect();
|
|
assert_eq!(seconds, [1, 2, 4, 8, 16, 32, 60, 60]);
|
|
assert_eq!(
|
|
backoff(u32::MAX),
|
|
RECONNECT_BACKOFF_CAP,
|
|
"a very long outage must not overflow into a tiny wait"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn two_tokens_are_not_the_same() {
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let first = load_or_create_token(&dir.path().join("a")).unwrap();
|
|
let second = load_or_create_token(&dir.path().join("b")).unwrap();
|
|
assert_ne!(first, second);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_token_file_is_readable_by_nobody_else() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let path = dir.path().join(TOKEN_NAME);
|
|
load_or_create_token(&path).expect("a token is minted");
|
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
|
assert_eq!(mode & 0o777, 0o600, "the token is a secret on disk");
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_token_file_is_replaced_rather_than_trusted() {
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let path = dir.path().join(TOKEN_NAME);
|
|
std::fs::write(&path, " \n").unwrap();
|
|
let token = load_or_create_token(&path).expect("a token is minted");
|
|
assert_eq!(token.len(), 64, "an empty secret must never authenticate");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_socket_left_by_a_dead_daemon_is_replaced_not_refused() {
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let path = dir.path().join(SOCKET_NAME);
|
|
// A plain file standing in for what a crash left behind: nothing is
|
|
// listening on it, so binding must succeed.
|
|
std::fs::write(&path, "").unwrap();
|
|
assert!(
|
|
server::bind_unix(&path).is_ok(),
|
|
"a stale socket must not strand the daemon"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_second_daemon_refuses_to_steal_a_live_socket() {
|
|
let dir = tempfile::tempdir().expect("a temp dir");
|
|
let path = dir.path().join(SOCKET_NAME);
|
|
let _first = server::bind_unix(&path).expect("the first daemon binds");
|
|
assert!(
|
|
server::bind_unix(&path).is_err(),
|
|
"taking over a live socket would silently steal its clients"
|
|
);
|
|
}
|
|
}
|