Advanced Metal Research
GitHub Contact AMR

Remote access (mTLS) and remote jog

On this page
  1. How the listener authenticates
  2. Enable the listener
  3. Provision certificates
  4. Connect a client
  5. Remote jog over WebSocket
  6. The exchange
  7. With the client libraries
  8. Related pages

By default rt-control listens only on a local Unix socket. To control a cell from another machine (a Steam Deck pendant, or an offline programming server on a workstation), you enable its remote listener: HTTPS with mutual TLS 1.3, where both sides present certificates from the cell's own component CA. The remote listener serves the same HTTP API as the local socket, plus a WebSocket lane for jogging.

Read Describe over mutual TLSBash
curl --cacert ca.pem --cert clients/pendant-1.pem --key clients/pendant-1-key.pem \
  https://rosie.local:8443/v1/describe

Warning

A remote client can do everything a local client can, including energising the drives and jogging. The hardware E-stop is the only emergency stop, and RosieOS has no software E-stop. Keep the E-stop within reach of whoever operates the robot, and read the safety model.

How the listener authenticates#

  • TLS 1.3 only, client certificate required. rt-control verifies the client against one configured CA certificate, which must be a self-signed CA. The client certificate must be issued directly by that CA; intermediate CAs are refused.
  • Identity comes from URI SANs. A client certificate must carry exactly one rosie-principal:<id> and exactly one rosie-pair:<pair> URI, both opaque tokens ([A-Za-z0-9_.:-]+). The pair must equal rt-control's --pair-id. The principal becomes the owner of any session that client acquires, so no other principal, and no local client, can use that session (session_principal_mismatch).
  • The CRL is checked on every handshake. A revoked serial, a CRL that the CA did not sign, a CRL with an unsupported critical extension, or a CRL outside its validity window all refuse the handshake.
  • Unauthenticated traffic never reaches the API. A failed handshake gets a TLS alert or a closed connection. Even a plaintext request, such as a Stop sent without TLS, receives no HTTP response.
  • Files are reloaded before each full handshake when their modification time changes. A reload that fails refuses new handshakes; it never falls back to the old credentials. Connections that are already open are not revoked, and TLS session tickets are disabled.

Enable the listener#

rt-control takes these flags. All four files are required when --remote-listen is set, and rt-control exits with status 2 if any check fails at startup.

FlagDefaultDescription
--remote-listenempty (disabled)host:port to listen on.
--remote-ca—PEM file with exactly one self-signed component CA certificate.
--remote-cert—Server certificate PEM.
--remote-key—Server private key PEM.
--remote-crl—CRL PEM, signed by the CA. Checked on every handshake.
--pair-id—Deployment pair; client certificates must carry the same rosie-pair.
--remote-jog-max-uncertainty-ns0Largest allowed clock-offset interval for remote jog, ns.
--remote-jog-drift-ppb0Remote clock drift bound, parts per billion.
--remote-jog-calibration-max-age-ns0Oldest usable jog clock calibration, ns.

The three --remote-jog-* values must all be non-zero for remote jog to work. With the defaults, every remote jog frame is refused with jog_clock_unqualified. They describe your measured network and clocks, and the code marks them as unverified; there are no recommended values.

On an installed cell host, host/install.sh --remote-pki <directory> writes these flags into /etc/rosie-rt-core/control.env, pointing at /etc/rosie-rt-core/pki/{ca.pem,server.pem,server-key.pem,crl.pem}. The listen address comes from ROSIE_RT_REMOTE_LISTEN and defaults to 127.0.0.1:8443: loopback only, so you must choose a host interface explicitly to expose it. The cell configuration template records the same address in nodes[].rt_core.remote_listen. Installing a cell host is covered in Install rt-core on a cell host.

The remote listener's HTTP timeouts follow the cell's link profile: 2 s to read request headers on the LAN defaults, 10 s otherwise, 30 s to read a request, 65 s to write a reply, and the Describe control_idle_timeout_ns (90 s) for idle connections.

Provision certificates#

rt-core/host/remote-pki.sh owns one CA per deployment pair. Run it as root on a host where the rosie-ctl group exists. Every subcommand takes a PKI directory and an identity token.

# Create the CA, the server certificate and the first CRL. The identity is the pair ID.
sudo ROSIE_RT_REMOTE_SERVER_SAN='DNS:rosie.local,DNS:localhost,IP:127.0.0.1' \
  rt-core/host/remote-pki.sh init /var/lib/rosie-rt-pki/cell-a cell-a

# Issue a client certificate for one principal.
sudo rt-core/host/remote-pki.sh issue-client /var/lib/rosie-rt-pki/cell-a pendant-1

# Revoke it. The CRL is regenerated.
sudo rt-core/host/remote-pki.sh revoke /var/lib/rosie-rt-pki/cell-a pendant-1

Note

Keep the CA directory separate from /etc/rosie-rt-core/pki/. The installer copies the CA and server files from the directory you pass to --remote-pki into /etc/rosie-rt-core/pki/, so passing the same directory makes it copy files onto themselves. See Install on a cell host.

SubcommandWhat it creates
init <dir> <pair>A new directory (it refuses to overwrite one) with an EC P-256 CA valid for 3650 days, a server certificate valid for 365 days, and a CRL. The server certificate's names come from ROSIE_RT_REMOTE_SERVER_SAN, which defaults to DNS:localhost,IP:127.0.0.1,IP:::1; add the host name your clients will use.
issue-client <dir> <principal>clients/<principal>.pem and clients/<principal>-key.pem, valid for 365 days, with URI:rosie-principal:<principal> and URI:rosie-pair:<pair>. Each principal can be issued once.
revoke <dir> <principal>Revokes that client certificate.

Every subcommand ends by regenerating crl.pem. The CRL is valid for 7 days, and rt-control refuses every handshake once it has expired (remote CRL not current). The script has no separate refresh command, so plan to regenerate the CRL before it expires. Keys and certificates are published root:rosie-ctl mode 0640, and the CA key is mode 0600.

Copy ca.pem and the client's certificate and key to the client machine. Treat the client key as a credential: anyone who holds it can control the cell.

Connect a client#

import (
	"crypto/tls"
	"crypto/x509"
	"os"

	"rosieos/rt-core/sdk/control"
)

caPEM, _ := os.ReadFile("ca.pem")
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
cert, err := tls.LoadX509KeyPair("clients/pendant-1.pem", "clients/pendant-1-key.pem")
if err != nil {
	log.Fatal(err)
}
c, err := control.DialTLS("https://rosie.local:8443", &tls.Config{
	RootCAs:      roots,
	Certificates: []tls.Certificate{cert},
})
#include <rosie/rt_control_client.hpp>
using namespace rosie::rt_control;

// The header-only client does no TLS itself. Supply a factory that returns a
// fresh mutual-TLS HttpStream per connection, verifying the server certificate
// and host name and presenting the client certificate and key.
StreamFactory tls_factory = [](Deadline d) -> std::unique_ptr<HttpStream> {
    return open_my_mtls_stream("rosie.local", 8443, d); // your TLS implementation
};
RtControlClient client(tls_factory);
auto description = client.describe();
curl --cacert ca.pem --cert clients/pendant-1.pem --key clients/pendant-1-key.pem \
  https://rosie.local:8443/v1/status

control.DialTLS refuses a config without RootCAs or a client certificate, and one with InsecureSkipVerify. It forces TLS 1.3 and HTTP/1.1, and the address must be a bare HTTPS origin. In C++, open_my_mtls_stream stands for your own TLS code: the stream must honour the absolute deadline on every read and write and never replay bytes. See C++ client.

Remote jog over WebSocket#

On the remote listener, GET /v1/jog is always a WebSocket upgrade (on the local socket the same path is the jog_status read). The WebSocket carries the same binary local_jog_update frames as the local jog.sock lane, after a clock calibration that maps the pendant's clock onto the cell's.

Warning

Remote jog moves the robot from another machine over a network. The input deadline, the idle timeout and the lease stop motion when the link stalls, but none of them is an emergency stop.

GET /v1/jog?session=<session>&generation=<generation>&jog_generation=<jog_generation>

Upgrades the mutual-TLS connection to the jog WebSocket.

Query parameterTypeRequiredDescription
sessionstringYesCurrent session, owned by this client's principal.
generationuint64YesCurrent grant generation, decimal.
jog_generationuint64Yes0 to set up the transport and begin over the socket (the normal path), or an existing jog generation.

The request needs Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Version: 13 and a base64 Sec-WebSocket-Key of 16 bytes. Success is HTTP 101. Failures before the upgrade are text/plain: 400 invalid websocket upgrade, 403 session_principal_mismatch, 409 for a stale session or generation (jog_session_stale, control_session_stale), and 503 independent_jog_unavailable while the listener shuts down. Only one remote jog connection is active at a time: opening a second one closes both with code 1008.

The exchange#

With jog_generation=0, the client and rt-control exchange text frames, then switch to binary updates:

client → {"type":"calibrate","source_incarnation":"<32 hex>","source_ns":S1,"seq":1}
server ← {"type":"calibrated","seq":1,"source_ns":S1,"host_rx_ns":R1,"host_tx_ns":T1}
client → {"type":"calibrate","source_incarnation":"<32 hex>","source_ns":S2,"seq":2,"host_tx_ns":T1}
server ← {"type":"calibrated","seq":2,"source_ns":S2,"host_rx_ns":R2,"host_tx_ns":T2}
client → {"type":"begin","seq":3,"axis_mask":1,"source_ns":S3,"host_tx_ns":T2}
server ← {"type":"begun","seq":3,"jog_generation":4}
client → <binary local_jog_update frames, no replies>
server ← {"type":"rejected","seq":N,"reason":"…"}   (only on refusal)
  1. Calibrate twice. source_incarnation identifies your monotonic clock (16 non-zero bytes, hex-encoded) and must stay the same. Take the second source sample after you receive the first reply, and echo that reply's host_tx_ns. This gives rt-control a causal bound on the offset between your clock and the cell's. A single one-way timestamp never qualifies. You can send more calibrations later to refresh the mapping.
  2. Begin. Send begin with seq: 3, the axis mask, a fresh source_ns and the second reply's host_tx_ns. rt-control calls begin_jog for you and replies begun with the new jog generation. Setup does not use up the first input's allowance.
  3. Stream updates. Send binary frames built with the SDK, carrying source-clock times. rt-control maps each input's capture time conservatively (the earliest possible host time, including drift) and never subtracts measured latency. Input captured before the Begin sample is refused.

The receiver closes the stream when no complete frame arrives within the cell's input-age ceiling (250 ms on LAN), answering jog_stream_idle first and ending the jog. During setup the idle limit is 5 s. When the grant is stopped or expires, it answers control_session_stale and closes. Neither the Go nor the C++ producer sends keepalives, so send updates at a steady cadence.

Frames are limited to 4096 bytes and must not be fragmented. Ping and pong are answered. Protocol violations close the connection with a WebSocket close code (1002, 1007 or 1009); the WebSocket reasons list them. Jog refusals, which keep the connection open, are listed under update_jog.

With the client libraries#

// After Acquire, StartRenewal, Enable, Arm and observed readiness:
jog, err := c.NewRemoteJogSession(ctx, 1, 100_000_000, nil) // mask, 100 ms input lifetime, CLOCK_MONOTONIC
if err != nil {
	log.Fatal(err) // e.g. independent_jog_unavailable, jog_clock_* reasons
}
v := make([]float64, len(d.Axes))
v[0] = 0.05 // rad/s on J1
if err := jog.Update(v, ipcclient.HostMonotonicNS()); err != nil {
	log.Print(err)
}
_ = jog.End(ctx) // ends the jog generation; call Release separately
#include <rosie/rt_jog_remote_producer.hpp>
// client was built with a mutual-TLS StreamFactory and holds a renewed grant.
RtJogRemoteProducer jog(client, "rosie.local", /*mask*/ 1, /*input_duration_ns*/ 100000000);
std::vector<double> v(axis_count, 0.0);
v[0] = 0.05; // rad/s
if (!jog.update(v, host_monotonic_ns())) { /* another writer held the lock: drop this sample */ }
jog.end();

Both constructors perform the calibration and Begin. The input lifetime may not exceed the cell's input-age ceiling. Rejection / rejection() read typed refusals, and Observe / observe() return the receiver's jog observation.