initial commit
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
target
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
deploy
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
4
.env.example
Normal file
4
.env.example
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
.env
|
||||||
3773
Cargo.lock
generated
Normal file
3773
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
Cargo.toml
Normal file
50
Cargo.toml
Normal 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
22
Dockerfile
Normal 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"]
|
||||||
24
crates/rsh-backend/Cargo.toml
Normal file
24
crates/rsh-backend/Cargo.toml
Normal 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 }
|
||||||
29
crates/rsh-backend/src/auth.rs
Normal file
29
crates/rsh-backend/src/auth.rs
Normal 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)
|
||||||
|
}
|
||||||
30
crates/rsh-backend/src/config.rs
Normal file
30
crates/rsh-backend/src/config.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/rsh-backend/src/keys.rs
Normal file
10
crates/rsh-backend/src/keys.rs
Normal 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()
|
||||||
|
}
|
||||||
60
crates/rsh-backend/src/main.rs
Normal file
60
crates/rsh-backend/src/main.rs
Normal 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(())
|
||||||
|
}
|
||||||
45
crates/rsh-backend/src/persist.rs
Normal file
45
crates/rsh-backend/src/persist.rs
Normal 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(())
|
||||||
|
}
|
||||||
71
crates/rsh-backend/src/state.rs
Normal file
71
crates/rsh-backend/src/state.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
388
crates/rsh-backend/src/ws_op.rs
Normal file
388
crates/rsh-backend/src/ws_op.rs
Normal 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)
|
||||||
|
}
|
||||||
155
crates/rsh-backend/src/ws_stub.rs
Normal file
155
crates/rsh-backend/src/ws_stub.rs
Normal 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)
|
||||||
|
}
|
||||||
9
crates/rsh-types/Cargo.toml
Normal file
9
crates/rsh-types/Cargo.toml
Normal 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
138
crates/rsh-types/src/lib.rs
Normal 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
20
crates/rsh/Cargo.toml
Normal 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
44
crates/rsh/src/main.rs
Normal 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
87
crates/rsh/src/pty.rs
Normal 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
187
crates/rsh/src/ws.rs
Normal 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
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)
|
||||||
|
}
|
||||||
6
deploy/helm/rsh-backend/Chart.yaml
Normal file
6
deploy/helm/rsh-backend/Chart.yaml
Normal 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"
|
||||||
37
deploy/helm/rsh-backend/templates/_helpers.tpl
Normal file
37
deploy/helm/rsh-backend/templates/_helpers.tpl
Normal 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 -}}
|
||||||
117
deploy/helm/rsh-backend/templates/deployment.yaml
Normal file
117
deploy/helm/rsh-backend/templates/deployment.yaml
Normal 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 }}
|
||||||
33
deploy/helm/rsh-backend/templates/ingress.yaml
Normal file
33
deploy/helm/rsh-backend/templates/ingress.yaml
Normal 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 }}
|
||||||
17
deploy/helm/rsh-backend/templates/pvc.yaml
Normal file
17
deploy/helm/rsh-backend/templates/pvc.yaml
Normal 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 }}
|
||||||
12
deploy/helm/rsh-backend/templates/secret.yaml
Normal file
12
deploy/helm/rsh-backend/templates/secret.yaml
Normal 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 }}
|
||||||
19
deploy/helm/rsh-backend/templates/service.yaml
Normal file
19
deploy/helm/rsh-backend/templates/service.yaml
Normal 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 }}
|
||||||
12
deploy/helm/rsh-backend/templates/serviceaccount.yaml
Normal file
12
deploy/helm/rsh-backend/templates/serviceaccount.yaml
Normal 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 }}
|
||||||
55
deploy/helm/rsh-backend/values.yaml
Normal file
55
deploy/helm/rsh-backend/values.yaml
Normal 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
19
docker-compose.yml
Normal 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:
|
||||||
Reference in New Issue
Block a user