Skip to content

feat(remote-ssh): support none authentication for pre-authenticated tunnels - #3659

Merged
bobleer merged 9 commits into
GCWing:mainfrom
Raing5Days:feat/remote-ssh-none-auth
Nov 2, 2026
Merged

bobleer merged 9 commits into
GCWing:mainfrom
Raing5Days:feat/remote-ssh-none-auth

Conversation

@Raing5Days

Copy link
Copy Markdown
Contributor

Summary

Add a none SSH authentication method so endpoints that only accept the SSH none method can be connected.

The motivating endpoint is a Coder workspace: coder ssh --stdio authenticates the tunnel with the CLI token, so the workspace agent's sshd trusts the transport and advertises only none. It disconnects any client that sends publickey or password instead. OpenSSH appears to work only because its first none probe succeeds before a key is ever offered — which is also why "every key works" against such an agent is an illusion. OpenBitFun had no way to choose none, so those workspaces failed during authentication with an opaque early eof.

Fixes: no tracking issue; behaviour was reproduced from a live workspace (see Verification).

Type and Areas

Type: Feature

Areas: Rust core (services-integrations remote SSH + persistence records), Web UI (remote SSH connection dialog)

Motivation / Impact

  • SSHAuthMethod::None / SavedAuthType::None are wired through the authenticate branch (authenticate_none), password-vault handling, saved-profile save and restore, and auto-reconnect.
  • SavedAuthTypeRecord::None keeps the legacy/migration record schema in step with the runtime enum.
  • The connection dialog gains a "No authentication" / 「无认证」 / 「不驗證」 method with a one-line hint explaining when it applies; locale keys are added to en-US, zh-CN, and zh-TW.
  • Saved none profiles do not create or keep a password vault entry, and never prompt for credentials.

User impact: Coder-style workspaces (and any other connection-switched tunnel) become connectable without a local relay or a separate sshd.

Verification

  • cargo check -p openbitfun-services-integrations --features remote-ssh — passed
  • cargo test -p openbitfun-services-integrations --features remote-ssh-concrete — passed (151 lib tests + 15 contract tests, including the new saved_none_auth_profile_round_trips_without_credentials)
  • pnpm --dir src/web-ui exec vitest run src/features/ssh-remote src/infrastructure/i18n — 14 files / 158 tests passed (includes the new dialog test and the new provider reconnect test)
  • pnpm --dir src/web-ui exec eslint <changed web-ui files> — passed
  • pnpm run i18n:audit — passed, 0 warnings
  • pnpm run fmt:rs — applied to the changed Rust files
  • pnpm --dir src/web-ui exec tsc --noEmit — 17 errors, all in files this PR does not touch. This checkout runs from a worktree that reuses another worktree's prebuilt design-system packages, so errors reference props that exist in this branch's design-system source but not in the older prebuilt dist (for example Select.showGroupLabels, chart-spline). No ssh-remote file reports an error.
  • pnpm run check:web — blocked before type-check by generated-artifact staleness unrelated to this change (miniapp-chat-styles, miniapp-chrome-theme, data-tools-design-system). This is the same local blocker recorded for previous work in this checkout; the audit suites that do run (typography, appearance contract) pass.

Endpoint behaviour was verified independently of this build: with a plain TCP bridge to coder ssh --stdio, paramiko.Transport.auth_none(...) succeeds, ssh -o PubkeyAuthentication=no -o PreferredAuthentications=none establishes a full session (exec, sftp, PTY), while both paramiko and russh publickey attempts are dropped by the agent right after key exchange. The new OpenBitFun code path itself was not exercised against a live Coder agent from a local build.

Reviewer Notes

  • Downgrade behaviour: a build older than this PR cannot deserialize {"type":"None"} in ssh_connections.json, so it logs a parse failure and starts with no saved connections. The file is not rewritten or deleted on that path, so re-upgrading restores every profile. This matches how KeyboardInteractive was introduced; say if you would rather make per-entry parsing tolerant first.
  • ProxyJump hops are unchanged: when the target uses none, a bastion without its own IdentityFile still falls back to the existing agent/key path, because the hop's own transport is a normal SSH endpoint.
  • No CSS or colour-token changes: the hint reuses the existing ssh-connection-dialog__hint class.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable.

Generated with OpenBitFun

…unnels

Coder workspace agents accept only the SSH `none` method: `coder ssh --stdio`
authenticates the tunnel with the CLI token, so the agent disconnects any client
that sends publickey or password. OpenSSH only connects because its first `none`
probe succeeds before a key is offered, which also means "any key works" is an
illusion. OpenBitFun had no way to select `none`, so these endpoints could not be
connected at all.

- add SSHAuthMethod::None and SavedAuthType::None with the authenticate_none
  branch, vault handling, and saved-profile round trip
- mirror the variant in SavedAuthTypeRecord so migrated profiles survive
- add a "No authentication" option with an explanatory hint to the SSH
  connection dialog and keep it on the auto-reconnect path
- cover it with a Rust round-trip test and two Web UI tests
@Raing5Days
Raing5Days marked this pull request as ready for review November 1, 2026 15:05

@bobleer bobleer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

这项功能有实际用途,但当前持久化和跨版本边界需要先补齐,暂不合入。

  1. P1:新增 SavedAuthType::None 会让旧版本整份 ssh_connections.json 反序列化失败。 旧读取器一次读取 Vec<SavedConnection>,写入器则用内存列表覆盖整个文件。因此“读取失败时不改文件”不足以保证安全:旧版本以空列表启动后,用户新增或保存一个连接就可能覆盖原来的全部 profiles。请提供兼容旧读取器的存储/迁移策略,并覆盖混合旧 profile + 新认证方式、旧端读取后保存、再次升级的 round-trip;不能仅增加新代码自读自写的测试,也不能仅靠本版本的 tolerant reader 修复已发布旧版本。
  2. P1:Peer 模式还缺目标能力协商。 新 UI 会直接把 {auth:{type:"None"}} 发给旧 Host,而旧 Host 不认识该枚举。请在目标端广告 typed capability,控制端在连接、保存和自动恢复前检查,并给出明确 unsupported 状态,保留已有记录。

依据:本 PR 的 Reviewer Notes 已确认旧端整表解析失败;当前 load_saved_connections / save_connections 的整表读取和覆盖写入路径也支持上述风险。认证行为本身不应回退为密码或 Agent,以免违背这个特性的安全意图。

… peers

Store None profiles in ssh_connections_extended.json so released builds
keep parsing and rewriting ssh_connections.json without losing anything,
gate None requests on a negotiated ssh_none_auth_v1 peer capability, and
stop rejected None profiles from falling back to a credentials prompt.
@bobleer

bobleer commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Pushed the changes needed for the two P1s from the earlier review (plus a merge of main):

Upgrade compatibility. None profiles now live in a new ssh_connections_extended.json; ssh_connections.json only ever holds records released builds can parse. A released build therefore loads and rewrites its own file normally, never sees the None entries, and re-upgrading merges both files. If a released build recreated the same ID, its later entry wins. An unreadable extended file is renamed to *.json.unreadable, not overwritten. A None record already in the primary file (dev builds of this PR) still loads and moves on the next save. Tests use a frozen copy of the released parser, including mixed profiles, old-side save, and re-upgrade.

Peer negotiation. Added the ssh_none_auth_v1 capability, Desktop only. PeerDeviceTransportAdapter rejects ssh_* requests with auth.type === 'None' before RPC when the host has not advertised it, the same way WSL is gated. The dialog hides the option on such hosts and asks to update the host instead of showing a generic failure; records are left untouched.

Fallback bug found in review. A rejected None profile went through isSshAuthenticationError and opened the password prompt (quick connect, inherited host, and MainNav). It now reports the failure and never asks for credentials.

Docs updated: remote-workspace-transport.md, remote-workspaces.md, the peer-device README.

Remaining limits: an older controller talking to a newer host cannot be gated and will show a None profile as keyboard-interactive; while downgraded, None profiles are not listed. SshNoneAuthV1 and SshProxyCommandV1 (#3591) touch the same lines in capabilities.rs, so whichever lands second needs a trivial rebase and pnpm run capabilities:generate. The code path is still unverified against a live Coder agent.

Exercised: unit and component tests for the local path and for peer gating at the adapter. Not exercised live: Remote Workspace to a real none endpoint, Peer Device against an actual older host, Remote Control, Detached Dispatch.

…e-auth

Keep SshProxyCommandV1 and SshNoneAuthV1 and regenerate the capability
artifacts.
Auto-reconnect and profile edits rebuilt the config without proxyCommand,
so a Coder profile would dial the alias directly. Cover the full shape
with a none-only server reached only through a ProxyCommand process.
@bobleer

bobleer commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Merged main after #3591. Only the generated digests conflicted; SshProxyCommandV1 and SshNoneAuthV1 are both kept and the artifacts regenerated.

ProxyCommand plus None works in the backend: none_auth_over_proxy_command_connects_after_restoring_the_saved_profile saves the profile, reloads it from the extended store, and connects to a none-only server whose alias does not resolve, so only the proxy process can reach it (real nc, skipped when nc is not on PATH).

Fixed one gap: auto-reconnect and profile edits rebuilt the config without proxyCommand, so such a profile would have dialed the alias directly. The value is now carried through unchanged.

Still open: the Web UI has no way to create a ProxyCommand profile. The dialog and ~/.ssh/config fill drop proxyCommand on new profiles, so this combination cannot be set up from the UI until the planned dialog batch lands, and that batch needs the ssh_proxy_command_v1 gate described in remote-workspace-transport.md. Not tested against a live Coder agent.

…e-auth

Desktop capabilities are now an explicit list; add SshNoneAuthV1 and the
missing SshProxyCommandV1 so Desktop keeps advertising both.
@bobleer

bobleer commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Merged main again. Desktop capabilities are now an explicit list (#3677), so SshNoneAuthV1 is added there, along with SshProxyCommandV1, which was missing (hotfix #3680 makes the same addition, so that conflict will be trivial). remote_surface tests and capabilities:check pass.

…e-auth

Keep SshNoneAuthV1 next to SshPortScopedIdentityV1 in the Desktop list and
both peer gates in the transport adapter.
…ofiles

Loading now shadows an extended profile by an equal id or a legacy :port
alias of the same endpoint, matching save_connection. Cover port-scoped
ids for None profiles across both stores.
@bobleer

bobleer commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Merged main after #3670. SshNoneAuthV1 stays in the Desktop list next to SshPortScopedIdentityV1, and the transport adapter keeps both peer gates. #3670 changed how saved profiles are deduplicated: an equal id, or a legacy :port alias of the same endpoint, counts as the same profile. Loading the extended store now uses that same rule. New tests cover #port ids for None profiles across both stores, a key profile replaced by a None alias of it, and an older build's alias shadowing an extended profile only for the same endpoint. Manager (91), contracts (17, 18), remote_surface (20), capabilities:check and ssh-remote/peer vitest (29 files) pass.

@bobleer bobleer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Both P1s are covered. (1) None profiles are stored in ssh_connections_extended.json, so ssh_connections.json stays readable by released builds; tests cover a mixed profile, an old-side save, a re-upgrade with a frozen copy of the released parser, and dedup with the new #port ids and legacy :port aliases. (2) The Desktop-only ssh_none_auth_v1 capability is advertised and checked by the controller before connect, save and auto-reconnect, with an explicit unsupported state, and a rejected None profile never falls back to a password or agent.

@bobleer
bobleer merged commit b74ea68 into GCWing:main Nov 2, 2026
13 checks passed
bobleer added a commit to Raing5Days/OpenBitFun that referenced this pull request Nov 2, 2026
Regenerate the remote surface registry artifacts after GCWing#3659.
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