Docs menu

Build a relay workbench

Goal: Let a caller send data to a device through a workbench you own, so the caller never holds a key.

When a relay helps

A page, an app or a partner system that sends data should not hold a device key: anything shipped to someone else can be read by them. A relay is a workbench you host that holds the key instead. A caller signs in to OpenX and calls the relay; the relay checks the caller, then sends the message to the hub as the device. To stop one caller you switch that caller off, and the device and its key stay as they are.

The hub keeps working the normal way underneath, with a key. The relay is something you build when you want it; nothing turns on by default. Use your IoT Hub securely covers every other context.

Before you start

Put the device key in the workbench

  1. Select the connection and open Inspector -> Connection Info. Copy Device ConnStr (primary), the key-based connection string for that one device.
  2. In the workbench's inspector, open the Environment tab and add a value named IOT_DEVICE_CONNECTION_STRING, with that string. A value you set there becomes a container secret (Host Your Own Workbench), so it is never in your source.

The relay holds one device's key. It can send as that device and does nothing else on the hub.

The relay code

A Web mode with one route, POST /send. It refuses any caller OpenX has not signed in, makes a short-lived pass from the device key the way Pilot Devices shows, and posts the body to the hub.

import {
  readWorkbenchIdentityConfig,
  WebMode,
  Workbench,
  WorkbenchIdentity,
} from '@fathym/fai/workbenches';

// OpenX sets the sign-in settings at a mode whose Who may call is Identity, and at no other mode.
const config = readWorkbenchIdentityConfig();
const identity = config ? WorkbenchIdentity(config) : undefined;

// The device's key-based connection string, set in the workbench's Environment tab.
function readDevice(): { Host: string; DeviceId: string; Key: string } | undefined {
  const parts = new Map(
    (Deno.env.get('IOT_DEVICE_CONNECTION_STRING') ?? '')
      .split(';')
      .filter((part) => part.includes('='))
      .map((part) => {
        const at = part.indexOf('=');

        return [part.slice(0, at), part.slice(at + 1)] as const;
      }),
  );
  const Host = parts.get('HostName');
  const DeviceId = parts.get('DeviceId');
  const Key = parts.get('SharedAccessKey');

  return Host && DeviceId && Key ? { Host, DeviceId, Key } : undefined;
}

async function mintPass(host: string, deviceId: string, keyB64: string, lifetimeSeconds: number) {
  const resource = encodeURIComponent(`${host}/devices/${deviceId}`.toLowerCase());
  const expiry = Math.floor(Date.now() / 1000) + lifetimeSeconds;
  const keyBytes = Uint8Array.from(atob(keyB64), (c) => c.charCodeAt(0));
  const key = await crypto.subtle.importKey(
    'raw',
    keyBytes,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const signed = await crypto.subtle.sign(
    'HMAC',
    key,
    new TextEncoder().encode(`${resource}\n${expiry}`),
  );
  const sig = encodeURIComponent(btoa(String.fromCharCode(...new Uint8Array(signed))));

  return `SharedAccessSignature sr=${resource}&sig=${sig}&se=${expiry}`;
}

async function relay(req: Request): Promise<Response> {
  if (req.method !== 'POST' || new URL(req.url).pathname !== '/send') {
    return new Response('Not Found', { status: 404 });
  }

  // No valid OpenX token, no message. With no sign-in settings, nobody is admitted.
  const caller = await identity?.Caller(req);

  if (!caller) return new Response('Sign in first.', { status: 401 });

  const device = readDevice();

  if (!device) return new Response('The relay has no device key.', { status: 500 });

  const pass = await mintPass(device.Host, device.DeviceId, device.Key, 300);
  const sent = await fetch(
    `https://${device.Host}/devices/${device.DeviceId}/messages/events?api-version=2020-03-13`,
    {
      method: 'POST',
      headers: { 'Authorization': pass, 'Content-Type': 'application/json' },
      body: await req.text(),
    },
  );

  return new Response(null, { status: sent.ok ? 202 : 502 });
}

export default Workbench('iot-relay').Modes({ Web: WebMode({ handler: relay }).Ports(4969) });

Who may call

  1. In the workbench's Modes tab, set Who may call this mode to Identity for the Web mode. With None the workbench receives no OpenX token, and this relay refuses everyone.
  2. If only other workbenches call the relay, also set No public access in its Hosting tab, so it has no address outside the environment (Workbenches Build on Each Other).

The workbench checks the caller itself, because a hosted workbench's container also answers at its own Azure address, so OpenX's address is not the only way to it (How a workbench secures itself). With Identity, OpenX passes the workbench a short-lived token that names the caller, and the helper in the code checks it.

Deploy and call it

Deploy the workspace, then call the relay's address as a signed-in caller:

curl -X POST 'https://openx.fathym.com/ox-api/w/<workspace id>/workbenches/<apiSlug>/Web/send' \
  -H 'Authorization: Bearer <ACCESS_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"reading": 21.4}'

The relay answers 202 when the hub accepted the message, 502 when it did not, and 401 when the caller is not signed in. How a Web mode is reached: Reach a Workbench from a Page.

Check it arrived

Open the connection's Inspector -> Connection Info: Last Received moves when the hub hears from the device (Check health). From any AI, get_stats reports the same connection's health.

Limits

  • The relay sends as one device. For many devices, host one relay per device, or put a gateway in front of them.
  • It passes the body on as it was sent. Check the shape of a message in the relay if the devices you serve depend on it.
  • OpenX gives the workbench a token for a person or job that signs in with an App Identity. A call made with a team key gets no token yet (Reach a Hosted Workbench), so this relay refuses it.
On this page