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

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
target
.git
.claude
deploy
*.md
Dockerfile
.dockerignore

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
RSH_PORT=7777
RSH_LOG=info
RSH_IMAGE_TAG=local
RSH_DATA_DIR=rsh-data

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
.env

3773
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

50
Cargo.toml Normal file
View File

@@ -0,0 +1,50 @@
[workspace]
resolver = "2"
members = ["crates/rsh-types", "crates/rsh-backend", "crates/rsh", "crates/rshc"]
[workspace.package]
edition = "2021"
license = "MIT"
rust-version = "1.78"
[workspace.dependencies]
rsh-types = { path = "crates/rsh-types" }
tokio = { version = "1.40", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
bytes = { version = "1", features = ["serde"] }
anyhow = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4", features = ["derive", "env"] }
uuid = { version = "1", features = ["v4", "serde"] }
axum = { version = "0.7", features = ["ws", "macros"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace"] }
dashmap = "6"
argon2 = "0.5"
rand = "0.8"
ssh-key = { version = "0.6", features = ["ed25519", "rsa", "ecdsa", "p256", "p384", "encryption"] }
signature = "2"
portable-pty = "0.8"
hostname = "0.4"
whoami = "1"
inquire = "0.7"
owo-colors = "4"
comfy-table = "7"
crossterm = { version = "0.28", features = ["event-stream"] }
dirs = "5"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] }
rpassword = "7"
humantime = "2"
time = { version = "0.3", features = ["formatting", "macros"] }
shellexpand = "3"
tempfile = "3"

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM rust:1.95.0-trixie AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock* ./
COPY crates ./crates
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/build/target \
cargo build --release -p rsh-backend && \
cp target/release/rsh-backend /usr/local/bin/rsh-backend
FROM debian:bookworm-slim AS runtime
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates tini && \
rm -rf /var/lib/apt/lists/* && \
useradd --system --uid 10001 --home /var/lib/rsh --create-home rsh
COPY --from=builder /usr/local/bin/rsh-backend /usr/local/bin/rsh-backend
ENV RSH_DATA=/var/lib/rsh \
RSH_BIND=0.0.0.0:7777 \
RSH_LOG=info
USER 10001
EXPOSE 7777
VOLUME ["/var/lib/rsh"]
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/rsh-backend"]

View File

@@ -0,0 +1,24 @@
[package]
name = "rsh-backend"
version = "0.1.0"
edition.workspace = true
[dependencies]
rsh-types = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
argon2 = { workspace = true }
ssh-key = { workspace = true }
signature = { workspace = true }
dashmap = { workspace = true }
rand = { workspace = true }
bytes = { workspace = true }
futures-util = { workspace = true }
time = { workspace = true }

View File

@@ -0,0 +1,29 @@
use anyhow::{anyhow, Result};
use argon2::password_hash::{PasswordHash, PasswordVerifier};
use argon2::Argon2;
use ssh_key::public::PublicKey;
use ssh_key::SshSig;
pub fn verify_password(password: &str, hash: &str) -> bool {
let parsed = match PasswordHash::new(hash) {
Ok(p) => p,
Err(_) => return false,
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
pub fn verify_signature(pubkey: &PublicKey, nonce: &[u8], signature_blob: &[u8]) -> Result<()> {
let sig = SshSig::from_pem(signature_blob)
.map_err(|e| anyhow!("parse signature: {e}"))?;
pubkey
.verify("rsh-auth", nonce, &sig)
.map_err(|e| anyhow!("verify failed: {e}"))?;
Ok(())
}
pub fn find_key<'a>(keys: &'a [PublicKey], offered: &PublicKey) -> Option<&'a PublicKey> {
let fp = offered.fingerprint(Default::default());
keys.iter().find(|k| k.fingerprint(Default::default()) == fp)
}

View File

@@ -0,0 +1,30 @@
use std::net::SocketAddr;
use std::path::PathBuf;
#[derive(Clone, Debug)]
pub struct Config {
pub data_dir: PathBuf,
pub bind: SocketAddr,
pub log: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let data_dir = std::env::var("RSH_DATA")
.unwrap_or_else(|_| "/var/lib/rsh".to_string())
.into();
let bind: SocketAddr = std::env::var("RSH_BIND")
.unwrap_or_else(|_| "0.0.0.0:7777".to_string())
.parse()?;
let log = std::env::var("RSH_LOG").unwrap_or_else(|_| "info,tower_http=warn".to_string());
Ok(Self { data_dir, bind, log })
}
pub fn sessions_path(&self) -> PathBuf {
self.data_dir.join("sessions.json")
}
pub fn authorized_keys_path(&self) -> PathBuf {
self.data_dir.join("authorized_keys")
}
}

View File

@@ -0,0 +1,10 @@
use ssh_key::PublicKey;
pub fn parse_authorized_keys(content: &str) -> Vec<PublicKey> {
content
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| PublicKey::from_openssh(l).ok())
.collect()
}

View File

@@ -0,0 +1,60 @@
mod auth;
mod config;
mod keys;
mod persist;
mod state;
mod ws_op;
mod ws_stub;
use anyhow::Context;
use axum::routing::get;
use axum::Router;
use config::Config;
use state::AppState;
use std::collections::HashMap;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cfg = Config::from_env()?;
let filter = EnvFilter::try_new(&cfg.log).unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
tokio::fs::create_dir_all(&cfg.data_dir).await.ok();
let state = Arc::new(AppState::new(cfg.clone()));
let sessions = persist::load_sessions(&cfg.sessions_path())
.await
.context("load sessions")?;
{
let mut map = state.sessions.write().await;
let mut h: HashMap<String, _> = HashMap::new();
for s in sessions {
h.insert(s.id.clone(), s);
}
*map = h;
}
let keys_text = persist::load_authorized_keys_text(&cfg.authorized_keys_path())
.await
.unwrap_or_default();
{
let mut k = state.authorized_keys.write().await;
*k = keys::parse_authorized_keys(&keys_text);
tracing::info!(count = k.len(), "loaded authorized keys");
}
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/ws/stub", get(ws_stub::handler))
.route("/ws/op", get(ws_op::handler))
.with_state(state.clone())
.layer(tower_http::trace::TraceLayer::new_for_http());
tracing::info!(bind = %cfg.bind, data = ?cfg.data_dir, "rsh-backend listening");
let listener = tokio::net::TcpListener::bind(cfg.bind).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@@ -0,0 +1,45 @@
use anyhow::Context;
use rsh_types::SessionRecord;
use std::path::Path;
use tokio::fs;
use tokio::io::AsyncWriteExt;
pub async fn load_sessions(path: &Path) -> anyhow::Result<Vec<SessionRecord>> {
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read(path).await.with_context(|| format!("read {:?}", path))?;
let v: Vec<SessionRecord> = serde_json::from_slice(&data)?;
Ok(v)
}
pub async fn save_sessions(path: &Path, sessions: &[SessionRecord]) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.ok();
}
let tmp = path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(sessions)?;
let mut f = fs::File::create(&tmp).await?;
f.write_all(&bytes).await?;
f.sync_all().await?;
drop(f);
fs::rename(&tmp, path).await?;
Ok(())
}
pub async fn load_authorized_keys_text(path: &Path) -> anyhow::Result<String> {
if !path.exists() {
return Ok(String::new());
}
Ok(fs::read_to_string(path).await?)
}
pub async fn save_authorized_keys_text(path: &Path, content: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.ok();
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, content.as_bytes()).await?;
fs::rename(&tmp, path).await?;
Ok(())
}

View File

@@ -0,0 +1,71 @@
use crate::config::Config;
use dashmap::DashMap;
use rsh_types::{BackendOpMsg, BackendStubMsg, OpEvent, SessionRecord, StubInfo};
use ssh_key::PublicKey;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc, Mutex, RwLock};
pub struct ConnHandle {
pub info: StubInfo,
pub to_stub: mpsc::Sender<BackendStubMsg>,
pub attach: Mutex<Option<AttachSink>>,
pub connected_at: i64,
}
pub struct AttachSink {
pub req_id: u64,
pub sender: mpsc::Sender<BackendOpMsg>,
}
pub struct AppState {
pub cfg: Config,
pub sessions: RwLock<HashMap<String, SessionRecord>>,
pub connections: DashMap<(String, u64), Arc<ConnHandle>>,
pub next_conn_id: DashMap<String, AtomicU64>,
pub authorized_keys: RwLock<Vec<PublicKey>>,
pub event_bus: broadcast::Sender<OpEvent>,
}
impl AppState {
pub fn new(cfg: Config) -> Self {
let (tx, _) = broadcast::channel(256);
Self {
cfg,
sessions: RwLock::new(HashMap::new()),
connections: DashMap::new(),
next_conn_id: DashMap::new(),
authorized_keys: RwLock::new(Vec::new()),
event_bus: tx,
}
}
pub fn alloc_conn_id(&self, session: &str) -> u64 {
let entry = self
.next_conn_id
.entry(session.to_string())
.or_insert_with(|| AtomicU64::new(1));
entry.fetch_add(1, Ordering::Relaxed)
}
pub fn connection_count(&self, session: &str) -> u32 {
self.connections
.iter()
.filter(|kv| kv.key().0 == session)
.count() as u32
}
pub fn list_connections(&self, filter: Option<&str>) -> Vec<rsh_types::ConnectionView> {
self.connections
.iter()
.filter(|kv| filter.map_or(true, |s| kv.key().0 == s))
.map(|kv| rsh_types::ConnectionView {
session_id: kv.key().0.clone(),
connection_id: kv.key().1,
info: kv.value().info.clone(),
connected_at: kv.value().connected_at,
})
.collect()
}
}

View File

@@ -0,0 +1,388 @@
use crate::auth::{find_key, verify_signature};
use crate::keys::parse_authorized_keys;
use crate::persist;
use crate::state::{AppState, AttachSink};
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{State, WebSocketUpgrade};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use rand::RngCore;
use rsh_types::{
AttachIOFrame, BackendOpMsg, BackendStubMsg, OpEvent, OpMsg, OpReq, OpResp, SessionRecord,
SessionView,
};
use ssh_key::PublicKey;
use std::sync::Arc;
use tokio::sync::mpsc;
pub async fn handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| run(socket, state))
}
async fn run(mut socket: WebSocket, state: Arc<AppState>) {
let pubkey = match auth_handshake(&mut socket, &state).await {
Ok(k) => k,
Err(reason) => {
let _ = send(&mut socket, &BackendOpMsg::AuthFail { reason }).await;
return;
}
};
if send(&mut socket, &BackendOpMsg::AuthOk).await.is_err() {
return;
}
tracing::info!(fingerprint = %pubkey.fingerprint(Default::default()), "operator authed");
let (out_tx, mut out_rx) = mpsc::channel::<BackendOpMsg>(128);
let (mut sink, mut stream) = socket.split();
let writer = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(_) => break,
};
if sink.send(Message::Text(text)).await.is_err() {
break;
}
}
let _ = sink.close().await;
});
let mut attached: Option<(String, u64, u64)> = None;
while let Some(Ok(msg)) = stream.next().await {
let text = match msg {
Message::Text(t) => t,
Message::Binary(b) => match String::from_utf8(b) {
Ok(s) => s,
Err(_) => continue,
},
Message::Close(_) => break,
_ => continue,
};
let op: OpMsg = match serde_json::from_str(&text) {
Ok(v) => v,
Err(e) => {
tracing::warn!("bad op msg: {e}");
continue;
}
};
let (req_id, body) = match op {
OpMsg::Req { id, body } => (id, body),
_ => continue,
};
let resp = handle_req(&state, &out_tx, &mut attached, req_id, body).await;
if let Some(r) = resp {
if out_tx.send(BackendOpMsg::Resp { id: req_id, body: r }).await.is_err() {
break;
}
}
}
if let Some((s, c, _)) = attached {
if let Some(handle) = state.connections.get(&(s, c)) {
let mut a = handle.attach.lock().await;
*a = None;
}
}
drop(out_tx);
let _ = writer.await;
}
async fn auth_handshake(socket: &mut WebSocket, state: &Arc<AppState>) -> Result<PublicKey, String> {
let init = recv_op(socket).await.ok_or_else(|| "no message".to_string())?;
let offered_str = match init {
OpMsg::AuthInit { pubkey_openssh } => pubkey_openssh,
_ => return Err("expected AuthInit".into()),
};
let offered = PublicKey::from_openssh(&offered_str).map_err(|e| format!("bad pubkey: {e}"))?;
let keys = state.authorized_keys.read().await;
let matched = find_key(&keys, &offered).cloned();
drop(keys);
let matched = matched.ok_or_else(|| "key not authorized".to_string())?;
let mut nonce = [0u8; 32];
rand::thread_rng().fill_bytes(&mut nonce);
send(socket, &BackendOpMsg::Challenge { nonce }).await.map_err(|e| e.to_string())?;
let signed = recv_op(socket).await.ok_or_else(|| "no signature".to_string())?;
let (sig_bytes, _alg) = match signed {
OpMsg::AuthSign { signature, alg } => (signature, alg),
_ => return Err("expected AuthSign".into()),
};
verify_signature(&matched, &nonce, &sig_bytes).map_err(|e| e.to_string())?;
Ok(matched)
}
async fn handle_req(
state: &Arc<AppState>,
out_tx: &mpsc::Sender<BackendOpMsg>,
attached: &mut Option<(String, u64, u64)>,
req_id: u64,
body: OpReq,
) -> Option<OpResp> {
match body {
OpReq::SessionList => Some(OpResp::Sessions(list_sessions(state).await)),
OpReq::SessionCreate { name, password_hash } => {
let mut s = state.sessions.write().await;
if s.contains_key(&name) {
return Some(OpResp::Err(format!("session '{name}' already exists")));
}
let rec = SessionRecord {
id: name.clone(),
password_hash,
created_at: now_unix(),
};
s.insert(name.clone(), rec.clone());
let snapshot: Vec<_> = s.values().cloned().collect();
drop(s);
if let Err(e) = persist::save_sessions(&state.cfg.sessions_path(), &snapshot).await {
return Some(OpResp::Err(format!("persist: {e}")));
}
let view = SessionView {
id: rec.id.clone(),
has_password: rec.password_hash.is_some(),
created_at: rec.created_at,
connection_count: 0,
};
let _ = state.event_bus.send(OpEvent::NewSession(view));
Some(OpResp::Ok)
}
OpReq::SessionDelete { name, disconnect } => {
let mut s = state.sessions.write().await;
if s.remove(&name).is_none() {
return Some(OpResp::Err(format!("no such session '{name}'")));
}
let snapshot: Vec<_> = s.values().cloned().collect();
drop(s);
if let Err(e) = persist::save_sessions(&state.cfg.sessions_path(), &snapshot).await {
return Some(OpResp::Err(format!("persist: {e}")));
}
if disconnect {
disconnect_session(state, &name).await;
}
let _ = state.event_bus.send(OpEvent::SessionDeleted { session: name });
Some(OpResp::Ok)
}
OpReq::SessionUpdate { name, set_password_hash, disconnect } => {
let mut s = state.sessions.write().await;
let Some(rec) = s.get_mut(&name) else {
return Some(OpResp::Err(format!("no such session '{name}'")));
};
if let Some(pw) = set_password_hash {
rec.password_hash = pw;
}
let snapshot: Vec<_> = s.values().cloned().collect();
drop(s);
if let Err(e) = persist::save_sessions(&state.cfg.sessions_path(), &snapshot).await {
return Some(OpResp::Err(format!("persist: {e}")));
}
if disconnect {
disconnect_session(state, &name).await;
}
Some(OpResp::Ok)
}
OpReq::ConnectionList { session } => {
Some(OpResp::Connections(state.list_connections(session.as_deref())))
}
OpReq::Attach { session, connection_id, pty: _, cols, rows } => {
let conn_id = match connection_id {
Some(c) => c,
None => {
let mut found = None;
for kv in state.connections.iter() {
if kv.key().0 == session {
if found.is_some() {
return Some(OpResp::Err("multiple connections; specify id".into()));
}
found = Some(kv.key().1);
}
}
match found {
Some(c) => c,
None => return Some(OpResp::Err("no connections".into())),
}
}
};
let Some(handle) = state.connections.get(&(session.clone(), conn_id)).map(|h| h.clone()) else {
return Some(OpResp::Err("connection not found".into()));
};
{
let mut a = handle.attach.lock().await;
*a = Some(AttachSink { req_id, sender: out_tx.clone() });
}
let _ = handle.to_stub.send(BackendStubMsg::Resize { cols, rows }).await;
*attached = Some((session, conn_id, req_id));
Some(OpResp::AttachReady { connection_id: conn_id })
}
OpReq::AttachIO(frame) => {
let Some((session, conn_id, _)) = attached.clone() else {
return Some(OpResp::Err("not attached".into()));
};
let Some(handle) = state.connections.get(&(session, conn_id)).map(|h| h.clone()) else {
return Some(OpResp::Err("connection gone".into()));
};
match frame {
AttachIOFrame::Stdin(b) => {
let _ = handle.to_stub.send(BackendStubMsg::Stdin(b)).await;
}
AttachIOFrame::Resize { cols, rows } => {
let _ = handle.to_stub.send(BackendStubMsg::Resize { cols, rows }).await;
}
AttachIOFrame::Kill => {
let _ = handle.to_stub.send(BackendStubMsg::Kill).await;
}
AttachIOFrame::Eof => {
let _ = handle.to_stub.send(BackendStubMsg::Stdin(Vec::new())).await;
}
}
None
}
OpReq::Detach => {
if let Some((s, c, _)) = attached.take() {
if let Some(handle) = state.connections.get(&(s, c)) {
let mut a = handle.attach.lock().await;
*a = None;
}
}
Some(OpResp::Ok)
}
OpReq::KeysList => {
let text = persist::load_authorized_keys_text(&state.cfg.authorized_keys_path())
.await
.unwrap_or_default();
let keys: Vec<String> = text
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
Some(OpResp::Keys(keys))
}
OpReq::KeysAppend { keys } => {
let path = state.cfg.authorized_keys_path();
let mut text = persist::load_authorized_keys_text(&path).await.unwrap_or_default();
for k in keys {
let k = k.trim();
if k.is_empty() {
continue;
}
if !text.lines().any(|l| l.trim() == k) {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(k);
text.push('\n');
}
}
if let Err(e) = persist::save_authorized_keys_text(&path, &text).await {
return Some(OpResp::Err(format!("persist: {e}")));
}
reload_authorized_keys(state, &text).await;
Some(OpResp::Ok)
}
OpReq::KeysRemove { keys } => {
let path = state.cfg.authorized_keys_path();
let text = persist::load_authorized_keys_text(&path).await.unwrap_or_default();
let targets: Vec<String> = keys.iter().map(|k| k.trim().to_string()).collect();
let new: String = text
.lines()
.filter(|l| {
let lt = l.trim();
!targets.iter().any(|t| t == lt)
})
.collect::<Vec<_>>()
.join("\n");
let new = if new.is_empty() { new } else { format!("{}\n", new) };
if let Err(e) = persist::save_authorized_keys_text(&path, &new).await {
return Some(OpResp::Err(format!("persist: {e}")));
}
reload_authorized_keys(state, &new).await;
Some(OpResp::Ok)
}
OpReq::KeysReplace { content } => {
let path = state.cfg.authorized_keys_path();
if let Err(e) = persist::save_authorized_keys_text(&path, &content).await {
return Some(OpResp::Err(format!("persist: {e}")));
}
reload_authorized_keys(state, &content).await;
Some(OpResp::Ok)
}
OpReq::Watch { session } => {
let mut rx = state.event_bus.subscribe();
let tx = out_tx.clone();
tokio::spawn(async move {
while let Ok(ev) = rx.recv().await {
let pass = match &ev {
OpEvent::NewConnection(v) => session.as_ref().map_or(true, |s| &v.session_id == s),
OpEvent::ConnectionClosed { session: s, .. } => session.as_ref().map_or(true, |x| x == s),
OpEvent::NewSession(v) => session.as_ref().map_or(true, |s| &v.id == s),
OpEvent::SessionDeleted { session: s } => session.as_ref().map_or(true, |x| x == s),
};
if !pass {
continue;
}
if tx.send(BackendOpMsg::Event(ev)).await.is_err() {
break;
}
}
});
Some(OpResp::WatchStarted)
}
}
}
async fn list_sessions(state: &Arc<AppState>) -> Vec<SessionView> {
let s = state.sessions.read().await;
s.values()
.map(|r| SessionView {
id: r.id.clone(),
has_password: r.password_hash.is_some(),
created_at: r.created_at,
connection_count: state.connection_count(&r.id),
})
.collect()
}
async fn disconnect_session(state: &Arc<AppState>, name: &str) {
let mut to_kill = Vec::new();
for kv in state.connections.iter() {
if kv.key().0 == name {
to_kill.push((kv.key().clone(), kv.value().clone()));
}
}
for (_, handle) in &to_kill {
let _ = handle.to_stub.send(BackendStubMsg::Kill).await;
}
}
async fn reload_authorized_keys(state: &Arc<AppState>, text: &str) {
let parsed = parse_authorized_keys(text);
let mut k = state.authorized_keys.write().await;
*k = parsed;
}
async fn send(socket: &mut WebSocket, msg: &BackendOpMsg) -> Result<(), axum::Error> {
let t = serde_json::to_string(msg).map_err(|e| axum::Error::new(e))?;
socket.send(Message::Text(t)).await
}
async fn recv_op(socket: &mut WebSocket) -> Option<OpMsg> {
loop {
match socket.recv().await? {
Ok(Message::Text(t)) => return serde_json::from_str(&t).ok(),
Ok(Message::Binary(b)) => return serde_json::from_slice(&b).ok(),
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => continue,
Ok(Message::Close(_)) => return None,
Err(_) => return None,
}
}
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

View File

@@ -0,0 +1,155 @@
use crate::auth::verify_password;
use crate::state::{AppState, ConnHandle};
use axum::extract::ws::{Message, WebSocket};
use axum::extract::{State, WebSocketUpgrade};
use axum::response::IntoResponse;
use futures_util::{SinkExt, StreamExt};
use rsh_types::{BackendOpMsg, BackendStubMsg, ConnectionView, OpEvent, OpResp, StubMsg};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
pub async fn handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| run(socket, state))
}
async fn run(mut socket: WebSocket, state: Arc<AppState>) {
let hello = match recv_text::<StubMsg>(&mut socket).await {
Some(StubMsg::Hello { session_id, password, info }) => (session_id, password, info),
_ => {
let _ = send(&mut socket, &BackendStubMsg::Rejected { reason: "expected Hello".into() }).await;
return;
}
};
let (session_id, password, info) = hello;
let sessions = state.sessions.read().await;
let session = match sessions.get(&session_id) {
Some(s) => s.clone(),
None => {
drop(sessions);
let _ = send(&mut socket, &BackendStubMsg::Rejected { reason: "no such session".into() }).await;
return;
}
};
drop(sessions);
if let Some(hash) = &session.password_hash {
let provided = password.unwrap_or_default();
if !verify_password(&provided, hash) {
let _ = send(&mut socket, &BackendStubMsg::Rejected { reason: "bad password".into() }).await;
return;
}
}
let conn_id = state.alloc_conn_id(&session_id);
let (to_stub_tx, mut to_stub_rx) = mpsc::channel::<BackendStubMsg>(64);
let connected_at = now_unix();
let handle = Arc::new(ConnHandle {
info: info.clone(),
to_stub: to_stub_tx.clone(),
attach: Mutex::new(None),
connected_at,
});
state.connections.insert((session_id.clone(), conn_id), handle.clone());
let _ = state.event_bus.send(OpEvent::NewConnection(ConnectionView {
session_id: session_id.clone(),
connection_id: conn_id,
info: info.clone(),
connected_at,
}));
if send(&mut socket, &BackendStubMsg::Accepted { connection_id: conn_id }).await.is_err() {
cleanup(&state, &session_id, conn_id).await;
return;
}
let (mut ws_sink, mut ws_stream) = socket.split();
let writer = tokio::spawn(async move {
while let Some(msg) = to_stub_rx.recv().await {
let text = match serde_json::to_string(&msg) {
Ok(t) => t,
Err(_) => break,
};
if ws_sink.send(Message::Text(text)).await.is_err() {
break;
}
}
let _ = ws_sink.close().await;
});
let state_r = state.clone();
let session_r = session_id.clone();
let handle_r = handle.clone();
let reader = tokio::spawn(async move {
while let Some(Ok(msg)) = ws_stream.next().await {
let text = 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: StubMsg = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => continue,
};
match parsed {
StubMsg::Stdout(b) => forward_op(&handle_r, OpResp::Stdout(b)).await,
StubMsg::Stderr(b) => forward_op(&handle_r, OpResp::Stderr(b)).await,
StubMsg::Exited { code } => {
forward_op(&handle_r, OpResp::Exited { code }).await;
break;
}
StubMsg::Pong => {}
StubMsg::Hello { .. } => {}
}
}
cleanup(&state_r, &session_r, conn_id).await;
});
let _ = tokio::join!(writer, reader);
}
async fn forward_op(handle: &Arc<crate::state::ConnHandle>, resp: OpResp) {
let attach = handle.attach.lock().await;
if let Some(sink) = attach.as_ref() {
let _ = sink
.sender
.send(BackendOpMsg::Resp { id: sink.req_id, body: resp })
.await;
}
}
async fn cleanup(state: &Arc<AppState>, session_id: &str, conn_id: u64) {
state.connections.remove(&(session_id.to_string(), conn_id));
let _ = state.event_bus.send(OpEvent::ConnectionClosed {
session: session_id.to_string(),
connection_id: conn_id,
});
}
async fn send(socket: &mut WebSocket, msg: &BackendStubMsg) -> Result<(), axum::Error> {
let t = serde_json::to_string(msg).map_err(|e| axum::Error::new(e))?;
socket.send(Message::Text(t)).await
}
async fn recv_text<T: serde::de::DeserializeOwned>(socket: &mut WebSocket) -> Option<T> {
loop {
match socket.recv().await? {
Ok(Message::Text(t)) => return serde_json::from_str(&t).ok(),
Ok(Message::Binary(b)) => return serde_json::from_slice(&b).ok(),
Ok(Message::Ping(_)) | Ok(Message::Pong(_)) => continue,
Ok(Message::Close(_)) => return None,
Err(_) => return None,
}
}
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

View File

@@ -0,0 +1,9 @@
[package]
name = "rsh-types"
version = "0.1.0"
edition.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

138
crates/rsh-types/src/lib.rs Normal file
View File

@@ -0,0 +1,138 @@
use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StubInfo {
pub hostname: String,
pub user: String,
pub os: String,
pub arch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionView {
pub id: String,
pub has_password: bool,
pub created_at: i64,
pub connection_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionView {
pub session_id: String,
pub connection_id: u64,
pub info: StubInfo,
pub connected_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRecord {
pub id: String,
pub password_hash: Option<String>,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum StubMsg {
Hello {
session_id: String,
password: Option<String>,
info: StubInfo,
},
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Exited { code: Option<i32> },
Pong,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum BackendStubMsg {
Accepted { connection_id: u64 },
Rejected { reason: String },
Stdin(Vec<u8>),
Resize { cols: u16, rows: u16 },
Kill,
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum AttachIOFrame {
Stdin(Vec<u8>),
Resize { cols: u16, rows: u16 },
Eof,
Kill,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum OpReq {
SessionCreate { name: String, password_hash: Option<String> },
SessionDelete { name: String, disconnect: bool },
SessionUpdate {
name: String,
set_password_hash: Option<Option<String>>,
disconnect: bool,
},
SessionList,
ConnectionList { session: Option<String> },
Attach {
session: String,
connection_id: Option<u64>,
pty: bool,
cols: u16,
rows: u16,
},
AttachIO(AttachIOFrame),
Detach,
KeysList,
KeysAppend { keys: Vec<String> },
KeysRemove { keys: Vec<String> },
KeysReplace { content: String },
Watch { session: Option<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum OpResp {
Ok,
Err(String),
Sessions(Vec<SessionView>),
Connections(Vec<ConnectionView>),
AttachReady { connection_id: u64 },
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Exited { code: Option<i32> },
Keys(Vec<String>),
WatchStarted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum OpEvent {
NewConnection(ConnectionView),
ConnectionClosed { session: String, connection_id: u64 },
NewSession(SessionView),
SessionDeleted { session: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum OpMsg {
AuthInit { pubkey_openssh: String },
AuthSign { signature: Vec<u8>, alg: String },
Req { id: u64, body: OpReq },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
pub enum BackendOpMsg {
Challenge { nonce: [u8; 32] },
AuthOk,
AuthFail { reason: String },
Resp { id: u64, body: OpResp },
Event(OpEvent),
}

20
crates/rsh/Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "rsh"
version = "0.1.0"
edition.workspace = true
[dependencies]
rsh-types = { workspace = true }
tokio = { workspace = true }
tokio-tungstenite = { workspace = true }
futures-util = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
clap = { workspace = true }
portable-pty = { workspace = true }
anyhow = { workspace = true }
bytes = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
hostname = { workspace = true }
whoami = { workspace = true }

44
crates/rsh/src/main.rs Normal file
View File

@@ -0,0 +1,44 @@
mod pty;
mod ws;
use anyhow::Result;
use clap::Parser;
use std::time::Duration;
use tracing_subscriber::EnvFilter;
#[derive(Parser, Debug, Clone)]
#[command(version, about = "rsh reverse-shell stub")]
struct Args {
#[arg(long)]
url: String,
#[arg(long)]
session: String,
#[arg(long)]
password: Option<String>,
#[arg(long)]
shell: Option<String>,
#[arg(long, default_value_t = false)]
no_pty: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn,rsh=info"));
tracing_subscriber::fmt().with_env_filter(filter).with_writer(std::io::stderr).init();
let args = Args::parse();
let mut backoff = Duration::from_secs(1);
loop {
match ws::run(&args).await {
Ok(ws::Outcome::Exited) => return Ok(()),
Ok(ws::Outcome::Rejected(reason)) => {
eprintln!("rsh: rejected by backend: {reason}");
return Ok(());
}
Ok(ws::Outcome::Dropped) | Err(_) => {
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(Duration::from_secs(30));
}
}
}
}

87
crates/rsh/src/pty.rs Normal file
View File

@@ -0,0 +1,87 @@
use anyhow::Result;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use std::io::{Read, Write};
use tokio::sync::mpsc;
pub struct PtySession {
pub stdout_rx: mpsc::Receiver<Vec<u8>>,
pub stdin_tx: mpsc::Sender<Vec<u8>>,
pub resize_tx: std::sync::mpsc::Sender<PtySize>,
pub exit_rx: tokio::sync::oneshot::Receiver<Option<i32>>,
pub kill: KillHandle,
}
pub struct KillHandle {
inner: Box<dyn portable_pty::ChildKiller + Send + Sync>,
}
impl KillHandle {
pub fn kill(&mut self) {
let _ = self.inner.kill();
}
}
pub fn spawn(shell: &str, cols: u16, rows: u16) -> Result<PtySession> {
let pty_system = native_pty_system();
let pair = pty_system.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })?;
let cmd = CommandBuilder::new(shell);
let mut child = pair.slave.spawn_command(cmd)?;
drop(pair.slave);
let mut reader = pair.master.try_clone_reader()?;
let mut writer = pair.master.take_writer()?;
let master = pair.master;
let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(64);
let (stdin_tx, mut stdin_rx) = mpsc::channel::<Vec<u8>>(64);
let (resize_tx, resize_rx) = std::sync::mpsc::channel::<PtySize>();
let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<Option<i32>>();
let killer = child.clone_killer();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if stdout_tx.blocking_send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
std::thread::spawn(move || {
while let Some(b) = stdin_rx.blocking_recv() {
if writer.write_all(&b).is_err() {
break;
}
let _ = writer.flush();
}
});
std::thread::spawn(move || {
while let Ok(size) = resize_rx.recv() {
let _ = master.resize(size);
}
});
std::thread::spawn(move || {
let status = child.wait().ok();
let code = status.and_then(|s| {
let raw = s.exit_code();
Some(raw as i32)
});
let _ = exit_tx.send(code);
});
Ok(PtySession {
stdout_rx,
stdin_tx,
resize_tx,
exit_rx,
kill: KillHandle { inner: killer },
})
}

187
crates/rsh/src/ws.rs Normal file
View File

@@ -0,0 +1,187 @@
use crate::pty;
use crate::Args;
use anyhow::{anyhow, Result};
use futures_util::{SinkExt, StreamExt};
use rsh_types::{BackendStubMsg, StubInfo, StubMsg};
use tokio_tungstenite::tungstenite::Message;
pub enum Outcome {
Exited,
Rejected(String),
Dropped,
}
pub async fn run(args: &Args) -> Result<Outcome> {
let (mut ws, _) = tokio_tungstenite::connect_async(&args.url).await?;
let info = StubInfo {
hostname: hostname::get().ok().and_then(|h| h.into_string().ok()).unwrap_or_default(),
user: whoami::username(),
os: std::env::consts::OS.into(),
arch: std::env::consts::ARCH.into(),
};
let hello = StubMsg::Hello {
session_id: args.session.clone(),
password: args.password.clone(),
info,
};
ws.send(Message::Text(serde_json::to_string(&hello)?)).await?;
let accepted = loop {
let Some(msg) = ws.next().await else { return Ok(Outcome::Dropped); };
let msg = msg?;
let text = match msg {
Message::Text(t) => t,
Message::Binary(b) => String::from_utf8(b).map_err(|e| anyhow!(e))?,
Message::Close(_) => return Ok(Outcome::Dropped),
_ => continue,
};
let parsed: BackendStubMsg = serde_json::from_str(&text)?;
match parsed {
BackendStubMsg::Accepted { connection_id } => {
tracing::info!(connection_id, "accepted");
break true;
}
BackendStubMsg::Rejected { reason } => return Ok(Outcome::Rejected(reason)),
_ => continue,
}
};
let _ = accepted;
let shell = args
.shell
.clone()
.or_else(|| std::env::var("SHELL").ok())
.unwrap_or_else(|| "/bin/sh".to_string());
if args.no_pty {
run_no_pty(ws, &shell).await
} else {
run_pty(ws, &shell).await
}
}
async fn run_pty(
ws: tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
shell: &str,
) -> Result<Outcome> {
let mut session = pty::spawn(shell, 80, 24)?;
let (mut sink, mut stream) = ws.split();
loop {
tokio::select! {
biased;
code = &mut session.exit_rx => {
let code = code.ok().flatten();
let _ = sink.send(Message::Text(serde_json::to_string(&StubMsg::Exited { code })?)).await;
let _ = sink.close().await;
return Ok(Outcome::Exited);
}
Some(out) = session.stdout_rx.recv() => {
if sink.send(Message::Text(serde_json::to_string(&StubMsg::Stdout(out))?)).await.is_err() {
session.kill.kill();
return Ok(Outcome::Dropped);
}
}
msg = stream.next() => {
let Some(msg) = msg else { session.kill.kill(); return Ok(Outcome::Dropped); };
let Ok(msg) = msg else { session.kill.kill(); return Ok(Outcome::Dropped); };
let text = match msg {
Message::Text(t) => t,
Message::Binary(b) => match String::from_utf8(b) { Ok(s) => s, Err(_) => continue },
Message::Close(_) => { session.kill.kill(); return Ok(Outcome::Dropped); }
Message::Ping(p) => { let _ = sink.send(Message::Pong(p)).await; continue; }
_ => continue,
};
let parsed: BackendStubMsg = match serde_json::from_str(&text) { Ok(v) => v, Err(_) => continue };
match parsed {
BackendStubMsg::Stdin(b) => { let _ = session.stdin_tx.send(b).await; }
BackendStubMsg::Resize { cols, rows } => {
let _ = session.resize_tx.send(portable_pty::PtySize { cols, rows, pixel_width: 0, pixel_height: 0 });
}
BackendStubMsg::Kill => { session.kill.kill(); }
BackendStubMsg::Ping => { let _ = sink.send(Message::Text(serde_json::to_string(&StubMsg::Pong)?)).await; }
_ => {}
}
}
}
}
}
async fn run_no_pty(
ws: tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
shell: &str,
) -> Result<Outcome> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
let mut child = Command::new(shell)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?;
let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
let (mut sink, mut stream) = ws.split();
let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<StubMsg>(64);
let out_tx2 = out_tx.clone();
tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
match stdout.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if out_tx.send(StubMsg::Stdout(buf[..n].to_vec())).await.is_err() {
break;
}
}
}
}
});
tokio::spawn(async move {
let mut buf = [0u8; 4096];
loop {
match stderr.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if out_tx2.send(StubMsg::Stderr(buf[..n].to_vec())).await.is_err() {
break;
}
}
}
}
});
loop {
tokio::select! {
status = child.wait() => {
let code = status.ok().and_then(|s| s.code());
let _ = sink.send(Message::Text(serde_json::to_string(&StubMsg::Exited { code })?)).await;
let _ = sink.close().await;
return Ok(Outcome::Exited);
}
Some(msg) = out_rx.recv() => {
if sink.send(Message::Text(serde_json::to_string(&msg)?)).await.is_err() {
let _ = child.kill().await;
return Ok(Outcome::Dropped);
}
}
m = stream.next() => {
let Some(m) = m else { let _ = child.kill().await; return Ok(Outcome::Dropped); };
let Ok(m) = m else { let _ = child.kill().await; return Ok(Outcome::Dropped); };
let text = match m {
Message::Text(t) => t,
Message::Binary(b) => match String::from_utf8(b) { Ok(s) => s, Err(_) => continue },
Message::Close(_) => { let _ = child.kill().await; return Ok(Outcome::Dropped); }
_ => continue,
};
let parsed: BackendStubMsg = match serde_json::from_str(&text) { Ok(v) => v, Err(_) => continue };
match parsed {
BackendStubMsg::Stdin(b) => { let _ = stdin.write_all(&b).await; }
BackendStubMsg::Kill => { let _ = child.kill().await; }
_ => {}
}
}
}
}
}

34
crates/rshc/Cargo.toml Normal file
View 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
View 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)?);
}
}

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(())
}

45
crates/rshc/src/config.rs Normal file
View 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
View 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
View 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)
}

View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: rsh-backend
description: rsh reverse-shell backend relay
type: application
version: 0.1.0
appVersion: "0.1.0"

View File

@@ -0,0 +1,37 @@
{{- define "rsh-backend.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "rsh-backend.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "rsh-backend.labels" -}}
app.kubernetes.io/name: {{ include "rsh-backend.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end -}}
{{- define "rsh-backend.selectorLabels" -}}
app.kubernetes.io/name: {{ include "rsh-backend.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "rsh-backend.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{- default (include "rsh-backend.fullname" .) .Values.serviceAccount.name -}}
{{- else -}}
{{- default "default" .Values.serviceAccount.name -}}
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,117 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "rsh-backend.fullname" . }}
labels:
{{- include "rsh-backend.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
strategy:
type: Recreate
selector:
matchLabels:
{{- include "rsh-backend.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "rsh-backend.selectorLabels" . | nindent 8 }}
annotations:
{{- if .Values.authorizedKeys }}
checksum/authorized-keys: {{ .Values.authorizedKeys | sha256sum }}
{{- end }}
spec:
serviceAccountName: {{ include "rsh-backend.serviceAccountName" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- if .Values.authorizedKeys }}
initContainers:
- name: seed-authorized-keys
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- /bin/sh
- -c
- |
set -eu
install -m 600 /seed/authorized_keys /var/lib/rsh/authorized_keys
volumeMounts:
- name: data
mountPath: /var/lib/rsh
- name: authorized-keys
mountPath: /seed
readOnly: true
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
{{- end }}
containers:
- name: rsh-backend
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 7777
protocol: TCP
env:
- name: RSH_DATA
value: /var/lib/rsh
- name: RSH_BIND
value: 0.0.0.0:7777
{{- range $k, $v := .Values.env }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 2
periodSeconds: 5
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/rsh
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "rsh-backend.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
{{- if .Values.authorizedKeys }}
- name: authorized-keys
secret:
secretName: {{ include "rsh-backend.fullname" . }}-authorized-keys
items:
- key: authorized_keys
path: authorized_keys
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,33 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "rsh-backend.fullname" . }}
labels:
{{- include "rsh-backend.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host | quote }}
secretName: {{ .Values.ingress.tls.secretName | quote }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "rsh-backend.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}

View File

@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "rsh-backend.fullname" . }}-data
labels:
{{- include "rsh-backend.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.size | quote }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass | quote }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.authorizedKeys }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "rsh-backend.fullname" . }}-authorized-keys
labels:
{{- include "rsh-backend.labels" . | nindent 4 }}
type: Opaque
stringData:
authorized_keys: |
{{ .Values.authorizedKeys | indent 4 }}
{{- end }}

View File

@@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "rsh-backend.fullname" . }}
labels:
{{- include "rsh-backend.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
selector:
{{- include "rsh-backend.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "rsh-backend.serviceAccountName" . }}
labels:
{{- include "rsh-backend.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,55 @@
image:
repository: rsh-backend
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
replicaCount: 1
env:
RSH_LOG: info
service:
type: ClusterIP
port: 7777
annotations: {}
ingress:
enabled: false
className: ""
annotations: {}
host: rsh.example.com
tls:
enabled: false
secretName: ""
persistence:
enabled: true
size: 1Gi
storageClass: ""
accessMode: ReadWriteOnce
authorizedKeys: ""
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}
podSecurityContext:
fsGroup: 10001
securityContext:
runAsNonRoot: true
runAsUser: 10001
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
serviceAccount:
create: true
name: ""
annotations: {}

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
rsh-backend:
build:
context: .
dockerfile: Dockerfile
image: rsh-backend:${RSH_IMAGE_TAG:-local}
container_name: rsh-backend
restart: unless-stopped
ports:
- "${RSH_PORT:-7777}:7777"
environment:
RSH_DATA: /var/lib/rsh
RSH_BIND: 0.0.0.0:7777
RSH_LOG: ${RSH_LOG:-info}
volumes:
- ${RSH_DATA_DIR:-rsh-data}:/var/lib/rsh
volumes:
rsh-data: