Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

BinaryRuns onRoleBuild feature
ruroco-clientyour machinebuilds, encrypts and sends the UDP packetwith-client
ruroco-client-uiyour machine / Androida GUI over the client (egui)with-gui
ruroco-serverremote host (exposed)receives, decrypts, validates, forwardswith-server
ruroco-commanderremote host (not exposed)looks up and runs the commandwith-commander

How to read this documentation

This book is organized top-down, like a tree.

  1. 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.
  2. 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.
  3. The leaves are the individual .rs files. 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-mermaid preprocessor. Build the book with mdbook build from the docs/ directory and open docs/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 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.

BinaryEntry pointFeatureOne-liner
client.rsclient::run_client(CliClient::parse())with-clientCLI dispatch
client_ui.rsui::run_ui()with-guidesktop GUI window
server.rsserver::run_server(CliServer::parse())with-serverUDP daemon
commander.rscommander::run_commander(CliCommander::parse())with-commanderprivileged 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:

CapabilityGated byNotes
encryptwith-clientonly the client encrypts
decryptwith-serveronly the server decrypts
ClientData::create / serializewith-clientclient builds the plaintext
ClientData::deserialize / validationwith-serverserver reads it
verify_ed25519with-clientself-update signature check
OpenSSL (crypto::handler, get_random_range)with-client or with-serverthe commander links none of it
ipc (CommanderData, socket path), normalize_ipwith-server or with-commanderthe runtime contract shared by both roles
write_atomicwith-server or with-guithe 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/).

  1. Lock. The client acquires a PID-based single-instance lock at <conf_dir>/client.lock so two runs cannot race the counter (lock.rs).
  2. Resolve. The destination --address is resolved to one or more IPs, filtered by the --ipv4 / --ipv6 flags. The client then loops over each destination IP.
  3. Counter. For each IP it increments the persistent counter and writes it to disk immediately. The counter is a u128 nanosecond value, seeded to “now” on first use, and is strictly increasing (counter.rs). This is what makes replays impossible.
  4. Build plaintext. ClientData::create hashes the command name with Blake2b-64 and packs version, cmd_hash, counter, strict, src_ip, dst_ip into a fixed 58-byte layout (Wire Protocol). Note the inversion: the CLI flag is --permissive, but the packet carries strict = !permissive.
  5. 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).
  6. Frame. The 8-byte key_id is prepended, giving the final 94-byte packet. The key_id tells the server which shared key to use without revealing it.
  7. Send. Exactly one UDP datagram goes out per destination IP, with send_delay_ms between 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).

  1. Receive. socket.rs reads a datagram into a 94-byte buffer. The socket is either inherited from systemd socket activation or bound to [::] as a fallback (socket.rs).
  2. Decode frame. The first 8 bytes are the key_id; the remaining 86 are the ciphertext blob.
  3. Select key. The server loads every *.key file in its config dir at startup; the key_id selects the matching CryptoHandler (keys.rs).
  4. Rate limit. RateLimiter::check enforces a per-IP cap (default 2 requests/second). This is throttling, not security (rate_limiter.rs).
  5. Decrypt. AES-256-GCM-SIV decrypts and verifies the tag. A bad key or tampered packet fails the tag check and is dropped silently.
  6. Deserialize. The 58-byte plaintext becomes a ClientData struct. The leading version byte is checked against PROTOCOL_VERSION (it is authenticated, so this happens after the tag verifies).
  7. 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_ip in the packet must be one of the server’s configured IPs.
    • Strict source IP: if the client set strict and included a src_ip, it must match the real source IP of the datagram.
  8. 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.
  9. 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).

  1. Receive. The commander reads the 24-byte CommanderData from the Unix socket.
  2. 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.
  3. Execute. It runs the configured shell string via sh -c, with the environment variable RUROCO_IP set to the requesting client’s IP (so commands can reference $RUROCO_IP, for example to allow that exact IP through the firewall).
  4. 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) from commander (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.rs and 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 by gen.
  • 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 prepend key_id.
  • decode (server): split [0..8] as key_id and [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
FieldBytesTypeMeaning
version[0]u8PROTOCOL_VERSION (currently 1). Authenticated; checked after the GCM tag verifies.
cmd_hash[1:9]u64Blake2b-64 hash of the command name. The name itself is never sent.
counter[9:25]u128Monotonic nanosecond timestamp. Drives replay protection.
strict[25]bool1 means enforce source-IP match. It is !permissive from the CLI.
src_ip[26:42]16 bytesThe claimed client IP, or all-zeros for “none”.
dst_ip[42:58]16 bytesThe 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_ip that differs from the datagram’s real source IP.

So:

  • Default (strict = true, no --ip): no src_ip is claimed, so the strict check passes trivially; the firewall command sees the real source IP via $RUROCO_IP.
  • --ip X without --permissive: the server enforces that the real source IP equals X. This defends against an attacker spoofing your source address.
  • --ip X --permissive: the server accepts the packet from any source and uses X downstream. 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]) -> ClientData reads 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.

PrimitiveAlgorithmUsed forWhere
Symmetric encryptionAES-256-GCM-SIV (OpenSSL)confidentiality + authenticity of the packetcrypto/handler.rs, crypto/handler_ops.rs
HashingBlake2b, 8-byte outputmapping command names to cmd_hashcrypto/mod.rs::blake2b_u64
SignaturesEd25519 (OpenSSL)verifying self-update binariescrypto/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 OpenSSL rand_bytes for both halves (surfaced to the user as ruroco-client gen and the GUI’s Generate button).
  • The same string is placed on the client (used with send) and on the server (one or more *.key files in the config dir).
  • CryptoHandler is ZeroizeOnDrop, and its Debug impl prints the key as <redacted>, so the secret never lands in logs or memory dumps after use.
  • The key_id is 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. encrypt generates 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. decrypt only 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 encrypt and decrypt assert the produced length equals PLAINTEXT_SIZE / CIPHERTEXT_SIZE and 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_KEY GitHub 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 .sig are verified with verify_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 reseed recovers 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_IP interpolated. Keep commands minimal and treat $RUROCO_IP as 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; reseed fixes 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.

FeaturePulls inEnables
with-clientureq, tempfile, opensslthe client module (send, gen, update, wizard, counter, lock)
with-commandertomlthe commander module (no OpenSSL, no UDP/decrypt)
with-serveropenssl, + with-commanderthe server module (network-facing daemon)
with-guieframe, toml, + with-clientthe ui module
android-buildjni, ndk-context, android-activity, wgpu, + with-guiAndroid GUI backend
with-vendored-opensslopenssl/vendoredstatic 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:

TargetWhat it does
make buildbuilds all four binaries (debug, x86_64-unknown-linux-gnu), each with its own feature
make testcargo nextest with all features and TEST_UPDATER=1 (runs networked update tests)
make test_unittests excluding the integration binary
make test_integrationonly the integration test (spins a real commander thread)
make checkcargo check --locked and cargo check --locked --no-default-features
make formatcargo fmt, then clippy -D warnings, then cargo fix
make coveragecargo tarpaulin (llvm engine), xml + html output
make releaserelease_android + release_linux
make release_linuxrelease build of all binaries (client/server/ui add with-vendored-openssl for static OpenSSL; commander uses with-commander, no OpenSSL)
make gen_signing_keygenerate the Ed25519 release signing keypair (one-time)
make install_clientbuild release, copy client binaries into ~/.local/bin
make install_serveralso copy server binaries to /usr/local/bin, then run the wizard
make test_end_to_endfull 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 via LISTEN_FDS; if absent it falls back to binding [::] itself.
  • ruroco.service: runs ruroco-server as the dedicated low-privilege ruroco user. Heavily sandboxed: it holds no capabilities (port 80 is bound by ruroco.socket, not the service), has its blocklist in a StateDirectory (/var/lib/ruroco) so /etc/ruroco stays fully read-only, and is restricted to AF_UNIX (correct only under socket activation — see the comments in the unit before changing the socket).
  • ruroco-commander.service: runs ruroco-commander as root, owning the Unix socket (placed in a RuntimeDirectory, /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 keeps CAP_CHOWN (to chown the socket) plus CAP_NET_ADMIN/CAP_NET_RAW and INET/NETLINK address families so the documented ufw firewall commands still work. Tighten to CAP_CHOWN + AF_UNIX only if all your commands are systemctl/dbus-style (see the comments in the unit).

Config files

  • /etc/ruroco/config.toml: allowed ips, rate limit, clock skew, socket user/group, config_dir, and the optional blocklist_dir / socket_dir relocations (defaulting to config_dir). Read by both processes through their own views (ConfigServer reads the server fields, ConfigCommander the socket-ownership fields; config_dir and socket_dir overlap). Has no command map. The whole /etc/ruroco directory is mounted read-only for the server; its mutable state lives in StateDirectory/RuntimeDirectory instead. See config and keys and Commander.
  • /etc/ruroco/commands.toml: the [commands] map (name to shell string), root-owned 0600. 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 *.key file; the packet’s key_id selects 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):

FilePurpose
*.keythe shared key (also pass via -k)
counterraw big-endian u128 replay counter
client.lockPID-based single-instance lock
commands_list.tomlthe 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-exportFromUsed by
blake2b_u64cryptoclient (build hash) and commander (command lookup)
get_random_rangecryptofs::write_atomic temp-name, UI
crypto_handler (alias for crypto::handler)cryptoparser
change_file_ownership, resolve_pathfsserver, update, wizard
infologgingeverywhere
client_data, data_parser (alias for protocol::parser)protocolclient, 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) and get_random_range, which pull in OpenSSL: with-client or with-server (never the commander).
  • ipc (CommanderData, socket path), normalize_ip, deserialize_ip: with-server or with-commander (both roles need them; no OpenSSL involved). The config structs themselves are not in common - ConfigServer is in server::config, ConfigCommander/ConfigCommands in commander::config.
  • write_atomic: with-server or with-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 ClientData struct, 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 a Zeroizing buffer, splits off the first 8 bytes as the id and the remaining 32 as the key, 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 is trimmed), 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 OpenSSL rand_bytes, concatenates them, and base64-encodes the result. This is the string surfaced by ruroco-client gen and 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: prints key: "<redacted>", so the secret never leaks into a log line or a panic message. A test asserts the raw key bytes never appear in the Debug output.

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]>
}
  1. Generates a fresh random 12-byte IV (rand_bytes).
  2. Runs AES-256-GCM-SIV in encrypt mode over the 58-byte plaintext.
  3. Asserts the produced ciphertext length equals PLAINTEXT_SIZE and that finalize emits 0 extra bytes (GCM is a stream cipher mode, so the lengths match).
  4. Reads the 16-byte authentication tag.
  5. 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]>
}
  1. Splits the blob into IV [0:12], tag [12:28], ciphertext [28:86].
  2. Runs AES-256-GCM-SIV in decrypt mode.
  3. Asserts the plaintext length equals PLAINTEXT_SIZE.
  4. Sets the expected tag and calls finalize. If the tag does not verify (wrong key, tampering, truncation), finalize errors and the function returns Err. 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

  • encrypt is client-only and decrypt is 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_id is 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 CryptoHandler expecting to see the key: the Debug impl 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>: hashes command with blake2b_u64 into cmd_hash and stores the rest verbatim.

  • serialize(&self) -> Result<[u8; 58]>: writes the fixed big-endian layout into a 58-byte array:

    FieldOffsetEncoding
    version[0]PROTOCOL_VERSION byte (currently 1)
    cmd_hash[1:9]u64 big-endian
    counter[9:25]u128 big-endian
    strict[25]1 or 0
    src_ip[26:42]serialize_ip, or all-zeros if None
    dst_ip[42:58]serialize_ip

Server side (with-server)

  • deserialize(data: [u8; 58]) -> ClientData: reads the same layout back. The version byte at [0] is checked against PROTOCOL_VERSION (after the GCM tag has verified). A src_ip field of all-zeros decodes to None (the “no claimed source IP” sentinel); any other value decodes via deserialize_ip.
  • is_source_ip_invalid(&self, source_ip: IpAddr) -> bool: returns true only when self.strict is set and self.src_ip is Some and the stored value differs from the datagram’s real source_ip. In all other cases it returns false (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 in ClientData are fixed 16-byte slots.
  • deserialize_ip (server only): reconstructs an Ipv6Addr from the 16 bytes and runs it through normalize_ip, so an IPv6-mapped IPv4 comes back out as a clean IpAddr::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, currently 1). 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.
  • decode returns borrowed slices into the input datagram; the server must keep that buffer alive while decrypting.
  • An all-zero src_ip is meaningful: it is the wire encoding of None, not of 0.0.0.0. The client never claims 0.0.0.0 as 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.&lt;random u16&gt;.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:

  1. Build a unique temp path next to the target using get_random_range(0, u16::MAX) as a suffix.
  2. Open it with create + write + truncate.
  3. write_all the contents, then sync_all() to force the bytes to disk before the rename.
  4. rename the 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.
  5. Best-effort fsync of 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 id in 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!("...")) or info("literal"). The project convention is to never write info(&format!(...)): borrowing a temporary is unnecessary and reads worse.
  • info prints to stdout, error to stderr, each prefixed with a UTC timestamp formatted as %Y-%m-%dT%H:%M:%SZ (via chrono::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,
}
}
BytesFieldEncoding
[0:8]cmd_hashu64 big-endian (to_be_bytes)
[8:24]ip16 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::ConfigServer reads the server-only fields (ips, rate limit, clock skew) plus config_dir. See Config and keys.
  • commander::config::ConfigCommander reads config_dir (plus the optional socket_dir) and the commander-only socket_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:

  1. Resolve the configuration directory with config::get_conf_dir()?.
  2. Acquire a single-instance PID lock at <conf_dir>/client.lock via ClientLock::acquire(...). The lock is held in a _lock binding for the whole function body; its Drop impl removes the lock file on return.
  3. Match on client.command (a CommandsClient) and dispatch:
    • Gen(_) builds Generator::create()? and calls .gen()?.
    • Send(send_command) builds Sender::create(send_command)? and calls .send().
    • Update(update_command) builds an Updater from the command’s force, version, bin_path, and server fields and calls .update().
    • Wizard(_) runs Wizard::create().run().
    • Reseed(_) calls Counter::reseed(Sender::get_counter_path()?, now_nanos()?)?, logs Counter reseeded, and returns Ok(()).

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.

VariantWrapped structHandler in run_clientEffect
GenGenCommandGenerator::gen()Print a fresh base64 AES key with embedded key id
SendSendCommandSender::send()Build and send the encrypted UDP packet
UpdateUpdateCommandUpdater::update()Self-update the client (or server) binary
WizardWizardCommandWizard::run()Interactive server-side setup
ReseedReseedCommandCounter::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 (see lock.rs).
  • <conf_dir>/counter: the replay counter, a raw big-endian u128 (see counter.rs, path produced by Sender::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::create stores cmd_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 exactly PLAINTEXT_SIZE = 58 bytes. The full datagram is MSG_SIZE = 94 bytes: 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) over pub. Internal items are crate-private; only the handful of types that the binaries and GUI need (for example CliClient, SendCommand, Sender, Generator, Counter) are pub.

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 parser CliClient, the CommandsClient subcommand enum, and get_conf_dir.
  • src/client/config/commands.rs: the per-subcommand argument structs (GenCommand, ReseedCommand, SendCommand, UpdateCommand, WizardCommand) and the Default impl for SendCommand.

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 to AndroidUtil::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:

  1. If the RUROCO_CONF_DIR environment variable is set, use it verbatim as the path. This is the hook tests use to isolate state.
  2. Otherwise, if HOME is set, use $HOME/.config/ruroco.
  3. Otherwise, fall back to the current working directory (env::current_dir()), adding the context Could not determine config dir on 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:

FieldFlagsTypeDefaultMeaning
address-a, --addressString(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, --keyString(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, --commandString"default"The command name to invoke. Only its Blake2b-64 hash is sent.
permissive-e, --permissiveboolfalseAllow 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, --ipOption<String>NoneOptional 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, --ipv4boolfalseRestrict the destination to IPv4 addresses.
ipv6-6, --ipv6boolfalseRestrict the destination to IPv6 addresses.
send_delay_ms-d, --send-delay-msu6450Milliseconds 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 --ip help text suggests using -6ei "dead:beef:dead:beef::/64" to allow a whole IPv6 network, and gives a one-liner to derive that automatically from api64.ipify.org.
  • ipv4 and ipv6 together (or neither) mean “no family restriction”; the resolver treats ipv4 == ipv6 as the undefined case. See send.md for 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,
}
}
FieldFlagsTypeMeaning
force-f, --forceboolForce the update even when versions match.
version-v, --versionOption<String>Target version (for example v0.14.2).
bin_path-b, --bin-pathOption<PathBuf>Directory where binaries are written.
server-s, --serverboolUpdate 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: the Sender struct, construction, port normalization, the send loop, 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 parsed SendCommand (with address already port-normalized).
  • data_parser: a DataParser built from cmd.key. It owns the CryptoHandler that holds the 32-byte AES key and the 8-byte key id, and performs encryption plus the key-id prepend.
  • counter: the replay Counter, 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:

  1. Normalize the destination: cmd.address = Self::ensure_port(cmd.address, 80).
  2. Compute the counter path with Self::get_counter_path()? and log Loading counter from <path> ....
  3. Build the DataParser with DataParser::create(&cmd.key)?. This is where an invalid key fails early (for example Key too short for an 8-byte input).
  4. 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 address starts 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]:1234 is unchanged.
  • Else if address contains : (an IPv4 with port like 1.2.3.4:5678, or a bare IPv6): keep it as-is.
  • Else (a hostname or a bare IPv4): append :<default_port>, so 127.0.0.1 becomes 127.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:

  1. Log Connecting to udp://<address>, using <openssl version> ....
  2. Resolve destinations with self.get_destination_ips()? (see below). This returns the validated, family-filtered list of IpAddr.
  3. Log the discovered IPs.
  4. Iterate the IPs with their index i. For every IP after the first (i > 0), if send_delay_ms > 0, sleep Duration::from_millis(send_delay_ms) before sending. Then call self.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 is strict. strict = !permissive. When permissive is false (the default), strict is true and the server enforces that the real source IP matches the claimed --ip.
  • Best-effort source IP. self.cmd.ip is parsed with .parse().ok(); an unparsable string becomes None (no source IP), which serializes as 16 zero bytes.

The ClientData plaintext layout (from ClientData::serialize) is exactly PLAINTEXT_SIZE = 58 bytes:

OffsetSizeField
0..11version (PROTOCOL_VERSION byte, currently 1)
1..98cmd_hash (Blake2b-64 of the command name, big-endian)
9..2516counter (u128, big-endian)
25..261strict (0 or 1)
26..4216src_ip (IPv6-mapped, all zero if None)
42..5816dst_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>>
}
  1. Resolve cmd.address with to_socket_addrs(). On failure the error carries the context Could not resolve hostname for <address>.
  2. Split the resolved SocketAddrs into IPv4 and IPv6 lists.
  3. Let use_ip_undef = (cmd.ipv4 == cmd.ipv6), i.e. the family is “undefined” when both flags are set or both are unset.
  4. Select results by matching on (first IPv4, first IPv6):
ConditionResult
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 IPv6error Could not find any IPv6 address for <address>
cmd.ipv4 set but no IPv4error Could not find any IPv4 address for <address>
nothing resolvederror 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:

  1. 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.
  2. Pick the bind address by family: 0.0.0.0:0 for IPv4, [::]:0 for IPv6.
  3. Log Connecting to <ip>....
  4. self.get_data_to_encrypt(ip)? builds the 58-byte plaintext.
  5. self.data_parser.encode(&data_to_encrypt)? produces the 94-byte datagram.
  6. Bind a UdpSocket to the bind address, connect to cmd.address, and send the bytes. Each of the three socket calls adds the context Could not connect/send data to <address> via Self::socket_ctx.
  7. 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 output CIPHERTEXT_SIZE = 86 bytes 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), so 12 + 16 + 58 = 86.
  • Frame / key_id prepend (DataParser::encode): prepend the 8-byte key id in front of the 86-byte ciphertext block, giving MSG_SIZE = 94 bytes: [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:

  1. Build Self { path, count: 0 }.
  2. Try read(). If reading the existing file succeeds, count is set to the stored value (the file wins).
  3. If read() fails (typically because the file does not exist yet), set count = initial and write() 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<()>
}
  • write calls File::create(path) (truncating) and write_all(&count.to_be_bytes()), with contexts Could not create counter file <path> and Could not write counter file <path>.
  • read opens the file, read_exact into a [0u8; 16] buffer, then u128::from_be_bytes. Contexts: Could not open counter file <path> and Could 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_init will not reset an existing file: re-initializing reads the stored value and ignores initial.

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>
}
  1. Try Self::open(&path), which uses OpenOptions::new().create_new(true).write(true) so it fails with AlreadyExists if the file is already there.
  2. On AlreadyExists: read the file, parse its contents as a u32 PID. If that PID is_pid_running, bail with Client already running (lock at <path>). Otherwise the lock is stale: remove the file and re-open it, adding the context Client lock unavailable at <path> after cleanup on failure.
  3. On any other open error: bail with Client lock unavailable at <path>: <e>.
  4. Write the current process id (std::process::id()) into the file (best-effort: the result of writeln! is ignored) and return the held ClientLock.

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 unavailable if the parent directory does not exist, since create_new cannot 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: the Updater type, 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: when true, skips both “already up to date” short-circuits and always downloads.
  • version: an optional explicit release tag (for example v0.14.2). None means “latest”.
  • bin_path: the directory the binary or binaries are written into.
  • server: when true, downloads and installs the server-side binaries (ruroco-server and ruroco-commander) instead of the client binaries (ruroco-client and ruroco-client-ui).
  • public_key_pem: the Ed25519 public key in PEM form used for verification. In production it is initialized from the embedded RELEASE_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 to GH_RELEASES_URL. Overridable so tests can point at a local TcpListener instead 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 via validate_dir_path.
  • None and server == true: defaults to SERVER_BIN_DIR (/usr/local/bin), validated.
  • None and server == false: defaults to $HOME/.local/bin, validated. Requires the HOME env var (Could not get home env on 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:

  1. Compute current_version = format!("v{}", env!("CARGO_PKG_VERSION")). The leading v matters: GitHub tags are vX.Y.Z, so the comparison is tag-to-tag.
  2. Early skip: if !force and Some(current_version) == self.version, log “Already using version …” and return Ok(()) without any network call.
  3. Fetch release metadata via get_github_api_data_from(&self.releases_url, self.version.as_ref()).
  4. Second skip: if !force and current_version == api_data.tag_name, log and return Ok(()). This catches the version == None (latest) case where the latest already matches what is installed.
  5. Branch on self.server:
    • server: download server-{tag}-{ARCH}-{OS} into SERVER_BIN_NAME (ruroco-server) with mode 0o500 and owner ruroco, then commander-{tag}-{ARCH}-{OS} into COMMANDER_BIN_NAME (ruroco-commander) with the same mode/owner.
    • client: download client-{tag}-{ARCH}-{OS} into CLIENT_BIN_NAME (ruroco-client) with mode 0o755 and no chown, then client-ui-{tag}-{ARCH}-{OS} into CLIENT_UI_BIN_NAME (ruroco-client-ui) with mode 0o755 and no chown.

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.Z tags, not semantic-version ordering. “Newer” effectively means “different from current”. --force bypasses the checks entirely and can therefore reinstall or downgrade.
  • The download targets the exact ARCH/OS of 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 whose tag_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:

  1. target_bin_path = self.bin_path.join(bin_name).
  2. Download the binary bytes and the signature bytes (both via download_bytes).
  3. 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.
  4. If the target already exists, rename it to {target}.old (Could not rename existing binary on failure).
  5. fs::write the new bytes. On write failure, rename {target}.old back to the original path (Could not recover old binary if even that fails) and then bail!("Could not write new binary to ..."). This is the rollback path: a failed write does not leave the host without a working binary.
  6. On Unix: set_permissions(target, permissions_mode), then if user_and_group is Some(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_all it (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): error can'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 .old file 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::write failure case. A failure in set_permissions or change_file_ownership after 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 as ruroco: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_key runs openssl genpkey -algorithm ed25519 to produce keys/ruroco-release-ed25519.key (private, gitignored, kept secret and backed up offline) and openssl pkey ... -pubout to produce keys/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 .sig for 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.0 and later releases ship .sig assets, so those are the only versions the signature-checked update path can install. Earlier releases would fail the .sig lookup 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 of Wizard.
  • core.rs: the Wizard type and the run flow plus the file-writing/config helpers.
  • wizard_systemd.rs: the hard-coded /etc paths, the compile-time embedded unit and config bytes, and the systemctl / useradd / Updater helpers.

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 ?):

  1. create_ruroco_user(): create the ruroco system user (see wizard_systemd.rs).
  2. update(): forced self-update of the server binaries into /usr/local/bin.
  3. write_data(RUROCO_SERVICE_FILE_PATH, RUROCO_SERVICE_FILE_DATA): write ruroco.service.
  4. write_data(COMMANDER_SERVICE_FILE_PATH, COMMANDER_SERVICE_FILE_DATA): write ruroco-commander.service.
  5. write_data(SOCKET_FILE_PATH, SOCKET_FILE_DATA): write ruroco.socket.
  6. init_config_file(): write /etc/ruroco/config.toml if it does not already exist, then set its mode to 0o600.
  7. reload_systemd_daemon(): systemctl daemon-reload.
  8. enable_systemd_services(): systemctl enable the three units.
  9. start_systemd_services(): systemctl start the three units.
  10. Print a multi-line completion banner instructing the operator to review /etc/ruroco/config.toml, generate a key with ruroco-client gen, place the key file in the configured config_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_data overwrites unit files unconditionally; only the config is preserved across runs.
  • The config existence guard checks the real /etc/ruroco/config.toml path; there is no injectable path here, so unit tests cover the helpers (write_data) rather than the full run against 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.socket is PartOf=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 via LISTEN_FDS/LISTEN_PID).
  • ruroco.service runs ruroco-server as the unprivileged ruroco user with a tightly restricted sandbox (ProtectSystem=strict, RestrictAddressFamilies=AF_UNIX, CapabilityBoundingSet=CAP_NET_BIND_SERVICE, broad SystemCallFilter deny-lists, ReadWritePaths=/etc/ruroco). It both Requires and is ordered After ruroco-commander.service and ruroco.socket.
  • ruroco-commander.service runs ruroco-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 (allowed ips, socket user/group, rate limit). The commander additionally reads /etc/ruroco/commands.toml, whose [commands] section defines what it can run (for example the open_port / close_port ufw rules in the shipped sample config). The wizard writes both files with mode 0o600; the command set is kept out of config.toml so 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 with 14.0 on both platforms during app construction.
  • run_ui (desktop): resolves the config dir via config::get_conf_dir(), acquires a ClientLock on <conf_dir>/client.lock (held for the process lifetime via _lock), then opens an eframe native window (inner size 540x1200, title ruroco) and constructs RurocoApp::new(&conf_dir).
  • run_ui_with_options (Android only): same lock + construction, but takes a caller-supplied NativeOptions (wgpu renderer + the AndroidApp handle) and a status_bar_dp inset height, constructing the app via RurocoApp::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:

  1. Reserve the Android status-bar inset (add_space(status_bar_dp)) if non-zero.
  2. On Android only, keep the soft keyboard in sync with egui_wants_keyboard_input().
  3. Draw the top tab bar (three selectable_value toggles bound to active_tab).
  4. Dispatch to the active tab’s render function in a CentralPanel.

Desktop vs Android

ConcernDesktopAndroid
Entry pointrun_ui() (native window)android_main -> run_ui_with_options() (wgpu / native-activity)
Clipboard copyui.ctx().copy_text(...)AndroidClipboard::set_text (JNI)
Clipboard pasteViewportCommand::RequestPaste event, handled next frameAndroidClipboard::get_text (JNI), applied immediately
AES key persistencenot persisted (load_persisted_key returns "")AndroidPrefs SharedPreferences (ruroco/aes_key)
Soft keyboardn/aAndroidKeyboard::ensure_visible per frame
Status-bar inset0.0AndroidStatusBar::height_dp()
Update actionclient::update::Updaterupdate_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_text from commands_list.to_string() so the Dashboard’s editable config text starts as the serialized command list.
  • active_tab starts at Tab::Dashboard.
  • dashboard.key is seeded from DashboardState::load_persisted_key() (Android SharedPrefs, empty on desktop).
  • create.command defaults to config::DEFAULT_COMMAND; the other create fields start empty/false.
  • execute.status starts as an empty HashMap.

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 (a RequestPaste viewport command is sent and the resulting Paste event 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.

  • StatusKey is derived from a CommandData’s identifying fields (everything except name), so a command keeps its status even though name is recomputed/#[serde(skip)]. It is hashable and used as the HashMap key.
  • color_for: returns GREEN for Status::Ok, RED for Status::Err, and GRAY when the command has no recorded status yet (never run this session).
  • set: records a command’s run result, inserting/overwriting by StatusKey.

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):

  1. If status_bar_dp > 0.0, ui.add_space(self.status_bar_dp) to clear the Android status bar.
  2. Under cfg(all(target_os = "android", feature = "android-build")), call AndroidKeyboard::ensure_visible(ui.ctx().egui_wants_keyboard_input()) to show/hide the soft keyboard, logging any error.
  3. A top egui::Panel::top("tabs") with a horizontal row of three selectable_value toggles bound to &mut self.active_tab.
  4. A CentralPanel that matches on self.active_tab and calls the corresponding tab render:
    • 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:

  1. Desktop paste completion. Scans this frame’s input events for an egui::Event::Paste(text). If dashboard.paste_target is 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_target is consumed with take().
  2. Renders the config sub-view (dashboard_config::render, given available_height() * 0.45), a separator, then the key sub-view (dashboard_key::render).
  3. 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 calls update_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-render config_text from 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 success dashboard.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_button on the left.
  • A right-to-left layout with a red 🗑 delete icon_button anchored right and the command name in a bordered box whose stroke color comes from state.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 a CommandData into a send CLI string, emitting only non-empty / true fields (--address, --command, --ip, --ipv4, --ipv6, --permissive). A Some(key) appends --key <k> (used when actually sending; persisted/display forms pass None). 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 calling add_command_name. Gotcha: it does not validate, so malformed input silently yields empty/default fields.
  • add_command_name: builds name as "{command}@{address}" plus permissive / ipv4 / ipv6 suffixes 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-separated send ... 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 through command_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 sets path, recomputes every name via add_command_name, and sorts.
  • get: borrow the current slice.
  • add / set / remove: mutate the list and immediately call save(). add and set also sort() first (remove preserves order). There is no batching: every mutation writes to disk.
  • save: serializes to TOML and writes via write_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 (no Self instance) returning an egui::Frame with a 2px stroke in color, 5px corner radius, and the given inner margin. Used both for icon buttons and the Execute tab’s status box.
  • icon_button: a fixed 46x46 button inside a bordered(color, 1.0) frame; returns the button’s Response. The closure body always runs, so the expect("frame body always runs") cannot fire.
  • equal_buttons: lays out labels.len() buttons of equal width (accounting for 8px gaps) in a horizontal row, each 50.0 tall, and returns a Vec<bool> of click states indexed to match the input labels.
  • copy_text: clipboard copy. On Android calls AndroidClipboard::set_text (logging errors); on desktop self.ui.ctx().copy_text(...).
  • paste_button: clipboard paste. On Android calls AndroidClipboard::get_text and applies the text immediately to the key or config field. On desktop it instead records dashboard.paste_target = Some(target) and fires ViewportCommand::RequestPaste; the actual text arrives as a Paste event handled next frame in tabs/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.rsAndroidUtil

#![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.rsAndroidUtil 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 a Global ref (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 to JString (unsafe { as_cast_unchecked }, relying on the caller having a real java.lang.String) and reads its MUTF-8 chars into a Rust String.
  • unpack_result: turns a JNI call Result into a JObject, 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.rsAndroidClipboard

#![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.rsAndroidKeyboard

#![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 the input_method service (InputMethodManager) showSoftInput(decorView, 0). Uses InputMethodManager.showSoftInput rather than the NDK ANativeActivity_showSoftInput, which is ignored on many devices.
  • hide: gets the decor view’s window token and calls InputMethodManager.hideSoftInputFromWindow(token, 0).

src/common/android/prefs.rsAndroidPrefs

#![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.rsAndroidStatusBar

#![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 typeAndroid behaviorDesktop
AndroidClipboardJNI ClipboardManageregui ctx().copy_text / RequestPaste
AndroidKeyboardInputMethodManager show/hidenot called
AndroidPrefsSharedPreferences rurocoload_persisted_key returns "", save_key skips persist
AndroidStatusBarreads status_bar_height dimenstatus_bar_dp = 0.0
AndroidUtilIntent/Uri/files dirdesktop 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-byte CommanderData message 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_mapped on the wire and are collapsed back via normalize_ip on receipt.
  • CommanderData on 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.

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:

  1. Explicit address argument (Some(address)): UdpSocket::bind(address). This is what the tests use to bind ephemeral ports such as 127.0.0.1:0.
  2. RUROCO_LISTEN_ADDRESS env var set and no argument: UdpSocket::bind(address).
  3. systemd socket activation: when LISTEN_PID equals the current process id (as a string) and LISTEN_FDS == "1", the socket is adopted from raw file descriptor 3:
    #![allow(unused)]
    fn main() {
    let fd: RawFd = 3;
    let sock = unsafe { UdpSocket::from_raw_fd(fd) };
    }
    systemd guarantees FD 3 is the first passed socket. Ownership of the fd transfers to the returned UdpSocket; this is the only unsafe block in the server path, and it is justified by the two environment checks above. This lets ruroco start on demand from a .socket unit without the daemon ever binding a port itself.
  4. Misconfigured activation guards:
    • LISTEN_FDS != "1" returns Err("LISTEN_FDS was set to {n}, expected 1").
    • LISTEN_PID not matching the current PID returns Err("LISTEN_PID ({pid}) does not match current PID").
  5. 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 [::], not 0.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_iteration finishes 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):

CauseWhereMessage fragment
Wrong datagram lengthreceive loopInvalid read count
Rate limit exceededcheck_rate_limitRate limit exceeded
Unknown key id / decrypt failuredecryptCould not find key for id
Replayed/old countervalidate_and_send_commandis on blocklist
Destination IP not configuredvalidate_and_send_commandInvalid host IP
Strict source IP mismatchvalidate_and_send_commandInvalid source IP
Commander socket unreachablesend_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.

  • create reads <dir>/blocklist.msgpck if it exists and deserializes it with rmp_serde (a corrupted file is a hard error: "Could not create blocklist from vec"), otherwise starts with an empty map. It then immediately save()s, so the file always exists after create.
  • save serializes the whole struct with rmp_serde::to_vec and writes it through write_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 (add then save). 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 >= max the 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 HashMap is 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’s dst_ip must be in this list (handler step 2). Defaults to ["127.0.0.1"]. Each entry is run through normalize_ip on load (via deserialize_ips), so "::ffff:127.0.0.1" is stored as 127.0.0.1.
  • config_dir: directory holding the *.key files (and, by default, blocklist.msgpck and ruroco.socket). Defaults to /etc/ruroco from TOML, or the current working directory in Default.
  • blocklist_dir: optional override for where blocklist.msgpck is persisted; defaults to config_dir. Point it at a writable systemd StateDirectory (/var/lib/ruroco) so config_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 where ruroco.socket lives; defaults to config_dir. Point it at a systemd RuntimeDirectory (/run/ruroco). Server and commander must resolve the same value (the commander reads the same field via ConfigCommander).
  • 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 .key extension filter.
  • Two distinct files with the same content is a hard startup error, not a warning.
  • resolve_config_dir runs config_dir through resolve_path before 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: the Commander struct and accept loop.
  • exec.rs: socket setup, shell execution, and the run_commander entry point.
  • config.rs: ConfigCommander (the commander’s view of config.toml), ConfigCommands (the commands.toml schema), and CliCommander.

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 user ruroco / group ruroco.

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 the socket_user so it actually holds the write bit.
  • The commander must be able to bind in config_dir; the directory is created with create_dir_all if absent.
  • cmd_hash is 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.