initial commit

This commit is contained in:
2026-05-12 21:38:14 +09:00
commit bab9ac8733
42 changed files with 6419 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
use crate::auth::AuthedClient;
use crate::cmd::connection;
use crate::ui;
use anyhow::{anyhow, Result};
use crossterm::terminal;
use rsh_types::{AttachIOFrame, OpReq, OpResp};
use std::io::Write;
use tokio::io::AsyncReadExt;
pub async fn run(
client: &AuthedClient,
session: String,
connection_id: Option<u64>,
no_pty: bool,
) -> Result<()> {
let conns = connection::fetch(client, Some(session.clone())).await?;
if conns.is_empty() {
return Err(anyhow!("no connections for session '{session}'"));
}
let target = match connection_id {
Some(id) => {
conns
.iter()
.find(|c| c.connection_id == id)
.ok_or_else(|| anyhow!("no connection {id} in session '{session}'"))?
.clone()
}
None => {
if conns.len() == 1 {
conns[0].clone()
} else {
let labels: Vec<String> = conns
.iter()
.map(|c| format!("#{} {}@{} ({})", c.connection_id, c.info.user, c.info.hostname, ui::fmt_time(c.connected_at)))
.collect();
let pick = inquire::Select::new("select connection:", labels.clone())
.prompt()
.map_err(|e| anyhow!("prompt: {e}"))?;
let idx = labels.iter().position(|l| l == &pick).unwrap();
conns[idx].clone()
}
}
};
let (cols, rows) = terminal::size().unwrap_or((80, 24));
let pty = !no_pty;
let (attach_id, mut resps) = client
.req_stream(OpReq::Attach {
session: session.clone(),
connection_id: Some(target.connection_id),
pty,
cols,
rows,
})
.await?;
let ready = resps.recv().await.ok_or_else(|| anyhow!("attach: no response"))?;
match ready {
OpResp::AttachReady { .. } => {}
OpResp::Err(e) => {
client.drop_stream(attach_id).await;
return Err(anyhow!(e));
}
other => {
client.drop_stream(attach_id).await;
return Err(anyhow!("unexpected: {other:?}"));
}
}
ui::print_info(&format!(
"attached to #{} ({}@{}) — Ctrl-] to detach",
target.connection_id, target.info.user, target.info.hostname
));
if pty {
terminal::enable_raw_mode().ok();
}
let result = pump(client, &mut resps, pty).await;
if pty {
terminal::disable_raw_mode().ok();
}
client.drop_stream(attach_id).await;
println!();
result
}
async fn pump(
client: &AuthedClient,
resps: &mut tokio::sync::mpsc::Receiver<OpResp>,
pty: bool,
) -> Result<()> {
let mut stdin = tokio::io::stdin();
let mut buf = [0u8; 4096];
let mut resize_rx: Option<tokio::sync::mpsc::Receiver<(u16, u16)>> = if pty {
let (tx, rx) = tokio::sync::mpsc::channel(8);
spawn_resize_watch(tx);
Some(rx)
} else {
None
};
loop {
tokio::select! {
r = stdin.read(&mut buf) => {
let n = r?;
if n == 0 {
let _ = client.send_attach_io(AttachIOFrame::Eof).await;
continue;
}
if pty && buf[..n].iter().any(|&b| b == 0x1d) {
let _ = client.send_attach_io(AttachIOFrame::Kill).await;
return Ok(());
}
let _ = client.send_attach_io(AttachIOFrame::Stdin(buf[..n].to_vec())).await;
}
Some(resp) = resps.recv() => {
match resp {
OpResp::Stdout(b) => {
let mut out = std::io::stdout();
out.write_all(&b).ok();
out.flush().ok();
}
OpResp::Stderr(b) => {
let mut err = std::io::stderr();
err.write_all(&b).ok();
err.flush().ok();
}
OpResp::Exited { code } => {
ui::print_info(&format!("remote exited (code {:?})", code));
return Ok(());
}
OpResp::Err(e) => return Err(anyhow!(e)),
_ => {}
}
}
Some((cols, rows)) = async {
match resize_rx.as_mut() {
Some(r) => r.recv().await,
None => std::future::pending().await,
}
} => {
let _ = client.send_attach_io(AttachIOFrame::Resize { cols, rows }).await;
}
}
}
}
fn spawn_resize_watch(tx: tokio::sync::mpsc::Sender<(u16, u16)>) {
tokio::spawn(async move {
let mut last = terminal::size().unwrap_or((80, 24));
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if let Ok(sz) = terminal::size() {
if sz != last {
last = sz;
if tx.send(sz).await.is_err() {
break;
}
}
}
}
});
}

View File

@@ -0,0 +1,22 @@
use crate::auth::AuthedClient;
use crate::ui;
use anyhow::{anyhow, Result};
use rsh_types::{ConnectionView, OpReq, OpResp};
pub async fn list(client: &AuthedClient, session: Option<String>) -> Result<()> {
let conns = fetch(client, session).await?;
if conns.is_empty() {
ui::print_info("no connections");
} else {
println!("{}", ui::connections_table(&conns));
}
Ok(())
}
pub async fn fetch(client: &AuthedClient, session: Option<String>) -> Result<Vec<ConnectionView>> {
match client.req(OpReq::ConnectionList { session }).await? {
OpResp::Connections(c) => Ok(c),
OpResp::Err(e) => Err(anyhow!(e)),
other => Err(anyhow!("unexpected: {other:?}")),
}
}

106
crates/rshc/src/cmd/keys.rs Normal file
View File

@@ -0,0 +1,106 @@
use crate::auth::AuthedClient;
use crate::ui;
use anyhow::{anyhow, Result};
use rsh_types::{OpReq, OpResp};
use std::io::{Read, Write};
pub async fn append(client: &AuthedClient, key: Option<String>, file: Option<String>, url: Option<String>) -> Result<()> {
let keys = resolve(key, file, url).await?;
if keys.is_empty() {
return Err(anyhow!("no keys to append"));
}
match client.req(OpReq::KeysAppend { keys }).await? {
OpResp::Ok => ui::print_ok("keys appended"),
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}
pub async fn remove(client: &AuthedClient, key: Option<String>, file: Option<String>, url: Option<String>) -> Result<()> {
let keys = resolve(key, file, url).await?;
if keys.is_empty() {
return Err(anyhow!("no keys to remove"));
}
match client.req(OpReq::KeysRemove { keys }).await? {
OpResp::Ok => ui::print_ok("keys removed"),
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}
pub async fn list(client: &AuthedClient) -> Result<()> {
match client.req(OpReq::KeysList).await? {
OpResp::Keys(k) => {
if k.is_empty() {
ui::print_info("no authorized keys");
} else {
for line in &k {
println!("{line}");
}
}
}
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}
pub async fn edit(client: &AuthedClient) -> Result<()> {
let current = match client.req(OpReq::KeysList).await? {
OpResp::Keys(k) => k.join("\n"),
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
};
let mut tmp = tempfile::NamedTempFile::new()?;
tmp.write_all(current.as_bytes())?;
tmp.write_all(b"\n")?;
let path = tmp.into_temp_path();
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".into());
let status = std::process::Command::new(&editor).arg(&path).status()?;
if !status.success() {
return Err(anyhow!("editor exited with {}", status));
}
let mut content = String::new();
std::fs::File::open(&path)?.read_to_string(&mut content)?;
match client.req(OpReq::KeysReplace { content }).await? {
OpResp::Ok => ui::print_ok("authorized_keys replaced"),
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}
async fn resolve(key: Option<String>, file: Option<String>, url: Option<String>) -> Result<Vec<String>> {
if let Some(k) = key {
return Ok(split(&k));
}
if let Some(f) = file {
let path = shellexpand::tilde(&f).to_string();
let content = std::fs::read_to_string(&path)?;
return Ok(split(&content));
}
if let Some(u) = url {
if !u.starts_with("https://") {
return Err(anyhow!("only https:// URLs allowed"));
}
let body = tokio::task::spawn_blocking(move || -> Result<String> {
let resp = reqwest::blocking::get(&u)?;
if !resp.status().is_success() {
return Err(anyhow!("HTTP {}", resp.status()));
}
Ok(resp.text()?)
})
.await??;
return Ok(split(&body));
}
Err(anyhow!("provide KEY, --file, or --url"))
}
fn split(s: &str) -> Vec<String> {
s.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
}

View File

@@ -0,0 +1,5 @@
pub mod session;
pub mod connection;
pub mod connect;
pub mod keys;
pub mod watch;

View File

@@ -0,0 +1,97 @@
use crate::auth::AuthedClient;
use crate::ui;
use anyhow::{anyhow, Result};
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHasher};
use rand::rngs::OsRng;
use rsh_types::{OpReq, OpResp};
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| anyhow!("hash: {e}"))
}
pub async fn create(client: &AuthedClient, name: String) -> Result<()> {
let pw = inquire::Password::new("password (empty for none):")
.without_confirmation()
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.prompt()
.map_err(|e| anyhow!("prompt: {e}"))?;
let password_hash = if pw.is_empty() { None } else { Some(hash_password(&pw)?) };
match client.req(OpReq::SessionCreate { name: name.clone(), password_hash }).await? {
OpResp::Ok => ui::print_ok(&format!("session '{name}' created")),
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}
pub async fn delete(client: &AuthedClient, name: String, yes: bool, disconnect: bool) -> Result<()> {
if !yes {
let ok = inquire::Confirm::new(&format!("delete session '{name}'?"))
.with_default(false)
.prompt()
.map_err(|e| anyhow!("prompt: {e}"))?;
if !ok {
ui::print_info("cancelled");
return Ok(());
}
}
match client.req(OpReq::SessionDelete { name: name.clone(), disconnect }).await? {
OpResp::Ok => ui::print_ok(&format!("session '{name}' deleted")),
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}
pub async fn update(
client: &AuthedClient,
name: String,
pw_flag: Option<Option<String>>,
disconnect: bool,
) -> Result<()> {
let set_password_hash = match pw_flag {
None => None,
Some(Some(p)) => Some(Some(hash_password(&p)?)),
Some(None) => {
let entered = inquire::Password::new("new password (empty clears):")
.without_confirmation()
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.prompt()
.map_err(|e| anyhow!("prompt: {e}"))?;
if entered.is_empty() {
Some(None)
} else {
Some(Some(hash_password(&entered)?))
}
}
};
match client
.req(OpReq::SessionUpdate { name: name.clone(), set_password_hash, disconnect })
.await?
{
OpResp::Ok => ui::print_ok(&format!("session '{name}' updated")),
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}
pub async fn list(client: &AuthedClient) -> Result<()> {
match client.req(OpReq::SessionList).await? {
OpResp::Sessions(s) => {
if s.is_empty() {
ui::print_info("no sessions");
} else {
println!("{}", ui::sessions_table(&s));
}
}
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(())
}

View File

@@ -0,0 +1,39 @@
use crate::auth::AuthedClient;
use crate::ui;
use anyhow::{anyhow, Result};
use owo_colors::OwoColorize;
use rsh_types::{OpEvent, OpReq, OpResp};
pub async fn run(client: &AuthedClient, session: Option<String>) -> Result<()> {
let mut events = client.take_events().await;
match client.req(OpReq::Watch { session: session.clone() }).await? {
OpResp::WatchStarted => {}
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
ui::print_info(&format!(
"watching {}",
session.as_deref().unwrap_or("all sessions")
));
while let Some(ev) = events.recv().await {
match ev {
OpEvent::NewConnection(c) => println!(
"{} {} #{} {}@{}",
"+conn".green().bold(),
c.session_id,
c.connection_id,
c.info.user,
c.info.hostname
),
OpEvent::ConnectionClosed { session, connection_id } => println!(
"{} {} #{}",
"-conn".red().bold(),
session,
connection_id
),
OpEvent::NewSession(s) => println!("{} {}", "+sess".cyan().bold(), s.id),
OpEvent::SessionDeleted { session } => println!("{} {}", "-sess".magenta().bold(), session),
}
}
Ok(())
}