Complete native SSH sessions and documentation
This commit is contained in:
1 parent
c996923bce
commit
4b7389cc93
15 files changed
+727
-162
No files matched your search
+12
-8
@@ -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
@@ -1786,6 +1786,7 @@ dependencies = [
|
||||
"crossfont",
|
||||
"dirs",
|
||||
"embed-resource",
|
||||
"getrandom 0.3.3",
|
||||
"gl_generator",
|
||||
"global-hotkey",
|
||||
"glutin",
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
_ => {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
+506
-114
@@ -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,44 +45,154 @@ 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) = 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")
|
||||
})?;
|
||||
let port = suffix
|
||||
.strip_prefix(':')
|
||||
.map(str::parse)
|
||||
.transpose()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SSH port"))?
|
||||
.unwrap_or(22);
|
||||
(host.to_owned(), port)
|
||||
} else if let Some((host, port)) = host_port.rsplit_once(':') {
|
||||
if host.contains(':') {
|
||||
(host_port.to_owned(), 22)
|
||||
} else {
|
||||
let port = port
|
||||
.parse()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid SSH port"))?;
|
||||
(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"));
|
||||
}
|
||||
|
||||
Ok(Self { original, user: user.to_owned(), host, port })
|
||||
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, "无效的 IPv6 SSH 地址")
|
||||
})?;
|
||||
let port = suffix
|
||||
.strip_prefix(':')
|
||||
.map(str::parse)
|
||||
.transpose()
|
||||
.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(':') {
|
||||
if host.contains(':') {
|
||||
(host_port.to_owned(), 22)
|
||||
} else {
|
||||
let port = port
|
||||
.parse()
|
||||
.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 主机为空"));
|
||||
}
|
||||
Ok((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 {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -978,6 +973,7 @@ impl WindowContext {
|
||||
pane.notifier.notify(launch.command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `path` in a read-only document viewer tab. A tab already viewing
|
||||
|
||||
+56
-4
@@ -61,11 +61,12 @@ fn run() {
|
||||
_ => args.last().cloned().unwrap_or_default().into_bytes(),
|
||||
};
|
||||
|
||||
// Constraint 2: outside Nebula there is no pipe variable — do nothing.
|
||||
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") {
|
||||
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);
|
||||
|
||||
// The server accepts one connection at a time and re-creates the pipe
|
||||
// instance in between, so a raced connect fails for microseconds.
|
||||
@@ -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==");
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user