Technical overview
This document describes core mechanisms in the current code. Complete message fields, frame formats, and handshake sequences are in Wire protocol.
Operators and devices both connect outbound to exchange
Home and branch-office devices usually sit behind NAT, so exchange cannot dial into them. Both sides initiate WebSocket connections to exchange:
- Devices connect to
wss://<主机>/ws/device(ws://for plaintext debugging), registering with a device token in the first frame. - Operators connect to
wss://<主机>/ws/operator, using an operator token for the handshake.
exchange handles authentication, routing, and audit trails. --listen or REMOTE_LISTEN determines its listen address; the reverse proxy in front terminates TLS.
Two session ID spaces
Operators and devices maintain independent session IDs. Operators can include their own IDs in requests without first waiting for server allocation before sending subsequent frames. Device-side IDs are allocated independently for each current device connection, incrementing from 1. Even when two operators both use local ID 1, they receive different IDs on the device.
ID 0 is reserved for connection-level messages. Registration fails without inserting a route if the operator-side ID is 0 or already used on that operator connection.
The translation table is in crates/exchange/src/hub/session_table.rs. hub.rs attaches translations to individual connections: device ID → operator ID on the device side, and operator ID → (device connection, device ID) on the operator side.
Forwarding rewrites only the session ID
PTY bytes and command stdout/stderr use binary frames. The nine-byte header contains a one-byte stream type, four-byte session_id, and four-byte payload length, followed by raw payload. Its definition is in crates/proto/src/frame.rs.
exchange replaces only session_id when forwarding:
- Operator to device:
crates/exchange/src/ws/operator.rsusesDataFrame::with_session_idto substitute the device-side ID, then places encoded bytes in the device send queue. - Device to operator:
handle_dataincrates/exchange/src/ws/device.rsreplaces the device-side ID with the operator's original ID. The source comment describes this rewrite.
Payloads pass through unchanged. Routing does not inspect PTY keystrokes or command output; frames without a matching session are discarded. Control messages are JSON text frames. Opening and closing sessions likewise translate IDs into the recipient's ID space while preserving business fields.
Device reconnection replaces the old connection
When a device reconnects, its new connection enters the online table and replaces the old one, which the caller closes. Rejecting the new connection as a duplicate is unsuitable: a half-open old connection may remain registered, forcing the device to wait for its timeout.
Each device connection has an epoch (UUID), generated in Hub::register_device. Disconnect cleanup uses Hub::unregister_device, removing the device and marking it offline only when the registered epoch matches the cleanup epoch. If old-connection cleanup arrives after new-connection registration, the epochs differ, the function returns an empty list, and the new connection remains registered.
Send queues are bounded; a full queue closes its connection
Each WebSocket has a queue of capacity 512, implemented by channel in crates/exchange/src/hub/outbound.rs. Routing holds the connection-table lock, so delivery uses only Sender::try_send, without waiting for the peer under the lock.
A full queue makes try_send return SendError::Lagging and set the close signal. A Close control message bypasses the queue and sets the same signal, so even a blocked queue can request shutdown. Both device and operator receive loops await sender.closed() inside tokio::select! (crates/exchange/src/ws/device.rs, crates/exchange/src/ws/operator.rs). Once signaled, forwarding ends, then unregisters the device by epoch or cleans up the operator's sessions. A peer that already dropped the receiver produces SendError::Closed, distinguished from a consumer that cannot keep up.
Tokens contain a prefix and secret; only an argon2 hash of the secret is stored
Plaintext tokens have the form <prefix>.<secret>. split_token in crates/exchange/src/auth.rs splits at the first ., rejecting an empty prefix or secret as malformed.
At issuance, the prefix is eight random bytes encoded as hexadecimal, and the secret is 32 random bytes encoded as base64url. The database stores only two columns:
token_prefix: plaintext with a unique index, used for one lookup during verification.token_hash: the secret's argon2id PHC string, generated byhash_secret.
Device and operator accounts use this format. Verification retrieves the hash by prefix, then compares the secret with verify_secret. Plaintext is returned only at issuance and cannot be recovered from the database.
argon2 uses 8 MiB of memory, two iterations, and parallelism 1, below library defaults. Secrets are 256-bit random values; additional slow hashing adds no practical brute-force protection but increases handshake latency when devices reconnect together. These parameters apply only to this kind of random token.
Related documents
- Development
- Usage
- Wire protocol
- Deployment guide: repository
docs/deployment.md(contains server addresses; not published on this site)