Skip to content

Cloud Backend for tobor (Mode A)

tobor is the small client that runs on each robot. It registers the robot with the cloud once (exchanging a registration key for a robot ID and an API key) and verifies that new credential with the cloud before reporting success, it can check that the robot can reach the cloud and that its stored credential is still accepted by the cloud, and it can unregister to remove its stored credential when a robot is reset or decommissioned. Keeping a robot continuously connected so the cloud sees it online is the next step, not what it does today.

The credential verification here started as the robot leading the contract: the robot side defined the verify endpoint it needed (GET /api/robots/verify) and called it, giving the cloud team a concrete robot-facing shape. The cloud has since delivered that endpoint at the same path, so verify is now part of the real API, not a placeholder. The endpoint is described under Endpoints below.

This doc lines up the robot side and the cloud side: how the pieces fit, where the binary comes from, what is aligned to the real cloud API today, and what is still open or planned. The register, verify, heartbeat, and unregister endpoints have all been exercised against the live cloud (register -> verify -> heartbeat -> unregister, all 200). Mode A is the simple, ship-now design; there is a short note on the more secure Mode B at the end.


How the flow works (today)

sequenceDiagram
    participant Cloud
    participant Operator
    participant Robot as Robot (tobor)
    Cloud->>Operator: 1. Issue a registration key for the robot
    Operator->>Robot: 2. tobor register <registerKey>
    Robot->>Cloud: 3. POST /api/robots/register (registerKey + device)
    Cloud-->>Robot: 4. 200 { robotDetails: { id, apiKey, status } }
    Note over Robot: 5. Robot saves the robot ID and API key on disk
    Robot->>Cloud: 6. GET /api/robots/verify (Bearer apiKey + X-Robot-ID)
    Cloud-->>Robot: 7. 200 accepted, or 401/403 rejected
    Note over Robot: 8. tobor check repeats GET /health then GET /api/robots/verify
  1. The cloud issues a registration key for a robot, and it reaches the operator.
  2. The operator runs tobor register <registerKey> on the robot.
  3. The robot sends the registration key plus its device identity to POST /api/robots/register.
  4. The cloud validates the key and returns a robot ID and a persistent API key (inside robotDetails).
  5. The robot saves both on disk (two files, see Credential storage) and reuses them.
  6. The robot immediately verifies the new credential with an authenticated GET /api/robots/verify, so success means the credential was not only received but accepted.
  7. The cloud answers 200 if it accepts the credential, or 401/403 if it does not.
  8. tobor check later runs the same two probes in order: GET /health for reachability, then GET /api/robots/verify for credential validity, naming the robot ID on success.

The registration key bootstraps once. The API key is the durable secret the robot authenticates with after that, and the robot ID is the durable identity the cloud knows the robot by.

Note on check. check proves the cloud still accepts the credential, not just that a credential file is present. It confirms reachability with GET /health, then presents the stored credential to GET /api/robots/verify and reports accepted or rejected. The cloud implements this verify endpoint at that path, so the probe runs against the real cloud (the mock mirrors it for local testing).


Endpoints the client calls today

A single-place reference for every HTTP call the current tobor binary makes: what it sends, and what it expects back. Endpoint paths are compiled into the client; only the base URL is configurable, from TOBOR_CLOUD_URL (D-0005). All four endpoints the client calls, GET /health, POST /api/robots/register, GET /api/robots/verify, and POST /api/robots/unregister, match the delivered cloud API and have been exercised against the live cloud. POST /api/robots/heartbeat also exists on the cloud, but the client does not call it yet (see the dormant table below).

Method and path Command Auth Request body Expected success Failure handling
GET /health check none none 200 (the body is not inspected today, D-0006; the mock returns JSON { success, status }) any non-200, unreachable, or a TLS verify failure is reported as "cloud not reachable" and exits non-zero
POST /api/robots/register register none (the registerKey in the body is the check) { registerKey, device { serialNo, robotModel?, firmwareVersion?, machineId?, osRelease? } }; empty optional fields are omitted, not sent as "" 200 with { robotDetails { id, apiKey, status } }; the robot parses id and apiKey as opaque strings and stores both 400 and 403 are terminal; 5xx and transport failures are retried (D-0012); a TLS verify failure is terminal (D-0013)
GET /api/robots/verify register (first-time), check Authorization: Bearer <apiKey> and X-Robot-ID: <robot ID> none 200 the stored credential is accepted; the body is not inspected (D-0028) 401/403 means the credential is rejected (re-register); unreachable, TLS failure, or an unexpected status is reported. For the verify that runs right after a successful register, a 401/403 rejection is a hard failure, but any other non-success (unreachable, TLS failure, or an unexpected status) is non-fatal and the registration stands (D-0028)
POST /api/robots/unregister unregister Authorization: Bearer <apiKey> and X-Robot-ID: <robot ID> {} (empty JSON object) 200 (the credential is now revoked) 400 (robot not found) is reported as already-gone; 401/403 (missing or mismatched credentials) and any other non-200, unreachable, or TLS failure is reported as "could not confirm"; the local files are deleted regardless (D-0019)

Defined in the client but dormant:

Method and path Status
POST /api/robots/heartbeat (Authorization: Bearer <apiKey>, body {}) The endpoint exists on the cloud (it answers 401 to an unknown credential), and the send_heartbeat transport exists in the client, but nothing calls it yet. Reserved for the future online-signal endpoint and the serve loop (D-0022). The first-time and on-demand credential check is GET /api/robots/verify above, kept separate from heartbeat by design (D-0028).

Cross-cutting expectations for every call:

  • TLS. When the base URL is https://, the connection is encrypted and the certificate is always verified (D-0013). A certificate that cannot be verified fails immediately and is never retried. Plain http:// is used only for the local mock.
  • Timeouts. 3-second connect, 5-second total per request. A cloud that does not answer within 5 seconds is treated as unreachable (and, for register, retried per D-0012), so robot-facing endpoints must respond well inside that.
  • Response size. The client aborts any response whose body exceeds 1 MiB (D-0025), surfacing it as a failed request. Every current endpoint's reply is far smaller; the cloud should keep robot-facing responses well under this ceiling.
  • Base URL only is configurable; the paths above are fixed in the binary.

Where the binary comes from

The robot binary is built by the GitHub Actions workflows and published to the repo's Releases, so it can be pulled from there. Each release has two files:

  • tobor-<version>-aarch64.tar.gz, the binary (named tobor inside).
  • tobor-<version>-aarch64.tar.gz.sha256, a checksum to verify the download.

Since the repo is private, downloading uses a GitHub token with read access. Two ways:

  • From the UI: open the repo's Releases page and download the two files.
  • With a command (handy for a backend that fetches on a trigger):
gh release download --repo ToborlifeRobotics/tobor \
  --pattern 'tobor-*-aarch64.tar.gz*'

gh handles the token. If a pure-curl fetch is easier for automation, call the Releases API with the token to read the latest release, then download the asset by its API URL with Accept: application/octet-stream.

Then verify and unpack:

sha256sum -c tobor-<version>-aarch64.tar.gz.sha256   # prints OK
tar xzf tobor-<version>-aarch64.tar.gz               # gives ./tobor

A natural cloud-side setup: fetch the latest release on a trigger, verify it, and serve tobor to robots (for example a download URL the robot pulls from).


Running it on the robot (the operator)

Once the binary is on the robot, the operator registers it and checks it:

./tobor register <registerKey>   # one time, at setup
./tobor check                    # cloud reachable, and this robot's credential accepted

On success, register saves the robot ID and API key on the robot, verifies the new credential with the cloud, and reuses the saved values after that. The cloud URL is set in the code for now (with an environment override used for testing), and a config file will come later so it can point elsewhere without a rebuild. The only thing needed from the cloud side is the URL itself.


The register step

Registration key plus device identity in; robot ID and API key out. This is aligned to the real cloud API.

Endpoint: POST /api/robots/register (no authentication; the registration key in the body is the check).

Request body the robot sends:

{
  "registerKey": "<the registration key>",
  "device": {
    "serialNo": "1424325095622",
    "robotModel": "NVIDIA Jetson Orin NX ...",
    "firmwareVersion": "1.2.3",
    "machineId": "5dbfb12414a3456d9014d88183e338b1",
    "osRelease": "Ubuntu 22.04.5 LTS"
  }
}
  • serialNo is the robot's hardware serial (/proc/device-tree/serial-number), the only field the board truly owns. It is required.
  • robotModel and firmwareVersion are operator-supplied (the board cannot know a product model or a firmware release on its own); robotModel falls back to the device-tree model when not set.
  • machineId and osRelease ride along as metadata for the record. osRelease is the PRETTY_NAME value from /etc/os-release (for example Ubuntu 22.04.5 LTS), not the whole file.
  • Any optional field that is empty is omitted from the body rather than sent as an empty string, so a strict validator does not reject an empty value. On a dev box with no device-tree, serialNo is supplied via TOBOR_DEVICE_SERIAL.

On success, the robot expects 200 with a robot ID and API key inside robotDetails:

{
  "robotDetails": {
    "id": "<robot ID>",
    "apiKey": "<persistent API key>",
    "status": "..."
  }
}

The robot parses robotDetails.id and robotDetails.apiKey, treating both as opaque strings, and stores them. Whatever API key is issued here, the cloud must recognize it again on later authenticated calls (verify, reset, revoke), since that is where the robot presents it.

On failure, the status code drives the robot's retry logic. The robot retries on 5xx and network errors, and stops on 4xx. The real cloud's codes, confirmed against the live cloud:

  • 400 - the registration key is missing, invalid, expired, or already used, or the robot is already registered (terminal; the robot stops and the operator fixes it). The live cloud returns 400 for all of these; it does not use 403 on register.
  • 500 / 503 - server-side (the robot waits and retries).

The robot maps 400 to a message that names the likely causes (key not found, expired, or already used, or the robot already registered) and stops. The classify layer still treats a 403 as a terminal rejection if one ever appears, but the live register path returns 400. (A single 400 code covering several distinct situations is hard to phrase precisely, see Open questions.)

First-time verification. After a successful register the robot saves the credential and then immediately verifies it with GET /api/robots/verify (see Verifying a credential, below), so register reports success only once the issued credential is confirmed usable. The policy is pragmatic (D-0028): an outright rejection (401/403) fails the command, while a transient problem reaching the cloud for that verify leaves the registration in place and points the operator at check.


Verifying a credential (built)

The register response hands the robot a credential, and the robot proves it works by presenting it to an authenticated verify endpoint. This is a plain accept-or-reject check, not the fuller pending-to-active lifecycle described in the next section.

Endpoint (robot-defined placeholder): GET /api/robots/verify. The path is chosen on the robot side and isolated behind the transport, so it re-points to the cloud's real validate path with a one-line change.

Request. Headers Authorization: Bearer <apiKey> and X-Robot-ID: <robot ID>. No body. The API key authenticates; the robot ID is sent so the cloud can cross-check that the key belongs to that robot, though the key alone already identifies the credential.

Response. The client reads only the HTTP status; it does not parse the body.

Status Meaning
200 The credential is accepted.
401 / 403 The credential is rejected (unknown, revoked, or expired).
other, unreachable, TLS failure Reported; for the post-register verify these are non-fatal (D-0028).

Whatever the cloud puts in the body is ignored, so its shape is not part of the contract. For reference, the live cloud replies { "status": 200, "success": true, "message": "Credential accepted" } on acceptance and, on rejection, a 401 whose error text is under an error field ({ "status": 401, "success": false, "error": "Unauthorized: Robot not found" }). Note the cloud uses message on success and error on these 401s; standardizing on one key is a small cloud-side cleanup. The client reads neither, so it is unaffected.

sequenceDiagram
    participant Robot as Robot (tobor)
    participant Cloud
    Note over Robot: right after register, and on every check
    Robot->>Cloud: GET /api/robots/verify (Bearer apiKey + X-Robot-ID)
    Cloud-->>Robot: 200 accepted, or 401 / 403 rejected
    Note over Robot: status only, body not read

Who calls it.

  • register, once, right after saving a new credential (first-time verification). A 401/403 rejection fails the command; any other non-success (unreachable, TLS, or an unexpected status) is reported but the registration stands.
  • check, on demand, after GET /health. A rejection makes check exit non-zero and tell the operator the credential may have been revoked.

Verification is deliberately its own call, separate from the future online-signal heartbeat (D-0028): the two have different lifetimes (a one-shot check versus a periodic ping) and will diverge further as serve grows, so folding them together now would couple things that should move apart.


Checking a robot

tobor check is the on-demand diagnostic. It confirms the cloud is reachable, then proves the stored credential is still accepted. The two probes run in order, and any failure exits non-zero with the reason.

sequenceDiagram
    participant Operator
    participant Robot as Robot (tobor)
    participant Cloud
    Operator->>Robot: tobor check
    Robot->>Cloud: GET /health
    Cloud-->>Robot: 200 reachable
    Robot->>Cloud: GET /api/robots/verify (Bearer apiKey + X-Robot-ID)
    Cloud-->>Robot: 200 accepted, or 401 / 403 rejected
    Note over Robot: exit 0 when verified, exit 1 on any failure (unreachable, not registered, or rejected)

Confirming a credential lifecycle (planned, not built)

The built verify above answers "does this credential authenticate right now." The fuller design adds a credential lifecycle on top of that: the cloud tracks whether a credential is merely issued or actually in use, and a credential issued but never used should not linger. This lifecycle is not built; the plain verify is the piece that exists today. The planned design (DECISIONS.md D-0023):

  • After a successful register (and after a reset), the robot saves the credential locally, then makes an authenticated confirm call to a dedicated endpoint, carrying the serial on the first confirm to bind the hardware to the credential. The confirm is a superset of today's verify: it both proves the credential works and moves it from pending to active.
  • The cloud moves the credential from a pending state to active on that confirm and stops any expiry clock. A credential that is never confirmed expires (only pending credentials expire; an active credential does not expire from idleness unless an operator opts in later).
  • The confirm is idempotent: a repeat on an already-active credential returns success, not an error. The robot retries the confirm, so a lost request and a lost response both converge; a single received confirm activates the credential regardless of whether the robot got the ack.

This is a separate endpoint from the online-signal heartbeat below, because it has different inputs (the serial on first call), its own state effect (pending to active), and its own idempotency and expiry rules. It uses the header authentication described under Transport.


Staying online (the next step)

The always-on signal, where the robot pings on a schedule so the cloud can mark it online with a last-seen time, is the next step (a reserved serve command). The send_heartbeat transport already exists in the client, unused, and will make that call on a loop once the endpoint is defined. The contract is open:

  • The ping cadence, and how long after the last ping a robot counts as offline.
  • Anything wanted in the ping body or the reply.
  • Which non-200 statuses mean the credential is rejected (the robot treats a 403/401 as rejected and re-register worthy; any other non-200 as an unexpected error).

A small status command is also under consideration, so an operator or a script can ask a robot to report cloud reachability and registration on demand. Open question before adding it: many robots checking often could add up to DDoS-style load. If the always-on serve process holds the connection, a status command could ask that local process instead of the cloud, so many status checks add no cloud load; whether it is worth having, and at what cadence and rate-limit, is worth deciding before it ships.


Resetting a credential (planned, not built)

Reset rotates a robot's API key. Because the connection is one-way (robot to cloud, no downlink), reset is robot-initiated. The planned design (DECISIONS.md D-0023):

  • The robot requests a reset, receives a new API key (starting pending), saves it, and confirms it (as in Confirming a credential).
  • The cloud keeps the old key valid until the new key is confirmed (a grace overlap), then retires the old key promptly. During the overlap the robot has two valid keys; this is what prevents a lost reset response from locking the robot out (old key dead, new key never received).

Reset uses PATCH /api/robots/reset with the header authentication below. The cloud has delivered this endpoint (it is in the cloud's API contract and returns a new apiKey inside robotDetails); the robot-side reset command is not built yet. The credential store is already reset-ready (the API key rotates in its own file, the robot ID stays put).


Decommissioning a robot (unregister)

tobor unregister removes a robot's stored credential when it is reset, resold, or decommissioned. It does two things: it asks the cloud to revoke the credential, then it deletes the local files. The local delete is unconditional (so a robot already off the network can still be wiped); the cloud revoke is best-effort.

sequenceDiagram
    participant Operator
    participant Robot as Robot (tobor)
    participant Cloud
    Operator->>Robot: tobor unregister [--force]
    Note over Robot: prompt to confirm unless --force
    Robot->>Cloud: POST /api/robots/unregister (Bearer apiKey + X-Robot-ID)
    Cloud-->>Robot: 200 revoked, or 400 / 401 / 403 otherwise
    Note over Robot: delete credential and robot-id locally, whatever the cloud said
    Note over Robot: exit 0

Request. POST /api/robots/unregister, headers Authorization: Bearer <apiKey> and X-Robot-ID: <robot ID>, body:

{}

Response. The client reads only the HTTP status. The codes below are what the live cloud returns.

Status Meaning
200 The cloud recognized the credential and it is now revoked.
400 The cloud has no record of this robot (already gone). The client reports "already unregistered" and still deletes locally.
401 Missing or malformed credentials.
403 The presented key does not match the robot id.

The robot treats any non-200 (including 400, 401, 403, unreachable, or a TLS failure) as a signal to report and still delete the local credential and robot-id files; a 400 is reported as already-gone rather than "could not confirm".

Note on the real cloud. POST /api/robots/unregister is a delivered endpoint (exercised against the live cloud). It is the narrow robot-revoke path the robot is allowed to call; the cloud's own full delete (DELETE /api/robots/:id) is a separate admin/UI action the robot is deliberately not given. The robot-side behavior (best-effort revoke, then unconditional local delete) stays as described.

Requirement: credentials must be fresh and never reused. Revocation only means something if a revoked credential can never come back. The real cloud must issue a fresh, unguessable API key on every register and reset, and must never reissue a revoked one. The live cloud satisfies this (each register returns a distinct random tv_robot_<random> key). The mock now mirrors this, issuing a random tv_robot_ token per registration rather than a serial-derived string.


Credential state (to agree)

To keep the robot and the cloud aligned, a shared credential state machine is proposed (DECISIONS.md D-0023). Agreeing on the names and the allowed transitions removes most of the ambiguity above, and the cloud returning the current state lets the robot and an operator always see where a credential stands:

  • Pending - issued (by register or reset), not yet confirmed. Expires if not confirmed in time.
  • Active - confirmed and in use. Does not expire from idleness (unless an operator opts in).
  • Expired - a pending credential that was never confirmed in time.
  • Revoked - killed by the cloud (robot revoke, or an admin action). Cloud-authoritative.
  • Retired - the old key after a reset's new key becomes active (the grace-overlap end state).

Transport (TLS)

Everything is HTTPS and the robot always verifies the certificate; there is no way to turn that off.

  • A public-CA certificate works out of the box (the robot uses the system CA bundle; it needs ca-certificates installed).
  • A private or internal CA means the robot is pointed at that CA bundle at setup (TOBOR_CA_BUNDLE), so the cloud side just needs to say which is in use.
  • A certificate that cannot be verified fails immediately and is never retried.

Two transport hardening details, independent of the API contract (DECISIONS.md D-0025), are worth the cloud team knowing: the client caps any response body at 1 MiB and aborts past that, and it enforces a 5-second total request timeout (3-second connect). Robot-facing responses must stay well under 1 MiB and answer inside 5 seconds, or the client treats the call as failed.

Authentication. Register carries no auth (the registration key in the body is the check). The authenticated calls the client makes today, GET /api/robots/verify and POST /api/robots/unregister, present the API key as Authorization: Bearer <apiKey> and the robot ID as X-Robot-ID: <robot ID>. The API key authenticates and, on its own, already identifies the credential to the cloud (the cloud issued it); the robot ID rides along so the cloud can cross-check that the key belongs to that robot, and for logging and routing. Both headers are built in a single place in the client (D-0027), so the one remaining step, renaming the key header to X-Robot-API-Key if the cloud prefers that over Authorization: Bearer, is a one-line change. Register itself stays unauthenticated.


What is aligned today vs. still open

Aligned to the real cloud API now (validated against the live cloud):

  • Register endpoint (POST /api/robots/register), body (registerKey + device), and response (robotDetails.id / apiKey).
  • Verify endpoint (GET /api/robots/verify) with Bearer + X-Robot-ID.
  • Unregister endpoint (POST /api/robots/unregister) with Bearer + X-Robot-ID.
  • Two-value credential storage (robot ID + API key), with a random tv_robot_ key issued per registration.
  • Register status codes (400 for key problems and already-registered, 500/503 server-side; the live cloud does not use 403 on register).
  • check is honest: reachability plus a real credential check against the cloud.

On the cloud but not yet used by the client:

  • POST /api/robots/heartbeat exists on the cloud; the client's send_heartbeat transport is dormant until the serve loop is built.
  • PATCH /api/robots/reset is delivered by the cloud; the robot-side reset command is not built yet.

Not yet the real contract (still planned):

  • The richer confirm/lifecycle endpoint above the plain verify (pending-to-active, expiry, idempotent confirms) is a design proposal, not built (D-0023).
  • The online-signal semantics for heartbeat (cadence, offline threshold, body) are open.

Things to confirm on the cloud side

  • Registration keys can be issued and delivered to the operator.
  • /api/robots/register validates the key, records the device, and returns robotDetails with an id and apiKey.
  • The robot id format (string or number), so the robot parses it correctly.
  • Whether robotModel and firmwareVersion are required, and whether extra device fields (machineId, osRelease) are accepted or rejected by strict validation.
  • The exact 400-vs-403 split, and ideally a machine-readable reason so "key not found" vs "expired" vs "already used" can be told apart.
  • The GET /health body shape (which of success / status to check), so check can verify the body rather than only HTTP 200.
  • Robot-facing responses stay under 1 MiB (the client aborts larger ones) and arrive within the client's 5-second request timeout.
  • HTTPS is served with a cert the robot can verify (public or private CA, and which one).
  • (Delivered) The robot-facing verify endpoint GET /api/robots/verify with Authorization: Bearer <apiKey> and X-Robot-ID: <robot ID>, no body, 200 for a credential the cloud accepts and 401/403 for one it rejects. Confirmed against the live cloud; kept here as a record of the contract that lets register and check prove a credential works.
  • How the cloud resolves an authenticated call: the mock looks the robot up by X-Robot-ID (a fast index) and then confirms the presented Bearer key is the one on record for that robot. Decide whether a mismatch or a missing id is a hard 401 (as the mock does) or accepted and logged, keeping in mind the id is a non-secret cross-check and routing key, not a second secret (see the Authentication note).
  • The planned confirm/validate endpoint (the richer lifecycle above the plain verify): its path, the pending-to-active lifecycle, the pending-expiry window, idempotent confirms, and the header-auth scheme.
  • (Delivered) The robot-revoke endpoint POST /api/robots/unregister; confirm that after a revoke the credential is never accepted again.
  • The planned reset endpoint (PATCH /api/robots/reset) and the old-key grace overlap so a lost reset response cannot strand a robot.
  • The shared credential state machine and states (Pending, Active, Expired, Revoked, Retired), and that the cloud returns the current state.

Open questions

  • Registration key: how is it minted and delivered? Does it expire? Single use, or reusable? Since recovery from a lost or expired credential is operator-driven (a new key issued on request), a strictly single-use key means every recovery needs a human; the pending-expiry window must be generous so routine timing never trips that path.
  • Credential lifecycle: the pending-expiry window, the confirm endpoint's idempotency, and whether an active credential ever expires (planned: no, unless an operator opts in). See D-0023.
  • Reset: the grace-overlap window before the old key is retired, kept as short as the confirm allows so a leaked old key does not stay valid long.
  • Compromise response: with a one-way connection there is no push to force-rotate a live robot; the response is a cloud-authoritative revoke, then an operator re-register. Confirm this is acceptable.
  • Auth failure codes: confirm which codes on authenticated calls mean "credential rejected, re-register" (403, 401, others).
  • Status command: worth adding, or a DDoS risk to avoid?

Heads-up: Mode B is coming (short)

Mode A is a stopgap. It relies on shared secrets: the registration key and the API key are bearer secrets, so whoever holds one can act as the robot, and the API key sits on the robot protected only by file permissions. That is fine for an early, controlled rollout, but not for the long term.

Mode B will replace it: each robot makes its own private key, the CA signs it after owner approval, and the robot connects with mutual TLS. The key never leaves the robot, and identity becomes cryptographic instead of a shared secret. Nothing to build now. The one thing worth keeping in mind is to leave the device registry and auth flexible enough that moving to per-robot keys later is an addition rather than a rewrite. The two-file credential store already helps here: the API key lives in its own file, so it can become a sealed blob or a hardware-backed handle without disturbing the robot ID.