The settings popup opens with an Account block: who the session is logged in as, how much it shares, the port peers reach it on, and the daemon it borrows when the window is attached to one. Two rows follow, both confirmed before they act. A password is typed twice, because the server sends no verdict on a change and a typo entered once would leave the account with a password nobody knows. Logging out closes the window and comes back to the login screen with the stored password deleted, so the next start asks rather than walking straight back in. A window attached to a daemon offers no logout: the login is the daemon's. "Change it on the server, then store it where the login lives" is one function over the session and the secret store, so the window, the command and the daemon share the rule rather than each spelling out its own idea of which process owns the password. Headless the same two acts are `account password` and `account logout`, and the daemon answers `account.password` so a routed run is not a dead end — `whoami` already reported who is logged in and what they share.
596 lines
19 KiB
Rust
596 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),
|
|
secrets: Arc::new(crate::persist::secret::KeyringStore),
|
|
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"
|
|
);
|
|
}
|
|
}
|