Skip to content

Reverse port forwarding and auto-reconnect#17

Merged
gogoout merged 22 commits into
mainfrom
evan/feat-reverse-mode
May 1, 2026
Merged

Reverse port forwarding and auto-reconnect#17
gogoout merged 22 commits into
mainfrom
evan/feat-reverse-mode

Conversation

@gogoout
Copy link
Copy Markdown
Owner

@gogoout gogoout commented Apr 26, 2026

Summary

  • Reverse forwarding (m to toggle mode): press m to switch to Reverse mode, which shows locally-listening ports. Select a local service and press Enter to expose it on a configurable remote bind port (SSH -R style via russh tcpip_forward). Reverse forwards persist across restarts and reactivate automatically on reconnect.
  • Auto-reconnect: the app no longer exits when the SSH connection drops. A sidecar reconnect loop retries with exponential backoff (immediate first attempt, up to 30s cap). All forwards restore: Local via scan reconciliation, Reverse via explicit Reactivate commands on Reconnected. UI shows a yellow Reconnecting... indicator.

Key changes

  • sshfwd-common: scanner module moved here from sshfwd-agent so the main binary can run local port scans in-process
  • forward/mod.rs: ForwardKind/ForwardKey composite key; handle_start_reverse/handle_stop_reverse/handle_incoming; ForwardManager is now created per session cycle with borrowed cmd_rx and a shutdown oneshot
  • ssh/session.rs: ClientHandler delivers incoming reverse channels via server_channel_open_forwarded_tcpip; Session.handle wrapped in Arc<Mutex> for tcpip_forward (&mut self)
  • app.rs: AppMode, ConnectionState::Reconnecting, ConnectionLost/Reconnecting/Reconnected messages; DiscoveryError/StreamEnded no longer exit the app
  • main.rs: outer reconnect loop (run_sidecar/run_session_cycle/reconnect_with_backoff)
  • UI: mode chip (M:Fwd/M:Rev), <-:NNNN reverse glyph, direction-aware modal labels, m Mode hotkey, yellow reconnecting state

Test plan

  • Forward mode: existing local-forward behaviour unchanged
  • Press m — table switches to local ports, header shows M:Rev
  • Select a local service, press Enter, enter a remote bind port — <-:NNNN appears in table
  • On remote: curl 127.0.0.1:<bind-port> reaches the local service
  • Quit and restart — reverse forward reactivates from persistence
  • Disconnect SSH session — app shows yellow Reconnecting..., reconnects automatically
  • After reconnect: both Local and Reverse forwards restore without restarting
  • Old forwards.json (no kind field) loads correctly as Local entries

gogoout added 13 commits April 26, 2026 19:35
…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
Copilot AI review requested due to automatic review settings April 26, 2026 22:22
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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/ForwardKey to 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 into sshfwd-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.

Comment thread crates/sshfwd/src/app.rs
Comment thread crates/sshfwd/src/ui/hotkey_bar.rs Outdated
Comment thread README.md
Comment thread crates/sshfwd/src/main.rs
Comment thread crates/sshfwd/src/ui/table.rs
Comment thread crates/sshfwd/src/app.rs Outdated
gogoout added 2 commits April 27, 2026 15:37
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.
Copilot AI review requested due to automatic review settings April 27, 2026 14:49
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread crates/sshfwd/src/ui/table.rs Outdated
Comment thread crates/sshfwd/src/app.rs Outdated
Comment thread crates/sshfwd-common/src/scanner/macos.rs Outdated
Comment thread crates/sshfwd/src/main.rs Outdated
Comment thread crates/sshfwd/src/ssh/session.rs Outdated
gogoout added 2 commits April 27, 2026 22:57
…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
Copilot AI review requested due to automatic review settings April 27, 2026 22:07
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread crates/sshfwd-common/src/scanner/macos.rs
Comment thread crates/sshfwd/src/ui/table.rs Outdated
Comment thread crates/sshfwd/src/app.rs
Comment thread crates/sshfwd-common/src/scanner/macos.rs
gogoout added 3 commits May 1, 2026 18:29
…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.
@gogoout gogoout merged commit 128dd41 into main May 1, 2026
7 checks passed
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.

2 participants