Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a4290bb
Add .worktrees/ to .gitignore
gogoout Apr 26, 2026
92b832c
A1: Extract scanner module from sshfwd-agent into sshfwd-common
gogoout Apr 26, 2026
c790757
A3: Add ForwardKind/ForwardKey, update all forward types to use keyed…
gogoout Apr 26, 2026
4c07dd3
A2: Add local port scan task and LocalScanReceived message
gogoout Apr 26, 2026
72ec0fe
A2: Suppress dead_code warnings on spawn_local_scan (wired in reconne…
gogoout Apr 26, 2026
cd366fa
A4+A5: Implement reverse forwarding in ForwardManager with ClientHand…
gogoout Apr 26, 2026
e9d88e6
A4+A5: Wire forwarded_tx into Session::connect for reverse forwarding
gogoout Apr 26, 2026
bffb303
A4+A5: Fix code quality issues from review
gogoout Apr 26, 2026
3ca9a72
A6+A7+A8: Mode toggle, reverse UI, direction-aware modal
gogoout Apr 26, 2026
ef322c1
A6+A7+A8: Fix code quality issues from review
gogoout Apr 26, 2026
fdae003
B: Add auto-reconnect with exponential backoff
gogoout Apr 26, 2026
263a207
B: Fix reconnect backoff ordering and clean up dummy receiver pattern
gogoout Apr 26, 2026
fe5f57d
docs: update CLAUDE.md rules and README for reverse forwarding and au…
gogoout Apr 26, 2026
9d9fff3
Fix reverse mode showing no active ports
gogoout Apr 27, 2026
7ab86cf
Implement macOS local port scanner using lsof
gogoout Apr 27, 2026
f648bfc
Align Enter/f hotkey label in Reverse mode; show Remote/Local port co…
gogoout Apr 27, 2026
db25866
Clarify Session Mutex comment; remove restating comments
gogoout Apr 27, 2026
769970d
Address PR review comments: selection hints, ProxyJump reverse fwd, s…
gogoout Apr 27, 2026
9361ef4
Fix clippy::collapsible_match: move if bodies into match guards
gogoout Apr 27, 2026
cab9678
Fix reconnect on network partition; simplify display row and forward …
gogoout May 1, 2026
8ebfe71
Show active reverse forwards as inactive when local service disappear…
gogoout May 1, 2026
ffa2326
Bump version to 0.3.0
gogoout May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 66 additions & 19 deletions .claude/rules/port-forwarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,102 @@

`ForwardManager` (in `forward/mod.rs`) runs on the same tokio runtime as discovery. It receives `ForwardCommand`s via an `mpsc` channel and sends `ForwardEvent`s back via the `crossbeam` background channel.

Each forward binds a local `TcpListener` on `127.0.0.1`, accepts connections, and tunnels them through `russh` `channel_open_direct_tcpip`.
A `ForwardManager` is created per session cycle and torn down on disconnect; the command channel (`fwd_cmd_rx`) is borrowed across cycles so commands queued during reconnect are not lost.

## Forward kinds

Two kinds of forwarding exist, distinguished by `ForwardKind` and keyed by `ForwardKey`:

```rust
pub enum ForwardKind { Local, Reverse }
pub struct ForwardKey { pub kind: ForwardKind, pub remote_port: u16 }
```

- **Local** (`ForwardKind::Local`): binds a local `TcpListener` on `127.0.0.1` and tunnels accepted connections to the remote via `channel_open_direct_tcpip`. `remote_port` = remote app's port.
- **Reverse** (`ForwardKind::Reverse`): calls `tcpip_forward` on the SSH server so it listens on `remote_port` and pushes incoming connections back via `server_channel_open_forwarded_tcpip`. The handler connects them to `127.0.0.1:local_port`. `remote_port` = the bind port on the SSH server.

`model.forwards: HashMap<ForwardKey, ForwardEntry>` holds all active/paused forwards.

## Forward lifecycle

1. `Start` → bind local port → `Started` event (or `BindError`)
### Local

1. `Start { kind: Local, remote_port, local_port, remote_host }` → bind local port → `Started` (or `BindError`)
2. Remote port disappears from scan → `Pause` → abort listener, keep handle for reactivation
3. Remote port reappears → `Reactivate` → re-bind using remembered `local_port`
4. User stops → `Stop` → abort listener, remove handle, `Stopped` event
4. User stops → `Stop` → abort listener, remove handle, `Stopped`

### Reverse

1. `Start { kind: Reverse, remote_port, local_port, remote_host: "127.0.0.1" }` → `tcpip_forward(remote_port)` → server listens → `Started` (or `BindError`)
2. Incoming connection via `server_channel_open_forwarded_tcpip` → `handle_incoming` → connect to `127.0.0.1:local_port` → bidirectional copy
3. User stops → `Stop` → `cancel_tcpip_forward(remote_port)` → `Stopped`
4. On reconnect → `Reactivate { kind: Reverse, ... }` is issued for all reverse entries by `Message::Reconnected` handler

`reconcile_forwards` (scan-driven reactivation) skips `kind != Local` — reverse forwards don't depend on the remote scan.

## No random port fallback

Bind failures immediately send `BindError`. The TUI reopens the port input modal with the error message so the user can pick a different port. Never silently fall back to port 0.

## AppMode and mode toggle

```rust
pub enum AppMode { Forward, Reverse }
```

`m` key toggles `model.mode`. In Forward mode the table shows remote scan ports + local forwards. In Reverse mode it shows local scan ports (from `model.local_ports`) + reverse forward entries.

Local scan is performed by `discovery::local::spawn_local_scan`, which runs every 2 seconds via `sshfwd_common::scanner::create_scanner()` in a `spawn_blocking` task. It is started per session cycle alongside the remote discovery stream.

## Modal UI (`ModalState`)

`ModalState` in `app.rs` replaces the old `InputMode`:
`ModalState` in `app.rs`:
- `None` — normal navigation
- `PortInput { remote_port, buffer, remote_host, error }` — centered modal overlay
- `PortInput { kind, remote_port, local_port, buffer, remote_host, error }` — centered modal overlay

Triggers:
- `F` / `Shift+Enter` on a non-forwarded port → opens modal with `error: None`
- `BindError` event → opens modal with `error: Some(message)`, pre-filled with the failed port
- Typing in the modal clears the error field
**Forward mode triggers:**
- `Enter`/`f` on unforwarded remote port → immediate same-port start (no modal)
- `F`/`Shift+Enter` on unforwarded remote port → open Local modal (`Local port:` label)
- `BindError` event → modal with `error: Some(message)`, pre-filled port

`Enter`/`f` on a non-forwarded port starts forwarding immediately (same local port, no modal).
**Reverse mode triggers:**
- `Enter`/`f` on unforwarded local port → open Reverse modal (`Remote bind port:` label), buffer pre-filled with local port as default
- `Enter`/`f` on active Reverse forward → stop it (toggles off like Forward mode)
- `Enter`/`f` on `InactiveReverseForward` → `Stop` (removes persisted entry)

## Display rows and table grouping

`ui/table.rs` exposes `DisplayRow` enum and `build_display_rows(model)`:
- Forwarded ports and inactive forwards merged together (sorted by port)
- `InactiveForward(u16)` — paused forwards whose remote port is not in the current scan, shown when `model.show_inactive_forwards` is true (toggle `p`), rendered dim with `(inactive)` label
`ui/table.rs` exposes `DisplayRow` enum and `build_display_rows(model)`, which branches on `model.mode`:

**Forward mode:**
- Forwarded ports + inactive forwards merged (sorted by port)
- `InactiveForward(u16)` — paused Local forwards whose remote port is not in current scan, shown when `model.show_inactive_forwards` is true (toggle `p`)
- Separator row (dim `─`, only when both groups non-empty)
- Non-forwarded ports (original sort: port → PID → protocol)
- Non-forwarded remote ports (sort: port → PID → protocol)

`selected_index` is a visual index into display rows. Navigation skips separator rows. `adjust_selection()` in `app.rs` preserves the selected port across display-row reorderings (scan updates, forward start/stop).
**Reverse mode:**
- `LocalPort(usize)` — index into `model.local_ports`; shows active Reverse forward status if one exists
- `InactiveReverseForward(u16)` — remote bind port of a paused Reverse forward whose local source port is not in current local scan
- FWD column glyphs: `->:NNNN` (Local, NNNN = local port), `<-:NNNN` (Reverse, NNNN = remote bind port)

Pressing `Enter`/`f` on an inactive forward sends `ForwardCommand::Stop`, removing the persisted forward.
Header shows `M:Fwd` (cyan) or `M:Rev` (magenta) mode chip, and mode-appropriate port count.

`selected_index` is a visual index into display rows. Navigation skips separator rows. `adjust_selection()` preserves the selected port across reorderings.

## Persistence

`forward/persistence.rs` stores active forwards in `~/.sshfwd/forwards.json`, keyed by destination string. On startup, persisted forwards load as `Paused` and reactivate when the first scan finds their remote port. The `Reactivate` command carries `local_port` so it works even without a prior listener handle (fresh process).
`forward/persistence.rs` stores active forwards in `~/.sshfwd/forwards.json`, keyed by destination string. `PersistedForward` has `#[serde(default)] kind: ForwardKind` so old files without `kind` load as `Local` (backward-compatible).

On startup, all persisted forwards load as `Paused`. Local entries reactivate when the first scan finds their remote port. Reverse entries reactivate when `Message::Reconnected` is processed (issues `Reactivate` for all `kind == Reverse` entries).

## Desktop notifications

`notify.rs` sends fire-and-forget notifications (via `notify-rust`) when ports change between scans. Change detection lives in `notify::detect_port_changes()`, called from `app::update()` on each `ScanReceived`. Uses `model.prev_scan_ports` diff; first scan is skipped (no baseline). Changes are batched via `NotifyBatch` with a 2-second debounce — rapid successive scans produce one combined notification instead of many. The batch flushes on `Tick` after the quiet period. Controlled by `model.notifications_enabled` (CLI flag `--no-notify`). On macOS, the icon is set to Terminal.app via `set_application("com.apple.Terminal")`; on Linux, uses `utilities-terminal` freedesktop icon.
`notify.rs` sends fire-and-forget notifications (via `notify-rust`) when ports change between scans. Change detection lives in `notify::detect_port_changes()`, called from `app::update()` on each `ScanReceived`. Uses `model.prev_scan_ports` diff; first scan is skipped (no baseline). Uses `ForwardKey::local(port)` — reverse-bind ports are never matched against the remote scan. Changes are batched via `NotifyBatch` with a 2-second debounce. The batch flushes on `Tick` after the quiet period. Controlled by `model.notifications_enabled` (CLI flag `--no-notify`).

## Adding a new ForwardCommand

1. Add variant to `ForwardCommand` in `forward/mod.rs`
2. Handle in `ForwardManager::run()` match
2. Handle in `ForwardManager::handle_command()` match — dispatch per `ForwardKind` if direction matters
3. Send corresponding `ForwardEvent` back
4. Handle the event in `app::update()` `Message::ForwardEvent` branch
28 changes: 28 additions & 0 deletions .claude/rules/tui-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,34 @@ All TUI state flows through `crates/sshfwd/src/app.rs`:
- `view()` takes `&mut Model` — `render()` writes `table_state` and `table_content_area` for mouse hit-testing
- Display rows and table grouping details: see [Port Forwarding](/.claude/rules/port-forwarding.md)

## Connection state

`ConnectionState` in `app.rs`:
```rust
pub enum ConnectionState { Connecting, Connected, Reconnecting, Disconnected }
```

Transitions:
- Initial connect → `Connecting` → `Connected` on first `ScanReceived`
- SSH drop → `ConnectionLost` message → `Reconnecting` (all `ForwardEntry.status` set to `Paused`)
- Backoff loop sends `Reconnecting` messages while retrying
- New session up → `Reconnected` message → `Connecting` (Reverse entries get `Reactivate` commands; Local entries reactivate via scan reconciliation)
- First scan after reconnect → `Connected`

`DiscoveryError` and `StreamEnded` do **not** set `model.running = false` — the sidecar reconnect loop manages session lifecycle.

## Sidecar reconnect loop

The sidecar thread (`main.rs`) runs `run_sidecar`, which contains an outer reconnect loop:

1. `DiscoveryStream::start` for current session
2. On success: send `Reconnected`, spawn local scan, create `ForwardManager`, drive discovery
3. On discovery end: signal `ForwardManager` shutdown (oneshot), await graceful shutdown (aborts all listener tasks), abort local scan, send `ConnectionLost`
4. Reconnect: send `Reconnecting` immediately, try `Session::connect`, sleep and double backoff (cap 30s) only on failure; reset backoff to 1s on success
5. Loop from step 1

`ForwardManager` is created per session cycle via `ForwardManager::new(session, event_tx)` and shut down via `shutdown_rx: oneshot::Receiver<()>`. The command channel receiver (`fwd_cmd_rx`) is owned by the sidecar and borrowed by each manager so queued commands survive reconnects.

## Exit gotcha

`process::exit(0)` is called after terminal restore. Do NOT try graceful cleanup via destructors:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
target/
.worktrees/
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
resolver = "2"

[workspace.package]
version = "0.2.0"
version = "0.3.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/gogoout/sshfwd.rs"
Expand Down Expand Up @@ -42,7 +42,7 @@ notify-rust = "4"
thiserror = "2"

# Workspace crates
sshfwd-common = { version = "0.2.0", path = "crates/sshfwd-common" }
sshfwd-common = { version = "0.3.0", path = "crates/sshfwd-common" }

# Optimized release profile for the agent binary (small, statically linked)
[profile.release-agent]
Expand Down
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ A TUI-based SSH port forwarding management tool built with Rust. Inspired by [k9

- **Automatic port detection** — deploys a lightweight agent that streams listening ports in real time
- **One-key forwarding** — `Enter`/`f` to forward with matching local port, `F`/`Shift+Enter` for custom port
- **Reverse forwarding** — press `m` to switch to Reverse mode; pick a local service and expose it on a remote port (SSH `-R` style)
- **Smart lifecycle management** — auto-pauses when remote port disappears, reactivates when it returns (unlike VS Code's stale forwards)
- **Auto-reconnect** — transparently reconnects with exponential backoff on connection drop; all forwards restore automatically
- **Clear error recovery** — bind failures show a modal to choose a different port (no silent fallbacks)
- **Visual grouping** — forwarded ports appear at the top, separated from unforwarded ports
- **Inactive forward visibility** — toggle `p` to show persisted forwards whose remote port isn't running
Expand Down Expand Up @@ -60,17 +62,33 @@ sshfwd user@hostname --agent-path ./target/debug/sshfwd-agent

### TUI Interface

**Forward mode** (default) — shows remote listening ports:

```
╭ ● user@host │ 5 ports │ 2 fwd ────────────────────╮
╭ ● user@host │ 5 remote ports │ 2 fwd │ M:Fwd ─────╮
│ FWD PORT PROTO PID COMMAND │
│▶->:5432 5432 tcp 1234 postgresql/15/..│
│ ->:8080 8080 tcp6 5678 node server.js │
│ ──────── ──────── ─────── ──────── ────────────────│
│ 3000 tcp 9012 ruby bin/rails s│
│ 6379 tcp 3456 redis-server │
╰───────────────────────────────────────────────────╯
<j/k>Navigate <g/G>Top/Bottom <Enter/f>Forward <F>Custom Port <p>Inactive <q>Quit
╰────────────────────────────────────────────────────╯
<j/k>Navigate <g/G>Top/Bottom <Enter/f>Forward <F>Custom Port <m>Mode <p>Inactive <q>Quit
```

**Reverse mode** (`m` to toggle) — shows local listening ports and exposes them on the remote:

```
╭ ● user@host │ 3 local ports │ 1 rev │ M:Rev ──────╮
│ FWD PORT PROTO PID COMMAND │
│▶<-:8080 3000 tcp 9012 ruby bin/rails s│
│ 5173 tcp 1234 vite │
│ 5432 tcp 3456 postgresql │
╰────────────────────────────────────────────────────╯
<j/k>Navigate <g/G>Top/Bottom <Enter/f>Reverse <m>Mode <p>Inactive <q>Quit
```
Comment thread
gogoout marked this conversation as resolved.

`<-:8080` means local port 3000 is exposed on remote port 8080. Press `Enter` on a local port to configure the remote bind port.

Forwarded ports are grouped at the top with a visual separator.

Expand All @@ -96,8 +114,9 @@ When pressing `F`/`Shift+Enter`, or when a bind error occurs:
| `k` / `Up` | Move selection up |
| `g` | Jump to top |
| `G` | Jump to bottom |
| `Enter` / `f` | Toggle forwarding (same local port) |
| `F` / `Shift+Enter` | Forward with custom local port (modal) |
| `m` | Toggle Forward / Reverse mode |
| `Enter` / `f` | Toggle forwarding (Forward: same local port; Reverse: opens modal) |
| `F` / `Shift+Enter` | Forward with custom local port — Forward mode only |
| `p` | Toggle inactive persisted forwards |
| `q` / `Esc` / `Ctrl+C` | Quit |

Expand Down Expand Up @@ -147,10 +166,12 @@ See [CLAUDE.md](./CLAUDE.md) for development rules and workspace conventions.
- Event loop uses the [dua-cli pattern](https://github.com/Byron/dua-cli): dedicated OS thread for keyboard input, `crossbeam_channel::select!` multiplexing

**Port Forwarding:**
- `ForwardManager` runs on a tokio runtime alongside discovery
- Each forward binds a local `TcpListener`, accepts connections, and tunnels them via `russh` `channel_open_direct_tcpip`
- Forward states: `Starting` → `Active` / `Paused` (port disappeared) / modal reopened on bind error
- Forwards persist to `~/.sshfwd/forwards.json` keyed by destination
- `ForwardManager` runs on a tokio runtime alongside discovery; one manager per session cycle, torn down and rebuilt on reconnect
- **Local** (`->:N`): binds a local `TcpListener`, tunnels accepted connections via `channel_open_direct_tcpip`
- **Reverse** (`<-:N`): calls `tcpip_forward` on the SSH server; incoming connections are pushed back via `server_channel_open_forwarded_tcpip` and forwarded to `127.0.0.1:local_port`
- Forward states: `Starting` → `Active` / `Paused` (port disappeared or disconnected) / modal reopened on bind error
- Forwards persist to `~/.sshfwd/forwards.json` keyed by destination; backward-compatible (old files load as Local)
- Auto-reconnect: exponential backoff 0s → 30s cap; all listener tasks are aborted cleanly on disconnect so ports are released before the next bind

**Data Flow:**
```
Expand All @@ -172,6 +193,7 @@ See [CLAUDE.md](./CLAUDE.md) for development rules and workspace conventions.
- **Atomic upload** — temp file → `mv` → `chmod +x` prevents mid-upload execution
- **Stale cleanup** — verifies `/proc/{pid}/comm` before killing to avoid hitting reused PIDs
- **No random port fallback** — bind failures surface immediately via error modal so the user stays in control
- **Reconnect over swap** — on disconnect, `ForwardManager` is torn down (aborting all listener tasks) and rebuilt fresh; simpler than live session swapping and reuses the existing reactivation path

## License

Expand Down
1 change: 0 additions & 1 deletion crates/sshfwd-agent/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,3 @@ publish = false
[dependencies]
sshfwd-common = { workspace = true }
serde_json = { workspace = true }
libc = { workspace = true }
5 changes: 2 additions & 3 deletions crates/sshfwd-agent/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
mod scanner;

use std::io::{self, Write};
use std::thread;
use std::time::Duration;

use sshfwd_common::scanner::create_scanner;
use sshfwd_common::types::{AgentError, AgentErrorKind, AgentResponse};

const SCAN_INTERVAL: Duration = Duration::from_secs(2);
Expand All @@ -20,7 +19,7 @@ fn main() {

write_pid_file();

let mut scanner = scanner::create_scanner();
let mut scanner = create_scanner();
let stdout = io::stdout();

loop {
Expand Down
57 changes: 0 additions & 57 deletions crates/sshfwd-agent/src/scanner/macos.rs

This file was deleted.

1 change: 1 addition & 0 deletions crates/sshfwd-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ keywords = ["ssh", "internal"]
categories = ["command-line-utilities"]

[dependencies]
libc = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
1 change: 1 addition & 0 deletions crates/sshfwd-common/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod scanner;
pub mod types;
Loading
Loading