forked from mincomk/rsh
Compare commits
4 Commits
4e515ba964
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f5091e826e | |||
|
|
3697600c96 | ||
|
|
de09d628ed | ||
|
|
45554836d8 |
140
CLAUDE.md
Normal file
140
CLAUDE.md
Normal 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
1544
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -32,3 +32,5 @@ rand = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
russh = "0.62.1"
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod connect;
|
||||
pub mod keys;
|
||||
pub mod watch;
|
||||
pub mod shell;
|
||||
pub mod serve_ssh;
|
||||
|
||||
430
crates/rshc/src/cmd/serve_ssh.rs
Normal file
430
crates/rshc/src/cmd/serve_ssh.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user