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

141 lines
8.0 KiB
Markdown

# 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`.