Introduction
Ruroco (Run Remote Command) lets you execute a pre-configured command on a remote server by sending a single encrypted UDP packet. The server never answers, so from the outside the relevant port looks closed: there is nothing to port-scan, nothing to fingerprint, and nothing to brute-force.
Ruroco triggers a pre-configured action on the server (open a firewall rule, restart a service, run a script). It is not a tunnel or a VPN: there is no session and no traffic is carried, so it grants a capability to act rather than network access. See Overview and Core Idea for how it compares to a VPN like WireGuard.
A common use is Single Packet Authorization (SPA): keep a sensitive port firewalled shut at all times, and use ruroco to briefly open it only for the IP that asked, only when you ask.
What makes ruroco different
- One-way and silent. The client sends one 94-byte UDP datagram. The server never sends a response of any kind. An attacker probing the port learns nothing.
- The client cannot choose arbitrary commands. Commands are defined on the server. The client only sends a Blake2b-64 hash of a command name. It literally does not transmit the command string, so a captured packet never reveals what runs.
- Privilege separation by design. The internet-facing process (
server) runs unprivileged and can only receive, decrypt, validate, and forward. A second process (commander) runs with the rights needed to execute commands and is reachable only over a local Unix socket, never from the network. - Replay-protected. Every packet carries a strictly increasing counter (a nanosecond timestamp). The server records the highest counter seen per key and rejects anything at or below it.
The four binaries
| Binary | Runs on | Role | Build feature |
|---|---|---|---|
ruroco-client | your machine | builds, encrypts and sends the UDP packet | with-client |
ruroco-client-ui | your machine / Android | a GUI over the client (egui) | with-gui |
ruroco-server | remote host (exposed) | receives, decrypts, validates, forwards | with-server |
ruroco-commander | remote host (not exposed) | looks up and runs the command | with-commander |
How to read this documentation
This book is organized top-down, like a tree.
- Top-Level Architecture sits at the root: the core idea, how the four big modules interact, the end-to-end flow, the wire protocol, the cryptography, the security model, and how the project is built and deployed.
- Common Layer, Client and UI, Server, and Commander are the branches: each documents how a subsystem works as a whole, then drills into its files.
- The leaves are the individual
.rsfiles. Every source file is documented with its real types, signatures, responsibilities, and gotchas.
If you want the big picture, read the Top-Level Architecture section straight through. If you are working on a specific file, jump to the branch that contains it and scroll to its leaf section.
The diagrams in this book are rendered with mermaid via the
mdbook-mermaidpreprocessor. Build the book withmdbook buildfrom thedocs/directory and opendocs/book/index.html.
Overview and Core Idea
Ruroco is built around one deliberately narrow idea: a client proves, with a shared secret, that it is allowed to trigger a named command on a server, while revealing nothing to anyone watching the wire and giving an attacker nothing to attack.
Everything in the codebase follows from that sentence. This chapter explains the idea and the shape of the system before the later chapters drill into the flow, the protocol, and the individual files.
The problem being solved
Exposing a service port to the internet (SSH, a web admin panel, a database) invites constant brute-force traffic and leaves you one zero-day away from compromise. The safe move is to keep the port firewalled shut. But then you cannot reach it either.
Ruroco resolves this tension. The port stays shut. When you want in, you send one encrypted UDP packet that authorizes a server-defined command (for example “add a firewall allow-rule for my IP”). The port opens just for you, just long enough to connect. A second command closes it again.
Opening a port like this is the use case that overlaps with a VPN — but it is only one shape of what ruroco does. The general job is to trigger a server-defined action: deploy, restart a service, rotate a secret, run a backup. In those cases there is no service to connect to, only something to make happen — and a VPN does not apply.
Ruroco is not a VPN
A fair question is why not just hide everything behind a VPN like WireGuard and open the tunnel when you need a service. For reaching your own services yourself, a VPN is the better tool: WireGuard is also silent to unauthenticated packets, far more heavily audited than any custom daemon, and gives you all your services at once once connected. Ruroco does not try to replace it.
The distinction is what each one grants. A VPN grants access; ruroco grants a capability. A VPN peer gets a position on your network and bidirectional reach to everything behind the tunnel, and a compromised client inherits that foothold. A ruroco client can only fire whitelisted, pre-defined commands and never gets a network position at all. That makes ruroco the right tool where a VPN does not fit:
- triggering server-side actions (deploy, restart, backup) where there is no service to connect to, only an action to perform;
- triggers from clients that cannot or should not hold a tunnel — a CI runner, a cron job, an IoT device, a phone tap: one stateless UDP packet, no handshake, no session;
- granting a third party an action without granting them your network — capability without connectivity, a least-privilege property a VPN cannot express.
Putting ruroco in front of a VPN (knock to open the WireGuard port) is not a security win: it fronts a more-audited daemon with a less-audited one without removing the internet-facing packet parser. Use ruroco for what only it does — triggering actions — not to wrap a VPN that is already silent on its own.
The design constraints
These constraints are invariants. They are enforced throughout the code and called out in the per-module chapters.
mindmap
root((Ruroco core idea))
One-way
Server never replies
Nothing to port-scan
No oracle for attackers
Least authority
Server unprivileged
Commander privileged but local-only
Unix socket boundary
Client picks, never defines
Sends Blake2b-64 hash of a name
Commands live in server config
Captured packet reveals nothing
Confidential and authentic
AES-256-GCM-SIV with shared key
GCM tag authenticates
Fresh random IV per packet
Replay-proof
u128 nanosecond counter
Per-key monotonic floor
Equal counter is a replay
The four modules at a glance
flowchart TB
subgraph local["Local host (you)"]
UI["client_ui (GUI)<br/>src/ui"]
CLI["client (CLI)<br/>src/client"]
UI -->|calls send directly| CLI
end
subgraph remote["Remote host"]
SRV["server (unprivileged)<br/>src/server"]
CMD["commander (privileged)<br/>src/commander"]
SRV -->|24-byte CommanderData<br/>over Unix socket| CMD
end
CLI -->|"one 94-byte<br/>AES-256-GCM-SIV UDP datagram"| SRV
CMD -->|"sh -c with $RUROCO_IP"| OS["configured shell command"]
COM["common<br/>src/common<br/>crypto - protocol - ipc - fs - logging"]
CLI -.shared code.- COM
SRV -.shared code.- COM
CMD -.shared code.- COM
client(src/client) is the CLI. It hashes a command name, builds the plaintext, encrypts it, and sends exactly one UDP datagram per destination IP. It also owns the local replay counter, key generation, self-update, and the server-setup wizard.ui(src/ui) is an egui GUI. It is a thin view layer: it calls the client’s send path directly rather than reimplementing networking. The same binary runs on desktop and Android.server(src/server) is the unprivileged, internet-facing daemon. It receives the datagram, decrypts it, runs rate-limit / replay / IP checks, and forwards an authorized command to the commander. It never sends a network response.commander(src/commander, separate binary) is the privileged executor. It owns a local Unix socket, looks the command hash up in its config, and runs the configured shell command. It links no OpenSSL and no network code.common(src/common) is the shared library: cryptography, the wire protocol, the server <-> commander IPC contract (CommanderData+ socket path), atomic file IO, and the project’s own logger. Client, server, and commander each compile pieces of it, gated by Cargo features.
Where to go next
- Top-Level Modules: the responsibilities and boundaries of each module, and a dependency map.
- End-to-End Flow: a single packet’s journey from keypress to executed command, with sequence diagrams.
- Wire Protocol and Cryptography: the exact bytes and how they are protected.
- Security Model: the threats ruroco defends against and how.
Top-Level Modules
The crate is a single Rust library (src/lib.rs) plus four thin binary entry points
(src/bin/). Which modules compile is decided by Cargo features, so the same source tree
produces four very different binaries.
The library root
src/lib.rs simply exposes the four top-level modules, each behind a feature gate:
#![allow(unused)]
fn main() {
#[cfg(feature = "with-client")]
pub mod client; // CLI client
#[cfg(feature = "with-commander")]
pub mod commander; // privileged root executor
pub mod common; // always compiled: shared crypto/protocol/ipc/fs/logging
#[cfg(feature = "with-server")]
pub mod server; // network-facing daemon
#[cfg(feature = "with-gui")]
pub mod ui; // egui GUI
}
common is always built. client, commander, server, and ui are opt-in. with-gui implies
with-client (the GUI needs the client’s send path), android-build implies with-gui, and
with-server implies with-commander (the server produces the IPC type the commander consumes, and
its integration tests drive a real commander). The commander on its own (with-commander) links
neither OpenSSL nor the UDP/decrypt path.
The binaries
Each binary in src/bin/ is a minimal main() that parses CLI args and dispatches into the
library. The real logic lives in the modules so it stays unit-testable.
| Binary | Entry point | Feature | One-liner |
|---|---|---|---|
client.rs | client::run_client(CliClient::parse()) | with-client | CLI dispatch |
client_ui.rs | ui::run_ui() | with-gui | desktop GUI window |
server.rs | server::run_server(CliServer::parse()) | with-server | UDP daemon |
commander.rs | commander::run_commander(CliCommander::parse()) | with-commander | privileged executor |
On Android the GUI uses ui::android::android_main instead of run_ui (see
Android integration).
Module responsibilities and boundaries
flowchart TB
bins["<b>src/bin</b> (thin main wrappers)<br/>client.rs · client_ui.rs · server.rs · commander.rs"]
client["<b>client</b> (with-client)<br/>send/ build + send UDP<br/>config/ clap schema + conf dir<br/>counter.rs · lock.rs · gen.rs<br/>update/ signed self-update<br/>wizard/ server setup"]
ui["<b>ui</b> (with-gui)<br/>app/ RurocoApp + state<br/>tabs/ dashboard · create · execute<br/>android bridge"]
server["<b>server</b> (with-server)<br/>listener.rs Server run loop<br/>socket.rs UDP + activation<br/>handler.rs decrypt + validate<br/>blocklist.rs · rate_limiter.rs<br/>config.rs ConfigServer · keys.rs · signal.rs"]
commander["<b>commander</b> (with-commander)<br/>mod.rs Commander + accept loop<br/>exec.rs socket + sh -c<br/>config.rs ConfigCommander + ConfigCommands"]
common["<b>common</b> (always)<br/>crypto/ AES-256-GCM-SIV · Ed25519 · Blake2b<br/>protocol/ ClientData · sizes · (de)serialize<br/>ipc.rs CommanderData + socket path<br/>fs.rs atomic write · logging.rs info / error"]
bins --> client
bins --> ui
bins --> server
bins --> commander
ui -->|calls send| client
client --> common
server --> common
commander --> common
ui --> common
client
Owns everything that happens on your machine: argument parsing (config/), building and
sending the packet (send/), the persistent replay counter (counter.rs), a PID-based
single-instance lock (lock.rs), shared-key generation (gen.rs), signed self-update
(update/), and the interactive server-setup wizard (wizard/). Hard invariant: the client
only ever sends Blake2b-64 hashes of command names, never command strings.
ui
A view layer over client. It does not open its own sockets. When the user runs a saved
command, the execute tab calls the client’s send path synchronously. The GUI adds persistence
of saved commands (commands_list.toml) and, on Android, a JNI bridge for clipboard, soft
keyboard, status-bar inset, and key storage in SharedPreferences.
server
The only internet-facing component, and deliberately unprivileged. It binds the UDP socket
(or inherits it from systemd socket activation), decrypts each datagram, enforces a per-IP
rate limit, deserializes the plaintext, and validates it (replay floor, destination IP, strict
source-IP match). On success it forwards a 24-byte CommanderData message over a Unix socket.
It never writes anything back to the network.
commander
The privileged half of the server side: its own top-level src/commander module, built as a
separate binary and run as a separate (root) process under the with-commander feature. It owns the
Unix socket, maps the command hash to a configured shell string, and runs it with $RUROCO_IP set
to the requesting client’s IP. It deliberately links neither OpenSSL nor any of the network/decrypt
code; with-server is a superset of with-commander.
common
Code shared by the above. The two most load-bearing submodules are crypto/ (AES-256-GCM-SIV
encrypt/decrypt, Ed25519 verification for updates, Blake2b-64 hashing) and protocol/ (the
ClientData plaintext struct, the fixed byte sizes, and (de)serialization). ipc.rs holds the one
runtime contract shared by the server and commander (CommanderData + the Unix socket path); the
config structs themselves are not here (ConfigServer is server-only, ConfigCommander/
ConfigCommands commander-only). fs.rs provides atomic, fsync-backed writes used for the counter,
blocklist, and saved-command list. logging.rs is a tiny custom logger
(info / error) with no external log crate.
Feature-gate matrix
Because modules are feature-gated, individual functions and even impl blocks are too. A useful
mental model:
| Capability | Gated by | Notes |
|---|---|---|
encrypt | with-client | only the client encrypts |
decrypt | with-server | only the server decrypts |
ClientData::create / serialize | with-client | client builds the plaintext |
ClientData::deserialize / validation | with-server | server reads it |
verify_ed25519 | with-client | self-update signature check |
OpenSSL (crypto::handler, get_random_range) | with-client or with-server | the commander links none of it |
ipc (CommanderData, socket path), normalize_ip | with-server or with-commander | the runtime contract shared by both roles |
write_atomic | with-server or with-gui | the components that persist files |
This is why cargo check --no-default-features is part of make check: it proves the shared
code still compiles when only common is present. The build minimization (the commander dropping
OpenSSL) is verified per-feature with ldd / cargo tree.
End-to-End Flow
This chapter follows one command from the moment you press Enter on the client to the moment the shell command runs on the server. It is the single most important page for understanding how the modules fit together. Each step links to the leaf chapter that documents it in detail.
The whole journey at a glance
sequenceDiagram
autonumber
participant U as User (CLI or GUI)
participant C as client (src/client)
participant Net as UDP network
participant S as server (unprivileged)
participant Sock as Unix socket
participant Cmd as commander (root)
participant Sh as shell
U->>C: ruroco-client send -a host:port -k KEY -c open_port
Note over C: acquire single-instance lock
C->>C: resolve host to IP(s), filter by --ipv4/--ipv6
loop for each destination IP
C->>C: increment + persist counter (u128 ns)
C->>C: ClientData::create(cmd, strict, src_ip, dst_ip, counter)
C->>C: serialize to 58-byte plaintext
C->>C: encrypt: IV(12)+tag(16)+ct(58) = 86 bytes
C->>C: prepend 8-byte key_id => 94-byte packet
C->>Net: send one UDP datagram
C->>C: sleep send_delay_ms
end
Net->>S: 94-byte datagram arrives
S->>S: split key_id (8) + ciphertext (86)
S->>S: look up CryptoHandler by key_id
S->>S: RateLimiter::check(source_ip)
S->>S: decrypt + GCM tag verify
S->>S: deserialize ClientData (58 bytes)
S->>S: validate (replay, dst_ip, strict src_ip)
S->>S: persist new counter to blocklist
S->>Sock: send 24-byte CommanderData (cmd_hash + ip)
Note over S: server NEVER replies to the client
Sock->>Cmd: 24 bytes
Cmd->>Cmd: look up command string by hash
Cmd->>Sh: sh -c "<command>" with RUROCO_IP set
Sh-->>Cmd: exit status (logged, not returned)
Phase 1: the client builds and sends the packet
Driven by Sender::send (send/).
- Lock. The client acquires a PID-based single-instance lock at
<conf_dir>/client.lockso two runs cannot race the counter (lock.rs). - Resolve. The destination
--addressis resolved to one or more IPs, filtered by the--ipv4/--ipv6flags. The client then loops over each destination IP. - Counter. For each IP it increments the persistent counter and writes it to disk
immediately. The counter is a
u128nanosecond value, seeded to “now” on first use, and is strictly increasing (counter.rs). This is what makes replays impossible. - Build plaintext.
ClientData::createhashes the command name with Blake2b-64 and packsversion,cmd_hash,counter,strict,src_ip,dst_ipinto a fixed 58-byte layout (Wire Protocol). Note the inversion: the CLI flag is--permissive, but the packet carriesstrict = !permissive. - Encrypt. The 58 bytes are encrypted with AES-256-GCM-SIV using the shared key. A fresh random
IV is generated per packet. Output is
IV(12) || tag(16) || ciphertext(58)= 86 bytes (Cryptography). - Frame. The 8-byte
key_idis prepended, giving the final 94-byte packet. The key_id tells the server which shared key to use without revealing it. - Send. Exactly one UDP datagram goes out per destination IP, with
send_delay_msbetween IPs. The client does not wait for and does not expect a reply.
Phase 2: the server receives and validates
Driven by the server main loop and handler.rs (Server Overview,
handler.rs).
- Receive.
socket.rsreads a datagram into a 94-byte buffer. The socket is either inherited from systemd socket activation or bound to[::]as a fallback (socket.rs). - Decode frame. The first 8 bytes are the
key_id; the remaining 86 are the ciphertext blob. - Select key. The server loads every
*.keyfile in its config dir at startup; thekey_idselects the matchingCryptoHandler(keys.rs). - Rate limit.
RateLimiter::checkenforces a per-IP cap (default 2 requests/second). This is throttling, not security (rate_limiter.rs). - Decrypt. AES-256-GCM-SIV decrypts and verifies the tag. A bad key or tampered packet fails the tag check and is dropped silently.
- Deserialize. The 58-byte plaintext becomes a
ClientDatastruct. The leadingversionbyte is checked againstPROTOCOL_VERSION(it is authenticated, so this happens after the tag verifies). - Validate, in order (handler.rs):
- Replay: the counter must be strictly greater than the highest counter previously seen
for this
key_id(the blocklist floor). Equal counts as a replay (blocklist.rs). - Destination IP: the
dst_ipin the packet must be one of the server’s configured IPs. - Strict source IP: if the client set
strictand included asrc_ip, it must match the real source IP of the datagram.
- Replay: the counter must be strictly greater than the highest counter previously seen
for this
- Persist. On success the new counter becomes the blocklist floor and is written to disk, so the same packet can never be accepted again, even across restarts.
- Forward. The server sends a 24-byte
CommanderData(cmd_hash[0:8]+ip[8:24]) over the Unix socket. It then goes back to listening. It never replies to the client.
Phase 3: the commander executes
Driven by the top-level commander module (mod.rs + exec.rs) (commander).
- Receive. The commander reads the 24-byte
CommanderDatafrom the Unix socket. - Look up. It hashes each configured command name with Blake2b-64 and finds the one matching
cmd_hash. An unknown hash is logged and ignored. - Execute. It runs the configured shell string via
sh -c, with the environment variableRUROCO_IPset to the requesting client’s IP (so commands can reference$RUROCO_IP, for example to allow that exact IP through the firewall). - Done. The exit status is logged. Nothing is sent back to the server or the client.
Why this shape
- Two processes, one socket. Splitting
server(unprivileged, network-facing) fromcommander(privileged, local-only) means a bug in the parser cannot directly run privileged commands; it can only ever push 24 well-formed bytes through a Unix socket whose other end is the commander. - Counter written before send, floor written after accept. The client advances its counter before sending and the server advances its floor only after accepting. Combined with the strictly-greater check, this guarantees monotonic, gap-tolerant replay protection even if packets are lost or reordered.
- No response, ever. The absence of a reply is a feature. There is no oracle to probe and no packet for an attacker to elicit.
Continue with the Wire Protocol to see the exact bytes, or jump straight into a subsystem via Client Overview or Server Overview.
Wire Protocol
The protocol is intentionally tiny and fixed-size. Every packet on the wire is exactly 94 bytes. There is no length prefix and no negotiation. A single version byte rides inside the authenticated plaintext (it is not visible on the wire), and otherwise there is no structure for an observer to exploit. This keeps the parser trivial.
The sizes are defined in
src/common/protocol/constants.rsand must not be changed without understanding the full impact. The file-level documentation lives in common/protocol.
The constants
#![allow(unused)]
fn main() {
pub(crate) const PLAINTEXT_SIZE: usize = 58;
pub(crate) const CIPHERTEXT_SIZE: usize = 86;
pub(crate) const KEY_ID_SIZE: usize = 8;
pub(crate) const MSG_SIZE: usize = KEY_ID_SIZE + CIPHERTEXT_SIZE; // = 94
}
The 94-byte packet on the wire
flowchart LR
subgraph packet["UDP datagram: MSG_SIZE = 94 bytes"]
direction LR
kid["key_id<br/>8 bytes<br/>(cleartext)"]
subgraph ct["ciphertext blob: CIPHERTEXT_SIZE = 86 bytes"]
direction LR
iv["IV<br/>12 bytes"]
tag["GCM tag<br/>16 bytes"]
enc["encrypted plaintext<br/>58 bytes"]
end
end
kid --- iv
key_id(8 bytes, cleartext). Identifies which shared key encrypted this packet. It is not secret: it only lets the server pick the right key out of the several it may have loaded. It is generated randomly alongside the key bygen.- ciphertext blob (86 bytes). The output of AES-256-GCM-SIV, laid out as
IV(12) || tag(16) || ciphertext(58). See Cryptography.
The framing is done in src/common/protocol/parser.rs:
- encode (client):
encrypt(plaintext)then prependkey_id. - decode (server): split
[0..8]askey_idand[8..94]as the ciphertext blob.
The 58-byte plaintext: ClientData
Before encryption, the client packs a version byte plus five fields into a fixed 58-byte buffer,
big-endian. This is the ClientData struct (src/common/protocol/client_data.rs).
flowchart LR
subgraph pt["ClientData serialized: PLAINTEXT_SIZE = 58 bytes"]
direction LR
v["version<br/>u8<br/>[0]"]
h["cmd_hash<br/>u64<br/>[1:9]"]
c["counter<br/>u128<br/>[9:25]"]
s["strict<br/>bool<br/>[25]"]
src["src_ip<br/>16 bytes<br/>[26:42]"]
dst["dst_ip<br/>16 bytes<br/>[42:58]"]
end
| Field | Bytes | Type | Meaning |
|---|---|---|---|
version | [0] | u8 | PROTOCOL_VERSION (currently 1). Authenticated; checked after the GCM tag verifies. |
cmd_hash | [1:9] | u64 | Blake2b-64 hash of the command name. The name itself is never sent. |
counter | [9:25] | u128 | Monotonic nanosecond timestamp. Drives replay protection. |
strict | [25] | bool | 1 means enforce source-IP match. It is !permissive from the CLI. |
src_ip | [26:42] | 16 bytes | The claimed client IP, or all-zeros for “none”. |
dst_ip | [42:58] | 16 bytes | The server IP this packet is for. Must match server config. |
IPs are always 16 bytes
Both IP fields are always 16 bytes. IPv4 addresses are stored as IPv6-mapped addresses
(serialize_ip in serialization.rs). On the server they are collapsed back to IPv4 where
applicable by normalize_ip. A src_ip of all-zeros deserializes to None, which is how the
client says “I did not claim a source IP”.
The strict / permissive logic
is_source_ip_invalid on the server rejects a packet only when both of these hold:
- the client set
strict(that is, the user did not pass--permissive), and - the client included a non-empty
src_ipthat differs from the datagram’s real source IP.
So:
- Default (
strict = true, no--ip): nosrc_ipis claimed, so the strict check passes trivially; the firewall command sees the real source IP via$RUROCO_IP. --ip Xwithout--permissive: the server enforces that the real source IP equalsX. This defends against an attacker spoofing your source address.--ip X --permissive: the server accepts the packet from any source and usesXdownstream. Useful when the packet egresses from a different IP than the one you want authorized.
Serialization and round-trip
ClientData::create(command, strict, src_ip, dst_ip, counter)hashes the name and fills the struct (client side).ClientData::serialize(&self) -> [u8; 58]writes the big-endian layout (client side).ClientData::deserialize([u8; 58]) -> ClientDatareads it back (server side).
The struct’s tests assert that the serialized form is always exactly 58 bytes regardless of field
values (a u128::MAX counter and a full IPv6 address still fit) and that a create -> serialize -> deserialize round-trip reproduces the original.
Why fixed-size matters
- No parser ambiguity. The server knows a valid packet is exactly 94 bytes; anything else is discarded before any crypto runs.
- No information leak via length. Every authorized command, short or long, produces the same 94 bytes. An observer cannot distinguish “open_port” from “deploy_production” by size.
- Constant work per packet. The server does the same bounded work for every datagram, which bounds the cost of flood traffic (further capped by the rate limiter).
Continue to Cryptography for how the 58 bytes become the 86-byte ciphertext blob, or common/protocol for the file-by-file implementation.
Cryptography
Ruroco uses three primitives, each for one job. None of them is configurable or negotiated on the wire, which removes a whole class of downgrade attacks.
| Primitive | Algorithm | Used for | Where |
|---|---|---|---|
| Symmetric encryption | AES-256-GCM-SIV (OpenSSL) | confidentiality + authenticity of the packet | crypto/handler.rs, crypto/handler_ops.rs |
| Hashing | Blake2b, 8-byte output | mapping command names to cmd_hash | crypto/mod.rs::blake2b_u64 |
| Signatures | Ed25519 (OpenSSL) | verifying self-update binaries | crypto/mod.rs::verify_ed25519 |
The file-level details are in common/crypto. This chapter is the conceptual overview.
The shared key
A ruroco key is a base64 string of 40 raw bytes: an 8-byte key_id followed by a 32-byte
AES-256 key.
flowchart LR
subgraph key["base64-decoded key material: 40 bytes"]
direction LR
id["key_id<br/>8 bytes<br/>(public selector)"]
k["AES-256 key<br/>32 bytes<br/>(secret)"]
end
- Generated by
CryptoHandler::gen_key()using OpenSSLrand_bytesfor both halves (surfaced to the user asruroco-client genand the GUI’s Generate button). - The same string is placed on the client (used with
send) and on the server (one or more*.keyfiles in the config dir). CryptoHandlerisZeroizeOnDrop, and itsDebugimpl prints the key as<redacted>, so the secret never lands in logs or memory dumps after use.- The
key_idis sent in the clear so the server can pick the right key. Only the 32-byte key is secret.
AES-256-GCM-SIV: how a packet is protected
AES-256-GCM-SIV (RFC 8452) is an authenticated encryption mode: it provides confidentiality (the plaintext is hidden) and integrity/authenticity (any tampering, or use of the wrong key, fails the tag check). It is the nonce-misuse-resistant variant of GCM (see “Fresh IV per packet” below). The 58-byte plaintext becomes an 86-byte blob:
sequenceDiagram
participant P as plaintext (58 B)
participant E as CryptoHandler::encrypt
participant Blob as ciphertext blob (86 B)
participant D as CryptoHandler::decrypt
participant Out as plaintext (58 B)
Note over E: client side (with-client)
E->>E: generate fresh random IV (12 B)
E->>E: AES-256-GCM-SIV encrypt with key + IV
E->>E: read out 16-byte GCM tag
E->>Blob: IV(12) || tag(16) || ciphertext(58)
Note over D: server side (with-server)
Blob->>D: split IV, tag, ciphertext
D->>D: AES-256-GCM-SIV decrypt with key + IV
D->>D: set expected tag, finalize
alt tag verifies
D->>Out: 58-byte plaintext
else tag mismatch / wrong key / tampering
D-->>D: error, packet dropped
end
Key properties enforced in code:
- Fresh IV per packet.
encryptgenerates a new random 12-byte IV every call, so encrypting the same plaintext twice yields different ciphertexts (a tested invariant). With AES-256-GCM-SIV an accidental IV repeat is not catastrophic (it would only reveal whether two plaintexts were identical, which the replay counter already rejects), unlike plain AES-GCM where IV reuse enables key recovery and forgery. The fresh random IV keeps packets indistinguishable from random on the wire; the replay counter lives inside the authenticated plaintext. - Fail closed.
decryptonly returns plaintext if the GCM tag verifies. A wrong key, a flipped bit, or a truncated packet all produce an error and the packet is dropped. There is no partial decryption and no error is sent back to the client. - Exact sizes checked. Both
encryptanddecryptassert the produced length equalsPLAINTEXT_SIZE/CIPHERTEXT_SIZEand that GCM finalize emits no extra bytes.
The blob layout IV || tag || ciphertext is fixed and matched by both sides. The server splits it
in exactly that order in decrypt.
Blake2b-64: hashing command names
blake2b_u64(name) -> u64 produces an 8-byte Blake2b digest of a command name and interprets it
big-endian as a u64. This is the cmd_hash carried in the packet and the key the commander uses
to look commands up.
Why hash instead of sending the name:
- The client never transmits a command string, so a captured packet does not reveal what would run.
- Both sides compute the same hash from the same configured name, so they agree without ever exchanging the name over the wire.
Collision risk is acceptable here because the command set is tiny and operator-controlled; the hash is an identifier, not a security boundary (the AES key is).
Ed25519: trusting self-update binaries
The self-update path is the one place ruroco pulls executable code from the internet, so it is the one place it verifies a signature.
- The release private key lives only in CI (the
RUROCO_SIGNING_KEYGitHub Actions secret). - The matching public key (
keys/ruroco-release-ed25519.pub.pem) is committed and embedded into the client at build time. - During
update, the downloaded binary and its.sigare verified withverify_ed25519(public_key_pem, message, signature)before anything is written to disk. A missing or invalid signature aborts the update and leaves the existing binary untouched.
This means a compromised release host or a man-in-the-middle cannot push a malicious binary: it
would not carry a signature that validates against the embedded public key. Only releases that ship
signatures (v0.14.0 and later) can be installed this way. Full flow in
client/update.
What is deliberately absent
- No asymmetric encryption of packets. Authorization is a shared-secret problem here, so a symmetric AEAD is the right and simplest tool.
- No key exchange / handshake. The key is provisioned out of band (you copy the same string to both ends). There is nothing to negotiate, so there is nothing to downgrade or interfere with.
- No server response. Because the server never replies, there is no ciphertext flowing back that an attacker could use as a decryption or timing oracle.
Security Model
This chapter states the threats ruroco is designed to resist, the mechanisms that resist them, and the assumptions that must hold. It ties together the protocol, the cryptography, and the two-process server design.
Threat model
Ruroco assumes a powerful network attacker who can:
- observe all traffic to and from the server (passive eavesdropping),
- send arbitrary UDP packets to the server (active injection),
- capture and replay packets you sent earlier,
- port-scan and fingerprint the host,
- spoof source IP addresses.
Ruroco assumes the attacker cannot:
- read the shared AES key (it is provisioned out of band and never transmitted),
- execute code on the server host already (that is game over regardless),
- break AES-256-GCM-SIV or Ed25519.
Defenses, mapped to mechanisms
flowchart TD
T1["Port scanning / fingerprinting"] -->|server never replies| D1["No oracle, port looks closed"]
T2["Eavesdropping"] -->|AES-256-GCM-SIV| D2["Plaintext confidential"]
T3["Packet tampering / forgery"] -->|GCM auth tag| D3["Bad packets dropped, fail closed"]
T4["Replay of a captured packet"] -->|monotonic counter + per-key floor| D4["counter <= floor rejected"]
T5["Learning what command runs"] -->|Blake2b-64 hash of name only| D5["Command string never on wire"]
T6["Source-IP spoofing"] -->|strict mode src_ip match| D6["Real source must equal claimed IP"]
T7["Flood / brute-force"] -->|per-IP rate limiter| D7["Throttled, bounded work"]
T8["Bug in network parser"] -->|privilege separation| D8["Cannot run privileged commands directly"]
T9["Malicious self-update binary"] -->|Ed25519 verify before write| D9["Unsigned binary refused"]
1. Silence defeats reconnaissance
The server sends no response of any kind, ever. A scanner cannot tell an open ruroco port from a closed one, there is no banner to fingerprint, and there is no reply packet to use as an oracle. This is an architectural invariant, not a config option.
2. Confidentiality and authenticity: AES-256-GCM-SIV
The packet body is encrypted and authenticated with AES-256-GCM-SIV under the shared key. Eavesdroppers see only random-looking bytes. Tampered or forged packets fail the GCM tag check and are dropped silently. See Cryptography.
3. Replay protection: the monotonic counter
Each packet carries a u128 counter that the client makes strictly increasing (a nanosecond
timestamp, persisted across runs). The server stores, per key_id, the highest counter it has
accepted (the “floor”, in the blocklist). A packet is rejected unless its counter is strictly
greater than the floor, so:
- replaying a captured packet fails (its counter equals the floor: equal is a replay),
- the floor is persisted (msgpack) and re-seeded to “now” on startup, so packets older than process start are rejected even after a restart.
See blocklist.rs and counter.rs.
Operational note: each client must use its own key. Two clients sharing a key keep independent local counters, but the server tracks only one floor per key, so whichever sends last advances the floor and the other client’s packets start getting rejected as replays. The fix is one key per client;
ruroco-client reseedrecovers a single client whose counter fell behind.
4. The client cannot choose arbitrary commands
The client sends only a Blake2b-64 hash of a command name. The actual shell command lives only in the server’s config. So even a fully compromised client (or a captured packet) can at most trigger one of the operator-defined commands; it cannot inject a new one.
5. Source-IP binding (strict mode)
When you pass --ip without --permissive, the server enforces that the datagram’s real source IP
matches the claimed src_ip. This stops an attacker from replaying or spoofing a packet to
authorize their own address. With --permissive the check is relaxed deliberately, for the case
where your packet egresses from a different IP than the one you want allowed.
6. Rate limiting (throttling, not security)
RateLimiter caps requests per source IP (default 2/second, in-memory). This blunts floods and
brute-force noise. It is explicitly not a replay or auth defense (it resets on restart and is
per-IP); the counter and GCM tag are the real defenses.
7. Privilege separation: two processes, one socket
The internet-facing server runs unprivileged. It can receive, decrypt, validate, and write at
most a 24-byte CommanderData to a Unix socket. The privileged commander runs as root, owns the
other end of that socket, and is the only component that executes commands. A vulnerability in the
network-facing parser therefore cannot directly run privileged commands; the blast radius is bounded
by the Unix-socket interface.
flowchart TB
Net["Internet"] -->|UDP 93 B| Srv["server<br/>unprivileged"]
Srv -->|"24 B CommanderData<br/>(local Unix socket only)"| Cmd["commander<br/>root"]
Cmd --> Sh["sh -c command"]
style Srv fill:#2d4
style Cmd fill:#d42
The systemd units reinforce this: the server runs as a dedicated low-privilege ruroco user, the
binaries are installed mode 0o500 and owned appropriately, and the wizard sets it all up
(wizard). The server unit is additionally locked down hard — no capabilities
at all (port 80 is bound by ruroco.socket, not the service), AF_UNIX-only sockets, no bind,
MemoryDenyWriteExecute, and a read-only /etc/ruroco (its only writable state, the blocklist,
lives in a StateDirectory). The commander’s sandbox is necessarily looser because its restrictions
are inherited by the root shell commands it spawns; it keeps only CAP_CHOWN plus the
network capabilities the documented ufw commands need. See
Build and deploy for the exact directives.
8. Signed self-update
Self-update verifies an Ed25519 signature against an embedded public key before writing any binary to disk. A compromised download path cannot deliver code that runs. See Cryptography and client/update.
Residual risks and assumptions
- Key secrecy is everything. Anyone with the shared key can craft valid packets (subject to the
counter). Store it in a password manager or keyring; the README suggests
secret-tool. - Command safety is the operator’s job. The commander runs whatever the config says via
sh -c, with$RUROCO_IPinterpolated. Keep commands minimal and treat$RUROCO_IPas attacker-influenced input when writing them. - The rate limiter is in-memory. A restart clears it; it is a throttle, not a guarantee.
- Clock sanity. The counter is a timestamp. A large backward clock jump on the client can make
its counter lag the server’s floor;
reseedfixes this.
No-panic discipline as a security property
Production code uses anyhow::Result everywhere and forbids unwrap, expect, panic!, and
fallible indexing. A network-facing daemon that cannot be crashed by a malformed packet is part of
the security posture, not just code hygiene. All error paths log and continue (or drop the packet);
they never abort the process.
Build, Features and Deployment
This chapter covers how the one source tree becomes four binaries, how the Cargo features carve it up, and how it is deployed and operated on a server. It is the bridge between “how the code is structured” and “how it runs in production”.
Cargo features
Cargo.toml defines the feature set that decides which modules and dependencies compile.
| Feature | Pulls in | Enables |
|---|---|---|
with-client | ureq, tempfile, openssl | the client module (send, gen, update, wizard, counter, lock) |
with-commander | toml | the commander module (no OpenSSL, no UDP/decrypt) |
with-server | openssl, + with-commander | the server module (network-facing daemon) |
with-gui | eframe, toml, + with-client | the ui module |
android-build | jni, ndk-context, android-activity, wgpu, + with-gui | Android GUI backend |
with-vendored-openssl | openssl/vendored | static OpenSSL for portable release binaries |
default = []: nothing is on by default, so each binary is built with --no-default-features plus
exactly the feature it needs. common always compiles. openssl is an optional dependency pulled
in only by with-client and with-server, so a with-commander-only build of the privileged
commander links no crypto/network code at all. This is why make check also runs
cargo check --no-default-features: to prove the shared code stands alone.
Binary / feature / entry-point mapping
flowchart TD
src["single crate: src/lib.rs"] --> f1
subgraph builds["four cargo builds (Makefile)"]
f1["--features with-client<br/>bin client"] --> e1["client::run_client"]
f2["--features with-gui<br/>bin client_ui"] --> e2["ui::run_ui"]
f3["--features with-server<br/>bin server"] --> e3["server::run_server"]
f4["--features with-commander<br/>bin commander"] --> e4["commander::run_commander"]
end
Each [[bin]] in Cargo.toml declares its required-features, so cargo build of one binary
cannot accidentally drag in another’s feature.
The Makefile workflow
The Makefile is the source of truth for commands. The ones you use most:
| Target | What it does |
|---|---|
make build | builds all four binaries (debug, x86_64-unknown-linux-gnu), each with its own feature |
make test | cargo nextest with all features and TEST_UPDATER=1 (runs networked update tests) |
make test_unit | tests excluding the integration binary |
make test_integration | only the integration test (spins a real commander thread) |
make check | cargo check --locked and cargo check --locked --no-default-features |
make format | cargo fmt, then clippy -D warnings, then cargo fix |
make coverage | cargo tarpaulin (llvm engine), xml + html output |
make release | release_android + release_linux |
make release_linux | release build of all binaries (client/server/ui add with-vendored-openssl for static OpenSSL; commander uses with-commander, no OpenSSL) |
make gen_signing_key | generate the Ed25519 release signing keypair (one-time) |
make install_client | build release, copy client binaries into ~/.local/bin |
make install_server | also copy server binaries to /usr/local/bin, then run the wizard |
make test_end_to_end | full systemd + sudo end-to-end test (see scripts/test_end_to_end.sh) |
The release profile (Cargo.toml) is tuned for small, self-contained binaries: opt-level = "z",
lto = true, codegen-units = 1, strip = true, panic = "abort".
Release signing
flowchart TB
gen["make gen_signing_key<br/>(local, one-time)"] --> priv["private key<br/>ruroco-release-ed25519.key<br/>(gitignored)"]
gen --> pub["public key<br/>ruroco-release-ed25519.pub.pem<br/>(committed, embedded in client)"]
priv -->|gh secret set RUROCO_SIGNING_KEY| ci["GitHub Actions"]
ci -->|signs each release asset| sig["binary + .sig"]
pub -->|embedded at build time| client["ruroco-client"]
sig -->|verified before install| client
The private key never leaves CI; the public key ships inside the client. This is what makes self-update trustworthy (see Cryptography and client/update).
Deployment topology
flowchart TB
subgraph local["Local host"]
cli["ruroco-client / ruroco-client-ui<br/>(~/.local/bin)"]
keyfile["~/.config/ruroco/user.key<br/>+ counter, lock, commands_list.toml"]
end
subgraph remote["Remote host (systemd)"]
socket["ruroco.socket<br/>(UDP socket activation)"]
server["ruroco.service<br/>ruroco-server (unprivileged user)"]
commander["ruroco-commander.service<br/>(root)"]
etc["/etc/ruroco/<br/>config.toml + commands.toml + *.key"]
unixsock["commander Unix socket"]
socket --> server
server --> unixsock --> commander
etc -.config.- server
etc -.config.- commander
end
cli -->|94-byte UDP| socket
keyfile -. same key string .- etc
Server setup via the wizard
ruroco-client wizard (run as root) provisions the server side: it force-updates the server
binaries, writes the three systemd units and /etc/ruroco/config.toml (mode 0o600, only if
missing), then runs daemon-reload, enable, and start. The unit files and the default config are
embedded into the client at compile time with include_bytes! from the systemd/ and config/
directories. Full detail in wizard.
The systemd units
ruroco.socket: holds the UDP listening socket and hands the file descriptor to the service (socket activation). The server reads it viaLISTEN_FDS; if absent it falls back to binding[::]itself.ruroco.service: runsruroco-serveras the dedicated low-privilegerurocouser. Heavily sandboxed: it holds no capabilities (port 80 is bound byruroco.socket, not the service), has its blocklist in aStateDirectory(/var/lib/ruroco) so/etc/rurocostays fully read-only, and is restricted toAF_UNIX(correct only under socket activation — see the comments in the unit before changing the socket).ruroco-commander.service: runsruroco-commanderas root, owning the Unix socket (placed in aRuntimeDirectory,/run/ruroco). Because it is a generic root command runner whose restrictions are inherited by every command it spawns, its sandbox is deliberately looser: it keepsCAP_CHOWN(to chown the socket) plusCAP_NET_ADMIN/CAP_NET_RAWand INET/NETLINK address families so the documentedufwfirewall commands still work. Tighten toCAP_CHOWN+AF_UNIXonly if all your commands aresystemctl/dbus-style (see the comments in the unit).
Config files
/etc/ruroco/config.toml: allowedips, rate limit, clock skew, socket user/group,config_dir, and the optionalblocklist_dir/socket_dirrelocations (defaulting toconfig_dir). Read by both processes through their own views (ConfigServerreads the server fields,ConfigCommanderthe socket-ownership fields;config_dirandsocket_diroverlap). Has no command map. The whole/etc/rurocodirectory is mounted read-only for the server; its mutable state lives inStateDirectory/RuntimeDirectoryinstead. See config and keys and Commander./etc/ruroco/commands.toml: the[commands]map (name to shell string),root-owned0600. Read only by the commander, never by the network-facing server. See Commander./etc/ruroco/*.key: one or more shared keys. The server loads every*.keyfile; the packet’skey_idselects which one. See keys.rs.
Local config layout (client)
The client keeps its state under the conf dir (RUROCO_CONF_DIR, else $HOME/.config/ruroco):
| File | Purpose |
|---|---|
*.key | the shared key (also pass via -k) |
counter | raw big-endian u128 replay counter |
client.lock | PID-based single-instance lock |
commands_list.toml | the GUI’s saved commands |
On Android these are kept in the app-private directory, and the AES key lives in SharedPreferences rather than a file (see Android integration).
Testing layers
- Unit tests: inline
#[cfg(test)]modules next to the code. - Integration tests (
tests/integration_test.rs): spin a real commander thread and a server, send an actual packet, and assert the configured command runs and that replays are rejected. - End-to-end (
scripts/test_end_to_end.sh): exercises the real systemd units with sudo.
Tests isolate state with tempfile::tempdir() and RUROCO_CONF_DIR; update tests are gated behind
TEST_UPDATER because they hit a real (local) HTTP server. See the per-module chapters for the test
hooks each subsystem exposes.
Common Layer Overview
src/common/ is the shared library compiled into every binary. It holds the code that the client,
server, and commander must agree on (cryptography, the wire protocol, and the server <-> commander
IPC contract) plus cross-cutting utilities (atomic file IO and logging). It is the only module not
behind a feature gate: it always compiles, even with --no-default-features (individual items
inside it are gated per feature).
Layout
flowchart TD
mod["common/mod.rs<br/>re-exports + now_nanos + normalize_ip"]
subgraph crypto["crypto/"]
ch["handler.rs<br/>CryptoHandler (key lifecycle)"]
cho["handler_ops.rs<br/>encrypt / decrypt"]
cm["mod.rs<br/>blake2b_u64, verify_ed25519, get_random_range"]
end
subgraph protocol["protocol/"]
pc["constants.rs<br/>sizes"]
pcd["client_data.rs<br/>ClientData struct"]
pp["parser.rs<br/>DataParser encode/decode"]
ps["serialization.rs<br/>IP <-> 16 bytes"]
end
ipc["ipc.rs<br/>CommanderData + socket path (server/commander)"]
fs["fs.rs<br/>write_atomic, resolve_path, chown"]
log["logging.rs<br/>info / error"]
andr["android/<br/>JNI bridge (android only)"]
mod --> crypto
mod --> protocol
mod --> ipc
mod --> fs
mod --> log
mod -.cfg android.- andr
What mod.rs itself provides
src/common/mod.rs wires the submodules together and re-exports the items used elsewhere. It also
defines two small but important helpers.
now_nanos
#![allow(unused)]
fn main() {
pub(crate) fn now_nanos() -> anyhow::Result<u128>
}
Returns the current time as nanoseconds since the Unix epoch, as a u128. This single function is
the source of every counter value in the system: the client seeds and advances its replay counter
from it, and the server seeds its blocklist floor from it on startup. It returns an error (rather
than panicking) if the system clock is before the epoch.
normalize_ip (server / commander)
#![allow(unused)]
fn main() {
#[cfg(any(feature = "with-server", feature = "with-commander"))]
pub(crate) fn normalize_ip(ip: IpAddr) -> IpAddr
}
Collapses an IPv6-mapped IPv4 address (for example ::ffff:192.168.0.1) back to a plain IPv4
address, leaving genuine IPv6 and IPv4 addresses unchanged. Because every IP on the wire is stored
as 16 bytes (IPv6-mapped), this is how a clean IPv4 value is recovered for comparison and for
$RUROCO_IP. Both the server (validation) and the commander (decoding CommanderData) need it, so
it is gated for either role.
Re-exports
mod.rs curates the crate-internal surface so the rest of the code does not reach deep into
submodules:
| Re-export | From | Used by |
|---|---|---|
blake2b_u64 | crypto | client (build hash) and commander (command lookup) |
get_random_range | crypto | fs::write_atomic temp-name, UI |
crypto_handler (alias for crypto::handler) | crypto | parser |
change_file_ownership, resolve_path | fs | server, update, wizard |
info | logging | everywhere |
client_data, data_parser (alias for protocol::parser) | protocol | client, server |
Feature gating inside common
Even though common always compiles, individual functions inside it are feature-gated so that, for
example, the server build does not pull in client-only code:
encrypt,verify_ed25519,ClientData::create/serialize:with-client.decrypt,ClientData::deserialize,is_source_ip_invalid:with-server.- the
crypto::handler(CryptoHandler) andget_random_range, which pull in OpenSSL:with-clientorwith-server(never the commander). ipc(CommanderData, socket path),normalize_ip,deserialize_ip:with-serverorwith-commander(both roles need them; no OpenSSL involved). The config structs themselves are not incommon-ConfigServeris inserver::config,ConfigCommander/ConfigCommandsincommander::config.write_atomic:with-serverorwith-gui(the components that persist files).
The leaf chapters that follow document each file in full:
- crypto/:
CryptoHandler, AES-256-GCM-SIV encrypt/decrypt, Blake2b-64, Ed25519. - protocol/: the
ClientDatastruct, sizes, parser, and IP serialization. - fs.rs and logging.rs: atomic writes, path/ownership helpers, the logger.
- ipc.rs: the server <-> commander IPC contract (
CommanderData+ the socket path).
The Android JNI bridge under common/android/ is documented alongside the GUI it serves, in
Android integration, because it only exists to back the UI on Android.
common/crypto/
The cryptography module. Three files: handler.rs (the key type and its lifecycle),
handler_ops.rs (the AES-256-GCM-SIV encrypt/decrypt operations), and mod.rs (the free functions:
Blake2b hashing, Ed25519 verification, random ranges). The conceptual overview is in
Cryptography; this is the file-by-file reference.
classDiagram
class CryptoHandler {
+key 32 bytes
+id 8 bytes
+create(key_string) Result
+gen_key() Result~String~
+encrypt(plaintext_58B) Result
+decrypt(blob_86B) Result
}
note for CryptoHandler "ZeroizeOnDrop. Debug redacts the key. gen_key and encrypt are with-client. decrypt is with-server."
handler.rs
Defines CryptoHandler, which owns the parsed key material and its lifecycle.
#![allow(unused)]
fn main() {
#[derive(ZeroizeOnDrop)]
pub(crate) struct CryptoHandler {
pub(crate) key: [u8; 32], // KEY_SIZE
pub(crate) id: [u8; 8], // KEY_ID_SIZE
}
}
create(key_string: &str) -> anyhow::Result<Self>: base64-decodes the (trimmed) key string into aZeroizingbuffer, splits off the first 8 bytes as theidand the remaining 32 as thekey, and validates lengths. Errors with"Key too short"if fewer than 8 bytes, and"Key length must be 32 bytes"if the remainder is not exactly 32. Leading/trailing whitespace in the input is tolerated (the key istrimmed), so a key copied with a trailing newline still works.gen_key() -> anyhow::Result<String>(with-client): generates 8 random id bytes and 32 random key bytes with OpenSSLrand_bytes, concatenates them, and base64-encodes the result. This is the string surfaced byruroco-client genand the GUI Generate button.
Security hygiene baked into the type:
ZeroizeOnDrop: the 32-byte key is wiped from memory when the handler is dropped.- Custom
Debug: printskey: "<redacted>", so the secret never leaks into a log line or a panic message. A test asserts the raw key bytes never appear in theDebugoutput.
handler_ops.rs
Adds the AES-256-GCM-SIV operations as feature-gated impl CryptoHandler blocks. Local constants:
IV_SIZE = 12, TAG_SIZE = 16.
encrypt (with-client)
#![allow(unused)]
fn main() {
pub(crate) fn encrypt(&self, plaintext: &[u8; 58]) -> anyhow::Result<[u8; 86]>
}
- Generates a fresh random 12-byte IV (
rand_bytes). - Runs AES-256-GCM-SIV in encrypt mode over the 58-byte plaintext.
- Asserts the produced ciphertext length equals
PLAINTEXT_SIZEand thatfinalizeemits 0 extra bytes (GCM is a stream cipher mode, so the lengths match). - Reads the 16-byte authentication tag.
- Returns the 86-byte blob laid out as
IV(12) || tag(16) || ciphertext(58).
Because the IV is random per call, encrypting identical plaintext twice yields different blobs (a tested invariant). Plain GCM requires unique IVs for safety; AES-256-GCM-SIV is misuse-resistant, so an accidental IV repeat only reveals whether two plaintexts were equal (rejected anyway by the replay counter) rather than enabling key recovery, the random IV is defense in depth plus wire indistinguishability.
decrypt (with-server)
#![allow(unused)]
fn main() {
pub(crate) fn decrypt(&self, iv_tag_ciphertext: &[u8; 86]) -> anyhow::Result<[u8; 58]>
}
- Splits the blob into IV
[0:12], tag[12:28], ciphertext[28:86]. - Runs AES-256-GCM-SIV in decrypt mode.
- Asserts the plaintext length equals
PLAINTEXT_SIZE. - Sets the expected tag and calls
finalize. If the tag does not verify (wrong key, tampering, truncation),finalizeerrors and the function returnsErr. It fails closed: no plaintext is returned on any integrity failure.
A test confirms that decrypting with a different key returns an error.
mod.rs
Houses the free cryptographic functions and declares the submodules.
blake2b_u64
#![allow(unused)]
fn main() {
pub(crate) fn blake2b_u64(s: &str) -> anyhow::Result<u64>
}
Hashes a string with Blake2bVar configured for an 8-byte output, then interprets those 8 bytes
big-endian as a u64. This produces the cmd_hash carried in the packet. The client hashes the
command name to fill ClientData; the server (in the commander) hashes each configured command
name to find the match. Both sides agree without the name ever crossing the wire.
verify_ed25519 (with-client)
#![allow(unused)]
fn main() {
pub(crate) fn verify_ed25519(
public_key_pem: &[u8],
message: &[u8],
signature: &[u8],
) -> anyhow::Result<()>
}
Parses an Ed25519 public key from PEM, builds an OpenSSL Verifier with no pre-hash
(new_without_digest, correct for Ed25519), and verifies the detached signature over message.
Returns Ok(()) only on a valid signature; otherwise it returns a descriptive error
("Could not parse Ed25519 public key", "Signature verification failed", etc). Used exclusively
by the self-update path to authenticate downloaded binaries before they touch disk. Its tests cover
valid signatures, tampered messages, wrong keys, and malformed PEM.
get_random_range
#![allow(unused)]
fn main() {
pub fn get_random_range(from: u16, to: u16) -> anyhow::Result<u16>
}
Returns a random u16 in [from, to) using OpenSSL rand_bytes over 4 bytes and a modulo of the
span. This is the one pub (not pub(crate)) function in the module. It is used by
fs::write_atomic to pick a unique temp-file suffix and by the UI. It is a simple uniform-ish
helper, not a security-critical primitive.
Gotchas
encryptis client-only anddecryptis server-only by feature gate, mirroring the one-way data flow. A binary that only sends never links the decrypt path and vice versa.- The key string’s
key_idis not secret; only the 32-byte key is. Treat the whole base64 string as a secret anyway, since it contains the key. - Never log a
CryptoHandlerexpecting to see the key: theDebugimpl redacts it by design.
common/protocol/
The wire protocol implementation. Four files: constants.rs (the fixed sizes), client_data.rs
(the plaintext struct and its (de)serialization), parser.rs (framing: prepend/strip the key_id
and call crypto), and serialization.rs (IP to 16 bytes and back). The conceptual layout is in
Wire Protocol; this is the file-by-file reference.
Do not change these sizes without understanding the full impact. They are matched on both sides and assumed throughout the crypto and validation code.
constants.rs
#![allow(unused)]
fn main() {
pub(crate) const PLAINTEXT_SIZE: usize = 58; // serialized ClientData
pub(crate) const CIPHERTEXT_SIZE: usize = 86; // IV(12) + tag(16) + ciphertext(58)
pub(crate) const KEY_ID_SIZE: usize = 8; // cleartext key selector
pub(crate) const MSG_SIZE: usize = KEY_ID_SIZE + CIPHERTEXT_SIZE; // = 94, the datagram
}
mod.rs re-exports all four for use across the crate.
client_data.rs
Defines the plaintext payload and the only struct that crosses the encryption boundary.
#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub(crate) struct ClientData {
pub(crate) cmd_hash: u64,
pub(crate) counter: u128,
pub(crate) strict: bool,
pub(crate) src_ip: Option<IpAddr>,
pub(crate) dst_ip: IpAddr,
}
}
Client side (with-client)
-
create(command, strict, src_ip, dst_ip, counter) -> Result<ClientData>: hashescommandwithblake2b_u64intocmd_hashand stores the rest verbatim. -
serialize(&self) -> Result<[u8; 58]>: writes the fixed big-endian layout into a 58-byte array:Field Offset Encoding version[0]PROTOCOL_VERSIONbyte (currently1)cmd_hash[1:9]u64big-endiancounter[9:25]u128big-endianstrict[25]1or0src_ip[26:42]serialize_ip, or all-zeros ifNonedst_ip[42:58]serialize_ip
Server side (with-server)
deserialize(data: [u8; 58]) -> ClientData: reads the same layout back. Theversionbyte at[0]is checked againstPROTOCOL_VERSION(after the GCM tag has verified). Asrc_ipfield of all-zeros decodes toNone(the “no claimed source IP” sentinel); any other value decodes viadeserialize_ip.is_source_ip_invalid(&self, source_ip: IpAddr) -> bool: returnstrueonly whenself.strictis set andself.src_ipisSomeand the stored value differs from the datagram’s realsource_ip. In all other cases it returnsfalse(the check passes). This is the entire strict-mode source-IP enforcement.
Tests
client_data.rs ships three test modules: size tests proving serialize always yields exactly
58 bytes for both extreme (u128::MAX counter, IPv6) and minimal (all zeros, IPv4) values, and a
cross-feature round-trip test asserting create -> serialize -> deserialize reproduces the
original struct including the Blake2b hash of the command name.
parser.rs
DataParser handles framing: turning the encrypted blob into the 94-byte datagram and back. It
owns a CryptoHandler on the client.
#![allow(unused)]
fn main() {
pub(crate) struct DataParser {
#[cfg(feature = "with-client")]
pub(crate) crypto_handler: CryptoHandler,
}
}
encode (with-client)
#![allow(unused)]
fn main() {
pub(crate) fn create(key_string: &str) -> Result<Self>
pub(crate) fn encode(&self, data: &[u8; 58]) -> Result<[u8; 94]>
}
create builds the inner CryptoHandler from the key string. encode encrypts the 58-byte
plaintext into the 86-byte blob, then prepends the handler’s 8-byte id, producing the final
94-byte message.
decode (with-server)
#![allow(unused)]
fn main() {
pub(crate) fn decode(data: &[u8; 94])
-> Result<(&[u8; 8], &[u8; 86])>
}
A static method (no handler needed): splits the datagram into the key_id ([0:8]) and the
ciphertext blob ([8:94]), returning references into the original buffer. The server then uses the
key_id to pick the right CryptoHandler and decrypt the blob. Decode is purely structural; it
does no crypto and cannot fail on content, only on a wrong-sized buffer.
serialization.rs
IP-to-bytes conversion. IP_SIZE = 16.
#![allow(unused)]
fn main() {
pub(crate) fn serialize_ip(ip: &IpAddr) -> [u8; 16]
#[cfg(feature = "with-server")]
pub(crate) fn deserialize_ip(data: [u8; 16]) -> IpAddr
}
serialize_ip: IPv4 addresses are converted to their IPv6-mapped form (to_ipv6_mapped().octets()); IPv6 addresses are taken as-is. Either way the result is 16 bytes, which is why both IP fields inClientDataare fixed 16-byte slots.deserialize_ip(server only): reconstructs anIpv6Addrfrom the 16 bytes and runs it throughnormalize_ip, so an IPv6-mapped IPv4 comes back out as a cleanIpAddr::V4.
This pairing is why the protocol can carry IPv4 and IPv6 uniformly in the same fixed layout, and why the server always compares and exposes normalized addresses.
Gotchas
- The protocol carries a version byte at plaintext offset
[0](PROTOCOL_VERSION, currently1). It lives inside the authenticated plaintext and is checked only after the GCM tag verifies, so it cannot be tampered with on the wire. Compatibility otherwise relies on never changing the sizes or field order. The constants file is the contract. decodereturns borrowed slices into the input datagram; the server must keep that buffer alive while decrypting.- An all-zero
src_ipis meaningful: it is the wire encoding ofNone, not of0.0.0.0. The client never claims0.0.0.0as a real source.
fs.rs and logging.rs
Two small cross-cutting utility files in common. fs.rs provides durable, atomic file writes and
ownership/path helpers used wherever state is persisted. logging.rs is the project’s own minimal
logger.
fs.rs
write_atomic (with-server or with-gui)
#![allow(unused)]
fn main() {
pub(crate) fn write_atomic(path: &Path, contents: &[u8]) -> anyhow::Result<()>
}
Writes a file atomically and durably, so a crash or power loss can never leave a half-written file. This backs the replay counter, the server blocklist, and the GUI’s saved-command list, all of which must survive interruption intact.
flowchart TD
A["pick temp path:<br/>path.<random u16>.tmp"] --> B["open temp (create, truncate)"]
B --> C["write_all(contents)"]
C --> D["sync_all() on the temp file<br/>(fsync data to disk)"]
D --> E["rename(temp, path)<br/>(atomic replace)"]
E --> F["best-effort fsync of parent dir<br/>(make the rename durable)"]
The steps in order:
- Build a unique temp path next to the target using
get_random_range(0, u16::MAX)as a suffix. - Open it with create + write + truncate.
write_allthe contents, thensync_all()to force the bytes to disk before the rename.renamethe temp over the target. On the same filesystem this is atomic: a reader sees either the entire old file or the entire new one, never a mix.- Best-effort
fsyncof the parent directory so the rename itself is durable across a crash. This step is allowed to fail silently (it is a durability nicety, not a correctness requirement).
A failure at the open or write stage returns a contextual error (the temp file is simply left behind, never the corrupted target). Tests cover create, overwrite, and the nonexistent-parent error path.
resolve_path
#![allow(unused)]
fn main() {
pub(crate) fn resolve_path(path: &Path) -> PathBuf
}
Resolves a path to an absolute, canonicalized form. Absolute paths are returned unchanged.
Relative paths are joined onto the current directory and canonicalized. This function never
returns an error: if the current directory cannot be read, or canonicalization fails (for example
the path does not exist yet), it logs via error(...) and returns the best path it has. That
infallible-but-logged behavior is intentional, so callers that just need a usable path are not
forced into error handling for non-critical cases.
change_file_ownership and helpers
#![allow(unused)]
fn main() {
pub(crate) fn change_file_ownership(path: &Path, user_name: &str, group_name: &str)
-> anyhow::Result<()>
}
Changes a file’s owner and/or group by name (using nix to resolve names to uid/gid, then
std::os::unix::fs::chown). An empty user_name or group_name means “leave that unchanged”
(passed to chown as None). Unknown user or group names produce a "Could not find user/group"
error; a chown failure (for example permission denied) produces a "Could not change ownership"
error. The private helpers get_uid_by_name and get_gid_by_name do the name-to-id lookup.
This is used by the server side to give the Unix socket and the installed binaries the right ownership, and by the self-update path when replacing privileged binaries.
Locale gotcha (from the project conventions): do not parse the output of
idin tests; the system locale changes error message wording. The tests here assert on substrings of ruroco’s own error messages, not on OS tool output.
logging.rs
A deliberately tiny logger, with no external log/tracing crate dependency.
#![allow(unused)]
fn main() {
pub(crate) fn info(msg: impl std::fmt::Display) // stdout, green "INFO"
pub(crate) fn error(msg: impl std::fmt::Display) // stderr, red "ERROR"
}
- Both take
impl Display, so callers pass an owned value:info(format!("..."))orinfo("literal"). The project convention is to never writeinfo(&format!(...)): borrowing a temporary is unnecessary and reads worse. infoprints to stdout,errorto stderr, each prefixed with a UTC timestamp formatted as%Y-%m-%dT%H:%M:%SZ(viachrono::Utc) and an ANSI-colored level tag.- There are no levels beyond info/error and no filtering. Output goes to the process’s standard streams, which under systemd means it lands in the journal for both the server and commander services.
Keeping the logger this small is a deliberate choice for a security-sensitive daemon: less
dependency surface, predictable output, and no risk of a logging framework accidentally capturing
secrets (and recall that CryptoHandler’s Debug redacts the key in any case).
ipc.rs
src/common/ipc.rs is the single thing the server and commander processes must agree on at
runtime: where their Unix socket lives and what flows over it. It lives in common (rather than in
either role’s module) because both depend on it, and it carries no crypto or network code, so the
commander can link it without OpenSSL. It is gated behind any(with-server, with-commander).
The 24-byte wire format
#![allow(unused)]
fn main() {
pub(crate) const CMDR_DATA_SIZE: usize = 24;
pub(crate) struct CommanderData {
pub(crate) cmd_hash: u64,
pub(crate) ip: IpAddr,
}
}
| Bytes | Field | Encoding |
|---|---|---|
[0:8] | cmd_hash | u64 big-endian (to_be_bytes) |
[8:24] | ip | 16 bytes, IPv6-mapped (serialize_ip) |
The From conversions are infallible (the buffer is a fixed 24 bytes): one direction writes
cmd_hash.to_be_bytes() then serialize_ip(&ip), the other reads them back and runs normalize_ip
on the IP, so an IPv4 client IP arrives at the commander as a plain IpAddr::V4. The server
produces a CommanderData (in handler.rs) and connects to the socket; the commander consumes
it and binds the socket.
The socket path
#![allow(unused)]
fn main() {
pub fn get_commander_unix_socket_path(config_dir: &Path) -> PathBuf {
resolve_path(config_dir).join("ruroco.socket")
}
}
Both the server (when deciding where to connect) and the commander (when deciding where to bind)
call this with their socket directory, so they always agree: <resolved socket dir>/ruroco.socket.
resolve_path is applied first so a relative dir resolves consistently on both sides. That socket
directory is config_dir by default, or the optional socket_dir field when set (e.g. a systemd
RuntimeDirectory like /run/ruroco); both sides read the same field, so they stay in agreement.
One config file, two views
The socket directory (config_dir, or socket_dir when set) is the one configuration value that
must match between the two processes (otherwise they resolve different socket paths and the IPC
silently breaks). It is therefore kept in a single shared config.toml file read by both. But the two processes do not share a config struct: each
deserializes the same file through its own view, declaring only the fields it uses and ignoring the
rest.
server::config::ConfigServerreads the server-only fields (ips, rate limit, clock skew) plusconfig_dir. See Config and keys.commander::config::ConfigCommanderreadsconfig_dir(plus the optionalsocket_dir) and the commander-onlysocket_user/socket_group. See Commander.
The command set is a separate file again (commands.toml, ConfigCommands), read only by the
commander, so the network-facing server never loads it.
Client Architecture Overview
The ruroco client is a CLI program that builds and sends a single encrypted UDP datagram to a ruroco server. It never opens a return channel and never learns the actual shell command that the server will run: it only transmits a Blake2b-64 hash of a command name. This document describes the client entry point, the subcommand dispatch model, the configuration-directory model, and the invariants that hold across the whole client.
Entry points: run_client and run_client_send
Both entry points live in src/client/mod.rs and take an already-parsed
CliClient value (clap parses argv into CliClient in the binary’s main).
#![allow(unused)]
fn main() {
pub fn run_client(client: CliClient) -> anyhow::Result<()>
pub fn run_client_send(client: CliClient) -> anyhow::Result<()>
}
run_client
run_client is the full dispatcher used by the standard client binary. Its steps:
- Resolve the configuration directory with
config::get_conf_dir()?. - Acquire a single-instance PID lock at
<conf_dir>/client.lockviaClientLock::acquire(...). The lock is held in a_lockbinding for the whole function body; itsDropimpl removes the lock file on return. - Match on
client.command(aCommandsClient) and dispatch:Gen(_)buildsGenerator::create()?and calls.gen()?.Send(send_command)buildsSender::create(send_command)?and calls.send().Update(update_command)builds anUpdaterfrom the command’sforce,version,bin_path, andserverfields and calls.update().Wizard(_)runsWizard::create().run().Reseed(_)callsCounter::reseed(Sender::get_counter_path()?, now_nanos()?)?, logsCounter reseeded, and returnsOk(()).
run_client_send
run_client_send is a narrowed entry point that only accepts the Send
subcommand. It does not acquire the lock and does not resolve the conf
dir itself. For any non-Send command it returns an error with the exact text
Invalid command for run_client_send. This path exists for callers (for example
the GUI/Android side) that have already established their own single-instance
guarantees and only ever want to send.
The CommandsClient enum and dispatch table
CommandsClient (defined in src/client/config/mod.rs) is the clap subcommand
enum carried inside CliClient. Each variant wraps a command struct from
src/client/config/commands.rs.
| Variant | Wrapped struct | Handler in run_client | Effect |
|---|---|---|---|
Gen | GenCommand | Generator::gen() | Print a fresh base64 AES key with embedded key id |
Send | SendCommand | Sender::send() | Build and send the encrypted UDP packet |
Update | UpdateCommand | Updater::update() | Self-update the client (or server) binary |
Wizard | WizardCommand | Wizard::run() | Interactive server-side setup |
Reseed | ReseedCommand | Counter::reseed() | Reset the replay counter to now_nanos() |
Only Send and Gen belong to the core/config/send subsystems documented in
this book section. Update and Wizard live in sibling modules.
Configuration-directory model
Every persistent client artifact lives under one configuration directory,
resolved exactly once per invocation by config::get_conf_dir():
<conf_dir>/client.lock: the single-instance PID lock (seelock.rs).<conf_dir>/counter: the replay counter, a raw big-endianu128(seecounter.rs, path produced bySender::get_counter_path()).
On Linux, get_conf_dir() prefers the RUROCO_CONF_DIR environment variable,
then $HOME/.config/ruroco, then the current working directory. It creates the
directory if it is missing. Tests set RUROCO_CONF_DIR to a tempfile::tempdir()
to isolate state. See config.md for the full resolution logic.
Invariants
These hold across the entire client and are enforced by code and by the project rules:
- Hashes only, never commands.
ClientData::createstorescmd_hash: blake2b_u64(command). The plaintext that gets encrypted contains the 8-byte hash, not the command string, so the wire never carries a command name. - Unidirectional. The client only ever calls
socket.send. It never reads a response; the server never sends one. - Fixed packet geometry. The encrypted plaintext (
ClientData::serialize) is exactlyPLAINTEXT_SIZE = 58bytes. The full datagram isMSG_SIZE = 94bytes: an 8-byte key id followed by an 86-byte ciphertext block (CIPHERTEXT_SIZE = 86= 12-byte IV + 16-byte GCM tag + 58-byte ciphertext). - IPv6-mapped storage. All IP addresses are serialized as 16 bytes; the unset source IP is all-zero (16 zero bytes).
- Monotonic counter. The counter is a nanosecond timestamp seeded to
now_nanos()and incremented (overflow-checked) and persisted on every send, so the server’s replay floor only moves forward. - No panics in production. All fallible paths return
anyhow::Result<T>and add context with.with_context(...);.unwrap()/.expect()appear only in tests. pub(crate)overpub. Internal items are crate-private; only the handful of types that the binaries and GUI need (for exampleCliClient,SendCommand,Sender,Generator,Counter) arepub.
Type relationships
classDiagram
direction TB
class CliClient {
+CommandsClient command
}
class CommandsClient {
<<enum>>
Gen(GenCommand)
Send(SendCommand)
Update(UpdateCommand)
Wizard(WizardCommand)
Reseed(ReseedCommand)
}
class SendCommand {
+String address
+String key
+String command
+bool permissive
+Option~String~ ip
+bool ipv4
+bool ipv6
+u64 send_delay_ms
}
class Sender {
+SendCommand cmd
+DataParser data_parser
+Counter counter
+create(SendCommand) Result~Sender~
+send() Result
+get_counter_path() Result~PathBuf~
}
class Generator {
+create() Result~Generator~
+gen() Result~String~
}
class Counter {
-PathBuf path
-u128 count
+create_and_init(PathBuf, u128) Result~Counter~
+count() u128
+inc() Result
+reseed(PathBuf, u128) Result
}
class ClientLock {
-PathBuf path
-Option~File~ file
+acquire(PathBuf) Result~ClientLock~
}
CliClient *-- CommandsClient
CommandsClient --> SendCommand : Send variant
Sender *-- SendCommand
Sender *-- Counter
run_client ..> CliClient : consumes
run_client ..> ClientLock : holds for lifetime
run_client ..> Sender : Send
run_client ..> Generator : Gen
run_client ..> Counter : Reseed
run_client dispatch flow
flowchart TD
A[run_client CliClient] --> B[get_conf_dir]
B --> C[ClientLock::acquire conf_dir/client.lock]
C --> D{match client.command}
D -->|Gen| E[Generator::create then gen]
D -->|Send| F[Sender::create then send]
D -->|Update| G[Updater::create then update]
D -->|Wizard| H[Wizard::create then run]
D -->|Reseed| I[Counter::reseed get_counter_path, now_nanos]
E --> Z[Ok]
F --> Z
G --> Z
H --> Z
I --> J[info Counter reseeded]
J --> Z
Z --> K[_lock drops, client.lock removed]
Client Configuration and CLI Schema
This chapter documents the two files that define the client’s command-line interface and its configuration-directory resolution:
src/client/config/mod.rs: the top-level clap parserCliClient, theCommandsClientsubcommand enum, andget_conf_dir.src/client/config/commands.rs: the per-subcommand argument structs (GenCommand,ReseedCommand,SendCommand,UpdateCommand,WizardCommand) and theDefaultimpl forSendCommand.
config/mod.rs
Constant
#![allow(unused)]
fn main() {
pub(crate) const DEFAULT_COMMAND: &str = "default";
}
This is the fallback command name used when --command is not supplied to
send, and the value SendCommand::default() uses for its command field.
CliClient
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct CliClient {
#[command(subcommand)]
pub(crate) command: CommandsClient,
}
}
CliClient is the root clap Parser. It carries the chosen subcommand in
command. #[command(version, ...)] wires up --version and --help. The
field is pub(crate): external crates construct a CliClient only by parsing
(for example CliClient::parse_from(...) or CliClient::try_parse_from(...)).
CommandsClient
#![allow(unused)]
fn main() {
#[derive(Debug, Subcommand)]
pub(crate) enum CommandsClient {
/// Generate a shared AES key (base64 with embedded key id).
Gen(GenCommand),
/// Send a command to a specific address.
Send(SendCommand),
/// Update the client binary
Update(UpdateCommand),
/// Run the wizard to set up the server side.
Wizard(WizardCommand),
/// Reseed the replay-protection counter to the current timestamp.
Reseed(ReseedCommand),
}
}
The doc comment on each variant becomes the subcommand’s help text. The variant
names map to lowercase subcommand names (gen, send, update, wizard,
reseed). run_client matches exhaustively on this enum (see overview.md).
get_conf_dir
#![allow(unused)]
fn main() {
pub(crate) fn get_conf_dir() -> anyhow::Result<PathBuf>
}
This is a thin platform dispatcher:
- On Linux it calls
get_conf_dir_linux(). - On Android it calls
get_conf_dir_android(), which delegates toAndroidUtil::create()?.get_conf_dir(). - On every other platform it returns
Err(anyhow!("unsupported platform")).
Linux resolution logic
#![allow(unused)]
fn main() {
#[cfg(target_os = "linux")]
fn get_conf_dir_linux() -> anyhow::Result<PathBuf>
}
The directory is chosen in strict priority order:
- If the
RUROCO_CONF_DIRenvironment variable is set, use it verbatim as the path. This is the hook tests use to isolate state. - Otherwise, if
HOMEis set, use$HOME/.config/ruroco. - Otherwise, fall back to the current working directory
(
env::current_dir()), adding the contextCould not determine config diron failure.
After selecting the path, the function calls
fs::create_dir_all(&path) and adds the context Could not create config dir
if that fails. It then returns the path. Two consequences worth noting:
- Calling
get_conf_dir()has the side effect of creating the directory tree. - If the chosen path cannot be created (for example because a parent component is
a regular file), the function returns an error containing
Could not create config dir.
Tests in config/mod.rs
The inline test module verifies: --help produces clap’s DisplayHelp error;
the env-var, $HOME, and no-HOME resolution branches; the create-failure path
(pointing RUROCO_CONF_DIR at a path under /etc/hostname, which is a file);
and SendCommand::default() field values.
config/commands.rs
This file defines one struct per subcommand. Empty structs exist so that clap can
still attach --help to subcommands that take no arguments.
GenCommand
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
pub(crate) struct GenCommand {}
}
No arguments. Selecting gen runs the key generator.
ReseedCommand
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
pub(crate) struct ReseedCommand {}
}
No arguments. Selecting reseed rewrites the counter file to now_nanos().
SendCommand
This is the main command struct and the only one that is pub (re-exported as
crate::client::config::SendCommand), because Sender and external callers
construct it directly.
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
pub struct SendCommand {
#[arg(short, long)]
pub address: String,
#[arg(short, long)]
pub key: String,
#[arg(short, long, default_value = DEFAULT_COMMAND)]
pub command: String,
#[arg(short = 'e', long)]
pub permissive: bool,
#[arg(short, long)]
pub ip: Option<String>,
#[arg(short = '4', long)]
pub ipv4: bool,
#[arg(short = '6', long)]
pub ipv6: bool,
#[arg(short = 'd', long, default_value = "50")]
pub send_delay_ms: u64,
}
}
Field-by-field:
| Field | Flags | Type | Default | Meaning |
|---|---|---|---|---|
address | -a, --address | String | (required) | Destination to send the command to. A hostname, IPv4, or IPv6 literal. A missing port is filled in by Sender::ensure_port (default port 80). |
key | -k, --key | String | (required) | Base64 key with embedded key id, the output of gen or the UI. Decoded into an 8-byte id plus a 32-byte AES key. |
command | -c, --command | String | "default" | The command name to invoke. Only its Blake2b-64 hash is sent. |
permissive | -e, --permissive | bool | false | Allow permissive IP validation: the server-side source IP need not match the provided --ip. Inverted into the packet’s strict flag (strict = !permissive). |
ip | -i, --ip | Option<String> | None | Optional source IP (or CIDR-like literal) from which the command is claimed to be sent. Parsed with .parse(); an unparsable value silently becomes “no source IP”. |
ipv4 | -4, --ipv4 | bool | false | Restrict the destination to IPv4 addresses. |
ipv6 | -6, --ipv6 | bool | false | Restrict the destination to IPv6 addresses. |
send_delay_ms | -d, --send-delay-ms | u64 | 50 | Milliseconds to sleep between datagrams when more than one destination IP is used (for example sending to both an IPv4 and an IPv6 address). |
Notes:
- The
--iphelp text suggests using-6ei "dead:beef:dead:beef::/64"to allow a whole IPv6 network, and gives a one-liner to derive that automatically fromapi64.ipify.org. ipv4andipv6together (or neither) mean “no family restriction”; the resolver treatsipv4 == ipv6as the undefined case. Seesend.mdfor the exact family-filter table.
SendCommand Default impl
#![allow(unused)]
fn main() {
impl Default for SendCommand {
fn default() -> SendCommand {
SendCommand {
address: "127.0.0.1:1234".to_string(),
key: "FFFFFFFF...DEADBEEF...".to_string(), // 80-char base64 placeholder
command: DEFAULT_COMMAND.to_string(),
permissive: false,
ip: None,
ipv4: false,
ipv6: false,
send_delay_ms: 50,
}
}
}
}
The default key is a fixed 80-character base64 placeholder (an all-F key id
followed by repeated DEADBEEF). The Default impl exists primarily so tests can
build a SendCommand with struct-update syntax (SendCommand { key, ..Default::default() }).
Whenever a field is added to SendCommand, this impl must be updated too.
UpdateCommand
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
pub(crate) struct UpdateCommand {
#[arg(short, long)]
pub(crate) force: bool,
#[arg(short, long)]
pub(crate) version: Option<String>,
#[arg(short, long)]
pub(crate) bin_path: Option<PathBuf>,
#[arg(short, long)]
pub(crate) server: bool,
}
}
| Field | Flags | Type | Meaning |
|---|---|---|---|
force | -f, --force | bool | Force the update even when versions match. |
version | -v, --version | Option<String> | Target version (for example v0.14.2). |
bin_path | -b, --bin-path | Option<PathBuf> | Directory where binaries are written. |
server | -s, --server | bool | Update the server-side binary instead of the client. |
These fields are unpacked by run_client and passed to Updater::create.
WizardCommand
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
pub(crate) struct WizardCommand {
#[arg(short, long)]
pub(crate) force: bool,
}
}
A single -f/--force flag. The wizard subsystem consumes it.
Client Send Subsystem
The send subsystem turns a parsed SendCommand into one or more encrypted UDP
datagrams on the wire. It is split across three files:
src/client/send/mod.rs: the module facade.src/client/send/core.rs: theSenderstruct, construction, port normalization, thesendloop, and plaintext assembly.src/client/send/network.rs: destination-IP resolution and the per-datagram socket send.
send/mod.rs
#![allow(unused)]
fn main() {
pub mod core;
mod network;
pub use core::Sender;
}
core is public so the rest of the crate can reach Sender internals through
super; network is private and only adds impl Sender blocks. The single
re-export pub use core::Sender is what the rest of the client imports.
send/core.rs
Sender
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct Sender {
pub(super) cmd: SendCommand,
pub(super) data_parser: DataParser,
pub(super) counter: Counter,
}
}
cmd: the parsedSendCommand(withaddressalready port-normalized).data_parser: aDataParserbuilt fromcmd.key. It owns theCryptoHandlerthat holds the 32-byte AES key and the 8-byte key id, and performs encryption plus the key-id prepend.counter: the replayCounter, loaded from or seeded into<conf_dir>/counter.
All three fields are pub(super) so network.rs (same module) can read them.
Sender::create
#![allow(unused)]
fn main() {
pub fn create(mut cmd: SendCommand) -> anyhow::Result<Self>
}
Steps:
- Normalize the destination:
cmd.address = Self::ensure_port(cmd.address, 80). - Compute the counter path with
Self::get_counter_path()?and logLoading counter from <path> .... - Build the
DataParserwithDataParser::create(&cmd.key)?. This is where an invalid key fails early (for exampleKey too shortfor an 8-byte input). - Build the counter with
Counter::create_and_init(counter_path, now_nanos()?)?, which reads the existing file or seeds it to the current nanosecond timestamp.
Sender::ensure_port
#![allow(unused)]
fn main() {
fn ensure_port(address: String, default_port: u16) -> String
}
Normalizes the destination string so it always carries a port:
- If
addressstarts with[(an IPv6 literal): keep it as-is when it already contains]:(a port is present), otherwise append:<default_port>. So[::1]becomes[::1]:34020, while[::1]:1234is unchanged. - Else if
addresscontains:(an IPv4 with port like1.2.3.4:5678, or a bare IPv6): keep it as-is. - Else (a hostname or a bare IPv4): append
:<default_port>, so127.0.0.1becomes127.0.0.1:34020.
The default port passed in create is 34020 (common::DEFAULT_PORT), matching the server’s
default listen port.
Sender::get_counter_path
#![allow(unused)]
fn main() {
pub fn get_counter_path() -> anyhow::Result<PathBuf>
}
Returns resolve_path(&get_conf_dir()?).join("counter"). It resolves the conf
dir, canonicalizes it via resolve_path, and appends counter. This is also the
path run_client passes to Counter::reseed for the reseed subcommand.
Sender::send
#![allow(unused)]
fn main() {
pub fn send(&mut self) -> anyhow::Result<()>
}
The send loop:
- Log
Connecting to udp://<address>, using <openssl version> .... - Resolve destinations with
self.get_destination_ips()?(see below). This returns the validated, family-filtered list ofIpAddr. - Log the discovered IPs.
- Iterate the IPs with their index
i. For every IP after the first (i > 0), ifsend_delay_ms > 0, sleepDuration::from_millis(send_delay_ms)before sending. Then callself.send_data(*destination_ip)?.
So with both an IPv4 and an IPv6 destination, two datagrams are sent with a delay between them; with one destination, no delay is applied.
Sender::get_data_to_encrypt
#![allow(unused)]
fn main() {
pub(super) fn get_data_to_encrypt(
&self,
destination_ip: IpAddr,
) -> anyhow::Result<[u8; PLAINTEXT_SIZE]>
}
Builds the 58-byte plaintext for one destination:
#![allow(unused)]
fn main() {
ClientData::create(
&self.cmd.command, // hashed to cmd_hash (Blake2b-64)
!self.cmd.permissive, // permissive -> strict inversion
self.cmd.ip.clone().and_then(|d| d.parse().ok()), // optional source IP
destination_ip, // this destination
self.counter.count(), // current counter value
)?
.serialize()
}
Two important transforms happen here:
- permissive -> strict inversion. The user-facing flag is
permissive; the wire field isstrict.strict = !permissive. Whenpermissiveisfalse(the default),strictistrueand the server enforces that the real source IP matches the claimed--ip. - Best-effort source IP.
self.cmd.ipis parsed with.parse().ok(); an unparsable string becomesNone(no source IP), which serializes as 16 zero bytes.
The ClientData plaintext layout (from ClientData::serialize) is exactly
PLAINTEXT_SIZE = 58 bytes:
| Offset | Size | Field |
|---|---|---|
| 0..1 | 1 | version (PROTOCOL_VERSION byte, currently 1) |
| 1..9 | 8 | cmd_hash (Blake2b-64 of the command name, big-endian) |
| 9..25 | 16 | counter (u128, big-endian) |
| 25..26 | 1 | strict (0 or 1) |
| 26..42 | 16 | src_ip (IPv6-mapped, all zero if None) |
| 42..58 | 16 | dst_ip (IPv6-mapped) |
send/network.rs
This file adds two impl Sender methods plus a small context helper.
Sender::get_destination_ips
#![allow(unused)]
fn main() {
pub(super) fn get_destination_ips(&self) -> anyhow::Result<Vec<IpAddr>>
}
- Resolve
cmd.addresswithto_socket_addrs(). On failure the error carries the contextCould not resolve hostname for <address>. - Split the resolved
SocketAddrs into IPv4 and IPv6 lists. - Let
use_ip_undef = (cmd.ipv4 == cmd.ipv6), i.e. the family is “undefined” when both flags are set or both are unset. - Select results by matching on
(first IPv4, first IPv6):
| Condition | Result |
|---|---|
Both families present and use_ip_undef | [ipv4, ipv6] (both) |
Only IPv4 present and use_ip_undef | [ipv4] |
Only IPv6 present and use_ip_undef | [ipv6] |
cmd.ipv6 set and an IPv6 exists | [ipv6] |
cmd.ipv4 set and an IPv4 exists | [ipv4] |
cmd.ipv6 set but no IPv6 | error Could not find any IPv6 address for <address> |
cmd.ipv4 set but no IPv4 | error Could not find any IPv4 address for <address> |
| nothing resolved | error Could not find any IPv4 or IPv6 address for <address> |
The “undefined” rows take the first address of each available family, so a dual- stack hostname yields two datagrams.
Sender::send_data
#![allow(unused)]
fn main() {
pub(super) fn send_data(&mut self, ip: IpAddr) -> anyhow::Result<()>
}
The single-datagram path:
self.counter.inc()?: increment (overflow-checked) and persist the counter to disk before building the packet. This advances the server’s replay floor monotonically and means every datagram, even the second one in a dual-stack send, carries a strictly larger counter.- Pick the bind address by family:
0.0.0.0:0for IPv4,[::]:0for IPv6. - Log
Connecting to <ip>.... self.get_data_to_encrypt(ip)?builds the 58-byte plaintext.self.data_parser.encode(&data_to_encrypt)?produces the 94-byte datagram.- Bind a
UdpSocketto the bind address,connecttocmd.address, andsendthe bytes. Each of the three socket calls adds the contextCould not connect/send data to <address>viaSelf::socket_ctx. - Log
Sent command <command> from <bind_address> to udp://<address>.
Note that the datagram is connected to cmd.address (the original, possibly
hostname-or-literal string), while the family of the resolved ip only decides
the local bind address.
Sender::socket_ctx
#![allow(unused)]
fn main() {
pub(super) fn socket_ctx<E: std::fmt::Debug>(val: E) -> String
}
Returns format!("Could not connect/send data to {val:?}"), the shared context
string for the three socket operations.
Packet assembly: from 58 bytes to the 94-byte datagram
The encryption and framing happen in the common crate, driven by the client’s
DataParser.
- Encrypt (
CryptoHandler::encrypt): AES-256-GCM-SIV with a freshly randomized 12-byte IV. The outputCIPHERTEXT_SIZE = 86bytes is laid out as[IV (12)] [GCM tag (16)] [ciphertext (58)]. The ciphertext is the same length as the plaintext (GCM is a stream cipher), so12 + 16 + 58 = 86. - Frame / key_id prepend (
DataParser::encode): prepend the 8-byte key id in front of the 86-byte ciphertext block, givingMSG_SIZE = 94bytes:[key_id (8)] [IV (12)] [tag (16)] [ciphertext (58)]. The key id lets the server pick the right shared key before attempting decryption.
So the full datagram geometry is:
94 bytes total
= 8 key_id
+ 86 ciphertext block
= 12 IV
+ 16 GCM tag
+ 58 encrypted ClientData
Send sequence diagram
sequenceDiagram
participant R as run_client
participant S as Sender
participant N as get_destination_ips
participant C as Counter
participant D as DataParser / CryptoHandler
participant K as UdpSocket
R->>S: Sender::create(send_command)
S->>C: Counter::create_and_init(path, now_nanos)
R->>S: send()
S->>N: resolve cmd.address, filter by ipv4/ipv6
N-->>S: Vec<IpAddr> (validated)
loop for each destination IP (delay send_delay_ms after the first)
S->>C: inc() then persist counter (u128 big-endian)
S->>S: get_data_to_encrypt(ip)
Note over S: ClientData::create(cmd, !permissive, src_ip, dst_ip, counter)<br/>serialize -> 58 bytes
S->>D: encode(58-byte plaintext)
Note over D: AES-256-GCM-SIV encrypt -> 86-byte (IV+tag+ct)<br/>prepend 8-byte key_id -> 94-byte datagram
D-->>S: [u8; 94]
S->>K: bind 0.0.0.0:0 or [::]:0, connect(address), send(datagram)
Note over K: one UDP datagram, no response read
end
S-->>R: Ok
Counter, Lock, Generator, and Util
This chapter covers the four leaf modules of the client core:
src/client/counter.rs: the persisted, monotonic replay counter.src/client/lock.rs: the PID-based single-instance lock.src/client/gen.rs: the AES key generator.src/client/util.rs: filesystem permission helpers.
counter.rs
The counter is the client side of replay protection. It is a u128 nanosecond
timestamp persisted to <conf_dir>/counter as 16 raw big-endian bytes. Every
send increments and rewrites it, so the value the server sees as its replay floor
only ever moves forward.
Counter
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct Counter {
path: PathBuf,
count: u128,
}
}
Both fields are private. path is the on-disk file; count is the in-memory
value.
Counter::create_and_init
#![allow(unused)]
fn main() {
pub fn create_and_init(path: PathBuf, initial: u128) -> anyhow::Result<Self>
}
Constructs the counter and loads its starting value:
- Build
Self { path, count: 0 }. - Try
read(). If reading the existing file succeeds,countis set to the stored value (the file wins). - If
read()fails (typically because the file does not exist yet), setcount = initialandwrite()it, propagating any write error.
Callers pass now_nanos() as initial, so a brand-new counter is seeded to the
current nanosecond timestamp; a pre-existing counter keeps its persisted value.
Counter::count
#![allow(unused)]
fn main() {
pub(crate) fn count(&self) -> u128
}
Returns the current in-memory value. Sender::get_data_to_encrypt uses this to
fill the packet’s counter field.
Counter::inc
#![allow(unused)]
fn main() {
pub(crate) fn inc(&mut self) -> anyhow::Result<()>
}
Increments with checked_add(1). On overflow (the value has reached
u128::MAX) it returns an error reading
counter overflow: value has reached u128::MAX (<MAX>) and cannot be incremented
rather than wrapping. On success it persists the new value via write().
Sender::send_data calls this before assembling each datagram.
Counter::reseed
#![allow(unused)]
fn main() {
pub fn reseed(path: PathBuf, value: u128) -> anyhow::Result<()>
}
Constructs a throwaway Counter { path, count: value } and writes it, overwriting
whatever was on disk. This is the reseed subcommand’s mechanism; run_client
calls it with now_nanos() to reset the counter to the current time.
Private persistence helpers
#![allow(unused)]
fn main() {
fn write(&self) -> anyhow::Result<()>
fn read(&mut self) -> anyhow::Result<()>
}
writecallsFile::create(path)(truncating) andwrite_all(&count.to_be_bytes()), with contextsCould not create counter file <path>andCould not write counter file <path>.readopens the file,read_exactinto a[0u8; 16]buffer, thenu128::from_be_bytes. Contexts:Could not open counter file <path>andCould not read counter file <path>.
Gotchas:
- The format is exactly 16 raw bytes (no text, no newline). Any tooling that
inspects the file must treat it as a big-endian
u128. - Because the value is a nanosecond timestamp, gaps between successive counters are expected and normal; the counter is not a sequential message index.
create_and_initwill not reset an existing file: re-initializing reads the stored value and ignoresinitial.
lock.rs
The lock guarantees at most one client run touches the conf dir at a time. It is a
PID file at <conf_dir>/client.lock with automatic stale-lock cleanup.
ClientLock
#![allow(unused)]
fn main() {
pub(crate) struct ClientLock {
path: PathBuf,
file: Option<File>,
}
}
path is the lock file path; file is the held handle (wrapped in Option so
Drop can take and close it before removing the file, which matters on Windows).
ClientLock::acquire
#![allow(unused)]
fn main() {
pub(crate) fn acquire(path: PathBuf) -> anyhow::Result<Self>
}
- Try
Self::open(&path), which usesOpenOptions::new().create_new(true).write(true)so it fails withAlreadyExistsif the file is already there. - On
AlreadyExists: read the file, parse its contents as au32PID. If that PIDis_pid_running, bail withClient already running (lock at <path>). Otherwise the lock is stale: remove the file and re-openit, adding the contextClient lock unavailable at <path> after cleanupon failure. - On any other open error: bail with
Client lock unavailable at <path>: <e>. - Write the current process id (
std::process::id()) into the file (best-effort: the result ofwriteln!is ignored) and return the heldClientLock.
ClientLock::open
#![allow(unused)]
fn main() {
fn open(path: &PathBuf) -> io::Result<File>
}
The create_new(true) open primitive that makes acquisition atomic: it both
creates the file and signals contention via AlreadyExists.
is_pid_running (per platform)
#![allow(unused)]
fn main() {
fn is_pid_running(pid: u32) -> bool
}
Implemented behind #[cfg(target_os = ...)]:
- Linux: checks whether
/proc/<pid>exists. - Android: always
false(the app runs at most once, so any existing lock is treated as stale). - macOS: runs
ps -p <pid>and checks for success. - Windows: runs
tasklist /FI "PID eq <pid>"and checks whether the PID appears in the output. - Other: always
true(conservatively assume the owner is alive, so an existing file blocks).
Drop for ClientLock
#![allow(unused)]
fn main() {
impl Drop for ClientLock {
fn drop(&mut self) {
let _ = self.file.take();
let _ = remove_file(&self.path);
}
}
}
On drop it closes the file handle first (Windows-friendly) and then removes the
lock file. Because run_client holds the lock in a _lock binding for the whole
call, the file is removed when the function returns, even on the error path.
Gotchas:
- A lock with non-numeric or unparsable contents is treated as stale and cleaned
up (the PID parse simply yields
None). - Acquisition fails with
Client lock unavailableif the parent directory does not exist, sincecreate_newcannot create the file.
gen.rs
Generator
#![allow(unused)]
fn main() {
pub struct Generator {}
}
A zero-field handle; it carries no state and exists so key generation has a
consistent create/gen shape matching the other client subsystems.
Generator::create
#![allow(unused)]
fn main() {
pub fn create() -> anyhow::Result<Self>
}
Returns Ok(Self {}). It is fallible only for interface symmetry.
Generator::gen
#![allow(unused)]
fn main() {
pub fn gen(&self) -> anyhow::Result<String>
}
Calls CryptoHandler::gen_key()?, prints the key to stdout with print!
(no trailing newline), and returns it. The key is a base64 string of 40 raw
bytes: an 8-byte random key id concatenated with a 32-byte (256-bit) random AES
key. Decoded, that is exactly 40 bytes; the inline test asserts
key_decoded.len() == 40. This base64 string is what the user passes back via
send --key.
util.rs
A single filesystem helper used elsewhere in the client.
set_permissions
#![allow(unused)]
fn main() {
pub(crate) fn set_permissions(path: &str, permissions_mode: u32) -> anyhow::Result<()>
}
Reads the file metadata (context Could not get <path> meta data), sets the Unix
mode bits to permissions_mode via PermissionsExt::set_mode, and applies them
with fs::set_permissions (context Could not set file permissions for <path>).
It is Unix-specific (std::os::unix::fs::PermissionsExt). The inline tests verify
round-tripping 0o644 and 0o600, and that a nonexistent path returns an error
containing Could not get.
Client Self-Update
The client self-update subsystem lets ruroco-client (and, with --server, the server-side
binaries) replace themselves in place from signed GitHub releases. It lives in
src/client/update/ and is split into three files:
mod.rs: theUpdatertype, version comparison, and the per-arch/OS download orchestration.github.rs: GitHub releases API lookup, the embedded release public key, and binary-name constants.filesystem.rs: download bytes, verify the Ed25519 signature, swap the on-disk binary safely, and set permissions/ownership.
The defining security property: every downloaded binary is verified against an Ed25519
signature before it is written to disk. The public key is embedded into the client at build
time so a tampered download cannot strip or swap it. If the .sig asset is missing or the
signature does not match, the update aborts and the existing binary is left untouched. As a
result, the client can only update to releases that ship signatures (v0.14.0 and later).
Update flow overview
sequenceDiagram
participant U as User
participant Up as Updater
participant GH as GitHub Releases API
participant CDN as GitHub asset CDN
participant V as verify_ed25519
participant FS as Filesystem
U->>Up: ruroco-client update [--force] [--version V] [--server]
Up->>Up: current_version = "v" + CARGO_PKG_VERSION
alt not force and version == current
Up-->>U: "Already using version ..." (return Ok)
end
Up->>GH: GET releases_url (user-agent rust-client)
GH-->>Up: [GithubApiData { tag_name, assets }]
Up->>Up: pick first release, or the one matching --version
alt not force and tag_name == current
Up-->>U: "Already using version ..." (return Ok)
end
Note over Up: build asset names from tag_name, ARCH, OS
loop each target binary (server: server+commander, else client+client-ui)
Up->>CDN: download binary bytes
Up->>CDN: download .sig bytes
Up->>V: verify_ed25519(public_key_pem, bin, sig)
alt signature invalid
V-->>Up: Err
Up-->>U: bail "Signature verification failed"
end
Up->>FS: rename existing -> .old (if present)
Up->>FS: write new binary
alt write fails
FS->>FS: restore .old -> original
Up-->>U: bail "Could not write new binary"
end
Up->>FS: set_permissions (0o755 client / 0o500 server)
opt server binaries
Up->>FS: chown to ruroco:ruroco
end
end
Up-->>U: Ok
mod.rs
struct Updater
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub(crate) struct Updater {
pub(super) force: bool,
pub(super) version: Option<String>,
pub(super) bin_path: PathBuf,
pub(super) server: bool,
/// Ed25519 public key (PEM) used to verify downloaded binaries. Defaults to the
/// embedded release key; overridable in tests for hermetic signing.
pub(super) public_key_pem: Vec<u8>,
/// GitHub releases API URL. Defaults to the real endpoint; overridable in tests.
pub(super) releases_url: String,
}
}
Field responsibilities:
force: whentrue, skips both “already up to date” short-circuits and always downloads.version: an optional explicit release tag (for examplev0.14.2).Nonemeans “latest”.bin_path: the directory the binary or binaries are written into.server: whentrue, downloads and installs the server-side binaries (ruroco-serverandruroco-commander) instead of the client binaries (ruroco-clientandruroco-client-ui).public_key_pem: the Ed25519 public key in PEM form used for verification. In production it is initialized from the embeddedRELEASE_PUBLIC_KEY. It is a struct field (not a hard-coded constant inside the verify path) precisely so tests can inject a freshly generated keypair.releases_url: the releases endpoint. Defaults toGH_RELEASES_URL. Overridable so tests can point at a localTcpListenerinstead of hitting GitHub.
Updater::create
#![allow(unused)]
fn main() {
pub(crate) fn create(
force: bool,
version: Option<String>,
bin_path: Option<PathBuf>,
server: bool,
) -> anyhow::Result<Self>
}
Resolves the target directory and builds an Updater with the production defaults
(public_key_pem: RELEASE_PUBLIC_KEY.to_vec(), releases_url: GH_RELEASES_URL.to_string()).
bin_path resolution:
Some(p)that does not exist or is not a directory:bail!("{p:?} does not exist or is not a directory").Some(p)valid directory: validated viavalidate_dir_path.Noneandserver == true: defaults toSERVER_BIN_DIR(/usr/local/bin), validated.Noneandserver == false: defaults to$HOME/.local/bin, validated. Requires theHOMEenv var (Could not get home envon failure).
validate_dir_path (in filesystem.rs) creates the directory if missing, errors if the path
exists but is not a directory, and errors if it is not writable.
Updater::update
#![allow(unused)]
fn main() {
pub(crate) fn update(&self) -> anyhow::Result<()>
}
The orchestration entry point. Steps:
- Compute
current_version = format!("v{}", env!("CARGO_PKG_VERSION")). The leadingvmatters: GitHub tags arevX.Y.Z, so the comparison is tag-to-tag. - Early skip: if
!forceandSome(current_version) == self.version, log “Already using version …” and returnOk(())without any network call. - Fetch release metadata via
get_github_api_data_from(&self.releases_url, self.version.as_ref()). - Second skip: if
!forceandcurrent_version == api_data.tag_name, log and returnOk(()). This catches theversion == None(latest) case where the latest already matches what is installed. - Branch on
self.server:- server: download
server-{tag}-{ARCH}-{OS}intoSERVER_BIN_NAME(ruroco-server) with mode0o500and ownerruroco, thencommander-{tag}-{ARCH}-{OS}intoCOMMANDER_BIN_NAME(ruroco-commander) with the same mode/owner. - client: download
client-{tag}-{ARCH}-{OS}intoCLIENT_BIN_NAME(ruroco-client) with mode0o755and no chown, thenclient-ui-{tag}-{ARCH}-{OS}intoCLIENT_UI_BIN_NAME(ruroco-client-ui) with mode0o755and no chown.
- server: download
Asset names are built from api_data.tag_name, std::env::consts::ARCH, and
std::env::consts::OS, so the client only ever requests the artifact matching the host it runs
on. The signature asset name is the binary asset name plus .sig.
Updater::get_download_url
#![allow(unused)]
fn main() {
fn get_download_url(
&self,
assets: &[GithubApiAsset],
client_bin_name: &String,
) -> anyhow::Result<String>
}
Finds the asset whose name equals the requested asset name and returns its
browser_download_url. If no asset matches, errors with Could not find {client_bin_name}. This
is the point at which a release that does not ship the expected .sig asset fails: the .sig
lookup returns an error and the whole update aborts before anything is written.
Gotchas
- The version comparison is purely string equality of
vX.Y.Ztags, not semantic-version ordering. “Newer” effectively means “different from current”.--forcebypasses the checks entirely and can therefore reinstall or downgrade. - The download targets the exact
ARCH/OSof the running binary. Cross-installing for a different platform is not supported through this path.
github.rs
Constants
#![allow(unused)]
fn main() {
pub(super) const GH_RELEASES_URL: &str = "https://api.github.com/repos/beac0n/ruroco/releases";
pub(super) const SERVER_BIN_DIR: &str = "/usr/local/bin";
pub(super) const COMMANDER_BIN_NAME: &str = "ruroco-commander";
pub(super) const SERVER_BIN_NAME: &str = "ruroco-server";
pub(super) const CLIENT_BIN_NAME: &str = "ruroco-client";
pub(super) const CLIENT_UI_BIN_NAME: &str = "ruroco-client-ui";
/// Ed25519 public key used to verify release binaries during self-update.
pub(super) const RELEASE_PUBLIC_KEY: &[u8] =
include_bytes!("../../../keys/ruroco-release-ed25519.pub.pem");
}
RELEASE_PUBLIC_KEY is embedded at compile time from keys/ruroco-release-ed25519.pub.pem. The
matching private key is held only as a CI secret (RUROCO_SIGNING_KEY) and signs the binaries at
release time. Embedding it in the binary means a downloaded artifact cannot strip or replace the
verification key.
struct GithubApiAsset and struct GithubApiData
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct GithubApiAsset {
pub(crate) name: String,
pub(crate) browser_download_url: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct GithubApiData {
pub(crate) tag_name: String,
pub(crate) assets: Vec<GithubApiAsset>,
}
}
Minimal deserialization targets for the subset of the GitHub releases JSON that the updater
needs: the tag and the asset list with download URLs. GithubApiAsset is re-exported at the
module level (pub(crate) use github::GithubApiAsset).
Updater::get_github_api_data_from
#![allow(unused)]
fn main() {
pub(super) fn get_github_api_data_from(
releases_url: &str,
version_to_download: Option<&String>,
) -> anyhow::Result<GithubApiData>
}
Builds a ureq agent with user-agent rust-client (GitHub rejects requests without a user
agent), GETs the releases URL, and parses the JSON array of GithubApiData. Selection:
version_to_download == None: takes the first element (GitHub returns releases newest-first).version_to_download == Some(v): finds the release whosetag_name == v.
Errors with Could not find version {version_to_download:?} if no release matches.
Updater::get_github_api_data (Android only)
#![allow(unused)]
fn main() {
#[cfg(target_os = "android")]
pub(crate) fn get_github_api_data(
version_to_download: Option<&String>,
) -> anyhow::Result<GithubApiData>
}
Used only by the Android update path, which queries the releases API to locate the .apk asset
and hands it to the OS installer. APK authenticity is enforced by Android’s own package signing,
so the Ed25519 check does not apply on that path.
filesystem.rs
This file holds the verify-and-swap logic. The decision flow:
flowchart TD
A[download_and_save_bin] --> B[download binary bytes]
B --> C[download .sig bytes]
C --> D{verify_ed25519 public_key_pem, bin, sig}
D -- invalid --> E[bail: Signature verification failed]
D -- valid --> F{target binary exists?}
F -- yes --> G[rename target -> target.old]
F -- no --> H[skip rename]
G --> I[write new binary]
H --> I
I --> J{write succeeded?}
J -- no --> K[rename target.old -> target]
K --> L[bail: Could not write new binary]
J -- yes --> M[set_permissions mode]
M --> N{user_and_group given?}
N -- yes --> O[change_file_ownership to ruroco:ruroco]
N -- no --> P[done Ok]
O --> P
Updater::download_and_save_bin
#![allow(unused)]
fn main() {
pub(super) fn download_and_save_bin(
&self,
bin_url: String,
sig_url: String,
bin_name: &str,
permissions_mode: u32,
user_and_group: Option<&str>,
) -> anyhow::Result<()>
}
The full per-binary install:
target_bin_path = self.bin_path.join(bin_name).- Download the binary bytes and the signature bytes (both via
download_bytes). verify_ed25519(&self.public_key_pem, &bin_resp_bytes, &sig_bytes). On failure:Signature verification failed for {bin_name}. Verification happens before any disk write.- If the target already exists, rename it to
{target}.old(Could not rename existing binaryon failure). fs::writethe new bytes. On write failure, rename{target}.oldback to the original path (Could not recover old binaryif even that fails) and thenbail!("Could not write new binary to ..."). This is the rollback path: a failed write does not leave the host without a working binary.- On Unix:
set_permissions(target, permissions_mode), then ifuser_and_groupisSome(ug),change_file_ownership(target, ug, ug).
Updater::download_bytes
#![allow(unused)]
fn main() {
fn download_bytes(url: &str) -> anyhow::Result<Vec<u8>>
}
GETs url with ureq and reads the full response body into a Vec<u8>
(Could not get binary / Could not get bytes).
Updater::validate_dir_path
#![allow(unused)]
fn main() {
pub(super) fn validate_dir_path(dir_path: PathBuf) -> anyhow::Result<PathBuf>
}
Pattern-matched validation of the target directory:
- Does not exist:
fs::create_dir_allit (Could not create .bin directory), then return it. - Exists but is not a directory: error
{p:?} exists but is not a directory. - Not writable (per
check_if_writable): errorcan't write to {p:?}. - Otherwise: return the path.
Updater::check_if_writable
#![allow(unused)]
fn main() {
pub(super) fn check_if_writable(path: &Path) -> anyhow::Result<bool>
}
Probes writability by attempting NamedTempFile::new_in(path) and returning whether it
succeeded. This catches read-only or permission-denied target directories up front.
Gotchas
- The
.oldfile is left in place on success. Repeated updates overwrite it. It is the recovery copy used only when the new write fails. - The rollback only covers the
fs::writefailure case. A failure inset_permissionsorchange_file_ownershipafter a successful write leaves the new binary in place with the prior step’s state. - Ownership change uses the same string for user and group (
ug, ug), which is why the server units run asruroco:ruroco.
CI signing model
Releases are signed in CI; the client only trusts that one embedded key.
flowchart TB
subgraph dev[One-time setup, make gen_signing_key]
K1[openssl genpkey ed25519 -> ruroco-release-ed25519.key]
K1 --> K2[openssl pkey -pubout -> ruroco-release-ed25519.pub.pem]
K2 --> K3[commit .pub.pem]
K1 --> K4[gh secret set RUROCO_SIGNING_KEY < .key]
end
subgraph build[Client build time]
K3 --> E[include_bytes! embeds .pub.pem as RELEASE_PUBLIC_KEY]
end
subgraph ci[Release CI]
K4 --> S[sign each release binary -> .sig assets]
end
subgraph run[Client at update time]
E --> V[verify_ed25519 RELEASE_PUBLIC_KEY, binary, .sig]
S --> V
end
Key points from the Makefile gen_signing_key target and the README:
make gen_signing_keyrunsopenssl genpkey -algorithm ed25519to producekeys/ruroco-release-ed25519.key(private, gitignored, kept secret and backed up offline) andopenssl pkey ... -puboutto producekeys/ruroco-release-ed25519.pub.pem(public, committed, embedded into the client). It refuses to overwrite an existing private key.- The private key is added as the GitHub Actions secret
RUROCO_SIGNING_KEY(gh secret set RUROCO_SIGNING_KEY < keys/ruroco-release-ed25519.key). Release CI uses it to produce a.sigfor every published binary asset. - The public key is embedded into the client at build time via
include_bytes!, so verification needs no network fetch and no key the attacker can substitute. - Only
v0.14.0and later releases ship.sigassets, so those are the only versions the signature-checked update path can install. Earlier releases would fail the.siglookup and abort.
Server-Side Wizard
The wizard is the one-command server-side installer, invoked as ruroco-client wizard and
intended to run as root. It installs and starts the server-side ruroco stack on the local
host: it creates the ruroco system user, performs a forced self-update of the server binaries,
writes the three systemd units and the server config, and then reloads, enables, and starts the
services.
It lives in src/client/wizard/ and is split into:
mod.rs: the module declarations and re-export ofWizard.core.rs: theWizardtype and therunflow plus the file-writing/config helpers.wizard_systemd.rs: the hard-coded/etcpaths, the compile-time embedded unit and config bytes, and thesystemctl/useradd/Updaterhelpers.
Everything the wizard writes is embedded into the client binary at compile time via
include_bytes!, so the installer carries its own copies of the unit files and the config and
needs no extra files on disk.
Wizard run flow
sequenceDiagram
participant R as root user
participant W as Wizard
participant Up as Updater (server mode)
participant SC as systemctl / useradd
participant FS as Filesystem (/etc)
R->>W: ruroco-client wizard
W->>SC: useradd --system ruroco --shell /bin/false
W->>Up: Updater::create(force=true, None, None, server=true).update()
Note over Up: installs ruroco-server + ruroco-commander into /usr/local/bin
W->>FS: write /etc/systemd/system/ruroco.service
W->>FS: write /etc/systemd/system/ruroco-commander.service
W->>FS: write /etc/systemd/system/ruroco.socket
alt /etc/ruroco/config.toml missing
W->>FS: write config.toml
end
W->>FS: chmod 0o600 /etc/ruroco/config.toml
W->>SC: systemctl daemon-reload
W->>SC: systemctl enable ruroco.service ruroco-commander.service ruroco.socket
W->>SC: systemctl start ruroco.service ruroco-commander.service ruroco.socket
W-->>R: print post-install instructions
mod.rs
Declares the submodules and re-exports the public type:
#![allow(unused)]
fn main() {
mod core;
mod wizard_systemd;
pub(crate) use core::Wizard;
}
Wizard is the only item exposed to the rest of the crate.
core.rs
struct Wizard
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub(crate) struct Wizard {}
impl Wizard {
pub(crate) fn create() -> Self { Self {} }
}
}
A unit-like struct: it holds no state. create() exists for symmetry with other client
subsystems.
Wizard::run
#![allow(unused)]
fn main() {
pub(crate) fn run(&self) -> anyhow::Result<()>
}
The full installer, executed top to bottom (any step returning Err aborts the run via ?):
create_ruroco_user(): create therurocosystem user (seewizard_systemd.rs).update(): forced self-update of the server binaries into/usr/local/bin.write_data(RUROCO_SERVICE_FILE_PATH, RUROCO_SERVICE_FILE_DATA): writeruroco.service.write_data(COMMANDER_SERVICE_FILE_PATH, COMMANDER_SERVICE_FILE_DATA): writeruroco-commander.service.write_data(SOCKET_FILE_PATH, SOCKET_FILE_DATA): writeruroco.socket.init_config_file(): write/etc/ruroco/config.tomlif it does not already exist, then set its mode to0o600.reload_systemd_daemon():systemctl daemon-reload.enable_systemd_services():systemctl enablethe three units.start_systemd_services():systemctl startthe three units.- Print a multi-line completion banner instructing the operator to review
/etc/ruroco/config.toml, generate a key withruroco-client gen, place the key file in the configuredconfig_dir, and store the client key in their secure key store.
Wizard::init_config_file
#![allow(unused)]
fn main() {
fn init_config_file() -> anyhow::Result<()>
}
Idempotent config installer. If CONFIG_TOML_PATH (/etc/ruroco/config.toml) does not exist,
it writes the embedded CONFIG_TOML_FILE_DATA. Either way, it then calls
set_permissions(CONFIG_TOML_PATH, 0o600) (owner read/write only). This means re-running the
wizard never clobbers an operator’s edited config, but it always re-asserts the restrictive
permissions.
Wizard::write_data
#![allow(unused)]
fn main() {
fn write_data(path: &str, data: &[u8]) -> anyhow::Result<()>
}
Creates path with fs::File::create (Failed to create {path}) and writes the byte slice
(Failed to write to {path}). Used for every unit file and, conditionally, the config. Note that
File::create truncates, so the three unit files are overwritten on every run (only the config is
guarded by the existence check). Tests exercise write_data against a tempdir, including an
invalid-path negative case.
Gotchas
write_dataoverwrites unit files unconditionally; only the config is preserved across runs.- The config existence guard checks the real
/etc/ruroco/config.tomlpath; there is no injectable path here, so unit tests cover the helpers (write_data) rather than the fullrunagainst the real/etc.
wizard_systemd.rs
Hard-coded paths
#![allow(unused)]
fn main() {
pub(super) const CONFIG_TOML_PATH: &str = "/etc/ruroco/config.toml";
pub(super) const RUROCO_SERVICE_FILE_PATH: &str = "/etc/systemd/system/ruroco.service";
pub(super) const COMMANDER_SERVICE_FILE_PATH: &str = "/etc/systemd/system/ruroco-commander.service";
pub(super) const SOCKET_FILE_PATH: &str = "/etc/systemd/system/ruroco.socket";
}
These destination paths are fixed constants. The wizard writes into the real /etc tree, which is
why it must run as root and why tests cannot exercise run directly (they use the file-writing
helpers against a tempdir instead).
Embedded file data
#![allow(unused)]
fn main() {
pub(super) const CONFIG_TOML_FILE_DATA: &[u8] = include_bytes!("../../../config/config.toml");
pub(super) const RUROCO_SERVICE_FILE_DATA: &[u8] =
include_bytes!("../../../systemd/ruroco.service");
pub(super) const COMMANDER_SERVICE_FILE_DATA: &[u8] =
include_bytes!("../../../systemd/ruroco-commander.service");
pub(super) const SOCKET_FILE_DATA: &[u8] = include_bytes!("../../../systemd/ruroco.socket");
}
The unit files come from the repo systemd/ directory and the config from the repo config/
directory, all baked into the client binary at build time. There is no template substitution: the
files are written verbatim.
Wizard::update
#![allow(unused)]
fn main() {
pub(super) fn update() -> anyhow::Result<()>
}
Forced self-update of the server binaries:
#![allow(unused)]
fn main() {
Updater::create(true, None, None, true)?.update()
}
That is: force = true (always reinstall), version = None (latest), bin_path = None (defaults
to /usr/local/bin because server = true), and server = true (installs ruroco-server and
ruroco-commander with mode 0o500, owned by ruroco). See the self-update chapter for the full
download/verify/swap behavior.
Wizard::create_ruroco_user
#![allow(unused)]
fn main() {
pub(super) fn create_ruroco_user() -> anyhow::Result<()>
}
Runs useradd --system ruroco --shell /bin/false. A system account with no login shell, matching
the User=ruroco / Group=ruroco directives in ruroco.service and the socket_user /
socket_group defaults in the config. The exit status is not checked beyond the command spawning
successfully, so re-running the wizard when the user already exists does not abort the flow.
Wizard::reload_systemd_daemon, enable_systemd_services, start_systemd_services
#![allow(unused)]
fn main() {
pub(super) fn reload_systemd_daemon() -> anyhow::Result<()>
pub(super) fn enable_systemd_services() -> anyhow::Result<()>
pub(super) fn start_systemd_services() -> anyhow::Result<()>
}
Thin wrappers over systemctl:
reload_systemd_daemon:systemctl daemon-reload.enable_systemd_services:systemctl enable ruroco.service ruroco-commander.service ruroco.socket.start_systemd_services:systemctl start ruroco.service ruroco-commander.service ruroco.socket.
Each uses .status() and attaches context on spawn failure (for example Failed to start ruroco systemd services). As with useradd, a non-zero exit status from systemctl itself is not
treated as an error.
Gotchas
- All destination paths are absolute and hard-coded; the wizard is Linux/systemd specific and root-only.
- The helpers report errors only when the external command cannot be spawned, not when it exits non-zero.
Resulting systemd unit relationships
The three installed units implement socket activation: the socket owns the listening UDP file descriptor and hands it to the server, the server requires the commander, and the commander runs the actual privileged actions.
flowchart TD
SOCK[ruroco.socket\nPartOf=ruroco.service\nlistens for UDP packets]
SVC[ruroco.service\nExecStart=/usr/local/bin/ruroco-server --config /etc/ruroco/config.toml\nUser=ruroco Group=ruroco\nRequires/After: network-online.target, ruroco.socket, ruroco-commander.service]
CMD[ruroco-commander.service\nExecStart=/usr/local/bin/ruroco-commander --config /etc/ruroco/config.toml\nRequires/After: network-online.target]
CFG[/etc/ruroco/config.toml\nmode 0o600/]
SOCK -- socket activation, passes fd --> SVC
SVC -- requires + after --> CMD
SVC -- reads --> CFG
CMD -- reads, runs commands --> CFG
SVC -- forwards decrypted command over Unix socket --> CMD
Notes drawn from the embedded unit files:
ruroco.socketisPartOf=ruroco.service, so its lifecycle is tied to the server service. It provides the activation file descriptor the server picks up (the server’s config supports systemd socket activation viaLISTEN_FDS/LISTEN_PID).ruroco.servicerunsruroco-serveras the unprivilegedrurocouser with a tightly restricted sandbox (ProtectSystem=strict,RestrictAddressFamilies=AF_UNIX,CapabilityBoundingSet=CAP_NET_BIND_SERVICE, broadSystemCallFilterdeny-lists,ReadWritePaths=/etc/ruroco). It bothRequiresand is orderedAfterruroco-commander.serviceandruroco.socket.ruroco-commander.servicerunsruroco-commander, which executes the configured commands. It is the privilege-separated half: the server decrypts and validates packets, then forwards the command over a Unix socket to the commander.- Both services read
/etc/ruroco/config.toml(allowedips, socket user/group, rate limit). The commander additionally reads/etc/ruroco/commands.toml, whose[commands]section defines what it can run (for example theopen_port/close_portufwrules in the shipped sample config). The wizard writes both files with mode0o600; the command set is kept out ofconfig.tomlso the network-facing server never loads it.
GUI Overview
The ruroco GUI is an egui application built on
eframe. It is a thin view layer over src/client/: when the user
runs a saved command, the GUI builds a send CLI invocation and calls the client’s send path
directly. There is no separate networking layer in the UI. A slow or blocking send blocks the UI
thread (see Execute tab).
The same code base runs on the desktop (Linux) and on Android. The platform differences (clipboard,
soft keyboard, status-bar inset, AES-key persistence) are localized behind cfg(target_os = ...)
branches and the src/common/android JNI bridge (see Android bridge).
Entry points (src/ui/mod.rs)
The module declares the GUI submodules. android is compiled only under cfg(target_os = "android").
#![allow(unused)]
fn main() {
fn set_font_size(ctx: &eframe::egui::Context, size: f32)
pub fn run_ui() -> Result<(), Box<dyn Error>>
#[cfg(target_os = "android")]
pub fn run_ui_with_options(opts: eframe::NativeOptions, status_bar_dp: f32) -> Result<(), Box<dyn Error>>
}
set_font_size: clones the global egui style, sets every text style’s size, and writes the style back. Called with14.0on both platforms during app construction.run_ui(desktop): resolves the config dir viaconfig::get_conf_dir(), acquires aClientLockon<conf_dir>/client.lock(held for the process lifetime via_lock), then opens aneframenative window (inner size540x1200, titleruroco) and constructsRurocoApp::new(&conf_dir).run_ui_with_options(Android only): same lock + construction, but takes a caller-suppliedNativeOptions(wgpu renderer + theAndroidApphandle) and astatus_bar_dpinset height, constructing the app viaRurocoApp::new_with_status_bar(&conf_dir, status_bar_dp).
The frame-loop model
RurocoApp implements eframe::App (in app_frame.rs). eframe calls into the app once per frame
(immediate-mode GUI: the whole UI is rebuilt every frame, typically ~60 fps). All UI state lives on
RurocoApp and persists across frames and across tab switches; nothing is reset when a tab is
re-entered. This is deliberate, to support multi-step workflows (for example, generate a key on the
Dashboard, then run a command on Execute).
Each frame:
- Reserve the Android status-bar inset (
add_space(status_bar_dp)) if non-zero. - On Android only, keep the soft keyboard in sync with
egui_wants_keyboard_input(). - Draw the top tab bar (three
selectable_valuetoggles bound toactive_tab). - Dispatch to the active tab’s
renderfunction in aCentralPanel.
Desktop vs Android
| Concern | Desktop | Android |
|---|---|---|
| Entry point | run_ui() (native window) | android_main -> run_ui_with_options() (wgpu / native-activity) |
| Clipboard copy | ui.ctx().copy_text(...) | AndroidClipboard::set_text (JNI) |
| Clipboard paste | ViewportCommand::RequestPaste event, handled next frame | AndroidClipboard::get_text (JNI), applied immediately |
| AES key persistence | not persisted (load_persisted_key returns "") | AndroidPrefs SharedPreferences (ruroco/aes_key) |
| Soft keyboard | n/a | AndroidKeyboard::ensure_visible per frame |
| Status-bar inset | 0.0 | AndroidStatusBar::height_dp() |
| Update action | client::update::Updater | update_android() opens the APK download URL via an Intent |
Component model
classDiagram
direction TB
class RurocoApp {
+CommandsList commands_list
+Tab active_tab
+f32 status_bar_dp
+DashboardState dashboard
+CreateForm create
+ExecuteState execute
+new(conf_dir) Result~Self~
+new_with_status_bar(conf_dir, dp) Result~Self~
+ui(ui, frame)
}
class Tab {
<<enum>>
Dashboard
Create
Execute
}
class DashboardState {
+String config_text
+String key
+bool show_key
+Option~PasteTarget~ paste_target
+load_persisted_key() String
+save_key(key)
}
class CreateForm {
+String address
+String command
+String ip
+bool permissive
+bool ipv4
+bool ipv6
}
class ExecuteState {
+HashMap~StatusKey, Status~ status
+color_for(cmd) Color32
+set(cmd, status)
}
class CommandsList {
-Vec~CommandData~ list
-PathBuf path
+create(cfg_dir) CommandsList
+get() &[CommandData]
+add(cmd)
+set(list)
+remove(cmd)
}
class CommandData {
+String address
+String command
+bool permissive
+String ip
+bool ipv4
+bool ipv6
+String name
}
RurocoApp --> Tab : active_tab
RurocoApp --> DashboardState
RurocoApp --> CreateForm
RurocoApp --> ExecuteState
RurocoApp --> CommandsList
CommandsList o-- CommandData : list
ExecuteState ..> CommandData : keyed by StatusKey
Frame loop and tab dispatch (app_frame.rs)
flowchart TD
A[eframe calls RurocoApp::ui per frame] --> B{status_bar_dp > 0?}
B -- yes --> C[add_space status_bar_dp]
B -- no --> D
C --> D{target_os = android?}
D -- yes --> E[AndroidKeyboard::ensure_visible<br/>egui_wants_keyboard_input]
D -- no --> F
E --> F[Top panel: 3 selectable_value tabs<br/>bound to active_tab]
F --> G{match active_tab}
G -- Dashboard --> H["tabs::dashboard::render(&mut dashboard, &mut commands_list, ui)"]
G -- Create --> I["tabs::create::render(&mut create, &mut commands_list, &mut dashboard.config_text, ui)"]
G -- Execute --> J["tabs::execute::render(&mut execute, &mut commands_list, &dashboard.key, ui)"]
Note that the trait method is named ui(&mut self, ui, _frame), not the more usual eframe
update; it receives a &mut egui::Ui directly and draws a top panel plus a central panel inside
it.
App Root and State
This chapter documents the app/ module (mod.rs, dashboard_state.rs, execute_state.rs) and
the frame dispatcher app_frame.rs. Together they define the root RurocoApp, the Tab enum, the
per-tab state structs, and how state persists across frames.
src/ui/app/mod.rs
Declares the dashboard_state and execute_state submodules and re-exports their public types:
#![allow(unused)]
fn main() {
pub(crate) use dashboard_state::{DashboardState, PasteTarget};
pub(crate) use execute_state::{ExecuteState, Status, StatusKey};
}
Tab
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum Tab { Dashboard, Create, Execute }
}
Identifies the active tab. Copy + PartialEq so it can be used directly with
egui::Ui::selectable_value.
CreateForm
#![allow(unused)]
fn main() {
pub(crate) struct CreateForm {
pub(crate) address: String,
pub(crate) command: String,
pub(crate) ip: String,
pub(crate) permissive: bool,
pub(crate) ipv4: bool,
pub(crate) ipv6: bool,
}
}
The edit buffers backing the Create tab’s form fields. Defined here (alongside RurocoApp) rather
than in a *_state.rs file because it has no platform behavior.
RurocoApp
#![allow(unused)]
fn main() {
pub(crate) struct RurocoApp {
pub(crate) commands_list: CommandsList,
pub(crate) active_tab: Tab,
pub(crate) status_bar_dp: f32,
pub(crate) dashboard: DashboardState,
pub(crate) create: CreateForm,
pub(crate) execute: ExecuteState,
}
impl RurocoApp {
pub(crate) fn new(conf_dir: &Path) -> anyhow::Result<Self>
pub(crate) fn new_with_status_bar(conf_dir: &Path, status_bar_dp: f32) -> anyhow::Result<Self>
}
}
The single source of truth for all GUI state. new delegates to new_with_status_bar(conf_dir, 0.0)
(desktop has no status bar inset). Construction:
- Loads
CommandsList::create(conf_dir)(reads<conf_dir>/commands_list.toml). - Seeds
dashboard.config_textfromcommands_list.to_string()so the Dashboard’s editable config text starts as the serialized command list. active_tabstarts atTab::Dashboard.dashboard.keyis seeded fromDashboardState::load_persisted_key()(Android SharedPrefs, empty on desktop).create.commanddefaults toconfig::DEFAULT_COMMAND; the other create fields start empty/false.execute.statusstarts as an emptyHashMap.
Because the struct lives for the whole process, all of this state survives frame redraws and tab switches.
src/ui/app/dashboard_state.rs
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum PasteTarget { Key, Config }
pub(crate) struct DashboardState {
pub(crate) config_text: String,
pub(crate) key: String,
pub(crate) show_key: bool,
pub(crate) paste_target: Option<PasteTarget>,
}
impl DashboardState {
pub(crate) fn load_persisted_key() -> String
pub(crate) fn save_key(&mut self, key: String)
}
}
State for the Dashboard tab.
config_text: the editable multi-line text of the saved command list.key: the AES key string used when running commands.show_key: toggles the key field between password (masked) and plaintext display.paste_target: on desktop, paste is asynchronous (aRequestPasteviewport command is sent and the resultingPasteevent arrives a frame later); this records whether that pending paste should land in the key or the config field.take()-n when the event is handled.
load_persisted_key(): on Android reads the aes_key SharedPreference via
AndroidPrefs::get_string; Ok(None) and any error both yield String::new() (errors are logged).
On non-Android it returns String::new() unconditionally.
save_key(key): always sets self.key; on Android it additionally persists via
AndroidPrefs::put_string("aes_key", ...) (errors logged, non-fatal). The KEY_PREF constant
("aes_key") and the error import are Android-only.
src/ui/app/execute_state.rs
#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum Status { Ok, Err }
#[derive(Hash, Eq, PartialEq, Clone)]
pub(crate) struct StatusKey { command, address, ip: String, ipv4, ipv6, permissive: bool }
impl From<&CommandData> for StatusKey { fn from(c: &CommandData) -> Self }
pub(crate) struct ExecuteState { pub(crate) status: HashMap<StatusKey, Status> }
impl ExecuteState {
pub(crate) fn color_for(&self, cmd: &CommandData) -> egui::Color32
pub(crate) fn set(&mut self, cmd: &CommandData, status: Status)
}
}
Tracks the last run outcome per command for the Execute tab.
StatusKeyis derived from aCommandData’s identifying fields (everything exceptname), so a command keeps its status even thoughnameis recomputed/#[serde(skip)]. It is hashable and used as theHashMapkey.color_for: returnsGREENforStatus::Ok,REDforStatus::Err, andGRAYwhen the command has no recorded status yet (never run this session).set: records a command’s run result, inserting/overwriting byStatusKey.
The status map is in-memory only; it is not persisted, so all rows start gray on a fresh launch.
src/ui/app_frame.rs
Implements the eframe entry point:
#![allow(unused)]
fn main() {
impl eframe::App for RurocoApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame)
}
}
Per-frame logic (see the overview flowchart):
- If
status_bar_dp > 0.0,ui.add_space(self.status_bar_dp)to clear the Android status bar. - Under
cfg(all(target_os = "android", feature = "android-build")), callAndroidKeyboard::ensure_visible(ui.ctx().egui_wants_keyboard_input())to show/hide the soft keyboard, logging any error. - A top
egui::Panel::top("tabs")with a horizontal row of threeselectable_valuetoggles bound to&mut self.active_tab. - A
CentralPanelthat matches onself.active_taband calls the corresponding tabrender:Dashboard->tabs::dashboard::render(&mut self.dashboard, &mut self.commands_list, ui)Create->tabs::create::render(&mut self.create, &mut self.commands_list, &mut self.dashboard.config_text, ui)Execute->tabs::execute::render(&mut self.execute, &mut self.commands_list, &self.dashboard.key, ui)
The Create tab receives a mutable borrow of dashboard.config_text so that adding a command also
refreshes the Dashboard’s config view; the Execute tab receives a shared borrow of dashboard.key
to use as the AES key when sending.
Tabs
The three tabs are plain functions, each taking its own mutable state plus shared resources, and
each dispatched from app_frame.rs. There is no tab trait: a tab is just a render(...) function
declared in tabs/mod.rs.
flowchart TB
subgraph Dashboard
D1[dashboard.rs<br/>paste routing + Update]
D2[dashboard_config.rs<br/>config text + Reset/Save/Copy/Paste]
D3[dashboard_key.rs<br/>key gen/show/copy/paste + Reseed]
D1 --> D2
D1 --> D3
end
subgraph Create
C1[create.rs<br/>form -> CommandData -> add]
end
subgraph Execute
E1[execute.rs<br/>list rows: run / delete<br/>colors by Status]
end
DS[(DashboardState)] --- D1
CF[(CreateForm)] --- C1
ES[(ExecuteState)] --- E1
CL[(CommandsList)] --- D2
CL --- C1
CL --- E1
src/ui/tabs/mod.rs
Declares the tab and widget submodules, all pub(crate):
#![allow(unused)]
fn main() {
pub(crate) mod create;
pub(crate) mod dashboard;
pub(crate) mod dashboard_config;
pub(crate) mod dashboard_key;
pub(crate) mod execute;
pub(crate) mod widgets;
}
Dashboard
The Dashboard manages the AES key and the saved-command-list config. It is split across three files:
dashboard.rs (layout + paste routing + Update), dashboard_config.rs (config text area),
dashboard_key.rs (key controls + counter reseed).
src/ui/tabs/dashboard.rs
#![allow(unused)]
fn main() {
pub(crate) fn render(dashboard: &mut DashboardState, commands_list: &mut CommandsList, ui: &mut egui::Ui)
}
Responsibilities:
- Desktop paste completion. Scans this frame’s input events for an
egui::Event::Paste(text). Ifdashboard.paste_targetis set (a paste was requested last frame), the pasted text is routed:PasteTarget::Key->dashboard.save_key(text),PasteTarget::Config->dashboard.config_text = text.paste_targetis consumed withtake(). - Renders the config sub-view (
dashboard_config::render, givenavailable_height() * 0.45), a separator, then the key sub-view (dashboard_key::render). - A bottom-anchored full-width Update Application button. On Linux it runs
Updater::create(false, None, None, false).and_then(|u| u.update()); on Android it callsupdate_android()(see Android bridge). Errors are logged, not surfaced in the UI.
src/ui/tabs/dashboard_config.rs
#![allow(unused)]
fn main() {
pub(crate) fn render(dashboard: &mut DashboardState, commands_list: &mut CommandsList, ui: &mut egui::Ui, config_height: f32)
}
A scrollable monospace multi-line TextEdit bound to dashboard.config_text, capped at
config_height, followed by four equal-width buttons (["Reset", "💾", "📋", "📥"]):
- Reset:
config_text = commands_list.to_string()(discard edits). - 💾 Save: parse each line with
command_to_data,commands_list.set(cmds)(auto-saves to disk), then re-renderconfig_textfrom the now-normalized/sorted list. - 📋 Copy:
copy_text(config_text)(platform clipboard). - 📥 Paste:
paste_button(dashboard, PasteTarget::Config)(see widgets; desktop defers, Android applies immediately).
src/ui/tabs/dashboard_key.rs
#![allow(unused)]
fn main() {
pub(crate) fn render(dashboard: &mut DashboardState, ui: &mut egui::Ui)
}
A single-line key TextEdit (password-masked unless show_key), then four equal-width buttons
(["Generate", lock_label, "📋", "📥"] where lock_label is "🔒" when shown, "🔓" when hidden):
- Generate:
CryptoHandler::gen_key(); on successdashboard.save_key(k)(persists on Android), on error logs. - lock toggle: flips
dashboard.show_key. - 📋 Copy:
copy_text(dashboard.key). - 📥 Paste:
paste_button(dashboard, PasteTarget::Key).
Below a separator, a full-width Reseed Counter button: resolves Sender::get_counter_path(),
then now_nanos() and Counter::reseed(path, value), logging success/failure. This rewrites the
client’s replay counter seed.
src/ui/tabs/create.rs
#![allow(unused)]
fn main() {
pub(crate) fn render(form: &mut CreateForm, commands_list: &mut CommandsList, config_text: &mut String, ui: &mut egui::Ui)
}
A scrollable form to build one CommandData. Rows: server (address), command,
ip sent to server, and checkboxes for permissive, ipv4, ipv6. Layout helpers arg_row and
arg_row_text split each row into a wrapped label (left half) and the widget (right half).
The full-width Add Command button builds a CommandData from the form, runs it through
add_command_name (to populate the display name), then commands_list.add(cmd) (auto-saves and
sorts). It refreshes *config_text = commands_list.to_string() and clears the form (address, ip,
and the three booleans; command is left as-is).
src/ui/tabs/execute.rs
#![allow(unused)]
fn main() {
pub(crate) fn render(state: &mut ExecuteState, commands_list: &mut CommandsList, key: &str, ui: &mut egui::Ui)
fn exec_command(state: &mut ExecuteState, key: &str, cmd: CommandData) // private
}
Lists the saved commands (a to_vec() snapshot taken before the loop so the list can be mutated
inside it). Each row:
- A blue ▶ run
icon_buttonon the left. - A right-to-left layout with a red 🗑 delete
icon_buttonanchored right and the command name in a bordered box whose stroke color comes fromstate.color_for(cmd)(gray = not run, green = last run ok, red = last run failed).
Deferred mutation: clicks set to_delete / to_exec locals; after the loop, delete removes the
command from both state.status (by StatusKey) and commands_list (auto-saves), and run calls
exec_command.
exec_command is the bridge to the client. It logs, trims the key (empty key -> None), builds a
send ... string via data_to_command(&cmd, key_opt), splits it on whitespace, prepends "ruroco",
parses it into CliClient with CliClient::try_parse_from, and runs run_client_send. This is the
client’s real send path, called synchronously on the UI thread: a slow send blocks rendering.
On success it records Status::Ok; on error it logs and records Status::Err. There is no async,
spinner, or response (the protocol is one-way).
Support Types and Widgets
This chapter covers the GUI’s shared building blocks: command_data.rs (the command model and
CLI string conversions), saved_command_list.rs (CommandsList, persistence), widgets.rs
(reusable egui helpers), and colors.rs (the palette).
src/ui/command_data.rs
#![allow(unused)]
fn main() {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub(crate) struct CommandData {
pub(crate) address: String,
pub(crate) command: String,
pub(crate) permissive: bool,
pub(crate) ip: String,
pub(crate) ipv4: bool,
pub(crate) ipv6: bool,
#[serde(skip)]
pub(crate) name: String,
}
pub(crate) fn data_to_command(data: &CommandData, key: Option<String>) -> String
pub(crate) fn command_to_data(input: &str) -> CommandData
pub(crate) fn add_command_name(mut data: CommandData) -> CommandData
}
CommandData is the in-memory model of one saved command. name is a derived display label and is
#[serde(skip)], so it is recomputed (via add_command_name) after every load rather than stored.
data_to_command: serializes aCommandDatainto asendCLI string, emitting only non-empty / true fields (--address,--command,--ip,--ipv4,--ipv6,--permissive). ASome(key)appends--key <k>(used when actually sending; persisted/display forms passNone). Trailing whitespace is trimmed.command_to_data: the inverse parser. Tokenizes on whitespace; recognizes the flags above (taking the next token as the value for--address/--command/--ip) and ignores unknown tokens. Always finishes by callingadd_command_name. Gotcha: it does not validate, so malformed input silently yields empty/default fields.add_command_name: buildsnameas"{command}@{address}"pluspermissive/ipv4/ipv6suffixes for whichever flags are set, then stores it on the struct.
src/ui/saved_command_list.rs
#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub(crate) struct CommandsList {
list: Vec<CommandData>,
#[serde(skip)]
path: PathBuf,
}
impl fmt::Display for CommandsList { ... }
impl CommandsList {
pub(crate) fn create(cfg_dir: &Path) -> CommandsList
pub(crate) fn get(&self) -> &[CommandData]
pub(crate) fn add(&mut self, cmd: CommandData)
pub(crate) fn set(&mut self, list: Vec<CommandData>)
pub(crate) fn remove(&mut self, cmd: &CommandData)
fn sort(&mut self) // by (command, address)
fn read_raw_from_path(path: &Path) -> String
fn save(&self)
}
}
The persistent store of saved commands, backed by <cfg_dir>/commands_list.toml.
Display: renders the list as newline-separatedsend ...strings (data_to_command(c, None)). This is what feeds the Dashboard’s editable config text.create: resolves<cfg_dir>/commands_list.toml, reads it (missing/unreadable file -> empty string), and parses it. Parsing tries the current TOML schema first; on failure it falls back to a legacy schema (list: Vec<String>of CLI invocations), mapping each string throughcommand_to_data. If both fail and the raw text was non-empty, it logs a parse error and starts empty while leaving the file on disk untouched (no overwrite until the next mutation). It then setspath, recomputes everynameviaadd_command_name, and sorts.get: borrow the current slice.add/set/remove: mutate the list and immediately callsave().addandsetalsosort()first (removepreserves order). There is no batching: every mutation writes to disk.save: serializes to TOML and writes viawrite_atomic(temp file + fsync + rename). Both serialization and write errors are logged, never panic, so a bad path degrades gracefully.
Sorting is by (command, address) lexicographically.
src/ui/tabs/widgets.rs
#![allow(unused)]
fn main() {
pub(crate) struct Widgets<'a> { ui: &'a mut egui::Ui }
impl<'a> Widgets<'a> {
pub(crate) fn new(ui: &'a mut egui::Ui) -> Self
pub(crate) fn bordered(color: egui::Color32, inner_margin: f32) -> egui::Frame // associated, no self
pub(crate) fn icon_button(&mut self, color: egui::Color32, label: &str) -> egui::Response
pub(crate) fn equal_buttons(&mut self, labels: &[&str]) -> Vec<bool>
pub(crate) fn copy_text(&self, text: &str)
pub(crate) fn paste_button(&mut self, dashboard: &mut DashboardState, target: PasteTarget)
}
}
A thin wrapper around a borrowed egui::Ui, holding shared layout helpers.
bordered: an associated fn (noSelfinstance) returning anegui::Framewith a 2px stroke incolor, 5px corner radius, and the given inner margin. Used both for icon buttons and the Execute tab’s status box.icon_button: a fixed46x46button inside abordered(color, 1.0)frame; returns the button’sResponse. The closure body always runs, so theexpect("frame body always runs")cannot fire.equal_buttons: lays outlabels.len()buttons of equal width (accounting for 8px gaps) in a horizontal row, each50.0tall, and returns aVec<bool>of click states indexed to match the input labels.copy_text: clipboard copy. On Android callsAndroidClipboard::set_text(logging errors); on desktopself.ui.ctx().copy_text(...).paste_button: clipboard paste. On Android callsAndroidClipboard::get_textand applies the text immediately to the key or config field. On desktop it instead recordsdashboard.paste_target = Some(target)and firesViewportCommand::RequestPaste; the actual text arrives as aPasteevent handled next frame intabs/dashboard.rs.
src/ui/colors.rs
#![allow(unused)]
fn main() {
pub(crate) const BLUE: Color32 = Color32::from_rgb(25, 118, 210);
pub(crate) const GREEN: Color32 = Color32::from_rgb(56, 142, 60);
pub(crate) const RED: Color32 = Color32::from_rgb(211, 47, 47);
pub(crate) const GRAY: Color32 = Color32::from_rgb(204, 204, 204);
}
The four palette colors. BLUE is the run button, RED the delete button and error status,
GREEN the success status, GRAY the not-yet-run / neutral status (see ExecuteState::color_for).
Android JNI Bridge
On Android the GUI cannot use the desktop egui/filesystem paths for clipboard, soft keyboard,
status bar, or key storage. Instead it reaches into the Android platform through JNI. The bridge
lives in src/common/android/ and the Android-specific GUI entry point in src/ui/android.rs.
All of this code is gated by #![cfg(target_os = "android")] and compiled under the
android-build feature; on desktop the modules do not exist and every call site is behind its own
cfg, so the desktop build no-ops these concerns (for example DashboardState::load_persisted_key
returns "", and Widgets::copy_text uses egui’s clipboard).
Architecture
Every JNI entry point follows the same pattern: grab the process’s AndroidContext from
ndk_context::android_context(), reconstruct a JavaVM from its raw vm() pointer, attach the
current thread (vm.attach_current_thread(|env| ...)), wrap the activity JObject from
ctx.context(), then make JNI call_method / call_static_method / field reads. AndroidUtil
provides the shared call/string helpers; the rest are thin task-specific wrappers.
flowchart TD
GUI[src/ui — RurocoApp + tabs + widgets] -->|copy/paste| CB[AndroidClipboard]
GUI -->|ensure_visible per frame| KB[AndroidKeyboard]
GUI -->|load/save AES key| PR[AndroidPrefs]
GUI -->|status_bar_dp at startup| SB[AndroidStatusBar]
GUI -->|update_android opens APK url| UT[AndroidUtil]
AM[android.rs::android_main] -->|run_ui_with_options| GUI
CB --> JU[AndroidUtil JNI helpers<br/>jni_util.rs]
KB --> JU
PR --> JU
SB --> JU
UT --> JU
JU -->|attach_current_thread via VM| NDK[ndk_context VM + Activity]
CB -. wraps .-> API1[ClipboardManager / ClipData]
KB -. wraps .-> API2[InputMethodManager + decorView]
PR -. wraps .-> API3[SharedPreferences 'ruroco']
SB -. wraps .-> API4[Resources / DisplayMetrics]
UT -. wraps .-> API5[Intent / Uri / startActivity / getFilesDir]
src/ui/android.rs
#![allow(unused)]
fn main() {
#[no_mangle]
fn android_main(app: AndroidApp)
pub(crate) fn update_android() -> anyhow::Result<()>
}
android_main is the native-activity entry point (#[no_mangle], called by
android-activity). It reads the status-bar inset via AndroidStatusBar::height_dp() (falling back
to 0.0), builds eframe::NativeOptions with android_app: Some(app) and
renderer: eframe::Renderer::Wgpu, then calls crate::ui::run_ui_with_options(opts, status_bar_dp).
update_android implements the Dashboard’s Update action on Android: fetches the latest GitHub
release (Updater::get_github_api_data(None)), finds the asset whose name ends in .apk, then uses
AndroidUtil to uri_parse the download URL, build a VIEW intent (new_view_intent), and
start_activity. The start_activity error is logged as “probably expected” (handing off to the
browser may report a benign error), and the function still returns Ok.
src/common/android/mod.rs
Declares the bridge submodules (#![cfg(target_os = "android")]) and re-exports the public façade:
#![allow(unused)]
fn main() {
pub(crate) use clipboard::AndroidClipboard;
pub(crate) use keyboard::AndroidKeyboard;
pub(crate) use prefs::AndroidPrefs;
pub(crate) use status_bar::AndroidStatusBar;
pub(crate) use util::AndroidUtil;
}
(clipboard_read and keyboard_hide add methods to the clipboard/keyboard types via impl blocks
and are not separately re-exported.)
src/common/android/util.rs — AndroidUtil
#![allow(unused)]
fn main() {
pub(crate) struct AndroidUtil { ctx: Global<JObject<'static>>, vm: JavaVM }
impl AndroidUtil {
pub(crate) fn create() -> anyhow::Result<AndroidUtil>
pub(crate) fn get_conf_dir(&self) -> anyhow::Result<PathBuf>
pub(crate) fn start_activity(&self, intent: &Global<JObject<'static>>) -> anyhow::Result<Global<JObject<'static>>>
pub(crate) fn new_view_intent(&self, uri: &Global<JObject<'static>>) -> anyhow::Result<Global<JObject<'static>>>
pub(crate) fn uri_parse(&self, url: String) -> anyhow::Result<Global<JObject<'static>>>
}
}
The only stateful bridge type: it caches a global ref to the activity context and the JavaVM.
create attaches the thread and builds a global ref of the activity. get_conf_dir resolves the
app’s getFilesDir().getAbsolutePath() (where ruroco stores its config). The Intent helpers build a
android.intent.action.VIEW intent for a parsed Uri, adding FLAG_ACTIVITY_NEW_TASK
(0x10000000, required when launching from a non-Activity context), and start it. Constants
J_STRING / J_FILE hold common JNI return signatures.
src/common/android/jni_util.rs — AndroidUtil JNI helpers
#![allow(unused)]
fn main() {
impl AndroidUtil {
pub(crate) fn call_method_impl(env, obj, name, sig, args) -> anyhow::Result<Global<JObject<'static>>>
pub(crate) fn call_static_method_impl(env, class, name, sig, args) -> anyhow::Result<Global<JObject<'static>>>
pub(super) fn new_object_impl(env, class, sig, args) -> anyhow::Result<Global<JObject<'static>>>
pub(crate) fn to_string_impl(env, global_ref) -> anyhow::Result<String>
pub(super) fn unpack_result(result) -> anyhow::Result<JObject>
}
}
Reusable JNI primitives used by every other module:
call_method_impl/call_static_method_impl: parse the method signature (RuntimeMethodSignature::from_str), invoke the (static) method, unpack the result object, and return aGlobalref (so it survives leaving the JNI frame).new_object_impl: construct a new Java object and return a global ref.to_string_impl: casts a global ref toJString(unsafe { as_cast_unchecked }, relying on the caller having a realjava.lang.String) and reads its MUTF-8 chars into a RustString.unpack_result: turns a JNI callResultinto aJObject, with context on each failure.
These wrappers return anyhow::Result with .context(...) on every fallible step, matching the
project’s no-unwrap rule. The unsafe here is JNI FFI, scoped to raw pointer reconstruction and the
string cast.
src/common/android/clipboard.rs + clipboard_read.rs — AndroidClipboard
#![allow(unused)]
fn main() {
pub(crate) struct AndroidClipboard;
impl AndroidClipboard {
pub(crate) fn set_text(text: &str) -> anyhow::Result<()> // clipboard.rs
pub(crate) fn get_text() -> anyhow::Result<String> // clipboard_read.rs
}
}
set_text obtains the clipboard system service (ClipboardManager), builds a
ClipData.newPlainText("text", text), and calls setPrimaryClip. get_text obtains the same
service, calls getPrimaryClip, bails if null or getItemCount() <= 0 (“Clipboard is empty”), then
reads item 0 via getItemAt(0).coerceToText(activity).toString(). The read path is split into its
own file (clipboard_read.rs) as a second impl block on the same struct.
src/common/android/keyboard.rs + keyboard_hide.rs — AndroidKeyboard
#![allow(unused)]
fn main() {
pub(crate) struct AndroidKeyboard;
impl AndroidKeyboard {
pub(crate) fn ensure_visible(want: bool) -> anyhow::Result<()> // keyboard.rs
fn show() -> anyhow::Result<()> // keyboard.rs (private)
pub(super) fn hide() -> anyhow::Result<()> // keyboard_hide.rs
}
}
Called every frame from app_frame.rs with egui_wants_keyboard_input(). A static
AtomicBool KEYBOARD_HIDDEN tracks state so hide is only invoked on a true->false transition
(otherwise the per-frame loop would fire ~60 JNI hide calls/sec). show is always called when
want is true: this re-shows the soft keyboard if the user dismissed it while a field stayed focused
(there is no transition to detect in that case).
show:getWindow().getDecorView(),requestFocus(), then theinput_methodservice (InputMethodManager)showSoftInput(decorView, 0). UsesInputMethodManager.showSoftInputrather than the NDKANativeActivity_showSoftInput, which is ignored on many devices.hide: gets the decor view’s window token and callsInputMethodManager.hideSoftInputFromWindow(token, 0).
src/common/android/prefs.rs — AndroidPrefs
#![allow(unused)]
fn main() {
pub(crate) struct AndroidPrefs;
impl AndroidPrefs {
pub(crate) fn get_string(key: &str) -> anyhow::Result<Option<String>>
pub(crate) fn put_string(key: &str, value: &str) -> anyhow::Result<()>
}
}
Wraps SharedPreferences under the prefs file name "ruroco" in MODE_PRIVATE (0). This is how
the AES key persists across launches on Android (key "aes_key", used by DashboardState).
get_string calls getSharedPreferences(...).getString(key, "") and maps an empty result to
None. put_string calls getSharedPreferences(...).edit().putString(key, value) then apply().
The JNI method signatures are kept as module constants (GET_PREFS_SIG, GET_STRING_SIG,
EDIT_SIG, PUT_STRING_SIG).
src/common/android/status_bar.rs — AndroidStatusBar
#![allow(unused)]
fn main() {
pub(crate) struct AndroidStatusBar;
impl AndroidStatusBar {
pub(crate) fn height_dp() -> anyhow::Result<f32>
}
}
Returns the system status-bar height in dp (which equals egui logical points, so it can be fed
directly into ui.add_space). Reads getResources().getDisplayMetrics().density, looks up the
android dimen resource status_bar_height via getIdentifier, reads it with
getDimensionPixelSize, and divides by density. Returns 0.0 if density is non-positive or the
resource id is 0. It reads a fixed system resource, so it is safe to call at startup with no
layout-timing concerns. The result flows android_main -> run_ui_with_options -> RurocoApp.status_bar_dp.
Desktop no-op summary
| Bridge type | Android behavior | Desktop |
|---|---|---|
AndroidClipboard | JNI ClipboardManager | egui ctx().copy_text / RequestPaste |
AndroidKeyboard | InputMethodManager show/hide | not called |
AndroidPrefs | SharedPreferences ruroco | load_persisted_key returns "", save_key skips persist |
AndroidStatusBar | reads status_bar_height dimen | status_bar_dp = 0.0 |
AndroidUtil | Intent/Uri/files dir | desktop uses client::update::Updater and the normal config dir |
Server and Commander Overview
Ruroco splits the receiving side of the system into two cooperating processes for privilege separation:
- Server (
run_server): an unprivileged daemon that owns the UDP socket. It receives the 94-byte datagram, decrypts it, enforces rate limiting, deserializes the plaintext, and runs all validation (replay, destination IP, strict source IP). It never executes anything itself. - Commander (
run_commander): a privileged (typically root) process that owns the Unix domain socket. It receives a 24-byteCommanderDatamessage from the server, looks the command up by its Blake2b-64 hash, and runs the configured shell command.
The two processes communicate over a single Unix domain socket (ruroco.socket). This is the only
boundary between them. The server can write to the socket, the commander reads from it. The server
never opens a privileged operation, and the commander never touches the network.
The never-replies invariant
The protocol is strictly one-way. The server reads UDP datagrams but never sends a UDP response.
There is no acknowledgement, no error reply, and no status returned to the client. A client that
sends a packet learns nothing about whether it was accepted, rejected, rate limited, or replayed.
All outcomes (success and every failure) are logged locally on the server and surfaced as
anyhow::Result errors inside the receive loop, never transmitted back over the wire.
Key invariants
- Server and Commander are separate processes (privilege separation via the Unix socket).
- The client never knows actual commands: it only sends a Blake2b-64 hash of the command name. The mapping from hash to shell string lives only in the commander’s config.
- The counter is a u128 nanosecond timestamp, not a sequential value. Gaps between accepted counters are normal and expected.
- All IPs are stored and compared internally as IPv6-mapped (16 bytes); IPv4 addresses round-trip
through
to_ipv6_mappedon the wire and are collapsed back vianormalize_ipon receipt. CommanderDataon the Unix socket is exactly 24 bytes:cmd_hash(u64, big-endian) in bytes[0:8]and the IP (16 bytes, IPv6-mapped) in bytes[8:24].
Main types
classDiagram
direction TB
class Server {
-ConfigServer config
-HashMap~[u8;8],CryptoHandler~ crypto_handlers
-UdpSocket socket
-[u8;94] client_recv_data
-PathBuf socket_path
-Blocklist blocklist
-RateLimiter rate_limiter
+create(ConfigServer, Option~String~) Server
+run() Result
-run_loop_iteration(...) Result
-check_rate_limit(IpAddr) Result
-decrypt() Result
-validate_and_send_command(...) Result
-send_command(CommanderData)
-write_to_socket(CommanderData) Result
}
class ConfigServer {
+Vec~IpAddr~ ips
+PathBuf config_dir
+String socket_user
+String socket_group
+u32 max_requests_per_second
+u64 max_clock_skew_seconds
+create_crypto_handlers() Result
+create_blocklist() Result
+create_server_udp_socket(Option~String~) Result
+get_commander_unix_socket_path() PathBuf
}
class ConfigCommander {
+PathBuf config_dir
+String socket_user
+String socket_group
}
class ConfigCommands {
+HashMap~String,String~ commands
+get_hash_to_cmd() Result
}
class CliServer {
+PathBuf config
}
class Blocklist {
-HashMap~[u8;8],u128~ map
-PathBuf path
+create(Path) Result
+is_counter_replayed([u8;8], u128) bool
+seed_if_absent([u8;8], u128)
+get_counter([u8;8]) Option
+add([u8;8], u128)
+save() Result
}
class RateLimiter {
-HashMap~IpAddr,(Instant,u32)~ map
+new() RateLimiter
+check(IpAddr, u32) Result
}
class Commander {
+PathBuf socket_path
+HashMap~u64,String~ cmds
+String socket_user
+String socket_group
+create(ConfigCommander, ConfigCommands) Result
+run() Result
-run_cycle(UnixStream) Result
-run_command(str, IpAddr)
}
class CommanderData {
+u64 cmd_hash
+IpAddr ip
}
class CliCommander {
+PathBuf config
+PathBuf commands
}
Server --> ConfigServer
Server --> Blocklist
Server --> RateLimiter
Server ..> CommanderData : sends 24 bytes
Commander --> CommanderData : receives 24 bytes
Commander --> ConfigCommander
Commander --> ConfigCommands
CliServer ..> Server : run_server
CliCommander ..> Commander : run_commander
ConfigServer / CliServer live in server::config; Commander, ConfigCommander,
ConfigCommands, and CliCommander live in the top-level commander module; the IPC type
CommanderData is the one shared piece, in common::ipc. config.toml is one file read by both
processes through their own views (ConfigServer vs ConfigCommander). The commander builds under
with-commander (no OpenSSL); with-server is a superset of it.
Full valid request flow
sequenceDiagram
participant C as Client
participant S as Server (unprivileged)
participant B as Blocklist
participant U as Unix socket
participant K as Commander (root)
participant SH as sh -c
C->>S: UDP datagram (94 bytes)
Note over S: recv_from into client_recv_data
S->>S: count == MSG_SIZE (94)?
S->>S: normalize_ip(src.ip())
S->>S: rate_limiter.check(src_ip, max)
S->>S: DataParser::decode -> (key_id, ciphertext)
S->>S: crypto_handlers[key_id].decrypt -> plaintext (58 bytes)
S->>S: ClientData::deserialize(plaintext)
S->>B: is_counter_replayed(key_id, counter)?
B-->>S: false (not a replay)
S->>S: config.ips contains dst_ip?
S->>S: is_source_ip_invalid(src_ip)?
S->>B: add(key_id, counter) + save()
S->>U: write 24-byte CommanderData (cmd_hash + ip)
U->>K: deliver 24 bytes
K->>K: cmds[cmd_hash] -> shell string
K->>SH: sh -c "<command>" with RUROCO_IP=<ip>
Note over S,C: Server never replies to the client
Validation decision tree
flowchart TD
A[UDP datagram received] --> B{count == 94?}
B -- no --> X1[Error: Invalid read count, drop]
B -- yes --> C{rate_limiter.check OK?}
C -- no --> X2[Error: Rate limit exceeded, drop]
C -- yes --> D{key_id known and decrypt OK?}
D -- no --> X3[Error: no key / decrypt fail, drop]
D -- yes --> E[ClientData::deserialize]
E --> F{counter replayed?<br/>stored >= counter}
F -- yes --> X4[Error: Invalid counter on blocklist, drop]
F -- no --> G{dst_ip in config.ips?}
G -- no --> X5[Error: Invalid host IP, drop]
G -- yes --> H{strict and src_ip mismatch?}
H -- yes --> X6[Error: Invalid source IP, drop]
H -- no --> I[update blocklist + save]
I --> J[send 24-byte CommanderData to Unix socket]
J --> K[Commander runs shell command]
All X* outcomes are returned as anyhow::Error from run_loop_iteration, logged via error(...),
and the loop continues. Nothing is sent back to the client in any case.
Where to read next
- Socket and signal handling
- Handler and validation
- Blocklist and rate limiter
- Config and keys
- IPC contract (ipc.rs)
- Commander
Socket Binding and Signal Handling
This chapter covers two small but important pieces of the server lifecycle: how the UDP socket is
acquired (socket.rs) and how the process shuts down cleanly (signal.rs).
socket.rs
Responsibilities
Decides which UDP socket the server will listen on. It supports three sources, in priority order:
an explicit address argument, the RUROCO_LISTEN_ADDRESS environment variable, systemd socket
activation, and finally a hardcoded fallback bind to [::].
Default port
#![allow(unused)]
fn main() {
pub(crate) const DEFAULT_PORT: u16 = 34020;
}
Used only by the fallback bind. The value is derived from the alphabet indices of the letters in
“ruroco” (r=18, u=21, o=15, c=3) multiplied together and doubled: 18 * 21 * 15 * 3 * 2 = 34020.
Signature
#![allow(unused)]
fn main() {
impl ConfigServer {
pub(crate) fn create_server_udp_socket(
&self,
address: Option<String>,
) -> anyhow::Result<UdpSocket>
}
}
Resolution order
The function matches on the tuple
(LISTEN_PID, LISTEN_FDS, RUROCO_LISTEN_ADDRESS, address) and picks the first arm that applies:
- Explicit
addressargument (Some(address)):UdpSocket::bind(address). This is what the tests use to bind ephemeral ports such as127.0.0.1:0. RUROCO_LISTEN_ADDRESSenv var set and no argument:UdpSocket::bind(address).- systemd socket activation: when
LISTEN_PIDequals the current process id (as a string) andLISTEN_FDS == "1", the socket is adopted from raw file descriptor3:
systemd guarantees FD 3 is the first passed socket. Ownership of the fd transfers to the returned#![allow(unused)] fn main() { let fd: RawFd = 3; let sock = unsafe { UdpSocket::from_raw_fd(fd) }; }UdpSocket; this is the onlyunsafeblock in the server path, and it is justified by the two environment checks above. This lets ruroco start on demand from a.socketunit without the daemon ever binding a port itself. - Misconfigured activation guards:
LISTEN_FDS != "1"returnsErr("LISTEN_FDS was set to {n}, expected 1").LISTEN_PIDnot matching the current PID returnsErr("LISTEN_PID ({pid}) does not match current PID").
- Fallback: bind
[::]:34020. Binding the unspecified IPv6 address[::]accepts both IPv6 and IPv6-mapped IPv4 traffic on dual-stack hosts.
Gotchas
- The argument always wins over the environment variable, which always wins over socket activation, which wins over the fallback.
- Socket activation is selected purely from environment variables; it does not validate that FD 3 is actually a UDP socket. The safety contract relies on systemd setting it up correctly.
- The fallback uses
[::], not0.0.0.0. If you need IPv4-only behaviour, supply an explicit address.
signal.rs
Responsibilities
Installs POSIX signal handlers for SIGTERM and SIGINT that flip a global atomic flag. The main
loop polls this flag once per iteration so the server can stop between datagrams without being
killed mid-processing.
State and signatures
#![allow(unused)]
fn main() {
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
pub(crate) fn shutdown_requested() -> bool;
pub(crate) fn install_signal_handlers();
}
install_signal_handlers calls the libc signal function for signal numbers 15 (SIGTERM) and
2 (SIGINT), both pointing at one handler:
#![allow(unused)]
fn main() {
extern "C" fn handle_signal(_sig: c_int) {
SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
}
}
shutdown_requested() reads the atomic with Ordering::SeqCst.
How the loop uses it
Server::run sets a 1-second read timeout on the socket, installs the handlers, then loops:
#![allow(unused)]
fn main() {
loop {
if shutdown_requested() {
info("Shutdown requested, stopping server loop");
break;
}
let data = self.socket.recv_from(&mut self.client_recv_data);
// WouldBlock / TimedOut -> continue
// otherwise -> run_loop_iteration
}
}
The 1-second read timeout is what makes clean shutdown responsive: even when no datagrams arrive,
recv_from returns WouldBlock/TimedOut every second, the loop continues, and the
shutdown_requested() check runs again. Without the timeout the process would block in recv_from
and could not notice the flag until the next packet arrived.
Gotchas
- The handler does the absolute minimum allowed in async-signal context: a single atomic store.
- The flag is process-global, so all tests reset it explicitly before asserting.
- Shutdown is cooperative: a datagram already being processed in
run_loop_iterationfinishes first; the flag is only checked at the top of the next iteration.
Handler and Validation
handler.rs implements the validation and dispatch half of the Server. It is reached from the
receive loop after the datagram has been received, size-checked, rate limited, and decrypted. Its
job is to deserialize the plaintext into ClientData, run the three validation checks, persist the
counter, and hand a CommanderData to the commander over the Unix socket.
All functions here are impl Server methods with pub(super) visibility (callable from the parent
server module but not outside the crate’s server tree).
Entry point: validate_and_send_command
#![allow(unused)]
fn main() {
pub(super) fn validate_and_send_command(
&mut self,
key_id: [u8; KEY_ID_SIZE], // KEY_ID_SIZE == 8
plaintext_data: [u8; PLAINTEXT_SIZE], // PLAINTEXT_SIZE == 57
src_ip: IpAddr,
) -> anyhow::Result<()>
}
It first deserializes the plaintext:
#![allow(unused)]
fn main() {
ClientData::deserialize(plaintext_data)
}
ClientData::deserialize validates the protocol version byte (the first byte of the authenticated
plaintext) and then reads fixed offsets out of the 58-byte buffer; an unknown version is rejected.
The resulting struct is then matched against guard clauses, evaluated top to bottom. The first guard
that matches produces an error and the packet is dropped; if none match, the success arm runs.
Step 1: replay check
#![allow(unused)]
fn main() {
client_data if self.blocklist.is_counter_replayed(key_id, client_data.counter) => ...
}
If is_counter_replayed returns true, the packet is rejected with:
Invalid counter for key {key_id:X?} - {counter} is on blocklist, expected > {server_counter:?}
is_counter_replayed uses a >= comparison against the stored per-key_id counter, so a counter
equal to or below the last accepted value is a replay. The counter is a u128 nanosecond timestamp,
so a freshly generated packet is normally strictly greater than the stored floor. See
Blocklist and rate limiter.
Step 2: destination IP check
#![allow(unused)]
fn main() {
client_data if !self.config.ips.contains(&client_data.dst_ip) => ...
}
The dst_ip the client encoded into the packet must be one of the server’s configured ips. If it
is not, the packet is rejected with:
Invalid host IP for key {key_id:X?} - expected {ips:?} to contain {destination_ip}
Both sides are compared as IpAddr. Config IPs are normalized at load time and dst_ip is
normalized during deserialization, so an IPv4 address and its IPv6-mapped form compare equal. This
check binds a captured packet to a specific destination host: replaying it against a different
server IP fails.
Step 3: strict source IP check
#![allow(unused)]
fn main() {
client_data if client_data.is_source_ip_invalid(src_ip) => ...
}
is_source_ip_invalid is defined as:
#![allow(unused)]
fn main() {
pub(crate) fn is_source_ip_invalid(&self, source_ip: IpAddr) -> bool {
self.strict && self.src_ip.is_some_and(|ip_sent| ip_sent != source_ip)
}
}
It only rejects when both conditions hold: the client set the strict flag, and it included a
src_ip that does not match the real UDP source address (src_ip here is the normalize_ip’d
sender from the receive loop). If strict is false, or no src_ip was sent, this check passes.
Rejection message:
Invalid source IP for key {key_id:X?} - expected {client_src_ip_str}, actual {src_ip}
where client_src_ip_str is the sent src_ip or the literal "none".
Success arm
When no guard matched, the server logs an info line and dispatches:
#![allow(unused)]
fn main() {
info("Valid data for key {key_id:X?} - trying cmd {cmd} and counter {client_counter}|{server_counter:?} with {ip}");
self.update_block_list(key_id, client_data.counter);
self.send_command(CommanderData { cmd_hash: cmd, ip });
Ok(())
}
Note the order: the blocklist is updated before the command is sent. The IP forwarded to the
commander is client_data.src_ip.unwrap_or(src_ip): the client-declared source IP if present,
otherwise the real packet source.
update_block_list
#![allow(unused)]
fn main() {
pub(super) fn update_block_list(&mut self, key_id: [u8; KEY_ID_SIZE], counter: u128)
}
Calls blocklist.add(key_id, counter) then blocklist.save(). A save failure is logged via
error(...) but does not abort the request: the in-memory counter is still advanced, so the replay
check is correct for the rest of the process lifetime even if persistence failed.
send_command
#![allow(unused)]
fn main() {
pub(super) fn send_command(&self, data: CommanderData)
}
Wraps write_to_socket. On success it logs "Successfully sent data to commander". On failure it
logs an error(...) including the socket path but swallows the error (returns nothing). A
missing or unreachable commander socket therefore does not crash the server loop; it is logged and
the next datagram is processed.
write_to_socket
#![allow(unused)]
fn main() {
pub(super) fn write_to_socket(&self, data: CommanderData) -> anyhow::Result<()>
}
Connects to the Unix socket at self.socket_path, converts the CommanderData into its 24-byte
array ([u8; CMDR_DATA_SIZE] via From), writes all bytes with write_all, then flushes.
Failures are wrapped with context: "Could not connect to socket {path}",
"Could not write {bytes} to socket {path}", or "Could not flush stream for {path}".
What causes a packet to be dropped
Summarising the failure modes that prevent a command from running (each returns an Err that is
logged and never sent to the client):
| Cause | Where | Message fragment |
|---|---|---|
| Wrong datagram length | receive loop | Invalid read count |
| Rate limit exceeded | check_rate_limit | Rate limit exceeded |
| Unknown key id / decrypt failure | decrypt | Could not find key for id |
| Replayed/old counter | validate_and_send_command | is on blocklist |
| Destination IP not configured | validate_and_send_command | Invalid host IP |
| Strict source IP mismatch | validate_and_send_command | Invalid source IP |
| Commander socket unreachable | send_command (logged only) | Could not send data to commander |
The last row is special: validation already passed and the blocklist was updated, so the counter is consumed even though the command did not reach the commander.
Blocklist and Rate Limiter
These two modules implement the server’s two independent defenses against abuse: the blocklist
(blocklist.rs) provides durable replay protection, and the rate limiter (rate_limiter.rs)
provides in-memory throttling. They serve different purposes and must not be confused: the
blocklist is security (it rejects replayed and stale packets across restarts), the rate limiter is
load protection (it caps requests per second and forgets everything on restart).
blocklist.rs
Responsibilities
Tracks the highest counter accepted per key_id and persists it to disk as MessagePack so replay
protection survives restarts. Each key has its own counter floor.
Type
#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct Blocklist {
map: HashMap<[u8; KEY_ID_SIZE], u128>, // KEY_ID_SIZE == 8
path: PathBuf,
}
}
The map is “key id -> most recent counter accepted”. The counter is a u128 nanosecond timestamp,
not a sequential number, so the stored value jumps forward by large amounts and gaps are expected.
Persistence (MessagePack)
#![allow(unused)]
fn main() {
pub fn create(dir: &Path) -> anyhow::Result<Blocklist>;
pub fn get_blocklist_path(dir: &Path) -> PathBuf; // dir/blocklist.msgpck
pub(crate) fn save(&self) -> anyhow::Result<()>;
}
The directory is config_dir by default, or the server’s optional blocklist_dir when set (e.g. a
writable systemd StateDirectory like /var/lib/ruroco, so config_dir itself can stay
read-only). ConfigServer::create_blocklist picks the directory and the rest is path-agnostic.
createreads<dir>/blocklist.msgpckif it exists and deserializes it withrmp_serde(a corrupted file is a hard error:"Could not create blocklist from vec"), otherwise starts with an empty map. It then immediatelysave()s, so the file always exists aftercreate.saveserializes the whole struct withrmp_serde::to_vecand writes it throughwrite_atomic(temp file, fsync, rename) so a crash mid-write cannot corrupt the file.
The replay check (>= semantics)
#![allow(unused)]
fn main() {
pub(crate) fn is_counter_replayed(&self, key_id: [u8; KEY_ID_SIZE], value: u128) -> bool {
match self.map.get(&key_id) {
Some(v) => v >= &value,
None => true,
}
}
}
This returns true (replayed, reject) when:
- the stored counter is greater than or equal to the incoming
value. Equal counts as a replay: the stored value records the most recent counter accepted, so an identical counter is a retransmit, capture, or adversarial replay and must be rejected. Do not relax this to>. - or the key id is unknown (
None). An entry that has never been seeded is treated as blocked. In normal operation this cannot happen for a configured key because every key is seeded at startup (below), but it makes the default safe.
Startup seeding to now_nanos
In Server::create:
#![allow(unused)]
fn main() {
let floor = now_nanos()?;
for key_id in crypto_handlers.keys() {
blocklist.seed_if_absent(*key_id, floor);
}
blocklist.save()?;
}
#![allow(unused)]
fn main() {
pub(crate) fn seed_if_absent(&mut self, key_id: [u8; KEY_ID_SIZE], floor: u128) {
self.map.entry(key_id).or_insert(floor);
}
}
Every loaded key gets its counter floor seeded to the current nanosecond timestamp only if it is
absent. An existing entry from a previous run is never overwritten. The effect: after a (re)start,
any packet whose counter is older than the moment the process came up is rejected, even one that was
never seen before. seed_if_absent uses entry().or_insert() so a higher persisted value wins over
the startup floor.
Other methods
#![allow(unused)]
fn main() {
pub(crate) fn get_counter(&self, key_id: [u8; KEY_ID_SIZE]) -> Option<&u128>;
pub fn get(&self) -> &HashMap<[u8; KEY_ID_SIZE], u128>;
pub(crate) fn add(&mut self, key_id: [u8; KEY_ID_SIZE], entry: u128);
}
add unconditionally inserts (overwrites) the counter for a key; the handler only calls it after
the replay check has passed, so it always moves the floor upward.
Gotchas
- Equal counter = replay. This is intentional and load-bearing for security.
- An unknown key id is treated as blocked, not allowed.
- The whole map is rewritten on every accepted packet (
addthensave). This is fine for the expected low request volume and gives crash-safe atomic persistence.
rate_limiter.rs
Responsibilities
Caps the number of accepted requests per source IP within a rolling ~1-second window. This is throttling to limit decrypt work and command floods; it is not replay defense and provides no guarantees across restarts.
Type and methods
#![allow(unused)]
fn main() {
#[derive(Debug)]
pub(crate) struct RateLimiter(HashMap<IpAddr, (Instant, u32)>);
impl RateLimiter {
pub(crate) fn new() -> Self;
pub(crate) fn check(&mut self, ip: IpAddr, max: u32) -> anyhow::Result<()>;
}
}
Each IP maps to a (window_start: Instant, count: u32) pair.
The window logic
#![allow(unused)]
fn main() {
let entry = self.0.entry(ip).or_insert_with(|| (Instant::now(), 0));
if entry.0.elapsed() >= Duration::from_secs(1) {
entry.0 = Instant::now(); // window expired, reset
entry.1 = 1;
} else if entry.1 >= max {
bail!("Rate limit exceeded for {ip}: more than {max} requests per second");
} else {
entry.1 += 1;
}
Ok(())
}
- First request from an IP creates an entry and counts as 1.
- If at least 1 second has elapsed since the window started, the window resets and the count goes back to 1.
- Within the window, once
count >= maxthe request is rejected with"Rate limit exceeded". - Otherwise the count is incremented and the request passes.
Default and wiring
The limit comes from ConfigServer::max_requests_per_second, whose default is 2 (see
default_max_requests_per_second). The server calls it from check_rate_limit:
#![allow(unused)]
fn main() {
self.rate_limiter.check(src_ip, self.config.max_requests_per_second)
}
This runs before decryption in the receive loop, so a flood of garbage packets from one IP is throttled before the relatively expensive AES-256-GCM-SIV decrypt.
Gotchas
- In-memory only: the
HashMapis rebuilt empty on every process start. Restarting the server clears all rate-limit state. - It is a sliding window keyed on the first request’s
Instant, not a fixed calendar second, so two bursts straddling a window boundary are each limited independently. - It throttles, it does not authenticate or detect replays. Replay defense is entirely the blocklist’s job.
- The map grows one entry per distinct source IP and is never pruned within the process lifetime.
Config and Keys
This chapter covers the server’s configuration (config.rs) and key file discovery / crypto handler
construction (keys.rs).
config.rs: ConfigServer and CliServer
The server’s view of config.toml. The commander reads the same file through its own
ConfigCommander view (see Commander); only config_dir is shared between the
two, and it must agree so both resolve the same ruroco.socket (see ipc.rs).
The command set is a separate file (commands.toml), never loaded here.
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
pub struct CliServer {
#[arg(short, long, default_value = "/etc/ruroco/config.toml")]
pub(crate) config: PathBuf,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct ConfigServer {
#[serde(deserialize_with = "deserialize_ips")]
pub ips: Vec<IpAddr>,
#[serde(default = "default_config_path")] // /etc/ruroco
pub config_dir: PathBuf,
#[serde(default)] // None -> config_dir
pub blocklist_dir: Option<PathBuf>,
#[serde(default)] // None -> config_dir
pub socket_dir: Option<PathBuf>,
#[serde(default = "default_max_requests_per_second")] // 2
pub max_requests_per_second: u32,
#[serde(default = "default_max_clock_skew_seconds")] // 3600
pub max_clock_skew_seconds: u64,
}
}
ips: the destination IPs this server answers for; a packet’sdst_ipmust be in this list (handler step 2). Defaults to["127.0.0.1"]. Each entry is run throughnormalize_ipon load (viadeserialize_ips), so"::ffff:127.0.0.1"is stored as127.0.0.1.config_dir: directory holding the*.keyfiles (and, by default,blocklist.msgpckandruroco.socket). Defaults to/etc/rurocofrom TOML, or the current working directory inDefault.blocklist_dir: optional override for whereblocklist.msgpckis persisted; defaults toconfig_dir. Point it at a writable systemdStateDirectory(/var/lib/ruroco) soconfig_dir(keys, config) can be mounted read-only — a compromised server can then only rewrite its own counter state, not the keys.socket_dir: optional override for whereruroco.socketlives; defaults toconfig_dir. Point it at a systemdRuntimeDirectory(/run/ruroco). Server and commander must resolve the same value (the commander reads the same field viaConfigCommander).max_requests_per_second: per-IP rate limit, default 2.max_clock_skew_seconds: how far ahead of server-local time an accepted counter may be, default 3600. See handler.rs.
Note there is no socket_user / socket_group here: those are commander-only (the commander
chowns the socket), so they live in ConfigCommander. ConfigServer simply ignores them when they
appear in config.toml.
The inherent methods that act on config_dir (key discovery, crypto handlers, the UDP socket, the
blocklist) are separate impl ConfigServer blocks in keys.rs and socket.rs, covered below and
in socket.rs and signal.rs.
keys.rs
Responsibilities
Discovers every *.key file in config_dir, reads them, and builds one CryptoHandler per key,
indexed by the 8-byte key id. Supporting multiple keys lets several independent clients (each with
its own key) talk to one server. Also constructs the blocklist and resolves the Unix socket path
(via common::ipc::get_commander_unix_socket_path).
Methods
#![allow(unused)]
fn main() {
pub(crate) fn create_blocklist(&self) -> anyhow::Result<Blocklist>;
pub(crate) fn create_crypto_handlers(&self)
-> anyhow::Result<HashMap<[u8; KEY_ID_SIZE], CryptoHandler>>;
pub(crate) fn get_commander_unix_socket_path(&self) -> PathBuf; // convenience over common::ipc
pub(crate) fn resolve_config_dir(&self) -> PathBuf;
pub(crate) fn get_key_paths(&self) -> anyhow::Result<Vec<PathBuf>>;
}
These are server-only, so they do not compile into the commander build (which loads ConfigServer
for its fields only). create_server_udp_socket is the matching server-only method in
socket.rs.
*.key discovery
#![allow(unused)]
fn main() {
pub(crate) fn get_key_paths(&self) -> anyhow::Result<Vec<PathBuf>> {
let config_dir = self.resolve_config_dir();
// read_dir, keep entries that are files with extension == "key"
match key_files.len() {
0 => Err(anyhow!("Could not find any .key files in {config_dir:?}")),
_ => Ok(key_files),
}
}
}
It filters config_dir for regular files whose extension is exactly key. A directory with no
.key files is an error (the server cannot start with no keys). A directory that cannot be read is
also an error: "Error reading directory {dir}: {e}".
Multiple keys, indexed by key id
#![allow(unused)]
fn main() {
pub(crate) fn create_crypto_handlers(&self) -> anyhow::Result<HashMap<[u8; KEY_ID_SIZE], CryptoHandler>> {
let key_paths = self.get_key_paths()?;
let content_to_path = Self::get_content_to_path(&key_paths)?; // HashMap<content, path>
if key_paths.len() != content_to_path.len() {
bail!("Duplicate key files detected; refusing to start");
}
// for each key: CryptoHandler::create(content), index by handler.id
}
}
Each key file is read to a String, a CryptoHandler is created from it, and the handlers are
collected into HashMap<[u8; 8], CryptoHandler> keyed by handler.id (the 8-byte key id that also
prefixes every datagram on the wire). The server uses this map in decrypt to pick the right
handler for an incoming packet’s key id.
Duplicate detection
get_content_to_path builds a HashMap keyed by file content, so two files with identical key
material collapse to one entry. If that shrinks the count relative to the number of key paths, the
server refuses to start with "Duplicate key files detected; refusing to start". This guards
against copy-paste mistakes that would otherwise produce two key ids for the same secret.
Gotchas
- The map is keyed by key id (
handler.id), not by filename. The filename only matters for the.keyextension filter. - Two distinct files with the same content is a hard startup error, not a warning.
resolve_config_dirrunsconfig_dirthroughresolve_pathbefore any filesystem access, so all of these methods (keys, blocklist, socket path) agree on the same resolved directory.
Commander
The commander is the privileged half of the receiving side: a separate process and binary from the
server, typically run as root. It owns the Unix domain socket, reads the 24-byte CommanderData the
server writes, looks the command up by its Blake2b-64 hash, and runs the configured shell command
with the client IP exported into the environment.
It lives in the top-level src/commander/ module and builds under the with-commander feature,
which links no OpenSSL and none of the server’s UDP/decrypt code (with-server is a superset of
with-commander, since the server produces the IPC type the commander consumes). The module is
three files plus the shared types it imports from common:
mod.rs: theCommanderstruct and accept loop.exec.rs: socket setup, shell execution, and therun_commanderentry point.config.rs:ConfigCommander(the commander’s view ofconfig.toml),ConfigCommands(thecommands.tomlschema), andCliCommander.
The only thing shared with the server is the IPC contract in common::ipc: the wire format
(CommanderData, CMDR_DATA_SIZE) and the socket path (get_commander_unix_socket_path). See
ipc.rs. The server’s own config lives in server::config::ConfigServer.
config.rs: ConfigCommander, ConfigCommands, CliCommander
The commander reads two files: the shared config.toml (for config_dir and the socket
ownership) and its own commands.toml. Both paths are configurable (--config / --commands) so
the command set can be relocated independently of the server config.
#![allow(unused)]
fn main() {
#[derive(Parser, Debug)]
pub struct CliCommander {
#[arg(short, long, default_value = "/etc/ruroco/config.toml")]
pub(crate) config: PathBuf,
#[arg(long, default_value = "/etc/ruroco/commands.toml")]
pub(crate) commands: PathBuf,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct ConfigCommander {
#[serde(default = "default_config_path")] // /etc/ruroco
pub config_dir: PathBuf,
#[serde(default)] // None -> falls back to config_dir
pub socket_dir: Option<PathBuf>,
#[serde(default = "default_socket_user")] // "ruroco"
pub socket_user: String,
#[serde(default = "default_socket_group")] // "ruroco"
pub socket_group: String,
#[serde(default)] // false: reject non-routable client IPs
pub allow_non_routable_ips: bool,
}
#[derive(Debug, Deserialize, PartialEq)]
pub struct ConfigCommands {
pub commands: HashMap<String, String>, // command name -> shell string
}
}
ConfigCommander is the commander’s view of config.toml: it declares only the fields it uses
(config_dir for the socket path, socket_user/socket_group for socket ownership). All its fields
are optional, so the server-only fields in the same file (ips, rate limit, clock skew) are simply
ignored. config_dir is the one value that must agree with ConfigServer (see
ipc.rs).
The command set is kept in its own file, commands.toml, separate from config.toml, so the
network-facing server process never loads it. It is installed root-owned 0600.
#![allow(unused)]
fn main() {
pub(crate) fn get_hash_to_cmd(&self) -> anyhow::Result<HashMap<u64, String>> {
self.commands
.iter()
.map(|(k, v)| {
let hash = blake2b_u64(k)?; // hash of the command NAME
Ok((hash, v.clone()))
})
.collect()
}
}
get_hash_to_cmd turns the name-keyed config into a hash-keyed lookup table. The incoming
CommanderData.cmd_hash is matched against these u64 keys. The hash is computed over the command
name (the map key), not the shell string, identically to how the client computes it, so the
client never has to transmit the command itself.
mod.rs: the Commander struct and accept loop
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
pub struct Commander {
pub(super) socket_path: PathBuf,
pub(super) cmds: HashMap<u64, String>, // cmd_hash -> shell string
pub(super) socket_user: String,
pub(super) socket_group: String,
}
}
Construction
#![allow(unused)]
fn main() {
pub(super) fn create_from_paths(config_path: &Path, commands_path: &Path) -> anyhow::Result<Commander>;
pub fn create(config: ConfigCommander, commands: ConfigCommands) -> anyhow::Result<Commander>;
}
create builds cmds via commands.get_hash_to_cmd(), derives the socket path from
get_commander_unix_socket_path(&config.config_dir), and copies the socket_user/socket_group
from the ConfigCommander. create_from_paths loads both TOML files
(ConfigCommander::create_from_path and ConfigCommands::create_from_path) and forwards to
create.
Accept loop
#![allow(unused)]
fn main() {
pub fn run(&self) -> anyhow::Result<()> {
for stream in self.create_listener()?.incoming() {
match stream {
Ok(mut stream) => if let Err(e) = self.run_cycle(&mut stream) { error(e) },
Err(e) => error(format!("Connection for {:?} failed: {e}", &self.socket_path)),
}
}
Ok(())
}
}
It binds the listener once, then serves connections forever. A per-connection error (unknown
command, read failure) is logged via error(...) and the loop continues; one bad message never
takes the commander down.
Per-connection cycle
#![allow(unused)]
fn main() {
fn run_cycle(&self, stream: &mut UnixStream) -> anyhow::Result<()> {
let msg = Commander::read(stream)?; // [u8; 24]
let cmdr_data: CommanderData = msg.into();
let cmd = self.cmds.get(&cmdr_data.cmd_hash)
.ok_or_else(|| anyhow!("Unknown command name: {cmd_hash}"))?;
info(format!("Running command ({cmd_hash}) {cmd}"));
self.run_command(cmd, cmdr_data.ip);
Ok(())
}
fn read(stream: &mut UnixStream) -> anyhow::Result<[u8; CMDR_DATA_SIZE]>;
}
read fills a fixed 24-byte buffer. The lookup self.cmds.get(&cmd_hash) is the point where the
opaque hash the client sent is finally resolved to a concrete shell string, and it happens only
inside the privileged process. A hash with no matching name produces "Unknown command name: {hash}" (logged, connection dropped).
exec.rs: socket setup and shell execution
Socket creation, permissions, ownership
#![allow(unused)]
fn main() {
pub(super) fn create_listener(&self) -> anyhow::Result<UnixListener> {
let socket_dir = self.socket_path.parent()
.ok_or_else(|| ... "Could not get parent dir ...")?;
fs::create_dir_all(socket_dir)?;
let _ = fs::remove_file(&self.socket_path); // clear stale socket
let mode = 0o204; // write-only for server, read for others
let listener = UnixListener::bind(&self.socket_path)?;
fs::set_permissions(&self.socket_path, Permissions::from_mode(mode))?;
self.change_socket_ownership()?;
Ok(listener)
}
}
- The parent directory is created if missing.
- Any stale socket file at the path is removed before binding (binding fails if the path exists).
- Permissions are set to
0o204: owner (the server user) has write, others have read, no execute. This is the access-control boundary: only the server may push commands in. - Ownership is applied via
change_socket_ownership->change_file_ownership(path, socket_user, socket_group)(both trimmed). Defaults are userruroco/ groupruroco.
Shell execution with $RUROCO_IP
#![allow(unused)]
fn main() {
const ENV_PREFIX: &str = "RUROCO_";
pub(super) fn run_command(&self, command: &str, ip: IpAddr) {
if !self.allow_non_routable_ips && !Self::is_ip_allowed(ip) { return; } // reject non-routable
Command::new("sh")
.arg("-c")
.arg(command)
.env(format!("{ENV_PREFIX}IP"), ip.to_string()) // RUROCO_IP=<client ip>
.output();
// logs stdout/stderr; info on success, error on non-zero exit or spawn failure
}
}
The configured command string is run through sh -c, with RUROCO_IP set to the client IP, so a
command can react to who triggered it (for example ufw allow from $RUROCO_IP). Output is captured:
on success both stdout and stderr are logged at info level, on a non-zero exit at error level, and a
spawn failure is logged as "Error executing {command} for {ip}: {e}" (the client IP is included in
every execution log line for an audit trail). A failing command is never fatal to the commander loop.
IP filtering
#![allow(unused)]
fn main() {
fn is_ip_allowed(ip: IpAddr) -> bool {
let reject = ip.is_unspecified() || ip.is_loopback() || ip.is_multicast()
|| match ip {
IpAddr::V4(v4) => v4.is_broadcast() || v4.is_private()
|| v4.is_link_local() || v4.is_documentation(),
IpAddr::V6(v6) => v6.is_unique_local() || v6.is_unicast_link_local(),
};
if reject { error(...); } // "refusing to execute with non-routable IP"
!reject
}
}
The IP placed into RUROCO_IP is meant to be an outside unicast peer (for example for
ufw allow from $RUROCO_IP), so by default only globally-routable addresses run the command:
unspecified, loopback, multicast, broadcast, private/RFC1918, link-local, and documentation
addresses are rejected. This stops a client from naming 127.0.0.1 or an internal address to
whitelist a host it does not own. Setting allow_non_routable_ips = true in config.toml bypasses
the filter (mainly for local testing, where the only available source address is loopback).
run_commander entry point
#![allow(unused)]
fn main() {
pub fn run_commander(commander: CliCommander) -> anyhow::Result<()> {
Commander::create_from_paths(&commander.config, &commander.commands)?.run()
}
}
This is the commander binary’s main path: load both TOML files, build the Commander, and serve
forever.
Gotchas
- The socket mode is
0o204, not a more common value: server writes, world reads, owner cannot read back. Confirm the server process runs as thesocket_userso it actually holds the write bit. - The commander must be able to bind in
config_dir; the directory is created withcreate_dir_allif absent. cmd_hashis the hash of the command name, computed identically on the config side (get_hash_to_cmd) and on the client. A mismatch in the name produces “Unknown command name”.- All execution happens in the commander, never the server; and the commander never touches the network or links OpenSSL: the only input it trusts is the 24-byte message on its own Unix socket.