Reverse port forwarding and auto-reconnect#17
Conversation
…ler wiring - Add IncomingForward struct and update ClientHandler from unit struct to hold an optional UnboundedSender<IncomingForward> for server_channel_open_forwarded_tcpip - Change Session.handle from Arc<Handle> to Arc<Mutex<Handle>> so tcpip_forward (&mut self) can be called through the shared session clone - Add Session::tcpip_forward and Session::cancel_tcpip_forward public methods - Add forwarded_rx and reverse_map fields to ForwardManager; update constructor - Refactor ForwardManager::run() to tokio::select! over cmd_rx and forwarded_rx - Extract handle_command dispatcher; rename handle_start/stop/pause to _local variants - Add handle_start_reverse, handle_stop_reverse, handle_incoming for SSH -R support - Update Session::connect signature to accept forwarded_tx (None passed for now) - Wire forwarded_rx into ForwardManager::new in main.rs
The channel was created and immediately dropped; the ClientHandler had no sender to deliver incoming reverse-forward channels to ForwardManager. Create the channel before Session::connect and pass Some(forwarded_tx).
- Validate connected_port u32→u16 cast in server_channel_open_forwarded_tcpip (server-provided values should not silently truncate) - Remove unused event_tx/remote_port clones from handle_incoming - Remove ForwardKey::reverse() helper (unused until Task 5 UI wires it) - Make Pause no-op for Reverse kind explicit with comment linking to reconcile guard
- Add AppMode (Forward/Reverse) to Model; `m` key toggles between modes - LocalScanReceived now sets needs_render = true - Extend ModalState::PortInput with `kind: ForwardKind` and `local_port: u16` - Reverse mode: Enter/f on local scan row opens reverse modal (remote bind port prompt) - Enter/f on InactiveReverseForward sends Stop to remove persisted forward - Add DisplayRow::LocalPort and InactiveReverseForward; build_display_rows branches on mode - format_local_fwd (->:N) and format_reverse_fwd (<-:N) for FWD column - Header shows M:Fwd/M:Rev chip and separate fwd/rev active counts - Hotkey bar updates based on current mode; adds m Mode hint - Modal: direction-aware title, label, and border color (cyan=local, magenta=reverse) - BindError handler populates new PortInput fields correctly for both kinds - notify.rs already correct: uses ForwardKey::local() so reverse never collides
- Stop active/starting reverse forwards on Enter/f (was silently no-op) - Fix InactiveReverseForward comment: stored value is remote bind port, not local port - Fix port_count in header to use local_ports.len() in Reverse mode - Fix hotkey label: "Enter/f" → "Enter" in Reverse mode (no separate custom-port shortcut) - Replace dead Pause/Reverse arm with unreachable! to document the invariant
When SSH session drops, the sidecar now reconnects transparently instead of exiting: exponential backoff (1s → 30s cap), fresh Session::connect and DiscoveryStream::start each cycle, ForwardManager gracefully shut down (listeners aborted so ports are released) before the next cycle. Model changes: - ConnectionState gains Reconnecting variant; DiscoveryError/StreamEnded no longer set running=false - New messages: ConnectionLost, Reconnecting, Reconnected - ConnectionLost pauses all forward entries (preserves desired state) - Reconnected re-issues Reactivate for reverse forwards; local forwards reactivate via the existing reconcile_forwards on next ScanReceived ForwardManager refactor: - No longer owns cmd_rx / forwarded_rx; run() borrows them so the same command channel is reused across reconnect cycles without buffering - Shutdown via oneshot channel aborts all local listeners on teardown UI: shows "Reconnecting..." status and yellow indicator during backoff
- reconnect_with_backoff: attempt connection immediately (no leading sleep); sleep only after a failed attempt, then double the backoff - Change return type from (&mut receiver mutation) to (Session, UnboundedReceiver) so callers no longer need to create a dummy channel before the call - Fix misleading comment in handle_pause_local: abort_handle is already inert, stored only to preserve local_port/remote_host for the Reactivate path
…to-reconnect - port-forwarding.md: ForwardKind/ForwardKey, reverse lifecycle, AppMode, updated modal triggers, reverse display rows, persistence backward-compat - tui-architecture.md: ConnectionState::Reconnecting, reconnect loop design, ForwardManager per-cycle lifetime - README: reverse forwarding feature, auto-reconnect, updated keyboard shortcuts, updated TUI screenshots, architecture notes
There was a problem hiding this comment.
Pull request overview
Adds reverse (SSH -R-style) port forwarding and a reconnecting session “sidecar” so the TUI can survive SSH drops and restore forwards automatically.
Changes:
- Introduces
ForwardKind/ForwardKeyto support both Local (->) and Reverse (<-) forwards with persistence updates for backward compatibility. - Adds a sidecar reconnect loop with exponential backoff and session-cycle teardown/rebuild, plus new connection lifecycle messages for the UI.
- Extends the TUI with a mode toggle (
m), reverse-forward UI flows, and a local-port scanner moved intosshfwd-common.
Reviewed changes
Copilot reviewed 23 out of 25 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/sshfwd/src/app.rs | Adds mode/reconnect message handling and reverse-forward commands/modal flow. |
| crates/sshfwd/src/main.rs | Implements sidecar session cycle + reconnect loop and wires reverse-forward channel ingress. |
| crates/sshfwd/src/forward/mod.rs | Adds ForwardKind/ForwardKey and reverse-forward manager logic (tcpip_forward + incoming channel handling). |
| crates/sshfwd/src/ssh/session.rs | Adds forwarded-tcpip handler + tcpip_forward/cancel_tcpip_forward APIs via Arc<Mutex<Handle>>. |
| crates/sshfwd/src/ui/table.rs | Adds Forward/Reverse table row building and reverse-forward glyph rendering. |
| crates/sshfwd/src/ui/header.rs | Adds reconnect indicator + mode chip and per-kind forward counts. |
| crates/sshfwd/src/ui/modal.rs | Makes port modal direction-aware (local vs remote bind port). |
| crates/sshfwd/src/ui/hotkey_bar.rs | Updates hotkey hints to be mode-aware. |
| crates/sshfwd/src/discovery/local.rs | New local port scan task used for Reverse mode. |
| crates/sshfwd-common/src/scanner/* | Exposes scanner from sshfwd-common and fixes crate-local imports / defaults. |
| README.md | Documents reverse mode + reconnect behavior and UI screenshots. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Reverse forwards are keyed by remote bind port (ForwardKey::remote_port),
but the Reverse-mode table is organized by local port. build_reverse_rows,
format_reverse_fwd, and open_reverse_modal were all looking up
ForwardKey { Reverse, local_port } — which only hits when local and remote
bind ports happen to be identical.
Fix: search forwards by entry.local_port instead of by key.remote_port.
build_reverse_rows builds a local_port→ForwardKey map upfront; the other
two sites do a linear find (forwards map is tiny).
The macOS scanner was a stub returning empty ports, causing Reverse mode to show nothing. Implement using `lsof -nP -iTCP -sTCP:LISTEN`, which lists all listening TCP sockets with PID and command. Deduplicates by port (same port can appear for both IPv4 and IPv6). Uses a per-scan ProcessInfo cache to avoid calling ps once per socket. Also remove #[allow(dead_code)] from spawn_local_scan now that it is wired into run_session_cycle.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 26 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…tartup reactivation - session.rs: pass forwarded_tx to ProxyJump final tunnel hop so reverse forwarding works through jump hosts (was always None) - app.rs: adjust_selection uses local_port (not remote_port) for Reverse forwards in Stopped and BindError handlers so cursor stays on the right row - main.rs: send Reconnected before first session cycle so persisted Reverse forwards reactivate on fresh start; remove unreachable None arm in next_event match - table.rs: fix Reverse splash gate (&& → ||) to match Forward mode behavior - macos.rs: remove String allocation in sort key; fix dedup comment - README: update ASCII screenshots to match actual header layout and hotkeys
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 26 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…logic
Add a 12-second discovery timeout so the sidecar forces a reconnect when
no scan arrives (e.g. network partition) instead of blocking on TCP timeout.
Simplify: use DisplayRow::is_selectable() in adjust_selection and 'G' key;
rename open_reverse_modal → handle_reverse_action; call adjust_selection on
LocalScanReceived; replace format!("{}") with .to_string(); use
ForwardKey::reverse() in port-input handler; remove fwd_count pre-compute
in header; drop unnecessary move on spawn_blocking closure.
…s; check lsof exit status If a reverse forward is Active but its local port is no longer in the local scan, display it as InactiveReverseForward so the user knows the local service has gone away (previously only Paused entries were shown). Return an error from scan_listening_ports() when lsof exits non-zero instead of silently treating partial/empty stdout as a valid result.
Summary
mto toggle mode): pressmto switch to Reverse mode, which shows locally-listening ports. Select a local service and pressEnterto expose it on a configurable remote bind port (SSH-Rstyle via russhtcpip_forward). Reverse forwards persist across restarts and reactivate automatically on reconnect.Reactivatecommands onReconnected. UI shows a yellowReconnecting...indicator.Key changes
sshfwd-common: scanner module moved here fromsshfwd-agentso the main binary can run local port scans in-processforward/mod.rs:ForwardKind/ForwardKeycomposite key;handle_start_reverse/handle_stop_reverse/handle_incoming;ForwardManageris now created per session cycle with borrowedcmd_rxand a shutdown oneshotssh/session.rs:ClientHandlerdelivers incoming reverse channels viaserver_channel_open_forwarded_tcpip;Session.handlewrapped inArc<Mutex>fortcpip_forward (&mut self)app.rs:AppMode,ConnectionState::Reconnecting,ConnectionLost/Reconnecting/Reconnectedmessages;DiscoveryError/StreamEndedno longer exit the appmain.rs: outer reconnect loop (run_sidecar/run_session_cycle/reconnect_with_backoff)M:Fwd/M:Rev),<-:NNNNreverse glyph, direction-aware modal labels,m Modehotkey, yellow reconnecting stateTest plan
m— table switches to local ports, header showsM:RevEnter, enter a remote bind port —<-:NNNNappears in tablecurl 127.0.0.1:<bind-port>reaches the local serviceReconnecting..., reconnects automaticallyforwards.json(nokindfield) loads correctly as Local entries