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