From cedea273d7ca036013ca5cb25be1d2f03ada8a2a Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Tue, 15 Sep 2026 16:56:27 -0700 Subject: [PATCH] Declare CLI client port forwards in the config; make the control panel read-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI client's port forwards are now `[[forwards]]` tables in the client config (label optional; local_port, remote_host, remote_port), validated at startup like the rest of the config (valid host and label, nonzero ports, unique local ports) and brought up with the client. There is no CLI flag. The per-instance forwards JSON store, the TUI add/edit/delete form and delete confirmation, and the on/off toggle are removed. The control channel now only serves status and connection-path snapshots — nothing about the client can be changed from `client control`. A forward whose listener fails to bind is still switched off by the client itself, with the reason shown next to its row. A `--quick` client reads no config, so it has no forwards. Co-Authored-By: Claude Fable 5.1 --- README.md | 60 +-- client.toml.example | 30 +- crates/flextunnel-cli/src/client_session.rs | 394 +++++--------------- crates/flextunnel-cli/src/forwards.rs | 121 ------ crates/flextunnel-cli/src/instance.rs | 6 +- crates/flextunnel-cli/src/ipc.rs | 123 ++---- crates/flextunnel-cli/src/lock.rs | 9 +- crates/flextunnel-cli/src/main.rs | 14 +- crates/flextunnel-cli/src/tui/form.rs | 181 --------- crates/flextunnel-cli/src/tui/mod.rs | 164 +------- crates/flextunnel-cli/src/tui/view.rs | 119 +----- crates/flextunnel-core/src/config.rs | 72 +++- docs/systemd.md | 13 +- 13 files changed, 306 insertions(+), 1000 deletions(-) delete mode 100644 crates/flextunnel-cli/src/forwards.rs delete mode 100644 crates/flextunnel-cli/src/tui/form.rs diff --git a/README.md b/README.md index eae274d..fd975d0 100644 --- a/README.md +++ b/README.md @@ -321,8 +321,8 @@ or another quick one), and both forget everything on exit. The quick client is **self-contained**: after the prompt it runs the same live panel as [`client control`](#client-control) right in that terminal — but it opens **no control socket** (nothing else can attach to it), and quitting the -panel (`q`) **disconnects** the tunnel and exits, rather than detaching. Port -forwards added in the panel live in memory only and are never written to disk. +panel (`q`) **disconnects** the tunnel and exits, rather than detaching. A +quick client reads no config, so it has no port forwards. #### Server status page @@ -382,8 +382,8 @@ analysis and what it doesn't cover (raw-TCP apps still need SOCKS5 or `socat`). | Command | Description | |---|---| | `server start` | Run the proxy server. | -| `client start` | Run the proxy client (optional SOCKS5 and HTTP proxy listeners, port forwards). | -| `client control` | Attach the terminal control panel to a running client. | +| `client start` | Run the proxy client (optional SOCKS5 and HTTP proxy listeners, plus the port forwards declared in its config). | +| `client control` | Attach the read-only terminal control panel to a running client. | | `client help` | Show the client subcommands and their help. | | `generate-iroh-key [-o ] [--force] [--json]` | Generate the server's iroh identity key (stdout without `-o`). | | `show-iroh-id --secret-file [--json]` | Print the iroh id (EndpointId) for a key. | @@ -431,17 +431,26 @@ runs the self-contained control panel described under [`client control`](#client right in that terminal — but with no control socket exposed, and quitting the panel disconnects instead of detaching. Nothing is saved. +Port forwards are declared in the config as `[[forwards]]` tables (see +[`client.toml.example`](client.toml.example)): each listens on +`localhost:` and opens a server-direct stream to +`:` on the authenticated connection (the server +enforces its routed set and resolves the host). They are validated at startup +(nonzero ports, valid host, unique local ports) and all come up with the +client. There is no CLI flag and nothing can be changed live: to change the +set, edit the config and restart the client. + With neither `--socks-port` nor `--http-port` (nor the config keys) the client runs in **port-forward-only mode**: it holds the tunnel and serves only -the control panel and any enabled port forwards. +the control panel and the port forwards. -A client's on-disk identity — the single-instance lock, the control socket, -and the persisted port forwards — is keyed by the prefix of its -`server_node_id` (which never changes for a profile). So one client runs per -server per user, and clients for different servers coexist without any extra -configuration. The optional `name` key in the config ("aws", "home network") -is a display-only label shown in the control panel. (A `--quick` client is -exempt: it takes none of these — no lock, no socket, no forwards file.) +A client's on-disk identity — the single-instance lock and the control +socket — is keyed by the prefix of its `server_node_id` (which never changes +for a profile). So one client runs per server per user, and clients for +different servers coexist without any extra configuration. The optional `name` +key in the config ("aws", "home network") is a display-only label shown in the +control panel. (A `--quick` client is exempt: it takes neither — no lock, no +socket.) ### `client control` @@ -451,20 +460,19 @@ flextunnel client control -c aws.toml # profile from a specific config flextunnel client control -n # or by server id directly ``` -Attaches a terminal control panel to the **running** client for a profile, -over its control socket (`~/.config/flextunnel/client-.sock`; -a named pipe on Windows). It shows live status — connection phase and uptime, -server/client node ids, connection paths (direct/relay), and the server-pushed -routing breakdown (split-tunnel rules, host aliases, DNS forwards, and bridge -routes) — plus an editable **port forwards** table: add/edit/delete -forwards and toggle them on/off (`space`), live. - -Forwards open server-direct streams on the authenticated connection (the -server enforces its routed set) and listen on localhost. They persist in -`~/.config/flextunnel/forwards-.json` — written only by the -running client — and always load **disabled**; enabling is a per-session -action, like the desktop app. A forward whose local port can't bind flips back -off with the reason shown next to its row. +Attaches a **read-only** terminal control panel to the **running** client for +a profile, over its control socket +(`~/.config/flextunnel/client-.sock`; a named pipe on +Windows). It shows live status — connection phase and uptime, server/client +node ids, connection paths (direct/relay), the server-pushed routing breakdown +(split-tunnel rules, host aliases, DNS forwards, and bridge routes), and the +**port forwards** declared in the client's config with their live state +(listening, active connections, or switched off with the bind-failure reason). + +Nothing about the client can be changed from the panel: the forward set is +fixed by the config for the life of the client (edit the config and restart to +change it), and the channel carries no mutations. It runs as the same user as +the client — no elevated privilege on either end. Detaching (`q`) never affects the tunnel; several panels can attach at once. diff --git a/client.toml.example b/client.toml.example index e4dd9a0..24fe5e7 100644 --- a/client.toml.example +++ b/client.toml.example @@ -12,9 +12,9 @@ # CLI flags override these values. Unknown keys are rejected. # EndpointId of the server to connect to (from `flextunnel show-iroh-id`). -# Also the client's identity: its prefix keys the single-instance lock, the -# control socket, and the persisted port forwards, so one client runs per -# server (per user) and `flextunnel client control` finds it from this config. +# Also the client's identity: its prefix keys the single-instance lock and the +# control socket, so one client runs per server (per user) and `flextunnel +# client control` finds it from this config. server_node_id = "" # Optional friendly name for this profile, shown in the control panel and @@ -28,8 +28,7 @@ server_node_id = "" # Optional HTTP proxy listener port (CONNECT + absolute-URI plain-HTTP # forwarding). Unset means disabled. With neither socks_port nor http_port -# set, the client runs in port-forward-only mode (manage forwards with -# `flextunnel client control`). +# set, the client runs in port-forward-only mode (see [[forwards]] below). # http_port = 8081 # This client's authentication keypair — always a key file (generate with @@ -54,3 +53,24 @@ auth_key_file = "~/.config/flextunnel/client.key" # The split-tunnel routed set (the "tunnel set") is configured on the server and # pushed to this client during the handshake — there is no client-side routed-set # config. See server.toml.example. + +# Server-direct port forwards: each `[[forwards]]` table listens on +# localhost: (127.0.0.1/::1 only) and opens a stream to +# : on the authenticated server connection. The +# server enforces its routed set and resolves the host (host aliases apply), +# so the remote must be covered by the server's tunnel set. Forwards are +# declared here only — the control panel (`flextunnel client control`) shows +# their state but cannot change them; edit this file and restart the client +# instead. A forward whose local port cannot bind is switched off with the +# reason shown next to its row. Local ports must be unique and nonzero. label +# is optional (display only). +# [[forwards]] +# label = "db" +# local_port = 5432 +# remote_host = "db.internal" +# remote_port = 5432 +# +# [[forwards]] +# local_port = 8443 +# remote_host = "10.0.0.7" +# remote_port = 443 diff --git a/crates/flextunnel-cli/src/client_session.rs b/crates/flextunnel-cli/src/client_session.rs index e0308ef..5bbc11a 100644 --- a/crates/flextunnel-cli/src/client_session.rs +++ b/crates/flextunnel-cli/src/client_session.rs @@ -4,8 +4,9 @@ //! //! Mirrors the desktop client's per-profile session (`flextunnel-desktop`'s //! `tunnel.rs`): bind the enabled listeners, run the reconnecting client, poll -//! routes/forward state on a ticker, and serve status/mutation commands — here -//! arriving over the IPC socket instead of a GUI channel. +//! routes/forward state on a ticker, and serve status snapshots — here over the +//! IPC socket instead of a GUI channel. Unlike the desktop, nothing is mutable +//! from the panel: the forward set is the config's. use anyhow::{Context, Result}; use std::collections::HashMap; @@ -17,6 +18,7 @@ use flextunnel_core::forwards::{ ForwardManager, ForwardState, ForwardStatus, PortForward, disable_failed_forwards, validate_label, validate_remote_host, }; +use flextunnel_core::config::ForwardConfig; use flextunnel_core::iroh::SecretKey; use flextunnel_core::proxy::{ClientAuth, ClientConfig, ProxyClient, reserved}; use flextunnel_core::transport::endpoint::{ @@ -26,17 +28,17 @@ use flextunnel_core::transport::paths::{ConnPath, ConnPathKind}; use flextunnel_core::{app, auth, config}; use crate::ipc::{ - self, ForwardRow, ForwardRowState, IpcCmd, Mutation, Phase, StatusSnapshot, WireBridge, - WireConnPath, WireConnSnapshot, WireCustomRelay, WireForward, WireRoutes, + self, ForwardRow, ForwardRowState, IpcCmd, Phase, StatusSnapshot, WireBridge, WireConnPath, + WireConnSnapshot, WireCustomRelay, WireForward, WireRoutes, }; -use crate::{forwards as store, instance, lock}; +use crate::{instance, lock}; pub async fn run(r: config::ResolvedClient) -> Result<()> { let server_node_id = r.server_node_id.clone().context( "The client requires a server node id (--server-node-id or server_node_id in the config).", )?; // A profile's server id never changes, so its prefix is the client's - // on-disk identity: lock, control socket, and forwards file. + // on-disk identity: lock and control socket. let key = instance::instance_key(&server_node_id)?; let client_key = resolve_client_key(&r)?; // The public half is what the server operator needs on their @@ -47,19 +49,14 @@ pub async fn run(r: config::ResolvedClient) -> Result<()> { // socket safe (see ipc.rs). let _lock = lock::acquire_client(&key)?; - // All forwards load disabled (`enabled` is never persisted) — enabling is - // an explicit per-session action, like the desktop. - let forwards = store::load(&key)?; - if let Err(e) = validate_loaded(&forwards) { - let path = store::forwards_path(&key)?; - anyhow::bail!("Invalid port forwards in {}: {e}", path.display()); - } + // The forward set is the config's `[[forwards]]` tables, fixed for the + // session (the panel only observes it). Validated here like the rest of + // the config, before the endpoint exists. All start enabled; one whose + // listener fails to bind is switched off by the ticker below. + let forwards = forwards_from_config(&r.forwards) + .map_err(|e| anyhow::anyhow!("Invalid [[forwards]] in the client config: {e}"))?; if !forwards.is_empty() { - log::info!( - "Loaded {} port forward(s) (disabled) from {}", - forwards.len(), - store::forwards_path(&key)?.display() - ); + log::info!("Loaded {} port forward(s) from the config", forwards.len()); } // The routed set (tunnel set) is configured on the server and pushed @@ -70,7 +67,6 @@ pub async fn run(r: config::ResolvedClient) -> Result<()> { SessionAuth::Key(client_key), key.clone(), forwards, - true, ) .await?; if runtime.state.socks_addr.is_none() && runtime.state.http_addr.is_none() { @@ -110,11 +106,12 @@ fn resolve_client_key(r: &config::ResolvedClient) -> Result { /// The self-contained `flextunnel client start --quick` session: an ephemeral /// client that runs the live control panel in *this* terminal instead of -/// detaching. Unlike [`run`] it takes **no single-instance lock**, loads and -/// writes **no forwards file**, and exposes **no control socket** — nothing is -/// persisted and nothing else can attach. The panel and the session talk over an -/// in-process channel; quitting the panel drops its sender, closing the channel, -/// which shuts the session down — so the tunnel disconnects rather than detaching. +/// detaching. Unlike [`run`] it takes **no single-instance lock** and exposes +/// **no control socket** — nothing is persisted and nothing else can attach. +/// It reads no config, so it has no port forwards. The panel and the session +/// talk over an in-process channel; quitting the panel drops its sender, closing +/// the channel, which shuts the session down — so the tunnel disconnects rather +/// than detaching. /// /// `client_secret` is the session's pre-generated identity, whose endpoint id /// the caller already printed for the user to allowlist on the quick server — @@ -124,19 +121,17 @@ pub async fn run_quick(r: config::ResolvedClient, client_secret: SecretKey) -> R let server_node_id = r.server_node_id.clone().context( "The client requires a server node id (--server-node-id or server_node_id in the config).", )?; - // Display-only in quick mode (no lock/socket/forwards paths are derived from - // it); computing it also validates the id shape up front. + // Display-only in quick mode (no lock/socket paths are derived from it); + // computing it also validates the id shape up front. let key = instance::instance_key(&server_node_id)?; - // Forwards are ephemeral: none are loaded, none are saved (`persist=false`). - // They can still be added/edited live in the panel, in memory only. + // No config, so no forwards (the panel cannot declare any). let runtime = build_session( r, server_node_id, SessionAuth::Quick(client_secret), key, Vec::new(), - false, ) .await?; @@ -155,7 +150,7 @@ pub async fn run_quick(r: config::ResolvedClient, client_secret: SecretKey) -> R /// The assembled per-session runtime that [`drive_session`] consumes: the iroh /// endpoint, the proxy client and its live routes, the bound proxy listeners, -/// the forward manager + set, and the status/mutation state. +/// the forward manager + set, and the status state. struct SessionRuntime { endpoint: ClientEndpoint, client: std::sync::Arc, @@ -184,16 +179,15 @@ enum SessionAuth { /// like the desktop client — unauthenticated, never exposed off-machine), and /// assemble the [`SessionRuntime`]. Shared by [`run`] and [`run_quick`]; the /// caller supplies the [`SessionAuth`] (which also determines the endpoint's -/// identity), the instance `key` (status display), the initial `forwards`, and -/// whether mutations `persist`. On any failure past endpoint creation the -/// endpoint is closed gracefully before returning. +/// identity), the instance `key` (status display), and the session's fixed +/// `forwards`. On any failure past endpoint creation the endpoint is closed +/// gracefully before returning. async fn build_session( r: config::ResolvedClient, server_node_id: String, auth: SessionAuth, key: String, forwards: Vec, - persist: bool, ) -> Result { let relay_config = RelayConfig::from_urls_with_token(&r.relay_urls, r.relay_auth_token.clone()) .context("Invalid relay configuration")?; @@ -267,7 +261,6 @@ async fn build_session( connected_since: None, last_error: None, disabled_reasons: HashMap::new(), - persist, }; Ok(SessionRuntime { @@ -385,12 +378,6 @@ async fn drive_session( }); }); } - Some(IpcCmd::Mutate(mutation, reply)) => { - let result = state - .apply_mutation(mutation, &mut forwards, &mut fwd_mgr) - .map(|()| state.snapshot(&routes, &forwards, &fwd_mgr)); - let _ = reply.send(result); - } None => { if quit_on_ipc_close { break Ok(()); @@ -449,7 +436,7 @@ fn local_addr(listener: &Option) -> Option listener.as_ref().and_then(|l| l.local_addr().ok()) } -/// Session-scoped status/mutation state shared by the ticker and the IPC arms. +/// Session-scoped status state shared by the ticker and the IPC arms. struct SessionState { /// The server-id-prefix instance key (see `instance.rs`). instance: String, @@ -462,13 +449,9 @@ struct SessionState { ever_connected: bool, connected_since: Option, last_error: Option, - /// Retained bind-failure reasons of auto-disabled forwards, keyed by - /// forward id; cleared when the forward is re-enabled, edited, or deleted. + /// Bind-failure reasons of forwards switched off by the ticker, keyed by + /// forward id, shown next to their rows for the rest of the session. disabled_reasons: HashMap, - /// Whether forward Add/Update/Delete are written to the forwards file. True - /// for a normal session; false for the ephemeral quick panel, whose forwards - /// live only in memory (nothing is persisted). - persist: bool, } impl SessionState { @@ -557,162 +540,54 @@ impl SessionState { last_conn_error, } } - - /// Validate and apply one forward mutation, reconcile the listeners, and - /// persist (Add/Update/Delete only — `enabled` is never persisted, so - /// toggles don't touch the file). - fn apply_mutation( - &mut self, - mutation: Mutation, - forwards: &mut Vec, - fwd_mgr: &mut ForwardManager, - ) -> Result<(), String> { - // `enabled` is never persisted, so a toggle is live-only: apply it - // directly and skip the save path entirely. - if let Mutation::SetEnabled(id, enabled) = mutation { - let forward = forwards - .iter_mut() - .find(|f| f.id == id) - .ok_or_else(|| format!("No forward with id {id:?}"))?; - forward.enabled = enabled; - if enabled { - self.disabled_reasons.remove(&id); - } - fwd_mgr.apply(forwards); - return Ok(()); - } - - // Add/Update/Delete change the forward set. Stage the change on a clone - // and, when persisting, save *first*: if the save fails, live state and - // listeners are untouched, so they never diverge from the file on disk. - let mut staged = forwards.clone(); - // The id whose retained bind-failure reason to clear on commit (an - // edit or delete supersedes it); `None` for an add. - let reason_to_clear = match mutation { - Mutation::Add(wire) => { - let mut forward = validated(wire, &staged, None)?; - if forward.id.is_empty() { - forward.id = PortForward::new_id(); - } else if staged.iter().any(|f| f.id == forward.id) { - return Err(format!("A forward with id {:?} already exists", forward.id)); - } - staged.push(forward); - None - } - Mutation::Update(wire) => { - let id = wire.id.clone(); - let forward = validated(wire, &staged, Some(&id))?; - let slot = staged - .iter_mut() - .find(|f| f.id == id) - .ok_or_else(|| format!("No forward with id {id:?}"))?; - *slot = forward; - Some(id) - } - Mutation::Delete(id) => { - let before = staged.len(); - staged.retain(|f| f.id != id); - if staged.len() == before { - return Err(format!("No forward with id {id:?}")); - } - Some(id) - } - Mutation::SetEnabled(..) => unreachable!("handled above"), - }; - - if self.persist - && let Err(e) = store::save(&self.instance, &staged) - { - log::warn!("Failed to persist port forwards: {e:#}"); - return Err(format!("Failed to save forwards: {e:#}")); - } - - // Committed: swap in the staged set, reconcile the listeners, and only - // now apply the matching `disabled_reasons` update. - *forwards = staged; - fwd_mgr.apply(forwards); - if let Some(id) = reason_to_clear { - self.disabled_reasons.remove(&id); - } - Ok(()) - } } -/// Reject a persisted forwards file that breaks the invariants the running -/// client and the TUI assume: nonempty and unique ids, valid remote hosts and -/// labels, and nonzero, unique local ports (plus nonzero remote ports). The -/// file is program-written (only by the running client, always after -/// [`validated`]), so a violation means corruption or a hand-edit — treated -/// like a corrupt config: a startup error, not a silent load. -fn validate_loaded(forwards: &[PortForward]) -> Result<(), String> { - let mut seen_ids = std::collections::HashSet::new(); - let mut seen_ports = std::collections::HashSet::new(); - for f in forwards { - if f.id.is_empty() { - return Err("a forward has an empty id".into()); - } - if !seen_ids.insert(f.id.as_str()) { - return Err(format!("duplicate forward id {:?}", f.id)); +/// Build the session's forward set from the config's `[[forwards]]` tables, +/// enforcing the invariants the running client and the panel rely on: valid +/// labels and remote hosts, nonzero ports, and unique local ports (the local +/// port identifies a forward on the control channel). Hosts and labels are +/// stored normalized (trimmed, IPv6 brackets stripped). Every forward starts +/// enabled. Errors name the offending entry by its 1-based position. +fn forwards_from_config(entries: &[ForwardConfig]) -> Result, String> { + let mut forwards: Vec = Vec::with_capacity(entries.len()); + for (i, entry) in entries.iter().enumerate() { + let n = i + 1; + let label = validate_label(&entry.label).map_err(|e| format!("forward #{n}: {e}"))?; + let remote_host = + validate_remote_host(&entry.remote_host).map_err(|e| format!("forward #{n}: {e}"))?; + if entry.local_port == 0 { + return Err(format!("forward #{n}: local_port must be 1-65535")); } - validate_label(&f.label).map_err(|e| format!("forward {:?}: {e}", f.id))?; - validate_remote_host(&f.remote_host).map_err(|e| format!("forward {:?}: {e}", f.id))?; - if f.local_port == 0 { - return Err(format!("forward {:?} has a local port of 0", f.id)); + if entry.remote_port == 0 { + return Err(format!("forward #{n}: remote_port must be 1-65535")); } - if f.remote_port == 0 { - return Err(format!("forward {:?} has a remote port of 0", f.id)); + if let Some(owner) = forwards.iter().find(|f| f.local_port == entry.local_port) { + return Err(format!( + "forward #{n}: local_port {} is already used by {}", + entry.local_port, + owner.display_name() + )); } - if !seen_ports.insert(f.local_port) { - return Err(format!("duplicate local port {}", f.local_port)); - } - } - Ok(()) -} - -/// Server-side (authoritative) validation of a wire forward; the TUI form -/// runs the same core validators for instant feedback. -fn validated( - wire: WireForward, - forwards: &[PortForward], - editing_id: Option<&str>, -) -> Result { - let label = validate_label(&wire.label)?; - let remote_host = validate_remote_host(&wire.remote_host)?; - if wire.local_port == 0 { - return Err("Local port must be 1-65535".into()); - } - if wire.remote_port == 0 { - return Err("Remote port must be 1-65535".into()); - } - if let Some(owner) = forwards - .iter() - .filter(|f| editing_id != Some(f.id.as_str())) - .find(|f| f.local_port == wire.local_port) - { - return Err(format!( - "Local port {} is already used by {}", - wire.local_port, - owner.display_name() - )); + forwards.push(PortForward { + // The core manager keys listeners by id; the local port is the + // natural unique key here (there is no persisted identity). + id: entry.local_port.to_string(), + label, + local_port: entry.local_port, + remote_host, + remote_port: entry.remote_port, + enabled: true, + }); } - Ok(PortForward { - id: wire.id, - label, - local_port: wire.local_port, - remote_host, - remote_port: wire.remote_port, - enabled: wire.enabled, - }) + Ok(forwards) } fn wire_forward(f: &PortForward) -> WireForward { WireForward { - id: f.id.clone(), label: f.label.clone(), local_port: f.local_port, remote_host: f.remote_host.clone(), remote_port: f.remote_port, - enabled: f.enabled, } } @@ -752,134 +627,45 @@ fn wire_routes(routes: flextunnel_core::proxy::TunnelRoutes) -> WireRoutes { mod tests { use super::*; - fn wire(id: &str, local_port: u16) -> WireForward { - WireForward { - id: id.into(), + fn entry(local_port: u16, remote_host: &str) -> ForwardConfig { + ForwardConfig { label: String::new(), local_port, - remote_host: "db.internal".into(), + remote_host: remote_host.into(), remote_port: 5432, - enabled: false, - } - } - - fn existing(id: &str, local_port: u16) -> PortForward { - PortForward { - id: id.into(), - label: String::new(), - local_port, - remote_host: "other.internal".into(), - remote_port: 80, - enabled: false, } } #[test] - fn validated_enforces_ports_host_and_uniqueness() { - let current = vec![existing("a", 5000)]; - - assert!(validated(wire("", 5001), ¤t, None).is_ok()); - assert!(validated(wire("", 0), ¤t, None).is_err()); - assert!(validated(wire("", 5000), ¤t, None).is_err(), "taken port"); - // Updating the owner itself may keep its port. - assert!(validated(wire("a", 5000), ¤t, Some("a")).is_ok()); - - let mut bad_host = wire("", 5001); - bad_host.remote_host = "bad..host".into(); - assert!(validated(bad_host, ¤t, None).is_err()); - - let mut bad_remote = wire("", 5001); - bad_remote.remote_port = 0; - assert!(validated(bad_remote, ¤t, None).is_err()); - } - - #[test] - fn validate_loaded_rejects_broken_persisted_data() { - let ok = vec![existing("a", 5000), existing("b", 5001)]; - assert!(validate_loaded(&ok).is_ok()); - - assert!(validate_loaded(&[existing("", 5000)]).is_err(), "empty id"); - assert!( - validate_loaded(&[existing("a", 5000), existing("a", 5002)]).is_err(), - "duplicate id" - ); - assert!( - validate_loaded(&[existing("a", 5000), existing("b", 5000)]).is_err(), - "duplicate local port" - ); - assert!(validate_loaded(&[existing("a", 0)]).is_err(), "zero local port"); - - let mut zero_remote = existing("a", 5000); + fn config_forwards_are_validated_and_start_enabled() { + let forwards = + forwards_from_config(&[entry(5000, "db.internal"), entry(5001, "other.internal")]) + .expect("valid"); + assert_eq!(forwards.len(), 2); + assert!(forwards.iter().all(|f| f.enabled)); + assert_eq!(forwards[0].id, "5000"); + assert_eq!(forwards[1].local_port, 5001); + + let err = forwards_from_config(&[entry(5000, "a"), entry(5000, "b")]).unwrap_err(); + assert!(err.contains("forward #2") && err.contains("5000"), "{err}"); + assert!(forwards_from_config(&[entry(0, "db.internal")]).is_err(), "zero local port"); + assert!(forwards_from_config(&[entry(5000, "bad..host")]).is_err(), "bad host"); + + let mut zero_remote = entry(5000, "db.internal"); zero_remote.remote_port = 0; - assert!(validate_loaded(&[zero_remote]).is_err(), "zero remote port"); + assert!(forwards_from_config(&[zero_remote]).is_err(), "zero remote port"); - let mut bad_host = existing("a", 5000); - bad_host.remote_host = "bad..host".into(); - assert!(validate_loaded(&[bad_host]).is_err(), "bad host"); + let mut long_label = entry(5000, "db.internal"); + long_label.label = "x".repeat(65); + assert!(forwards_from_config(&[long_label]).is_err(), "oversized label"); } #[test] fn host_and_label_are_normalized() { - let mut w = wire("", 5001); - w.label = " db ".into(); - w.remote_host = " [2001:db8::1] ".into(); - let f = validated(w, &[], None).unwrap(); + let mut e = entry(5001, " [2001:db8::1] "); + e.label = " db ".into(); + let f = forwards_from_config(&[e]).unwrap().remove(0); assert_eq!(f.label, "db"); assert_eq!(f.remote_host, "2001:db8::1"); } - - fn session_state(instance: &str, persist: bool) -> SessionState { - SessionState { - instance: instance.into(), - name: None, - server_node_id: "server".into(), - client_node_id: "client".into(), - socks_addr: None, - http_addr: None, - ever_connected: false, - connected_since: None, - last_error: None, - disabled_reasons: HashMap::new(), - persist, - } - } - - /// The quick panel edits forwards in memory only: an Add applies to the live - /// set but writes no `forwards-.json`. (With `persist=false` nothing is - /// written, so on success this touches no disk; the file is removed - /// defensively in case a regression re-enables the save.) - #[tokio::test] - async fn quick_session_does_not_persist_forward_edits() { - use flextunnel_core::proxy::{ClientAuth, ClientConfig, ProxyClient}; - - let key = "quickpersisttestkey0"; - let path = store::forwards_path(key).unwrap(); - let _ = std::fs::remove_file(&path); - - let client = ProxyClient::new(ClientConfig { - server_node_id: "server".into(), - auth: ClientAuth::QuickAllowlisted, - socks_listen: None, - http_listen: None, - relay_urls: Vec::new(), - relay_auth_token: None, - auto_reconnect: false, - max_reconnect_attempts: None, - }); - let mut fwd_mgr = ForwardManager::new( - tokio::runtime::Handle::current(), - client.server_forwarder(), - &[], - ); - let mut state = session_state(key, false); - - let mut forwards = Vec::new(); - state - .apply_mutation(Mutation::Add(wire("", 5555)), &mut forwards, &mut fwd_mgr) - .expect("in-memory add should succeed"); - assert_eq!(forwards.len(), 1, "the forward is applied in memory"); - assert!(!path.exists(), "quick mode must not write the forwards file"); - - let _ = std::fs::remove_file(&path); - } } diff --git a/crates/flextunnel-cli/src/forwards.rs b/crates/flextunnel-cli/src/forwards.rs deleted file mode 100644 index cf4da8f..0000000 --- a/crates/flextunnel-cli/src/forwards.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! Per-instance port-forward persistence. -//! -//! Forwards live in `~/.config/flextunnel/forwards-.json` (keyed by the -//! server-id prefix, see `instance.rs`) — a separate file from the client -//! TOML, because the TOML is the hand-edited profile while this file is -//! program-written (from `flextunnel client control` edits, applied by the -//! running client). Only the running client process writes it. The `enabled` -//! flag is `#[serde(skip)]` on [`PortForward`], so every forward loads -//! disabled — enabling is an explicit per-session action, exactly like the -//! desktop client. - -use anyhow::{Context, Result}; -use flextunnel_core::forwards::PortForward; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -use crate::instance; - -/// Wrapper object (not a bare array) so the schema can grow. -#[derive(Default, Serialize, Deserialize)] -struct ForwardsFile { - #[serde(default)] - forwards: Vec, -} - -/// `~/.config/flextunnel/forwards-.json`. -pub fn forwards_path(key: &str) -> Result { - Ok(instance::instance_dir()?.join(format!("forwards-{key}.json"))) -} - -/// Load the instance's forwards; a missing file is an empty list, a corrupt -/// file is a startup error (matching the strict TOML config philosophy). -pub fn load(key: &str) -> Result> { - load_path(&forwards_path(key)?) -} - -/// Persist the instance's forwards (atomic temp + rename). -pub fn save(key: &str, forwards: &[PortForward]) -> Result<()> { - save_path(&forwards_path(key)?, forwards) -} - -fn load_path(path: &Path) -> Result> { - match std::fs::read(path) { - Ok(raw) => { - let file: ForwardsFile = serde_json::from_slice(&raw) - .with_context(|| format!("Failed to parse {}", path.display()))?; - Ok(file.forwards) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), - Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())), - } -} - -/// Write via a temp file + rename so a crash mid-write can't truncate the file. -fn save_path(path: &Path, forwards: &[PortForward]) -> Result<()> { - let dir = path - .parent() - .ok_or_else(|| anyhow::anyhow!("forwards path has no parent directory"))?; - std::fs::create_dir_all(dir).with_context(|| format!("Failed to create {}", dir.display()))?; - let file = ForwardsFile { - forwards: forwards.to_vec(), - }; - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, serde_json::to_vec_pretty(&file)?) - .with_context(|| format!("Failed to write {}", tmp.display()))?; - std::fs::rename(&tmp, path).with_context(|| format!("Failed to persist {}", path.display()))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn forward(enabled: bool) -> PortForward { - PortForward { - id: PortForward::new_id(), - label: "db".into(), - local_port: 5432, - remote_host: "db.internal".into(), - remote_port: 5432, - enabled, - } - } - - #[test] - fn missing_file_is_empty() { - let dir = tempfile::tempdir().unwrap(); - assert!(load_path(&dir.path().join("nope.json")).unwrap().is_empty()); - } - - #[test] - fn corrupt_file_is_an_error() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("forwards.json"); - std::fs::write(&path, b"{ not json").unwrap(); - assert!(load_path(&path).is_err()); - } - - #[test] - fn roundtrip_loads_disabled() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("forwards.json"); - let saved = vec![forward(true), forward(false)]; - save_path(&path, &saved).unwrap(); - - // `enabled` is never serialized... - let raw = std::fs::read_to_string(&path).unwrap(); - assert!(!raw.contains("enabled"), "{raw}"); - - // ...so everything loads disabled, with all other fields intact. - let loaded = load_path(&path).unwrap(); - assert_eq!(loaded.len(), 2); - for (l, s) in loaded.iter().zip(&saved) { - assert!(!l.enabled); - assert_eq!(l.id, s.id); - assert_eq!(l.label, s.label); - assert_eq!(l.local_port, s.local_port); - assert_eq!(l.remote_endpoint(), s.remote_endpoint()); - } - } -} diff --git a/crates/flextunnel-cli/src/instance.rs b/crates/flextunnel-cli/src/instance.rs index f974505..5c0a965 100644 --- a/crates/flextunnel-cli/src/instance.rs +++ b/crates/flextunnel-cli/src/instance.rs @@ -3,9 +3,9 @@ //! A client *instance* is one running `flextunnel client start` process, //! identified by the server it connects to: since a profile's `server_node_id` never //! changes, its prefix keys every per-instance artifact — the single-instance -//! lock (`client-.lock`), the control socket (`client-.sock` / -//! `\\.\pipe\flextunnel-client-`), and the persisted port forwards -//! (`forwards-.json`). There is deliberately no way to override the key: +//! lock (`client-.lock`) and the control socket (`client-.sock` / +//! `\\.\pipe\flextunnel-client-`). There is deliberately no way to +//! override the key: //! one client per server per user, and `flextunnel client control` finds the //! right socket from the same config. The optional `name` in the config //! ("aws", "home network") is display-only. diff --git a/crates/flextunnel-cli/src/ipc.rs b/crates/flextunnel-cli/src/ipc.rs index fe3bdf7..26104ac 100644 --- a/crates/flextunnel-cli/src/ipc.rs +++ b/crates/flextunnel-cli/src/ipc.rs @@ -8,9 +8,10 @@ //! ends are this one binary and the repo has a no-compatibility policy, so //! there is no protocol version field. //! -//! Unlike a read-only status socket, this channel *mutates* state (port -//! forwards), so the Unix socket is chmod'd 0600 (owner only). On Windows the -//! default pipe security descriptor already restricts other users. +//! The channel is read-only (status and connection-path snapshots), but the +//! snapshots describe the user's network (routes, hosts, forwards), so the Unix +//! socket is still chmod'd 0600 (owner only). On Windows the default pipe +//! security descriptor already restricts other users. //! //! The socket is not the single-instance lock — see `lock.rs` for why. But //! *because* the caller holds that lock, unconditionally removing a stale @@ -53,14 +54,8 @@ pub enum Request { /// desktop modal / iOS sheet). Kept off the polled `Status` because the /// custom-relay `/healthz` probe does on-demand HTTP. ConnPath, - AddForward { forward: WireForward }, - UpdateForward { forward: WireForward }, - DeleteForward { id: String }, - SetForwardEnabled { id: String, enabled: bool }, } -/// Every success — mutations included — answers with a fresh snapshot so the -/// TUI redraws immediately instead of waiting for its next poll tick. #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "resp", rename_all = "snake_case")] pub enum Response { @@ -69,18 +64,26 @@ pub enum Response { Error { message: String }, } -/// [`flextunnel_core::forwards::PortForward`] with `enabled` made explicit -/// (the model marks it `#[serde(skip)]` so it is never *persisted*, but the -/// live wire must carry it). +/// One declared forward as the panel sees it (the config entry, normalized). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WireForward { - pub id: String, #[serde(default)] pub label: String, pub local_port: u16, pub remote_host: String, pub remote_port: u16, - pub enabled: bool, +} + +impl WireForward { + /// Label if set, otherwise `host:port` — mirrors `PortForward::display_name`. + pub fn display_name(&self) -> String { + let label = self.label.trim(); + if label.is_empty() { + flextunnel_core::forwards::format_host_port(&self.remote_host, self.remote_port) + } else { + label.to_string() + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -170,7 +173,7 @@ pub struct WireConnPath { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ForwardRowState { - /// Disabled (the switch is off). + /// Switched off after its listener failed to bind (see `error`). Stopped, Starting, Listening, @@ -198,14 +201,6 @@ pub struct ForwardRow { pub enum IpcCmd { Status(oneshot::Sender), ConnPath(oneshot::Sender), - Mutate(Mutation, oneshot::Sender>), -} - -pub enum Mutation { - Add(WireForward), - Update(WireForward), - Delete(String), - SetEnabled(String, bool), } // --------------------------------------------------------------------------- @@ -267,7 +262,7 @@ fn spawn_unix(socket: std::path::PathBuf, tx: mpsc::Sender) -> Result, request: Request) -> Option), ConnPath(oneshot::Receiver), - Mutate(oneshot::Receiver>), } impl PendingReply { @@ -407,7 +401,6 @@ impl PendingReply { Some(match self { PendingReply::Status(rx) => Response::Status(Box::new(rx.await.ok()?)), PendingReply::ConnPath(rx) => Response::ConnPath(rx.await.ok()?), - PendingReply::Mutate(rx) => reply_to_response(rx.await.ok()?), }) } @@ -415,18 +408,10 @@ impl PendingReply { Some(match self { PendingReply::Status(rx) => Response::Status(Box::new(rx.blocking_recv().ok()?)), PendingReply::ConnPath(rx) => Response::ConnPath(rx.blocking_recv().ok()?), - PendingReply::Mutate(rx) => reply_to_response(rx.blocking_recv().ok()?), }) } } -fn reply_to_response(reply: Result) -> Response { - match reply { - Ok(snapshot) => Response::Status(Box::new(snapshot)), - Err(message) => Response::Error { message }, - } -} - fn build_cmd(request: Request) -> (IpcCmd, PendingReply) { match request { Request::Status => { @@ -437,18 +422,9 @@ fn build_cmd(request: Request) -> (IpcCmd, PendingReply) { let (reply, rx) = oneshot::channel(); (IpcCmd::ConnPath(reply), PendingReply::ConnPath(rx)) } - Request::AddForward { forward } => mutate_cmd(Mutation::Add(forward)), - Request::UpdateForward { forward } => mutate_cmd(Mutation::Update(forward)), - Request::DeleteForward { id } => mutate_cmd(Mutation::Delete(id)), - Request::SetForwardEnabled { id, enabled } => mutate_cmd(Mutation::SetEnabled(id, enabled)), } } -fn mutate_cmd(mutation: Mutation) -> (IpcCmd, PendingReply) { - let (reply, rx) = oneshot::channel(); - (IpcCmd::Mutate(mutation, reply), PendingReply::Mutate(rx)) -} - /// Read one `\n`-terminated line into `buf`, rejecting lines over [`MAX_LINE`] /// (`read_line` alone would buffer without bound). Returns the number of bytes /// read (0 on clean EOF); the trailing newline is stripped. @@ -605,12 +581,10 @@ mod tests { fn wire_forward() -> WireForward { WireForward { - id: "f1".into(), label: "db".into(), local_port: 5432, remote_host: "db.internal".into(), remote_port: 5432, - enabled: true, } } @@ -650,21 +624,7 @@ mod tests { #[test] fn wire_types_roundtrip() { - for request in [ - Request::Status, - Request::ConnPath, - Request::AddForward { - forward: wire_forward(), - }, - Request::UpdateForward { - forward: wire_forward(), - }, - Request::DeleteForward { id: "f1".into() }, - Request::SetForwardEnabled { - id: "f1".into(), - enabled: false, - }, - ] { + for request in [Request::Status, Request::ConnPath] { let json = serde_json::to_string(&request).unwrap(); let _: Request = serde_json::from_str(&json).unwrap(); } @@ -675,16 +635,14 @@ mod tests { Response::Status(s) => { assert_eq!(s.phase, Phase::Connected); assert_eq!(s.forwards.len(), 1); - // The wire carries `enabled` even though the model's serde skips it. - assert!(s.forwards[0].forward.enabled); + assert_eq!(s.forwards[0].forward.local_port, 5432); assert_eq!(s.forwards[0].state, ForwardRowState::Listening); } other => panic!("expected Status, got {other:?}"), } } - /// Stub session loop: answers Status with a canned snapshot, accepts - /// SetEnabled, rejects Delete. + /// Stub session loop: answers Status and ConnPath with canned snapshots. fn stub_session() -> mpsc::Sender { let (tx, mut rx) = mpsc::channel(8); tokio::spawn(async move { @@ -707,12 +665,6 @@ mod tests { }], }); } - IpcCmd::Mutate(Mutation::Delete(id), reply) => { - let _ = reply.send(Err(format!("no forward with id {id:?}"))); - } - IpcCmd::Mutate(_, reply) => { - let _ = reply.send(Ok(snapshot())); - } } } }); @@ -722,7 +674,7 @@ mod tests { /// Spin up the real platform transport (UDS here, named pipe on Windows) /// against the stub session and drive it through the real `IpcClient`. #[tokio::test] - async fn loopback_status_and_mutations() { + async fn loopback_status_and_conn_path() { // Unique per test run: instance names (and thus pipe names on // Windows) are global, and parallel `cargo test` runs must not collide. let instance = format!("test-ipc-{}", std::process::id()); @@ -761,26 +713,19 @@ mod tests { other => panic!("expected Status, got {other:?}"), } - // A mutation that the session accepts returns a fresh snapshot... - match client - .request(&Request::SetForwardEnabled { - id: "f1".into(), - enabled: false, - }) - .await - .unwrap() - { - Response::Status(_) => {} - other => panic!("expected Status, got {other:?}"), + // The on-demand snapshot... + match client.request(&Request::ConnPath).await.unwrap() { + Response::ConnPath(s) => assert_eq!(s.paths.len(), 1), + other => panic!("expected ConnPath, got {other:?}"), } - // ...and one it rejects surfaces the message, keeping the connection open. - match client - .request(&Request::DeleteForward { id: "nope".into() }) - .await - .unwrap() - { - Response::Error { message } => assert!(message.contains("nope"), "{message}"), + // ...and a malformed line gets an error while the connection stays open. + let stream = client.stream.as_mut().unwrap(); + stream.get_mut().write_all(b"{\"cmd\":\"nope\"}\n").await.unwrap(); + let mut line = Vec::new(); + read_line_capped(stream, &mut line).await.unwrap(); + match serde_json::from_slice::(&line).unwrap() { + Response::Error { message } => assert!(message.contains("Bad request"), "{message}"), other => panic!("expected Error, got {other:?}"), } match client.request(&Request::Status).await.unwrap() { diff --git a/crates/flextunnel-cli/src/lock.rs b/crates/flextunnel-cli/src/lock.rs index a85fb4f..825629e 100644 --- a/crates/flextunnel-cli/src/lock.rs +++ b/crates/flextunnel-cli/src/lock.rs @@ -11,11 +11,10 @@ //! [`flextunnel_core::lock`]; this module only picks the per-user paths. //! //! The client lock is deliberately a dedicated file rather than the control -//! socket or the forwards JSON: a Unix socket file persists after a crash and -//! offers no kernel-enforced liveness (probe-then-unlink-then-bind is a TOCTOU -//! race), and the forwards file is rewritten via temp+rename, which replaces -//! the locked inode on every save. An advisory lock on a stable file has -//! neither problem and is auto-released on crash. +//! socket: a Unix socket file persists after a crash and offers no +//! kernel-enforced liveness (probe-then-unlink-then-bind is a TOCTOU race). An +//! advisory lock on a stable file has neither problem and is auto-released on +//! crash. use anyhow::{Context, Result}; use flextunnel_core::lock::InstanceLock; diff --git a/crates/flextunnel-cli/src/main.rs b/crates/flextunnel-cli/src/main.rs index 80ba56a..4ecf7fa 100644 --- a/crates/flextunnel-cli/src/main.rs +++ b/crates/flextunnel-cli/src/main.rs @@ -2,7 +2,8 @@ //! //! A SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P connections. The //! client runs optional local SOCKS5/HTTP proxy listeners and server-direct -//! port forwards (managed live from `flextunnel client control`); routed +//! port forwards (declared in its config; `flextunnel client control` shows +//! their state); routed //! targets are tunneled as reliable QUIC bi-streams to the server, which //! resolves DNS and connects from its own network. Uses a fixed ALPN for //! protocol selection, client keypairs (ed25519) for access control, and TLS @@ -18,7 +19,6 @@ use std::time::Duration; use tokio::sync::Notify; mod client_session; -mod forwards; mod instance; mod ipc; mod lock; @@ -135,8 +135,8 @@ enum ServerAction { #[derive(Subcommand)] enum ClientAction { - /// Start the proxy client (optional SOCKS5 + HTTP proxy listeners, port - /// forwards). Needs at least one flag: `-c`/other options load a config (the + /// Start the proxy client (optional SOCKS5 + HTTP proxy listeners, plus the + /// port forwards declared in the config). Needs at least one flag: `-c`/other options load a config (the /// default ~/.config/flextunnel/client.toml when no -c is given), or /// `--quick` prompts for the connection details without persisting them. Run /// with no arguments to print this help. @@ -188,8 +188,9 @@ enum ClientAction { #[arg(long, conflicts_with_all = ["config", "auth_key", "auth_key_file"])] quick: bool, }, - /// Attach the control panel to the running client for a profile: live - /// status + editable port forwards (in this terminal). The client is + /// Attach the read-only control panel to the running client for a profile: + /// live status and the state of its config-declared port forwards (in this + /// terminal). The client is /// identified by the profile's server node id; with no flags, the default /// config (~/.config/flextunnel/client.toml) selects it. Control { @@ -537,6 +538,7 @@ async fn run_async(command: Command) -> Result<()> { relay_auth_token, auto_reconnect, max_reconnect_attempts, + forwards: None, // config-file only; no CLI flag }; // `--quick` is a self-contained ephemeral session: it ignores any // saved config, mints a session identity whose EndpointId is the diff --git a/crates/flextunnel-cli/src/tui/form.rs b/crates/flextunnel-cli/src/tui/form.rs deleted file mode 100644 index ed36709..0000000 --- a/crates/flextunnel-cli/src/tui/form.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! Add/edit buffers for one port forward, mirroring the desktop's form: the -//! same core validators run here for instant feedback, while the running -//! client re-validates authoritatively on apply. - -use flextunnel_core::forwards::{parse_port, validate_label, validate_remote_host}; - -use crate::ipc::{ForwardRow, WireForward}; - -/// Order of the focusable fields. -pub const FIELDS: usize = 5; -pub const FIELD_LABEL: usize = 0; -pub const FIELD_LOCAL_PORT: usize = 1; -pub const FIELD_REMOTE_HOST: usize = 2; -pub const FIELD_REMOTE_PORT: usize = 3; -pub const FIELD_ENABLED: usize = 4; - -pub struct FormState { - /// `None` when adding; the id being edited otherwise. - pub editing_id: Option, - pub label: String, - pub local_port: String, - pub remote_host: String, - pub remote_port: String, - pub enabled: bool, - pub focus: usize, - pub error: Option, -} - -impl FormState { - pub fn add() -> Self { - Self { - editing_id: None, - label: String::new(), - local_port: String::new(), - remote_host: String::new(), - remote_port: String::new(), - enabled: true, - focus: FIELD_LABEL, - error: None, - } - } - - pub fn edit(forward: &WireForward) -> Self { - Self { - editing_id: Some(forward.id.clone()), - label: forward.label.clone(), - local_port: forward.local_port.to_string(), - remote_host: forward.remote_host.clone(), - remote_port: forward.remote_port.to_string(), - enabled: forward.enabled, - focus: FIELD_LABEL, - error: None, - } - } - - pub fn is_edit(&self) -> bool { - self.editing_id.is_some() - } - - pub fn focus_next(&mut self) { - self.focus = (self.focus + 1) % FIELDS; - } - - pub fn focus_prev(&mut self) { - self.focus = (self.focus + FIELDS - 1) % FIELDS; - } - - pub fn focused_text(&mut self) -> Option<&mut String> { - match self.focus { - FIELD_LABEL => Some(&mut self.label), - FIELD_LOCAL_PORT => Some(&mut self.local_port), - FIELD_REMOTE_HOST => Some(&mut self.remote_host), - FIELD_REMOTE_PORT => Some(&mut self.remote_port), - _ => None, - } - } - - /// Validate against the latest snapshot's forward list (local-port - /// uniqueness excludes the forward being edited). On success returns the - /// wire forward to submit — with an empty id on add; the running client - /// assigns one. - pub fn validate(&self, forwards: &[ForwardRow]) -> Result { - let label = validate_label(&self.label)?; - let local_port = parse_port(&self.local_port, "Local port")?; - let remote_host = validate_remote_host(&self.remote_host)?; - let remote_port = parse_port(&self.remote_port, "Remote port")?; - if let Some(row) = forwards - .iter() - .filter(|r| Some(r.forward.id.as_str()) != self.editing_id.as_deref()) - .find(|r| r.forward.local_port == local_port) - { - return Err(format!( - "Local port {local_port} is already used by {}", - display_name(&row.forward) - )); - } - Ok(WireForward { - id: self.editing_id.clone().unwrap_or_default(), - label, - local_port, - remote_host, - remote_port, - enabled: self.enabled, - }) - } -} - -/// Label if set, otherwise `host:port` — mirrors `PortForward::display_name`. -pub fn display_name(forward: &WireForward) -> String { - let label = forward.label.trim(); - if label.is_empty() { - flextunnel_core::forwards::format_host_port(&forward.remote_host, forward.remote_port) - } else { - label.to_string() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ipc::ForwardRowState; - - fn row(id: &str, local_port: u16) -> ForwardRow { - ForwardRow { - forward: WireForward { - id: id.into(), - label: String::new(), - local_port, - remote_host: "other.internal".into(), - remote_port: 80, - enabled: false, - }, - state: ForwardRowState::Stopped, - error: None, - active: 0, - last_conn_error: None, - } - } - - fn filled() -> FormState { - FormState { - editing_id: None, - label: " db ".into(), - local_port: " 5432 ".into(), - remote_host: " db.internal ".into(), - remote_port: "5432".into(), - enabled: true, - focus: 0, - error: None, - } - } - - #[test] - fn validates_and_trims() { - let wire = filled().validate(&[row("a", 9999)]).expect("valid"); - assert_eq!(wire.id, ""); - assert_eq!(wire.label, "db"); - assert_eq!(wire.local_port, 5432); - assert_eq!(wire.remote_host, "db.internal"); - assert!(wire.enabled); - } - - #[test] - fn rejects_taken_port_except_own() { - let mut form = filled(); - assert!(form.validate(&[row("a", 5432)]).is_err()); - form.editing_id = Some("a".into()); - assert!(form.validate(&[row("a", 5432)]).is_ok()); - } - - #[test] - fn focus_cycles() { - let mut form = filled(); - for _ in 0..FIELDS { - form.focus_next(); - } - assert_eq!(form.focus, 0); - form.focus_prev(); - assert_eq!(form.focus, FIELDS - 1); - } -} diff --git a/crates/flextunnel-cli/src/tui/mod.rs b/crates/flextunnel-cli/src/tui/mod.rs index ee4b43a..d846550 100644 --- a/crates/flextunnel-cli/src/tui/mod.rs +++ b/crates/flextunnel-cli/src/tui/mod.rs @@ -1,5 +1,6 @@ //! The ratatui control panel — status (connection, routing, connection paths) -//! plus editable port forwards — reachable two ways over the same UI: +//! and the config-declared port forwards. Read-only: nothing about the client +//! can be changed from here. Reachable two ways over the same UI: //! //! - `flextunnel client control`: attaches to a *running* client over its //! control socket (see `ipc.rs`), as a separate process. Detaching (q) never @@ -15,7 +16,6 @@ //! `EventStream` so no separate crossterm dependency (with version-sync risk //! against ratatui's re-export) is needed. -mod form; mod view; use anyhow::{Context, Result, anyhow}; @@ -28,7 +28,6 @@ use tokio::sync::mpsc; use crate::instance; use crate::ipc::{IpcClient, IpcCmd, Request, Response, StatusSnapshot, WireConnSnapshot}; -use form::{FIELD_ENABLED, FormState}; /// The panel's transport to a client session: one request → one response, /// blocking the UI thread. Implemented over the control socket (a separate @@ -70,8 +69,6 @@ const REFRESH: Duration = Duration::from_secs(1); enum Mode { Normal, - Form(FormState), - ConfirmDelete { id: String, name: String }, /// On-demand connection-path overlay: a point-in-time snapshot (paths + /// custom-relay health) captured when opened, refreshable, not polled — /// mirrors the desktop modal / iOS sheet. @@ -80,11 +77,10 @@ enum Mode { struct App { snapshot: StatusSnapshot, - /// Selected row in the forwards table. - selected: usize, routing_scroll: u16, + forwards_scroll: u16, mode: Mode, - /// Transient error line (e.g. a rejected toggle), cleared on next input. + /// Transient error line (e.g. a failed path probe), cleared on next input. notice: Option, } @@ -215,8 +211,8 @@ impl App { fn new(snapshot: StatusSnapshot) -> Self { App { snapshot, - selected: 0, routing_scroll: 0, + forwards_scroll: 0, mode: Mode::Normal, notice: None, } @@ -246,14 +242,6 @@ impl App { } let quit = match &mut self.mode { Mode::Normal => self.handle_normal_key(key.code, backend)?, - Mode::Form(_) => { - self.handle_form_key(key.code, backend)?; - false - } - Mode::ConfirmDelete { .. } => { - self.handle_confirm_key(key.code, backend)?; - false - } Mode::ConnPath(_) => { self.handle_conn_path_key(key.code, backend)?; false @@ -265,48 +253,13 @@ impl App { } if last_refresh.elapsed() >= REFRESH { - // Poll-based refresh, like the desktop's ticker. Mutations - // also refresh inline via the returned snapshot. - self.set_snapshot(request_snapshot(backend, Request::Status)?); + // Poll-based refresh, like the desktop's ticker. + self.snapshot = request_snapshot(backend, Request::Status)?; last_refresh = Instant::now(); } } } - fn set_snapshot(&mut self, snapshot: StatusSnapshot) { - // Follow the selected forward by its stable id across the refresh: a - // row added or removed above it shifts the index, so clamping alone - // would silently move the selection to a different forward. - let selected_id = self - .selected_forward() - .map(|row| row.forward.id.clone()); - self.snapshot = snapshot; - self.selected = selected_id - .and_then(|id| self.snapshot.forwards.iter().position(|r| r.forward.id == id)) - .unwrap_or_else(|| self.selected.min(self.snapshot.forwards.len().saturating_sub(1))); - } - - /// Send a mutation; a fresh snapshot means success, an error message is - /// returned for the caller to surface (form line or footer notice). - fn mutate( - &mut self, - backend: &mut dyn ControlBackend, - request: Request, - ) -> Result> { - match backend.request(request)? { - Response::Status(snapshot) => { - self.set_snapshot(*snapshot); - Ok(None) - } - Response::Error { message } => Ok(Some(message)), - Response::ConnPath(_) => Ok(Some("unexpected conn-path response".to_string())), - } - } - - fn selected_forward(&self) -> Option<&crate::ipc::ForwardRow> { - self.snapshot.forwards.get(self.selected) - } - fn handle_normal_key( &mut self, code: KeyCode, @@ -314,10 +267,12 @@ impl App { ) -> Result { match code { KeyCode::Char('q') | KeyCode::Esc => return Ok(true), - KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1), + // Both panes clamp against their content height at render time. + KeyCode::Up | KeyCode::Char('k') => { + self.forwards_scroll = self.forwards_scroll.saturating_sub(1); + } KeyCode::Down | KeyCode::Char('j') => { - self.selected = (self.selected + 1) - .min(self.snapshot.forwards.len().saturating_sub(1)); + self.forwards_scroll = self.forwards_scroll.saturating_add(1); } KeyCode::Char('[') | KeyCode::PageUp => { self.routing_scroll = self.routing_scroll.saturating_sub(3); @@ -326,29 +281,6 @@ impl App { // Clamped against the content height at render time. self.routing_scroll = self.routing_scroll.saturating_add(3); } - KeyCode::Char('a') => self.mode = Mode::Form(FormState::add()), - KeyCode::Char('e') | KeyCode::Enter => { - if let Some(row) = self.selected_forward() { - self.mode = Mode::Form(FormState::edit(&row.forward)); - } - } - KeyCode::Char('d') => { - if let Some(row) = self.selected_forward() { - self.mode = Mode::ConfirmDelete { - id: row.forward.id.clone(), - name: form::display_name(&row.forward), - }; - } - } - KeyCode::Char(' ') => { - if let Some(row) = self.selected_forward() { - let request = Request::SetForwardEnabled { - id: row.forward.id.clone(), - enabled: !row.forward.enabled, - }; - self.notice = self.mutate(backend, request)?; - } - } // On-demand connection-path + custom-relay-health overlay (not polled). KeyCode::Char('p') => match self.request_conn_path(backend)? { Ok(snapshot) => self.mode = Mode::ConnPath(snapshot), @@ -392,76 +324,6 @@ impl App { } Ok(()) } - - fn handle_form_key( - &mut self, - code: KeyCode, - backend: &mut dyn ControlBackend, - ) -> Result<()> { - let Mode::Form(form) = &mut self.mode else { - return Ok(()); - }; - match code { - KeyCode::Esc => self.mode = Mode::Normal, - KeyCode::Tab | KeyCode::Down => form.focus_next(), - KeyCode::BackTab | KeyCode::Up => form.focus_prev(), - KeyCode::Enter => { - match form.validate(&self.snapshot.forwards) { - Err(message) => form.error = Some(message), - Ok(forward) => { - let request = if form.is_edit() { - Request::UpdateForward { forward } - } else { - Request::AddForward { forward } - }; - match self.mutate(backend, request)? { - // The running client rejected it (it re-validates - // authoritatively): keep the form open. - Some(message) => { - if let Mode::Form(form) = &mut self.mode { - form.error = Some(message); - } - } - None => self.mode = Mode::Normal, - } - } - } - } - KeyCode::Char(' ') if form.focus == FIELD_ENABLED => form.enabled = !form.enabled, - KeyCode::Char(c) => { - if let Some(text) = form.focused_text() { - text.push(c); - } - } - KeyCode::Backspace => { - if let Some(text) = form.focused_text() { - text.pop(); - } - } - _ => {} - } - Ok(()) - } - - fn handle_confirm_key( - &mut self, - code: KeyCode, - backend: &mut dyn ControlBackend, - ) -> Result<()> { - let Mode::ConfirmDelete { id, .. } = &self.mode else { - return Ok(()); - }; - match code { - KeyCode::Char('y') | KeyCode::Char('Y') => { - let request = Request::DeleteForward { id: id.clone() }; - self.mode = Mode::Normal; - self.notice = self.mutate(backend, request)?; - } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => self.mode = Mode::Normal, - _ => {} - } - Ok(()) - } } #[cfg(test)] @@ -475,7 +337,7 @@ mod tests { "macintel.toml", "aws.toml", "server.toml", - "forwards-abc.json", + "client-abc.lock", "client.key", ] { std::fs::write(dir.path().join(name), "").unwrap(); diff --git a/crates/flextunnel-cli/src/tui/view.rs b/crates/flextunnel-cli/src/tui/view.rs index c6a3de3..7f3ae9e 100644 --- a/crates/flextunnel-cli/src/tui/view.rs +++ b/crates/flextunnel-cli/src/tui/view.rs @@ -1,6 +1,6 @@ //! Rendering for the control panel, mirroring the desktop status page: -//! connection header, connection paths, routing breakdown, and the editable -//! port-forwards table. +//! connection header, connection paths, routing breakdown, and the +//! config-declared port forwards with their live state. use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; @@ -10,9 +10,6 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use crate::ipc::{ForwardRow, ForwardRowState, Phase, StatusSnapshot, WireConnSnapshot, WireRoutes}; -use super::form::{ - FIELD_ENABLED, FIELD_LABEL, FIELD_LOCAL_PORT, FIELD_REMOTE_HOST, FIELD_REMOTE_PORT, FormState, -}; use super::{App, Mode}; const DIM: Style = Style::new().fg(Color::DarkGray); @@ -44,14 +41,15 @@ pub fn draw(frame: &mut Frame, app: &App) { routing_area, ); - // Keep the selected row visible when the list is taller than the table: - // scroll just enough that the selection sits at the bottom edge. - let visible_forwards = forwards_area.height.saturating_sub(2) as usize; - let forwards_scroll = (app.selected + 1).saturating_sub(visible_forwards.max(1)) as u16; + let max_forwards_scroll = + (s.forwards.len() as u16).saturating_sub(forwards_area.height.saturating_sub(2)); frame.render_widget( - Paragraph::new(forward_lines(&s.forwards, app.selected, matches!(app.mode, Mode::Normal))) - .scroll((forwards_scroll, 0)) - .block(titled_block(&format!("Port forwards · {}", s.forwards.len()))), + Paragraph::new(forward_lines(&s.forwards)) + .scroll((app.forwards_scroll.min(max_forwards_scroll), 0)) + .block(titled_block(&format!( + "Port forwards · {} (↑/↓ to scroll)", + s.forwards.len() + ))), forwards_area, ); @@ -59,8 +57,6 @@ pub fn draw(frame: &mut Frame, app: &App) { match &app.mode { Mode::Normal => {} - Mode::Form(form) => draw_form(frame, form), - Mode::ConfirmDelete { name, .. } => draw_confirm(frame, name), Mode::ConnPath(snapshot) => draw_conn_path(frame, snapshot), } } @@ -290,7 +286,7 @@ fn routing_lines(r: &WireRoutes, status_host: &str) -> Vec> { fn forward_state_span(row: &ForwardRow) -> Span<'static> { match row.state { - ForwardRowState::Stopped => Span::styled("stopped", DIM), + ForwardRowState::Stopped => Span::styled("off", Style::new().fg(Color::Red)), ForwardRowState::Starting => Span::styled("starting", Style::new().fg(Color::Yellow)), ForwardRowState::Listening => Span::styled( format!("listening ({} active)", row.active), @@ -300,30 +296,21 @@ fn forward_state_span(row: &ForwardRow) -> Span<'static> { } } -fn forward_lines(forwards: &[ForwardRow], selected: usize, show_cursor: bool) -> Vec> { +fn forward_lines(forwards: &[ForwardRow]) -> Vec> { if forwards.is_empty() { return vec![Line::from(Span::styled( - "no port forwards — press a to add one", + "no port forwards — declare them as [[forwards]] in the client config", DIM, ))]; } forwards .iter() - .enumerate() - .map(|(i, row)| { + .map(|row| { let f = &row.forward; let mut spans = vec![ - Span::raw(if show_cursor && i == selected { "❯ " } else { " " }), + Span::raw(" "), Span::styled( - if f.enabled { "[on] " } else { "[off] " }, - if f.enabled { - Style::new().fg(Color::Green) - } else { - DIM - }, - ), - Span::styled( - format!("{:<20}", super::form::display_name(f)), + format!("{:<20}", f.display_name()), Style::new().add_modifier(Modifier::BOLD), ), Span::raw(format!( @@ -341,12 +328,7 @@ fn forward_lines(forwards: &[ForwardRow], selected: usize, show_cursor: bool) -> } else if let Some(err) = &row.last_conn_error { spans.push(Span::styled(format!(" last error: {err}"), DIM)); } - let line = Line::from(spans); - if show_cursor && i == selected { - line.style(Style::new().bg(Color::Rgb(40, 40, 40))) - } else { - line - } + Line::from(spans) }) .collect() } @@ -359,11 +341,7 @@ fn footer_line(app: &App) -> Line<'static> { )); } let hints = match app.mode { - Mode::Normal => { - "q quit · ↑/↓ select · space on/off · a add · e edit · d delete · p path · [/] scroll" - } - Mode::Form(_) => "Tab/Shift-Tab field · space toggle enabled · Enter save · Esc cancel", - Mode::ConfirmDelete { .. } => "y delete · n cancel", + Mode::Normal => "q quit · p path · [/] scroll routing · ↑/↓ scroll forwards", Mode::ConnPath(_) => "r refresh · Esc close", }; Line::from(Span::styled(hints, DIM)) @@ -380,64 +358,3 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { height, } } - -fn draw_form(frame: &mut Frame, form: &FormState) { - let area = centered(frame.area(), 56, 10); - frame.render_widget(Clear, area); - - let field = |idx: usize, name: &str, value: String| { - let focused = form.focus == idx; - Line::from(vec![ - Span::styled( - format!("{}{name:<12}", if focused { "❯ " } else { " " }), - if focused { - Style::new().add_modifier(Modifier::BOLD) - } else { - DIM - }, - ), - Span::styled( - if focused { format!("{value}█") } else { value }, - Style::new(), - ), - ]) - }; - - let mut lines = vec![ - field(FIELD_LABEL, "Label", form.label.clone()), - field(FIELD_LOCAL_PORT, "Local port", form.local_port.clone()), - field(FIELD_REMOTE_HOST, "Remote host", form.remote_host.clone()), - field(FIELD_REMOTE_PORT, "Remote port", form.remote_port.clone()), - field( - FIELD_ENABLED, - "Enabled", - (if form.enabled { "[x]" } else { "[ ]" }).to_string(), - ), - ]; - if let Some(err) = &form.error { - lines.push(Line::default()); - lines.push(Line::from(Span::styled( - err.clone(), - Style::new().fg(Color::Red), - ))); - } - - frame.render_widget( - Paragraph::new(lines).block(titled_block(if form.is_edit() { - "Edit port forward" - } else { - "Add port forward" - })), - area, - ); -} - -fn draw_confirm(frame: &mut Frame, name: &str) { - let area = centered(frame.area(), 44, 3); - frame.render_widget(Clear, area); - frame.render_widget( - Paragraph::new(Line::from(format!("Delete forward \"{name}\"? y/n"))) - .block(titled_block("Confirm")), - area, - ); -} diff --git a/crates/flextunnel-core/src/config.rs b/crates/flextunnel-core/src/config.rs index 2c2c387..c1bd355 100644 --- a/crates/flextunnel-core/src/config.rs +++ b/crates/flextunnel-core/src/config.rs @@ -93,6 +93,28 @@ pub struct ServerConfig { pub allowed_bridge_servers: Option>, } +/// One `[[forwards]]` entry in the client config: a server-direct port forward +/// `localhost:local_port` → `remote_host:remote_port`, opened on the +/// authenticated connection (the server enforces its routed set and resolves +/// names). Config-file only — the CLI client's forwards are declared here and +/// nowhere else; the control panel only shows them. Validated at startup +/// (nonzero ports, valid host, unique local ports), like the rest of the +/// config. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ForwardConfig { + /// Optional display label; the status rows show `remote_host:remote_port` + /// when it is empty. + #[serde(default)] + pub label: String, + /// Loopback port to listen on (binds `127.0.0.1`/`::1` only). + pub local_port: u16, + /// Host to connect to from the server's network — an IP literal or a name + /// the server resolves (host aliases apply). + pub remote_host: String, + pub remote_port: u16, +} + /// Client config file schema. Every field is optional; CLI flags override these. #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] @@ -101,7 +123,7 @@ pub struct ClientConfig { pub server_node_id: Option, /// Friendly display name for this profile (e.g. "aws", "home network"), /// shown in status UIs. Purely cosmetic — the client's on-disk identity - /// (lock, control socket, forwards file) is keyed by `server_node_id`. + /// (lock, control socket) is keyed by `server_node_id`. pub name: Option, /// Loopback port for the optional SOCKS5 listener (binds `127.0.0.1` /// only, like the desktop client — the front-ends are unauthenticated and @@ -132,6 +154,9 @@ pub struct ClientConfig { pub auto_reconnect: Option, /// Cap on reconnect attempts between successful connections. pub max_reconnect_attempts: Option, + /// Server-direct port forwards (`[[forwards]]` tables). Config-file only — + /// there is no CLI flag. + pub forwards: Option>, } /// Fully-resolved server settings (CLI > file > default), paths tilde-expanded. @@ -176,6 +201,9 @@ pub struct ResolvedClient { pub relay_auth_token: Option, pub auto_reconnect: bool, pub max_reconnect_attempts: Option, + /// Declared port forwards, in config order (validated by the client at + /// startup). + pub forwards: Vec, } /// Expand a leading `~` / `~/…` to the user's home directory. @@ -417,6 +445,7 @@ pub fn resolve_client(cli: ClientConfig, file: Option) -> Resolved relay_auth_token: cli.relay_auth_token.or(file.relay_auth_token), auto_reconnect: cli.auto_reconnect.or(file.auto_reconnect).unwrap_or(true), max_reconnect_attempts: cli.max_reconnect_attempts.or(file.max_reconnect_attempts), + forwards: cli.forwards.or(file.forwards).unwrap_or_default(), } } @@ -456,6 +485,40 @@ mod tests { assert_eq!(cfg.max_reconnect_attempts, NonZeroU32::new(5)); } + #[test] + fn client_forwards_parse_with_optional_label() { + let toml = r#" + server_node_id = "abc123" + + [[forwards]] + local_port = 5432 + remote_host = "db.internal" + remote_port = 5432 + + [[forwards]] + label = "nas" + local_port = 8443 + remote_host = "10.0.0.7" + remote_port = 443 + "#; + let cfg: ClientConfig = toml::from_str(toml).unwrap(); + let forwards = cfg.forwards.expect("forwards parsed"); + assert_eq!(forwards.len(), 2); + assert_eq!(forwards[0].label, ""); + assert_eq!(forwards[0].local_port, 5432); + assert_eq!(forwards[1].label, "nas"); + assert_eq!(forwards[1].remote_host, "10.0.0.7"); + + // Entries are strict too: a typo inside a table is a hard error, and a + // forward without a remote host is incomplete. + let err = toml::from_str::( + "[[forwards]]\nlocal_port = 1\nremote_hots = \"x\"\nremote_port = 1", + ) + .unwrap_err(); + assert!(err.to_string().contains("unknown field"), "{err}"); + assert!(toml::from_str::("[[forwards]]\nlocal_port = 1\nremote_port = 1").is_err()); + } + #[test] fn unknown_key_is_rejected() { // A misspelled key must be a hard error, not silently ignored. @@ -734,8 +797,15 @@ mod tests { relay_urls = ["https://relay.example"] auto_reconnect = true max_reconnect_attempts = 10 + + [[forwards]] + label = "db" + local_port = 5432 + remote_host = "db.internal" + remote_port = 5432 "#; let c: ClientConfig = toml::from_str(client).expect("maximal client config parses"); assert!(c.server_node_id.is_some() && c.max_reconnect_attempts.is_some()); + assert_eq!(c.forwards.as_deref().map(<[_]>::len), Some(1)); } } diff --git a/docs/systemd.md b/docs/systemd.md index e90e8b1..6a787b4 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -2,9 +2,8 @@ The CLI client is strictly **one process per server**: a `client.toml` holds a single `server_node_id`, and that id's prefix keys the client's on-disk -identity — the single-instance lock, the control socket, and the persisted -port forwards — so clients for different servers coexist with no extra -configuration. The natural systemd shape for that is a **template unit**: one +identity — the single-instance lock and the control socket — so clients for +different servers coexist with no extra configuration. The natural systemd shape for that is a **template unit**: one `flextunnel-client@` instance per server, each reading its own config file. (This mirrors what the desktop app does inside one process — one independent session per connected profile — the CLI just packages each session @@ -91,10 +90,10 @@ 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 -comes up with all forwards off. +Detaching (`q`) never affects the tunnel — the panel is read-only. Port +forwards are declared in the instance's config (`[[forwards]]` tables) and come +up with the unit; to change the set, edit the config and +`systemctl --user restart flextunnel-client@`. ## Duplicate configs