Skip to content

feat(tunnels): import SSH forwards as tunnels + working per-tunnel au…#81

Open
BungeeDEV wants to merge 1 commit into
macnev2013:mainfrom
BungeeDEV:feat/ssh-forward-import-autostart
Open

feat(tunnels): import SSH forwards as tunnels + working per-tunnel au…#81
BungeeDEV wants to merge 1 commit into
macnev2013:mainfrom
BungeeDEV:feat/ssh-forward-import-autostart

Conversation

@BungeeDEV

@BungeeDEV BungeeDEV commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

This pull request improves the SSH config import process by making port-forwarding (tunnel) imports idempotent and more robust. It introduces a natural-key lookup for port-forwarding rules to avoid duplicate tunnels when re-importing the same SSH config, and adds support for parsing and importing SSH port-forwarding directives directly from config files. The changes are covered by new tests to ensure correctness.

SSH config import improvements:

  • Added parsing and extraction of port-forwarding directives (LocalForward, RemoteForward, DynamicForward) from SSH config files, mapping them to a new SshForward struct and including them in SshConfigEntry and SshConfigImportEntry (src-tauri/src/import/mod.rs). [1] [2] [3] [4] [5] [6]

  • On import, port-forwarding rules are now created per host entry, but only if an identical rule does not already exist (idempotency). This is achieved by checking for an existing rule using a new natural-key lookup before creating a new one (src-tauri/src/import/commands.rs). [1] [2] [3]

Database enhancements:

  • Added find_pf_rule_id_by_natural_key and get_pf_rule methods to HostDb to support natural-key lookup and retrieval of port-forwarding rules, enabling idempotent import and easier rule management (src-tauri/src/db/mod.rs). [1] [2]

Testing and reliability:

  • Added tests to ensure that importing forwards is idempotent (no duplicate tunnels are created on repeated imports) and that the new database methods work as expected (src-tauri/src/db/mod.rs, src-tauri/src/import/commands.rs). [1] [2]

Greptile Summary

This PR adds SSH config forward-directive import (LocalForward/RemoteForward/DynamicForward → tunnel records), makes both host and tunnel import idempotent via natural-key lookups, introduces Dynamic (SOCKS5) forwarding, adds per-tunnel SSH connections for all forward types, and wires up auto-start/auto-stop of tunnels on host connect/disconnect.

  • Import idempotency: existing hosts are matched by (host, user, port) before inserting, and tunnel records are matched by their natural key before creating — re-importing the same SSH config is now a no-op, covered by new tests.
  • New forward types: Dynamic (SOCKS5 proxy with a hand-rolled negotiation loop) and Remote (ssh -R, server-side listener) are fully implemented end-to-end from the DB through the manager to the UI.
  • Lifecycle plumbing: session_hosts in SshManager tracks which sessions belong to which saved host so the last-disconnect event correctly tears down auto-started tunnels; reconnect in DisconnectOverlay intentionally delays the old-session teardown to avoid a false "host disconnected" flash.

Confidence Score: 3/5

Safe to merge for local and dynamic tunnel types; remote forwarding tunnels will silently appear as Active after the underlying SSH connection drops and never recover on their own.

The import and local/dynamic forwarding paths are well-structured and tested. The remote-tunnel lifecycle has a concrete gap: the spawned task parks on the cancel token with no SSH-connection-loss detection, so a dropped server connection leaves the tunnel stuck forever as Active in the map with no Stopped event to the UI. For a feature that is on by default, this is likely to surface quickly in real use.

Files Needing Attention: src-tauri/src/portforward/manager.rs — specifically start_remote_tunnel and RemoteForwardHandler; the per-connection cancel-token inconsistency is a secondary concern in the same file.

Important Files Changed

Filename Overview
src-tauri/src/portforward/manager.rs Adds Local/Dynamic SOCKS5 and Remote forwarding support with per-tunnel SSH connections; remote tunnels have a lifecycle gap where SSH connection loss is not detected, leaving tunnels indefinitely Active in the map.
src-tauri/src/portforward/commands.rs Refactors pf_start_tunnel to load the rule by id from the DB, adds start_auto_tunnels_for_host and stop_auto_tunnels_for_host helpers; logic is clean.
src-tauri/src/import/mod.rs Adds forward-directive parsing via a manual line scan; handles bare port, bind:port, and [ipv6]:port forms, skips unix sockets and wildcards, well-tested.
src-tauri/src/import/commands.rs Makes host import idempotent by matching existing (host, user, port) before inserting, then creates tunnel records idempotently; well-tested.
src-tauri/src/db/mod.rs Adds find_pf_rule_id_by_natural_key and get_pf_rule; SQL queries are parameterised, logic correct, tests cover happy path and no-match.
src-tauri/src/ssh/manager.rs Adds session_hosts map for host-to-session tracking to enable auto-tunnel teardown on last-disconnect.
src-tauri/src/ssh/commands.rs Passes host_id through connect calls and spawns auto-tunnel start after successful connect.
src/components/port-forwarding/PortForwardingPage.tsx Adds Dynamic/Remote type selector, adjusts labels, hides remote-port for SOCKS tunnels, shows type badges.
src/components/settings/SettingsPage.tsx Adds Tunnels settings section with auto-start-by-default toggle.
src/hooks/use-port-forward-events.ts Surfaces pf:status Error events as toasts for auto-start failures.
src/components/terminal/DisconnectOverlay.tsx Defers old-session disconnect until after new session is up to avoid premature auto-tunnel teardown.

Sequence Diagram

sequenceDiagram
    participant UI as Frontend UI
    participant Cmd as portforward/commands
    participant Mgr as PortForwardManager
    participant SSH as SshManager
    participant DB as HostDb

    Note over UI,DB: Host connect + auto-start tunnels
    UI->>SSH: ssh_connect(host_id)
    SSH-->>UI: SessionId
    SSH->>Cmd: start_auto_tunnels_for_host(host_id)
    Cmd->>DB: list_pf_rules(host_id)
    DB-->>Cmd: "rules where auto_start=true"
    loop each auto_start rule
        Cmd->>DB: build_host_config_blocking
        Cmd->>Mgr: "start_tunnel(rule, auto_started=true)"
        Mgr-->>UI: pf:status Active
    end

    Note over UI,DB: Manual tunnel start
    UI->>Cmd: pf_start_tunnel(rule_id)
    Cmd->>DB: get_pf_rule(rule_id)
    Cmd->>Mgr: "start_tunnel(rule, auto_started=false)"
    Mgr-->>UI: pf:status Active

    Note over UI,DB: Host disconnect
    UI->>SSH: ssh_disconnect(session_id)
    SSH->>SSH: remove from session_hosts
    alt last session for host
        SSH->>Cmd: stop_auto_tunnels_for_host
        Mgr-->>UI: pf:status Stopped per tunnel
    end

    Note over UI,DB: SSH config import
    UI->>Cmd: import_save_ssh_hosts(entries)
    Cmd->>DB: list_hosts() snapshot
    loop each entry
        Cmd->>DB: find existing or save_host
        loop each forward
            Cmd->>DB: find_pf_rule_id_by_natural_key
            alt not found
                Cmd->>DB: create_pf_rule
            end
        end
    end
    Cmd-->>UI: ImportResult
Loading

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
src-tauri/src/portforward/manager.rs:377-404
**Remote tunnel stays "Active" after SSH connection drops**

The spawned task blocks indefinitely on `cancel_token.cancelled().await`. There is no mechanism to detect a broken SSH connection — if the server crashes or the network goes down, `russh` calls the handler's `disconnected()` (not implemented by `RemoteForwardHandler`, so the default no-op fires), but the cancel token is never signalled. The entry in `self.tunnels` is never removed and no `pf:status Stopped` event is emitted. The UI shows the tunnel as live even though the server-side listener is gone and all new remote connections silently fail.

`RemoteForwardHandler` should implement `disconnected()` and cancel the token (or use a separate `JoinHandle` for the russh connection task and `select!` on it alongside the cancel token).

### Issue 2 of 5
src-tauri/src/portforward/manager.rs:632-641
**In-progress remote-forward connections are not cancelled when tunnel is stopped**

Each `server_channel_open_forwarded_tcpip` call pumps with `CancellationToken::new()` — a fresh, never-cancelled token — rather than reusing the tunnel's own cancel token. When `stop_tunnel` fires the tunnel's token, already-established remote channels keep running to natural termination. By contrast, the local-forward path propagates the tunnel's cancel token to every per-connection `proxy_to_remote` call. Using the tunnel-level cancel token here would make behaviour consistent.

### Issue 3 of 5
src-tauri/src/portforward/manager.rs:330-339
`tcpip_forward` returns `Ok(u32)` where the value is the server-allocated port when the requested port is 0. That value is silently discarded here. If a port-0 remote tunnel is ever created (the UI currently blocks it, but it can arrive via the import path), `local_port` in the `ActiveTunnel` entry would stay 0 and the status event would also report 0, giving users no indication of which port to connect to on the remote side.

```suggestion
        // Ask the server to start listening. A bind conflict on the *server* is
        // reported here as a request denial.
        let actual_listen_port = handle
            .tcpip_forward(bind_address.clone(), listen_port)
            .await
            .map_err(|e| {
                SshError::IoError(format!(
                    "server refused to forward {bind_address}:{listen_port}: {e}"
                ))
            })?;
        // When listen_port == 0 the server chooses; use the returned port.
        let listen_port = if listen_port == 0 { actual_listen_port } else { listen_port };
```

### Issue 4 of 5
src/stores/port-forward-store.ts:102-105
The `void hostId` idiom suppresses the "unused variable" lint but leaves dead code in place. Since the backend now resolves everything from `ruleId`, `hostId` can be dropped from the call.

```suggestion
      const status = await invoke<TunnelStatus>("pf_start_tunnel", { ruleId });
```

### Issue 5 of 5
src-tauri/src/db/mod.rs:1573-1574
`find_pf_rule_id_by_natural_key` has 6 non-`self` parameters, which is below Clippy's default limit of 7, so the `#[allow]` is unnecessary.

```suggestion
    pub fn find_pf_rule_id_by_natural_key(
```

Reviews (1): Last reviewed commit: "feat(tunnels): import SSH forwards as tu..." | Re-trigger Greptile

Greptile also left 5 inline comments on this PR.

…to-start

Parse LocalForward/RemoteForward/DynamicForward from ~/.ssh/config and
import each as a tunnel bound to the host. Make auto-start actually fire:
tunnels flagged auto_start start when their host connects and stop on the
host's last disconnect, with a global "auto-start by default" setting that
seeds new (and imported) tunnels.

- parser: Local/Remote/Dynamic directives ([bind:]port, [ipv6]:port,
  host:hostport); idempotent re-import via a natural key + host reuse
- backend forwarding: Local + Dynamic (SOCKS5) + Remote (tcpip_forward);
  each tunnel owns its own SSH connection with clean teardown and is
  ProxyJump-aware for Local/Dynamic
- lifecycle: start on connect_saved_host, stop when the host's last
  session disconnects; per-instance teardown guard so a reconnect can't
  let a stale listener clobber the restarted tunnel
- surface bind/start failures as toasts (friendly hint for ports <1024)
- frontend: forward-type selector, type/SOCKS badges, global Settings
  toggle seeding new tunnels' auto_start

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@BungeeDEV
BungeeDEV force-pushed the feat/ssh-forward-import-autostart branch from 8e8ee5a to 1bbb492 Compare June 16, 2026 02:44
@BungeeDEV
BungeeDEV marked this pull request as ready for review June 16, 2026 03:23
Comment on lines +377 to 404
tokio::spawn(async move {
cancel_token.cancelled().await;
let _ = handle
.cancel_tcpip_forward(bind_for_cancel, listen_port)
.await;
let _ = handle
.disconnect(russh::Disconnect::ByApplication, "", "en")
.await;
drop(handle);

// Only announce Stopped if this instance is still the live one (see
// the listener-tunnel teardown for the reasoning).
if tunnels
.remove_if(&rid, |_, t| t.instance_id == instance_id)
.is_some()
{
let _ = app_handle.emit(
"pf:status",
&TunnelStatus {
rule_id: rid,
status: TunnelState::Stopped,
local_port: listen_port,
connections: 0,
error: None,
},
);
}
let _ = app_handle.emit(
"pf:status",
&TunnelStatus {
rule_id: rid,
status: TunnelState::Stopped,
local_port: actual_port,
connections: 0,
error: None,
},
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Remote tunnel stays "Active" after SSH connection drops

The spawned task blocks indefinitely on cancel_token.cancelled().await. There is no mechanism to detect a broken SSH connection — if the server crashes or the network goes down, russh calls the handler's disconnected() (not implemented by RemoteForwardHandler, so the default no-op fires), but the cancel token is never signalled. The entry in self.tunnels is never removed and no pf:status Stopped event is emitted. The UI shows the tunnel as live even though the server-side listener is gone and all new remote connections silently fail.

RemoteForwardHandler should implement disconnected() and cancel the token (or use a separate JoinHandle for the russh connection task and select! on it alongside the cancel token).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src-tauri/src/portforward/manager.rs
Line: 377-404

Comment:
**Remote tunnel stays "Active" after SSH connection drops**

The spawned task blocks indefinitely on `cancel_token.cancelled().await`. There is no mechanism to detect a broken SSH connection — if the server crashes or the network goes down, `russh` calls the handler's `disconnected()` (not implemented by `RemoteForwardHandler`, so the default no-op fires), but the cancel token is never signalled. The entry in `self.tunnels` is never removed and no `pf:status Stopped` event is emitted. The UI shows the tunnel as live even though the server-side listener is gone and all new remote connections silently fail.

`RemoteForwardHandler` should implement `disconnected()` and cancel the token (or use a separate `JoinHandle` for the russh connection task and `select!` on it alongside the cancel token).

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +632 to +641
Ok(true)
}

Ok(())
async fn server_channel_open_forwarded_tcpip(
&mut self,
channel: russh::Channel<client::Msg>,
_connected_address: &str,
_connected_port: u32,
_originator_address: &str,
_originator_port: u32,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 In-progress remote-forward connections are not cancelled when tunnel is stopped

Each server_channel_open_forwarded_tcpip call pumps with CancellationToken::new() — a fresh, never-cancelled token — rather than reusing the tunnel's own cancel token. When stop_tunnel fires the tunnel's token, already-established remote channels keep running to natural termination. By contrast, the local-forward path propagates the tunnel's cancel token to every per-connection proxy_to_remote call. Using the tunnel-level cancel token here would make behaviour consistent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src-tauri/src/portforward/manager.rs
Line: 632-641

Comment:
**In-progress remote-forward connections are not cancelled when tunnel is stopped**

Each `server_channel_open_forwarded_tcpip` call pumps with `CancellationToken::new()` — a fresh, never-cancelled token — rather than reusing the tunnel's own cancel token. When `stop_tunnel` fires the tunnel's token, already-established remote channels keep running to natural termination. By contrast, the local-forward path propagates the tunnel's cancel token to every per-connection `proxy_to_remote` call. Using the tunnel-level cancel token here would make behaviour consistent.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +330 to +339
// Ask the server to start listening. A bind conflict on the *server* is
// reported here as a request denial.
handle
.tcpip_forward(bind_address.clone(), listen_port)
.await
.map_err(|e| {
SshError::IoError(format!(
"server refused to forward {bind_address}:{listen_port}: {e}"
))
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 tcpip_forward returns Ok(u32) where the value is the server-allocated port when the requested port is 0. That value is silently discarded here. If a port-0 remote tunnel is ever created (the UI currently blocks it, but it can arrive via the import path), local_port in the ActiveTunnel entry would stay 0 and the status event would also report 0, giving users no indication of which port to connect to on the remote side.

Suggested change
// Ask the server to start listening. A bind conflict on the *server* is
// reported here as a request denial.
handle
.tcpip_forward(bind_address.clone(), listen_port)
.await
.map_err(|e| {
SshError::IoError(format!(
"server refused to forward {bind_address}:{listen_port}: {e}"
))
})?;
// Ask the server to start listening. A bind conflict on the *server* is
// reported here as a request denial.
let actual_listen_port = handle
.tcpip_forward(bind_address.clone(), listen_port)
.await
.map_err(|e| {
SshError::IoError(format!(
"server refused to forward {bind_address}:{listen_port}: {e}"
))
})?;
// When listen_port == 0 the server chooses; use the returned port.
let listen_port = if listen_port == 0 { actual_listen_port } else { listen_port };
Prompt To Fix With AI
This is a comment left during a code review.
Path: src-tauri/src/portforward/manager.rs
Line: 330-339

Comment:
`tcpip_forward` returns `Ok(u32)` where the value is the server-allocated port when the requested port is 0. That value is silently discarded here. If a port-0 remote tunnel is ever created (the UI currently blocks it, but it can arrive via the import path), `local_port` in the `ActiveTunnel` entry would stay 0 and the status event would also report 0, giving users no indication of which port to connect to on the remote side.

```suggestion
        // Ask the server to start listening. A bind conflict on the *server* is
        // reported here as a request denial.
        let actual_listen_port = handle
            .tcpip_forward(bind_address.clone(), listen_port)
            .await
            .map_err(|e| {
                SshError::IoError(format!(
                    "server refused to forward {bind_address}:{listen_port}: {e}"
                ))
            })?;
        // When listen_port == 0 the server chooses; use the returned port.
        let listen_port = if listen_port == 0 { actual_listen_port } else { listen_port };
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Comment on lines +102 to +105
// The backend loads the rule (forward type + params) by id; hostId is
// kept in the signature for callers but no longer sent.
void hostId;
const status = await invoke<TunnelStatus>("pf_start_tunnel", { ruleId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The void hostId idiom suppresses the "unused variable" lint but leaves dead code in place. Since the backend now resolves everything from ruleId, hostId can be dropped from the call.

Suggested change
// The backend loads the rule (forward type + params) by id; hostId is
// kept in the signature for callers but no longer sent.
void hostId;
const status = await invoke<TunnelStatus>("pf_start_tunnel", { ruleId });
const status = await invoke<TunnelStatus>("pf_start_tunnel", { ruleId });
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/stores/port-forward-store.ts
Line: 102-105

Comment:
The `void hostId` idiom suppresses the "unused variable" lint but leaves dead code in place. Since the backend now resolves everything from `ruleId`, `hostId` can be dropped from the call.

```suggestion
      const status = await invoke<TunnelStatus>("pf_start_tunnel", { ruleId });
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment thread src-tauri/src/db/mod.rs
Comment on lines +1573 to +1574
#[allow(clippy::too_many_arguments)]
pub fn find_pf_rule_id_by_natural_key(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 find_pf_rule_id_by_natural_key has 6 non-self parameters, which is below Clippy's default limit of 7, so the #[allow] is unnecessary.

Suggested change
#[allow(clippy::too_many_arguments)]
pub fn find_pf_rule_id_by_natural_key(
pub fn find_pf_rule_id_by_natural_key(
Prompt To Fix With AI
This is a comment left during a code review.
Path: src-tauri/src/db/mod.rs
Line: 1573-1574

Comment:
`find_pf_rule_id_by_natural_key` has 6 non-`self` parameters, which is below Clippy's default limit of 7, so the `#[allow]` is unnecessary.

```suggestion
    pub fn find_pf_rule_id_by_natural_key(
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant