# Remote access (mTLS) and remote jog

> How to expose rt-control to other machines over mutual TLS 1.3, provision the component CA and client certificates, connect from Go, C++ or curl, and jog over the WebSocket lane.

URL: https://advancedmetalresearch.com/docs/apis/remote-access-mtls
Section: RosieOS docs / APIs
Last updated: 2026-10-10

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](https://advancedmetalresearch.com/docs/apis/rt-control-http) as the local socket, plus a WebSocket lane for jogging.

Read Describe over mutual TLS:

```bash
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](https://advancedmetalresearch.com/docs/get-started/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.

| Flag | Default | Description |
|---|---|---|
| `--remote-listen` | empty (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-ns` | 0 | Largest allowed clock-offset interval for remote jog, ns. |
| `--remote-jog-drift-ppb` | 0 | Remote clock drift bound, parts per billion. |
| `--remote-jog-calibration-max-age-ns` | 0 | Oldest 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](https://advancedmetalresearch.com/docs/guides/install-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.

```bash
# 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](https://advancedmetalresearch.com/docs/guides/install-on-a-cell-host).

| Subcommand | What 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

**Go**

```go
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},
})
```
**C++**

```cpp
#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**

```bash
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](https://advancedmetalresearch.com/docs/apis/cpp-client#remote-transport).

## 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`](/docs/apis/rt-control-http#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.

**Endpoint: `GET /v1/jog?session=<session>&generation=<generation>&jog_generation=<jog_generation>`**

Upgrades the mutual-TLS connection to the jog WebSocket.

| Query parameter | Type | Required | Description |
|---|---|---|---|
| `session` | `string` | Yes | Current session, owned by this client's principal. |
| `generation` | `uint64` | Yes | Current grant generation, decimal. |
| `jog_generation` | `uint64` | Yes | 0 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:

```text
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](https://advancedmetalresearch.com/docs/reference/error-codes#reasons-websocket) list them. Jog refusals, which keep the connection open, are listed under [`update_jog`](/docs/apis/rt-control-http#update-jog).

### With the client libraries

**Go**

```go
// 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
```
**C++**

```cpp
#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.

## Related pages

- [rt-control HTTP API](https://advancedmetalresearch.com/docs/apis/rt-control-http)
- [Control authority](https://advancedmetalresearch.com/docs/concepts/control-authority#the-jog-lane): the jog lane's deadlines and generations.
- [Error codes: remote TLS](https://advancedmetalresearch.com/docs/reference/error-codes#reasons-tls) and [WebSocket](https://advancedmetalresearch.com/docs/reference/error-codes#reasons-websocket) reasons.

## Sources

Written from these files in the RosieOS repository (https://github.com/advanced-metal-research/RosieOS):

- `rt-core/cmd/rt-control/main.go:32-39,43-50,99-120,155-180,199-213`
- `rt-core/adapters/rosie/control/remote_tls.go:17-160`
- `rt-core/adapters/rosie/control/remote_listener.go:24-145`
- `rt-core/adapters/rosie/control/jog_ws.go:33-393`
- `rt-core/adapters/rosie/control/websocket.go:20-183`
- `rt-core/host/remote-pki.sh:1-107`
- `rt-core/host/generate-control-env.sh:25-26,65-67`
- `rt-core/host/install.sh:20-33,127-164`
- `rt-core/config/templates/cell.json:17`
- `rt-core/protocol/application-v1.schema.json (rules.remote_jog_upgrade)`
- `rt-core/sdk/control/client.go:90-111`
- `rt-core/sdk/control/jog_remote.go:30,90-114,313-377`
- `rt-core/clients/cpp/include/rosie/rt_jog_remote_producer.hpp:176-270`
- `rt-core/clients/cpp/include/rosie/rt_control_client.hpp:80-90,524`
