diff --git a/CHANGELOG.md b/CHANGELOG.md
index f77640ff..6e98a6bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to tty7 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- **A port forward can be switched off without losing its rule** (#439). A
+ forward could only be removed, so pointing one local port at a different
+ remote target meant deleting the rule and typing the other one in again. Each
+ forward in the Ports section, and each rule under Settings → SSH → Port
+ forwarding, now has a switch: off releases the listener and keeps the rule in
+ the list, on binds it again from the rule it was made from. Switching one on
+ while another switched-on forward holds its port is refused with a notice
+ naming that forward, rather than failing at bind time. A switch flipped in the
+ panel on a rule that came from a saved host is written back to that host, so
+ the next connection opens the same set; a rule saved switched off is listed
+ but not opened. Profiles saved before this load with every rule on.
+
## [26.9.3] - 2026-09-23
### Added
diff --git a/assets/icons/power.svg b/assets/icons/power.svg
new file mode 100644
index 00000000..16fc83f7
--- /dev/null
+++ b/assets/icons/power.svg
@@ -0,0 +1 @@
+
diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs
index 97a43d5c..651aaea0 100644
--- a/crates/tty7-core/src/core/ssh_profile.rs
+++ b/crates/tty7-core/src/core/ssh_profile.rs
@@ -153,6 +153,16 @@ pub struct ForwardRule {
pub bind: HostPort,
pub target: HostPort,
pub description: String,
+ /// Whether the rule is opened with the connection. A switched-off rule
+ /// keeps its whole configuration and simply binds nothing, so one local
+ /// port can be pointed at different targets by switching rules rather
+ /// than retyping them.
+ ///
+ /// Missing from every profile saved before the switch existed, and those
+ /// rules were all live: the container's `#[serde(default)]` fills it from
+ /// `Default`, which says `true`, or upgrading would silently turn every
+ /// saved forward off.
+ pub enabled: bool,
}
impl Default for ForwardRule {
@@ -162,6 +172,7 @@ impl Default for ForwardRule {
bind: HostPort::default(),
target: HostPort::default(),
description: String::new(),
+ enabled: true,
}
}
}
@@ -569,6 +580,7 @@ mod tests {
bind: HostPort::new("127.0.0.1", 8080),
target: HostPort::new("10.0.0.1", 80),
description: "web".to_string(),
+ enabled: false,
}];
original.socks_proxy = Some(HostPort::new("proxy", 1080));
original.algorithms.kex = vec!["curve25519-sha256".to_string()];
@@ -597,6 +609,25 @@ mod tests {
let m: AuthMode = serde_json::from_str("\"agent\"").unwrap();
assert_eq!(m, AuthMode::Agent);
}
+
+ #[test]
+ fn a_forward_rule_saved_before_the_switch_existed_loads_enabled() {
+ let old = r#"{"name":"box","host":"h","forwards":[
+ {"kind":"local","bind":{"host":"127.0.0.1","port":8080},
+ "target":{"host":"srv","port":80},"description":"web"}
+ ]}"#;
+ let p: SshProfile = serde_json::from_str(old).unwrap();
+ assert_eq!(p.forwards.len(), 1);
+ assert!(p.forwards[0].enabled, "an old rule must stay live");
+ assert!(ForwardRule::default().enabled);
+
+ let mut off = p.forwards[0].clone();
+ off.enabled = false;
+ let back: ForwardRule =
+ serde_json::from_str(&serde_json::to_string(&off).unwrap()).unwrap();
+ assert!(!back.enabled, "a switched-off rule stays off across a save");
+ assert_eq!(back, off);
+ }
}
fn new_id() -> Uuid {
diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs
index 3f9632f4..cc89de7e 100644
--- a/crates/tty7-core/src/daemon/protocol.rs
+++ b/crates/tty7-core/src/daemon/protocol.rs
@@ -181,6 +181,10 @@ pub enum WorkspaceOp {
RemoveForward {
forward_id: u64,
},
+ SetForwardEnabled {
+ forward_id: u64,
+ enabled: bool,
+ },
ListForwards,
TeardownForwards,
SftpList {
@@ -261,6 +265,13 @@ pub struct SshForwardRule {
pub target_port: u16,
#[serde(default)]
pub description: Option,
+ /// A switched-off rule is registered but binds nothing. Defaults to on,
+ /// and is not a `PROTOCOL_VERSION` bump: a peer that predates it never
+ /// sends it and every rule it knows is live, while one that receives it
+ /// ignores it — the worst case is an older far end opening a rule the
+ /// user switched off, which is what it always did.
+ #[serde(default = "default_true")]
+ pub enabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -420,6 +431,13 @@ pub struct ManagedForward {
#[serde(default)]
pub description: Option,
pub status: ForwardStatus,
+ /// Whether the forward is switched on. A field rather than a
+ /// `ForwardStatus` variant for the reason `loop_exit_status` gives: an
+ /// older peer fails the whole frame on a variant it has never heard of,
+ /// but skips a field it does not know. A switched-off forward's status is
+ /// an `Error` saying so, which is what such a peer then draws.
+ #[serde(default = "default_true")]
+ pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -892,6 +910,11 @@ pub enum ClientMsg {
pane_id: u64,
forward_id: u64,
},
+ SetForwardEnabled {
+ pane_id: u64,
+ forward_id: u64,
+ enabled: bool,
+ },
ListForwards {
pane_id: u64,
},
@@ -987,6 +1010,7 @@ mod kind {
pub const ADD_FORWARD: u8 = 20;
pub const REMOVE_FORWARD: u8 = 21;
pub const LIST_FORWARDS: u8 = 22;
+ pub const SET_FORWARD_ENABLED: u8 = 23;
pub const VERSION: u8 = 40;
pub const QUERY_PROCS: u8 = 50;
pub const ON_WORKSPACE: u8 = 52;
@@ -1260,6 +1284,15 @@ impl ClientMsg {
pane_id,
forward_id,
} => write_frame(w, kind::REMOVE_FORWARD, &to_json(&(pane_id, forward_id))?),
+ ClientMsg::SetForwardEnabled {
+ pane_id,
+ forward_id,
+ enabled,
+ } => write_frame(
+ w,
+ kind::SET_FORWARD_ENABLED,
+ &to_json(&(pane_id, forward_id, enabled))?,
+ ),
ClientMsg::QueryProcs { pane_id } => {
write_frame(w, kind::QUERY_PROCS, &to_json(pane_id)?)
}
@@ -1390,6 +1423,14 @@ impl ClientMsg {
forward_id,
}
}
+ kind::SET_FORWARD_ENABLED => {
+ let (pane_id, forward_id, enabled) = from_json(&payload)?;
+ ClientMsg::SetForwardEnabled {
+ pane_id,
+ forward_id,
+ enabled,
+ }
+ }
kind::LIST_FORWARDS => ClientMsg::ListForwards {
pane_id: from_json(&payload)?,
},
@@ -1758,6 +1799,7 @@ mod tests {
target_host: "10.0.0.5".into(),
target_port: 80,
description: Some("web".into()),
+ enabled: true,
},
},
ClientMsg::AddForward {
@@ -1769,12 +1811,18 @@ mod tests {
target_host: String::new(),
target_port: 0,
description: None,
+ enabled: false,
},
},
ClientMsg::RemoveForward {
pane_id: 7,
forward_id: 3,
},
+ ClientMsg::SetForwardEnabled {
+ pane_id: 7,
+ forward_id: 3,
+ enabled: false,
+ },
ClientMsg::ListForwards { pane_id: 7 },
ClientMsg::Version,
];
@@ -1934,6 +1982,7 @@ mod tests {
target_port: 80,
description: Some("web".into()),
status: ForwardStatus::Listening,
+ enabled: true,
},
ManagedForward {
id: 2,
@@ -1945,6 +1994,7 @@ mod tests {
target_port: 3000,
description: None,
status: ForwardStatus::Error("bind refused".into()),
+ enabled: false,
},
]),
DaemonMsg::Version(DaemonVersion {
@@ -2385,6 +2435,7 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 80,
description: Some("web".into()),
+ enabled: true,
}],
keepalive_interval_s: Some(30),
keepalive_count_max: Some(3),
@@ -2471,9 +2522,14 @@ mod tests {
target_host: "127.0.0.1".into(),
target_port: 5432,
description: Some("db".into()),
+ enabled: true,
},
},
WorkspaceOp::RemoveForward { forward_id: 4 },
+ WorkspaceOp::SetForwardEnabled {
+ forward_id: 4,
+ enabled: true,
+ },
WorkspaceOp::ListForwards,
WorkspaceOp::TeardownForwards,
WorkspaceOp::SftpList {
@@ -2702,4 +2758,39 @@ mod tests {
"the session daemon must not advertise a dialect it cannot serve"
);
}
+
+ /// `enabled` crosses the same peers as `rejected` above and gets the same
+ /// treatment: a rule or a forward from a peer that predates the switch is
+ /// a live one, and a peer that predates it reads a frame carrying it.
+ #[test]
+ fn a_forward_enabled_flag_decodes_from_a_peer_that_never_sends_it() {
+ let rule: SshForwardRule = serde_json::from_str(
+ r#"{"kind":"local","bind_host":"127.0.0.1","bind_port":8080,
+ "target_host":"srv","target_port":80}"#,
+ )
+ .unwrap();
+ assert!(rule.enabled);
+
+ let managed: ManagedForward = serde_json::from_str(
+ r#"{"id":1,"pane_id":7,"kind":"local","bind_host":"127.0.0.1",
+ "bind_port":8080,"status":"listening"}"#,
+ )
+ .unwrap();
+ assert!(managed.enabled);
+
+ #[derive(Deserialize)]
+ struct LegacyManagedForward {
+ id: u64,
+ status: ForwardStatus,
+ }
+ let off = serde_json::to_string(&ManagedForward {
+ enabled: false,
+ status: ForwardStatus::Error("disabled".into()),
+ ..managed
+ })
+ .unwrap();
+ let legacy: LegacyManagedForward = serde_json::from_str(&off).unwrap();
+ assert_eq!(legacy.id, 1);
+ assert_eq!(legacy.status, ForwardStatus::Error("disabled".into()));
+ }
}
diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs
index d2a43c73..f0da41de 100644
--- a/crates/tty7-core/src/daemon/server.rs
+++ b/crates/tty7-core/src/daemon/server.rs
@@ -1035,6 +1035,31 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> {
Ok(())
}
+ ClientMsg::SetForwardEnabled {
+ pane_id,
+ forward_id,
+ enabled,
+ } => {
+ let mut w = write_stream;
+ // Only switching on needs the connection; switching off has to
+ // work on a pane whose connection is already gone.
+ let conn = match forward_pane_connection(®istry, pane_id) {
+ Ok(conn) => Some(conn),
+ Err(e) if enabled => {
+ DaemonMsg::Error(e).encode(&mut w)?;
+ return Ok(());
+ }
+ Err(_) => None,
+ };
+ match crate::daemon::ssh::SshManager::global()
+ .set_forward_enabled(pane_id, conn, forward_id, enabled)
+ {
+ Ok(list) => DaemonMsg::ForwardList(list).encode(&mut w)?,
+ Err(e) => DaemonMsg::Error(e).encode(&mut w)?,
+ }
+ Ok(())
+ }
+
ClientMsg::QueryProcs { pane_id } => {
let mut w = write_stream;
let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default();
diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs
index 726ff9f1..e6d055ef 100644
--- a/crates/tty7-core/src/daemon/ssh/forward.rs
+++ b/crates/tty7-core/src/daemon/ssh/forward.rs
@@ -257,17 +257,39 @@ fn bind_failed(rule: &SshForwardRule, e: io::Error) -> SharedStatus {
))))
}
+/// What a switched-off forward reports. An `Error` for the same reason as
+/// `loop_exit_status`, so an older peer reading it draws a stopped forward
+/// with a word of explanation; a current one reads `enabled` instead.
+fn disabled_status() -> SharedStatus {
+ Arc::new(Mutex::new(ForwardStatus::Error("disabled".to_string())))
+}
+
+/// Whether two forwards would want the same listening socket: the same port,
+/// on the same side of the connection. Local and dynamic forwards both listen
+/// here; a remote forward listens on the far host.
+fn same_socket(a: SshForwardKind, a_port: u16, b: SshForwardKind, b_port: u16) -> bool {
+ let far = |k| k == SshForwardKind::Remote;
+ a_port != 0 && a_port == b_port && far(a) == far(b)
+}
+
struct ForwardEntry {
id: u64,
kind: SshForwardKind,
bind_host: String,
bind_port: u16,
+ /// The port the rule asked for, which `bind_port` is not when that was 0
+ /// and the OS picked one. Switching the forward back on asks again for
+ /// this, not for whatever it happened to get last time.
+ requested_port: u16,
target_host: String,
target_port: u16,
description: Option,
status: SharedStatus,
cancel: ForwardCancel,
auto_local: bool,
+ /// Switched off: the entry, and the rule in it, stay listed, but nothing
+ /// is bound and `cancel` is `None`.
+ enabled: bool,
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
@@ -288,6 +310,19 @@ impl ForwardEntry {
target_port: self.target_port,
description: self.description.clone(),
status: self.status.lock().unwrap().clone(),
+ enabled: self.enabled,
+ }
+ }
+
+ fn rule(&self) -> SshForwardRule {
+ SshForwardRule {
+ kind: self.kind,
+ bind_host: self.bind_host.clone(),
+ bind_port: self.requested_port,
+ target_host: self.target_host.clone(),
+ target_port: self.target_port,
+ description: self.description.clone(),
+ enabled: self.enabled,
}
}
}
@@ -318,6 +353,23 @@ impl SshForwardRegistry {
.await
}
+ pub async fn set_enabled(
+ &self,
+ pane_id: u64,
+ conn: Option>,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ self.set_enabled_owned(
+ &ForwardOwner::Pane(pane_id),
+ pane_id,
+ conn,
+ forward_id,
+ enabled,
+ )
+ .await
+ }
+
pub async fn teardown_pane(&self, pane_id: u64) {
self.teardown_owned(&ForwardOwner::Pane(pane_id)).await;
}
@@ -347,6 +399,24 @@ impl SshForwardRegistry {
.await
}
+ pub async fn set_enabled_workspace(
+ &self,
+ workspace: WorkspaceId,
+ view_pane: u64,
+ conn: Arc,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ self.set_enabled_owned(
+ &ForwardOwner::Workspace(workspace),
+ view_pane,
+ Some(conn),
+ forward_id,
+ enabled,
+ )
+ .await
+ }
+
pub async fn teardown_workspace(&self, workspace: WorkspaceId) {
self.teardown_owned(&ForwardOwner::Workspace(workspace))
.await;
@@ -360,22 +430,24 @@ impl SshForwardRegistry {
rule: &SshForwardRule,
) -> ManagedForward {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
- let (bind_port, status, cancel) = match rule.kind {
- SshForwardKind::Local => self.start_local(&conn, rule).await,
- SshForwardKind::Dynamic => self.start_dynamic(&conn, rule).await,
- SshForwardKind::Remote => self.start_remote(&conn, rule).await,
+ let (bind_port, status, cancel) = if rule.enabled {
+ self.start(&conn, rule).await
+ } else {
+ (rule.bind_port, disabled_status(), ForwardCancel::None)
};
let entry = ForwardEntry {
id,
kind: rule.kind,
bind_host: rule.bind_host.clone(),
bind_port,
+ requested_port: rule.bind_port,
target_host: rule.target_host.clone(),
target_port: rule.target_port,
description: rule.description.clone(),
status,
cancel,
auto_local: false,
+ enabled: rule.enabled,
};
let managed = entry.to_managed(view_pane);
self.owners
@@ -413,20 +485,145 @@ impl SshForwardRegistry {
})
};
if let Some(entry) = removed {
- Self::cancel_entry(entry).await;
+ Self::cancel(entry.cancel).await;
}
self.list_owned(owner, view_pane)
}
+ /// Switch one forward off or back on, keeping its entry — and so its
+ /// place in the list and the rule it was made from — either way.
+ ///
+ /// Switching on refuses, rather than fails at bind time with whatever the
+ /// OS says, when another switched-on forward of the same owner already
+ /// holds the port: the answer then names that forward, which is the one
+ /// the user has to switch off first. A forward that cannot be started for
+ /// any other reason stays off, with the reason as the error.
+ async fn set_enabled_owned(
+ &self,
+ owner: &ForwardOwner,
+ view_pane: u64,
+ conn: Option>,
+ forward_id: u64,
+ enabled: bool,
+ ) -> Result, String> {
+ if !enabled {
+ let cancel = {
+ let mut owners = self.owners.lock().unwrap();
+ let entry = Self::entry_mut(&mut owners, owner, forward_id)?;
+ entry.enabled = false;
+ entry.status = disabled_status();
+ std::mem::replace(&mut entry.cancel, ForwardCancel::None)
+ };
+ Self::cancel(cancel).await;
+ return Ok(self.list_owned(owner, view_pane));
+ }
+
+ let Some(rule) = self.enable_plan(owner, forward_id)? else {
+ return Ok(self.list_owned(owner, view_pane));
+ };
+ let Some(conn) = conn else {
+ return Err("the SSH connection is gone — reconnect and try again".to_string());
+ };
+ let (bound, status, cancel) = self.start(&conn, &rule).await;
+ if let Some(e) = bind_error(&status) {
+ Self::cancel(cancel).await;
+ return Err(e);
+ }
+ if let Some(cancel) = self.finish_enable(owner, forward_id, bound, status, cancel) {
+ // Removed while it was being started: nobody wants what was bound.
+ Self::cancel(cancel).await;
+ }
+ Ok(self.list_owned(owner, view_pane))
+ }
+
+ fn entry_mut<'a>(
+ owners: &'a mut HashMap>,
+ owner: &ForwardOwner,
+ forward_id: u64,
+ ) -> Result<&'a mut ForwardEntry, String> {
+ owners
+ .get_mut(owner)
+ .and_then(|entries| entries.iter_mut().find(|e| e.id == forward_id))
+ .ok_or_else(|| format!("no forward {forward_id}"))
+ }
+
+ /// The rule to start for switching `forward_id` on, `None` when it is on
+ /// already, or why it may not be switched on.
+ fn enable_plan(
+ &self,
+ owner: &ForwardOwner,
+ forward_id: u64,
+ ) -> Result