Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
107 changes: 100 additions & 7 deletions crates/flextunnel-cli/src/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -96,17 +96,16 @@ pub fn run(config_path: Option<PathBuf>, server_node_id: Option<String>) -> 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 <file>, the default config, or -n <server id>")?
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}…"));

Expand All @@ -127,6 +126,63 @@ pub fn run(config_path: Option<PathBuf>, server_node_id: Option<String>) -> 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 `<instance>.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 <server EndpointId>.",
path.display()
);
}
let Some(default_path) = config::default_client_config_path() else {
return anyhow!(
"Could not determine the default config directory. Pass -c <file> or \
-n <server EndpointId>."
);
};
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 <file> for another \
profile, or pass -n <server EndpointId>.",
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 <file>, or attach by server id with \
-n <server EndpointId>.",
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<Vec<String>> {
let mut names: Vec<String> = 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
Expand Down Expand Up @@ -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}");
}
}
9 changes: 8 additions & 1 deletion crates/flextunnel-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,20 @@ pub fn load_server_config(path: Option<&Path>, default_config: bool) -> Result<O
pub fn load_client_config(path: Option<&Path>) -> Result<Option<ClientConfig>> {
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<PathBuf> {
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
Expand Down
6 changes: 6 additions & 0 deletions docs/systemd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<instance>.toml`. (Running it bare says so,
and lists the profile files it found.) `-n <server EndpointId>` attaches
without any config file.

Detaching (`q`) never affects the tunnel. Port forwards edited there persist
per server (`~/.config/flextunnel/forwards-<server id prefix>.json`) but
always load **disabled**; enabling is a per-session action, so a unit restart
Expand Down
Loading