8.0 KiB
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.
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:
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):
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) andBackendStubMsg(backend → stub:Accepted/Rejected,Stdin,Resize,Kill,Ping,SpawnShell/ShellStdin/ShellResize/ShellKill)./ws/op(rsh-backend/src/ws_op.rs) — thershcoperator client connects here. Messages:OpMsg/BackendOpMsgwrapping request/response bodiesOpReq/OpRespand pushedOpEvents (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 tosessions.jsonviarsh-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 byshell_id), spawned on demand viaOpReq::SpawnShell→BackendStubMsg::SpawnShell. The backend tracks pending spawns inAppState::spawn_shell_pendingand waits (with a 10s timeout) for the stub'sShellReadybefore replying to the operator. - Only one operator can be attached to a given connection/shell at a time; attaching replaces
whatever
AttachSinkwas 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 viaGET /check-auth?s=<session>&pw=<pw>(used byrsh authto cache a working password locally in~/.config/rsh.yaml) and/or presents it in theHellomessage on/ws/stub. - Operator auth (
rsh-backend/src/ws_op.rs::auth_handshake): SSH public-key challenge/response. Client sendsAuthInit{pubkey_openssh}; backend checks it againstauthorized_keys(file on disk, reloadable viarshc keys ..., plus keys baked in viaRSH_AUTHORIZED_KEYSenv var —rsh-backend/src/keys.rs), sends a random 32-byteChallenge, and expects a signature over that nonce in SSH-signature (SshSig) format using namespace"rsh-auth".rshcsigns with the private key atssh_key_filefrom 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.