initial commit
This commit is contained in:
34
crates/rshc/Cargo.toml
Normal file
34
crates/rshc/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "rshc"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rsh-types = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
inquire = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
comfy-table = { workspace = true }
|
||||
crossterm = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
shellexpand = { workspace = true }
|
||||
ssh-key = { workspace = true }
|
||||
signature = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
rpassword = { workspace = true }
|
||||
humantime = { workspace = true }
|
||||
time = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
197
crates/rshc/src/auth.rs
Normal file
197
crates/rshc/src/auth.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
use crate::config::Config;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rsh_types::{BackendOpMsg, OpEvent, OpMsg, OpReq, OpResp};
|
||||
use ssh_key::{HashAlg, PrivateKey};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||
|
||||
type Ws = WebSocketStream<MaybeTlsStream<TcpStream>>;
|
||||
type WsSink = SplitSink<Ws, Message>;
|
||||
type WsStream = SplitStream<Ws>;
|
||||
|
||||
enum PendingKind {
|
||||
Once(oneshot::Sender<OpResp>),
|
||||
Stream(mpsc::Sender<OpResp>),
|
||||
}
|
||||
|
||||
pub struct AuthedClient {
|
||||
next_id: AtomicU64,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingKind>>>,
|
||||
events_tx: Arc<Mutex<Option<mpsc::Sender<OpEvent>>>>,
|
||||
out: mpsc::Sender<OpMsg>,
|
||||
_reader: tokio::task::JoinHandle<()>,
|
||||
_writer: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl AuthedClient {
|
||||
pub async fn connect(cfg: &Config) -> Result<Self> {
|
||||
let key_path = cfg.ssh_key_path();
|
||||
let raw = std::fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
|
||||
let mut priv_key = PrivateKey::from_openssh(&raw).context("parse openssh private key")?;
|
||||
if priv_key.is_encrypted() {
|
||||
let pw = inquire::Password::new(&format!("Passphrase for {}:", key_path.display()))
|
||||
.without_confirmation()
|
||||
.prompt()
|
||||
.map_err(|e| anyhow!("password prompt: {e}"))?;
|
||||
priv_key = priv_key.decrypt(pw.as_bytes()).context("decrypt private key")?;
|
||||
}
|
||||
let pub_openssh = priv_key.public_key().to_openssh().context("encode pubkey")?;
|
||||
|
||||
let (ws, _) = tokio_tungstenite::connect_async(&cfg.backend_url)
|
||||
.await
|
||||
.with_context(|| format!("ws connect {}", cfg.backend_url))?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
send_msg(&mut sink, &OpMsg::AuthInit { pubkey_openssh: pub_openssh }).await?;
|
||||
let challenge = recv_msg(&mut stream).await?;
|
||||
let nonce = match challenge {
|
||||
BackendOpMsg::Challenge { nonce } => nonce,
|
||||
BackendOpMsg::AuthFail { reason } => return Err(anyhow!("auth fail: {reason}")),
|
||||
other => return Err(anyhow!("unexpected: {other:?}")),
|
||||
};
|
||||
let sig = priv_key
|
||||
.sign("rsh-auth", HashAlg::Sha512, &nonce)
|
||||
.context("sign challenge")?;
|
||||
let alg = sig.algorithm().as_str().to_string();
|
||||
let pem = sig.to_pem(ssh_key::LineEnding::LF).context("encode signature")?;
|
||||
send_msg(&mut sink, &OpMsg::AuthSign { signature: pem.into_bytes(), alg }).await?;
|
||||
match recv_msg(&mut stream).await? {
|
||||
BackendOpMsg::AuthOk => {}
|
||||
BackendOpMsg::AuthFail { reason } => return Err(anyhow!("auth fail: {reason}")),
|
||||
other => return Err(anyhow!("unexpected after AuthSign: {other:?}")),
|
||||
}
|
||||
|
||||
let pending: Arc<Mutex<HashMap<u64, PendingKind>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let events_tx: Arc<Mutex<Option<mpsc::Sender<OpEvent>>>> = Arc::new(Mutex::new(None));
|
||||
let (out_tx, mut out_rx) = mpsc::channel::<OpMsg>(64);
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(m) = out_rx.recv().await {
|
||||
let txt = match serde_json::to_string(&m) {
|
||||
Ok(t) => t,
|
||||
Err(_) => break,
|
||||
};
|
||||
if sink.send(Message::Text(txt)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = sink.close().await;
|
||||
});
|
||||
|
||||
let pending_r = pending.clone();
|
||||
let events_tx_r = events_tx.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
while let Some(msg) = stream.next().await {
|
||||
let Ok(msg) = msg else { break };
|
||||
let txt = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Binary(b) => match String::from_utf8(b) { Ok(s) => s, Err(_) => continue },
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
let parsed: BackendOpMsg = match serde_json::from_str(&txt) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
match parsed {
|
||||
BackendOpMsg::Resp { id, body } => {
|
||||
let mut p = pending_r.lock().await;
|
||||
match p.get(&id) {
|
||||
Some(PendingKind::Stream(tx)) => {
|
||||
let _ = tx.send(body).await;
|
||||
}
|
||||
_ => {
|
||||
if let Some(PendingKind::Once(tx)) = p.remove(&id) {
|
||||
let _ = tx.send(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BackendOpMsg::Event(ev) => {
|
||||
let lock = events_tx_r.lock().await;
|
||||
if let Some(tx) = lock.as_ref() {
|
||||
let _ = tx.send(ev).await;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
next_id: AtomicU64::new(1),
|
||||
pending,
|
||||
events_tx,
|
||||
out: out_tx,
|
||||
_reader: reader,
|
||||
_writer: writer,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn req(&self, body: OpReq) -> Result<OpResp> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().await.insert(id, PendingKind::Once(tx));
|
||||
self.out
|
||||
.send(OpMsg::Req { id, body })
|
||||
.await
|
||||
.map_err(|_| anyhow!("send failed (connection closed)"))?;
|
||||
rx.await.map_err(|_| anyhow!("response dropped"))
|
||||
}
|
||||
|
||||
pub async fn req_stream(&self, body: OpReq) -> Result<(u64, mpsc::Receiver<OpResp>)> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
self.pending.lock().await.insert(id, PendingKind::Stream(tx));
|
||||
self.out
|
||||
.send(OpMsg::Req { id, body })
|
||||
.await
|
||||
.map_err(|_| anyhow!("send failed"))?;
|
||||
Ok((id, rx))
|
||||
}
|
||||
|
||||
pub async fn drop_stream(&self, id: u64) {
|
||||
self.pending.lock().await.remove(&id);
|
||||
}
|
||||
|
||||
pub async fn send_attach_io(&self, frame: rsh_types::AttachIOFrame) -> Result<()> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
self.out
|
||||
.send(OpMsg::Req { id, body: OpReq::AttachIO(frame) })
|
||||
.await
|
||||
.map_err(|_| anyhow!("send failed"))
|
||||
}
|
||||
|
||||
pub async fn take_events(&self) -> mpsc::Receiver<OpEvent> {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
*self.events_tx.lock().await = Some(tx);
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_msg(sink: &mut WsSink, msg: &OpMsg) -> Result<()> {
|
||||
sink.send(Message::Text(serde_json::to_string(msg)?))
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn recv_msg(stream: &mut WsStream) -> Result<BackendOpMsg> {
|
||||
loop {
|
||||
let Some(msg) = stream.next().await else { return Err(anyhow!("connection closed")) };
|
||||
let msg = msg?;
|
||||
let txt = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Binary(b) => String::from_utf8(b)?,
|
||||
Message::Close(_) => return Err(anyhow!("connection closed")),
|
||||
_ => continue,
|
||||
};
|
||||
return Ok(serde_json::from_str(&txt)?);
|
||||
}
|
||||
}
|
||||
163
crates/rshc/src/cmd/connect.rs
Normal file
163
crates/rshc/src/cmd/connect.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
22
crates/rshc/src/cmd/connection.rs
Normal file
22
crates/rshc/src/cmd/connection.rs
Normal 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
106
crates/rshc/src/cmd/keys.rs
Normal 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()
|
||||
}
|
||||
5
crates/rshc/src/cmd/mod.rs
Normal file
5
crates/rshc/src/cmd/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod session;
|
||||
pub mod connection;
|
||||
pub mod connect;
|
||||
pub mod keys;
|
||||
pub mod watch;
|
||||
97
crates/rshc/src/cmd/session.rs
Normal file
97
crates/rshc/src/cmd/session.rs
Normal 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(())
|
||||
}
|
||||
39
crates/rshc/src/cmd/watch.rs
Normal file
39
crates/rshc/src/cmd/watch.rs
Normal 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(())
|
||||
}
|
||||
45
crates/rshc/src/config.rs
Normal file
45
crates/rshc/src/config.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub backend_url: String,
|
||||
pub ssh_key_file: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("RSHC_CONFIG_PATH") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("rsh.yaml")
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let p = Self::path();
|
||||
if !p.exists() {
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
let stub = Config {
|
||||
backend_url: "wss://example.invalid/ws/op".into(),
|
||||
ssh_key_file: "~/.ssh/id_ed25519".into(),
|
||||
};
|
||||
let txt = serde_yaml::to_string(&stub)?;
|
||||
std::fs::write(&p, txt)?;
|
||||
return Err(anyhow!(
|
||||
"no config — created stub at {}. Edit backend_url and ssh_key_file.",
|
||||
p.display()
|
||||
));
|
||||
}
|
||||
let raw = std::fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
|
||||
let cfg: Config = serde_yaml::from_str(&raw).with_context(|| format!("parse {}", p.display()))?;
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub fn ssh_key_path(&self) -> PathBuf {
|
||||
PathBuf::from(shellexpand::tilde(&self.ssh_key_file).to_string())
|
||||
}
|
||||
}
|
||||
156
crates/rshc/src/main.rs
Normal file
156
crates/rshc/src/main.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
mod auth;
|
||||
mod cmd;
|
||||
mod config;
|
||||
mod ui;
|
||||
|
||||
use anyhow::Result;
|
||||
use auth::AuthedClient;
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use config::Config;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "rshc", version, about = "rsh operator client")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Cmd {
|
||||
Watch(WatchArgs),
|
||||
#[command(alias = "sessions", alias = "s", alias = "sess")]
|
||||
Session(SessionCmd),
|
||||
#[command(alias = "connections", alias = "conn")]
|
||||
Connection(ConnectionCmd),
|
||||
#[command(alias = "c")]
|
||||
Connect(ConnectArgs),
|
||||
Keys(KeysCmd),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct WatchArgs {
|
||||
session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct SessionCmd {
|
||||
#[command(subcommand)]
|
||||
sub: SessionSub,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum SessionSub {
|
||||
#[command(alias = "c")]
|
||||
Create { name: String },
|
||||
#[command(alias = "del", alias = "d", alias = "rm")]
|
||||
Delete {
|
||||
name: String,
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
#[arg(long)]
|
||||
disconnect: bool,
|
||||
},
|
||||
Update {
|
||||
name: String,
|
||||
#[arg(long = "pw", num_args = 0..=1, default_missing_value = "")]
|
||||
pw: Option<String>,
|
||||
#[arg(long)]
|
||||
disconnect: bool,
|
||||
},
|
||||
#[command(alias = "l", alias = "ls")]
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct ConnectionCmd {
|
||||
#[command(subcommand)]
|
||||
sub: ConnectionSub,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ConnectionSub {
|
||||
#[command(alias = "l", alias = "ls")]
|
||||
List {
|
||||
#[arg(long)]
|
||||
session: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct ConnectArgs {
|
||||
session: String,
|
||||
connection_id: Option<u64>,
|
||||
#[arg(long)]
|
||||
no_pty: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
struct KeysCmd {
|
||||
#[command(subcommand)]
|
||||
sub: KeysSub,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum KeysSub {
|
||||
Append {
|
||||
key: Option<String>,
|
||||
#[arg(long)]
|
||||
file: Option<String>,
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
},
|
||||
Rm {
|
||||
key: Option<String>,
|
||||
#[arg(long)]
|
||||
file: Option<String>,
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
},
|
||||
#[command(alias = "l", alias = "ls")]
|
||||
List,
|
||||
Edit,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")))
|
||||
.try_init();
|
||||
if let Err(e) = run().await {
|
||||
ui::print_err(&e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let cfg = Config::load()?;
|
||||
let client = AuthedClient::connect(&cfg).await?;
|
||||
match cli.cmd {
|
||||
Cmd::Watch(a) => cmd::watch::run(&client, a.session).await,
|
||||
Cmd::Session(s) => match s.sub {
|
||||
SessionSub::Create { name } => cmd::session::create(&client, name).await,
|
||||
SessionSub::Delete { name, yes, disconnect } => cmd::session::delete(&client, name, yes, disconnect).await,
|
||||
SessionSub::Update { name, pw, disconnect } => {
|
||||
let pw_flag = match pw {
|
||||
None => None,
|
||||
Some(s) if s.is_empty() => Some(None),
|
||||
Some(s) => Some(Some(s)),
|
||||
};
|
||||
cmd::session::update(&client, name, pw_flag, disconnect).await
|
||||
}
|
||||
SessionSub::List => cmd::session::list(&client).await,
|
||||
},
|
||||
Cmd::Connection(c) => match c.sub {
|
||||
ConnectionSub::List { session } => cmd::connection::list(&client, session).await,
|
||||
},
|
||||
Cmd::Connect(a) => cmd::connect::run(&client, a.session, a.connection_id, a.no_pty).await,
|
||||
Cmd::Keys(k) => match k.sub {
|
||||
KeysSub::Append { key, file, url } => cmd::keys::append(&client, key, file, url).await,
|
||||
KeysSub::Rm { key, file, url } => cmd::keys::remove(&client, key, file, url).await,
|
||||
KeysSub::List => cmd::keys::list(&client).await,
|
||||
KeysSub::Edit => cmd::keys::edit(&client).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
73
crates/rshc/src/ui.rs
Normal file
73
crates/rshc/src/ui.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use comfy_table::{presets::UTF8_FULL, Cell, Table};
|
||||
use owo_colors::OwoColorize;
|
||||
use rsh_types::{ConnectionView, SessionView};
|
||||
|
||||
pub fn print_err(e: &anyhow::Error) {
|
||||
eprintln!("{} {}", "✗".red().bold(), format!("{e}").red());
|
||||
let mut src = e.source();
|
||||
while let Some(s) = src {
|
||||
eprintln!(" {} {}", "↳".red(), s);
|
||||
src = s.source();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_ok(msg: &str) {
|
||||
println!("{} {}", "✓".green().bold(), msg);
|
||||
}
|
||||
|
||||
pub fn print_info(msg: &str) {
|
||||
println!("{} {}", "›".cyan(), msg);
|
||||
}
|
||||
|
||||
pub fn sessions_table(rows: &[SessionView]) -> String {
|
||||
let mut t = Table::new();
|
||||
t.load_preset(UTF8_FULL);
|
||||
t.set_header(vec!["session", "password", "created", "connections"]);
|
||||
for r in rows {
|
||||
t.add_row(vec![
|
||||
Cell::new(&r.id),
|
||||
Cell::new(if r.has_password { "yes" } else { "no" }),
|
||||
Cell::new(fmt_time(r.created_at)),
|
||||
Cell::new(r.connection_count),
|
||||
]);
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
pub fn connections_table(rows: &[ConnectionView]) -> String {
|
||||
let mut t = Table::new();
|
||||
t.load_preset(UTF8_FULL);
|
||||
t.set_header(vec!["session", "id", "host", "user", "os/arch", "connected"]);
|
||||
for r in rows {
|
||||
t.add_row(vec![
|
||||
Cell::new(&r.session_id),
|
||||
Cell::new(r.connection_id),
|
||||
Cell::new(&r.info.hostname),
|
||||
Cell::new(&r.info.user),
|
||||
Cell::new(format!("{}/{}", r.info.os, r.info.arch)),
|
||||
Cell::new(fmt_time(r.connected_at)),
|
||||
]);
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
pub fn fmt_time(t: i64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let delta = now - t;
|
||||
if delta < 0 {
|
||||
return "now".into();
|
||||
}
|
||||
if delta < 60 {
|
||||
return format!("{}s ago", delta);
|
||||
}
|
||||
if delta < 3600 {
|
||||
return format!("{}m ago", delta / 60);
|
||||
}
|
||||
if delta < 86400 {
|
||||
return format!("{}h ago", delta / 3600);
|
||||
}
|
||||
format!("{}d ago", delta / 86400)
|
||||
}
|
||||
Reference in New Issue
Block a user