Compare commits

..

4 Commits

Author SHA1 Message Date
f5091e826e Merge pull request 'main' (#1) from 200mill/rsh:main into main
Reviewed-on: mincomk/rsh#1
2026-07-19 09:07:22 +00:00
200mill
3697600c96 fix: asdf 2026-07-04 16:50:09 +09:00
200mill
de09d628ed fix: security 2026-07-04 03:05:21 +09:00
200mill
45554836d8 feat: rshc: add sshserve 2026-07-04 02:32:54 +09:00
7 changed files with 2010 additions and 134 deletions

140
CLAUDE.md Normal file
View File

@@ -0,0 +1,140 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
`rsh` is a self-hosted reverse-shell / remote-access system, similar in spirit to a lightweight
teleport/ssh-reverse-tunnel setup. A central **backend** exposes a public HTTP/WebSocket endpoint.
Target machines run a tiny **stub** binary that dials out to the backend and registers under a
named **session**. An **operator** uses the `rshc` CLI, authenticated with an SSH keypair, to list
sessions/connections and attach an interactive shell to any connected stub.
## Workspace layout
Cargo workspace with 4 crates (`Cargo.toml` at repo root):
- `crates/rsh-types` — shared wire protocol types only (`StubMsg`, `BackendStubMsg`, `OpReq`,
`OpResp`, `OpEvent`, `OpMsg`, `BackendOpMsg`, ...), all serde-tagged enums (`#[serde(tag = "t", content = "c")]`).
This is the contract between all three binaries; changing a variant here touches backend, stub,
and client.
- `crates/rsh-backend` — the server (`axum`). Binary: `rsh-backend`.
- `crates/rsh` — the stub agent that runs on a target machine. Binary: `rsh`. Statically linked
(musl) so it runs on arbitrary Linux hosts with no dependencies.
- `crates/rshc` — the operator's CLI client. Binary: `rshc`.
## Build & dev commands
There are no automated tests in this repo currently.
```sh
cargo build # build everything (debug)
cargo build --release # release build
cargo check # fast typecheck
cargo clippy # lint
cargo fmt # format
```
Backend development requires the musl stub binaries to be built first and embedded via env vars
(see "Stub embedding" below) — use the `justfile` targets rather than raw cargo:
```sh
just stubs # cross-compiles the `rsh` stub for x86_64 and aarch64 musl targets
just backend # builds stubs, then builds rsh-backend with them embedded (release)
just dev # builds stubs, then `cargo run -p rsh-backend` bound to 127.0.0.1:7777,
# data dir /tmp/rsh-dev
```
`rshc` and `rsh` (stub) can be built/run directly with plain `cargo build -p rshc` /
`cargo run -p rsh -- <subcommand>` — they don't need the stub-embedding dance.
Container image (per user's global config, use `podman`/`podman compose` in place of any
`docker`/`docker compose` commands referenced by the justfile or docs):
```sh
just docker-build # docker build -t rsh-backend:local .
just docker-run # run the built image, mounts a data volume, exposes 7777
just docker-publish # tag + push to registry.walruslab.org/pub/rsh-backend
```
## Architecture
### Protocol split: two independent WebSocket channels
- `/ws/stub` (`rsh-backend/src/ws_stub.rs`) — target-machine stubs connect here. Messages:
`StubMsg` (stub → backend: `Hello`, `Stdout`/`Stderr`, `Exited`, `ShellReady`/`ShellStdout`/
`ShellStderr`/`ShellExited`, `Pong`) and `BackendStubMsg` (backend → stub: `Accepted`/`Rejected`,
`Stdin`, `Resize`, `Kill`, `Ping`, `SpawnShell`/`ShellStdin`/`ShellResize`/`ShellKill`).
- `/ws/op` (`rsh-backend/src/ws_op.rs`) — the `rshc` operator client connects here. Messages:
`OpMsg`/`BackendOpMsg` wrapping request/response bodies `OpReq`/`OpResp` and pushed `OpEvent`s
(new/closed connections, new/deleted sessions).
The backend is the hub connecting the two: `AppState` (`rsh-backend/src/state.rs`) holds live
stub connections (`connections: DashMap<(session_id, conn_id), Arc<ConnHandle>>`) and forwards
data between an operator's `AttachSink` and a stub's channel. `AttachIO` frames from an operator
become `Stdin`/`Resize`/`Kill` messages sent down `ConnHandle::to_stub`; stdout/stderr coming back
from the stub is forwarded to whichever operator is currently attached.
### Sessions, connections, shells
- A **session** is a named, optionally password-protected registration point (`SessionRecord`,
persisted to `sessions.json` via `rsh-backend/src/persist.rs`). Created/deleted/updated by
operators (`rshc session ...`).
- A **connection** is one stub process currently connected under a session (a session can have
multiple simultaneous stub connections, e.g. multiple machines using the same session name).
Connection IDs are allocated per-session (`AppState::alloc_conn_id`).
- Each connection has one primary attach point (`ConnHandle::attach`) plus any number of
**extra shells** (`ConnHandle::extra_shells`, keyed by `shell_id`), spawned on demand via
`OpReq::SpawnShell``BackendStubMsg::SpawnShell`. The backend tracks pending spawns in
`AppState::spawn_shell_pending` and waits (with a 10s timeout) for the stub's `ShellReady`
before replying to the operator.
- Only one operator can be attached to a given connection/shell at a time; attaching replaces
whatever `AttachSink` was previously stored.
### Auth model (two different mechanisms for two different actors)
- **Stub auth** (`rsh-backend/src/auth.rs::verify_password`): sessions may have an argon2 password
hash. The stub either checks it up front via `GET /check-auth?s=<session>&pw=<pw>` (used by
`rsh auth` to cache a working password locally in `~/.config/rsh.yaml`) and/or presents it in the
`Hello` message on `/ws/stub`.
- **Operator auth** (`rsh-backend/src/ws_op.rs::auth_handshake`): SSH public-key challenge/response.
Client sends `AuthInit{pubkey_openssh}`; backend checks it against `authorized_keys` (file on
disk, reloadable via `rshc keys ...`, plus keys baked in via `RSH_AUTHORIZED_KEYS` env var —
`rsh-backend/src/keys.rs`), sends a random 32-byte `Challenge`, and expects a signature over that
nonce in SSH-signature (`SshSig`) format using namespace `"rsh-auth"`. `rshc` signs with the
private key at `ssh_key_file` from its config (prompting for a passphrase if encrypted).
### Stub self-install / bootstrap (`rsh-backend/src/dist.rs`)
`GET /` or `/run.sh?s=<session>` serves a POSIX shell script (`SCRIPT_TEMPLATE`) that detects
arch, downloads the matching stub binary from `/rsh/:arch`, runs `<stub> auth --url <ws> <session>`
to cache the session password, then launches `<stub> stub --url .../ws/stub --session <session>`
as a backgrounded, disowned process. This is the one-liner meant to be curled onto a target host.
### Stub embedding (why `just backend` needs `just stubs` first)
`rsh-backend/build.rs` embeds two prebuilt `rsh` binaries into the `rsh-backend` binary itself via
`include_bytes!` (see `dist.rs`'s `STUB_X86_64`/`STUB_AARCH64` statics), reading their paths from
the `RSH_STUB_X86_64`/`RSH_STUB_AARCH64` env vars at build time. If those env vars aren't set (or
point to a missing file), the build succeeds anyway but embeds an empty placeholder, and `/rsh/:arch`
serves `503` for that arch instead of the binary — this lets `cargo build -p rsh-backend` work
standalone for iterating on non-stub-serving code, at the cost of a backend that can't actually
bootstrap new targets. The `Dockerfile` does the equivalent two-phase build (musl cross-compile
stages `stub-amd64`/`stub-arm64`, then `cargo chef` for the backend) so the shipped image always
has both stubs embedded.
### `rshc` CLI structure
`crates/rshc/src/cmd/` has one module per subcommand family (`session`, `connection`, `connect`,
`shell`, `keys`, `watch`); `main.rs` just parses `clap` args and dispatches. `AuthedClient`
(`crates/rshc/src/auth.rs`) owns the `/ws/op` connection after the handshake, multiplexing
request/response pairs by an incrementing `id` (oneshot for single responses, `mpsc` for streaming
ones like `Watch`/`Attach`) over one shared writer task. Config lives at `~/.config/rshc.yaml`
(`backend_url`, `ssh_key_file`) — `rshc` writes a stub file and errors out on first run if missing.
### Deployment
`Dockerfile` + `docker-compose.yml` + Helm chart at `deploy/helm/rsh-backend/` (standard
Deployment/Service/Ingress/PVC/Secret/ServiceAccount). Backend runtime config is all env vars
(`rsh-backend/src/config.rs`): `RSH_DATA` (default `/var/lib/rsh`), `RSH_BIND` (default
`0.0.0.0:7777`), `RSH_LOG`, `RSH_AUTHORIZED_KEYS`.

1544
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,3 +32,5 @@ rand = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
bytes = { workspace = true }
russh = "0.62.1"

View File

@@ -4,3 +4,4 @@ pub mod connect;
pub mod keys;
pub mod watch;
pub mod shell;
pub mod serve_ssh;

View File

@@ -0,0 +1,430 @@
use crate::auth::AuthedClient;
use crate::cmd::connection;
use crate::config::Config;
use crate::ui;
use anyhow::{anyhow, Context, Result};
use rsh_types::{AttachIOFrame, OpReq, OpResp};
use russh::server::{Auth, Session};
use russh::ChannelId;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use bytes::Bytes;
use tokio::net::TcpListener;
pub async fn run(
cfg: Config,
client: AuthedClient,
session_name: String,
listen_host: String,
listen_port: u16,
connection_id: Option<u64>,
shell: Option<String>,
) -> Result<()> {
// Validate that we have connections before starting the server.
// Also resolve connection_id if not explicitly provided but only 1 exists.
let conns = connection::fetch(&client, Some(session_name.clone())).await?;
if conns.is_empty() {
return Err(anyhow!("no connections for session '{}'", session_name));
}
let conn_id = match connection_id {
Some(id) => {
conns
.iter()
.find(|c| c.connection_id == id)
.ok_or_else(|| anyhow!("no connection {} in session '{}'", id, session_name))?
.connection_id
}
None => {
if conns.len() == 1 {
conns[0].connection_id
} else {
return Err(anyhow!(
"multiple connections found. please specify --connection <id>"
));
}
}
};
// Load the operator's SSH key once. Its public half is folded into the
// client allowlist (an always-allowed client key). It is NOT used as the
// SSH host key — see load_or_create_host_key.
let operator_key = load_operator_key(&cfg)?;
// Build the set of client public keys allowed to connect: the backend's
// authorized_keys plus the operator's own key. Refuse to start otherwise —
// an empty allowlist would be an open, unauthenticated shell.
let allowed_fps = build_allowed_fingerprints(&client, &operator_key).await?;
if allowed_fps.is_empty() {
return Err(anyhow!(
"no authorized keys available; refusing to start an open SSH server"
));
}
let allowed_fps = Arc::new(allowed_fps);
ui::print_info(&format!("{} authorized key(s) loaded", allowed_fps.len()));
let mut config = russh::server::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(3600)),
auth_rejection_time: std::time::Duration::from_secs(1),
auth_rejection_time_initial: Some(std::time::Duration::from_secs(0)),
// Only public-key auth is accepted; don't advertise password/none.
methods: russh::MethodSet::from(&[russh::MethodKind::PublicKey][..]),
..Default::default()
};
// Dedicated, persistent SSH host key (never the operator's auth key).
let host_key = load_or_create_host_key()?;
config.keys.push(host_key);
let config = Arc::new(config);
let bind_addr = format!("{}:{}", listen_host, listen_port);
let listener = TcpListener::bind(&bind_addr).await.map_err(|e| anyhow!(e))?;
ui::print_info(&format!("serving SSH on {}", bind_addr));
loop {
let (stream, _) = match listener.accept().await {
Ok(res) => res,
Err(_) => continue,
};
let handler = SessionHandler {
cfg: cfg.clone(),
session_name: session_name.clone(),
connection_id: conn_id,
shell: shell.clone(),
allowed_fps: allowed_fps.clone(),
clients: HashMap::new(),
pty_reqs: HashMap::new(),
};
let config_clone = config.clone();
tokio::spawn(async move {
let _ = russh::server::run_stream(config_clone, stream, handler).await;
});
}
}
fn reject() -> Auth {
Auth::Reject {
proceed_with_methods: None,
partial_success: false,
}
}
/// Load and (if needed) decrypt the operator's SSH private key.
fn load_operator_key(cfg: &Config) -> Result<ssh_key::PrivateKey> {
let key_path = cfg.ssh_key_path();
let raw = std::fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;
let mut key = ssh_key::PrivateKey::from_openssh(&raw).context("parse openssh private key")?;
if key.is_encrypted() {
let pw = inquire::Password::new(&format!("Passphrase for {}:", key_path.display()))
.without_confirmation()
.prompt()
.map_err(|e| anyhow!("password prompt: {e}"))?;
key = key.decrypt(pw.as_bytes()).context("decrypt private key")?;
}
Ok(key)
}
/// Load the persistent serve-ssh host key, generating an Ed25519 one on first
/// use. This is a dedicated key, distinct from the operator's auth key, so the
/// listener has a stable identity without cross-context key reuse.
fn load_or_create_host_key() -> Result<russh::keys::PrivateKey> {
let path = Config::serve_ssh_host_key_path();
let key: ssh_key::PrivateKey = if path.exists() {
let raw = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
ssh_key::PrivateKey::from_openssh(&raw).context("parse host key")?
} else {
let k = ssh_key::PrivateKey::random(&mut rand::rngs::OsRng, ssh_key::Algorithm::Ed25519)
.map_err(|e| anyhow!("generate host key: {e}"))?;
let pem = k.to_openssh(ssh_key::LineEnding::LF).context("encode host key")?;
write_private(&path, pem.as_bytes())?;
ui::print_info(&format!("generated serve-ssh host key at {}", path.display()));
k
};
ui::print_info(&format!(
"host key fingerprint {}",
key.fingerprint(ssh_key::HashAlg::Sha256)
));
// Round-trip into russh's ssh-key version (russh re-exports an incompatible
// ssh-key release, so we cannot hand it our value directly).
let pem = key.to_openssh(ssh_key::LineEnding::LF).context("encode host key")?;
russh::keys::PrivateKey::from_openssh(pem.as_bytes()).map_err(|e| anyhow!("parse host key: {e}"))
}
/// Write private-key bytes to `path` with `0600` permissions, creating the
/// parent directory if needed.
fn write_private(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("create {}", path.display()))?;
f.write_all(bytes)
.with_context(|| format!("write {}", path.display()))?;
Ok(())
}
/// SHA-256 fingerprints of every public key allowed to connect: the backend's
/// authorized_keys plus the operator's own key.
async fn build_allowed_fingerprints(
client: &AuthedClient,
operator_key: &ssh_key::PrivateKey,
) -> Result<HashSet<String>> {
let mut set = HashSet::new();
// The operator's own key is always allowed, even if not in authorized_keys.
let op_pub = operator_key
.public_key()
.to_openssh()
.context("encode operator pubkey")?;
if let Ok(pk) = russh::keys::PublicKey::from_openssh(&op_pub) {
set.insert(pk.fingerprint(russh::keys::HashAlg::Sha256).to_string());
}
// The backend's authorized_keys — the same trust set that gates operator auth.
match client.req(OpReq::KeysList).await? {
OpResp::Keys(lines) => {
for line in lines {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Ok(pk) = russh::keys::PublicKey::from_openssh(line) {
set.insert(pk.fingerprint(russh::keys::HashAlg::Sha256).to_string());
}
}
}
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {other:?}")),
}
Ok(set)
}
struct SessionHandler {
cfg: Config,
session_name: String,
connection_id: u64,
shell: Option<String>,
allowed_fps: Arc<HashSet<String>>,
clients: HashMap<ChannelId, Arc<AuthedClient>>,
pty_reqs: HashMap<ChannelId, (u16, u16)>,
}
impl SessionHandler {
async fn spawn_shell(
&mut self,
channel: ChannelId,
session: &mut Session,
shell_cmd: Option<String>,
) -> Result<(), anyhow::Error> {
let pty = self.pty_reqs.contains_key(&channel);
let (cols, rows) = self.pty_reqs.get(&channel).cloned().unwrap_or((80, 24));
let client = AuthedClient::connect(&self.cfg).await?;
let spawned = client
.req(OpReq::SpawnShell {
session: self.session_name.clone(),
connection_id: Some(self.connection_id),
shell: shell_cmd,
pty,
cols,
rows,
})
.await?;
let shell_id = match spawned {
OpResp::ShellSpawned { shell_id, .. } => shell_id,
OpResp::Err(e) => return Err(anyhow!(e)),
other => return Err(anyhow!("unexpected: {:?}", other)),
};
let (attach_id, mut resps) = client
.req_stream(OpReq::Attach {
session: self.session_name.clone(),
connection_id: Some(self.connection_id),
shell_id: Some(shell_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));
}
}
let client = Arc::new(client);
self.clients.insert(channel, client.clone());
// Spawn a task to pump output from rsh-backend to russh
let handle = session.handle();
tokio::spawn(async move {
while let Some(resp) = resps.recv().await {
match resp {
OpResp::Stdout(b) => {
let _ = handle.data(channel, Bytes::from(b)).await;
}
OpResp::Stderr(b) => {
let _ = handle.extended_data(channel, 1, Bytes::from(b)).await;
}
OpResp::Exited { code } => {
let _ = handle.exit_status_request(channel, code.unwrap_or(0) as u32).await;
let _ = handle.close(channel).await;
break;
}
OpResp::Err(_) => {
let _ = handle.close(channel).await;
break;
}
_ => {}
}
}
client.drop_stream(attach_id).await;
});
Ok(())
}
}
impl russh::server::Handler for SessionHandler {
type Error = anyhow::Error;
async fn channel_open_session(
&mut self,
_channel: russh::Channel<russh::server::Msg>,
reply: russh::server::ChannelOpenHandle,
_session: &mut Session,
) -> Result<(), Self::Error> {
reply.accept().await;
Ok(())
}
async fn auth_publickey(
&mut self,
_: &str,
key: &russh::keys::PublicKey,
) -> Result<Auth, Self::Error> {
let fp = key.fingerprint(russh::keys::HashAlg::Sha256).to_string();
if self.allowed_fps.contains(&fp) {
Ok(Auth::Accept)
} else {
Ok(reject())
}
}
async fn auth_password(&mut self, _: &str, _: &str) -> Result<Auth, Self::Error> {
Ok(reject())
}
async fn auth_none(&mut self, _: &str) -> Result<Auth, Self::Error> {
Ok(reject())
}
async fn pty_request(
&mut self,
channel: ChannelId,
_term: &str,
col_width: u32,
row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
_session: &mut Session,
) -> Result<(), Self::Error> {
self.pty_reqs.insert(channel, (col_width as u16, row_height as u16));
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
self.spawn_shell(channel, session, self.shell.clone()).await?;
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
let command = String::from_utf8_lossy(data).to_string();
self.spawn_shell(channel, session, Some(command)).await?;
Ok(())
}
async fn window_change_request(
&mut self,
channel: ChannelId,
col_width: u32,
row_height: u32,
_pix_width: u32,
_pix_height: u32,
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(client) = self.clients.get(&channel) {
let _ = client.send_attach_io(AttachIOFrame::Resize {
cols: col_width as u16,
rows: row_height as u16,
}).await;
}
Ok(())
}
async fn data(
&mut self,
channel: ChannelId,
data: &[u8],
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(client) = self.clients.get(&channel) {
let _ = client.send_attach_io(AttachIOFrame::Stdin(data.to_vec())).await;
}
Ok(())
}
async fn channel_eof(
&mut self,
channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(client) = self.clients.get(&channel) {
let _ = client.send_attach_io(AttachIOFrame::Eof).await;
}
Ok(())
}
async fn channel_close(
&mut self,
channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
if let Some(client) = self.clients.remove(&channel) {
let _ = client.send_attach_io(AttachIOFrame::Kill).await;
}
self.pty_reqs.remove(&channel);
Ok(())
}
}

View File

@@ -43,4 +43,14 @@ impl Config {
pub fn ssh_key_path(&self) -> PathBuf {
PathBuf::from(shellexpand::tilde(&self.ssh_key_file).to_string())
}
/// Path to the dedicated, persistent serve-ssh host key, kept next to the
/// config file (respecting `RSHC_CONFIG_PATH`).
pub fn serve_ssh_host_key_path() -> PathBuf {
Self::path()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default()
.join("serve_ssh_host_ed25519")
}
}

View File

@@ -26,6 +26,7 @@ enum Cmd {
Connect(ConnectArgs),
#[command(alias = "sh")]
Shell(ShellArgs),
ServeSsh(ServeSshArgs),
Keys(KeysCmd),
}
@@ -97,6 +98,20 @@ struct ShellArgs {
no_pty: bool,
}
#[derive(Args, Debug)]
struct ServeSshArgs {
session: String,
#[arg(short = 'H', long, default_value = "127.0.0.1")]
listen_host: String,
#[arg(short = 'p', long, default_value_t = 2222)]
listen_port: u16,
#[arg(long)]
connection: Option<u64>,
#[arg(long)]
shell: Option<String>,
}
#[derive(Args, Debug)]
struct KeysCmd {
#[command(subcommand)]
@@ -163,6 +178,8 @@ async fn run() -> Result<()> {
},
Cmd::Connect(a) => cmd::connect::run(&client, a.session, a.connection_id, a.no_pty).await,
Cmd::Shell(a) => cmd::shell::run(&client, a.session, a.connection, a.shell, a.no_pty).await,
Cmd::ServeSsh(a) => cmd::serve_ssh::run(cfg, client, a.session, a.listen_host, a.listen_port, a.connection, a.shell).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,