Files
rsh/CLAUDE.md
2026-07-04 02:32:54 +09:00

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) 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 OpEvents (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::SpawnShellBackendStubMsg::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.