Skip to content

feat: add connections, a saved SSH connection manager#6483

Draft
gustavosbarreto wants to merge 12 commits into
masterfrom
feature/direct-hosts
Draft

feat: add connections, a saved SSH connection manager#6483
gustavosbarreto wants to merge 12 commits into
masterfrom
feature/direct-hosts

Conversation

@gustavosbarreto

@gustavosbarreto gustavosbarreto commented Jun 13, 2026

Copy link
Copy Markdown
Member

Warning

DO NOT MERGE — MVP / work in progress, opened for review and visibility only.

Connections are saved SSH targets, kept separate from devices. A connection targets either a direct host (dial host:port directly, with no agent) or a device (an agent-registered machine reached through the gateway). It adds a connection-manager entry point alongside the fleet.

Direct connections are dialed by the ssh service over a lightweight /ws/connect bridge, independent of the device session machinery. Device connections reuse the existing terminal flow.

Reachability is probed server-side over TCP: shown per row in the list and checked at save time, where an unreachable direct host surfaces a hint to install the agent (for hosts behind NAT or a firewall) with a save-anyway override.

What changed

  • APIconnection routes/service/store across pg, with migrations 007_connections and 008_connection_device_target, new permissions, request/response models.
  • SSH/ws/connect bridge in ssh/web/connect.go that dials direct hosts independently of the device session machinery.
  • UI (console) — connections page, device picker, sidebar entry, terminal integration, hooks/stores/types.

Screenshots

Connections list — direct hosts and devices side by side, each with a live status dot.

Add a connection — direct host — dial a host by address, no agent required.

Unreachable host → install-the-agent funnel — save-time TCP probe; if the host can't be reached, it nudges toward installing the agent (NAT/firewall), with a save-anyway override.

Add a connection — device — pick an agent-registered machine via a server-side searched picker.

Connect — device connections reuse the rich terminal/auth flow.

Screenshots are hotlinked from branch docs/connections-screenshots (images only, deletable once this PR closes).

Connections are saved SSH targets, kept separate from devices. A
connection targets either a direct host (dial host:port directly, with no
agent) or a device (an agent-registered machine reached through the
gateway). It adds a connection-manager entry point alongside the fleet.

Direct connections are dialed by the ssh service over a lightweight
/ws/connect bridge, independent of the device session machinery. Device
connections reuse the existing terminal flow.

Reachability is probed server-side over TCP: shown per row in the list and
checked at save time, where an unreachable direct host surfaces a hint to
install the agent (for hosts behind NAT or a firewall) with a save-anyway
override.
Saved SSH connection manager design and product strategy notes.
Web connect to external hosts now supports SSH key auth via the
zero-knowledge vault: the server proxies the signing challenge over the
websocket, so the private key never leaves the browser. /ws/connect gains
public-key auth (reusing the device Signer); the browser sends the public
key since external hosts are not registered in ShellHub.

Persist a per-connection auth preference (method + key fingerprint, never
the secret). The key is referenced by fingerprint, so it resolves against
whichever vault is unlocked (local or remote). Migration 009 adds
auth_method and key_fingerprint to connections.

Unify ConnectDrawer into a single drawer for create, edit and connect,
replacing the separate connection form. Footer is Save plus Connect; a
"Save changes" checkbox appears when connecting a saved entry with edits,
so persisting is always explicit. The devices list opens a saved
connection when one already exists for the device.

Rename the connection kind "direct" to "external" (stored value and UI
label). The external target renders as a plain cyan ssh endpoint with an
external-link icon, visually distinct from the boxed device chip.
A connection is now the shared TARGET; personal auth (username + key) is no
longer stored on the shared row. This fixes the multi-user smell where one
member's saved key/username leaked onto a connection everyone sees.

Ownership and visibility:
- connections gain owner_id and visibility ("personal" | "namespace").
  Personal connections are private to their owner; namespace connections are
  shared with the whole namespace. List filtering uses a new VisibleTo query
  option (shared OR owned-by-me), resolved from the X-ID header.
- Replace the single unique(namespace_id,label) index with partial indexes:
  team labels unique per namespace, personal labels unique per owner.

Per-user auth:
- New connection_user_prefs table keyed by (connection_id, user_id) holds the
  username, auth method and key fingerprint (never the secret). The drawer
  reads/writes the caller's own prefs, so each member keeps their own identity.
- Connections keep an optional shared default_username hint that prefs override.

Authorization:
- Personal connections are writable by their owner regardless of role (observers
  included, now granted the connection permissions). Namespace connections still
  require operator+, enforced in the service since ownership is row-level data.

Frontend:
- ConnectDrawer gains a Personal/Team toggle (Team gated by role) and reads or
  saves per-user prefs instead of auth on the connection. Edit mode only touches
  the shared record (label/visibility/target/default username), not auth.
- Connections page groups rows into "Your connections" and "Team connections".

Migrations are edited in place (007 adds owner_id/visibility, renames username
to default_username; 009 now creates connection_user_prefs) since the feature is
unreleased. Mocks and the authorizer role test are updated accordingly.
Rework the Connections feature:

- Personal-only in CE: a connection belongs to its owner (owner_id) and the
  auth lives on the row; drop the shared visibility / per-user-prefs model.
- SSRF egress guard via code.dny.dev/ssrf on the reachability probe and the
  /ws/connect dial; public-only by default. A local-dev allowance for private
  ranges is included, marked DEV and to be removed before shipping.
- Team connections UI (Enterprise/Cloud) merged into one list, gated by
  edition; the backend lives in the cloud repo.
- Known hosts (TOFU) for external targets: scan and confirm the host key in
  the browser, persist it (ssh_known_hosts, scoped personal/team), verify on
  connect and abort on mismatch. The external target chip opens a host-key
  modal. Error messages distinguish blocked vs unreachable vs key mismatch.

Mocks regenerated; tests added (owner scoping, connectionDirty, egress).
Fold create save into the Connect action via a "Save connection" checkbox
(unchecked connects one-off without persisting), let team edit set the
caller's own auth prefs, route "Set up auth" to the edit drawer, and
prompt a vault unlock when connecting with a key whose vault is locked.
AcceptKnownHost derives key_type and fingerprint from the parsed public
key instead of trusting the client, and any namespace-scoped write now
requires operator+, so a member can't plant a host key the team trusts.
Device connections validate device_uid against the caller's namespace on
create/update, and ConnectionStatus scopes DeviceResolve to the
namespace, closing a cross-tenant liveness leak. KnownHostUpsert is a
single ON CONFLICT, and a non-UUID id resolves to not-found.
An empty known_host_key is rejected instead of silently falling back to
InsecureIgnoreHostKey, closing a browser-forced MITM downgrade. A
host-key mismatch is tracked out of band so it surfaces as a real
mismatch rather than a generic auth failure. Connect tokens are consumed
on first use (one-shot) to stop replay, and the dial is bound to the
request context so a dropped client cancels it.
Add the connections, known-hosts, and team-connection endpoints to the
OpenAPI spec and move the hand-written clients onto the generated SDK
(typed params, no raw URLs or scattered casts). The connections page
paginates (100/page) over the generated list endpoints. Also invalidate
the host-key cache after a TOFU accept, keep direct connects out of the
recents palette, surface a permission error when editing a team target
without operator+, and refresh status after an edit.
The POST decode error, token failure, and stdio pipe failures returned
the raw Go error to the browser, which can carry internal detail. Log
them and return a generic message or session sentinel instead.
Mark the connection, team-connection, known-host and prefs schema fields
as required so the generated types are non-optional, then drop the
hand-written types/connection.ts and the boundary casts in favor of the
generated @/client types end to end.
The All/Personal/Team filter added noise; the list now always shows every
connection (personal and team) with the scope badges still present.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant