# 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 -- ` — 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>`) 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=&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=` serves a POSIX shell script (`SCRIPT_TEMPLATE`) that detects arch, downloads the matching stub binary from `/rsh/:arch`, runs ` auth --url ` to cache the session password, then launches ` stub --url .../ws/stub --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`.