Complete native SSH sessions and documentation

This commit is contained in:
kuddev committed 2026-07-13 16:16:11 +08:00
1 parent c996923bce
commit 4b7389cc93
15 files changed
+702 -137

No files matched your search

+12 -8
View File
@@ -32,18 +32,22 @@ Every release entry is provided in English and Simplified Chinese.
### SSH
- **SSH inside the configured default shell** — saved hosts now open a normal Nebula pane rooted in the selected default shell and run SSH inside its PTY, eliminating the external `ssh.exe` console window.
**中文:** 保存的 SSH host 现在先创建用户设置的默认 Shell pane,再在其 PTY 内运行 SSH,不再弹出外部 `ssh.exe` 黑窗口。
- **Native Rust SSH transport** — saved hosts now connect directly to a remote PTY channel without a wrapper shell, injected command, or external `ssh.exe` console window.
**中文:** 保存的 SSH host 现在通过 Rust SSH 传输直接连接远端 PTY,不再依赖包装 Shell、命令注入或外部 `ssh.exe` 黑窗口。
- **Complete authentication chain** — resolves aliases, users, ports and identity files from `~/.ssh/config`, then supports private keys, OpenSSH certificates, encrypted-key passphrases, Windows OpenSSH Agent, Pageant, saved or prompted passwords, and keyboard-interactive/MFA.
**中文:** 从 `~/.ssh/config` 解析别名、用户、端口和 IdentityFile,并支持私钥、OpenSSH 证书、加密密钥口令、Windows OpenSSH Agent、Pageant、已保存/现场输入密码以及 keyboard-interactive/MFA。
- **Connection reuse** — authenticated sessions are pooled by `user@host:port`, so additional SSH tabs open a new shell channel without repeating transport setup and authentication.
**中文:** 已认证连接按 `user@host:port` 复用;后续 SSH 标签页直接创建新 Shell channel,无需重复传输握手和认证。
- **Standard host-key verification** — verifies and learns host keys through the standard `known_hosts` store, prompts on first connection, and rejects changed keys with a security warning.
**中文:** 使用标准 `known_hosts` 校验和保存主机密钥;首次连接会确认,密钥变化时会拒绝连接并显示安全警告。
- **Authenticated remote Hook bridge** — remote AI lifecycle envelopes can travel through a private OSC protected by a random per-channel token; pane identity is always assigned locally before notifications are dispatched.
**中文:** 远端 AI 生命周期信封可通过每通道随机令牌保护的私有 OSC 返回;通知分发前始终由本地分配 Pane 身份。
- **Built-in host editor** — the `SSH HOSTS` header has an add button and an internal form for `user@host`, optional non-default ports, and passwords.
**中文:** `SSH HOSTS` 标题新增添加按钮和内部编辑面板,可输入 `user@host`、非默认端口和密码。
- **Secure credential persistence** — passwords are saved only with explicit consent and are stored in Windows Credential Manager, never in Nebula settings, command arguments, shell history, or logs.
**中文:** 密码仅在用户明确选择保存时写入 Windows Credential Manager,绝不会进入 Nebula 设置、命令参数、Shell 历史或日志。
- **Host deletion** — SSH host rows expose a tab-style close button; removing a saved record also clears its pin and stored Windows credential.
**中文:** SSH host 行新增与标签页一致的关闭按钮;删除保存记录时同步清理置顶状态和 Windows 凭据。
- **OpenSSH AskPass integration** — supports automatic login with stored passwords, invalid-password recovery, host-key confirmation, and one-time authentication-state cleanup.
**中文:** OpenSSH AskPass 支持已保存密码自动登录、密码失效后重新询问、host key 确认和一次性认证状态清理。
- **Shell-specific command injection** — PowerShell, CMD, Git Bash, Nushell, and WSL use dedicated quoting and environment propagation.
**中文:** PowerShell、CMD、Git Bash、Nushell 和 WSL 均使用各自的安全转义与环境传递方式注入 SSH 命令。
- **Host deletion and cleaner right-click behavior** — SSH rows keep their tab-style delete button and credential cleanup, while right-click no longer silently pins or reorders a host.
**中文:** SSH host 行保留标签页式删除按钮和凭据清理;右键不再静默置顶或改变主机顺序。
### Session And Rendering / 会话与渲染
Generated
+1
View File
@@ -1786,6 +1786,7 @@ dependencies = [
"crossfont",
"dirs",
"embed-resource",
"getrandom 0.3.3",
"gl_generator",
"global-hotkey",
"glutin",
+32 -18
View File
@@ -90,16 +90,24 @@ experience that works without extra setup.
notifier keeps firing.
- **Fallback signals** — OSC 133 command tracking + BEL still cover every
other CLI: long builds toast on completion, with their duration.
- **AI-aware over SSH** — `nebula ssh user@host` bootstraps Nebula's shell
integration on the *remote* host, so a `claude` / `vim` / `cargo` running on
the server shows the same sidebar icon, spinner and cwd as if it were local.
SSH is a transparent pipe: the remote shell's OSC escapes travel back and are
parsed exactly like local ones. Forwarding and query forms
(`ssh -N -L …`, `-W`, `-G`, an explicit remote command) pass straight through
untouched — including settings resolved from `~/.ssh/config`. v1 targets Linux
remotes with bash/zsh; anything else falls back to a plain login shell (the
connection is never lost to a bootstrap problem). To make plain `ssh`
transparent, alias it in your Nebula shell: `alias ssh='nebula ssh'`.
- **Native SSH sessions** — saved hosts open directly through Nebula's Rust SSH
transport: no wrapper shell and no external console window. Host aliases,
usernames, ports and identity files are resolved from `~/.ssh/config`;
authentication supports standard private keys and certificates, encrypted-key
passphrases, Windows OpenSSH Agent, Pageant, Windows Credential Manager
passwords, and keyboard-interactive/MFA. Accepted host keys use the standard
`known_hosts` store, and additional tabs reuse an already authenticated
connection to the same `user@host:port` for a faster second shell.
- **AI-aware over SSH** — remote Hook envelopes can return through a
per-channel, randomly authenticated private OSC bridge. Nebula validates the
channel token, replaces any remote pane identifier with the local pane, and
routes the event through the existing sidebar and Windows notification path.
The `nebula ssh` compatibility command remains available for forwarding,
query and explicit-command forms that need the system SSH client.
<p align="center">
<img src="docs/screenshots/ssh.gif" alt="Nebula native SSH session" width="920" />
</p>
**Performance & correctness**
@@ -231,14 +239,20 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU
`--chain` 包装而非顶掉:原有通知程序照常触发。
- **兜底信号** — OSC 133 命令跟踪 + BEL 覆盖其余所有 CLI:长构建完成也
弹通知,并带耗时。
- **SSH 里也 AI 感知** — `nebula ssh user@host` 会把 Nebula 的 shell 集成
引导到*远程*主机,让服务器上跑的 `claude` / `vim` / `cargo` 像在本地一样
显示侧栏图标、转圈和 cwd。原理:SSH 是透明管道,远程 shell 发的 OSC 转义
原样穿回本地,被同一套解析器处理。转发与查询形态(`ssh -N -L …`、`-W`、
`-G`、显式远程命令)原样透传不注入 —— 包括从 `~/.ssh/config` 解析出的
设置。v1 面向 Linux + bash/zsh 远端;其余降级为普通登录 shell(连接绝不
因引导失败而丢)。想让直接打的 `ssh` 也透明,在 Nebula 的 shell 里加个
别名:`alias ssh='nebula ssh'`。
- **原生 SSH 会话** — 保存的主机现在直接通过 Nebula 的 Rust SSH 传输连接,
不再经过包装 Shell,也不会弹出外部控制台窗口。主机别名、用户名、端口和
IdentityFile 会从 `~/.ssh/config` 解析;认证覆盖标准私钥与证书、加密密钥
口令、Windows OpenSSH Agent、Pageant、Windows 凭据管理器密码以及
keyboard-interactive/MFA。主机密钥写入标准 `known_hosts`;同一
`user@host:port` 再开标签页会复用已认证连接,让第二个 Shell 更快出现。
- **SSH 里也 AI 感知** — 远端 Hook 信封可通过每通道随机认证的私有 OSC 桥
返回。Nebula 会校验通道令牌,用本地 Pane 身份覆盖远端字段,再复用现有侧栏
状态和 Windows 通知链路。需要端口转发、配置查询或显式远程命令时,仍可使用
`nebula ssh` 兼容命令交给系统 SSH 客户端处理。
<p align="center">
<img src="docs/screenshots/ssh.gif" alt="Nebula 原生 SSH 会话" width="920" />
</p>
**性能与正确性**
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+1
View File
@@ -60,6 +60,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
unicode-width = "0.2.0"
winit = { version = "0.30.9", default-features = false, features = ["rwh_06", "serde"] }
global-hotkey = "0.8.0"
getrandom = "0.3.3"
[build-dependencies]
gl_generator = "0.14.0"
+22
View File
@@ -162,6 +162,28 @@ fn parse_envelope(bytes: &[u8]) -> Option<AiHookEvent> {
Some(AiHookEvent { pane, source, kind, message })
}
/// 远端会话只能提交事件语义,Pane 身份始终由本地 SSH 通道覆盖,
/// 防止远端载荷把通知路由到同一窗口中的其他标签页。
pub(crate) fn parse_remote_envelope(bytes: &[u8], pane: Option<u64>) -> Option<AiHookEvent> {
let mut event = parse_envelope(bytes)?;
event.pane = pane;
Some(event)
}
#[cfg(test)]
mod remote_tests {
use super::{AiHookKind, parse_remote_envelope};
#[test]
fn remote_envelope_uses_local_pane_identity() {
let raw = b"nebula-hook/1 source=codex pane=999\n{\"type\":\"agent-turn-complete\",\"last-assistant-message\":\"done\"}";
let event = parse_remote_envelope(raw, Some(7)).unwrap();
assert_eq!(event.pane, Some(7));
assert_eq!(event.kind, AiHookKind::TurnDone);
assert_eq!(event.message.as_deref(), Some("done"));
}
}
/// Char-boundary-safe cut with an ellipsis (toast bodies are small).
fn truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
+7
View File
@@ -2617,6 +2617,7 @@ impl input::Processor<EventProxy, ActionContext<'_, Notifier, EventProxy>> {
);
}
},
TerminalEvent::AiHookEnvelope(_) => (),
TerminalEvent::Bell => {
// Claude Code / Codex ring BEL when a turn finishes, so
// an unfocused bell is the primary "AI task done"
@@ -2933,6 +2934,12 @@ impl EventProxy {
impl EventListener for EventProxy {
fn send_event(&self, event: TerminalEvent) {
if let TerminalEvent::AiHookEnvelope(envelope) = event {
if let Some(hook) = crate::ai_hook::parse_remote_envelope(&envelope, self.tab_id) {
self.send_event(EventType::AiHook(hook));
}
return;
}
let _ = self.proxy.send_event(Event {
window_id: Some(self.target()),
tab_id: self.tab_id,
+4 -5
View File
@@ -1215,8 +1215,9 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
// Right-clicking the sidebar "+" opens the quick-launch profile menu
// (Windows Terminal's profile dropdown); left-click keeps opening the
// default shell. Right-clicking a tab or SSH host row pins it to the
// top of its section (a second right-click on a host unpins).
// default shell. Tab and SSH context menus will replace the old
// reorder/pin shortcuts; until that menu lands, SSH right-click is
// consumed without changing the saved-host order.
if button == MouseButton::Right {
let x = self.ctx.mouse().x as f32;
let y = self.ctx.mouse().y as f32;
@@ -1237,9 +1238,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
self.ctx.mark_dirty();
return;
},
crate::display::ChromeHit::Host(index) => {
self.ctx.display().pin_host(index);
self.ctx.mark_dirty();
crate::display::ChromeHit::Host(_) => {
return;
},
_ => {},
+9
View File
@@ -309,6 +309,15 @@ pub fn load_stored_password(destination: &str) -> std::io::Result<Option<Vec<u8>
windows_store::load_password(destination)
}
#[cfg(windows)]
pub fn prompt_password(
destination: &str,
initial: Option<&[u8]>,
allow_save: bool,
) -> std::io::Result<Option<(Vec<u8>, bool)>> {
windows_store::prompt_password(destination, initial, allow_save)
}
#[cfg(not(windows))]
pub fn load_stored_password(_destination: &str) -> std::io::Result<Option<Vec<u8>>> {
Ok(None)
+483 -91
View File
@@ -3,28 +3,41 @@
//! 远端 Pane 不创建本地伪终端,但继续使用统一的输入、缩放和关闭消息协议,
//! 从而让渲染与键盘处理保持传输层无关。
use std::collections::HashMap;
use std::io;
use std::sync::Arc;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::Receiver;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use log::{error, warn};
use log::{error, info, warn};
use nebula_terminal::event::{Event as TerminalEvent, WindowSize};
use nebula_terminal::event_loop::{EventLoopSender, Msg, StreamProcessor};
use nebula_terminal::sync::FairMutex;
use nebula_terminal::term::Term;
use russh::client;
use russh::client::{self, AuthResult, KeyboardInteractiveAuthResponse};
use russh::keys::agent::AgentIdentity;
use russh::keys::agent::client::AgentClient;
use russh::keys::ssh_key;
use russh::{ChannelMsg, Disconnect};
use russh::keys::{HashAlg, PrivateKeyWithHashAlg};
use russh::ChannelMsg;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::event::EventProxy;
type SessionError = Box<dyn std::error::Error + Send + Sync>;
type ClientSession = client::Handle<ClientHandler>;
type SharedSession = Arc<tokio::sync::Mutex<ClientSession>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshDestination {
pub original: String,
pub user: String,
pub host: String,
pub port: u16,
identity_files: Vec<PathBuf>,
proxy_jump: Option<String>,
}
impl SshDestination {
@@ -32,24 +45,75 @@ impl SshDestination {
let original = value.trim().to_owned();
let address = original.strip_prefix("ssh://").unwrap_or(&original).to_owned();
let (user, host_port) = address.rsplit_once('@').ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "SSH destination requires user@host")
io::Error::new(io::ErrorKind::InvalidInput, "SSH 地址需要包含 user@host")
})?;
if user.is_empty() || host_port.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"SSH destination is incomplete",
));
return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH 地址不完整"));
}
let (host, port) = parse_host_port(host_port)?;
Ok(Self {
original,
user: user.to_owned(),
host,
port,
identity_files: Vec::new(),
proxy_jump: None,
})
}
/// 使用系统 SSH 的离线配置展开能力解析别名、用户名、端口和 IdentityFile。
/// 这能保持用户现有 `~/.ssh/config` 行为,同时网络连接仍完全由 Rust 传输层承担。
fn resolve(value: &str) -> io::Result<Self> {
let original = value.trim().to_owned();
if original.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH 地址为空"));
}
let output = Command::new(find_ssh()).arg("-G").arg("--").arg(&original).output();
if let Ok(output) = output {
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
if let Some(destination) = parse_resolved_config(&original, &text) {
return Ok(destination);
}
}
}
if let Ok(destination) = Self::parse(&original) {
return Ok(destination);
}
let address = original.strip_prefix("ssh://").unwrap_or(&original);
let (host, port) = parse_host_port(address)?;
let user = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无法确定 SSH 用户名"))?;
Ok(Self {
original,
user,
host,
port,
identity_files: default_identity_files(),
proxy_jump: None,
})
}
fn pool_key(&self) -> String {
format!("{}@{}:{}", self.user, self.host.to_ascii_lowercase(), self.port)
}
}
fn parse_host_port(host_port: &str) -> io::Result<(String, u16)> {
let (host, port) = if let Some(rest) = host_port.strip_prefix('[') {
let (host, suffix) = rest.split_once(']').ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "invalid bracketed SSH address")
io::Error::new(io::ErrorKind::InvalidInput, "无效的 IPv6 SSH 地址")
})?;
let port = suffix
.strip_prefix(':')
.map(str::parse)
.transpose()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SSH port"))?
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无效的 SSH 端口"))?
.unwrap_or(22);
(host.to_owned(), port)
} else if let Some((host, port)) = host_port.rsplit_once(':') {
@@ -58,19 +122,78 @@ impl SshDestination {
} else {
let port = port
.parse()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SSH port"))?;
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "无效的 SSH 端口"))?;
(host.to_owned(), port)
}
} else {
(host_port.to_owned(), 22)
};
if host.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH host is empty"));
return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH 主机为空"));
}
Ok((host, port))
}
Ok(Self { original, user: user.to_owned(), host, port })
fn parse_resolved_config(original: &str, text: &str) -> Option<SshDestination> {
let mut user = None;
let mut host = None;
let mut port = None;
let mut identity_files = Vec::new();
let mut proxy_jump = None;
for line in text.lines() {
let (key, value) = line.split_once(char::is_whitespace)?;
let value = value.trim();
match key.to_ascii_lowercase().as_str() {
"user" if user.is_none() => user = Some(value.to_owned()),
"hostname" if host.is_none() => host = Some(value.to_owned()),
"port" if port.is_none() => port = value.parse().ok(),
"identityfile" => identity_files.push(expand_home(value)),
"proxyjump" if !value.eq_ignore_ascii_case("none") => {
proxy_jump = Some(value.to_owned());
},
_ => {},
}
}
Some(SshDestination {
original: original.to_owned(),
user: user?,
host: host?,
port: port.unwrap_or(22),
identity_files,
proxy_jump,
})
}
fn find_ssh() -> PathBuf {
if let Some(root) = std::env::var_os("SystemRoot") {
let path = PathBuf::from(root).join("System32").join("OpenSSH").join("ssh.exe");
if path.is_file() {
return path;
}
}
PathBuf::from("ssh")
}
fn expand_home(value: &str) -> PathBuf {
let value = value.trim_matches('"');
if let Some(rest) = value.strip_prefix("~/").or_else(|| value.strip_prefix("~\\")) {
if let Some(home) = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")) {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(value)
}
fn default_identity_files() -> Vec<PathBuf> {
["id_ed25519", "id_ecdsa", "id_rsa"]
.into_iter()
.filter_map(|name| {
std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(|home| PathBuf::from(home).join(".ssh").join(name))
})
.collect()
}
struct ClientHandler {
host: String,
@@ -94,7 +217,7 @@ impl client::Handler for ClientHandler {
self.port,
server_public_key,
) {
warn!("Failed to store SSH host key: {err}");
warn!("保存 SSH 主机密钥失败: {err}");
}
Ok(true)
} else {
@@ -102,7 +225,7 @@ impl client::Handler for ClientHandler {
}
},
Err(err) => {
warn!("SSH host key verification failed: {err}");
warn!("SSH 主机密钥验证失败: {err}");
show_host_key_changed(&self.host, self.port, &err.to_string());
Ok(false)
},
@@ -110,90 +233,73 @@ impl client::Handler for ClientHandler {
}
}
/// 启动密码认证的 SSH Pane。返回 `Unsupported` 时保留兼容路径,
/// 避免尚未覆盖的认证方式在迁移期间失效。
pub fn spawn_password_session(
destination: SshDestination,
fn runtime() -> io::Result<&'static tokio::runtime::Runtime> {
static RUNTIME: OnceLock<Result<tokio::runtime::Runtime, String>> = OnceLock::new();
match RUNTIME.get_or_init(|| {
let workers = std::thread::available_parallelism()
.map(|count| count.get().clamp(2, 4))
.unwrap_or(2);
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(workers)
.thread_name("nebula-ssh")
.build()
.map_err(|err| err.to_string())
}) {
Ok(runtime) => Ok(runtime),
Err(err) => Err(io::Error::other(format!("SSH Runtime 初始化失败: {err}"))),
}
}
fn connection_pool() -> &'static tokio::sync::Mutex<HashMap<String, SharedSession>> {
static POOL: OnceLock<tokio::sync::Mutex<HashMap<String, SharedSession>>> = OnceLock::new();
POOL.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()))
}
/// 启动 SSH Pane。地址解析和认证在共享 Runtime 中执行,避免阻塞窗口线程。
pub fn spawn_session(
destination: String,
initial_size: WindowSize,
terminal: Arc<FairMutex<Term<EventProxy>>>,
event_proxy: EventProxy,
) -> io::Result<EventLoopSender> {
let Some(password) = crate::ssh_credentials::load_stored_password(&destination.original)?
else {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"no stored password for direct SSH authentication",
));
};
let (sender, receiver) = EventLoopSender::standalone()?;
std::thread::Builder::new().name(format!("SSH {}", destination.host)).spawn(move || {
run_session(destination, password, initial_size, terminal, event_proxy, receiver)
})?;
Ok(sender)
}
fn run_session(
destination: SshDestination,
mut password: Vec<u8>,
initial_size: WindowSize,
terminal: Arc<FairMutex<Term<EventProxy>>>,
event_proxy: EventProxy,
receiver: Receiver<Msg>,
) {
let runtime =
match tokio::runtime::Builder::new_multi_thread().enable_all().worker_threads(2).build() {
Ok(runtime) => runtime,
Err(err) => {
render_error(&terminal, &event_proxy, &format!("SSH runtime failed: {err}"));
return;
runtime()?.spawn(async move {
let raw = destination.clone();
let resolved = tokio::task::spawn_blocking(move || SshDestination::resolve(&raw)).await;
let result = match resolved {
Ok(Ok(destination)) => {
run_session_async(destination, initial_size, terminal.clone(), event_proxy.clone(), receiver).await
},
Ok(Err(err)) => Err(err.into()),
Err(err) => Err(format!("SSH 地址解析任务失败: {err}").into()),
};
runtime.block_on(async move {
if let Err(err) = run_session_async(
&destination,
&password,
initial_size,
terminal.clone(),
event_proxy.clone(),
receiver,
)
.await
{
error!("Direct SSH session failed for {}: {err}", destination.original);
render_error(&terminal, &event_proxy, &format!("SSH connection failed: {err}"));
if let Err(err) = result {
error!("直连 SSH 会话失败 {destination}: {err}");
render_error(&terminal, &event_proxy, &format!("SSH 连接失败: {err}"));
}
password.fill(0);
terminal.lock().exit();
event_proxy.send_event(TerminalEvent::Wakeup.into());
});
Ok(sender)
}
async fn run_session_async(
destination: &SshDestination,
password: &[u8],
destination: SshDestination,
initial_size: WindowSize,
terminal: Arc<FairMutex<Term<EventProxy>>>,
event_proxy: EventProxy,
receiver: Receiver<Msg>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(Duration::from_secs(30)),
keepalive_interval: Some(Duration::from_secs(5)),
keepalive_max: 6,
..Default::default()
});
let handler = ClientHandler { host: destination.host.clone(), port: destination.port };
let mut session =
client::connect(config, (destination.host.as_str(), destination.port), handler).await?;
let password = String::from_utf8(password.to_vec())?;
let auth = session.authenticate_password(destination.user.clone(), password).await?;
if !auth.success() {
let _ = crate::ssh_credentials::forget_password(&destination.original);
return Err("password authentication was rejected".into());
) -> Result<(), SessionError> {
if let Some(proxy_jump) = destination.proxy_jump.as_deref() {
return Err(format!("当前直连模式尚未接入跳板机 {proxy_jump}").into());
}
let mut channel = session.channel_open_session().await?;
let session = authenticated_session(&destination).await?;
let mut channel = {
let session = session.lock().await;
session.channel_open_session().await?
};
channel
.request_pty(
true,
@@ -205,6 +311,10 @@ async fn run_session_async(
&[],
)
.await?;
let hook_token = remote_hook_token()?;
channel
.set_env(false, "NEBULA_REMOTE_HOOK_TOKEN", hook_token.clone())
.await?;
channel.request_shell(true).await?;
let (input_tx, mut input_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -218,7 +328,9 @@ async fn run_session_async(
let mut stream = StreamProcessor::default();
stream.resize(initial_size);
stream.set_remote_hook_token(hook_token);
loop {
let sync_deadline = stream.next_sync_timeout();
tokio::select! {
message = input_rx.recv() => match message {
Some(Msg::Input(bytes)) => channel.data(bytes.as_ref()).await?,
@@ -241,15 +353,293 @@ async fn run_session_async(
stream.feed(&mut *terminal.lock(), &event_proxy, data.as_ref());
event_proxy.send_event(TerminalEvent::Wakeup.into());
},
Some(ChannelMsg::ExitStatus { .. }) | None => break,
Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::Eof) | None => break,
_ => {},
},
_ = wait_for_sync(sync_deadline), if sync_deadline.is_some() => {
stream.stop_sync(&mut *terminal.lock());
event_proxy.send_event(TerminalEvent::Wakeup.into());
},
}
}
let _ = session.disconnect(Disconnect::ByApplication, "", "English").await;
Ok(())
}
async fn wait_for_sync(deadline: Option<std::time::Instant>) {
if let Some(deadline) = deadline {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
}
}
async fn authenticated_session(destination: &SshDestination) -> Result<SharedSession, SessionError> {
let key = destination.pool_key();
if let Some(existing) = connection_pool().lock().await.get(&key).cloned() {
if !existing.lock().await.is_closed() {
info!("复用已认证 SSH 连接: {key}");
return Ok(existing);
}
connection_pool().lock().await.remove(&key);
}
let config = Arc::new(client::Config {
inactivity_timeout: None,
keepalive_interval: Some(Duration::from_secs(15)),
keepalive_max: 3,
..Default::default()
});
let handler = ClientHandler { host: destination.host.clone(), port: destination.port };
let mut session = client::connect(
config,
(destination.host.as_str(), destination.port),
handler,
)
.await?;
authenticate(&mut session, destination).await?;
let session = Arc::new(tokio::sync::Mutex::new(session));
let mut pool = connection_pool().lock().await;
if let Some(existing) = pool.get(&key).cloned() {
if !existing.lock().await.is_closed() {
return Ok(existing);
}
}
pool.insert(key, session.clone());
Ok(session)
}
async fn authenticate(
session: &mut ClientSession,
destination: &SshDestination,
) -> Result<(), SessionError> {
if session.authenticate_none(&destination.user).await?.success() {
return Ok(());
}
for path in &destination.identity_files {
if try_private_key(session, destination, path).await? {
return Ok(());
}
}
if try_windows_agents(session, &destination.user).await {
return Ok(());
}
let mut reusable_password = crate::ssh_credentials::load_stored_password(&destination.original)?;
if let Some(password) = reusable_password.as_ref() {
if authenticate_password(session, &destination.user, password).await? {
clear_secret(&mut reusable_password);
return Ok(());
}
crate::ssh_credentials::forget_password(&destination.original)?;
clear_secret(&mut reusable_password);
}
if let Some((mut password, save)) = prompt_secret(destination.original.clone(), None, true).await?
{
let accepted = authenticate_password(session, &destination.user, &password).await?;
if accepted {
if save {
crate::ssh_credentials::store_password(&destination.original, &password)?;
}
password.fill(0);
return Ok(());
}
reusable_password = Some(password);
}
if try_keyboard_interactive(session, destination, reusable_password.as_deref()).await? {
clear_secret(&mut reusable_password);
return Ok(());
}
clear_secret(&mut reusable_password);
Err("服务器拒绝了所有可用的 SSH 认证方式".into())
}
async fn try_private_key(
session: &mut ClientSession,
destination: &SshDestination,
path: &Path,
) -> Result<bool, SessionError> {
if !path.is_file() {
return Ok(false);
}
let target = format!("密钥口令: {}", path.display());
let mut stored = crate::ssh_credentials::load_stored_password(&target)?;
let mut key = stored
.as_deref()
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.and_then(|passphrase| russh::keys::load_secret_key(path, Some(passphrase)).ok())
.or_else(|| russh::keys::load_secret_key(path, None).ok());
if key.is_none() {
clear_secret(&mut stored);
crate::ssh_credentials::forget_password(&target)?;
if let Some((mut passphrase, save)) = prompt_secret(target.clone(), None, true).await? {
let text = String::from_utf8_lossy(&passphrase).into_owned();
key = russh::keys::load_secret_key(path, Some(&text)).ok();
if key.is_some() && save {
crate::ssh_credentials::store_password(&target, &passphrase)?;
}
passphrase.fill(0);
}
}
clear_secret(&mut stored);
let Some(key) = key else { return Ok(false) };
let key = Arc::new(key);
let cert_path = PathBuf::from(format!("{}-cert.pub", path.display()));
if cert_path.is_file() {
if let Ok(certificate) = russh::keys::load_openssh_certificate(&cert_path) {
if session
.authenticate_openssh_cert(&destination.user, key.clone(), certificate)
.await?
.success()
{
return Ok(true);
}
}
}
let hash = rsa_hash_for(session, key.algorithm().is_rsa()).await;
let key = PrivateKeyWithHashAlg::new(key, hash);
Ok(session.authenticate_publickey(&destination.user, key).await?.success())
}
async fn try_windows_agents(session: &mut ClientSession, user: &str) -> bool {
const OPENSSH_AGENT: &str = r"\\.\pipe\openssh-ssh-agent";
if let Ok(mut agent) = AgentClient::connect_named_pipe(OPENSSH_AGENT).await {
if authenticate_agent(session, user, &mut agent).await.unwrap_or(false) {
return true;
}
}
if let Ok(mut agent) = AgentClient::connect_pageant().await {
if authenticate_agent(session, user, &mut agent).await.unwrap_or(false) {
return true;
}
}
false
}
async fn authenticate_agent<S>(
session: &mut ClientSession,
user: &str,
agent: &mut AgentClient<S>,
) -> Result<bool, SessionError>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
let identities = agent.request_identities().await?;
for identity in identities.into_iter().take(5) {
let is_rsa = identity.public_key().algorithm().is_rsa();
let hash = rsa_hash_for(session, is_rsa).await;
let result: AuthResult = match identity {
AgentIdentity::PublicKey { key, .. } => {
session.authenticate_publickey_with(user, key, hash, agent).await?
},
AgentIdentity::Certificate { certificate, .. } => {
session.authenticate_certificate_with(user, certificate, hash, agent).await?
},
};
if result.success() {
return Ok(true);
}
}
Ok(false)
}
async fn rsa_hash_for(session: &ClientSession, rsa: bool) -> Option<HashAlg> {
if !rsa {
return None;
}
match session.best_supported_rsa_hash().await {
Ok(Some(hash)) => hash,
_ => Some(HashAlg::Sha512),
}
}
async fn authenticate_password(
session: &mut ClientSession,
user: &str,
password: &[u8],
) -> Result<bool, SessionError> {
let password = String::from_utf8(password.to_vec())?;
Ok(session.authenticate_password(user, password).await?.success())
}
async fn try_keyboard_interactive(
session: &mut ClientSession,
destination: &SshDestination,
password: Option<&[u8]>,
) -> Result<bool, SessionError> {
let mut state = session
.authenticate_keyboard_interactive_start(&destination.user, None::<String>)
.await?;
for _ in 0..8 {
match state {
KeyboardInteractiveAuthResponse::Success => return Ok(true),
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
let mut responses = Vec::with_capacity(prompts.len());
for prompt in prompts {
if !prompt.echo
&& prompt.prompt.to_ascii_lowercase().contains("password")
&& password.is_some()
{
responses.push(String::from_utf8_lossy(password.unwrap()).into_owned());
continue;
}
let label = format!(
"{} - {} {} {}",
destination.original,
name,
instructions,
prompt.prompt
);
let Some((mut response, _)) = prompt_secret(label, None, false).await? else {
return Ok(false);
};
responses.push(String::from_utf8_lossy(&response).into_owned());
response.fill(0);
}
state = session.authenticate_keyboard_interactive_respond(responses).await?;
},
}
}
Ok(false)
}
async fn prompt_secret(
destination: String,
initial: Option<Vec<u8>>,
allow_save: bool,
) -> io::Result<Option<(Vec<u8>, bool)>> {
tokio::task::spawn_blocking(move || {
crate::ssh_credentials::prompt_password(&destination, initial.as_deref(), allow_save)
})
.await
.map_err(|err| io::Error::other(format!("凭据输入任务失败: {err}")))?
}
fn clear_secret(secret: &mut Option<Vec<u8>>) {
if let Some(secret) = secret.as_mut() {
secret.fill(0);
}
*secret = None;
}
fn remote_hook_token() -> io::Result<String> {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes)
.map_err(|err| io::Error::other(format!("生成 SSH Hook 令牌失败: {err}")))?;
let mut token = String::with_capacity(32);
for byte in bytes {
use std::fmt::Write as _;
let _ = write!(token, "{byte:02x}");
}
Ok(token)
}
fn render_error(
terminal: &Arc<FairMutex<Term<EventProxy>>>,
event_proxy: &EventProxy,
@@ -283,11 +673,6 @@ fn confirm_new_host(host: &str, port: u16, key: &ssh_key::PublicKey) -> bool {
}
}
#[cfg(not(windows))]
fn confirm_new_host(_host: &str, _port: u16, _key: &ssh_key::PublicKey) -> bool {
false
}
#[cfg(windows)]
fn show_host_key_changed(host: &str, port: u16, detail: &str) {
use std::ptr::null_mut;
@@ -308,9 +693,6 @@ fn show_host_key_changed(host: &str, port: u16, detail: &str) {
}
}
#[cfg(not(windows))]
fn show_host_key_changed(_host: &str, _port: u16, _detail: &str) {}
#[cfg(windows)]
fn wide(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
@@ -318,7 +700,7 @@ fn wide(value: &str) -> Vec<u16> {
#[cfg(test)]
mod tests {
use super::SshDestination;
use super::{SshDestination, parse_resolved_config};
#[test]
fn parses_saved_destinations() {
@@ -337,4 +719,14 @@ mod tests {
let ipv6 = SshDestination::parse("ssh://root@[2001:db8::1]:2222").unwrap();
assert_eq!((ipv6.host.as_str(), ipv6.port), ("2001:db8::1", 2222));
}
#[test]
fn parses_resolved_ssh_config() {
let config = "user deploy\nhostname server.internal\nport 2200\nidentityfile ~/.ssh/id_ed25519\n";
let destination = parse_resolved_config("prod", config).unwrap();
assert_eq!(destination.user, "deploy");
assert_eq!(destination.host, "server.internal");
assert_eq!(destination.port, 2200);
assert_eq!(destination.identity_files.len(), 1);
}
}
+7 -11
View File
@@ -552,7 +552,7 @@ impl WindowContext {
config: &UiConfig,
proxy: &EventLoopProxy<Event>,
pane_id: PaneId,
destination: crate::ssh_session::SshDestination,
destination: String,
) -> Result<Pane, Box<dyn Error>> {
let window_route = Arc::new(AtomicU64::new(window_id.into()));
let event_proxy = EventProxy::new_tab(proxy.clone(), window_route.clone(), pane_id);
@@ -561,7 +561,7 @@ impl WindowContext {
size_info,
event_proxy.clone(),
)));
let sender = crate::ssh_session::spawn_password_session(
let sender = crate::ssh_session::spawn_session(
destination,
(*size_info).into(),
terminal.clone(),
@@ -900,7 +900,7 @@ impl WindowContext {
/// Nebula's ConPTY instead of becoming the pane's GUI-subsystem root.
fn spawn_tab_ssh(&mut self, host: String) {
#[cfg(windows)]
if let Ok(destination) = crate::ssh_session::SshDestination::parse(&host) {
{
let pane_id = self.next_pane_id;
match Self::create_ssh_pane(
&self.display.size_info,
@@ -908,7 +908,7 @@ impl WindowContext {
&self.config,
&self.proxy,
pane_id,
destination,
host.clone(),
) {
Ok(pane) => {
self.next_pane_id += 1;
@@ -926,13 +926,6 @@ impl WindowContext {
self.dirty = true;
return;
},
Err(err)
if err
.downcast_ref::<std::io::Error>()
.is_some_and(|err| err.kind() == std::io::ErrorKind::Unsupported) =>
{
log::info!("直连 SSH 暂无可用认证方式,回退到兼容连接路径");
},
Err(err) => {
error!("创建直连 SSH Pane 失败: {err}");
return;
@@ -940,6 +933,8 @@ impl WindowContext {
}
}
#[cfg(not(windows))]
{
let Ok(exe) = std::env::current_exe() else {
error!("Cannot locate nebula.exe for the SSH AskPass helper");
return;
@@ -979,6 +974,7 @@ impl WindowContext {
}
}
}
}
/// Open `path` in a read-only document viewer tab. A tab already viewing
/// this file is re-focused (and re-read, so the view is fresh) instead of
+54 -2
View File
@@ -61,12 +61,13 @@ fn run() {
_ => args.last().cloned().unwrap_or_default().into_bytes(),
};
// Constraint 2: outside Nebula there is no pipe variable — do nothing.
if let Some(pipe) = std::env::var_os("NEBULA_NOTIFY_PIPE") {
let pane = std::env::var("NEBULA_PANE_ID").unwrap_or_default();
let mut message = format!("nebula-hook/1 source={source} pane={pane}\n").into_bytes();
message.extend_from_slice(&payload);
// 本地 Pane 使用命名管道;远端 Pane 没有本地管道时,把同一信封写入控制终端的私有 OSC。
if let Some(pipe) = std::env::var_os("NEBULA_NOTIFY_PIPE") {
// The server accepts one connection at a time and re-creates the pipe
// instance in between, so a raced connect fails for microseconds.
// Retry briefly, then give up silently: notifications are best-effort.
@@ -79,6 +80,21 @@ fn run() {
Err(_) => std::thread::sleep(std::time::Duration::from_millis(5)),
}
}
} else if let Ok(token) = std::env::var("NEBULA_REMOTE_HOOK_TOKEN") {
if token.len() == 32
&& token.bytes().all(|byte| byte.is_ascii_hexdigit())
&& message.len() <= 64 * 1024
{
let osc = format!(
"\x1b]777;nebula-hook;{};{}\x07",
token,
base64_encode(&message)
);
if let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
let _ = tty.write_all(osc.as_bytes());
let _ = tty.flush();
}
}
}
// Chain mode: keep a pre-existing codex notifier working. Runs even
@@ -91,3 +107,39 @@ fn run() {
}
}
}
fn base64_encode(input: &[u8]) -> String {
const TABLE: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut output = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let a = chunk[0];
let b = chunk.get(1).copied().unwrap_or(0);
let c = chunk.get(2).copied().unwrap_or(0);
output.push(TABLE[(a >> 2) as usize] as char);
output.push(TABLE[(((a & 0x03) << 4) | (b >> 4)) as usize] as char);
output.push(if chunk.len() > 1 {
TABLE[(((b & 0x0f) << 2) | (c >> 6)) as usize] as char
} else {
'='
});
output.push(if chunk.len() > 2 {
TABLE[(c & 0x3f) as usize] as char
} else {
'='
});
}
output
}
#[cfg(test)]
mod tests {
use super::base64_encode;
#[test]
fn encodes_remote_hook_payload() {
assert_eq!(base64_encode(b"abc"), "YWJj");
assert_eq!(base64_encode(b"ab"), "YWI=");
assert_eq!(base64_encode(b"a"), "YQ==");
}
}
+6
View File
@@ -45,6 +45,9 @@ pub enum Event {
/// OSC 9 — free-text notification from a program (iTerm style).
Notify(String),
/// 已通过 SSH 通道令牌校验的远端 AI Hook 原始信封。
AiHookEnvelope(Vec<u8>),
/// Request to store a text string in the clipboard.
ClipboardStore(ClipboardType, String),
@@ -98,6 +101,9 @@ impl Debug for Event {
Event::CommandStart => write!(f, "CommandStart"),
Event::CommandDone => write!(f, "CommandDone"),
Event::Notify(text) => write!(f, "Notify({text})"),
Event::AiHookEnvelope(envelope) => {
write!(f, "AiHookEnvelope({} bytes)", envelope.len())
},
Event::CursorBlinkingChange => write!(f, "CursorBlinkingChange"),
Event::MouseCursorDirty => write!(f, "MouseCursorDirty"),
Event::ResetTitle => write!(f, "ResetTitle"),
+10
View File
@@ -35,6 +35,7 @@ pub struct StreamProcessor {
parser: ansi::Processor,
cwd_sniffer: crate::osc_cwd::CwdSniffer,
window_size: Option<WindowSize>,
remote_hook_token: Option<String>,
}
impl StreamProcessor {
@@ -42,6 +43,10 @@ impl StreamProcessor {
self.window_size = Some(window_size);
}
pub fn set_remote_hook_token(&mut self, token: String) {
self.remote_hook_token = Some(token);
}
pub fn next_sync_timeout(&self) -> Option<Instant> {
self.parser.sync_timeout().sync_timeout()
}
@@ -69,6 +74,11 @@ impl StreamProcessor {
OscEvent::CommandStart => event_proxy.send_event(Event::CommandStart),
OscEvent::CommandDone => event_proxy.send_event(Event::CommandDone),
OscEvent::Notify(text) => event_proxy.send_event(Event::Notify(text)),
OscEvent::RemoteHook { token, envelope } => {
if self.remote_hook_token.as_deref() == Some(token.as_str()) {
event_proxy.send_event(Event::AiHookEnvelope(envelope));
}
},
OscEvent::PromptMark => {
self.parser.advance(terminal, &bytes[advanced..offset]);
advanced = offset;
+54 -2
View File
@@ -29,6 +29,8 @@ const MAX_PAYLOAD: usize = 4096;
/// Cap for OSC 1337 inline-image payloads (base64 PNG). Anything larger is
/// dropped rather than buffered forever.
const MAX_IMAGE_PAYLOAD: usize = 12 * 1024 * 1024;
/// 远端 Hook JSON 使用 Base64 传输;上限阻止伪造 OSC 长时间占用内存。
const MAX_HOOK_PAYLOAD: usize = 96 * 1024;
/// An OSC event recognized by the sniffer.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -43,6 +45,8 @@ pub enum OscEvent {
CommandDone,
/// OSC 9 — free-text program notification (iTerm style).
Notify(String),
/// Nebula 远端 Hook 私有 OSC:随机通道令牌 + 原始 Hook 信封。
RemoteHook { token: String, envelope: Vec<u8> },
/// OSC 1337 `File=...inline=1:<base64>` — an iTerm2 inline image (PNG).
/// `width`/`height` come from the PNG header, in pixels.
InlineImage { png: Vec<u8>, width: u32, height: u32 },
@@ -143,7 +147,13 @@ impl CwdSniffer {
return;
}
// Inline images are the one legitimately huge OSC we buffer.
let cap = if self.payload.starts_with(b"1337;") { MAX_IMAGE_PAYLOAD } else { MAX_PAYLOAD };
let cap = if self.payload.starts_with(b"1337;") {
MAX_IMAGE_PAYLOAD
} else if self.payload.starts_with(b"777;nebula-hook;") {
MAX_HOOK_PAYLOAD
} else {
MAX_PAYLOAD
};
if self.payload.len() >= cap {
self.interested = false;
return;
@@ -169,6 +179,9 @@ impl CwdSniffer {
if let Some(rest) = self.payload.strip_prefix(b"1337;") {
return parse_osc1337_image(rest);
}
if let Some(rest) = self.payload.strip_prefix(b"777;nebula-hook;") {
return parse_remote_hook(rest);
}
if let Some(rest) = self.payload.strip_prefix(b"133;") {
// Semantic prompt zones (FinalTerm). `A` may carry kitty-style
// `;key=value` params — accept those too. B (command start being
@@ -205,8 +218,26 @@ fn prefix_could_match(payload: &[u8]) -> bool {
const B: &[u8] = b"9;";
const C: &[u8] = b"133;";
const D: &[u8] = b"1337;";
const E: &[u8] = b"777;";
let matches = |target: &[u8]| target.starts_with(payload) || payload.starts_with(target);
matches(A) || matches(B) || matches(C) || matches(D)
matches(A) || matches(B) || matches(C) || matches(D) || matches(E)
}
fn parse_remote_hook(rest: &[u8]) -> Option<OscEvent> {
use base64::Engine as _;
let separator = rest.iter().position(|byte| *byte == b';')?;
let token = std::str::from_utf8(&rest[..separator]).ok()?.trim();
if token.len() != 32 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let envelope = base64::engine::general_purpose::STANDARD
.decode(&rest[separator + 1..])
.ok()?;
if envelope.is_empty() || envelope.len() > 64 * 1024 {
return None;
}
Some(OscEvent::RemoteHook { token: token.to_owned(), envelope })
}
/// Parse an OSC 1337 body (`File=key=value;...:<base64>`) into an inline
@@ -423,6 +454,27 @@ mod tests {
);
}
#[test]
fn remote_hook_requires_valid_token_and_base64() {
use base64::Engine as _;
let envelope = b"nebula-hook/1 source=codex pane=999\n{\"type\":\"agent-turn-complete\"}";
let encoded = base64::engine::general_purpose::STANDARD.encode(envelope);
let seq = format!(
"\x1b]777;nebula-hook;0123456789abcdef0123456789abcdef;{encoded}\x07"
);
assert_eq!(
events(seq.as_bytes()),
vec![(
seq.len(),
OscEvent::RemoteHook {
token: "0123456789abcdef0123456789abcdef".into(),
envelope: envelope.to_vec(),
}
)]
);
assert!(events(b"\x1b]777;nebula-hook;short;AAAA\x07").is_empty());
}
#[test]
fn cwd_and_mark_interleaved() {
let ev = events(b"\x1b]7;file:///C:/w\x07\x1b]133;A\x07");