Skip to content

Interactive TUI, SSH 2FA, multi-forward tunnels, and connection testing#37

Open
aretcamgoz wants to merge 57 commits into
alebeck:mainfrom
aretcamgoz:tui-and-2fa
Open

Interactive TUI, SSH 2FA, multi-forward tunnels, and connection testing#37
aretcamgoz wants to merge 57 commits into
alebeck:mainfrom
aretcamgoz:tui-and-2fa

Conversation

@aretcamgoz
Copy link
Copy Markdown

This is a sizeable, multi-feature contribution — four related additions plus a couple of fixes. I'm aware that's a lot for a single PR; I'm happy to split it into separate focused PRs, or to open issues to discuss scope first, if you'd prefer — just say the word.

Everything is backward compatible: existing configs and the existing CLI commands (open/close/list/edit) behave exactly as before.

Features

Interactive terminal UI — boring tui

A Bubble Tea dashboard for managing tunnels and editing the config without hand-editing .boring.toml:

  • Live, colour-coded status (open / reconnecting / needs-auth / closed).
  • Open, close and test connections; add / edit / delete tunnels through a form that writes back to .boring.toml (the original is backed up once as .boring.toml.bak).
  • Multi-forward tunnels render as a grouped tree.

SSH interactive authentication — 2FA and key passphrases

  • Keyboard-interactive (2FA / one-time codes) and passphrase-protected private keys are now supported. boring open prompts on the terminal; the TUI shows a modal.
  • The daemon relays auth prompts to the connected client via a multi-message Open IPC exchange (AuthPrompt/AuthReply).
  • A 2FA tunnel that drops is not blindly auto-reconnected (a fresh code is required); it rests at a new needs auth status so the user can re-open it.

Multiple forwards over one SSH connection

  • One [[tunnels]] entry can carry multiple [[tunnels.forward]] blocks — one SSH connection serving many port forwards (one handshake, one authentication, one 2FA prompt). The legacy single-local/remote form is unchanged: it is simply a tunnel with one forward, so existing configs need no edits.

boring test (alias t)

  • Verifies a tunnel's SSH handshake + authentication without opening a listener — boring test <patterns>....

Fixes

  • Honour the IdentityAgent ssh_config directive, so agents configured that way (e.g. 1Password's SSH agent) are used — previously only $SSH_AUTH_SOCK was consulted.
  • Offer an explicitly-configured identity key before unconfigured agent keys, so a server's MaxAuthTries limit no longer cuts off the right key when the agent holds many.

Notes

  • New dependencies: charmbracelet/bubbletea, lipgloss, bubbles (TUI only).
  • ~9k lines across 89 files. All five CI static-analysis gates pass (gofmt, go vet, gocyclo ≤ 15, ineffassign, misspell); combined unit + e2e coverage is 84.5%.
  • README updated; the design rationale can be shared if useful.

Test plan

  • `make test` — unit + e2e suites
  • `make cover` — coverage report
  • Static analysis: gofmt / go vet / gocyclo / ineffassign / misspell
  • Manual: `boring tui`; `boring test`; a multi-forward `[[tunnels.forward]]` config; a 2FA-protected host

aretcamgoz added 30 commits May 20, 2026 13:02
aretcamgoz added 27 commits May 20, 2026 15:32
The daemon's per-tunnel cleanup goroutine unconditionally deleted a
tunnel from d.tunnels once its run() exited, so a dropped 2FA
(keyboard-interactive) tunnel vanished from `list` and showed as
closed — the NeedsAuth status was unreachable in practice.

Hold a tunnel.Desc snapshot of a dropped 2FA tunnel in a separate
needsAuth map. The snapshot is taken in the cleanup goroutine after
<-t.Closed (run() has exited, t.Status is stable) under d.mutex, so
no live tunnel's status is read off-lock. listTunnels merges the map
into its response; openTunnel clears the entry when re-opening;
closeTunnel succeeds against a needsAuth-only entry by dropping it.

Fix the TUI's selectedIsRunning so a NeedsAuth tunnel (now reported
by list) counts as not-running: enter on it re-opens (re-auth)
rather than closes.
Enforce the multi-forward validation rules in config.Load:

- A tunnel must not set both the legacy local/remote shorthand and
  [[tunnels.forward]] blocks. Checked from the raw pre-normalization
  state in validateRawForwards, before normalizeForwards folds the
  shorthand away.
- A tunnel must define at least one forward, via either form. Also
  checked in validateRawForwards.
- Each forward's local/remote addresses are validated against its mode
  (local required for local, remote and socks modes; remote required
  for local, remote and socks-remote modes) in validateForwards,
  called from Validate.
- Forward names, when set, must be unique within a tunnel.

Update stale TUI test fixtures that built forward-less Desc values,
which config.Load now correctly rejects on the post-save reload.

The mode -> canonical-name mapping lives once as the exported
tunnel.Mode.ConfigValue method; config validation and the TUI form
call it instead of keeping their own copies.
The 'open' confirmation message read the legacy singular
LocalAddress/RemoteAddress/Mode fields, which are unset for tunnels
configured with [[tunnels.forward]] blocks. Migrate it to a
describeForwards helper that renders every forward from Desc.Forwards.

Add daemon unit tests for the Cmd/Resp JSON round-trip carrying a
multi-forward Desc, and e2e coverage for list/close of a multi-forward
tunnel.
boring list now renders each tunnel from Desc.Forwards instead of the
legacy singular local/remote/mode fields. A single-forward tunnel still
renders inline on one line; a multi-forward tunnel renders a
connection-level header row plus one indented branch sub-row per forward,
each showing the forward's label and local -> remote.

The grouped-tree layout lives in a new internal/table/tunnels.go
(TunnelTable) because the generic flat Table cannot express a header row
followed by indented sub-rows with their own aligned columns. Forward.label
is exported as Forward.Label so the CLI can reuse it.
@alebeck
Copy link
Copy Markdown
Owner

alebeck commented May 22, 2026

Hi @aretcamgoz, thanks for your interest in the project and your contributions. My goal is to keep boring minimal/lightweight and to avoid scope creep, which means to be conservative regarding new features.

That said, I think there might be some interesting additions/fixes in your PR. Give me some time to experiment with it, and we can potentially formulate 1-2 issues for smaller-scoped changes based on that and work from there?

@alebeck
Copy link
Copy Markdown
Owner

alebeck commented May 22, 2026

Hi @aretcamgoz, I think two good first bug fixes that we can spin off into dedicated issues (and later PRs) would be:

  • IdentityAgent ssh_config directive was ignored
  • Explicitly-configured identity key offered too late

More on the scope-expanding side, but potentially worth exploring would be:

  • Multi-forward tunnels
    but these would probably need some discussion on how to implement them best.

Would you like to open corresponding issues? Thanks again.

@mchrisb03
Copy link
Copy Markdown

Lack of multiple port forwards per ssh tunnel is the one thing that is preventing me from using this program and suggesting it to others.

It really needs to support something like:

   name = "dev"                                                                                                                                                                                                           
   forwards = [                                                                                                                                                                                                           
     { local = "3000", remote = "localhost:3000" },                                                                                                                                                                       
     { local = "5432", remote = "localhost:5432" },                                                                                                                                                                       
   ]

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.

3 participants