From 4d941c38962932f22677ad142f2a1c9e2ce3d2ed Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Sat, 5 Sep 2026 22:20:39 -0700 Subject: [PATCH 1/2] Say which config `client control` could not find a profile in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare `flextunnel client control` under the systemd template layout (docs/systemd.md: one `.toml` per unit, no `client.toml`) failed with "The profile has no server node id (set server_node_id in the config or pass -n)" — blaming a profile that was never found, since the default config does not exist at all. Split that into the three cases it was covering: an explicit -c file with no server_node_id (name that file), a default client.toml with no server_node_id (name it, and mention -c), and no config file at all (say so, and list the *.toml profiles that are in the config dir, which is exactly the set of systemd instances). Also drop the .context() that wrapped genuine read/parse failures with "client control needs a profile", and expose config::default_client_config_path() so the path can be named in the errors. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017qaKrfofmiVWdkXnyjubXF --- crates/flextunnel-cli/src/tui/mod.rs | 107 +++++++++++++++++++++++++-- crates/flextunnel-core/src/config.rs | 9 ++- docs/systemd.md | 6 ++ 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/crates/flextunnel-cli/src/tui/mod.rs b/crates/flextunnel-cli/src/tui/mod.rs index 01080e7..ee4b43a 100644 --- a/crates/flextunnel-cli/src/tui/mod.rs +++ b/crates/flextunnel-cli/src/tui/mod.rs @@ -18,8 +18,8 @@ mod form; mod view; -use anyhow::{Context, Result}; -use std::path::PathBuf; +use anyhow::{Context, Result, anyhow}; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use flextunnel_core::config; @@ -96,17 +96,16 @@ pub fn run(config_path: Option, server_node_id: Option) -> Resu let file = if server_node_id.is_some() && config_path.is_none() { None } else { - config::load_client_config(config_path.as_deref()) - .context("client control needs a profile: -c , the default config, or -n ")? + config::load_client_config(config_path.as_deref())? }; let cli = config::ClientConfig { server_node_id, ..Default::default() }; let r = config::resolve_client(cli, file); - let server_id = r.server_node_id.context( - "The profile has no server node id (set server_node_id in the config or pass -n).", - )?; + let Some(server_id) = r.server_node_id else { + return Err(no_profile_error(config_path.as_deref())); + }; let key = instance::instance_key(&server_id)?; let profile = r.name.unwrap_or_else(|| format!("server {key}…")); @@ -127,6 +126,63 @@ pub fn run(config_path: Option, server_node_id: Option) -> Resu run_panel(app, &mut backend).context("Lost the connection to the client (did it stop?)") } +/// The error for a `client control` that has nothing to identify a client by. +/// The two cases read very differently and want different fixes: a config file +/// that exists but carries no `server_node_id`, versus no config file at all — +/// the usual shape under the systemd template (see `docs/systemd.md`), where +/// every profile lives in its own `.toml` and there is no +/// `client.toml` for a bare `client control` to find. In that case name the +/// profiles that *are* there, so the fix is a copy-paste away. +fn no_profile_error(config_path: Option<&Path>) -> anyhow::Error { + if let Some(path) = config_path { + return anyhow!( + "The client config {} has no server_node_id, so it does not say which client to \ + attach to. Set server_node_id in it, or pass -n .", + path.display() + ); + } + let Some(default_path) = config::default_client_config_path() else { + return anyhow!( + "Could not determine the default config directory. Pass -c or \ + -n ." + ); + }; + if default_path.exists() { + return anyhow!( + "The default client config {} has no server_node_id, so it does not say which \ + client to attach to. Set server_node_id in it, pass -c for another \ + profile, or pass -n .", + default_path.display() + ); + } + let mut msg = format!( + "There is no client config at {}, so `client control` has no profile to attach to. \ + Pass the profile's config with -c , or attach by server id with \ + -n .", + default_path.display() + ); + if let Some(dir) = default_path.parent() + && let Some(found) = profile_configs(dir) + { + msg.push_str(&format!("\nProfiles in {}: {}", dir.display(), found.join(", "))); + } + anyhow!(msg) +} + +/// Config files in the flextunnel config dir that could be a client profile, +/// as bare file names, sorted. `None` when there are none (or the directory is +/// unreadable) — the caller then says nothing rather than an empty list. +fn profile_configs(dir: &Path) -> Option> { + let mut names: Vec = std::fs::read_dir(dir) + .ok()? + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".toml") && n != "server.toml") + .collect(); + names.sort(); + (!names.is_empty()).then_some(names) +} + /// Run the self-contained control panel for `client start --quick`: the same UI /// as `client control`, but driving the session in this process over `tx` /// instead of a socket. Quitting (q/Esc/Ctrl-C) returns, dropping `tx` — which @@ -407,3 +463,40 @@ impl App { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_configs_lists_candidate_client_configs() { + let dir = tempfile::tempdir().unwrap(); + for name in [ + "macintel.toml", + "aws.toml", + "server.toml", + "forwards-abc.json", + "client.key", + ] { + std::fs::write(dir.path().join(name), "").unwrap(); + } + assert_eq!( + profile_configs(dir.path()).unwrap(), + ["aws.toml", "macintel.toml"] + ); + } + + #[test] + fn no_profiles_reports_nothing_to_list() { + let dir = tempfile::tempdir().unwrap(); + assert!(profile_configs(dir.path()).is_none()); + assert!(profile_configs(&dir.path().join("missing")).is_none()); + } + + #[test] + fn a_named_config_without_a_server_id_names_that_file() { + let msg = no_profile_error(Some(Path::new("/tmp/aws.toml"))).to_string(); + assert!(msg.contains("/tmp/aws.toml"), "{msg}"); + assert!(msg.contains("server_node_id"), "{msg}"); + } +} diff --git a/crates/flextunnel-core/src/config.rs b/crates/flextunnel-core/src/config.rs index bed1f3d..2c2c387 100644 --- a/crates/flextunnel-core/src/config.rs +++ b/crates/flextunnel-core/src/config.rs @@ -241,13 +241,20 @@ pub fn load_server_config(path: Option<&Path>, default_config: bool) -> Result) -> Result> { match path { Some(p) => Ok(Some(load_config(&expand_tilde(p))?)), - None => match default_config_path("client.toml") { + None => match default_client_config_path() { Some(p) if p.exists() => Ok(Some(load_config(&p)?)), _ => Ok(None), }, } } +/// Where [`load_client_config`] looks when no path is given: +/// `~/.config/flextunnel/client.toml` (which need not exist). `None` only when +/// the home directory is unknown. Callers use it to name the file in errors. +pub fn default_client_config_path() -> Option { + default_config_path("client.toml") +} + /// Merge CLI-provided values over file values over defaults for the server. /// /// `cli` carries the CLI flags as a `ServerConfig` (a field is `Some`/non-empty diff --git a/docs/systemd.md b/docs/systemd.md index 9182ff1..e90e8b1 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -85,6 +85,12 @@ the server id — systemd isn't involved: flextunnel client control -c ~/.config/flextunnel/aws.toml ``` +The `-c` is not optional here: a bare `flextunnel client control` reads only +`~/.config/flextunnel/client.toml`, which this layout deliberately does not +have — each instance's profile is `.toml`. (Running it bare says so, +and lists the profile files it found.) `-n ` attaches +without any config file. + Detaching (`q`) never affects the tunnel. Port forwards edited there persist per server (`~/.config/flextunnel/forwards-.json`) but always load **disabled**; enabling is a per-session action, so a unit restart From 7b1a6890c7602f9295fc30ad005e8f648f38b855 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Sat, 5 Sep 2026 22:20:52 -0700 Subject: [PATCH 2/2] Bump version to 0.0.77 for flextunnel packages Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017qaKrfofmiVWdkXnyjubXF --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d73a56d..ef86f50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,7 +1945,7 @@ dependencies = [ [[package]] name = "flextunnel-cli" -version = "0.0.76" +version = "0.0.77" dependencies = [ "anyhow", "clap", @@ -1961,7 +1961,7 @@ dependencies = [ [[package]] name = "flextunnel-core" -version = "0.0.76" +version = "0.0.77" dependencies = [ "anyhow", "askama", @@ -1992,7 +1992,7 @@ dependencies = [ [[package]] name = "flextunnel-desktop" -version = "0.0.76" +version = "0.0.77" dependencies = [ "aes-gcm", "anyhow", @@ -2021,7 +2021,7 @@ dependencies = [ [[package]] name = "flextunnel-ffi" -version = "0.0.76" +version = "0.0.77" dependencies = [ "flextunnel-core", "iroh", diff --git a/Cargo.toml b/Cargo.toml index 0b44c87..526c6b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ default-members = [ ] [workspace.package] -version = "0.0.76" +version = "0.0.77" edition = "2024" description = "SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P"