Files
shell-rs/src/cli/protocol.rs
T
XuzandClaude Opus 5.5 e09adeefb7 外部 CLI 新增 exec --terminal:在已打开的终端里执行命令
只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。
--terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出
交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和
执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。
外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。

shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:30 +08:00

725 lines
23 KiB
Rust

//! What travels over the CLI socket. Every message is a frame: one byte
//! saying what it holds, four bytes of big-endian length, then the payload.
//! Control messages are JSON; a command's output is passed on as raw bytes,
//! so binary output needs no encoding.
//!
//! One connection carries one request: the client sends it as the first
//! frame, the app answers with any number of frames and closes.
use std::{
io::{self, Read, Write},
path::PathBuf,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::DeserializeOwned};
use zeroize::Zeroizing;
use super::link::OpenLink;
/// Bumped when a request or reply changes shape. The command and the app
/// come from the same build, so they differ only while an older copy still
/// runs, or on Windows while the copy on the PATH has not been updated.
///
/// New requests and replies are added without bumping it, so a command
/// and an app of different builds still agree on what both know; an app
/// asked for a request it does not know answers `bad_request`, which the
/// command explains.
pub const PROTOCOL_VERSION: u32 = 2;
/// Frames larger than this are refused, so a confused peer cannot make the
/// other side allocate without bound.
const MAX_FRAME: u32 = 16 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrameKind {
Json,
Stdout,
Stderr,
}
impl FrameKind {
fn tag(self) -> u8 {
match self {
FrameKind::Json => b'J',
FrameKind::Stdout => b'1',
FrameKind::Stderr => b'2',
}
}
fn from_tag(tag: u8) -> Option<Self> {
match tag {
b'J' => Some(FrameKind::Json),
b'1' => Some(FrameKind::Stdout),
b'2' => Some(FrameKind::Stderr),
_ => None,
}
}
}
pub fn write_frame(writer: &mut impl Write, kind: FrameKind, payload: &[u8]) -> io::Result<()> {
let length = u32::try_from(payload.len())
.ok()
.filter(|length| *length <= MAX_FRAME)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "frame too large"))?;
let mut header = [0; 5];
header[0] = kind.tag();
header[1..].copy_from_slice(&length.to_be_bytes());
writer.write_all(&header)?;
writer.write_all(payload)?;
writer.flush()
}
/// The next frame, or `None` when the peer closed the connection cleanly
/// between frames.
pub fn read_frame(reader: &mut impl Read) -> io::Result<Option<(FrameKind, Vec<u8>)>> {
let mut tag = [0; 1];
match reader.read_exact(&mut tag) {
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
result => result?,
}
let kind = FrameKind::from_tag(tag[0])
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "unknown frame"))?;
let mut length = [0; 4];
reader.read_exact(&mut length)?;
let length = u32::from_be_bytes(length);
if length > MAX_FRAME {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"frame too large",
));
}
let mut payload = vec![0; length as usize];
reader.read_exact(&mut payload)?;
Ok(Some((kind, payload)))
}
pub fn write_json(writer: &mut impl Write, message: &impl Serialize) -> io::Result<()> {
let payload = serde_json::to_vec(message).map_err(io::Error::other)?;
write_frame(writer, FrameKind::Json, &payload)
}
pub fn parse_json<T: DeserializeOwned>(payload: &[u8]) -> io::Result<T> {
serde_json::from_slice(payload)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
/// The first frame of every connection.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
pub version: u32,
pub request: Request,
}
/// The version alone, read before the request: a request this build does
/// not know is then a version mismatch rather than a puzzle.
#[derive(Deserialize)]
pub struct VersionOnly {
pub version: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Request {
List {
query: Option<String>,
},
Exec {
host: String,
command: String,
},
/// `exec --terminal`: type `command` into the host's open terminal
/// instead of logging in again, for a bastion host that allows one
/// login. Its output comes as stdout, then [`Reply::Context`], then
/// [`Reply::Exit`].
ExecInTerminal {
host: String,
command: String,
},
/// `source` is absolute: the app's working directory is not the
/// caller's.
Upload {
host: String,
source: PathBuf,
destination: String,
},
Download {
host: String,
source: String,
destination: PathBuf,
},
/// Copy what the local folder `source` holds into `destination` on the
/// host, skipping what has not changed; with `delete`, remove what is
/// there and not here first.
Sync {
host: String,
source: PathBuf,
destination: String,
#[serde(default)]
delete: bool,
},
ShowHost {
host: String,
},
CreateHost {
fields: HostFields,
},
UpdateHost {
host: String,
fields: HostFields,
},
/// `force` closes the host's tabs; without it, a host with tabs open
/// is refused.
DeleteHost {
host: String,
#[serde(default)]
force: bool,
},
ListCredentials {
query: Option<String>,
},
ShowCredential {
credential: String,
},
CreateCredential {
fields: CredentialFields,
},
UpdateCredential {
credential: String,
fields: CredentialFields,
},
DeleteCredential {
credential: String,
},
/// Bring the app's window forward, and open `open` in it. Not the
/// `shellrs` command's: ShellRS sends it when it is opened while it is
/// already running, so it is answered whatever 启用外部 CLI says.
///
/// Without a link it is `{"type":"activate"}`, as it always was. A
/// ShellRS from before links ignores `open` and only comes forward.
Activate {
#[serde(default, skip_serializing_if = "Option::is_none")]
open: Option<OpenLink>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Reply {
Hosts {
hosts: Vec<HostInfo>,
},
/// How far a transfer has come.
Progress(TransferCounters),
TransferDone(TransferSummary),
/// The remote command ended with this exit code.
Exit {
code: i32,
},
/// Where an `exec --terminal` command runs, once it started.
Context(TerminalContext),
/// A host as it is now: shown, created or changed.
Host(HostDetails),
HostDeleted(HostDeleted),
Credentials {
credentials: Vec<CredentialDetails>,
},
Credential(CredentialDetails),
CredentialDeleted(CredentialDeleted),
/// The app heard [`Request::Activate`].
Activated,
Error {
code: ErrorCode,
message: String,
},
}
/// A host as the CLI lists it: a saved one, or a temporary one.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostInfo {
/// The host's public ID, the one 复制 ID copies.
pub id: String,
pub name: String,
/// The full group path, such as `生产/数据库`; `None` at the root.
pub group: Option<String>,
pub user: String,
pub host: String,
pub port: u16,
/// The detected operating system, as stored (`ubuntu`, `macos`, …).
pub os: Option<String>,
/// Connected to without being saved (a 临时连接, or an 外部连接 a bastion
/// host's link opened): listed while its tab is open, under one ID for
/// as long; opened again, it is another host with another ID. Missing
/// from an older app's answer, which listed saved hosts only.
#[serde(default)]
pub temporary: bool,
}
impl HostInfo {
pub fn address(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
}
/// Where an `exec --terminal` command ran: the terminal may have gone to
/// another user, folder or machine since it was opened.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TerminalContext {
pub user: String,
pub host: String,
pub cwd: String,
/// The interactive shell the command was typed into.
pub shell: String,
/// What ran the command: `bash`, or `sh` where there is none.
pub interp: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferCounters {
pub files: u64,
pub total_files: u64,
pub bytes: u64,
pub total_bytes: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransferSummary {
pub files: u64,
pub bytes: u64,
/// For a sync, the files left alone because they had not changed.
pub skipped: u64,
pub failed: u64,
/// What a sync with `--delete` removed from the host. Missing from an
/// older app's answer.
#[serde(default)]
pub deleted: u64,
/// Why each failed item failed.
pub failures: Vec<String>,
}
/// Stable names for what went wrong, for an agent to act on whatever
/// language the message is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
/// The app is not running. Only the command itself reports this.
NotRunning,
/// 启用外部 CLI is off.
NotEnabled,
VersionMismatch,
HostNotFound,
HostKeyUnknown,
HostKeyChanged,
MissingCredential,
ConnectFailed,
TransferFailed,
BadRequest,
CredentialNotFound,
/// A host with tabs open, asked to be deleted without `--force`.
HostInUse,
/// A change was made, but could not all be written to disk or the
/// keychain.
SaveFailed,
/// `exec --terminal`: the host has no connected terminal.
NoTerminal,
/// `exec --terminal`: the terminal could not take the command.
TerminalBusy,
/// `exec --terminal`: the shell is not bash, dash or busybox ash.
UnsupportedShell,
/// `exec --terminal`: the command line is longer than the shell takes.
TooLong,
}
impl ErrorCode {
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::NotRunning => "not_running",
ErrorCode::NotEnabled => "not_enabled",
ErrorCode::VersionMismatch => "version_mismatch",
ErrorCode::HostNotFound => "host_not_found",
ErrorCode::HostKeyUnknown => "host_key_unknown",
ErrorCode::HostKeyChanged => "host_key_changed",
ErrorCode::MissingCredential => "missing_credential",
ErrorCode::ConnectFailed => "connect_failed",
ErrorCode::TransferFailed => "transfer_failed",
ErrorCode::BadRequest => "bad_request",
ErrorCode::CredentialNotFound => "credential_not_found",
ErrorCode::HostInUse => "host_in_use",
ErrorCode::SaveFailed => "save_failed",
ErrorCode::NoTerminal => "no_terminal",
ErrorCode::TerminalBusy => "terminal_busy",
ErrorCode::UnsupportedShell => "unsupported_shell",
ErrorCode::TooLong => "too_long",
}
}
}
/// A failure with its code, as the backend reports it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliError {
pub code: ErrorCode,
pub message: String,
}
impl CliError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
/// A password, passphrase or private key on its way to the app, never
/// the other way. `Debug` leaves it out, so a request can be printed or
/// compared in a test without it showing.
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(Zeroizing<String>);
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Self(Zeroizing::new(value.into()))
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("Secret(***)")
}
}
impl Serialize for Secret {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Secret {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
String::deserialize(deserializer).map(Self::new)
}
}
/// For a field that can be left out, set or set to `null`, three different
/// things: `None` when left out, `Some(None)` for `null`.
fn present<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: Deserialize<'de>,
D: Deserializer<'de>,
{
Option::<T>::deserialize(deserializer).map(Some)
}
/// How a host logs in, as the CLI spells it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthChoice {
Password,
Credential,
NoPassword,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProxyChoice {
#[default]
Http,
Socks5,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CredentialKindChoice {
Password,
Key,
Agent,
}
/// A saved host in full, as `hosts show` prints it. `hosts create` and
/// `hosts update` read the same names back, so what one prints the other
/// takes. Never a secret: only whether one is saved, and only when shown.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostDetails {
pub id: String,
pub name: String,
/// The full group path, such as `生产/数据库`; `None` at the root.
pub group: Option<String>,
pub host: String,
pub port: u16,
pub user: String,
pub auth: AuthChoice,
/// The credential's ID, for a host that logs in with one.
pub credential: Option<String>,
/// For a host with its own password, whether one is saved. Looked up
/// only for `hosts show`, and for the host a change returns.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_saved: Option<bool>,
pub route: RouteDetails,
pub notes: String,
pub os: Option<String>,
#[serde(default)]
pub temporary: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RouteDetails {
Direct,
/// The jump hosts' IDs in order; `None` for one that was deleted.
Jump {
hosts: Vec<Option<String>>,
},
Proxy {
kind: ProxyChoice,
host: String,
port: u16,
user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
password_saved: Option<bool>,
},
}
/// What `hosts create` sets and `hosts update` changes: every field may be
/// left out (`update` leaves it as it is), and those that can be empty may
/// be `null`. What `hosts show` prints and nobody sets is taken out before
/// these are read.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, alias = "address", skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// `null` for the root.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub group: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthChoice>,
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub credential: Option<Option<String>>,
/// `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub password: Option<Option<Secret>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<RouteFields>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
/// A route as `hosts create` and `hosts update` take it: always whole.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum RouteFields {
Direct,
Jump {
hosts: Vec<Option<String>>,
},
Proxy {
#[serde(default)]
kind: ProxyChoice,
host: String,
port: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
user: Option<String>,
/// `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
password: Option<Option<Secret>>,
},
}
/// Deleted, with what went with it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostDeleted {
pub host: HostDetails,
/// Its port forwarding rules, deleted with it.
pub forwards: u64,
/// Other hosts that went through it, which now have a deleted jump
/// host in its place.
pub jump_users: u64,
}
/// A saved credential, as `credentials show` prints it. Never a secret,
/// nor the path of a key ShellRS keeps: only whether one is saved.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CredentialDetails {
pub id: String,
pub name: String,
pub kind: CredentialKindChoice,
pub user: String,
/// The user's own key file; `None` for a key ShellRS keeps (`kept`).
pub key_path: Option<String>,
/// The key was pasted or generated, and ShellRS keeps it itself.
#[serde(default)]
pub kept: bool,
/// The IDs of the saved hosts that log in with it.
#[serde(default)]
pub hosts: Vec<String>,
/// Looked up only for `credentials show`, and for the credential a
/// change returns.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub password_saved: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub passphrase_saved: Option<bool>,
}
/// What `credentials create` sets and `credentials update` changes, as
/// for [`HostFields`].
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CredentialFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<CredentialKindChoice>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// A password credential's; `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub password: Option<Option<Secret>>,
/// A key file on this machine, absolute by the time it reaches the app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_path: Option<PathBuf>,
/// The key's text, for ShellRS to keep.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private_key: Option<Secret>,
/// The key's; `null` deletes the saved one.
#[serde(
default,
deserialize_with = "present",
skip_serializing_if = "Option::is_none"
)]
pub passphrase: Option<Option<Secret>>,
}
/// Deleted; the hosts that logged in with it now log in on their own.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CredentialDeleted {
pub credential: CredentialDetails,
pub released: Vec<HostInfo>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_round_trip_including_binary_output() {
let mut wire = Vec::new();
write_json(
&mut wire,
&Envelope {
version: PROTOCOL_VERSION,
request: Request::Exec {
host: "VmLkf1snMOuPKJ07".into(),
command: "uname -a".into(),
},
},
)
.unwrap();
write_frame(&mut wire, FrameKind::Stderr, &[0, 159, 146, 150, b'\n']).unwrap();
let mut reader = wire.as_slice();
let (kind, payload) = read_frame(&mut reader).unwrap().unwrap();
assert_eq!(kind, FrameKind::Json);
let envelope: Envelope = parse_json(&payload).unwrap();
assert_eq!(
envelope.request,
Request::Exec {
host: "VmLkf1snMOuPKJ07".into(),
command: "uname -a".into(),
}
);
assert_eq!(
read_frame(&mut reader).unwrap(),
Some((FrameKind::Stderr, vec![0, 159, 146, 150, b'\n']))
);
// A clean close between frames.
assert_eq!(read_frame(&mut reader).unwrap(), None);
}
#[test]
fn a_truncated_or_foreign_frame_is_an_error() {
let mut wire = Vec::new();
write_frame(&mut wire, FrameKind::Stdout, b"hello").unwrap();
wire.truncate(wire.len() - 1);
assert!(read_frame(&mut wire.as_slice()).is_err());
assert!(read_frame(&mut &b"X\0\0\0\0"[..]).is_err());
assert!(read_frame(&mut &b"J\xff\xff\xff\xff"[..]).is_err());
}
#[test]
fn error_codes_are_spelled_as_documented() {
for code in [
ErrorCode::NotEnabled,
ErrorCode::HostKeyUnknown,
ErrorCode::NoTerminal,
ErrorCode::TerminalBusy,
ErrorCode::UnsupportedShell,
ErrorCode::TooLong,
] {
assert_eq!(
serde_json::to_string(&code).unwrap(),
format!("\"{}\"", code.as_str())
);
}
}
#[test]
fn activating_without_a_link_is_what_it_always_was() {
let plain = Envelope {
version: PROTOCOL_VERSION,
request: Request::Activate { open: None },
};
assert_eq!(
serde_json::to_string(&plain).unwrap(),
r#"{"version":2,"request":{"type":"activate"}}"#
);
let read: Envelope = parse_json(br#"{"version":2,"request":{"type":"activate"}}"#).unwrap();
assert_eq!(read, plain);
let with_link = Request::Activate {
open: Some(OpenLink {
url: "ssh://token@10.0.0.9:2222".into(),
tab: Some("堡垒机".into()),
}),
};
let wire = serde_json::to_vec(&with_link).unwrap();
assert_eq!(parse_json::<Request>(&wire).unwrap(), with_link);
// How a ShellRS from before links read it: still a plain Activate.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Before {
Activate,
}
assert_eq!(parse_json::<Before>(&wire).unwrap(), Before::Activate);
}
}