From 97c2d5cf4531acaef02f0c2e417f112f665d0107 Mon Sep 17 00:00:00 2001 From: cabbage Date: Fri, 21 Aug 2026 09:02:57 +0000 Subject: [PATCH] docs: add initial RVBox design and protocol contracts --- buf.yaml | 9 ++ docs/README.md | 18 +++ docs/architecture.md | 183 +++++++++++++++++++++++++++ docs/control-plane.md | 58 +++++++++ docs/platform-and-operations.md | 64 ++++++++++ docs/protocol.md | 99 +++++++++++++++ init-design.md | 195 ++++++++++++++++++++++++++++ protos/rvbox/v1/agent.proto | 147 ++++++++++++++++++++++ protos/rvbox/v1/common.proto | 217 ++++++++++++++++++++++++++++++++ protos/rvbox/v1/control.proto | 148 ++++++++++++++++++++++ 10 files changed, 1138 insertions(+) create mode 100644 buf.yaml create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/control-plane.md create mode 100644 docs/platform-and-operations.md create mode 100644 docs/protocol.md create mode 100644 init-design.md create mode 100644 protos/rvbox/v1/agent.proto create mode 100644 protos/rvbox/v1/common.proto create mode 100644 protos/rvbox/v1/control.proto diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..cad2d92 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,9 @@ +version: v2 +modules: + - path: protos +lint: + use: + - STANDARD +breaking: + use: + - FILE diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3a094ea --- /dev/null +++ b/docs/README.md @@ -0,0 +1,18 @@ +# RVBox v1 design set + +This design set turns the initial proposal into an implementation contract. +Read the documents in this order: + +1. [Architecture](architecture.md) — scope, trust model, state machine, + execution, storage, and retention decisions. +2. [Agent protocol](protocol.md) — WSS framing, sessions, replay, ordering, + script transfer, heartbeat, and failure containment. +3. [Control plane](control-plane.md) — `rvc`, Unix-socket gRPC, JSON-RPC, and + query/foreground semantics. +4. [Platform and operations](platform-and-operations.md) — Unix/Windows + contracts, recovery, storage safety, telemetry, and defaults. + +The wire authority is in [`../protos/rvbox/v1`](../protos/rvbox/v1): +`common.proto` contains shared data types, `agent.proto` contains the +client/server WebSocket envelopes, and `control.proto` defines the local gRPC +service that the HTTP JSON-RPC adapter mirrors. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..18767d6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,183 @@ +# RVBox v1 architecture + +## Purpose and scope + +RVBox is a reverse-connection remote command system. A client daemon (`rvbox`) +maintains a WebSocket connection to `rvbox-server`; the server persists command +state and exposes a local control plane to `rvc` and, optionally, JSON-RPC +callers. This design is the v1 contract for Go implementations and a later Rust +client implementation. + +RVBox deliberately executes arbitrary commands with the identity, permissions, +and base environment of the client daemon. It is therefore an administrative +tool, not a multi-tenant remote-execution service. + +## Non-goals + +- Mutual TLS, client certificates, enrollment tokens, and a client-ID allowlist + are not part of v1. TLS is terminated by the deployment's nginx instance. +- RVBox does not promise a definitive cross-platform “hung” determination. +- V1 does not provide arbitrary file transfer; `--script` transfers only the + temporary script required to execute that request. +- Resource limits are supported only when explicitly requested by an execution + profile; they are not imposed by default. + +## Trust boundary + +The server accepts a self-reported hostname as `client_id`; it is an opaque +1–128 ASCII-character routing/display key. Unknown IDs are accepted. A newer +registration for an ID replaces its prior live session. Consequently, a peer +able to reach nginx can impersonate or take over a client ID. This is an +accepted v1 limitation and deployments must restrict the endpoint to a trusted +network. + +The Unix control socket is local-only and mode `0600`, owned by the server +account. The optional HTTP JSON-RPC endpoint is intentionally unauthenticated; +it defaults to loopback but can be bound elsewhere by configuration. Exposing +it to a network exposes full remote-command authority and is unsafe without an +external access-control layer. + +## Components + +```text +rvc -- gRPC/Unix socket -- rvbox-server -- WSS/nginx -- rvbox client -- shell + | | + SQLite/WAL +-- optional HTTP JSON-RPC (debug/batch) + | + compressed output segment files + audit log +``` + +`rvc` is a thin control-plane client. The server owns durable command history, +dispatch, queueing, pagination, audit records, and session fencing. A client +owns active process supervision, unacknowledged output spooling, and safe +reconnection. Neither daemon lets a slow peer, command, or output stream block +its dispatch loops. + +## Identity, sessions, and lifecycle + +1. The client connects over WSS and sends `ClientHello` with its client ID, + protocol capability, OS/architecture, daemon version, current daemon CWD, + supported shells, and a fresh reconnect UUID. +2. The server accepts the current compatible protocol version, fences the + previous connection for that ID, and returns a fresh server-issued + `session_id` and monotonic `session_generation`. +3. Every client-to-server envelope and server dispatch is bound to that token. + The server discards traffic from superseded sessions, including late output. +4. The server queues work while a client is offline and dispatches it only when + the active session advertises capacity. A replacement session immediately + resumes non-terminal reconciliation. + +## Command model + +Each user request has a server-generated UUID (`issue_uuid`) and a durable +request record: target client, request/issue timestamps, shell type, command +text or script descriptor, CWD, environment overrides, resource-profile flags, +and lifecycle state. The UUID is the end-to-end idempotency key. Transport is +at-least-once, but the client durably remembers accepted UUIDs and never starts +the same request twice. + +The server states are: + +```text +queued -> dispatched -> accepted -> running -> succeeded | failed | terminated + \-------------------------------> cancelled +``` + +`accepted` means the client has durably accepted the request; `running` means +the process has been launched. `cancelled` is used when it is stopped before +launch. A kill racing launch is resolved by command revision: the client either +acknowledges cancellation before launch or launches then immediately applies +the requested signal, recording the race. + +The client permits 16 concurrent processes and 100 pending commands by default. +Those values are configurable and advertised to the server. A full queue causes +a structured capacity error rather than creating unbounded work. + +### Execution contract + +- Shell selection is explicit: Unix-like clients support `sh` and `bash`; + Windows supports `cmd` and `powershell`. Defaults are `sh` and `powershell`. + Unsupported shells are rejected; no fallback occurs. +- A command receives the daemon account's permissions and startup environment, + overlaid with the persisted `env_overrides` map. The effective CWD is the + requested existing directory or the registered daemon CWD when omitted. +- Each command is isolated into a process tree: a Unix session/process group or + a Windows Job Object. A daemon that cannot supervise its children terminates + them and reports interruption rather than claiming recovery it cannot make. +- Resource profiles are composable flags (`LIGHT`, `CPU_MEDIUM`, `CPU_HEAVY`, + `MEM_MEDIUM`, `MEM_HEAVY`, `DISK_MEDIUM`, `DISK_HEAVY`). Profiles are opt-in; + the concrete administrator-configured limits are applied with cgroup v2 when + available on Linux and Job Object limits on Windows. Unsupported requested + controls are reported, never silently ignored. + +### Script execution + +Scripts are content-addressed uploads, not shell-escaped command strings. The +server sends a descriptor containing SHA-256 and then ordered chunks (default +maximum: 10 MiB). The client verifies the digest, writes an owner-only temporary +file beneath the effective CWD, executes it with the selected shell, and removes +it after the command reaches a terminal state. Script content is not copied into +the audit log; its digest and metadata are. + +## Process control and diagnostics + +On Unix, `kill` addresses the command's process group and accepts normal signal +names/numbers supported by that client. On Windows, only `SIGTERM` and `SIGKILL` +are valid. `SIGTERM` makes a best-effort `CTRL_BREAK_EVENT` delivery to the +dedicated console group, waits 10 seconds, then terminates the Job Object if +needed. `SIGKILL` immediately terminates the Job Object. The response reports +the actual escalation outcome. + +Lifecycle state never asserts `hung`. A separate `suspected_hung` diagnostic is +emitted after the configurable default of 10 minutes without observable +progress. Linux enriches this with `/proc` state, CPU, memory, and I/O data; +Windows uses process and Job Object APIs where available. It is explicitly a +heuristic, not proof of an I/O stall. + +## Durability, ordering, and retention + +The server uses SQLite in WAL mode for metadata/indexes and append-only Zstandard +compressed segment files for output. It recovers non-terminal commands after a +restart and reconciles only these; server-confirmed historical terminal commands +are not re-reconciled. The client stores only active command state and output +not acknowledged by the server. + +Every execution-originated event has a strictly increasing `event_seq` scoped to +one command. This includes lifecycle transitions, stdout/stderr chunks, stdin +acknowledgments, resource snapshots, signals, and terminal events. The server +preserves the sequence and additionally records receipt time. This is the +canonical reconstruction order across interleaved streams and retries. +Truncation is separate range metadata so it can truthfully describe missing +event sequences without consuming one itself. + +Output chunks are Zstandard-compressed before persistent quota accounting. +Per-command history is a rolling compressed window (10 MiB default), so the +oldest output segments for that command are removed first and a sequence-range +truncation marker remains. This applies to active and terminal commands. When +connected, the client first removes server-acknowledged segments. While offline, +it must still honor both hard caps: it retains the newest tail, removes oldest +unacknowledged compressed chunks when necessary, and records their exact missing +ranges for durable reporting on reconnect. + +Each client also has a 50 MiB aggregate compressed spool cap for active, +unacknowledged work. The server's matching per-client compressed-history cap is +50 MiB; it evicts that client's oldest terminal command records as needed. The +server-wide cap is 1 GiB; it evicts whole oldest terminal command records +(metadata and output), never arbitrary stdout/stderr rows. Active commands are +protected. If active commands alone consume a per-client budget, their oldest +acknowledged output rotates by the per-command rule; pipes continue draining so +a child cannot deadlock on output. + +## Audit and timestamps + +The server writes a separate durable audit trail for control actions and session +events: source/transport identity where available, target client, UUID, action, +request time, result, and error. It records command text, environment-override +names (not values), and script metadata/digest, but not duplicated +stdin/stdout/stderr payloads. The persisted execution request necessarily keeps +override values for dispatch/retry and must be access-controlled as sensitive +data. Audit retention is configured independently of output retention. + +All protocol timestamps are UTC `google.protobuf.Timestamp` values. Client +observed timestamps and server receipt timestamps are distinct; the latter is +authoritative for server records, while `event_seq` is authoritative for order. diff --git a/docs/control-plane.md b/docs/control-plane.md new file mode 100644 index 0000000..74fcc82 --- /dev/null +++ b/docs/control-plane.md @@ -0,0 +1,58 @@ +# RVBox v1 control plane + +[`../protos/rvbox/v1/control.proto`](../protos/rvbox/v1/control.proto) defines +the canonical control API. `rvc` uses the `Control` gRPC service over the local +Unix-domain socket. The server also exposes the same unary operations through +an optional JSON-RPC 2.0 HTTP adapter for local debugging and batch automation. + +## Endpoints + +- Unix socket: enabled by default, mode `0600`, owned by the server account. +- HTTP JSON-RPC: disabled unless enabled; default bind `127.0.0.1:6900`. + It has no authentication by design. Binding it beyond loopback is an explicit + deployment choice and requires external protection. + +gRPC can stream `RunCommandAndFollow` and `FollowCommand`. JSON-RPC remains +simple: callers issue work, query command state, poll event/output pages after +an event sequence, append stdin, close stdin, or signal a command. It does not +invent a separate event-stream protocol. + +The JSON-RPC method names are the lower-camel protobuf operation names: +`listClients`, `getClient`, `listCommands`, `getCommand`, `runCommand`, +`appendStdin`, `closeStdin`, `signalCommand`, and `getOutput`. Parameters and +results use protobuf JSON mapping (including base64 strings for `bytes` and UTC +RFC 3339 strings for timestamps); JSON-RPC errors carry the corresponding +`ControlError` code/data. `getOutput` and `getCommand` are the polling path for +what gRPC exposes as follow streams. + +## CLI semantics + +`rvc stat` maps to `ListClients`, `GetClient`, `ListCommands`, and `GetCommand`. +History pages default to 20 commands and may request at most 100. Output pages +default to 100 lines; a line is a display operation over ordered chunks, not a +protocol boundary. Output can be filtered by stream and timestamped with the +server's recorded client-observed timestamp plus stream name. + +`rvc run` creates a command. Foreground mode runs `RunCommandAndFollow`, which +streams output and stops on a terminal event. `--background` uses `RunCommand` +and returns the UUID immediately. Interrupting the CLI, timing out its local +wait, or losing the local control connection never cancels remote work. The +explicit `rvc kill` operation is the only termination path. + +`rvc append` turns a string into `StdinWrite` with `append_newline=true` unless +the caller selects raw mode; `--file` supplies raw bytes; `--attach` streams +local standard input. `CloseStdin` is available separately. All stdin actions +are acknowledged and idempotent. + +## Query behavior + +Command details expose persisted request fields, effective execution metadata, +lifecycle, terminal result, output availability/truncation, current resource +snapshot, and `suspected_hung` diagnostics. The latter is informational and +must not be shown as a terminal state. Pagination response cursors are stable +within their declared ordering (newest issue time for command lists; increasing +`event_seq` for events/output). + +The service returns `ControlError` codes for not found, offline, capacity, +invalid request, unsupported platform feature, conflict, truncation, and +internal/transient errors. It never encodes errors only as CLI text. diff --git a/docs/platform-and-operations.md b/docs/platform-and-operations.md new file mode 100644 index 0000000..0f2011d --- /dev/null +++ b/docs/platform-and-operations.md @@ -0,0 +1,64 @@ +# RVBox v1 platform and operations contract + +## Unix-like clients + +The client starts `sh` or `bash` in a new session/process group. Unix signals +address that group, so normally created descendants receive the signal too. On +orderly shutdown, or recovery after an unclean daemon failure, managed command +groups are terminated and marked interrupted because pipe capture cannot be +safely resumed. + +Linux diagnostics sample `/proc/` and relevant children for state, CPU, +resident memory, I/O counters, CWD, and wait-channel information when readable. +These values may be unavailable due to permissions, kernel configuration, or a +short-lived process; absence is represented explicitly rather than fabricated. +Cgroup v2 is used for requested resource profiles only when available. + +## Windows clients + +The client launches `cmd` or `powershell` in an appropriate dedicated console +process group and assigns the root process to a per-command Job Object. Child +processes normally join the Job Object. Job Object limits enforce requested +profiles and `KILL_ON_JOB_CLOSE` protects against lost supervision. + +Only `SIGTERM` and `SIGKILL` are accepted. `SIGTERM` attempts `CTRL_BREAK_EVENT` +and waits 10 seconds, then calls Job Object termination if the job persists; +`SIGKILL` calls Job Object termination immediately. A console signal is +best-effort, so callers receive an explicit escalation result. Windows status +uses process and Job Object accounting APIs; it does not claim Linux-only +diagnostics such as an I/O wait channel. + +## Storage and recovery + +SQLite runs in WAL mode with integrity checking on startup. Output segments are +written atomically, fsynced according to the configured durability interval, and +indexed only after successful durable append. Startup scans/repairs incomplete +tail records before accepting control requests. Segment compression is Zstandard; +limits always measure stored compressed bytes, while clients expose raw byte +counts separately. + +The system must reserve headroom before writes and use transactional metadata +updates. Storage-full, permission, and corruption failures are surfaced as +structured server/client health states and audit events. They must isolate the +affected command/session, reject work when needed, and keep the daemon's +heartbeat/control loops alive. + +## Metrics, logging, and safe defaults + +Both daemons should emit structured logs and metrics for session transitions, +heartbeat timeout, reconnect backoff, command state transitions, queue depth, +spool bytes, segment rotation/eviction, output loss markers, storage errors, +and protocol violations. Never emit stdin or raw output in normal daemon logs. + +Recommended configuration defaults are: 10-second heartbeat idle period, +30-second liveness timeout, 1–60-second full-jitter reconnect backoff, +60-second stable-session reset, 16 running/100 queued commands per client, +10 MiB per-command compressed window, 50 MiB per-client active spool and server +history, 1 GiB server history, 64 KiB uncompressed stream chunk, 1 MiB decoded +envelope, and 10 MiB script maximum. + +These bounds protect RVBox's own loops; they cannot make arbitrary child +commands harmless when no resource profile is requested. Operators should +enable resource profiles for untrusted or expensive workloads and keep nginx, +Unix-socket permissions, filesystem capacity, and service supervision correctly +configured. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..09a3a3a --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,99 @@ +# RVBox v1 agent protocol + +The authoritative schemas are [`../protos/rvbox/v1/common.proto`](../protos/rvbox/v1/common.proto) +and [`../protos/rvbox/v1/agent.proto`](../protos/rvbox/v1/agent.proto). This +document specifies their use over WSS. + +## Transport and compatibility + +The nginx-terminated `wss://` connection carries exactly one serialized +`rvbox.v1.AgentEnvelope` in each binary WebSocket message. There is no extra +length prefix. A decoded envelope may not exceed 1 MiB. A chunk's uncompressed +payload may not exceed 64 KiB. Both peers validate declared and actual expanded +sizes before allocation/decompression. + +The protobuf package is `rvbox.v1`. Registration negotiates a major/minor +protocol range: incompatible majors are rejected; the highest shared minor is +chosen. New fields are append-only. A peer ignores unknown optional fields but +must respond with `PROTOCOL_ERROR` to an unknown required envelope feature. + +## Heartbeat and reconnect + +Both endpoints use the same algorithm. Any received valid WebSocket frame is +inbound activity. After 10 seconds without inbound activity, send a WebSocket +Ping. After 30 seconds without inbound activity, close the session and treat it +as dead. Pong processing is normal WebSocket behavior; it is not an application +message and never queues behind command traffic. + +The client reconnects with full-jitter exponential backoff (initial 1 second, +cap 60 seconds). A session stable for 60 seconds resets the backoff. The server +does not reconnect; it waits for clients. A reconnect always registers again, +receives a new fencing generation, resends unacknowledged delivery/output, and +reconciles only commands the server still considers non-terminal. + +## Session fencing + +`ClientHello` starts registration. `ServerWelcome` gives the selected version, +random `session_id`, and `session_generation`. Except `ClientHello`, all +envelopes carry those values. A new accepted registration fences and disconnects +the prior one for the same client ID. The server accepts messages only from the +current generation; command dispatches also identify their intended generation. + +## Reliable work flows + +### Dispatch and reconciliation + +The server persistently creates a command before dispatching `CommandDispatch`. +It redelivers until it receives `CommandAccepted`. Clients durably deduplicate +on `issue_uuid`. A client whose queue is full sends a capacity rejection. + +Following registration the server sends `ReconcileRequest` only for its +non-terminal commands for that client. The client replies with a +`ReconcileSnapshot` per requested known command, or `unknown_to_client`. It +continues normal event retransmission from the server's last acknowledged event +sequence. Terminal history already confirmed by the server is deliberately +excluded. + +### Output and events + +Client execution events use an increasing `event_seq`; retries reuse the same +sequence and content. The server durably writes an event before sending +`EventAck`. `EventAck` is cumulative through a sequence number. Output is +Zstandard compressed with an explicit original-size field. Server storage can +reuse the validated compressed bytes. + +When rolling output removes old retained segments, the server writes +`OutputTruncation` metadata containing the removed event/byte ranges. Queries +must show that marker rather than silently presenting an apparently complete +stream. An offline client that reaches a cap sends `ClientOutputTruncated` +before replaying its retained tail on reconnect. The server records it, permits +the named event-sequence gap, and exposes it in output queries. Truncation +metadata is not an execution event and does not consume an `event_seq`. + +### Stdin and signals + +`StdinWrite` is binary-safe and ordered by `write_seq`; the client durably +deduplicates it and returns `StdinAck`. `append_newline` is true by default in +the CLI but explicit on the wire. `CloseStdin` is a separate idempotent action. +Signal and cancellation requests carry a command revision to settle start/kill +races. + +### Scripts + +For a script command, dispatch first contains a `ScriptDescriptor`; then the +server sends `ScriptChunk` messages and a commit. The client checks offset, +chunk order, full length, and SHA-256 before it reports upload complete or +launches the process. A retransmitted chunk is idempotent by offset/content. + +## Flow control and failure containment + +No receive loop runs an executor, database write, decompressor, or slow socket +operation inline. Each side has bounded staging queues. Durable spools are the +source of truth and are charged to the 10 MiB/50 MiB/1 GiB compressed retention +budgets described in the architecture document. A full staging queue pauses the +related read/dispatch path and drains from disk; it never grows without bound. + +Malformed protobuf, over-size payload, invalid compressed data, impossible +sequence, bad session token, or protocol-version violation yields a structured +error where safe and closes that WebSocket session. It does not crash either +daemon. Network loss is normal and is handled through idempotent resend. diff --git a/init-design.md b/init-design.md new file mode 100644 index 0000000..5508584 --- /dev/null +++ b/init-design.md @@ -0,0 +1,195 @@ +# RVBox - A Reverse Shell Solution for LLM Agent + +* It's a C/S architecture, the clients are the one connects to the server, and are controlled by it. +* The clients runs the `rvbox` daemon, actively and persistently connects to a configured server. It registers and identifies itself to the server first, if accepted, then keeps a long connection and accepting commands from the server. +* The server runs the `rvbox-server` daemon, listening on a `wss://` endpoint, waiting for incoming `rvbox` clients. An LLM agent on the server machine then can use `rvc` command to communicate with the server daemon, issuing commands to do all sorts of things: + - listing clients, + - issuing/terminating commands on specific client, + - retrieving running/stopping status of a command, + - getting stdout/stderr, or return code from a command, + - inputing into stdin of a command, + +* commands can be issued in two modes: foreground or background, for foreground ones, `rvc` command wait for the return, otherwise, `rvc` returns immediately with metadata of the issued command process on the client. +* for each issued command from `rvc`, the server daemon attaches an `issue_uuid` to it, and sends it to the related client, along with these fields: `issue_timestamp`, `shell_type`, `shell_command_text`. The server also records all these fields into its data store, with another `target_client_id`, to track the full states of every command. +* The client's daemon runs the issued commands, and reports stdout/stderr of all issued commands to the server in real-time, meaning no waiting for new-line flush. Also, multiple commands can be issued to a client daemon, and the client daemon can execute them concurrently. For each running command, the daemon appends new stdout and stderr updates to the server in every short 3-5s window, or only reports the command's status in a longer 10-15s window if no new updates. The server keeps on track of everything, so it can reconstruct the full context/history correctly for each command, when demanded so by `rvc` . +* The connection should be guarded by a properly designed heartbeat mechanism on both C/S sides. +* `rvc` is just a cli command that communicates with the server daemon through local unixsock. Also, the same server daemon control interface can be accessed through an optional simple HTTP json rpc endpoint, e.g. `--json-rpc http://127.0.0.1:6900` by default, so that we can interact with the server in a more structured way when complex or large batch operations are needed. + +### Example usages of `rvc` on server side + +1. Listing clients + + ```bash + $ rvc stat + CLIENT Commands Running Connected Time + client_a 5 10 days + client_b 0 10 min + ... + ``` + +2. Stating a client + + ```bash + $ rvc stat client_a + Command ID Command Snippet Status Issue Time + "cp /path1/file /path2/" Running 10s ago + "python very_long_computing.py" Running 2 days ago + "sha256sum /path/very_large_file" Hung 10min ago + ``` + +3. Stating all commands of a client. Note this `--all` can return very long history, so it has a optional pager `--page ` arg. By default the optional `--per-page` is at 20, and accepts 100 at max. + + ```bash + $ rvc stat client_a --all + Page 1/34 + Command ID Command Snippet Status Issue Time + "cp /path1/file /path2/" Running 10s ago + "python very_long_computing.py" Running 2 days ago + "sha256sum /path/very_large_file" Hung(I/O) 10min ago + "curl -fsLO https://..." Done 15min ago + "culr -fsLO https://..." Failed(127) 15min ago + "curl -fsLO https://..." Terminated(130) 15min ago + ... + ``` + +4. Stating some commands of client_a + + ```bash + $ rvc stat client_a + Command Text: "cp /path1/file /path2/" + Status: Running + Issue Time: 20xx-01-01T10:01:01Z + CWD: "/..." + CPU TIME: 0:00.23 + RES RAM: 12K + ``` + + ```bash + $ rvc stat client_a + Command Text: "python very_long_computing.py" + Status: Running + Issue Time: 20xx-12-30T12:01:01Z + CWD: "/..." + CPU TIME: 12h34:45 + RES RAM: 892M + ``` + + ```bash + $ rvc stat client_a + Command Text: "sha256sum /path/very_large_file" + Status: Hung + Reason: I/O + Issue Time: 20xx-01-01T10:01:01Z + CWD: "/..." + CPU TIME: 0:10.24 + RES RAM: 125K + ``` + + ```bash + $ rvc stat client_a + Command Text: "curl -fsLO https://" + Status: Success + Issue Time: 20xx-01-01T10:01:01Z + CWD: "/..." + CPU TIME: 0:10.24 + RES RAM: 125K + ``` + + ```bash + $ rvc stat client_a + Command Text: "culr -fsLO https://" + Status: Failure + Exit Code: 127 + Issue Time: 20xx-01-01T10:01:01Z + Return Time: 20xx-01-01T10:01:01Z + CWD: "/..." + ``` + + ```bash + $ rvc stat client_a + Command Text: "curl -fsLO https://" + Status: Terminated + Exit Code: 130 + Issue Time: 20xx-01-01T10:01:01Z + Return Time: 20xx-01-01T10:02:01Z + CWD: "/..." + ``` + +5. Issue a new command to a client. + + - By design, the commands triggered/executed by the client daemon should inherit its user/permission settings. + + - By default, the optional `--current-work-dir/--cwd` of the issued command is inherited from the client daemon. + - If `--script` is used to issue a whole script, the script will first be put into the `--cwd` dir, then executed by the client daemon. As soon as it's exited, the script will be reclaimed. + + ```bash + # wait until return by default + $ rvc run client_a --cwd '/home/user' 'ls' + Desktop Documents Downloads Pictures... + # run in background, return the command id immediately + $ rvc run --background client_a 'python -c "for i in range(15): print(\'hi\'); import time; time.sleep(1)"' + Command ID: + # if the command is too long or the symbol escapings are too complicated, write a whole script then issue. + $ cat << 'EOF' > ./py_script + heredoc> #!/usr/bin/env python + heredoc> print("what's your name?") + heredoc> name = input("type your name here: ") + heredoc> print(f"Hello, {name}!") + heredoc> EOF + $ rvc run client_a --script ./py_script --cwd /tmp --background + Command ID: + ``` + +6. Get stdout and/or stderr of a command, + + - optionally with `--timestamped` to get a leading timestamp on each line, + - and optionally follow new updates with `--follow/-f`, just like the `tail -f` command + - the output of `--stdout` and `--stderr` are paged, with `--lines-per-page` defaults to 100 and `--page` defaults to 1. + + ```bash + $ rvc stat client_a --stdout --stderr --timestamped --page 2 --lines-per-page 3 + Page 2/4 + [20xx-01-01T10:21:01Z stdout] hi + [20xx-01-01T10:21:02Z stdout] hi + [20xx-01-01T10:21:03Z stdout] hi + ``` + +7. Write new content into stdin of a command + + ```bash + # write the string into its stdin, and a \n will be appended to the end automatically + $ rvc append client_a 'Alice' + $ rvc stat client_a --stdout --stderr --timestamped + [20xx-01-01T10:23:11Z stdout] type your name here: + [20xx-01-01T10:24:09Z stdout] Hello, Alice! + # or write a file content into its stdin + $ rvc append client_a --file ./name_file + # or patch the current stdin into it + $ rvc append client_a --attach + Bob + ``` + +8. Send a signal to a command. + + - For unix/linux client daemons, this `kill` subcommand behaves just like the normal `kill` command. It sends SIGTERM by default, and can be used to send other signals with args like `-1/-HUP`, `-2/-INT`, etc. + - On windows client daemons, it should only accept SIGTERM and SIGKILL, equivalent to `taskkill /IM` and `taskkill /F /IM`, respectively. + + ```bash + $ rvc kill client_a + ``` + + + +### Implementation requirements + +1. We should first do proper design on the protocol - both the C/S interface and the server daemon unixsock/jsonrpc control interface. protobuf should be a solid choice to do the protocol design. +2. Currently we'll implement both C/S daemon in Golang. We'll later implement a Rust version client to adapt on low power devices. + + +### Technical nuances + +1. The `rvbox-server` daemon and the `rvbox` client daemon should always be responsive. The server daemon should always be dispatching clients, recording the status, and listening for requests from `rvc`, etc. And the client daemons should keep on receiving/dispatching incoming commands/requests, monitoring ongoing commands, reporting new updates/status to the server. In ABSOLUTELY NO circumstances should the daemons themselves be crashed, blocked, hung, flooded, unresponsive, etc., due to any possible reason. + +2. The heartbeat mechanism that keeps and guards the websocket connection should be robust, clean and correct. When connection exceptions/interruptions/hangs happen, the heartbeat mechanism should detect them in time, and trigger the reconnect, re-register route correctly. It should guard on both the inbound and the outbound traffic. It should not disrupt, hog, block, interrupt, mutate the normal traffic in any way. + + diff --git a/protos/rvbox/v1/agent.proto b/protos/rvbox/v1/agent.proto new file mode 100644 index 0000000..6868513 --- /dev/null +++ b/protos/rvbox/v1/agent.proto @@ -0,0 +1,147 @@ +syntax = "proto3"; + +package rvbox.v1; + +option go_package = "github.com/rvbox/rvbox/gen/go/rvbox/v1;rvboxv1"; + +import "google/protobuf/timestamp.proto"; +import "rvbox/v1/common.proto"; + +// One AgentEnvelope is carried in one binary WebSocket message. +message AgentEnvelope { + // Empty only for ClientHello. All other envelopes are fenced to a session. + string session_id = 1; + uint64 session_generation = 2; + string message_id = 3; + + oneof payload { + ClientHello client_hello = 10; + ServerWelcome server_welcome = 11; + CommandDispatch command_dispatch = 12; + CommandAccepted command_accepted = 13; + CommandEvent command_event = 14; + EventAck event_ack = 15; + StdinWrite stdin_write = 16; + CloseStdin close_stdin = 17; + SignalCommand signal_command = 18; + ScriptChunk script_chunk = 19; + ScriptCommit script_commit = 20; + ClientCapacity client_capacity = 21; + ReconcileRequest reconcile_request = 22; + ReconcileSnapshot reconcile_snapshot = 23; + AgentError error = 24; + ClientOutputTruncated client_output_truncated = 25; + } +} + +message ClientHello { + // Configured hostname; opaque 1-128 ASCII characters. + string client_id = 1; + ProtocolRange supported_protocol = 2; + string daemon_version = 3; + Platform platform = 4; + string architecture = 5; + string daemon_cwd = 6; + repeated ShellType supported_shells = 7; + string reconnect_uuid = 8; + uint32 max_running_commands = 9; + uint32 max_queued_commands = 10; + google.protobuf.Timestamp sent_at = 11; +} + +message ServerWelcome { + ProtocolVersion selected_protocol = 1; + // AgentEnvelope.session_id and .session_generation are canonical. + google.protobuf.Timestamp server_time = 2; +} + +message CommandDispatch { + string issue_uuid = 1; + uint64 command_revision = 2; + uint64 target_session_generation = 3; + google.protobuf.Timestamp issue_time = 4; + ExecutionSpec spec = 5; +} + +message CommandAccepted { + string issue_uuid = 1; + uint64 command_revision = 2; + bool accepted = 3; + ControlError rejection = 4; +} + +// Cumulative durable acknowledgement of client CommandEvent values. +message EventAck { + string issue_uuid = 1; + uint64 through_event_seq = 2; +} + +message StdinWrite { + string issue_uuid = 1; + uint64 write_seq = 2; + bytes data = 3; + bool append_newline = 4; +} + +message CloseStdin { + string issue_uuid = 1; + uint64 write_seq = 2; +} + +message SignalCommand { + string issue_uuid = 1; + uint64 command_revision = 2; + SignalKind signal = 3; +} + +message ScriptChunk { + string issue_uuid = 1; + uint64 offset = 2; + bytes data = 3; + bytes sha256 = 4; +} + +message ScriptCommit { + string issue_uuid = 1; + uint64 size_bytes = 2; + bytes sha256 = 3; +} + +message ClientCapacity { + uint32 running_commands = 1; + uint32 queued_commands = 2; + uint32 max_running_commands = 3; + uint32 max_queued_commands = 4; +} + +message ReconcileRequest { + repeated ReconcileTarget targets = 1; +} + +message ReconcileTarget { + string issue_uuid = 1; + uint64 last_server_event_seq = 2; + uint64 command_revision = 3; +} + +// Sent only for requests in ReconcileRequest; server-confirmed terminal history +// is intentionally not requested. +message ReconcileSnapshot { + string issue_uuid = 1; + bool known_to_client = 2; + CommandLifecycle lifecycle = 3; + uint64 last_client_event_seq = 4; + uint64 command_revision = 5; +} + +// Sent before retained replay data when an offline client had to rotate +// unacknowledged output to remain within its hard spool limits. +message ClientOutputTruncated { + string issue_uuid = 1; + OutputTruncation truncation = 2; +} + +message AgentError { + ControlError error = 1; + bool close_session = 2; +} diff --git a/protos/rvbox/v1/common.proto b/protos/rvbox/v1/common.proto new file mode 100644 index 0000000..bbbc3d8 --- /dev/null +++ b/protos/rvbox/v1/common.proto @@ -0,0 +1,217 @@ +syntax = "proto3"; + +package rvbox.v1; + +option go_package = "github.com/rvbox/rvbox/gen/go/rvbox/v1;rvboxv1"; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +// An inclusive protocol-version range advertised during registration. +message ProtocolRange { + uint32 major = 1; + uint32 min_minor = 2; + uint32 max_minor = 3; +} + +message ProtocolVersion { + uint32 major = 1; + uint32 minor = 2; +} + +enum Platform { + PLATFORM_UNSPECIFIED = 0; + PLATFORM_LINUX = 1; + PLATFORM_DARWIN = 2; + PLATFORM_WINDOWS = 3; + PLATFORM_OTHER_UNIX = 4; +} + +enum ShellType { + SHELL_TYPE_UNSPECIFIED = 0; + SHELL_SH = 1; + SHELL_BASH = 2; + SHELL_CMD = 3; + SHELL_POWERSHELL = 4; +} + +enum Compression { + COMPRESSION_UNSPECIFIED = 0; + COMPRESSION_NONE = 1; + COMPRESSION_ZSTD = 2; +} + +enum CommandLifecycle { + COMMAND_LIFECYCLE_UNSPECIFIED = 0; + COMMAND_QUEUED = 1; + COMMAND_DISPATCHED = 2; + COMMAND_ACCEPTED = 3; + COMMAND_RUNNING = 4; + COMMAND_SUCCEEDED = 5; + COMMAND_FAILED = 6; + COMMAND_TERMINATED = 7; + COMMAND_CANCELLED = 8; + COMMAND_INTERRUPTED = 9; +} + +enum StreamKind { + STREAM_KIND_UNSPECIFIED = 0; + STREAM_STDOUT = 1; + STREAM_STDERR = 2; +} + +enum SignalKind { + SIGNAL_KIND_UNSPECIFIED = 0; + SIGNAL_HUP = 1; + SIGNAL_INT = 2; + SIGNAL_TERM = 3; + SIGNAL_KILL = 4; + SIGNAL_USR1 = 5; + SIGNAL_USR2 = 6; +} + +// These flags are composable. Their numeric limits are local administrator +// policy rather than part of the interoperable protocol. +enum ExecutionProfile { + EXECUTION_PROFILE_UNSPECIFIED = 0; + EXECUTION_PROFILE_LIGHT = 1; + EXECUTION_PROFILE_CPU_MEDIUM = 2; + EXECUTION_PROFILE_CPU_HEAVY = 3; + EXECUTION_PROFILE_MEM_MEDIUM = 4; + EXECUTION_PROFILE_MEM_HEAVY = 5; + EXECUTION_PROFILE_DISK_MEDIUM = 6; + EXECUTION_PROFILE_DISK_HEAVY = 7; +} + +message ScriptDescriptor { + string filename = 1; + uint64 size_bytes = 2; + bytes sha256 = 3; +} + +// Execution settings sent to the client. A script's bytes travel separately. +message ExecutionSpec { + ShellType shell_type = 1; + string cwd = 2; + map env_overrides = 3; + repeated ExecutionProfile execution_profiles = 4; + oneof source { + string command_text = 5; + ScriptDescriptor script = 6; + } +} + +message CommandRecord { + string issue_uuid = 1; + string target_client_id = 2; + google.protobuf.Timestamp issue_time = 3; + google.protobuf.Timestamp server_receipt_time = 4; + ExecutionSpec spec = 5; + CommandLifecycle lifecycle = 6; + uint64 last_event_seq = 7; + int32 exit_code = 8; + google.protobuf.Timestamp terminal_time = 9; + bool output_truncated = 10; + uint64 retained_compressed_bytes = 11; +} + +message LifecycleChange { + CommandLifecycle lifecycle = 1; + int32 exit_code = 2; + string detail = 3; +} + +// data is compressed according to compression. uncompressed_size is mandatory +// when compression is ZSTD and must be validated before decompression. +message OutputChunk { + StreamKind stream = 1; + Compression compression = 2; + bytes data = 3; + uint64 uncompressed_size = 4; + uint64 compressed_size = 5; +} + +message ResourceSnapshot { + uint64 resident_memory_bytes = 1; + uint64 virtual_memory_bytes = 2; + google.protobuf.Duration cpu_time = 3; + uint64 read_bytes = 4; + uint64 write_bytes = 5; + string process_state = 6; + string wait_reason = 7; + bool suspected_hung = 8; + string diagnostic_detail = 9; +} + +enum OutputTruncationSource { + OUTPUT_TRUNCATION_SOURCE_UNSPECIFIED = 0; + OUTPUT_TRUNCATION_SOURCE_CLIENT_SPOOL = 1; + OUTPUT_TRUNCATION_SOURCE_SERVER_COMMAND_WINDOW = 2; + OUTPUT_TRUNCATION_SOURCE_SERVER_CLIENT_CAP = 3; +} + +// Persistent query metadata for a missing contiguous event range. It is not a +// CommandEvent and therefore does not consume an event_seq. +message OutputTruncation { + uint64 first_removed_event_seq = 1; + uint64 last_removed_event_seq = 2; + uint64 removed_compressed_bytes = 3; + uint64 removed_uncompressed_bytes = 4; + string reason = 5; + OutputTruncationSource source = 6; +} + +message StdinAcknowledgement { + uint64 write_seq = 1; + bool stdin_closed = 2; + string detail = 3; +} + +message SignalResult { + SignalKind signal = 1; + bool accepted = 2; + bool graceful_delivery_attempted = 3; + bool forced_termination_used = 4; + string detail = 5; +} + +message ScriptUploadStatus { + uint64 received_bytes = 1; + bool complete = 2; + string detail = 3; +} + +// Client-originated ordered history. event_seq is strictly increasing per +// issue_uuid and is reused exactly on retransmission. +message CommandEvent { + string issue_uuid = 1; + uint64 event_seq = 2; + google.protobuf.Timestamp observed_at = 3; + oneof payload { + LifecycleChange lifecycle = 10; + OutputChunk output = 11; + ResourceSnapshot resource = 12; + StdinAcknowledgement stdin_ack = 13; + SignalResult signal_result = 14; + ScriptUploadStatus script_status = 15; + } +} + +message ControlError { + enum Code { + CODE_UNSPECIFIED = 0; + INVALID_ARGUMENT = 1; + NOT_FOUND = 2; + OFFLINE = 3; + CAPACITY_EXHAUSTED = 4; + CONFLICT = 5; + UNSUPPORTED = 6; + PROTOCOL_ERROR = 7; + TRANSIENT = 8; + INTERNAL = 9; + } + Code code = 1; + string message = 2; + bool retryable = 3; + string issue_uuid = 4; +} diff --git a/protos/rvbox/v1/control.proto b/protos/rvbox/v1/control.proto new file mode 100644 index 0000000..b09a118 --- /dev/null +++ b/protos/rvbox/v1/control.proto @@ -0,0 +1,148 @@ +syntax = "proto3"; + +package rvbox.v1; + +option go_package = "github.com/rvbox/rvbox/gen/go/rvbox/v1;rvboxv1"; + +import "rvbox/v1/common.proto"; + +service Control { + rpc ListClients(ListClientsRequest) returns (ListClientsResponse); + rpc GetClient(GetClientRequest) returns (GetClientResponse); + rpc ListCommands(ListCommandsRequest) returns (ListCommandsResponse); + rpc GetCommand(GetCommandRequest) returns (GetCommandResponse); + rpc RunCommand(RunCommandRequest) returns (RunCommandResponse); + rpc RunCommandAndFollow(RunCommandRequest) returns (stream CommandEvent); + rpc FollowCommand(FollowCommandRequest) returns (stream CommandEvent); + rpc AppendStdin(AppendStdinRequest) returns (AppendStdinResponse); + rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); + rpc SignalCommand(ControlSignalCommandRequest) returns (ControlSignalCommandResponse); + rpc GetOutput(GetOutputRequest) returns (GetOutputResponse); +} + +message ClientSummary { + string client_id = 1; + bool connected = 2; + google.protobuf.Timestamp connected_at = 3; + google.protobuf.Timestamp last_seen_at = 4; + uint32 running_commands = 5; + uint32 queued_commands = 6; + Platform platform = 7; + string architecture = 8; + string daemon_version = 9; + string daemon_cwd = 10; + repeated ShellType supported_shells = 11; +} + +message ListClientsRequest { + uint32 page_size = 1; + string page_token = 2; +} + +message ListClientsResponse { + repeated ClientSummary clients = 1; + string next_page_token = 2; +} + +message GetClientRequest { + string client_id = 1; +} + +message GetClientResponse { + ClientSummary client = 1; + ControlError error = 2; +} + +message ListCommandsRequest { + string client_id = 1; + bool include_terminal = 2; + uint32 page_size = 3; + string page_token = 4; +} + +message ListCommandsResponse { + repeated CommandRecord commands = 1; + string next_page_token = 2; + ControlError error = 3; +} + +message GetCommandRequest { + string client_id = 1; + string issue_uuid = 2; +} + +message GetCommandResponse { + CommandRecord command = 1; + ResourceSnapshot latest_resource = 2; + ControlError error = 3; +} + +// For script execution, script_content contains the bytes whose descriptor is +// placed in spec.script. The server chunks it for the Agent protocol. +message RunCommandRequest { + string target_client_id = 1; + ExecutionSpec spec = 2; + bytes script_content = 3; +} + +message RunCommandResponse { + string issue_uuid = 1; + CommandLifecycle lifecycle = 2; + ControlError error = 3; +} + +message FollowCommandRequest { + string client_id = 1; + string issue_uuid = 2; + uint64 after_event_seq = 3; + bool include_existing = 4; +} + +message AppendStdinRequest { + string client_id = 1; + string issue_uuid = 2; + bytes data = 3; + bool append_newline = 4; +} + +message AppendStdinResponse { + uint64 write_seq = 1; + ControlError error = 2; +} + +message CloseStdinRequest { + string client_id = 1; + string issue_uuid = 2; +} + +message CloseStdinResponse { + uint64 write_seq = 1; + ControlError error = 2; +} + +message ControlSignalCommandRequest { + string client_id = 1; + string issue_uuid = 2; + SignalKind signal = 3; +} + +message ControlSignalCommandResponse { + uint64 command_revision = 1; + ControlError error = 2; +} + +message GetOutputRequest { + string client_id = 1; + string issue_uuid = 2; + repeated StreamKind streams = 3; + uint64 after_event_seq = 4; + uint32 page_size = 5; +} + +message GetOutputResponse { + repeated CommandEvent events = 1; + string next_page_token = 2; + bool output_truncated = 3; + ControlError error = 4; + repeated OutputTruncation truncations = 5; +}