diff --git a/README.md b/README.md index 7f98272..0f84666 100644 --- a/README.md +++ b/README.md @@ -165,23 +165,76 @@ file *and* update a running daemon, so a folder is served straight away rather than at its next start. Files land on the *daemon's* filesystem — `daemon status` reports where. -For a client on another machine, bind a TCP port as well and give it the -token: +#### A download box you drive from your laptop + +This is what daemon mode is really for. Put soulseek-rs on the machine that +should be doing the downloading — a home server, a NAS, a Raspberry Pi, a +seedbox — and drive it from wherever you happen to be. + +**On that machine**, run the daemon and let it accept connections from your +network: ```bash soulseek-rs daemon --bind 0.0.0.0:5030 -soulseek-rs daemon token # the secret that connection needs +soulseek-rs daemon token # copy this, you need it once ``` -There is no transport encryption, so put SSH or a TLS proxy in front of -anything non-loopback. +It needs no terminal and no one logged in; hand it to systemd or launchd and +forget about it. -Third-party clients are first-class: the daemon speaks newline-delimited -JSON-RPC 2.0 described by an [OpenRPC](https://open-rpc.org) document -([`docs/openrpc.json`](docs/openrpc.json), also served by `rpc.discover`), so -a generator produces a client in any language. -[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the framing, the -handshake, and how pushed events behave. +**On your laptop**, say once where it lives: + +```bash +soulseek-rs config set daemon nas.local:5030 +soulseek-rs config set daemon_token +``` + +That is the whole setup. Every command from now on is the normal command — +your laptop needs no Soulseek account of its own, because it is using the +server's: + +```bash +soulseek-rs search 'aphex twin' --json +soulseek-rs get 'selected ambient works' +soulseek-rs daemon status # what it's doing right now +``` + +Then close the lid. **The downloads keep going**, because they belong to the +daemon, not to the command that asked for them or to the laptop that ran it. +Come back tomorrow, run `soulseek-rs daemon status` again, and pick up where +you left off. The files are on the download box, which is where you wanted +them. + +When you are finished with it: + +```bash +soulseek-rs daemon stop # shuts down the remote daemon +``` + +One caution: there is no encryption on that TCP port. On a home network that +is usually fine. Over the internet, don't expose it — reach it through SSH +instead, either with a tunnel (`ssh -L 5030:localhost:5030 nas`) or by running +the commands over `ssh` on the box itself. + +#### Build your own remote + +The daemon's interface is open and documented, so nothing about it is specific +to this CLI. It speaks newline-delimited JSON-RPC 2.0 over a socket, described +by an [OpenRPC](https://open-rpc.org) document — +[`docs/openrpc.json`](docs/openrpc.json), which the daemon also serves itself +via `rpc.discover`, so a client can ask a running daemon what it can do. + +OpenRPC is the JSON-RPC counterpart to OpenAPI: point a client generator at +that document and you get typed bindings in whatever language you are working +in. If you want an Android or iOS app that queues downloads on your home +server from the sofa, or a small web page that shows what is transferring, you +have everything you need and no permission to ask for. Searches, transfers, +rooms, private messages and shares are all on the same interface this CLI +uses — there is no private back channel it keeps for itself, and live updates +are pushed to every connected client rather than polled for. + +[`docs/daemon-protocol.md`](docs/daemon-protocol.md) covers the parts a schema +cannot state: the framing, the handshake, and how pushed events behave. ### Scripting diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 350a797..e6edbde 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -165,6 +165,13 @@ accepts connections from other machines, which need the token. **`daemon token`** — the secret a client on another machine needs. Local clients never need it. +The daemon can also be on a *different* machine — a home server or NAS doing +the downloading. Point at it once with `config set daemon host:5030` and +`config set daemon_token `, and every command afterwards drives that +machine; files land there, not here. The interface is an open JSON-RPC schema +(`rpc.discover` serves it), so a purpose-built client is a reasonable thing to +write. + **`shares list`** — one object per configured folder: `directory`, `usable`. **`shares add `** and **`shares remove `** — change the config file; they print nothing. With a daemon running they also update it in diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index f0c6d13..3781647 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -23,6 +23,14 @@ pub struct FileConfig { /// Command whose stdout is the password (headless fallback, like mutt's /// `password_cmd`). Never store the password itself in the file. pub password_cmd: Option, + /// A daemon to control instead of opening a session of our own — + /// `host:port`, or a Unix socket path. Unset means "use the local one if + /// there is one", which is what a daemon on this machine wants. + pub daemon: Option, + /// The token for a remote daemon. Not a secret about *this* account, and + /// useless without reaching the host, so unlike a password it is + /// reasonable to keep here rather than behind a command. + pub daemon_token: Option, /// Standing searches the server lets us repeat once per wishlist interval. /// A TOML array rather than a `config set` key, because a query may itself /// contain a comma. @@ -43,6 +51,8 @@ impl FileConfig { "max_concurrent_downloads", "search_timeout", "password_cmd", + "daemon", + "daemon_token", ]; /// The value of `key`, or `None` when it is unset or unknown. Lists are @@ -64,6 +74,8 @@ impl FileConfig { } "search_timeout" => self.search_timeout.map(|v| v.to_string()), "password_cmd" => self.password_cmd.clone(), + "daemon" => self.daemon.clone(), + "daemon_token" => self.daemon_token.clone(), _ => None, } } @@ -88,6 +100,8 @@ impl FileConfig { "server" => text(&mut self.server), "download_dir" => text(&mut self.download_dir), "password_cmd" => text(&mut self.password_cmd), + "daemon" => text(&mut self.daemon), + "daemon_token" => text(&mut self.daemon_token), "listener_port" => { self.listener_port = parse_opt(value, clear, key)?; } @@ -242,6 +256,8 @@ pub struct Resolved { pub max_concurrent_downloads: usize, pub search_timeout: u64, pub password_cmd: Option, + pub daemon: Option, + pub daemon_token: Option, } pub const DEFAULT_SERVER: &str = "server.slsknet.org:2416"; @@ -297,6 +313,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .password_cmd .clone() .or_else(|| file.password_cmd.clone()), + daemon: cli.daemon.clone().or_else(|| file.daemon.clone()), + daemon_token: cli + .daemon_token + .clone() + .or_else(|| file.daemon_token.clone()), } } @@ -488,6 +509,8 @@ mod tests { max_concurrent_downloads: Some(2), search_timeout: Some(30), password_cmd: Some("pass show slsk".into()), + daemon: Some("nas.local:5030".into()), + daemon_token: Some("deadbeef".into()), wishlist: None, }; let resolved = resolve(&bare_cli(), &file); @@ -497,6 +520,8 @@ mod tests { assert!(resolved.disable_listener); assert_eq!(resolved.download_dir, "/music"); assert_eq!(resolved.shared_dirs, vec!["/shared".to_string()]); + assert_eq!(resolved.daemon.as_deref(), Some("nas.local:5030")); + assert_eq!(resolved.daemon_token.as_deref(), Some("deadbeef")); assert_eq!(resolved.max_concurrent_downloads, 2); assert_eq!(resolved.search_timeout, 30); assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk")); diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 7a47c13..05fec99 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -37,7 +37,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { // 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. - if let Some(endpoint) = daemon_endpoint(&cli) { + if let Some(endpoint) = daemon_endpoint(&cli, &resolved) { return run_attached_tui(&cli, &resolved, &endpoint); } return run_default_tui(&cli, &resolved, config_path, &file_config); @@ -87,7 +87,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { out, &store, directory, - &live_daemon(&cli), + &live_daemon(&cli, &resolved), ); } Commands::Shares(SharesCommand::Remove { ref directory }) => { @@ -95,7 +95,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { out, &store, directory, - &live_daemon(&cli), + &live_daemon(&cli, &resolved), ); } Commands::Wish(WishCommand::Add { ref query }) => { @@ -114,8 +114,8 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { command: Some(ref command), .. }) => { - let endpoint = daemon_endpoint(&cli); - let token = cli.daemon_token.clone(); + let endpoint = daemon_endpoint(&cli, &resolved); + let token = resolved.daemon_token; return match command { DaemonCommand::Token => crate::commands::daemon::token(out), DaemonCommand::Status => crate::commands::daemon::status( @@ -182,7 +182,7 @@ fn context( resolved: &crate::persist::config::Resolved, out: &Out, ) -> CliResult { - let daemon = daemon_endpoint(cli); + let daemon = daemon_endpoint(cli, resolved); // A run routed to a daemon needs no account of its own: the daemon is // already logged in, and asking for credentials it will not use would // stop a script that never had any. @@ -213,7 +213,7 @@ fn context( // honest — `whoami` naming the right server, `download` naming the file // where the bytes actually landed. let described = daemon.as_ref().and_then(|endpoint| { - describe_daemon(endpoint, cli.daemon_token.clone()) + describe_daemon(endpoint, resolved.daemon_token.clone()) }); let server_address = described.as_ref().map_or_else( || PeerAddress::new(host, port), @@ -244,16 +244,19 @@ fn context( max_concurrent_downloads: resolved.max_concurrent_downloads, search_timeout: Duration::from_secs(resolved.search_timeout), daemon, - daemon_token: cli.daemon_token.clone(), + daemon_token: resolved.daemon_token.clone(), session: std::sync::OnceLock::new(), }) } /// The daemon a config change should also reach, if one is running. -fn live_daemon(cli: &Cli) -> crate::commands::settings::LiveDaemon { +fn live_daemon( + cli: &Cli, + resolved: &crate::persist::config::Resolved, +) -> crate::commands::settings::LiveDaemon { crate::commands::settings::LiveDaemon { - endpoint: daemon_endpoint(cli), - token: cli.daemon_token.clone(), + endpoint: daemon_endpoint(cli, resolved), + token: resolved.daemon_token.clone(), } } @@ -278,11 +281,16 @@ fn describe_daemon( /// ssh-agent set, and what lets an agent run a command with no flags at all. /// With nothing listening, the run opens its own session exactly as it always /// has. -fn daemon_endpoint(cli: &Cli) -> Option { +fn daemon_endpoint( + cli: &Cli, + resolved: &crate::persist::config::Resolved, +) -> Option { if cli.no_daemon { return None; } - if let Some(address) = &cli.daemon { + // Flag, then environment, then config file — `resolved` has already + // layered those — and otherwise a daemon on this machine, if one is up. + if let Some(address) = &resolved.daemon { return Some(crate::remote::Endpoint::parse(address)); } crate::daemon::local_daemon().map(crate::remote::Endpoint::Socket) diff --git a/soulseek-rs/tests/daemon_protocol_e2e.rs b/soulseek-rs/tests/daemon_protocol_e2e.rs index 06ea7f1..2ac33ba 100644 --- a/soulseek-rs/tests/daemon_protocol_e2e.rs +++ b/soulseek-rs/tests/daemon_protocol_e2e.rs @@ -70,6 +70,8 @@ impl Daemon { "pw", "--listener-port", &free_port()?.to_string(), + "--download-dir", + state.join("downloads").to_str()?, "daemon", "--bind", &format!("127.0.0.1:{port}"), @@ -492,3 +494,74 @@ fn scratch(label: &str) -> PathBuf { std::fs::create_dir_all(&path).expect("scratch directory"); path } + +/// A client configured for a remote daemon reports the *daemon's* world. +/// +/// This is the download-box workflow: the token lives in the client's config +/// file rather than on its command line, and it has no Soulseek account of its +/// own. Reading the token from the flag alone made the status probe fail +/// silently, and every command that names a path or a server then quietly fell +/// back to this machine's defaults — `whoami` claiming downloads land in the +/// laptop's home directory when they land on the box. +#[test] +fn a_config_driven_remote_client_reports_the_daemons_world() { + let daemon = daemon_or_skip!(); + let config = scratch("remote-client-config"); + let state = scratch("remote-client-state"); + + // A laptop that knows only where the daemon is. No username, no password, + // no server — exactly what the documented setup produces. + std::fs::write( + config.join("config.toml"), + format!( + "daemon = \"127.0.0.1:{}\"\ndaemon_token = \"{}\"\n", + daemon.port, + daemon.token() + ), + ) + .expect("config file"); + + let mut command = Command::new(BIN); + for name in CLI_ENV_VARS { + command.env_remove(name); + } + command.env("SOULSEEK_CONFIG_DIR", &config); + command.env("SOULSEEK_STATE_DIR", &state); + command.current_dir(std::env::temp_dir()); + command.args(["--json", "whoami"]); + let output = command.output().expect("the binary should run"); + + assert!( + output.status.success(), + "a client configured for a remote daemon should just work: {}", + String::from_utf8_lossy(&output.stderr) + ); + let record: serde_json::Value = serde_json::from_str( + String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| !line.trim().is_empty()) + .expect("one record"), + ) + .expect("valid JSON"); + + assert_eq!( + record["user"], "cli_e2e_proto", + "the daemon's account, not this machine's" + ); + assert!( + record["server"] + .as_str() + .is_some_and(|server| server.starts_with("127.0.0.1:")), + "the server the daemon is connected to, not a local default: {record}" + ); + assert!( + record["download_dir"].as_str().is_some_and( + |dir| dir.starts_with(std::env::temp_dir().to_str().unwrap()) + ), + "the directory the bytes actually land in, on the daemon's host, not \ + this machine's Downloads folder: {record}" + ); + + let _ = std::fs::remove_dir_all(&config); + let _ = std::fs::remove_dir_all(&state); +} diff --git a/website/src/pages/docs.tsx b/website/src/pages/docs.tsx index 3c6c1c1..bada849 100644 --- a/website/src/pages/docs.tsx +++ b/website/src/pages/docs.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router' import { Button } from '@/components/ui/button' +import { ExtLink } from '@/components/ui/ext-link' import { Code } from '@/components/ui/inline-code' import { Cols, @@ -257,18 +258,99 @@ const Daemon = () => ( rescan every invocation, and downloads that outlive the command that queued them. If you drive this from a script or an agent, start one.

+ + + +) + +const DownloadBox = () => ( +
+ + A home server, a NAS, a Raspberry Pi. It needs no terminal and nobody logged in. + You drive it from your laptop, and it keeps working after you close the lid. + + + ' }, + { t: 'cm', text: '' }, + { t: 'cm', text: '# then just use it, no flags:' }, + { t: 'cmd', text: 'soulseek-rs get "selected ambient works"' }, + { t: 'cmd', text: 'soulseek-rs daemon status' }, + { t: 'cmd', text: 'soulseek-rs daemon stop' }, + { t: 'cm', text: '# shuts down the remote one when you are done' }, + ]} + /> +

- It speaks newline-delimited JSON-RPC 2.0 described by an{' '} - openrpc.json the daemon serves itself, so a client generator gives - you one in any language. daemon --bind adds a TCP port for other - machines; those need the token from daemon token, while the local - socket never does. + Your laptop needs no Soulseek account of its own — it is using the box’s. + Queue what you want, then close the lid: the downloads keep going, because they + belong to the daemon rather than to the command that asked for them. Come back + tomorrow, run daemon status, and pick up where you left off. The + files are on the box, which is where you wanted them. +

+

+ There is no encryption on that port. On a home network that is usually fine; + over the internet, reach it through SSH instead —{' '} + ssh -L 5030:localhost:5030 nas, or just run the commands on the box.

) +const OpenSchema = () => ( +
+ + Nothing about the daemon is specific to this CLI. Point a client generator at its + schema and write your own remote. + + + +

+ It speaks newline-delimited JSON-RPC 2.0, described by an{' '} + OpenRPC document. The daemon + serves that document itself through rpc.discover, so a client can + ask a running daemon what it can do. +

+

+ OpenRPC is the JSON-RPC counterpart to OpenAPI: point a generator at it and you + get typed bindings in whatever language you are working in. An Android or iOS app + that queues downloads on your home server from the sofa, a small web page showing + what is transferring — you have everything you need and nobody to ask. +

+

+ Searches, transfers, rooms, private messages and shares are all on the same + interface this CLI uses. There is no private back channel it keeps for itself, + and live updates are pushed to every connected client rather than polled for. +

+
+ +
+
+) + const TheRest = () => (
@@ -305,6 +387,8 @@ export const Docs = () => ( + + )