vike
Concepts

The node protocol

How the desktop app and the CLI talk to a running daemon — length-prefixed JSON frames, an HMAC nonce-challenge handshake with capability bound into the signature, and a wire vocabulary that mirrors the core without depending on it.

vike-tradehub-client is the light crate a thin client links to reach a headless node. It carries no server and no DataFusion; what it defines is the wire schema (proto.rs), the auth primitive bound to this service (auth.rs), and standalone mirrors of the snapshot and command vocabulary (wire.rs). The server half lives in the daemon.

Framing

A frame is [u32 len][len bytes of UTF-8 JSON], big-endian, and the codec is re-exported from vike_datahub_client::proto rather than re-implemented: both localhost services — the data service and the live-core node — speak the identical frame with the same pre-allocation guard, MAX_FRAME_LEN. Two primitives matter to a server. read_frame_raw returns the framed body without decoding, so a well-framed body that fails to decode into a known request is answered with Response::Error and the connection survives — a decode error is a bad request, not a bad connection. read_frame_raw_capped reads pre-auth frames under a ceiling far below MAX_FRAME_LEN, so an unauthenticated peer cannot make the node allocate from a four-byte length prefix.

The handshake

The core is a headless process that accepts orders, so an unauthenticated peer must never reach the command path. The handshake is a nonce-challenge HMAC:

  1. Client → Request::Hello { proto_version }.
  2. Server → Response::Welcome { proto_version, nonce, features } — nonce is 32 fresh random bytes for this connection.
  3. Client → Request::Auth { scope, mac }, where the tag is auth::sign(key_for(scope), &nonce, proto_version, scope).
  4. Server recomputes and compares in constant time: AuthOk { scope }, or AuthDenied { reason } with a reason that never leaks key material. Only after AuthOk may the client subscribe, ask for a snapshot, or — with Scope::Control — send a command.

The signed message is b"vike-tradehub-auth\0" (a 19-byte, NUL-terminated domain separator), the protocol version as a big-endian u32, one scope byte (Observe 0x00, Control 0x01), and the nonce; the tag is HMAC-SHA256. Each field buys a property. The separator makes the preimage disjoint from every other HMAC the workspace signs — the venue request signers, and the data service's own vike-datahub-auth\0 — so a tag captured off one surface is useless at the other. The scope byte binds capability into the signature. The nonce binds the tag to one connection, so a captured transcript cannot be replayed. The version in the message means a client and server that disagree on it can never produce a matching tag. Verification uses the hmac crate's constant-time verify_slice, never a byte-wise ==.

The two scopes sign under different keys. Scope::Observe is snapshots and subscriptions; Scope::Control additionally admits commands; and because an observe-only key cannot produce a valid control tag, capability is cryptographic rather than a claim the server trusts. The keys are VIKE_TRADEHUB_OBSERVE_KEY and VIKE_TRADEHUB_CONTROL_KEY, read by auth::from_vars from the credential-store map the binary already loaded — not the process environment, where a store-defined key is invisible. Neither present means nothing to authenticate, the same credential-is-the-gate idiom the venues use; an absent control key brings the node up observe-only, while an absent observe key means the daemon starts no node server at all. NodeKeys redacts its Debug, and NodeKeys::key_id yields a stable nk- plus sixteen hex characters fingerprint an audit record can name a credential by.

An empty key is not a closed gate

The NodeKeys::key_for doc records a claim that stood until 2026-08-29 — that verifying against an absent key always fails. It is false: HMAC accepts a zero-length key, so a peer signing with the empty key produces a tag that verifies. What closes the gate is NodeKeys::has, consulted before the key; both servers refuse an unkeyed scope without reaching verify at all. The handshake is also plaintext: it authenticates the connection, not each frame, and confidentiality still comes from the tunnel or VPN in front of it.

Versions and features

NODE_PROTO_VERSION is 4 — v2 added bounded bar tails to the snapshot, v3 the server-authoritative Preview dry-run, v4 an optional rationale beside a command. Because the version is folded into the signed tag, a bump fails the handshake against every running node, so every verb since v4 rides Welcome.features instead: strategy-verbs, settings-show, settings-write, strategy-mount-verbs, and the one value-carrying entry, datahub=<addr>, which advertises the data service's dial address so an operator configures one address and the client dials the data plane itself — advertisement, never proxying. A client must check for the feature before sending the verb, so against an older server nothing goes on the wire and the caller gets "this node predates settings-write" rather than an undecodable-request error.

The vocabulary

Requests after auth: Subscribe { topics } over Topic::{Orders, Positions, Events, All}, Snapshot, Command { cmd, reason }, Preview(cmd), Ping, StrategyStatus, SettingsShow. Responses: SnapshotFrame, Ack { coid }, Preview { accepted, reason }, Error, Pong, StrategyStatus, SettingsShow, and SettingsWritten { restart_required } — the one accepted command that is not an Ack, because nothing entered the core; false means the node hot-applied the value, and policy keys never are. A command's reason is remote free text landing in the audit trail: the node sanitizes it, and it never reaches the order request, the core fold, the journal or a venue.

wire.rs re-declares the field shapes of the core's snapshot and command types rather than depending on vike-core, for two reasons its header gives: weight, since the live-runtime tree would otherwise ride into the thin client, and a stable contract, since an internal refactor of CoreSnapshot cannot then silently change the wire. WireCommand is deliberately the session-relevant subset — Submit, Cancel, Modify, MassCancel, Flatten, MarketExit, SetTradingState, UpdateParams, MountStrategy, UnmountStrategy, SetSetting — and not the reconcile and journal plumbing (ApplySnapshot, ReconcileReports, ConfirmRecon), which is never a remote client's business. UpdateParams and MountStrategy carry their parameter tables as the core's own serde JSON rather than a hand mirror that would drift within a release. The server lowers each variant into the core's real command at its edge and hands it to the control boundary's CommandSink.

Two handles wrap all of this. RemoteCoreHandle authenticates as Observe, subscribes, and exposes the latest pushed snapshot off a local arc-swap cell — the read side observe mode runs on. RemoteControlHandle authenticates as Control, never subscribes, and offers a non-blocking try_command over a worker thread whose ticket resolves to that command's outcome. Both run the one crate-private handshake; a version mismatch names both numbers, and an AuthDenied surfaces as PermissionDenied carrying the server's reason.

On this page