feat(config): remember which daemon to use, and document the download box
A remote daemon could only be named with --daemon and --daemon-token on every invocation, which made the one workflow daemon mode is most useful for — a headless download box driven from a laptop — a matter of retyping a 64-character token. Both are now config keys, so it is two 'config set' commands once and plain commands thereafter. Fixes a bug that setup exposed: the up-front daemon status probe read the token from the command line only, so with the token in the config file it failed silently and every command that names a path or a server fell back to this machine's defaults — whoami claiming downloads land in the laptop's home directory when they land on the box. The regression test was checked by reintroducing the bug. Documents the download-box workflow in plain terms across the README, the website and the agent skill, and says plainly that the schema is open: OpenRPC, served by the daemon itself, so an Android, iOS or web remote is something anyone can build without asking.
This commit is contained in:
@@ -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 <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 <directory>`** and **`shares remove <directory>`** — change the
|
||||
config file; they print nothing. With a daemon running they also update it in
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
pub daemon: Option<String>,
|
||||
pub daemon_token: Option<String>,
|
||||
}
|
||||
|
||||
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"));
|
||||
|
||||
+21
-13
@@ -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<Ctx> {
|
||||
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<crate::remote::Endpoint> {
|
||||
fn daemon_endpoint(
|
||||
cli: &Cli,
|
||||
resolved: &crate::persist::config::Resolved,
|
||||
) -> Option<crate::remote::Endpoint> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user