From a9c8e421b92ef6890e819bfd9f332d445906d388 Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:34 +0400 Subject: [PATCH 1/8] feat(protocol): define opaque relay transport contracts Signed-off-by: Lokesh --- packages/protocol/README.md | 3 +- packages/protocol/src/index.ts | 1 + packages/protocol/src/remote-transport.ts | 658 ++++++++++++++++++ .../test/fixtures/internal-relay-api-v1.json | 39 ++ .../test/fixtures/remote-transport-v1.json | 70 ++ .../protocol/test/remote-transport.test.ts | 167 +++++ .../test/support/fake-remote-crypto.ts | 66 ++ 7 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 packages/protocol/src/remote-transport.ts create mode 100644 packages/protocol/test/fixtures/internal-relay-api-v1.json create mode 100644 packages/protocol/test/fixtures/remote-transport-v1.json create mode 100644 packages/protocol/test/remote-transport.test.ts create mode 100644 packages/protocol/test/support/fake-remote-crypto.ts diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 3adc6e75..2c8f890b 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -1,6 +1,7 @@ + # `@axl/protocol` -This dependency-free package defines Axl's versioned JSONL events, model stream messages, and local wire protocol. The current wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Runtime parsers validate every value received from an untrusted boundary. +This dependency-free package defines Axl's versioned JSONL events, model stream messages, local wire protocol, and opaque remote-transport framing. The current local wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Remote transport contracts define routing identifiers, limits, tickets, receipts, delivery states, and bounded binary frames without defining or implementing cryptography. Runtime parsers validate every value received from an untrusted boundary. diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 60892b73..23450afd 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -8,6 +8,7 @@ export * from "./event-envelope.ts"; export * from "./events.ts"; export * from "./model-stream.ts"; export * from "./provider-management.ts"; +export * from "./remote-transport.ts"; export * from "./version.ts"; export * from "./wire.ts"; export * from "./host-control.ts"; diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts new file mode 100644 index 00000000..1af9804a --- /dev/null +++ b/packages/protocol/src/remote-transport.ts @@ -0,0 +1,658 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { ProtocolValidationError } from "./event-envelope.ts"; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const methodPattern = /^[a-z][a-z0-9]*(?:[._-][a-zA-Z0-9]+)*$/; +const frameMagic = Uint8Array.of(0x41, 0x58, 0x4c, 0x52); +const routedFrameHeaderBytes = 42; +const shortFrameBytes = 23; + +declare const installationIdBrand: unique symbol; +declare const deviceIdBrand: unique symbol; +declare const cryptoSessionIdBrand: unique symbol; +declare const axlSessionIdBrand: unique symbol; +declare const routeIdBrand: unique symbol; +declare const transportAttemptIdBrand: unique symbol; +declare const envelopeIdBrand: unique symbol; +declare const requestIdBrand: unique symbol; +declare const idempotencyKeyBrand: unique symbol; +declare const objectIdBrand: unique symbol; + +type Nominal = string & { readonly [Key in Brand]: true }; + +export type InstallationId = Nominal; +export type DeviceId = Nominal; +export type CryptoSessionId = Nominal; +export type AxlSessionId = Nominal; +export type RouteId = Nominal; +export type TransportAttemptId = Nominal; +export type EnvelopeId = Nominal; +export type RequestId = Nominal; +export type IdempotencyKey = Nominal; +export type ObjectId = Nominal; + +export const REMOTE_TRANSPORT_VERSION = 1 as const; +export const INTERNAL_RELAY_API_VERSION = 1 as const; +export const MAX_RELAY_FRAME_BYTES = 65_535; +export const MAX_RELAY_QUEUED_BYTES = 512 * 1024; +export const RELAY_HEARTBEAT_INTERVAL_MS = 20_000; +export const RELAY_IDLE_TIMEOUT_MS = 60_000; +export const RELAY_TICKET_LIFETIME_MS = 60_000; +export const MAX_RELAY_OPAQUE_PAYLOAD_BYTES = MAX_RELAY_FRAME_BYTES - routedFrameHeaderBytes; + +export interface RelayLimits { + readonly maxFrameBytes: number; + readonly maxQueuedBytes: number; + readonly heartbeatIntervalMs: number; + readonly idleTimeoutMs: number; +} + +export const DEFAULT_RELAY_LIMITS: RelayLimits = Object.freeze({ + maxFrameBytes: MAX_RELAY_FRAME_BYTES, + maxQueuedBytes: MAX_RELAY_QUEUED_BYTES, + heartbeatIntervalMs: RELAY_HEARTBEAT_INTERVAL_MS, + idleTimeoutMs: RELAY_IDLE_TIMEOUT_MS, +}); + +export interface IssueRelayTicketRequest { + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly role: "daemon" | "device"; +} + +export interface IssueRelayTicketResult { + readonly ticket: string; + readonly relayUrl: string; + readonly expiresAt: number; + readonly proofSchemeVersion: number; + readonly limits: RelayLimits; +} + +export interface ConsumeRelayTicketRequest { + readonly ticket: string; + readonly relayInstanceId: string; + readonly connectionNonce: string; + readonly possessionProof: Uint8Array; +} + +export interface ConsumeRelayTicketResult { + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly sourceRouteId: RouteId; + readonly role: "daemon" | "device"; + readonly leaseExpiresAt: number; + readonly limits: RelayLimits; +} + +export interface RelaySendFrame { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly destinationRouteId: RouteId; + readonly opaquePayload: Uint8Array; +} + +export interface RelayDelivery { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly sourceRouteId: RouteId; + readonly opaquePayload: Uint8Array; +} + +export type RelayReceiptStatus = "admitted" | "forwarded"; + +export interface RelayReceipt { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly status: RelayReceiptStatus; +} + +export const RELAY_FAILURE_CODES = [ + "bad_frame", + "unsupported_transport_version", + "unauthorized", + "forbidden_route", + "ticket_expired", + "ticket_consumed", + "destination_offline", + "rate_limited", + "queue_full", + "slow_consumer", + "service_unavailable", +] as const; + +export type RelayFailureCode = (typeof RELAY_FAILURE_CODES)[number]; + +export interface RelayFailure { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly code: RelayFailureCode; +} + +export type RelayBinaryFrame = RelaySendFrame | RelayDelivery | RelayReceipt | RelayFailure; + +export interface AuthenticatedRemoteRequest { + readonly deviceId: DeviceId; + readonly requestId: RequestId; + readonly idempotencyKey?: IdempotencyKey; + readonly method: string; + readonly params: unknown; +} + +export type RemoteDeliveryState = + | "queued_local" + | "sending" + | "relay_admitted" + | "relay_forwarded" + | "daemon_accepted" + | "operation_running" + | "completed" + | "failed"; + +export interface OpaqueOutboxRecord { + readonly requestId: RequestId; + readonly idempotencyKey: IdempotencyKey; + readonly destinationRouteId: RouteId; + readonly opaqueEnvelope: Uint8Array; + readonly createdAt: number; + readonly state: "queued_local" | "sending" | "daemon_accepted"; +} + +export interface RelayRevocationNotification { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly generation: number; + readonly effectiveAt: number; +} + +export interface RelayRevocationResult { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly accepted: true; +} + +export type InternalConsumeRelayTicketWireRequest = Omit< + ConsumeRelayTicketRequest, + "possessionProof" +> & { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly possessionProof: string; +}; + +export type InternalConsumeRelayTicketWireResult = ConsumeRelayTicketResult & { + readonly version: typeof INTERNAL_RELAY_API_VERSION; +}; + +function fail(path: string, message: string): never { + throw new ProtocolValidationError(path, message); +} + +function object(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail(path, "must be an object"); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(path, "must be a plain object"); + return value as Record; +} + +function exact( + value: Record, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${path}.${key}`, "is unknown"); + for (const key of required) if (!(key in value)) fail(`${path}.${key}`, "is required"); +} + +function boundedString(value: unknown, path: string, maximum: number): string { + if (typeof value !== "string" || value.length === 0 || value.length > maximum) { + fail(path, `must be a non-empty string no longer than ${maximum} characters`); + } + return value; +} + +function integer(value: unknown, path: string, minimum: number, maximum: number): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + fail(path, `must be an integer from ${minimum} through ${maximum}`); + } + return value as number; +} + +function timestamp(value: unknown, path: string): number { + return integer(value, path, 0, Number.MAX_SAFE_INTEGER); +} + +function role(value: unknown, path: string): "daemon" | "device" { + if (value !== "daemon" && value !== "device") fail(path, "must be daemon or device"); + return value; +} + +function uuid(value: unknown, path: string): Nominal { + if (typeof value !== "string" || !uuidPattern.test(value)) { + fail(path, "must be a lowercase RFC 9562 UUID"); + } + return value as Nominal; +} + +export function parseInstallationId(value: unknown, path = "installationId"): InstallationId { + return uuid(value, path); +} + +export function parseDeviceId(value: unknown, path = "deviceId"): DeviceId { + return uuid(value, path); +} + +export function parseCryptoSessionId(value: unknown, path = "cryptoSessionId"): CryptoSessionId { + return uuid(value, path); +} + +export function parseAxlSessionId(value: unknown, path = "axlSessionId"): AxlSessionId { + return uuid(value, path); +} + +export function parseRouteId(value: unknown, path = "routeId"): RouteId { + return uuid(value, path); +} + +export function parseTransportAttemptId(value: unknown, path = "attemptId"): TransportAttemptId { + return uuid(value, path); +} + +export function parseEnvelopeId(value: unknown, path = "envelopeId"): EnvelopeId { + return uuid(value, path); +} + +export function parseRemoteRequestId(value: unknown, path = "requestId"): RequestId { + return uuid(value, path); +} + +export function parseIdempotencyKey(value: unknown, path = "idempotencyKey"): IdempotencyKey { + return uuid(value, path); +} + +export function parseObjectId(value: unknown, path = "objectId"): ObjectId { + return uuid(value, path); +} + +export function parseRelayLimits(value: unknown, path = "limits"): RelayLimits { + const candidate = object(value, path); + exact(candidate, path, [ + "maxFrameBytes", + "maxQueuedBytes", + "heartbeatIntervalMs", + "idleTimeoutMs", + ]); + return { + maxFrameBytes: integer( + candidate.maxFrameBytes, + `${path}.maxFrameBytes`, + 1, + MAX_RELAY_FRAME_BYTES, + ), + maxQueuedBytes: integer( + candidate.maxQueuedBytes, + `${path}.maxQueuedBytes`, + 1, + MAX_RELAY_QUEUED_BYTES, + ), + heartbeatIntervalMs: integer( + candidate.heartbeatIntervalMs, + `${path}.heartbeatIntervalMs`, + 1, + 300_000, + ), + idleTimeoutMs: integer(candidate.idleTimeoutMs, `${path}.idleTimeoutMs`, 1, 600_000), + }; +} + +export function parseIssueRelayTicketRequest(value: unknown): IssueRelayTicketRequest { + const candidate = object(value, "request"); + exact(candidate, "request", ["installationId", "role"], ["deviceId"]); + const parsedRole = role(candidate.role, "request.role"); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, "request.deviceId"); + if (parsedRole === "device" && deviceId === undefined) { + fail("request.deviceId", "is required for the device role"); + } + if (parsedRole === "daemon" && deviceId !== undefined) { + fail("request.deviceId", "is not allowed for the daemon role"); + } + return { + installationId: parseInstallationId(candidate.installationId, "request.installationId"), + ...(deviceId === undefined ? {} : { deviceId }), + role: parsedRole, + }; +} + +export function parseIssueRelayTicketResult(value: unknown): IssueRelayTicketResult { + const candidate = object(value, "result"); + exact(candidate, "result", ["ticket", "relayUrl", "expiresAt", "proofSchemeVersion", "limits"]); + const relayUrl = boundedString(candidate.relayUrl, "result.relayUrl", 2_048); + let parsedUrl: URL; + try { + parsedUrl = new URL(relayUrl); + } catch { + fail("result.relayUrl", "must be an absolute URL"); + } + if (parsedUrl.protocol !== "wss:") { + fail("result.relayUrl", "must use wss"); + } + return { + ticket: boundedString(candidate.ticket, "result.ticket", 1_024), + relayUrl, + expiresAt: timestamp(candidate.expiresAt, "result.expiresAt"), + proofSchemeVersion: integer(candidate.proofSchemeVersion, "result.proofSchemeVersion", 1, 255), + limits: parseRelayLimits(candidate.limits, "result.limits"), + }; +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let encoded = ""; + for (let offset = 0; offset < binary.length; offset += 3) { + const first = binary.charCodeAt(offset); + const hasSecond = offset + 1 < binary.length; + const hasThird = offset + 2 < binary.length; + const second = hasSecond ? binary.charCodeAt(offset + 1) : 0; + const third = hasThird ? binary.charCodeAt(offset + 2) : 0; + const bits = (first << 16) | (second << 8) | third; + encoded += alphabet[(bits >>> 18) & 63]; + encoded += alphabet[(bits >>> 12) & 63]; + encoded += hasSecond ? alphabet[(bits >>> 6) & 63] : "="; + encoded += hasThird ? alphabet[bits & 63] : "="; + } + return encoded; +} + +export function decodeBase64(value: unknown, path: string, maximumBytes: number): Uint8Array { + const encoded = boundedString(value, path, Math.ceil(maximumBytes / 3) * 4); + if (!base64Pattern.test(encoded)) fail(path, "must be canonical base64"); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const output: number[] = []; + for (let offset = 0; offset < encoded.length; offset += 4) { + const chars = encoded.slice(offset, offset + 4); + const values = [...chars].map((character) => + character === "=" ? 0 : alphabet.indexOf(character), + ); + if (values.some((entry) => entry < 0)) fail(path, "must be canonical base64"); + const [first, second, third, fourth] = values; + if ( + first === undefined || + second === undefined || + third === undefined || + fourth === undefined + ) { + fail(path, "must be canonical base64"); + } + const bits = (first << 18) | (second << 12) | (third << 6) | fourth; + output.push((bits >>> 16) & 0xff); + if (chars[2] !== "=") output.push((bits >>> 8) & 0xff); + if (chars[3] !== "=") output.push(bits & 0xff); + } + if (output.length > maximumBytes || encodeBase64(Uint8Array.from(output)) !== encoded) { + fail(path, `must encode no more than ${maximumBytes} bytes`); + } + return Uint8Array.from(output); +} + +export function parseInternalConsumeRelayTicketRequest(value: unknown): ConsumeRelayTicketRequest { + const candidate = object(value, "request"); + exact(candidate, "request", [ + "version", + "ticket", + "relayInstanceId", + "connectionNonce", + "possessionProof", + ]); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("request.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + return { + ticket: boundedString(candidate.ticket, "request.ticket", 1_024), + relayInstanceId: boundedString(candidate.relayInstanceId, "request.relayInstanceId", 128), + connectionNonce: boundedString(candidate.connectionNonce, "request.connectionNonce", 256), + possessionProof: decodeBase64(candidate.possessionProof, "request.possessionProof", 1_024), + }; +} + +export function encodeInternalConsumeRelayTicketRequest( + request: ConsumeRelayTicketRequest, +): InternalConsumeRelayTicketWireRequest { + return { + version: INTERNAL_RELAY_API_VERSION, + ticket: request.ticket, + relayInstanceId: request.relayInstanceId, + connectionNonce: request.connectionNonce, + possessionProof: encodeBase64(request.possessionProof), + }; +} + +export function parseInternalConsumeRelayTicketResult(value: unknown): ConsumeRelayTicketResult { + const candidate = object(value, "result"); + exact( + candidate, + "result", + ["version", "installationId", "sourceRouteId", "role", "leaseExpiresAt", "limits"], + ["deviceId"], + ); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("result.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + const parsedRole = role(candidate.role, "result.role"); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, "result.deviceId"); + if (parsedRole === "device" && deviceId === undefined) fail("result.deviceId", "is required"); + if (parsedRole === "daemon" && deviceId !== undefined) fail("result.deviceId", "is not allowed"); + return { + installationId: parseInstallationId(candidate.installationId, "result.installationId"), + ...(deviceId === undefined ? {} : { deviceId }), + sourceRouteId: parseRouteId(candidate.sourceRouteId, "result.sourceRouteId"), + role: parsedRole, + leaseExpiresAt: timestamp(candidate.leaseExpiresAt, "result.leaseExpiresAt"), + limits: parseRelayLimits(candidate.limits, "result.limits"), + }; +} + +export function encodeInternalConsumeRelayTicketResult( + result: ConsumeRelayTicketResult, +): InternalConsumeRelayTicketWireResult { + return { version: INTERNAL_RELAY_API_VERSION, ...result }; +} + +export function parseRelayRevocationNotification(value: unknown): RelayRevocationNotification { + const candidate = object(value, "request"); + exact( + candidate, + "request", + ["version", "installationId", "generation", "effectiveAt"], + ["deviceId"], + ); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("request.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + return { + version: INTERNAL_RELAY_API_VERSION, + installationId: parseInstallationId(candidate.installationId, "request.installationId"), + ...(candidate.deviceId === undefined + ? {} + : { deviceId: parseDeviceId(candidate.deviceId, "request.deviceId") }), + generation: integer(candidate.generation, "request.generation", 1, Number.MAX_SAFE_INTEGER), + effectiveAt: timestamp(candidate.effectiveAt, "request.effectiveAt"), + }; +} + +export function parseRelayRevocationResult(value: unknown): RelayRevocationResult { + const candidate = object(value, "result"); + exact(candidate, "result", ["version", "accepted"]); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("result.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + if (candidate.accepted !== true) fail("result.accepted", "must be true"); + return { version: INTERNAL_RELAY_API_VERSION, accepted: true }; +} + +export function parseAuthenticatedRemoteRequest(value: unknown): AuthenticatedRemoteRequest { + const candidate = object(value, "request"); + exact(candidate, "request", ["deviceId", "requestId", "method", "params"], ["idempotencyKey"]); + const method = boundedString(candidate.method, "request.method", 128); + if (!methodPattern.test(method)) fail("request.method", "has an invalid method name"); + return { + deviceId: parseDeviceId(candidate.deviceId, "request.deviceId"), + requestId: parseRemoteRequestId(candidate.requestId, "request.requestId"), + ...(candidate.idempotencyKey === undefined + ? {} + : { + idempotencyKey: parseIdempotencyKey(candidate.idempotencyKey, "request.idempotencyKey"), + }), + method, + params: candidate.params, + }; +} + +function uuidBytes(value: string): Uint8Array { + const hexadecimal = value.replaceAll("-", ""); + return Uint8Array.from({ length: 16 }, (_, index) => + Number.parseInt(hexadecimal.slice(index * 2, index * 2 + 2), 16), + ); +} + +function bytesUuid(bytes: Uint8Array, offset: number, path: string): string { + const hexadecimal = [...bytes.subarray(offset, offset + 16)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return uuidPattern.test( + `${hexadecimal.slice(0, 8)}-${hexadecimal.slice(8, 12)}-${hexadecimal.slice(12, 16)}-${hexadecimal.slice(16, 20)}-${hexadecimal.slice(20)}`, + ) + ? `${hexadecimal.slice(0, 8)}-${hexadecimal.slice(8, 12)}-${hexadecimal.slice(12, 16)}-${hexadecimal.slice(16, 20)}-${hexadecimal.slice(20)}` + : fail(path, "contains an invalid RFC 9562 UUID"); +} + +function writePrefix(output: Uint8Array, kind: number, attemptId: TransportAttemptId): void { + output.set(frameMagic, 0); + output[4] = REMOTE_TRANSPORT_VERSION; + output[5] = kind; + output.set(uuidBytes(attemptId), 6); +} + +function encodeRoutedFrame( + kind: 1 | 2, + attemptId: TransportAttemptId, + routeId: RouteId, + payload: Uint8Array, +): Uint8Array { + if (payload.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES) { + fail("frame.opaquePayload", `must not exceed ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`); + } + const output = new Uint8Array(routedFrameHeaderBytes + payload.byteLength); + writePrefix(output, kind, attemptId); + output.set(uuidBytes(routeId), 22); + new DataView(output.buffer).setUint32(38, payload.byteLength, false); + output.set(payload, routedFrameHeaderBytes); + return output; +} + +export function encodeRelayBinaryFrame(frame: RelayBinaryFrame): Uint8Array { + switch ( + "destinationRouteId" in frame + ? "send" + : "sourceRouteId" in frame + ? "delivery" + : "status" in frame + ? "receipt" + : "failure" + ) { + case "send": + return encodeRoutedFrame( + 1, + frame.attemptId, + (frame as RelaySendFrame).destinationRouteId, + (frame as RelaySendFrame).opaquePayload, + ); + case "delivery": + return encodeRoutedFrame( + 2, + frame.attemptId, + (frame as RelayDelivery).sourceRouteId, + (frame as RelayDelivery).opaquePayload, + ); + case "receipt": { + const output = new Uint8Array(shortFrameBytes); + writePrefix(output, 3, frame.attemptId); + const status = (frame as RelayReceipt).status; + if (status !== "admitted" && status !== "forwarded") fail("frame.status", "is invalid"); + output[22] = status === "admitted" ? 1 : 2; + return output; + } + case "failure": { + const output = new Uint8Array(shortFrameBytes); + writePrefix(output, 4, frame.attemptId); + const failureIndex = RELAY_FAILURE_CODES.indexOf((frame as RelayFailure).code); + if (failureIndex < 0) fail("frame.code", "is invalid"); + output[22] = failureIndex + 1; + return output; + } + } +} + +export function parseRelayBinaryFrame(value: Uint8Array): RelayBinaryFrame { + if (!(value instanceof Uint8Array)) fail("frame", "must be bytes"); + if (value.byteLength < 6 || value.byteLength > MAX_RELAY_FRAME_BYTES) { + fail("frame", `must contain 6 through ${MAX_RELAY_FRAME_BYTES} bytes`); + } + if (!frameMagic.every((byte, index) => value[index] === byte)) fail("frame.magic", "is invalid"); + if (value[4] !== REMOTE_TRANSPORT_VERSION) { + fail("frame.transportVersion", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + const kind = value[5]; + const attemptId = parseTransportAttemptId(bytesUuid(value, 6, "frame.attemptId")); + if (kind === 1 || kind === 2) { + if (value.byteLength < routedFrameHeaderBytes) fail("frame", "has a truncated routed header"); + const routeId = parseRouteId(bytesUuid(value, 22, "frame.routeId")); + const payloadLength = new DataView(value.buffer, value.byteOffset, value.byteLength).getUint32( + 38, + false, + ); + if (payloadLength !== value.byteLength - routedFrameHeaderBytes) { + fail("frame.opaquePayload", "length does not match the frame size"); + } + const opaquePayload = value.slice(routedFrameHeaderBytes); + return kind === 1 + ? { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId: routeId, + opaquePayload, + } + : { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + sourceRouteId: routeId, + opaquePayload, + }; + } + if (value.byteLength !== shortFrameBytes) fail("frame", "has an invalid control-frame size"); + if (kind === 3) { + const status = value[22] === 1 ? "admitted" : value[22] === 2 ? "forwarded" : undefined; + if (status === undefined) fail("frame.status", "is invalid"); + return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, status }; + } + if (kind === 4) { + const failureByte = value[22]; + if (failureByte === undefined) fail("frame.code", "is missing"); + const code = RELAY_FAILURE_CODES[failureByte - 1]; + if (code === undefined) fail("frame.code", "is invalid"); + return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, code }; + } + return fail("frame.kind", "is invalid"); +} diff --git a/packages/protocol/test/fixtures/internal-relay-api-v1.json b/packages/protocol/test/fixtures/internal-relay-api-v1.json new file mode 100644 index 00000000..7a6596de --- /dev/null +++ b/packages/protocol/test/fixtures/internal-relay-api-v1.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "consumeTicket": { + "request": { + "version": 1, + "ticket": "fixture-ticket-never-valid-outside-tests", + "relayInstanceId": "relay-fixture-1", + "connectionNonce": "fixture-connection-nonce", + "possessionProof": "AAECA/8=" + }, + "result": { + "version": 1, + "installationId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "sourceRouteId": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "role": "device", + "leaseExpiresAt": 2000000000000, + "limits": { + "maxFrameBytes": 65535, + "maxQueuedBytes": 524288, + "heartbeatIntervalMs": 20000, + "idleTimeoutMs": 60000 + } + } + }, + "revocation": { + "request": { + "version": 1, + "installationId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "generation": 7, + "effectiveAt": 1900000000000 + }, + "result": { + "version": 1, + "accepted": true + } + } +} diff --git a/packages/protocol/test/fixtures/remote-transport-v1.json b/packages/protocol/test/fixtures/remote-transport-v1.json new file mode 100644 index 00000000..628d839a --- /dev/null +++ b/packages/protocol/test/fixtures/remote-transport-v1.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "accepted": [ + { + "name": "send", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "frame": { + "kind": "send", + "attemptId": "11111111-1111-4111-8111-111111111111", + "routeId": "22222222-2222-4222-8222-222222222222", + "opaquePayloadBase64": "AAEC/0FYTFI=" + } + }, + { + "name": "delivery", + "base64": "QVhMUgECERERERERQRGBERERERERETMzMzMzM0MzgzMzMzMzMzMAAAAIAAEC/0FYTFI=", + "frame": { + "kind": "delivery", + "attemptId": "11111111-1111-4111-8111-111111111111", + "routeId": "33333333-3333-4333-8333-333333333333", + "opaquePayloadBase64": "AAEC/0FYTFI=" + } + }, + { + "name": "admitted-receipt", + "base64": "QVhMUgEDERERERERQRGBEREREREREQE=", + "frame": { + "kind": "receipt", + "attemptId": "11111111-1111-4111-8111-111111111111", + "status": "admitted" + } + }, + { + "name": "destination-offline", + "base64": "QVhMUgEEERERERERQRGBEREREREREQc=", + "frame": { + "kind": "failure", + "attemptId": "11111111-1111-4111-8111-111111111111", + "code": "destination_offline" + } + } + ], + "rejected": [ + { + "name": "wrong-magic", + "base64": "QlhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "errorPath": "frame.magic" + }, + { + "name": "unsupported-version", + "base64": "QVhMUgIBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "errorPath": "frame.transportVersion" + }, + { + "name": "truncated-header", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIi", + "errorPath": "frame" + }, + { + "name": "payload-length-mismatch", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAJAAEC/0FYTFI=", + "errorPath": "frame.opaquePayload" + }, + { + "name": "unknown-kind", + "base64": "QVhMUgEJERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "errorPath": "frame" + } + ] +} diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts new file mode 100644 index 00000000..63b60f8e --- /dev/null +++ b/packages/protocol/test/remote-transport.test.ts @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + decodeBase64, + DEFAULT_RELAY_LIMITS, + encodeBase64, + encodeInternalConsumeRelayTicketRequest, + encodeRelayBinaryFrame, + MAX_RELAY_FRAME_BYTES, + MAX_RELAY_OPAQUE_PAYLOAD_BYTES, + parseInternalConsumeRelayTicketRequest, + parseDeviceId, + parseInternalConsumeRelayTicketResult, + parseIssueRelayTicketRequest, + parseRelayBinaryFrame, + parseRelayRevocationNotification, + ProtocolValidationError, + REMOTE_TRANSPORT_VERSION, + type RelayBinaryFrame, +} from "../src/index.ts"; +import { DeterministicFakeRemoteCryptoAdapter } from "./support/fake-remote-crypto.ts"; + +interface BinaryFixture { + readonly accepted: readonly { + readonly name: string; + readonly base64: string; + readonly frame: Readonly>; + }[]; + readonly rejected: readonly { + readonly name: string; + readonly base64: string; + readonly errorPath: string; + }[]; +} + +const binaryFixtures = JSON.parse( + readFileSync(new URL("./fixtures/remote-transport-v1.json", import.meta.url), "utf8"), +) as BinaryFixture; +const internalFixtures = JSON.parse( + readFileSync(new URL("./fixtures/internal-relay-api-v1.json", import.meta.url), "utf8"), +) as { + readonly consumeTicket: { readonly request: unknown; readonly result: unknown }; + readonly revocation: { readonly request: unknown; readonly result: unknown }; +}; + +function fixtureShape(frame: RelayBinaryFrame): Readonly> { + if ("destinationRouteId" in frame) { + return { + kind: "send", + attemptId: frame.attemptId, + routeId: frame.destinationRouteId, + opaquePayloadBase64: encodeBase64(frame.opaquePayload), + }; + } + if ("sourceRouteId" in frame) { + return { + kind: "delivery", + attemptId: frame.attemptId, + routeId: frame.sourceRouteId, + opaquePayloadBase64: encodeBase64(frame.opaquePayload), + }; + } + if ("status" in frame) + return { kind: "receipt", attemptId: frame.attemptId, status: frame.status }; + return { kind: "failure", attemptId: frame.attemptId, code: frame.code }; +} + +test("accepts and reproduces every canonical relay frame", () => { + for (const fixture of binaryFixtures.accepted) { + const bytes = decodeBase64(fixture.base64, `${fixture.name}.base64`, MAX_RELAY_FRAME_BYTES); + const parsed = parseRelayBinaryFrame(bytes); + assert.deepEqual(fixtureShape(parsed), fixture.frame, fixture.name); + assert.deepEqual(encodeRelayBinaryFrame(parsed), bytes, fixture.name); + } +}); + +test("rejects every malformed canonical relay frame", () => { + for (const fixture of binaryFixtures.rejected) { + const bytes = decodeBase64(fixture.base64, `${fixture.name}.base64`, MAX_RELAY_FRAME_BYTES); + assert.throws( + () => parseRelayBinaryFrame(bytes), + (error) => error instanceof ProtocolValidationError && error.path === fixture.errorPath, + fixture.name, + ); + } +}); + +test("enforces the complete frame bound before encoding", () => { + const attemptId = "11111111-1111-4111-8111-111111111111" as const; + const destinationRouteId = "22222222-2222-4222-8222-222222222222" as const; + const frame = { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId, + opaquePayload: new Uint8Array(MAX_RELAY_OPAQUE_PAYLOAD_BYTES + 1), + } as RelayBinaryFrame; + assert.throws( + () => encodeRelayBinaryFrame(frame), + (error) => error instanceof ProtocolValidationError && error.path === "frame.opaquePayload", + ); +}); + +test("keeps the deterministic fake E2EE adapter in test support", async () => { + const daemonId = parseDeviceId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + const daemon = new DeterministicFakeRemoteCryptoAdapter(daemonId, deviceId); + const device = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonId); + const plaintext = Uint8Array.of(0, 1, 2, 255); + + const opaque = await device.seal(daemonId, plaintext); + assert.match(new TextDecoder().decode(opaque), /TEST_ONLY_NOT_ENCRYPTED/); + assert.deepEqual(await daemon.open(opaque), { + authenticatedDeviceId: deviceId, + plaintext, + }); + + const modified = JSON.parse(new TextDecoder().decode(opaque)) as Record; + modified.sourceDeviceId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + await assert.rejects(daemon.open(new TextEncoder().encode(JSON.stringify(modified)))); +}); + +test("validates ticket roles and the language-neutral internal contract", () => { + assert.deepEqual( + parseIssueRelayTicketRequest({ + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: "device", + }), + { + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: "device", + }, + ); + assert.throws( + () => + parseIssueRelayTicketRequest({ + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + role: "device", + }), + (error) => error instanceof ProtocolValidationError && error.path === "request.deviceId", + ); + + const request = parseInternalConsumeRelayTicketRequest(internalFixtures.consumeTicket.request); + assert.deepEqual([...request.possessionProof], [0, 1, 2, 3, 255]); + assert.deepEqual( + encodeInternalConsumeRelayTicketRequest(request), + internalFixtures.consumeTicket.request, + ); + assert.deepEqual(parseInternalConsumeRelayTicketResult(internalFixtures.consumeTicket.result), { + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + sourceRouteId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + role: "device", + leaseExpiresAt: 2_000_000_000_000, + limits: DEFAULT_RELAY_LIMITS, + }); + assert.deepEqual( + parseRelayRevocationNotification(internalFixtures.revocation.request), + internalFixtures.revocation.request, + ); +}); diff --git a/packages/protocol/test/support/fake-remote-crypto.ts b/packages/protocol/test/support/fake-remote-crypto.ts new file mode 100644 index 00000000..245313ff --- /dev/null +++ b/packages/protocol/test/support/fake-remote-crypto.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { decodeBase64, encodeBase64, parseDeviceId, type DeviceId } from "../../src/index.ts"; + +export interface AuthenticatedPlaintext { + readonly authenticatedDeviceId: DeviceId; + readonly plaintext: Uint8Array; +} + +export interface RemoteCryptoAdapter { + open(opaqueEnvelope: Uint8Array): Promise; + seal(destinationDeviceId: DeviceId, plaintext: Uint8Array): Promise; +} + +interface FakeEnvelope { + readonly warning: "TEST_ONLY_NOT_ENCRYPTED"; + readonly sourceDeviceId: string; + readonly destinationDeviceId: string; + readonly plaintextBase64: string; +} + +/** Deterministic test framing. It provides no confidentiality, integrity, or replay protection. */ +export class DeterministicFakeRemoteCryptoAdapter implements RemoteCryptoAdapter { + private readonly localDeviceId: DeviceId; + private readonly expectedRemoteDeviceId: DeviceId; + + constructor(localDeviceId: DeviceId, expectedRemoteDeviceId: DeviceId) { + this.localDeviceId = localDeviceId; + this.expectedRemoteDeviceId = expectedRemoteDeviceId; + } + + async open(opaqueEnvelope: Uint8Array): Promise { + let candidate: FakeEnvelope; + try { + candidate = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(opaqueEnvelope)); + } catch (cause) { + throw new Error("Fake E2EE envelope is invalid", { cause }); + } + if ( + candidate.warning !== "TEST_ONLY_NOT_ENCRYPTED" || + parseDeviceId(candidate.sourceDeviceId) !== this.expectedRemoteDeviceId || + parseDeviceId(candidate.destinationDeviceId) !== this.localDeviceId + ) { + throw new Error("Fake E2EE envelope identity does not match the test endpoints"); + } + return { + authenticatedDeviceId: this.expectedRemoteDeviceId, + plaintext: decodeBase64(candidate.plaintextBase64, "fakeEnvelope.plaintextBase64", 65_535), + }; + } + + async seal(destinationDeviceId: DeviceId, plaintext: Uint8Array): Promise { + if (destinationDeviceId !== this.expectedRemoteDeviceId) { + throw new Error("Fake E2EE destination does not match the configured test endpoint"); + } + return new TextEncoder().encode( + JSON.stringify({ + warning: "TEST_ONLY_NOT_ENCRYPTED", + sourceDeviceId: this.localDeviceId, + destinationDeviceId, + plaintextBase64: encodeBase64(plaintext), + } satisfies FakeEnvelope), + ); + } +} From 726cb8a07ac4b7d382eab65c83508b40f59fb414 Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:41 +0400 Subject: [PATCH 2/8] feat(control-plane): add atomic relay ticket admission Signed-off-by: Lokesh --- package.json | 5 +- pnpm-lock.yaml | 6 + pnpm-workspace.yaml | 2 + services/control-plane/README.md | 10 + services/control-plane/package.json | 25 +++ services/control-plane/src/index.ts | 6 + services/control-plane/src/revocations.ts | 27 +++ services/control-plane/src/server.ts | 138 +++++++++++++ services/control-plane/src/tickets.ts | 197 ++++++++++++++++++ services/control-plane/test/tickets.test.ts | 209 ++++++++++++++++++++ services/control-plane/tsconfig.build.json | 12 ++ services/control-plane/tsconfig.json | 7 + tsconfig.base.json | 1 + tsconfig.json | 2 +- 14 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 services/control-plane/README.md create mode 100644 services/control-plane/package.json create mode 100644 services/control-plane/src/index.ts create mode 100644 services/control-plane/src/revocations.ts create mode 100644 services/control-plane/src/server.ts create mode 100644 services/control-plane/src/tickets.ts create mode 100644 services/control-plane/test/tickets.test.ts create mode 100644 services/control-plane/tsconfig.build.json create mode 100644 services/control-plane/tsconfig.json diff --git a/package.json b/package.json index 838d22b4..73ef4290 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": "^22.19.0 || >=24.0.0" }, "scripts": { - "build": "tsc -b packages/*/tsconfig.build.json packages/extensions/*/tsconfig.build.json --force && pnpm --filter @axl/web build", + "build": "tsc -b packages/*/tsconfig.build.json packages/extensions/*/tsconfig.build.json services/*/tsconfig.build.json --force && pnpm --filter @axl/web build", "build:release": "node scripts/build-release-package.ts", "build:release-metadata": "node scripts/build-release-metadata.ts", "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm check:boundaries && pnpm check:generated", @@ -20,7 +20,8 @@ "lint": "biome lint --error-on-warnings .", "release": "node scripts/release.ts", "release:preview": "node scripts/release.ts --preview", - "test": "pnpm build && node --test --test-concurrency=1 --test-timeout=30000 packages/*/test/*.test.ts packages/extensions/*/test/*.test.ts scripts/*.test.ts", + "relay:check": "cd services/relay && mix format --check-formatted && mix compile --warnings-as-errors && mix test && mix credo --strict && mix dialyzer && mix deps.audit", + "test": "pnpm build && node --test --test-concurrency=1 --test-timeout=30000 packages/*/test/*.test.ts packages/extensions/*/test/*.test.ts services/*/test/*.test.ts scripts/*.test.ts", "typecheck": "tsc --noEmit && pnpm --filter @axl/ui typecheck && pnpm --filter @axl/web typecheck" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5375298..280e6c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,12 @@ importers: specifier: 8.2.2 version: 8.2.2(@types/node@22.19.19)(esbuild@0.28.2)(yaml@2.8.3) + services/control-plane: + dependencies: + '@axl/protocol': + specifier: workspace:* + version: link:../../packages/protocol + packages: '@aws-sdk/core@3.977.9': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ac38dc1..ef9913f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,11 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 packages: - packages/* - packages/extensions/* + - services/* - apps/* allowBuilds: esbuild: false diff --git a/services/control-plane/README.md b/services/control-plane/README.md new file mode 100644 index 00000000..0d34b5e5 --- /dev/null +++ b/services/control-plane/README.md @@ -0,0 +1,10 @@ + + + +# Axl control plane + +This separately deployable TypeScript service owns hosted control-plane mutations. The first slice implements authorized relay-ticket issuance, atomic one-use consumption, and the authenticated internal HTTP boundary used by the relay. + +The service uses injected principal authentication, relay authentication, authorization, proof verification, clocks, and stores. Tests use deterministic in-memory implementations. No production identity provider, datastore, service-authentication scheme, or cryptographic proof is selected. + +The service never logs or places tickets or internal credentials in URLs. Production assembly remains blocked until those deployment decisions receive owner approval. diff --git a/services/control-plane/package.json b/services/control-plane/package.json new file mode 100644 index 00000000..8c3b1499 --- /dev/null +++ b/services/control-plane/package.json @@ -0,0 +1,25 @@ +{ + "name": "@axl/control-plane", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Hosted Axl control-plane service", + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json --force", + "test": "pnpm --filter @axl/protocol build && node --test test/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@axl/protocol": "workspace:*" + } +} diff --git a/services/control-plane/src/index.ts b/services/control-plane/src/index.ts new file mode 100644 index 00000000..a807c194 --- /dev/null +++ b/services/control-plane/src/index.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +export * from "./revocations.ts"; +export * from "./server.ts"; +export * from "./tickets.ts"; diff --git a/services/control-plane/src/revocations.ts b/services/control-plane/src/revocations.ts new file mode 100644 index 00000000..2f3eb8e5 --- /dev/null +++ b/services/control-plane/src/revocations.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { + parseRelayRevocationNotification, + parseRelayRevocationResult, + type RelayRevocationNotification, + type RelayRevocationResult, +} from "@axl/protocol"; + +export interface AuthenticatedRelayInternalTransport { + post(path: string, body: unknown): Promise; +} + +export class RelayRevocationNotifier { + private readonly transport: AuthenticatedRelayInternalTransport; + + constructor(transport: AuthenticatedRelayInternalTransport) { + this.transport = transport; + } + + async notify(value: RelayRevocationNotification): Promise { + const notification = parseRelayRevocationNotification(value); + const response = await this.transport.post("/internal/v1/revocations", notification); + return parseRelayRevocationResult(response); + } +} diff --git a/services/control-plane/src/server.ts b/services/control-plane/src/server.ts new file mode 100644 index 00000000..d0ff7e09 --- /dev/null +++ b/services/control-plane/src/server.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import type { IncomingMessage, RequestListener, ServerResponse } from "node:http"; + +import { + encodeInternalConsumeRelayTicketResult, + parseInternalConsumeRelayTicketRequest, + ProtocolValidationError, +} from "@axl/protocol"; + +import { RelayTicketError, type AccountPrincipal, type RelayTicketService } from "./tickets.ts"; + +const MAX_REQUEST_BYTES = 4_096; + +export interface PublicPrincipalAuthenticator { + authenticate(request: IncomingMessage): Promise; +} + +export interface InternalRelayAuthenticator { + authenticate(request: IncomingMessage, exactBody: Uint8Array): Promise; +} + +export interface ControlPlaneHandlerOptions { + readonly tickets: RelayTicketService; + readonly publicAuthentication: PublicPrincipalAuthenticator; + readonly internalAuthentication: InternalRelayAuthenticator; +} + +class HttpRequestError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "HttpRequestError"; + this.status = status; + } +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of request) { + const bytes = + typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk); + size += bytes.byteLength; + if (size > MAX_REQUEST_BYTES) throw new HttpRequestError(413, "Request body is too large"); + chunks.push(bytes); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseJson(body: Uint8Array): unknown { + if (body.byteLength === 0) throw new HttpRequestError(400, "Request body is required"); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + } catch { + throw new HttpRequestError(400, "Request body must be valid UTF-8 JSON"); + } +} + +function respond(response: ServerResponse, status: number, body: unknown): void { + const bytes = new TextEncoder().encode(JSON.stringify(body)); + response.writeHead(status, { + "cache-control": "no-store", + "content-length": bytes.byteLength, + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff", + }); + response.end(bytes); +} + +function respondError(response: ServerResponse, error: unknown): void { + if (error instanceof RelayTicketError) { + respond(response, error.httpStatus, { error: { code: error.code, message: error.message } }); + return; + } + if (error instanceof ProtocolValidationError) { + respond(response, 400, { + error: { code: "bad_request", message: "Request validation failed", path: error.path }, + }); + return; + } + if (error instanceof HttpRequestError) { + respond(response, error.status, { error: { code: "bad_request", message: error.message } }); + return; + } + respond(response, 503, { + error: { code: "service_unavailable", message: "Control plane is unavailable" }, + }); +} + +function requestPath(request: IncomingMessage): string | undefined { + if (request.url === undefined) return undefined; + const url = new URL(request.url, "http://control-plane.invalid"); + return url.search === "" ? url.pathname : undefined; +} + +export function createControlPlaneHandler(options: ControlPlaneHandlerOptions): RequestListener { + return (request, response) => { + void (async () => { + if (request.method !== "POST") { + respond(response, 405, { error: { code: "method_not_allowed" } }); + return; + } + const path = requestPath(request); + if (path === "/v1/relay/tickets") { + const principal = await options.publicAuthentication.authenticate(request); + if (principal === undefined) { + respond(response, 401, { error: { code: "unauthorized" } }); + return; + } + const result = await options.tickets.issue(principal, parseJson(await readBody(request))); + respond(response, 201, result); + return; + } + if (path === "/internal/v1/relay/tickets/consume") { + const body = await readBody(request); + if (!(await options.internalAuthentication.authenticate(request, body))) { + respond(response, 401, { error: { code: "unauthorized" } }); + return; + } + const result = await options.tickets.consume( + parseInternalConsumeRelayTicketRequest(parseJson(body)), + ); + respond(response, 200, encodeInternalConsumeRelayTicketResult(result)); + return; + } + respond(response, 404, { error: { code: "not_found" } }); + })().catch((error: unknown) => respondError(response, error)); + }; +} diff --git a/services/control-plane/src/tickets.ts b/services/control-plane/src/tickets.ts new file mode 100644 index 00000000..bb8fc0cf --- /dev/null +++ b/services/control-plane/src/tickets.ts @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import { + DEFAULT_RELAY_LIMITS, + parseIssueRelayTicketRequest, + parseIssueRelayTicketResult, + parseRelayLimits, + parseRouteId, + RELAY_TICKET_LIFETIME_MS, + type ConsumeRelayTicketRequest, + type ConsumeRelayTicketResult, + type IssueRelayTicketRequest, + type IssueRelayTicketResult, + type RelayLimits, +} from "@axl/protocol"; + +export interface AccountPrincipal { + readonly accountId: string; +} + +export interface Clock { + now(): number; +} + +export interface RelayTicketAuthorizer { + authorize(principal: AccountPrincipal, request: IssueRelayTicketRequest): Promise; +} + +export interface RelayTicketProofVerifier { + verify(ticket: Readonly, request: ConsumeRelayTicketRequest): Promise; +} + +export interface RelayTicketRecord extends IssueRelayTicketRequest { + readonly ticketDigest: string; + readonly sourceRouteId: ConsumeRelayTicketResult["sourceRouteId"]; + readonly issuedAt: number; + readonly expiresAt: number; + readonly limits: RelayLimits; + consumedAt?: number; + consumedByRelayInstanceId?: string; +} + +export interface RelayTicketStore { + insert(record: RelayTicketRecord): Promise; + find(ticketDigest: string): Promise | undefined>; + /** Atomically returns and marks one unexpired ticket as consumed. */ + consume( + ticketDigest: string, + relayInstanceId: string, + now: number, + ): Promise>; +} + +export type RelayTicketErrorCode = + | "unauthorized" + | "forbidden_route" + | "ticket_expired" + | "ticket_consumed" + | "service_unavailable"; + +export class RelayTicketError extends Error { + readonly code: RelayTicketErrorCode; + readonly httpStatus: number; + + constructor(code: RelayTicketErrorCode, message: string, httpStatus: number) { + super(message); + this.name = "RelayTicketError"; + this.code = code; + this.httpStatus = httpStatus; + } +} + +export class InMemoryRelayTicketStore implements RelayTicketStore { + private readonly records = new Map(); + + async insert(record: RelayTicketRecord): Promise { + if (this.records.has(record.ticketDigest)) throw new Error("Relay ticket digest collision"); + this.records.set(record.ticketDigest, record); + } + + async find(ticketDigest: string): Promise | undefined> { + return this.records.get(ticketDigest); + } + + async consume( + ticketDigest: string, + relayInstanceId: string, + now: number, + ): Promise> { + const record = this.records.get(ticketDigest); + if (record === undefined) { + throw new RelayTicketError("unauthorized", "Relay ticket is invalid", 401); + } + if (record.expiresAt <= now) { + throw new RelayTicketError("ticket_expired", "Relay ticket has expired", 401); + } + if (record.consumedAt !== undefined) { + throw new RelayTicketError("ticket_consumed", "Relay ticket has already been consumed", 409); + } + record.consumedAt = now; + record.consumedByRelayInstanceId = relayInstanceId; + return record; + } +} + +export interface RelayTicketServiceOptions { + readonly store: RelayTicketStore; + readonly authorizer: RelayTicketAuthorizer; + readonly proofVerifier: RelayTicketProofVerifier; + readonly relayUrl: string; + readonly clock?: Clock; + readonly limits?: RelayLimits; + readonly ticketLifetimeMs?: number; + readonly randomToken?: () => string; + readonly randomId?: () => string; +} + +function digestTicket(ticket: string): string { + return createHash("sha256").update(ticket, "utf8").digest("hex"); +} + +export class RelayTicketService { + private readonly options: RelayTicketServiceOptions; + private readonly clock: Clock; + private readonly limits: RelayLimits; + private readonly ticketLifetimeMs: number; + private readonly randomToken: () => string; + private readonly randomId: () => string; + + constructor(options: RelayTicketServiceOptions) { + this.options = options; + this.clock = options.clock ?? { now: () => Date.now() }; + this.limits = parseRelayLimits(options.limits ?? DEFAULT_RELAY_LIMITS); + this.ticketLifetimeMs = options.ticketLifetimeMs ?? RELAY_TICKET_LIFETIME_MS; + this.randomToken = options.randomToken ?? (() => randomBytes(32).toString("base64url")); + this.randomId = options.randomId ?? randomUUID; + if ( + !Number.isSafeInteger(this.ticketLifetimeMs) || + this.ticketLifetimeMs <= 0 || + this.ticketLifetimeMs > RELAY_TICKET_LIFETIME_MS + ) { + throw new TypeError(`Ticket lifetime must be from 1 through ${RELAY_TICKET_LIFETIME_MS} ms`); + } + } + + async issue(principal: AccountPrincipal, value: unknown): Promise { + const request = parseIssueRelayTicketRequest(value); + if (!(await this.options.authorizer.authorize(principal, request))) { + throw new RelayTicketError("forbidden_route", "Principal cannot access this route", 403); + } + const now = this.clock.now(); + const ticket = this.randomToken(); + const record: RelayTicketRecord = { + ...request, + ticketDigest: digestTicket(ticket), + sourceRouteId: parseRouteId(this.randomId(), "sourceRouteId"), + issuedAt: now, + expiresAt: now + this.ticketLifetimeMs, + limits: this.limits, + }; + await this.options.store.insert(record); + return parseIssueRelayTicketResult({ + ticket, + relayUrl: this.options.relayUrl, + expiresAt: record.expiresAt, + proofSchemeVersion: 1, + limits: record.limits, + }); + } + + async consume(request: ConsumeRelayTicketRequest): Promise { + const ticketDigest = digestTicket(request.ticket); + const candidate = await this.options.store.find(ticketDigest); + if (candidate === undefined) { + throw new RelayTicketError("unauthorized", "Relay ticket is invalid", 401); + } + if (!(await this.options.proofVerifier.verify(candidate, request))) { + throw new RelayTicketError("unauthorized", "Possession proof is invalid", 401); + } + const consumed = await this.options.store.consume( + ticketDigest, + request.relayInstanceId, + this.clock.now(), + ); + return { + installationId: consumed.installationId, + ...(consumed.deviceId === undefined ? {} : { deviceId: consumed.deviceId }), + sourceRouteId: consumed.sourceRouteId, + role: consumed.role, + leaseExpiresAt: consumed.expiresAt, + limits: consumed.limits, + }; + } +} diff --git a/services/control-plane/test/tickets.test.ts b/services/control-plane/test/tickets.test.ts new file mode 100644 index 00000000..33d73eba --- /dev/null +++ b/services/control-plane/test/tickets.test.ts @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + DEFAULT_RELAY_LIMITS, + encodeInternalConsumeRelayTicketRequest, + INTERNAL_RELAY_API_VERSION, + parseInstallationId, + parseRelayRevocationNotification, +} from "@axl/protocol"; + +import { + createControlPlaneHandler, + InMemoryRelayTicketStore, + RelayRevocationNotifier, + RelayTicketError, + RelayTicketService, +} from "../src/index.ts"; + +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const deviceId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" as const; +const fixture = JSON.parse( + readFileSync( + new URL("../../../packages/protocol/test/fixtures/internal-relay-api-v1.json", import.meta.url), + "utf8", + ), +) as { + readonly revocation: { readonly request: unknown; readonly result: unknown }; +}; + +function createTicketService( + clock: { now(): number } = { now: () => 1_900_000_000_000 }, +): RelayTicketService { + let routeCounter = 0; + return new RelayTicketService({ + store: new InMemoryRelayTicketStore(), + authorizer: { + async authorize(principal, request) { + return ( + principal.accountId === "account-fixture" && request.installationId === installationId + ); + }, + }, + proofVerifier: { + async verify(_ticket, request) { + return Buffer.from(request.possessionProof).equals(Buffer.from([0, 1, 2, 3, 255])); + }, + }, + relayUrl: "wss://relay.invalid/v1/connect", + clock, + randomToken: () => "fixture-ticket-never-valid-outside-tests", + randomId: () => { + routeCounter += 1; + return `cccccccc-cccc-4ccc-8ccc-${routeCounter.toString().padStart(12, "0")}`; + }, + }); +} + +test("atomically consumes a relay ticket once under concurrent calls", async () => { + const service = createTicketService(); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + const request = { + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }; + + const results = await Promise.allSettled([service.consume(request), service.consume(request)]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + const rejection = results.find((result) => result.status === "rejected"); + assert.ok(rejection?.status === "rejected"); + assert.ok(rejection.reason instanceof RelayTicketError); + assert.equal(rejection.reason.code, "ticket_consumed"); +}); + +test("rejects unauthorized issuance, invalid proof, and expired tickets", async () => { + const service = createTicketService(); + await assert.rejects( + service.issue({ accountId: "another-account" }, { installationId, deviceId, role: "device" }), + (error) => error instanceof RelayTicketError && error.code === "forbidden_route", + ); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + await assert.rejects( + service.consume({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(9), + }), + (error) => error instanceof RelayTicketError && error.code === "unauthorized", + ); + + let now = 1_900_000_000_000; + const expiringService = createTicketService({ now: () => now }); + const expiring = await expiringService.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + now += 60_000; + await assert.rejects( + expiringService.consume({ + ticket: expiring.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + (error) => error instanceof RelayTicketError && error.code === "ticket_expired", + ); +}); + +test("serves authenticated public issuance and internal consumption without URL credentials", async (context) => { + const service = createTicketService(); + const handler = createControlPlaneHandler({ + tickets: service, + publicAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer public-fixture" + ? { accountId: "account-fixture" } + : undefined; + }, + }, + internalAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer internal-fixture"; + }, + }, + }); + const server = createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + context.after(() => server.close()); + const address = server.address(); + assert.ok(address !== null && typeof address !== "string"); + const origin = `http://127.0.0.1:${address.port}`; + + const issueResponse = await fetch(`${origin}/v1/relay/tickets`, { + method: "POST", + headers: { authorization: "Bearer public-fixture", "content-type": "application/json" }, + body: JSON.stringify({ installationId, deviceId, role: "device" }), + }); + assert.equal(issueResponse.status, 201); + const issued = (await issueResponse.json()) as { readonly ticket: string }; + + const unauthorized = await fetch(`${origin}/internal/v1/relay/tickets/consume`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + encodeInternalConsumeRelayTicketRequest({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + ), + }); + assert.equal(unauthorized.status, 401); + + const consumeResponse = await fetch(`${origin}/internal/v1/relay/tickets/consume`, { + method: "POST", + headers: { authorization: "Bearer internal-fixture", "content-type": "application/json" }, + body: JSON.stringify( + encodeInternalConsumeRelayTicketRequest({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + ), + }); + assert.equal(consumeResponse.status, 200); + assert.deepEqual(await consumeResponse.json(), { + version: INTERNAL_RELAY_API_VERSION, + installationId, + deviceId, + sourceRouteId: "cccccccc-cccc-4ccc-8ccc-000000000001", + role: "device", + leaseExpiresAt: 1_900_000_060_000, + limits: DEFAULT_RELAY_LIMITS, + }); +}); + +test("validates the authenticated relay revocation boundary", async () => { + let observedPath: string | undefined; + let observedBody: unknown; + const notifier = new RelayRevocationNotifier({ + async post(path, body) { + observedPath = path; + observedBody = body; + return fixture.revocation.result; + }, + }); + const notification = parseRelayRevocationNotification(fixture.revocation.request); + assert.deepEqual(await notifier.notify(notification), fixture.revocation.result); + assert.equal(observedPath, "/internal/v1/revocations"); + assert.deepEqual(observedBody, notification); +}); diff --git a/services/control-plane/tsconfig.build.json b/services/control-plane/tsconfig.build.json new file mode 100644 index 00000000..083eb148 --- /dev/null +++ b/services/control-plane/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "test"], + "references": [{ "path": "../../packages/protocol/tsconfig.build.json" }] +} diff --git a/services/control-plane/tsconfig.json b/services/control-plane/tsconfig.json new file mode 100644 index 00000000..7baee43a --- /dev/null +++ b/services/control-plane/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index a1f39a3e..8b72c4bc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,6 +9,7 @@ "paths": { "@axl/ai": ["./packages/ai/src/index.ts"], "@axl/ai/models": ["./packages/ai/src/models.ts"], + "@axl/control-plane": ["./services/control-plane/src/index.ts"], "@axl/daemon": ["./packages/daemon/src/index.ts"], "@axl/daemon/client": ["./packages/daemon/src/client.ts"], "@axl/extension-api": ["./packages/extensions/api/src/index.ts"], diff --git a/tsconfig.json b/tsconfig.json index e21b7a71..db27a9cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["packages/**/*.ts", "scripts/**/*.ts"], + "include": ["packages/**/*.ts", "services/**/*.ts", "scripts/**/*.ts"], "exclude": ["packages/ui", "packages/web"] } From f76c763876879d0619486292bf7fd60d1479912e Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:51 +0400 Subject: [PATCH 3/8] feat(relay): add bounded opaque WebSocket routing Signed-off-by: Lokesh --- .github/workflows/ci.yml | 44 ++++ .gitignore | 3 + scripts/check-boundaries.test.ts | 24 ++ scripts/check-boundaries.ts | 56 +++- scripts/check-generated.test.ts | 14 +- scripts/check-generated.ts | 4 +- services/relay/.formatter.exs | 6 + services/relay/.tool-versions | 2 + services/relay/README.md | 18 ++ services/relay/lib/axl_relay/admission.ex | 52 ++++ services/relay/lib/axl_relay/application.ex | 19 ++ services/relay/lib/axl_relay/connection.ex | 176 +++++++++++++ services/relay/lib/axl_relay/frame.ex | 168 ++++++++++++ .../axl_relay/http_control_plane_client.ex | 167 ++++++++++++ services/relay/lib/axl_relay/listener.ex | 32 +++ .../relay/lib/axl_relay/revocation_handler.ex | 48 ++++ .../relay/lib/axl_relay/route_registry.ex | 205 ++++++++++++++ services/relay/lib/axl_relay/router.ex | 75 ++++++ services/relay/mix.exs | 35 +++ services/relay/mix.lock | 20 ++ services/relay/test/frame_test.exs | 59 +++++ .../relay/test/internal_contract_test.exs | 52 ++++ services/relay/test/route_registry_test.exs | 115 ++++++++ services/relay/test/test_helper.exs | 4 + services/relay/test/websocket_relay_test.exs | 249 ++++++++++++++++++ 25 files changed, 1641 insertions(+), 6 deletions(-) create mode 100644 services/relay/.formatter.exs create mode 100644 services/relay/.tool-versions create mode 100644 services/relay/README.md create mode 100644 services/relay/lib/axl_relay/admission.ex create mode 100644 services/relay/lib/axl_relay/application.ex create mode 100644 services/relay/lib/axl_relay/connection.ex create mode 100644 services/relay/lib/axl_relay/frame.ex create mode 100644 services/relay/lib/axl_relay/http_control_plane_client.ex create mode 100644 services/relay/lib/axl_relay/listener.ex create mode 100644 services/relay/lib/axl_relay/revocation_handler.ex create mode 100644 services/relay/lib/axl_relay/route_registry.ex create mode 100644 services/relay/lib/axl_relay/router.ex create mode 100644 services/relay/mix.exs create mode 100644 services/relay/mix.lock create mode 100644 services/relay/test/frame_test.exs create mode 100644 services/relay/test/internal_contract_test.exs create mode 100644 services/relay/test/route_registry_test.exs create mode 100644 services/relay/test/test_helper.exs create mode 100644 services/relay/test/websocket_relay_test.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b0efb90..6da928c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 name: CI @@ -27,6 +28,7 @@ jobs: outputs: code: ${{ steps.filter.outputs.code }} workflows: ${{ steps.filter.outputs.workflows }} + relay: ${{ steps.filter.outputs.relay }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 @@ -35,6 +37,7 @@ jobs: filters: | code: - 'packages/**' + - 'services/**' - 'distribution/**' - 'scripts/**' - 'package.json' @@ -46,6 +49,12 @@ jobs: - '.github/workflows/ci.yml' workflows: - '.github/workflows/**' + relay: + - 'services/relay/**' + - 'packages/protocol/src/remote-transport.ts' + - 'packages/protocol/test/fixtures/remote-transport-v1.json' + - 'packages/protocol/test/fixtures/internal-relay-api-v1.json' + - '.github/workflows/ci.yml' quality: name: Build and test @@ -74,6 +83,41 @@ jobs: if: needs.changes.outputs.code == 'true' run: pnpm check + relay: + name: Relay build and test + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: changes + steps: + - name: No relay changes + if: needs.changes.outputs.relay != 'true' + run: echo "No relay changes detected; required check reports success." + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + if: needs.changes.outputs.relay == 'true' + - name: Set up Erlang and Elixir + if: needs.changes.outputs.relay == 'true' + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + otp-version: 27.3.4.17 + elixir-version: 1.18.5 + - name: Restore Mix caches + if: needs.changes.outputs.relay == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + services/relay/deps + services/relay/_build + ~/.mix + key: relay-${{ runner.os }}-${{ hashFiles('services/relay/mix.lock') }} + - name: Fetch relay dependencies + if: needs.changes.outputs.relay == 'true' + working-directory: services/relay + run: mix deps.get --check-locked + - name: Format, compile, test, analyze, and audit relay + if: needs.changes.outputs.relay == 'true' + working-directory: services/relay + run: mix format --check-formatted && mix compile --warnings-as-errors && mix test && mix credo --strict && mix dialyzer && mix deps.audit + licenses: name: REUSE licenses runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 5da15464..dce75226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,13 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 node_modules/ dist/ .release/ coverage/ +services/relay/_build/ +services/relay/deps/ docs/screenshots/ *.tsbuildinfo .env diff --git a/scripts/check-boundaries.test.ts b/scripts/check-boundaries.test.ts index 29d4e159..74aa24fa 100644 --- a/scripts/check-boundaries.test.ts +++ b/scripts/check-boundaries.test.ts @@ -68,6 +68,30 @@ test("enforces protocol, kernel, runtime, TUI, and extension dependency boundari ]); }); +test("enforces control-plane and relay service boundaries", () => { + const root = mkdtempSync(join(tmpdir(), "axl-service-boundaries-")); + const controlPlane = join(root, "services/control-plane"); + mkdirSync(join(controlPlane, "src"), { recursive: true }); + writeFileSync( + join(controlPlane, "package.json"), + JSON.stringify({ name: "@axl/control-plane", dependencies: { fastify: "1.0.0" } }), + ); + writeFileSync(join(controlPlane, "src/index.ts"), 'import "@axl/daemon";\n'); + const relay = join(root, "services/relay"); + mkdirSync(relay, { recursive: true }); + writeFileSync( + join(relay, "mix.exs"), + 'defp deps, do: [{:bandit, "1.12.5"}, {:forbidden, path: "../../packages/kernel"}]\n', + ); + + assert.deepEqual(checkWorkspace(root), [ + "services/control-plane may depend only on @axl/protocol, found fastify", + "services/control-plane/src/index.ts imports @axl/daemon; control plane may import only Node.js and @axl/protocol", + "services/relay may not depend on unapproved package forbidden", + "services/relay must not use path dependencies into repository packages", + ]); +}); + test("checks real imports without interpreting embedded clipboard scripts as dependencies", () => { const root = mkdtempSync(join(tmpdir(), "axl-boundary-syntax-")); writePackage( diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index ff452dcb..c82f9a7d 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -27,7 +27,8 @@ type PackageManifest = { function walk(directory: string, visit: (path: string) => void): void { if (!existsSync(directory)) return; for (const entry of readdirSync(directory, { withFileTypes: true })) { - if ([".git", "dist", "node_modules"].includes(entry.name)) continue; + if ([".git", "_build", "deps", "dist", "node_modules"].includes(entry.name)) continue; + if (entry.isSymbolicLink()) continue; const path = resolve(directory, entry.name); if (entry.isDirectory()) walk(path, visit); else visit(path); @@ -36,9 +37,11 @@ function walk(directory: string, visit: (path: string) => void): void { function packageDirectories(root: string): string[] { const directories: string[] = []; - walk(resolve(root, "packages"), (path) => { - if (path.endsWith(`${sep}package.json`)) directories.push(dirname(path)); - }); + for (const workspaceRoot of ["packages", "services"]) { + walk(resolve(root, workspaceRoot), (path) => { + if (path.endsWith(`${sep}package.json`)) directories.push(dirname(path)); + }); + } return directories; } @@ -93,6 +96,9 @@ export function checkWorkspace(root: string): string[] { const sdk = packages.find(({ directory }) => directory === resolve(root, "packages/sdk")); const ui = packages.find(({ directory }) => directory === resolve(root, "packages/ui")); const tui = packages.find(({ directory }) => directory === resolve(root, "packages/tui")); + const controlPlane = packages.find( + ({ directory }) => directory === resolve(root, "services/control-plane"), + ); const protocolName = protocol?.manifest.name ?? "@axl/protocol"; const kernelName = kernel?.manifest.name ?? "@axl/kernel"; const tuiName = tui?.manifest.name ?? "@axl/tui"; @@ -141,6 +147,16 @@ export function checkWorkspace(root: string): string[] { } } + if (controlPlane) { + for (const dependency of runtimeDependencies(controlPlane.manifest)) { + if (dependency !== protocolName) { + errors.push( + `${relative(root, controlPlane.directory)} may depend only on ${protocolName}, found ${dependency}`, + ); + } + } + } + if (runtime && runtimeDependencies(runtime.manifest).includes(tuiName)) { errors.push( `${relative(root, runtime.directory)} must not depend on presentation package ${tuiName}`, @@ -197,6 +213,16 @@ export function checkWorkspace(root: string): string[] { `${relative(root, path)} imports ${specifier}; UI source may import only shared client presentation packages`, ); } + if ( + directory === controlPlane?.directory && + !specifier.startsWith(".") && + !specifier.startsWith("node:") && + specifier !== protocolName + ) { + errors.push( + `${relative(root, path)} imports ${specifier}; control plane may import only Node.js and ${protocolName}`, + ); + } if ( directory === tui?.directory && !specifier.startsWith(".") && @@ -224,6 +250,28 @@ export function checkWorkspace(root: string): string[] { }); } + const relayMixPath = resolve(root, "services/relay/mix.exs"); + if (existsSync(relayMixPath)) { + const relayMix = readFileSync(relayMixPath, "utf8"); + const allowedRelayDependencies = new Set([ + "bandit", + "plug", + "websock_adapter", + "credo", + "dialyxir", + "mix_audit", + ]); + for (const match of relayMix.matchAll(/\{:([a-z][a-z0-9_]*),/g)) { + const dependency = match[1] as string; + if (!allowedRelayDependencies.has(dependency)) { + errors.push(`services/relay may not depend on unapproved package ${dependency}`); + } + } + if (/\bpath:\s*/.test(relayMix)) { + errors.push("services/relay must not use path dependencies into repository packages"); + } + } + walk(resolve(root, "apps"), (path) => { const extension = path.slice(path.lastIndexOf(".")); if (!sourceExtensions.has(extension)) return; diff --git a/scripts/check-generated.test.ts b/scripts/check-generated.test.ts index b3e2b01a..204da945 100644 --- a/scripts/check-generated.test.ts +++ b/scripts/check-generated.test.ts @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2026 Hari Srinivasan +// SPDX-FileCopyrightText: 2026 Lokesh // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -24,3 +25,14 @@ test("requires generated files to name an existing passing generator", () => { [], ); }); + +test("ignores dependency builds and directory symlinks", () => { + const root = mkdtempSync(join(tmpdir(), "axl-generated-builds-")); + mkdirSync(join(root, "deps")); + mkdirSync(join(root, "_build")); + writeFileSync(join(root, "deps", "ignored.generated.ts"), ""); + writeFileSync(join(root, "_build", "ignored.generated.ts"), ""); + symlinkSync(join(root, "deps"), join(root, "linked-deps")); + + assert.deepEqual(checkGenerated(root), []); +}); diff --git a/scripts/check-generated.ts b/scripts/check-generated.ts index 391adf9c..b0663b96 100644 --- a/scripts/check-generated.ts +++ b/scripts/check-generated.ts @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2026 Hari Srinivasan +// SPDX-FileCopyrightText: 2026 Lokesh // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; @@ -14,7 +15,8 @@ type GeneratorRunner = ( function walk(directory: string, visit: (path: string) => void): void { for (const entry of readdirSync(directory, { withFileTypes: true })) { - if ([".git", "dist", "node_modules"].includes(entry.name)) continue; + if ([".git", "_build", "deps", "dist", "node_modules"].includes(entry.name)) continue; + if (entry.isSymbolicLink()) continue; const path = resolve(directory, entry.name); if (entry.isDirectory()) walk(path, visit); else visit(path); diff --git a/services/relay/.formatter.exs b/services/relay/.formatter.exs new file mode 100644 index 00000000..5301699e --- /dev/null +++ b/services/relay/.formatter.exs @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/services/relay/.tool-versions b/services/relay/.tool-versions new file mode 100644 index 00000000..5767f96d --- /dev/null +++ b/services/relay/.tool-versions @@ -0,0 +1,2 @@ +erlang 27.3.4.17 +elixir 1.18.5-otp-27 diff --git a/services/relay/README.md b/services/relay/README.md new file mode 100644 index 00000000..f7a9aa54 --- /dev/null +++ b/services/relay/README.md @@ -0,0 +1,18 @@ + + + +# Axl relay + +This separately deployable Elixir/OTP service admits connections through the control plane and routes bounded opaque binary frames in memory. It has no E2EE, daemon RPC, canonical-event, account-database, or attachment-body dependency. + +The first slice provides: + +- one-use ticket admission through an injected control-plane client +- exact transport-v1 binary framing shared with TypeScript fixtures +- installation-scoped in-memory route registration +- bounded per-route pending bytes +- WebSocket compression disabled and a 65,535-byte frame ceiling +- heartbeat, idle, lease-expiry, revocation, and draining behavior +- fail-closed admission and internal-authentication interfaces + +Production control-plane origins, service authentication, TLS termination, and deployment configuration remain unselected. Tests use deterministic fake adapters. diff --git a/services/relay/lib/axl_relay/admission.ex b/services/relay/lib/axl_relay/admission.ex new file mode 100644 index 00000000..25857830 --- /dev/null +++ b/services/relay/lib/axl_relay/admission.ex @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Admission do + @moduledoc "Parses the bounded, pre-routing WebSocket admission message." + + @max_message_bytes 4_096 + @required_keys MapSet.new([ + "version", + "ticket", + "connectionNonce", + "possessionProof" + ]) + + @spec parse(binary()) :: {:ok, map()} | {:error, :bad_frame} + def parse(message) when is_binary(message) and byte_size(message) <= @max_message_bytes do + with {:ok, decoded} <- decode_json(message), + true <- is_map(decoded), + true <- MapSet.new(Map.keys(decoded)) == @required_keys, + 1 <- decoded["version"], + ticket when is_binary(ticket) and byte_size(ticket) in 1..1024 <- decoded["ticket"], + nonce when is_binary(nonce) and byte_size(nonce) in 1..256 <- decoded["connectionNonce"], + proof when is_binary(proof) <- decoded["possessionProof"], + {:ok, proof_bytes} <- Base.decode64(proof), + true <- byte_size(proof_bytes) <= 1_024, + ^proof <- Base.encode64(proof_bytes) do + {:ok, + %{ + "ticket" => ticket, + "connectionNonce" => nonce, + "possessionProof" => proof + }} + else + _other -> {:error, :bad_frame} + end + end + + def parse(_message), do: {:error, :bad_frame} + + defp decode_json(message) do + {:ok, :json.decode(message)} + catch + _kind, _reason -> {:error, :bad_frame} + end +end + +defmodule AxlRelay.ControlPlaneClient do + @moduledoc "Injected fail-closed boundary for atomic ticket consumption." + + @callback consume_ticket(map(), String.t(), keyword()) :: + {:ok, map()} | {:error, atom()} +end diff --git a/services/relay/lib/axl_relay/application.ex b/services/relay/lib/axl_relay/application.ex new file mode 100644 index 00000000..61bf1891 --- /dev/null +++ b/services/relay/lib/axl_relay/application.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = + case Application.get_env(:axl_relay, :listener_options) do + nil -> [AxlRelay.RouteRegistry] + options -> [AxlRelay.RouteRegistry, {AxlRelay.Listener, options}] + end + + Supervisor.start_link(children, strategy: :one_for_one, name: AxlRelay.Supervisor) + end +end diff --git a/services/relay/lib/axl_relay/connection.ex b/services/relay/lib/axl_relay/connection.ex new file mode 100644 index 00000000..6033e1f3 --- /dev/null +++ b/services/relay/lib/axl_relay/connection.ex @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Connection do + @moduledoc "Ticket-admitted WebSock handler for opaque relay frames." + + @behaviour WebSock + + alias AxlRelay.{Admission, Frame, RouteRegistry} + + @admission_timeout_ms 5_000 + @rate_window_ms 10_000 + @max_frames_per_window 100 + + @impl true + def init(options) do + Process.send_after(self(), :admission_timeout, @admission_timeout_ms) + + {:ok, + %{ + phase: :awaiting_admission, + control_plane: Keyword.fetch!(options, :control_plane), + control_plane_options: Keyword.get(options, :control_plane_options, []), + relay_instance_id: Keyword.fetch!(options, :relay_instance_id), + registry: Keyword.get(options, :registry, RouteRegistry), + route_id: nil, + limits: nil, + rate_window_started: System.monotonic_time(:millisecond), + rate_frames: 0, + rate_bytes: 0 + }} + end + + @impl true + def handle_in({message, opcode: :binary}, %{phase: :awaiting_admission} = state) do + with {:ok, admission} <- Admission.parse(message), + {:ok, result} <- + state.control_plane.consume_ticket( + admission, + state.relay_instance_id, + state.control_plane_options + ), + true <- result.lease_expires_at > System.system_time(:millisecond), + :ok <- RouteRegistry.register(state.registry, self(), result) do + Process.send_after(self(), :heartbeat, result.limits.heartbeat_interval_ms) + + Process.send_after( + self(), + :lease_expired, + result.lease_expires_at - System.system_time(:millisecond) + ) + + {:ok, %{state | phase: :active, route_id: result.source_route_id, limits: result.limits}} + else + {:error, code} -> close(code, state) + false -> close(:ticket_expired, state) + _other -> close(:service_unavailable, state) + end + end + + def handle_in({message, opcode: :binary}, %{phase: :active} = state) do + with true <- byte_size(message) <= state.limits.max_frame_bytes, + {:ok, %{kind: :send} = frame} <- Frame.decode(message), + {:ok, rate_state} <- rate_limit(state, byte_size(message)) do + admitted = receipt(frame.attempt_id, :admitted) + + case RouteRegistry.forward( + state.registry, + state.route_id, + frame.route_id, + frame.attempt_id, + frame.payload + ) do + :ok -> + {:push, [binary: admitted, binary: receipt(frame.attempt_id, :forwarded)], rate_state} + + {:error, code} -> + {:push, [binary: admitted, binary: failure(frame.attempt_id, code)], rate_state} + end + else + {:error, :rate_limited} -> close(:rate_limited, state) + _other -> close(:bad_frame, state) + end + end + + def handle_in(_frame, state), do: close(:bad_frame, state) + + @impl true + def handle_control({_payload, opcode: opcode}, state) when opcode in [:ping, :pong], + do: {:ok, state} + + @impl true + def handle_info( + {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes}, + %{phase: :active} = state + ) do + encoded = + encode!(%{ + kind: :delivery, + attempt_id: attempt_id, + route_id: source_route_id, + payload: payload + }) + + send(self(), {:delivery_handed_to_socket, queued_bytes}) + {:push, {:binary, encoded}, state} + end + + def handle_info({:delivery_handed_to_socket, queued_bytes}, %{phase: :active} = state) do + RouteRegistry.delivered(state.registry, state.route_id, queued_bytes) + {:ok, state} + end + + def handle_info(:heartbeat, %{phase: :active} = state) do + Process.send_after(self(), :heartbeat, state.limits.heartbeat_interval_ms) + {:push, {:ping, <<>>}, state} + end + + def handle_info(:lease_expired, state), do: close(:unauthorized, state) + def handle_info(:route_revoked, state), do: close(:unauthorized, state) + def handle_info(:relay_draining, state), do: close(:service_unavailable, state) + + def handle_info(:admission_timeout, %{phase: :awaiting_admission} = state), + do: close(:unauthorized, state) + + def handle_info(:admission_timeout, state), do: {:ok, state} + def handle_info(_message, state), do: {:ok, state} + + @impl true + def terminate(_reason, %{route_id: nil}), do: :ok + + def terminate(_reason, state) do + RouteRegistry.unregister(state.registry, state.route_id) + :ok + end + + defp rate_limit(state, bytes) do + now = System.monotonic_time(:millisecond) + + current = + if now - state.rate_window_started >= @rate_window_ms do + %{state | rate_window_started: now, rate_frames: 0, rate_bytes: 0} + else + state + end + + max_bytes = current.limits.max_frame_bytes * @max_frames_per_window + + if current.rate_frames + 1 > @max_frames_per_window or current.rate_bytes + bytes > max_bytes do + {:error, :rate_limited} + else + {:ok, + %{ + current + | rate_frames: current.rate_frames + 1, + rate_bytes: current.rate_bytes + bytes + }} + end + end + + defp receipt(attempt_id, status) do + encode!(%{kind: :receipt, attempt_id: attempt_id, status: status}) + end + + defp failure(attempt_id, code) do + encode!(%{kind: :failure, attempt_id: attempt_id, code: code}) + end + + defp encode!(frame) do + {:ok, encoded} = Frame.encode(frame) + encoded + end + + defp close(code, state), + do: {:stop, :normal, {1008, Atom.to_string(code)}, state} +end diff --git a/services/relay/lib/axl_relay/frame.ex b/services/relay/lib/axl_relay/frame.ex new file mode 100644 index 00000000..54e1b661 --- /dev/null +++ b/services/relay/lib/axl_relay/frame.ex @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Frame do + @moduledoc "Bounded transport-v1 framing for opaque relay payloads." + + @magic "AXLR" + @transport_version 1 + @max_frame_bytes 65_535 + @routed_header_bytes 42 + @max_payload_bytes @max_frame_bytes - @routed_header_bytes + @failure_codes [ + :bad_frame, + :unsupported_transport_version, + :unauthorized, + :forbidden_route, + :ticket_expired, + :ticket_consumed, + :destination_offline, + :rate_limited, + :queue_full, + :slow_consumer, + :service_unavailable + ] + + @type relay_frame :: + %{ + kind: :send | :delivery, + attempt_id: String.t(), + route_id: String.t(), + payload: binary() + } + | %{kind: :receipt, attempt_id: String.t(), status: :admitted | :forwarded} + | %{kind: :failure, attempt_id: String.t(), code: atom()} + + @spec max_frame_bytes() :: pos_integer() + def max_frame_bytes, do: @max_frame_bytes + + @spec max_payload_bytes() :: pos_integer() + def max_payload_bytes, do: @max_payload_bytes + + @spec decode(binary()) :: {:ok, relay_frame()} | {:error, atom()} + def decode(frame) when is_binary(frame) and byte_size(frame) <= @max_frame_bytes do + decode_bounded(frame) + end + + def decode(_frame), do: {:error, :bad_frame} + + defp decode_bounded(<<@magic, version, _rest::binary>>) when version != @transport_version, + do: {:error, :unsupported_transport_version} + + defp decode_bounded( + <<@magic, @transport_version, kind, attempt::binary-size(16), route::binary-size(16), + payload_size::unsigned-big-32, payload::binary>> + ) + when kind in [1, 2] and payload_size == byte_size(payload) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, route_id} <- decode_uuid(route) do + {:ok, + %{ + kind: if(kind == 1, do: :send, else: :delivery), + attempt_id: attempt_id, + route_id: route_id, + payload: payload + }} + end + end + + defp decode_bounded(<<@magic, @transport_version, 3, attempt::binary-size(16), status>>) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, decoded_status} <- decode_status(status) do + {:ok, %{kind: :receipt, attempt_id: attempt_id, status: decoded_status}} + end + end + + defp decode_bounded(<<@magic, @transport_version, 4, attempt::binary-size(16), code>>) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, decoded_code} <- decode_failure(code) do + {:ok, %{kind: :failure, attempt_id: attempt_id, code: decoded_code}} + end + end + + defp decode_bounded(<<@magic, @transport_version, _rest::binary>>), do: {:error, :bad_frame} + defp decode_bounded(_frame), do: {:error, :bad_frame} + + @spec encode(relay_frame()) :: {:ok, binary()} | {:error, :bad_frame} + def encode(%{kind: kind, attempt_id: attempt_id, route_id: route_id, payload: payload}) + when kind in [:send, :delivery] and is_binary(payload) and + byte_size(payload) <= @max_payload_bytes do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, route} <- encode_uuid(route_id) do + kind_byte = if kind == :send, do: 1, else: 2 + + {:ok, + <<@magic, @transport_version, kind_byte, attempt::binary, route::binary, + byte_size(payload)::unsigned-big-32, payload::binary>>} + end + end + + def encode(%{kind: :receipt, attempt_id: attempt_id, status: status}) do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, status_byte} <- encode_status(status) do + {:ok, <<@magic, @transport_version, 3, attempt::binary, status_byte>>} + end + end + + def encode(%{kind: :failure, attempt_id: attempt_id, code: code}) do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, code_byte} <- encode_failure(code) do + {:ok, <<@magic, @transport_version, 4, attempt::binary, code_byte>>} + end + end + + def encode(_frame), do: {:error, :bad_frame} + + defp decode_status(1), do: {:ok, :admitted} + defp decode_status(2), do: {:ok, :forwarded} + defp decode_status(_status), do: {:error, :bad_frame} + + defp encode_status(:admitted), do: {:ok, 1} + defp encode_status(:forwarded), do: {:ok, 2} + defp encode_status(_status), do: {:error, :bad_frame} + + defp decode_failure(value) when value in 1..length(@failure_codes)//1 do + {:ok, Enum.fetch!(@failure_codes, value - 1)} + end + + defp decode_failure(_value), do: {:error, :bad_frame} + + defp encode_failure(code) do + case Enum.find_index(@failure_codes, &(&1 == code)) do + nil -> {:error, :bad_frame} + index -> {:ok, index + 1} + end + end + + defp encode_uuid(value) when is_binary(value) do + case Base.decode16(String.replace(value, "-", ""), case: :lower) do + {:ok, bytes} when byte_size(bytes) == 16 -> decode_uuid(bytes, bytes) + _other -> {:error, :bad_frame} + end + end + + defp encode_uuid(_value), do: {:error, :bad_frame} + + defp decode_uuid(bytes), do: decode_uuid(bytes, format_uuid(bytes)) + + defp decode_uuid(<<_::48, version::4, _::12, 2::2, _::62>>, result) + when version >= 1 and version <= 8, + do: {:ok, result} + + defp decode_uuid(_bytes, _result), do: {:error, :bad_frame} + + defp format_uuid(bytes) do + hex = Base.encode16(bytes, case: :lower) + + Enum.join( + [ + binary_part(hex, 0, 8), + binary_part(hex, 8, 4), + binary_part(hex, 12, 4), + binary_part(hex, 16, 4), + binary_part(hex, 20, 12) + ], + "-" + ) + end +end diff --git a/services/relay/lib/axl_relay/http_control_plane_client.ex b/services/relay/lib/axl_relay/http_control_plane_client.ex new file mode 100644 index 00000000..08cf5446 --- /dev/null +++ b/services/relay/lib/axl_relay/http_control_plane_client.ex @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.HttpControlPlaneClient do + @moduledoc "HTTP implementation of the authenticated control-plane admission boundary." + + @behaviour AxlRelay.ControlPlaneClient + + @impl true + def consume_ticket(admission, relay_instance_id, options) do + with {:ok, origin} <- Keyword.fetch(options, :origin), + true <- valid_origin?(origin), + {:ok, headers} when headers != [] <- Keyword.fetch(options, :headers), + body <- + :json.encode(%{ + "version" => 1, + "ticket" => admission["ticket"], + "relayInstanceId" => relay_instance_id, + "connectionNonce" => admission["connectionNonce"], + "possessionProof" => admission["possessionProof"] + }) + |> IO.iodata_to_binary(), + {:ok, response} <- post(origin, headers, body), + {:ok, result} <- validate_result(response) do + {:ok, result} + else + {:error, code} when is_atom(code) -> {:error, code} + _other -> {:error, :service_unavailable} + end + end + + defp valid_origin?(origin) when is_binary(origin) do + case URI.parse(origin) do + %URI{scheme: "https", host: host, path: path, query: nil, fragment: nil, userinfo: nil} + when is_binary(host) and path in [nil, ""] -> + true + + _other -> + false + end + end + + defp valid_origin?(_origin), do: false + + defp post(origin, headers, body) do + url = String.to_charlist(origin <> "/internal/v1/relay/tickets/consume") + request_headers = [{~c"content-type", ~c"application/json"} | headers] + request = {url, request_headers, ~c"application/json", body} + + case :httpc.request(:post, request, [timeout: 5_000, connect_timeout: 3_000], + body_format: :binary + ) do + {:ok, {{_http, 200, _reason}, _headers, response}} -> + decode_json(response) + + {:ok, {{_http, status, _reason}, _headers, response}} when status in [401, 409] -> + decode_error(response) + + _other -> + {:error, :service_unavailable} + end + end + + defp decode_json(body) do + {:ok, :json.decode(body)} + catch + _kind, _reason -> {:error, :service_unavailable} + end + + defp decode_error(body) do + with {:ok, %{"error" => %{"code" => code}}} <- decode_json(body), + mapped when not is_nil(mapped) <- + Map.get( + %{ + "unauthorized" => :unauthorized, + "ticket_expired" => :ticket_expired, + "ticket_consumed" => :ticket_consumed + }, + code + ) do + {:error, mapped} + else + _other -> {:error, :service_unavailable} + end + end + + @doc false + def validate_result(result) when is_map(result) do + required = + MapSet.new([ + "version", + "installationId", + "sourceRouteId", + "role", + "leaseExpiresAt", + "limits" + ]) + + allowed = MapSet.put(required, "deviceId") + keys = MapSet.new(Map.keys(result)) + + with true <- MapSet.subset?(required, keys) and MapSet.subset?(keys, allowed), + 1 <- result["version"], + true <- uuid?(result["installationId"]), + true <- uuid?(result["sourceRouteId"]), + role when role in ["daemon", "device"] <- result["role"], + true <- valid_device?(role, result["deviceId"]), + lease when is_integer(lease) and lease >= 0 <- result["leaseExpiresAt"], + {:ok, limits} <- validate_limits(result["limits"]) do + {:ok, + %{ + installation_id: result["installationId"], + device_id: result["deviceId"], + source_route_id: result["sourceRouteId"], + role: String.to_existing_atom(role), + lease_expires_at: lease, + limits: limits + }} + else + _other -> {:error, :service_unavailable} + end + end + + def validate_result(_result), do: {:error, :service_unavailable} + + defp validate_limits(limits) when is_map(limits) do + keys = + MapSet.new([ + "maxFrameBytes", + "maxQueuedBytes", + "heartbeatIntervalMs", + "idleTimeoutMs" + ]) + + with ^keys <- MapSet.new(Map.keys(limits)), + frame when is_integer(frame) and frame in 1..65_535 <- limits["maxFrameBytes"], + queued when is_integer(queued) and queued in 1..524_288 <- limits["maxQueuedBytes"], + heartbeat when is_integer(heartbeat) and heartbeat in 1..300_000 <- + limits["heartbeatIntervalMs"], + idle when is_integer(idle) and idle in 1..600_000 <- limits["idleTimeoutMs"] do + {:ok, + %{ + max_frame_bytes: frame, + max_queued_bytes: queued, + heartbeat_interval_ms: heartbeat, + idle_timeout_ms: idle + }} + else + _other -> {:error, :service_unavailable} + end + end + + defp validate_limits(_limits), do: {:error, :service_unavailable} + + defp valid_device?("daemon", nil), do: true + defp valid_device?("device", value), do: uuid?(value) + defp valid_device?(_role, _value), do: false + + defp uuid?(value) when is_binary(value) do + Regex.match?( + ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + value + ) + end + + defp uuid?(_value), do: false +end diff --git a/services/relay/lib/axl_relay/listener.ex b/services/relay/lib/axl_relay/listener.ex new file mode 100644 index 00000000..1ca0c91b --- /dev/null +++ b/services/relay/lib/axl_relay/listener.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Listener do + @moduledoc "Configured Bandit listener for the relay's public and internal boundaries." + + def child_spec(options) do + Bandit.child_spec(bandit_options(options)) + end + + def start_link(options) do + Bandit.start_link(bandit_options(options)) + end + + defp bandit_options(options) do + [ + plug: + {AxlRelay.Router, + [ + connection_options: Keyword.fetch!(options, :connection_options), + internal_authenticator: Keyword.fetch!(options, :internal_authenticator), + internal_authenticator_options: + Keyword.get(options, :internal_authenticator_options, []), + registry: Keyword.get(options, :registry, AxlRelay.RouteRegistry) + ]}, + scheme: Keyword.get(options, :scheme, :https), + ip: Keyword.get(options, :ip, {127, 0, 0, 1}), + port: Keyword.fetch!(options, :port), + startup_log: false + ] + end +end diff --git a/services/relay/lib/axl_relay/revocation_handler.ex b/services/relay/lib/axl_relay/revocation_handler.ex new file mode 100644 index 00000000..a39b095e --- /dev/null +++ b/services/relay/lib/axl_relay/revocation_handler.ex @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.InternalAuthenticator do + @moduledoc "Injected authentication boundary for control-plane callbacks." + + @callback authenticate(Plug.Conn.t(), binary(), keyword()) :: boolean() +end + +defmodule AxlRelay.RevocationHandler do + @moduledoc "Runtime validation for best-effort route revocation." + + @doc false + def parse_notification(body) do + with decoded when is_map(decoded) <- :json.decode(body), + required <- MapSet.new(["version", "installationId", "generation", "effectiveAt"]), + allowed <- MapSet.put(required, "deviceId"), + keys <- MapSet.new(Map.keys(decoded)), + true <- MapSet.subset?(required, keys) and MapSet.subset?(keys, allowed), + 1 <- decoded["version"], + true <- uuid?(decoded["installationId"]), + true <- is_nil(decoded["deviceId"]) or uuid?(decoded["deviceId"]), + generation when is_integer(generation) and generation > 0 <- decoded["generation"], + effective_at when is_integer(effective_at) and effective_at >= 0 <- + decoded["effectiveAt"] do + {:ok, + %{ + installation_id: decoded["installationId"], + device_id: decoded["deviceId"], + generation: generation, + effective_at: effective_at + }} + else + _other -> {:error, :bad_request} + end + catch + _kind, _reason -> {:error, :bad_request} + end + + defp uuid?(value) when is_binary(value) do + Regex.match?( + ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + value + ) + end + + defp uuid?(_value), do: false +end diff --git a/services/relay/lib/axl_relay/route_registry.ex b/services/relay/lib/axl_relay/route_registry.ex new file mode 100644 index 00000000..6c3db1c6 --- /dev/null +++ b/services/relay/lib/axl_relay/route_registry.ex @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.RouteRegistry do + @moduledoc "In-memory, installation-scoped route table with bounded pending bytes." + + use GenServer + + @type admission :: %{ + installation_id: String.t(), + device_id: String.t() | nil, + source_route_id: String.t(), + limits: %{max_queued_bytes: pos_integer()} + } + + def start_link(options \\ []) do + case Keyword.get(options, :name, __MODULE__) do + nil -> GenServer.start_link(__MODULE__, options) + name -> GenServer.start_link(__MODULE__, options, name: name) + end + end + + def register(server \\ __MODULE__, pid, admission) do + GenServer.call(server, {:register, pid, admission}) + end + + def unregister(server \\ __MODULE__, route_id) do + GenServer.call(server, {:unregister, route_id}) + end + + def forward(server \\ __MODULE__, source_route_id, destination_route_id, attempt_id, payload) do + GenServer.call( + server, + {:forward, source_route_id, destination_route_id, attempt_id, payload} + ) + end + + def delivered(server \\ __MODULE__, route_id, bytes) do + GenServer.cast(server, {:delivered, route_id, bytes}) + end + + def revoke(server \\ __MODULE__, notification) do + GenServer.call(server, {:revoke, notification}) + end + + def drain(server \\ __MODULE__) do + GenServer.call(server, :drain) + end + + def snapshot(server \\ __MODULE__) do + GenServer.call(server, :snapshot) + end + + @impl true + def init(_options) do + {:ok, %{routes: %{}, monitors: %{}, generations: %{}, draining: false}} + end + + @impl true + def handle_call({:register, _pid, _admission}, _from, %{draining: true} = state) do + {:reply, {:error, :service_unavailable}, state} + end + + def handle_call({:register, pid, admission}, _from, state) do + route_id = admission.source_route_id + + if Map.has_key?(state.routes, route_id) do + {:reply, {:error, :forbidden_route}, state} + else + monitor = Process.monitor(pid) + route = Map.merge(admission, %{pid: pid, monitor: monitor, queued_bytes: 0}) + + {:reply, :ok, + %{ + state + | routes: Map.put(state.routes, route_id, route), + monitors: Map.put(state.monitors, monitor, route_id) + }} + end + end + + def handle_call({:unregister, route_id}, _from, state) do + {:reply, :ok, remove_route(state, route_id)} + end + + def handle_call( + {:forward, source_route_id, destination_route_id, attempt_id, payload}, + _from, + state + ) do + source = state.routes[source_route_id] + destination = state.routes[destination_route_id] + queued_bytes = byte_size(payload) + 42 + + cond do + source == nil -> + {:reply, {:error, :unauthorized}, state} + + destination == nil -> + {:reply, {:error, :destination_offline}, state} + + source.installation_id != destination.installation_id -> + {:reply, {:error, :forbidden_route}, state} + + destination.queued_bytes + queued_bytes > destination.limits.max_queued_bytes -> + {:reply, {:error, :queue_full}, state} + + true -> + send( + destination.pid, + {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes} + ) + + next_state = + put_in( + state, + [:routes, destination_route_id, :queued_bytes], + destination.queued_bytes + queued_bytes + ) + + {:reply, :ok, next_state} + end + end + + def handle_call({:revoke, notification}, _from, state) do + key = {notification.installation_id, notification.device_id || :all} + previous = Map.get(state.generations, key, 0) + + if notification.generation <= previous do + {:reply, :ok, state} + else + matching = + state.routes + |> Enum.filter(fn {_route_id, route} -> + route.installation_id == notification.installation_id and + (notification.device_id == nil or route.device_id == notification.device_id) + end) + + Enum.each(matching, fn {_route_id, route} -> send(route.pid, :route_revoked) end) + + next_state = + Enum.reduce(matching, state, fn {route_id, _route}, current -> + remove_route(current, route_id) + end) + + {:reply, :ok, + %{next_state | generations: Map.put(next_state.generations, key, notification.generation)}} + end + end + + def handle_call(:drain, _from, state) do + Enum.each(state.routes, fn {_route_id, route} -> send(route.pid, :relay_draining) end) + {:reply, :ok, %{state | draining: true}} + end + + def handle_call(:snapshot, _from, state) do + routes = + Map.new(state.routes, fn {route_id, route} -> + {route_id, + %{ + installation_id: route.installation_id, + device_id: route.device_id, + queued_bytes: route.queued_bytes + }} + end) + + {:reply, %{routes: routes, draining: state.draining}, state} + end + + @impl true + def handle_cast({:delivered, route_id, bytes}, state) do + case state.routes[route_id] do + nil -> + {:noreply, state} + + route -> + next_state = + put_in(state, [:routes, route_id, :queued_bytes], max(0, route.queued_bytes - bytes)) + + {:noreply, next_state} + end + end + + @impl true + def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do + case Map.pop(state.monitors, monitor) do + {nil, _monitors} -> + {:noreply, state} + + {route_id, monitors} -> + {:noreply, %{state | routes: Map.delete(state.routes, route_id), monitors: monitors}} + end + end + + defp remove_route(state, route_id) do + case Map.pop(state.routes, route_id) do + {nil, _routes} -> + state + + {route, routes} -> + Process.demonitor(route.monitor, [:flush]) + %{state | routes: routes, monitors: Map.delete(state.monitors, route.monitor)} + end + end +end diff --git a/services/relay/lib/axl_relay/router.ex b/services/relay/lib/axl_relay/router.ex new file mode 100644 index 00000000..c2da801f --- /dev/null +++ b/services/relay/lib/axl_relay/router.ex @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Router do + @moduledoc "Plug boundary for WebSocket admission and authenticated revocation." + + import Plug.Conn + + @behaviour Plug + @max_body_bytes 4_096 + + @impl true + def init(options), do: options + + @impl true + def call(%{method: "GET", path_info: ["v1", "connect"]} = connection, options) do + connection + |> WebSockAdapter.upgrade( + AxlRelay.Connection, + Keyword.fetch!(options, :connection_options), + compress: false, + timeout: 60_000, + max_frame_size: AxlRelay.Frame.max_frame_bytes() + ) + |> halt() + end + + def call( + %{method: "POST", path_info: ["internal", "v1", "revocations"]} = connection, + options + ) do + with {:ok, body, connection} <- + read_body(connection, length: @max_body_bytes, read_length: @max_body_bytes), + authenticator <- Keyword.fetch!(options, :internal_authenticator), + true <- + authenticator.authenticate( + connection, + body, + Keyword.get(options, :internal_authenticator_options, []) + ), + {:ok, notification} <- AxlRelay.RevocationHandler.parse_notification(body), + :ok <- + AxlRelay.RouteRegistry.revoke( + Keyword.get(options, :registry, AxlRelay.RouteRegistry), + notification + ) do + json(connection, 200, %{"version" => 1, "accepted" => true}) + else + false -> + json(connection, 401, %{"error" => %{"code" => "unauthorized"}}) + + {:more, _body, connection} -> + json(connection, 413, %{"error" => %{"code" => "bad_request"}}) + + _other -> + json(connection, 400, %{"error" => %{"code" => "bad_request"}}) + end + end + + def call(connection, _options) do + status = if connection.method in ["GET", "POST"], do: 404, else: 405 + json(connection, status, %{"error" => %{"code" => "not_found"}}) + end + + defp json(connection, status, body) do + encoded = body |> :json.encode() |> IO.iodata_to_binary() + + connection + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("content-type", "application/json; charset=utf-8") + |> put_resp_header("x-content-type-options", "nosniff") + |> send_resp(status, encoded) + |> halt() + end +end diff --git a/services/relay/mix.exs b/services/relay/mix.exs new file mode 100644 index 00000000..84f1b0e6 --- /dev/null +++ b/services/relay/mix.exs @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.MixProject do + use Mix.Project + + def project do + [ + app: :axl_relay, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps(), + dialyzer: [plt_add_apps: [:bandit, :inets, :ssl]] + ] + end + + def application do + [ + extra_applications: [:logger, :inets, :ssl], + mod: {AxlRelay.Application, []} + ] + end + + defp deps do + [ + {:bandit, "1.12.5"}, + {:plug, "1.20.3"}, + {:websock_adapter, "0.6.0"}, + {:credo, "1.7.12", only: [:dev, :test], runtime: false}, + {:dialyxir, "1.4.6", only: [:dev, :test], runtime: false}, + {:mix_audit, "2.1.5", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/services/relay/mix.lock b/services/relay/mix.lock new file mode 100644 index 00000000..d364c596 --- /dev/null +++ b/services/relay/mix.lock @@ -0,0 +1,20 @@ +%{ + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, +} diff --git a/services/relay/test/frame_test.exs b/services/relay/test/frame_test.exs new file mode 100644 index 00000000..3669c371 --- /dev/null +++ b/services/relay/test/frame_test.exs @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.FrameTest do + use ExUnit.Case, async: true + + alias AxlRelay.Frame + + @fixture_path Path.expand( + "../../../packages/protocol/test/fixtures/remote-transport-v1.json", + __DIR__ + ) + @fixtures @fixture_path |> File.read!() |> :json.decode() + + test "accepts and reproduces the TypeScript canonical frames" do + for fixture <- @fixtures["accepted"] do + bytes = Base.decode64!(fixture["base64"]) + assert {:ok, frame} = Frame.decode(bytes), fixture["name"] + assert fixture_shape(frame) == fixture["frame"], fixture["name"] + assert {:ok, ^bytes} = Frame.encode(frame), fixture["name"] + end + end + + test "rejects every malformed canonical frame" do + for fixture <- @fixtures["rejected"] do + bytes = Base.decode64!(fixture["base64"]) + assert {:error, _reason} = Frame.decode(bytes), fixture["name"] + end + end + + test "rejects an oversized frame before parsing" do + assert {:error, :bad_frame} = Frame.decode(:binary.copy(<<0>>, Frame.max_frame_bytes() + 1)) + end + + defp fixture_shape(%{kind: kind, attempt_id: attempt_id, route_id: route_id, payload: payload}) do + %{ + "kind" => Atom.to_string(kind), + "attemptId" => attempt_id, + "routeId" => route_id, + "opaquePayloadBase64" => Base.encode64(payload) + } + end + + defp fixture_shape(%{kind: :receipt, attempt_id: attempt_id, status: status}) do + %{ + "kind" => "receipt", + "attemptId" => attempt_id, + "status" => Atom.to_string(status) + } + end + + defp fixture_shape(%{kind: :failure, attempt_id: attempt_id, code: code}) do + %{ + "kind" => "failure", + "attemptId" => attempt_id, + "code" => Atom.to_string(code) + } + end +end diff --git a/services/relay/test/internal_contract_test.exs b/services/relay/test/internal_contract_test.exs new file mode 100644 index 00000000..70104d9e --- /dev/null +++ b/services/relay/test/internal_contract_test.exs @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.InternalContractTest do + use ExUnit.Case, async: true + + alias AxlRelay.{Admission, HttpControlPlaneClient, RevocationHandler} + + @fixture_path Path.expand( + "../../../packages/protocol/test/fixtures/internal-relay-api-v1.json", + __DIR__ + ) + @fixtures @fixture_path |> File.read!() |> :json.decode() + + test "accepts the TypeScript ticket-consumption fixture" do + result = @fixtures["consumeTicket"]["result"] + + assert {:ok, parsed} = HttpControlPlaneClient.validate_result(result) + assert parsed.installation_id == result["installationId"] + assert parsed.device_id == result["deviceId"] + assert parsed.source_route_id == result["sourceRouteId"] + assert parsed.role == :device + assert parsed.limits.max_frame_bytes == 65_535 + assert parsed.limits.max_queued_bytes == 524_288 + end + + test "forms the admitted WebSocket message without relay-owned fields" do + consume = @fixtures["consumeTicket"]["request"] + + admission = + Map.take(consume, ["version", "ticket", "connectionNonce", "possessionProof"]) + |> :json.encode() + |> IO.iodata_to_binary() + + assert {:ok, parsed} = Admission.parse(admission) + assert parsed["ticket"] == consume["ticket"] + refute Map.has_key?(parsed, "relayInstanceId") + end + + test "accepts the TypeScript revocation fixture and rejects unknown fields" do + request = @fixtures["revocation"]["request"] + bytes = request |> :json.encode() |> IO.iodata_to_binary() + + assert {:ok, parsed} = RevocationHandler.parse_notification(bytes) + assert parsed.installation_id == request["installationId"] + assert parsed.device_id == request["deviceId"] + assert parsed.generation == request["generation"] + + malformed = request |> Map.put("unexpected", true) |> :json.encode() |> IO.iodata_to_binary() + assert {:error, :bad_request} = RevocationHandler.parse_notification(malformed) + end +end diff --git a/services/relay/test/route_registry_test.exs b/services/relay/test/route_registry_test.exs new file mode 100644 index 00000000..2812c611 --- /dev/null +++ b/services/relay/test/route_registry_test.exs @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.RouteRegistryTest do + use ExUnit.Case, async: true + + alias AxlRelay.RouteRegistry + + @installation "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + @source "11111111-1111-4111-8111-111111111111" + @destination "22222222-2222-4222-8222-222222222222" + @attempt "33333333-3333-4333-8333-333333333333" + + setup do + registry = start_supervised!({RouteRegistry, name: nil}) + parent = self() + + source = spawn_link(fn -> forward_messages(parent, :source) end) + destination = spawn_link(fn -> forward_messages(parent, :destination) end) + + limits = %{max_queued_bytes: 50} + + assert :ok = + RouteRegistry.register(registry, source, %{ + installation_id: @installation, + device_id: nil, + source_route_id: @source, + limits: limits + }) + + assert :ok = + RouteRegistry.register(registry, destination, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + source_route_id: @destination, + limits: limits + }) + + %{registry: registry, destination: destination, source: source} + end + + test "routes only inside one installation and bounds pending bytes", %{registry: registry} do + assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + assert_receive {:destination, {:relay_delivery, @source, @attempt, <<1, 2, 3>>, 45}} + + assert {:error, :queue_full} = + RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + RouteRegistry.delivered(registry, @destination, 45) + + assert_eventually(fn -> + RouteRegistry.snapshot(registry).routes[@destination].queued_bytes == 0 + end) + + other_route = "44444444-4444-4444-8444-444444444444" + parent = self() + other = spawn_link(fn -> forward_messages(parent, :other) end) + + assert :ok = + RouteRegistry.register(registry, other, %{ + installation_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + device_id: nil, + source_route_id: other_route, + limits: %{max_queued_bytes: 50} + }) + + assert {:error, :forbidden_route} = + RouteRegistry.forward(registry, @source, other_route, @attempt, <<1>>) + end + + test "revocation closes matching routes and draining rejects admission", %{registry: registry} do + assert :ok = + RouteRegistry.revoke(registry, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + generation: 1 + }) + + assert_receive {:destination, :route_revoked} + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + + assert :ok = RouteRegistry.drain(registry) + assert_receive {:source, :relay_draining} + + assert {:error, :service_unavailable} = + RouteRegistry.register(registry, self(), %{ + installation_id: @installation, + device_id: nil, + source_route_id: "55555555-5555-4555-8555-555555555555", + limits: %{max_queued_bytes: 50} + }) + end + + defp forward_messages(parent, label) do + receive do + message -> + send(parent, {label, message}) + forward_messages(parent, label) + end + end + + defp assert_eventually(assertion, attempts \\ 20) + + defp assert_eventually(assertion, attempts) when attempts > 0 do + if assertion.() do + :ok + else + Process.sleep(5) + assert_eventually(assertion, attempts - 1) + end + end + + defp assert_eventually(_assertion, 0), do: flunk("condition did not become true") +end diff --git a/services/relay/test/test_helper.exs b/services/relay/test/test_helper.exs new file mode 100644 index 00000000..25e87925 --- /dev/null +++ b/services/relay/test/test_helper.exs @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +ExUnit.start() diff --git a/services/relay/test/websocket_relay_test.exs b/services/relay/test/websocket_relay_test.exs new file mode 100644 index 00000000..8f0c5bf7 --- /dev/null +++ b/services/relay/test/websocket_relay_test.exs @@ -0,0 +1,249 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.WebSocketRelayTest do + use ExUnit.Case, async: false + + alias AxlRelay.{Frame, Listener, RouteRegistry} + + @daemon_route "11111111-1111-4111-8111-111111111111" + @device_route "22222222-2222-4222-8222-222222222222" + @attempt "33333333-3333-4333-8333-333333333333" + + defmodule FakeControlPlane do + @behaviour AxlRelay.ControlPlaneClient + + @impl true + def consume_ticket(%{"ticket" => "unavailable"}, _relay_instance_id, _options), + do: {:error, :service_unavailable} + + def consume_ticket(%{"ticket" => ticket}, _relay_instance_id, options) + when ticket in ["daemon", "device"] do + role = if ticket == "daemon", do: :daemon, else: :device + route_id = Keyword.fetch!(options, role) + + {:ok, + %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: + if(ticket == "device", + do: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + else: nil + ), + source_route_id: route_id, + role: role, + lease_expires_at: System.system_time(:millisecond) + 60_000, + limits: %{ + max_frame_bytes: 65_535, + max_queued_bytes: 524_288, + heartbeat_interval_ms: 20_000, + idle_timeout_ms: 60_000 + } + }} + end + end + + defmodule FakeInternalAuthenticator do + @behaviour AxlRelay.InternalAuthenticator + + @impl true + def authenticate(connection, _body, _options) do + Plug.Conn.get_req_header(connection, "authorization") == ["Bearer internal-fixture"] + end + end + + setup do + registry = start_supervised!({RouteRegistry, name: nil}) + port = free_port() + + listener = + start_supervised!( + {Listener, + scheme: :http, + port: port, + ip: {127, 0, 0, 1}, + connection_options: [ + control_plane: FakeControlPlane, + control_plane_options: [daemon: @daemon_route, device: @device_route], + relay_instance_id: "relay-test", + registry: registry + ], + internal_authenticator: FakeInternalAuthenticator, + registry: registry} + ) + + %{listener: listener, registry: registry, port: port} + end + + test "admits two sockets and routes an opaque frame with distinct receipts", %{ + registry: registry, + port: port + } do + daemon = connect(port, "daemon") + device = connect(port, "device") + + assert_eventually(fn -> map_size(RouteRegistry.snapshot(registry).routes) == 2 end) + + assert {:ok, send_frame} = + Frame.encode(%{ + kind: :send, + attempt_id: @attempt, + route_id: @daemon_route, + payload: <<0, 1, 2, 255>> + }) + + :ok = :gen_tcp.send(device, client_binary_frame(send_frame)) + + assert {:ok, admitted} = device |> receive_binary_frame() |> Frame.decode() + assert admitted == %{kind: :receipt, attempt_id: @attempt, status: :admitted} + + assert {:ok, forwarded} = device |> receive_binary_frame() |> Frame.decode() + assert forwarded == %{kind: :receipt, attempt_id: @attempt, status: :forwarded} + + assert {:ok, delivery} = daemon |> receive_binary_frame() |> Frame.decode() + + assert delivery == %{ + kind: :delivery, + attempt_id: @attempt, + route_id: @device_route, + payload: <<0, 1, 2, 255>> + } + + :gen_tcp.close(device) + :gen_tcp.close(daemon) + end + + test "fails admission closed when the control plane is unavailable", %{ + registry: registry, + port: port + } do + socket = connect(port, "unavailable") + {:ok, <<0x88, _length>>} = :gen_tcp.recv(socket, 2, 2_000) + assert RouteRegistry.snapshot(registry).routes == %{} + :gen_tcp.close(socket) + end + + test "rejects unauthenticated revocation and applies an authenticated notification", %{ + registry: registry, + port: port + } do + route = "44444444-4444-4444-8444-444444444444" + + assert :ok = + RouteRegistry.register(registry, self(), %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + source_route_id: route, + limits: %{max_queued_bytes: 524_288} + }) + + body = + :json.encode(%{ + "version" => 1, + "installationId" => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId" => "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "generation" => 1, + "effectiveAt" => 1_900_000_000_000 + }) + |> IO.iodata_to_binary() + + url = ~c"http://127.0.0.1:#{port}/internal/v1/revocations" + + assert {:ok, {{_version, 401, _reason}, _headers, _response}} = + :httpc.request(:post, {url, [], ~c"application/json", body}, [], []) + + headers = [{~c"authorization", ~c"Bearer internal-fixture"}] + + assert {:ok, {{_version, 200, _reason}, _headers, response}} = + :httpc.request(:post, {url, headers, ~c"application/json", body}, [], + body_format: :binary + ) + + assert :json.decode(response) == %{"version" => 1, "accepted" => true} + assert_receive :route_revoked + end + + defp free_port do + {:ok, socket} = :gen_tcp.listen(0, [:binary, ip: {127, 0, 0, 1}]) + {:ok, {_address, port}} = :inet.sockname(socket) + :gen_tcp.close(socket) + port + end + + defp connect(port, ticket) do + {:ok, socket} = :gen_tcp.connect({127, 0, 0, 1}, port, [:binary, active: false]) + + request = [ + "GET /v1/connect HTTP/1.1\r\n", + "Host: 127.0.0.1:", + Integer.to_string(port), + "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n", + "Sec-WebSocket-Key: AAECAwQFBgcICQoLDA0ODw==\r\n", + "Sec-WebSocket-Version: 13\r\n\r\n" + ] + + :ok = :gen_tcp.send(socket, request) + {:ok, response} = :gen_tcp.recv(socket, 0, 2_000) + assert String.starts_with?(response, "HTTP/1.1 101") + + admission = + :json.encode(%{ + "version" => 1, + "ticket" => ticket, + "connectionNonce" => "fixture-nonce", + "possessionProof" => "AAECA/8=" + }) + |> IO.iodata_to_binary() + + :ok = :gen_tcp.send(socket, client_binary_frame(admission)) + socket + end + + defp client_binary_frame(payload) do + mask = <<1, 2, 3, 4>> + + encoded_length = + if byte_size(payload) < 126, + do: <<0x80 + byte_size(payload)>>, + else: <<0x80 + 126, byte_size(payload)::unsigned-big-16>> + + masked = + payload + |> :binary.bin_to_list() + |> Enum.with_index() + |> Enum.map(fn {byte, index} -> Bitwise.bxor(byte, :binary.at(mask, rem(index, 4))) end) + |> :binary.list_to_bin() + + <<0x82, encoded_length::binary, mask::binary, masked::binary>> + end + + defp receive_binary_frame(socket) do + {:ok, <<0x82, length>>} = :gen_tcp.recv(socket, 2, 2_000) + + size = + case length do + value when value < 126 -> + value + + 126 -> + {:ok, <>} = :gen_tcp.recv(socket, 2, 2_000) + value + end + + {:ok, payload} = :gen_tcp.recv(socket, size, 2_000) + payload + end + + defp assert_eventually(assertion, attempts \\ 40) + + defp assert_eventually(assertion, attempts) when attempts > 0 do + if assertion.() do + :ok + else + Process.sleep(5) + assert_eventually(assertion, attempts - 1) + end + end + + defp assert_eventually(_assertion, 0), do: flunk("condition did not become true") +end From 95eb04165868979395967812400ffb43d730595c Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:29:59 +0400 Subject: [PATCH 4/8] docs(remote): record E2EE transport checkpoint Signed-off-by: Lokesh --- AGENTS.md | 1 + CODE_STRUCTURE.md | 21 +++- REUSE.toml | 23 +++- ROADMAP.md | 17 +++ docs/architecture/e2ee-transport-preflight.md | 108 ++++++++++++++++++ 5 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/e2ee-transport-preflight.md diff --git a/AGENTS.md b/AGENTS.md index 0173542f..49b0d412 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ + # Axl development guide diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md index 6461c9d8..3f88723e 100644 --- a/CODE_STRUCTURE.md +++ b/CODE_STRUCTURE.md @@ -7,7 +7,7 @@ Status: working plan. This document accompanies [ROADMAP.md](ROADMAP.md) and [OPEN_SOURCE.md](OPEN_SOURCE.md). -Updated: 2026-08-28 +Updated: 2026-09-12 ## 1. Keep everything in one repository @@ -29,7 +29,8 @@ Codex offers a useful contrast. Its CLI and Rust core share a repository, while ## 2. Languages -- Use **TypeScript** for the kernel, protocol, daemon, adoption compiler, terminal client, web client, and extensions. It matches the ecosystems and standards Axl integrates with. +- Use **TypeScript** for the kernel, protocol, daemon, adoption compiler, terminal client, web client, extensions, and hosted control plane. It matches the ecosystems and standards Axl integrates with. +- Use **Elixir/OTP only for the hosted ciphertext relay** under `services/relay/`. The relay is a bounded transport process and must not own daemon, RPC, account, persistence, or cryptographic behavior. - Use **Kotlin with Jetpack Compose** for Android and **Swift with SwiftUI** for iOS. Choose protocol code generation when the first of these clients is built. - Do not add another application language. Tooling should use TypeScript or POSIX shell. @@ -51,6 +52,9 @@ axl/ ui/ # shared presentation tokens and React renderers sdk/ # shared TypeScript client SDK when multiple clients need it extensions/ # first-party extensions, one package per feature (roadmap §2.9) + services/ + control-plane/ # separately deployable TypeScript hosted control plane + relay/ # separately deployable Elixir/OTP opaque WebSocket relay apps/ android/ # Gradle project using the generated Kotlin SDK ios/ # Xcode project using the generated Swift SDK @@ -65,8 +69,11 @@ These rules keep package ownership clear: - `packages/protocol` has no runtime dependencies. - `packages/kernel` depends only on `packages/protocol` and Node.js built-ins. - First-party extensions use the same public extension API as third-party extensions. -- `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript client creates a real need for generation. +- `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript presentation client creates a real need for generation. The Elixir relay implements only its narrow transport and internal-service framing against canonical byte and JSON fixtures; it is not a daemon-protocol client. - Apps use the public protocol SDK rather than package internals. +- `services/control-plane` may depend on `packages/protocol`. It owns hosted account, installation, device, ticket, prekey, grant, upload-reservation, quota, and security-audit mutation. Identity providers, persistent datastores, and production service authentication stay behind injected interfaces until approved. +- `services/relay` consumes versioned language-neutral fixtures. It must not import TypeScript package internals, access the control-plane datastore, decrypt envelopes, interpret daemon RPC, persist canonical history, or store attachment bodies. It calls the authenticated control-plane admission API once per new connection and accepts authenticated revocation notifications. +- The control plane and relay are separate deployables. They share no private implementation imports and communicate only through their versioned internal HTTP contract. - `packages/runtime` assembles providers, tools, extensions, sandboxing, and the authoritative daemon without importing a presentation client. - `packages/tui` is a daemon client projection. It does not construct the runtime or depend at runtime on sandbox, kernel, or concrete extension implementations. It may depend on the dependency-free public `@axl/extension-api` for client-local presentation customization. - `packages/ui` owns shared presentation tokens and React renderers. It may depend only on `packages/sdk` and presentation libraries. It owns no daemon or process authority. @@ -80,7 +87,7 @@ The protocol package owns the contract between the daemon and every client. - TypeScript definitions are authoritative while all clients use TypeScript. - A schema change requires prior design discussion and compatibility notes. -- The first Swift or Kotlin client triggers a decision on the schema language and generator. +- The first Swift or Kotlin client triggers a decision on the schema language and generator. The relay's bounded outer-frame parser does not trigger client SDK generation because it does not parse daemon RPC or canonical events. - Generated SDKs then ship through their native package systems so external and in-tree clients use the same contract. ## 5. Independent implementation @@ -91,8 +98,8 @@ Any approved adaptation records its source, commit, and changes in an SPDX heade ## 6. Build tools -- Use pnpm workspaces for package management. Add a task runner with remote caching only when repository scale justifies it. -- Keep Gradle and Xcode native. CI coordinates the build systems but the JavaScript toolchain does not wrap them. +- Use pnpm workspaces for TypeScript package and service management. Add a task runner with remote caching only when repository scale justifies it. +- Keep Mix native for `services/relay`, and keep Gradle and Xcode native. CI coordinates the build systems but the JavaScript toolchain does not wrap them. - Version packages in `packages/` together. Mobile apps keep their own store versions. Bazel would add more contributor cost than value at the current scale. @@ -110,6 +117,8 @@ Bazel would add more contributor cost than value at the current scale. Every required check reports a result. Path filters decide whether the full job runs or a small gate job reports that no relevant files changed. - Kernel, protocol, and SDK changes run all builds, including both mobile apps. +- Control-plane changes run the root TypeScript checks and package-boundary checks. +- Relay or shared remote-fixture changes run Mix formatting, compilation with warnings as errors, tests, Credo, Dialyzer, dependency audit, cross-language fixture checks, package-boundary checks, and REUSE. - App-only changes run that app and lint checks. - Documentation and plan changes run formatting, link checking, and REUSE checks. - CodeQL, Gitleaks, and dependency review run for every merge candidate. diff --git a/REUSE.toml b/REUSE.toml index 58e69673..3ad289d7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 version = 1 @@ -11,7 +12,6 @@ path = [ "LICENSES/Apache-2.0.txt", "biome.json", "distribution/npm/package.json", - "package.json", "packages/ai/tsconfig.build.json", "packages/ai/tsconfig.json", "packages/daemon/tsconfig.build.json", @@ -42,6 +42,14 @@ path = [ SPDX-FileCopyrightText = "2026 Hari Srinivasan" SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = ["package.json"] +SPDX-FileCopyrightText = [ + "2026 Hari Srinivasan", + "2026 Lokesh", +] +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = [ "packages/ai/package.json", @@ -60,6 +68,19 @@ SPDX-FileCopyrightText = [ ] SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = [ + "packages/protocol/test/fixtures/internal-relay-api-v1.json", + "packages/protocol/test/fixtures/remote-transport-v1.json", + "services/control-plane/package.json", + "services/control-plane/tsconfig.build.json", + "services/control-plane/tsconfig.json", + "services/relay/.tool-versions", + "services/relay/mix.lock", +] +SPDX-FileCopyrightText = "2026 Lokesh" +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = ["NOTICE"] SPDX-FileCopyrightText = [ diff --git a/ROADMAP.md b/ROADMAP.md index f7d96246..c0fefe28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1361,6 +1361,10 @@ Requirements: The current mobile plan favors SwiftUI on iOS and Jetpack Compose on Android because native code supports Live Activities, Android foreground services, notification actions, widgets, share sheets, and efficient streaming text. This is not a binding stack decision. Choose the implementation when mobile work begins and its requirements are concrete. +Remote transport uses pairwise application-level E2EE in addition to TLS. The approved direction is PQXDH for asynchronous session establishment and Triple Ratchet for ongoing messages. This direction supersedes any earlier Noise selection. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, secure-state design, interoperability fixtures, and independent security review. Transport code treats encrypted envelopes and public prekey bundles as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. + +The managed path uses two separately deployable services: the TypeScript control plane owns hosted state and one-use admission, while the Elixir/OTP relay owns bounded in-memory WebSocket routing. The daemon remains the command and session authority. Transport proof uses only disposable sessions, a deterministic fake provider, opaque fixtures, and a test-only fake E2EE adapter. Ordinary-session steering and remote permission approval remain disabled until the E2EE and release gates pass. + #### 16.4 Headless and automation The same daemon serves non-interactive callers: @@ -2348,6 +2352,19 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin - [ ] Keep disconnected input as an explicit draft until the daemon durably accepts it; do not create a browser-authoritative prompt queue. - [ ] Support existing-session observation and steering first. Require a daemon-owned approved workspace identifier before creating a remote Code session. +The transport-first remote-control slice is an approved exception to phase ordering. It may establish service boundaries, opaque framing, one-use ticket admission, bounded relay routing, daemon authorization behind a test-only fake E2EE adapter, and reusable SDK delivery machinery. It must not implement cryptography, select production identity or storage infrastructure, enable ordinary-session remote access, or advertise production remote control. + +The integration base for this private slice is clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` on `feature/e2ee-transport`. Stop for architecture review after the documentation, separate service boundaries, versioned fixture contract, atomic ticket-consumption path, and first bounded relay slice land. + +#### Remote transport preflight + +- [x] Record PQXDH plus Triple Ratchet as the approved direction and keep exact production cryptography blocked on Person 1's reviewed contract and library. +- [x] Add the separately deployable TypeScript control plane under `services/control-plane/` with authenticated ticket issuance and atomic one-use consumption through injected interfaces. +- [x] Add the separately deployable Elixir/OTP relay under `services/relay/` with authenticated admission, opaque bounded framing, in-memory installation-scoped routing, backpressure, heartbeat, lease, revocation, and draining behavior. +- [x] Publish language-neutral admission, revocation, and exact binary accept/reject fixtures consumed by both implementations. +- [x] Run TypeScript and Mix formatting, compilation, tests, static analysis, dependency auditing, package-boundary, and SPDX/REUSE checks in CI. +- [x] Stop at the architecture checkpoint before daemon, SDK, prekey, attachment, or production integration work. + #### Mobile clients - [ ] Choose mobile implementation stacks when work begins, based on concrete platform and product requirements. diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md new file mode 100644 index 00000000..d6c23157 --- /dev/null +++ b/docs/architecture/e2ee-transport-preflight.md @@ -0,0 +1,108 @@ + + + +# E2EE transport preflight + +Status: architecture review checkpoint + +## Integration base + +The private implementation branch is `feature/e2ee-transport`, created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687`. + +## Scope + +This checkpoint proves bounded opaque transport. It does not provide E2EE or production remote control. + +Allowed work is limited to: + +- the TypeScript control-plane boundary and deterministic in-memory stores +- one-use relay tickets and authenticated internal admission +- the Elixir/OTP WebSocket relay +- opaque structural schemas and cross-language fixtures +- bounded routing, queues, heartbeat, lease expiry, revocation, draining, and rate limits +- later daemon authorization and SDK delivery tests behind a test-only fake E2EE adapter + +Person 1 exclusively owns PQXDH, Triple Ratchet, pairing cryptography, signatures, cryptographic prekey validation and consumption, cryptographic replay behavior, secure key and ratchet storage, encryption and decryption, associated data, attachment cryptography, and cryptographic test vectors. + +PQXDH plus Triple Ratchet is the approved direction and supersedes earlier Noise selections. No production cryptography may be implemented or enabled until Person 1 supplies an approved RFC, exact suite, reviewed library, secure-state contract, and interoperability fixtures and the integrated result passes independent review. + +## Service ownership + +`services/control-plane` is the only hosted component allowed to mutate account, installation, device, ticket, prekey, grant, upload-reservation, quota, and security-audit state. This slice implements ticket state only. Authentication, authorization, proof verification, clocks, and persistence are injected. Test adapters are deterministic and are not production defaults. + +`services/relay` owns ticket-authenticated WebSocket admission and bounded in-memory routing. It has no database access, E2EE dependency, RPC knowledge, canonical history, durable mailbox, or attachment storage. The relay derives the source route from consumed-ticket state and never accepts it from a sender. + +The daemon remains authoritative for grants, revocation, session authorization, idempotency, durable acceptance, canonical JSONL, and execution. Cryptographic authentication will identify a sender but will never authorize a command. + +## Internal service contract + +The relay sends `POST /internal/v1/relay/tickets/consume` once during admission. The exact JSON request and response fixture is `packages/protocol/test/fixtures/internal-relay-api-v1.json`. Binary proof bytes use canonical base64 in JSON. The control plane validates the body at runtime and atomically consumes one unexpired ticket. One concurrent consumer succeeds. Replays fail. + +The control plane sends `POST /internal/v1/revocations` to the relay. The same fixture defines its versioned request and response. Notifications are best effort. The daemon will still recheck current authority before durable command acceptance. + +Both HTTP boundaries require injected service authentication and fail closed when it is absent or rejects the request. This checkpoint does not select the production authentication mechanism. Tickets and internal credentials are forbidden in URLs, logs, metrics, and canonical events. + +If the control plane is unavailable, new admissions fail. Existing connections continue only through their consumed-ticket lease. + +## WebSocket admission + +Clients connect to `/v1/connect` with compression disabled. They do not put a ticket in the URL. The first binary message is bounded JSON with exactly: + +```json +{ + "version": 1, + "ticket": "opaque", + "connectionNonce": "opaque", + "possessionProof": "canonical-base64" +} +``` + +The relay adds its own instance ID and calls the control plane. Proof bytes and proof verification are fake and test-only in this checkpoint. No production proof construction is implied. + +## Binary relay framing + +`packages/protocol/test/fixtures/remote-transport-v1.json` is the byte-level cross-language fixture. Every integer is unsigned big-endian. UUIDs use their 16 RFC 9562 bytes. + +```text +bytes size field +0 4 ASCII AXLR +4 1 transport version (1) +5 1 kind: send=1, delivery=2, receipt=3, failure=4 +6 16 transport attempt UUID +``` + +Send and delivery continue with: + +```text +22 16 destination route for send; source route for delivery +38 4 opaque payload length +42 n opaque payload +``` + +Receipt and failure frames instead contain one byte at offset 22. Receipt values are `admitted=1` and `forwarded=2`. Failure values follow the order of `RELAY_FAILURE_CODES` in `packages/protocol/src/remote-transport.ts`, starting at 1. + +A complete WebSocket message, including this framing, is at most 65,535 bytes. Therefore the largest opaque payload is 65,493 bytes. The relay rejects oversized messages through the WebSocket parser ceiling and checks negotiated limits again before parsing or enqueueing. + +`attemptId` is transport-local. Retrying exact opaque bytes uses a new attempt ID while retaining the encrypted request and daemon idempotency identifiers inside the opaque payload. The relay does not define or inspect that payload. + +## Receipt meaning + +- `admitted`: the relay accepted one valid bounded frame. +- `forwarded`: the relay handed the bytes to the destination socket path. +- `daemon_accepted`: not a relay receipt. It is produced only after daemon authorization and durable acceptance. + +A client may remove a mutation from its durable outbox only after `daemon_accepted`. + +## Reviewed limits + +```text +maximum complete relay frame: 65,535 bytes +pending bytes per connection: 512 KiB +heartbeat interval: 20 seconds +idle timeout: 60 seconds +maximum ticket lifetime: 60 seconds +``` + +## Review boundary + +Stop here after the documentation, CI boundaries, fixtures, ticket-consumption path, and first bounded relay slice pass. Daemon authorization, SDK outbox behavior, prekey storage, S3 transport, real E2EE integration, ordinary-session steering, and permission approvals require the next reviewed milestone. From cfd99ba0ceb24d59ea6e118e4d3b887b2caa218e Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:56:15 +0400 Subject: [PATCH 5/8] fix(protocol): simplify relay frame encoding Signed-off-by: Lokesh --- docs/architecture/e2ee-transport-preflight.md | 38 ++++++++++--- packages/protocol/src/remote-transport.ts | 53 +++++++++---------- .../test/fixtures/remote-transport-v1.json | 16 +++--- .../protocol/test/remote-transport.test.ts | 17 ++++++ services/relay/lib/axl_relay/frame.ex | 48 ++++++++--------- .../relay/lib/axl_relay/route_registry.ex | 2 +- services/relay/test/frame_test.exs | 25 +++++++++ services/relay/test/route_registry_test.exs | 4 +- 8 files changed, 134 insertions(+), 69 deletions(-) diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index d6c23157..d96fda8d 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -40,7 +40,17 @@ The relay sends `POST /internal/v1/relay/tickets/consume` once during admission. The control plane sends `POST /internal/v1/revocations` to the relay. The same fixture defines its versioned request and response. Notifications are best effort. The daemon will still recheck current authority before durable command acceptance. -Both HTTP boundaries require injected service authentication and fail closed when it is absent or rejects the request. This checkpoint does not select the production authentication mechanism. Tickets and internal credentials are forbidden in URLs, logs, metrics, and canonical events. +Both HTTP boundaries require injected service authentication and fail closed when it is absent or rejects the request. Tickets and internal credentials are forbidden in URLs, logs, metrics, and canonical events. + +Production service authentication is distinct from user authentication and ticket proof. It answers whether this exact relay instance may consume tickets and whether this exact control-plane instance may revoke routes. TLS without client authentication protects bytes in transit but does not establish that caller authority. The production mechanism remains an owner decision because it depends on deployment identity: + +- Prefer mutually authenticated TLS with short-lived workload certificates when both services have stable workload identities. +- A cloud-native signed workload token is acceptable when the selected platform provides audience-bound, short-lived service identities. +- Do not use a long-lived static bearer secret as the production design. +- Bind credentials to service role, environment, and endpoint audience. Rotate them without reconnecting existing leased clients. +- Authenticate the exact request body before parsing it, reject replays within the chosen mechanism, and redact all credential material. + +The current code therefore injects authentication on both sides and provides no production credential implementation. Selecting mTLS, SPIFFE, or a cloud IAM mechanism waits for the deployment decision. Tests use obvious fixture credentials only. If the control plane is unavailable, new admissions fail. Existing connections continue only through their consumed-ticket lease. @@ -75,24 +85,40 @@ Send and delivery continue with: ```text 22 16 destination route for send; source route for delivery -38 4 opaque payload length -42 n opaque payload +38 n opaque payload through the end of the WebSocket message +``` + +The revised encoding deliberately has no inner payload-length field. One binary WebSocket message is exactly one relay frame, so the WebSocket message boundary is authoritative. Removing the duplicate untrusted length avoids a second allocation decision and one class of inconsistent-length input. + +Receipt and failure frames instead contain one byte at offset 22. Receipt values are `admitted=1` and `forwarded=2`. Failure values are permanently assigned as follows: + +```text +1 bad_frame 7 destination_offline +2 unsupported_transport_version 8 rate_limited +3 unauthorized 9 queue_full +4 forbidden_route 10 slow_consumer +5 ticket_expired 11 service_unavailable +6 ticket_consumed ``` -Receipt and failure frames instead contain one byte at offset 22. Receipt values are `admitted=1` and `forwarded=2`. Failure values follow the order of `RELAY_FAILURE_CODES` in `packages/protocol/src/remote-transport.ts`, starting at 1. +These assignments must not be reordered. A new failure receives a new number or requires a transport-version change. -A complete WebSocket message, including this framing, is at most 65,535 bytes. Therefore the largest opaque payload is 65,493 bytes. The relay rejects oversized messages through the WebSocket parser ceiling and checks negotiated limits again before parsing or enqueueing. +A complete WebSocket message, including this framing, is at most 65,535 bytes. Therefore the largest opaque payload is 65,497 bytes. The relay rejects oversized messages through the WebSocket parser ceiling and checks negotiated limits again before parsing or enqueueing. `attemptId` is transport-local. Retrying exact opaque bytes uses a new attempt ID while retaining the encrypted request and daemon idempotency identifiers inside the opaque payload. The relay does not define or inspect that payload. ## Receipt meaning - `admitted`: the relay accepted one valid bounded frame. -- `forwarded`: the relay handed the bytes to the destination socket path. +- `forwarded`: the relay enqueued the bytes into the destination WebSocket process after route and queue checks. It does not prove a network write, endpoint receipt, parsing, decryption, or daemon acceptance. - `daemon_accepted`: not a relay receipt. It is produced only after daemon authorization and durable acceptance. A client may remove a mutation from its durable outbox only after `daemon_accepted`. +## Approved relay dependencies + +The first relay slice uses pinned Bandit, Plug, and WebSock Adapter production dependencies. They are approved for this boundary. Cowboy was evaluated and rejected after its locked version reported active security advisories. Credo, Dialyxir, and mix_audit are development-only checks. + ## Reviewed limits ```text diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts index 1af9804a..37631054 100644 --- a/packages/protocol/src/remote-transport.ts +++ b/packages/protocol/src/remote-transport.ts @@ -7,7 +7,7 @@ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3} const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const methodPattern = /^[a-z][a-z0-9]*(?:[._-][a-zA-Z0-9]+)*$/; const frameMagic = Uint8Array.of(0x41, 0x58, 0x4c, 0x52); -const routedFrameHeaderBytes = 42; +const routedFrameHeaderBytes = 38; const shortFrameBytes = 23; declare const installationIdBrand: unique symbol; @@ -109,21 +109,24 @@ export interface RelayReceipt { readonly status: RelayReceiptStatus; } -export const RELAY_FAILURE_CODES = [ - "bad_frame", - "unsupported_transport_version", - "unauthorized", - "forbidden_route", - "ticket_expired", - "ticket_consumed", - "destination_offline", - "rate_limited", - "queue_full", - "slow_consumer", - "service_unavailable", -] as const; - -export type RelayFailureCode = (typeof RELAY_FAILURE_CODES)[number]; +export const RELAY_FAILURE_CODE_VALUES = Object.freeze({ + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11, +} as const); + +export type RelayFailureCode = keyof typeof RELAY_FAILURE_CODE_VALUES; +export const RELAY_FAILURE_CODES = Object.freeze( + Object.keys(RELAY_FAILURE_CODE_VALUES) as RelayFailureCode[], +); export interface RelayFailure { readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; @@ -557,7 +560,6 @@ function encodeRoutedFrame( const output = new Uint8Array(routedFrameHeaderBytes + payload.byteLength); writePrefix(output, kind, attemptId); output.set(uuidBytes(routeId), 22); - new DataView(output.buffer).setUint32(38, payload.byteLength, false); output.set(payload, routedFrameHeaderBytes); return output; } @@ -597,9 +599,9 @@ export function encodeRelayBinaryFrame(frame: RelayBinaryFrame): Uint8Array { case "failure": { const output = new Uint8Array(shortFrameBytes); writePrefix(output, 4, frame.attemptId); - const failureIndex = RELAY_FAILURE_CODES.indexOf((frame as RelayFailure).code); - if (failureIndex < 0) fail("frame.code", "is invalid"); - output[22] = failureIndex + 1; + const failureCode = RELAY_FAILURE_CODE_VALUES[(frame as RelayFailure).code]; + if (failureCode === undefined) fail("frame.code", "is invalid"); + output[22] = failureCode; return output; } } @@ -619,13 +621,6 @@ export function parseRelayBinaryFrame(value: Uint8Array): RelayBinaryFrame { if (kind === 1 || kind === 2) { if (value.byteLength < routedFrameHeaderBytes) fail("frame", "has a truncated routed header"); const routeId = parseRouteId(bytesUuid(value, 22, "frame.routeId")); - const payloadLength = new DataView(value.buffer, value.byteOffset, value.byteLength).getUint32( - 38, - false, - ); - if (payloadLength !== value.byteLength - routedFrameHeaderBytes) { - fail("frame.opaquePayload", "length does not match the frame size"); - } const opaquePayload = value.slice(routedFrameHeaderBytes); return kind === 1 ? { @@ -650,7 +645,9 @@ export function parseRelayBinaryFrame(value: Uint8Array): RelayBinaryFrame { if (kind === 4) { const failureByte = value[22]; if (failureByte === undefined) fail("frame.code", "is missing"); - const code = RELAY_FAILURE_CODES[failureByte - 1]; + const code = RELAY_FAILURE_CODES.find( + (candidate) => RELAY_FAILURE_CODE_VALUES[candidate] === failureByte, + ); if (code === undefined) fail("frame.code", "is invalid"); return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, code }; } diff --git a/packages/protocol/test/fixtures/remote-transport-v1.json b/packages/protocol/test/fixtures/remote-transport-v1.json index 628d839a..54b29358 100644 --- a/packages/protocol/test/fixtures/remote-transport-v1.json +++ b/packages/protocol/test/fixtures/remote-transport-v1.json @@ -3,7 +3,7 @@ "accepted": [ { "name": "send", - "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "frame": { "kind": "send", "attemptId": "11111111-1111-4111-8111-111111111111", @@ -13,7 +13,7 @@ }, { "name": "delivery", - "base64": "QVhMUgECERERERERQRGBERERERERETMzMzMzM0MzgzMzMzMzMzMAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgECERERERERQRGBERERERERETMzMzMzM0MzgzMzMzMzMzMAAQL/QVhMUg==", "frame": { "kind": "delivery", "attemptId": "11111111-1111-4111-8111-111111111111", @@ -43,12 +43,12 @@ "rejected": [ { "name": "wrong-magic", - "base64": "QlhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QlhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "errorPath": "frame.magic" }, { "name": "unsupported-version", - "base64": "QVhMUgIBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgIBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "errorPath": "frame.transportVersion" }, { @@ -57,13 +57,13 @@ "errorPath": "frame" }, { - "name": "payload-length-mismatch", - "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAJAAEC/0FYTFI=", - "errorPath": "frame.opaquePayload" + "name": "invalid-attempt-id", + "base64": "QVhMUgEBERERERERARGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", + "errorPath": "frame.attemptId" }, { "name": "unknown-kind", - "base64": "QVhMUgEJERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAAAIAAEC/0FYTFI=", + "base64": "QVhMUgEJERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", "errorPath": "frame" } ] diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts index 63b60f8e..be0c0e89 100644 --- a/packages/protocol/test/remote-transport.test.ts +++ b/packages/protocol/test/remote-transport.test.ts @@ -20,6 +20,7 @@ import { parseRelayBinaryFrame, parseRelayRevocationNotification, ProtocolValidationError, + RELAY_FAILURE_CODE_VALUES, REMOTE_TRANSPORT_VERSION, type RelayBinaryFrame, } from "../src/index.ts"; @@ -90,6 +91,22 @@ test("rejects every malformed canonical relay frame", () => { } }); +test("keeps relay failure byte assignments stable", () => { + assert.deepEqual(RELAY_FAILURE_CODE_VALUES, { + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11, + }); +}); + test("enforces the complete frame bound before encoding", () => { const attemptId = "11111111-1111-4111-8111-111111111111" as const; const destinationRouteId = "22222222-2222-4222-8222-222222222222" as const; diff --git a/services/relay/lib/axl_relay/frame.ex b/services/relay/lib/axl_relay/frame.ex index 54e1b661..bd533099 100644 --- a/services/relay/lib/axl_relay/frame.ex +++ b/services/relay/lib/axl_relay/frame.ex @@ -7,21 +7,21 @@ defmodule AxlRelay.Frame do @magic "AXLR" @transport_version 1 @max_frame_bytes 65_535 - @routed_header_bytes 42 + @routed_header_bytes 38 @max_payload_bytes @max_frame_bytes - @routed_header_bytes - @failure_codes [ - :bad_frame, - :unsupported_transport_version, - :unauthorized, - :forbidden_route, - :ticket_expired, - :ticket_consumed, - :destination_offline, - :rate_limited, - :queue_full, - :slow_consumer, - :service_unavailable - ] + @failure_codes %{ + 1 => :bad_frame, + 2 => :unsupported_transport_version, + 3 => :unauthorized, + 4 => :forbidden_route, + 5 => :ticket_expired, + 6 => :ticket_consumed, + 7 => :destination_offline, + 8 => :rate_limited, + 9 => :queue_full, + 10 => :slow_consumer, + 11 => :service_unavailable + } @type relay_frame :: %{ @@ -51,9 +51,9 @@ defmodule AxlRelay.Frame do defp decode_bounded( <<@magic, @transport_version, kind, attempt::binary-size(16), route::binary-size(16), - payload_size::unsigned-big-32, payload::binary>> + payload::binary>> ) - when kind in [1, 2] and payload_size == byte_size(payload) do + when kind in [1, 2] do with {:ok, attempt_id} <- decode_uuid(attempt), {:ok, route_id} <- decode_uuid(route) do {:ok, @@ -92,8 +92,7 @@ defmodule AxlRelay.Frame do kind_byte = if kind == :send, do: 1, else: 2 {:ok, - <<@magic, @transport_version, kind_byte, attempt::binary, route::binary, - byte_size(payload)::unsigned-big-32, payload::binary>>} + <<@magic, @transport_version, kind_byte, attempt::binary, route::binary, payload::binary>>} end end @@ -121,16 +120,17 @@ defmodule AxlRelay.Frame do defp encode_status(:forwarded), do: {:ok, 2} defp encode_status(_status), do: {:error, :bad_frame} - defp decode_failure(value) when value in 1..length(@failure_codes)//1 do - {:ok, Enum.fetch!(@failure_codes, value - 1)} + defp decode_failure(value) do + case Map.fetch(@failure_codes, value) do + {:ok, code} -> {:ok, code} + :error -> {:error, :bad_frame} + end end - defp decode_failure(_value), do: {:error, :bad_frame} - defp encode_failure(code) do - case Enum.find_index(@failure_codes, &(&1 == code)) do + case Enum.find(@failure_codes, fn {_value, candidate} -> candidate == code end) do nil -> {:error, :bad_frame} - index -> {:ok, index + 1} + {value, _candidate} -> {:ok, value} end end diff --git a/services/relay/lib/axl_relay/route_registry.ex b/services/relay/lib/axl_relay/route_registry.ex index 6c3db1c6..0e8017e7 100644 --- a/services/relay/lib/axl_relay/route_registry.ex +++ b/services/relay/lib/axl_relay/route_registry.ex @@ -90,7 +90,7 @@ defmodule AxlRelay.RouteRegistry do ) do source = state.routes[source_route_id] destination = state.routes[destination_route_id] - queued_bytes = byte_size(payload) + 42 + queued_bytes = byte_size(payload) + 38 cond do source == nil -> diff --git a/services/relay/test/frame_test.exs b/services/relay/test/frame_test.exs index 3669c371..4cf56995 100644 --- a/services/relay/test/frame_test.exs +++ b/services/relay/test/frame_test.exs @@ -28,6 +28,31 @@ defmodule AxlRelay.FrameTest do end end + test "keeps failure byte assignments stable" do + codes = [ + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11 + ] + + for {code, value} <- codes do + assert {:ok, <<"AXLR", 1, 4, _attempt::binary-size(16), ^value>>} = + Frame.encode(%{ + kind: :failure, + attempt_id: "11111111-1111-4111-8111-111111111111", + code: code + }) + end + end + test "rejects an oversized frame before parsing" do assert {:error, :bad_frame} = Frame.decode(:binary.copy(<<0>>, Frame.max_frame_bytes() + 1)) end diff --git a/services/relay/test/route_registry_test.exs b/services/relay/test/route_registry_test.exs index 2812c611..8c4ed0cf 100644 --- a/services/relay/test/route_registry_test.exs +++ b/services/relay/test/route_registry_test.exs @@ -42,12 +42,12 @@ defmodule AxlRelay.RouteRegistryTest do test "routes only inside one installation and bounds pending bytes", %{registry: registry} do assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) - assert_receive {:destination, {:relay_delivery, @source, @attempt, <<1, 2, 3>>, 45}} + assert_receive {:destination, {:relay_delivery, @source, @attempt, <<1, 2, 3>>, 41}} assert {:error, :queue_full} = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) - RouteRegistry.delivered(registry, @destination, 45) + RouteRegistry.delivered(registry, @destination, 41) assert_eventually(fn -> RouteRegistry.snapshot(registry).routes[@destination].queued_bytes == 0 From 6ea73a07243a157be20d94a65c6f543ca60e8dec Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 17:56:15 +0400 Subject: [PATCH 6/8] docs(remote): draft permission authorization contract Signed-off-by: Lokesh --- ROADMAP.md | 3 +- .../remote-permission-authorization.md | 222 ++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/remote-permission-authorization.md diff --git a/ROADMAP.md b/ROADMAP.md index c0fefe28..3798e7e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1361,7 +1361,7 @@ Requirements: The current mobile plan favors SwiftUI on iOS and Jetpack Compose on Android because native code supports Live Activities, Android foreground services, notification actions, widgets, share sheets, and efficient streaming text. This is not a binding stack decision. Choose the implementation when mobile work begins and its requirements are concrete. -Remote transport uses pairwise application-level E2EE in addition to TLS. The approved direction is PQXDH for asynchronous session establishment and Triple Ratchet for ongoing messages. This direction supersedes any earlier Noise selection. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, secure-state design, interoperability fixtures, and independent security review. Transport code treats encrypted envelopes and public prekey bundles as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. +Remote transport uses pairwise application-level E2EE in addition to TLS. The approved direction is PQXDH for asynchronous session establishment and Triple Ratchet for ongoing messages. This direction supersedes any earlier Noise selection. Production cryptography remains blocked on Person 1's security RFC, exact suite, reviewed library, secure-state design, interoperability fixtures, and independent security review. Transport code treats encrypted envelopes and public prekey bundles as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. The proposed remote action-binding and approval rules are in [`docs/architecture/remote-permission-authorization.md`](docs/architecture/remote-permission-authorization.md); that draft does not enable remote approval. The managed path uses two separately deployable services: the TypeScript control plane owns hosted state and one-use admission, while the Elixir/OTP relay owns bounded in-memory WebSocket routing. The daemon remains the command and session authority. Transport proof uses only disposable sessions, a deterministic fake provider, opaque fixtures, and a test-only fake E2EE adapter. Ordinary-session steering and remote permission approval remain disabled until the E2EE and release gates pass. @@ -2363,6 +2363,7 @@ The integration base for this private slice is clean `main` commit `ea906d0295ba - [x] Add the separately deployable Elixir/OTP relay under `services/relay/` with authenticated admission, opaque bounded framing, in-memory installation-scoped routing, backpressure, heartbeat, lease, revocation, and draining behavior. - [x] Publish language-neutral admission, revocation, and exact binary accept/reject fixtures consumed by both implementations. - [x] Run TypeScript and Mix formatting, compilation, tests, static analysis, dependency auditing, package-boundary, and SPDX/REUSE checks in CI. +- [x] Draft the daemon-owned remote permission action-binding contract without enabling it. - [x] Stop at the architecture checkpoint before daemon, SDK, prekey, attachment, or production integration work. #### Mobile clients diff --git a/docs/architecture/remote-permission-authorization.md b/docs/architecture/remote-permission-authorization.md new file mode 100644 index 00000000..be6a21a3 --- /dev/null +++ b/docs/architecture/remote-permission-authorization.md @@ -0,0 +1,222 @@ + + + +# Remote permission authorization contract + +Status: proposed for architecture and security review + +## Purpose + +This contract binds a remote permission response to one pending daemon action. It defines authorization and durable acceptance after endpoint authentication. It does not define E2EE, pairing, signatures, or ratchet behavior. + +The existing `permission.requested` event does not carry enough action-binding data, and the existing `session.interaction.respond` RPC covers MCP interactions rather than daemon policy approval. Neither existing surface is remotely approvable under this contract. + +## Initial release boundary + +The first remotely approvable action is a gated tool call in an ordinary session that: + +- runs under an enforced sandbox +- remains within the daemon's current policy ceiling +- is already pending local permission review +- exposes `allow_once` and `deny` only +- comes from a device with the effective `approve_within_policy` scope + +Remote `allow_session` is excluded initially because it changes authority for future actions. Unsafe sessions, sandbox bypasses, credential grants, device administration, policy changes, network or filesystem widening, audit changes, and generated-code activation are never remotely approvable. + +Observer devices cannot respond, including with a denial. This prevents an observer from cancelling work. + +## Identifiers + +Use distinct nominal types: + +```ts +type PermissionInteractionId = EventId; +type PolicyGeneration = string; // lowercase RFC 9562 UUID +type ActionDigest = string; // 64 lowercase hexadecimal SHA-256 characters +type DeviceGrantGeneration = number; +``` + +`PermissionInteractionId` is the canonical `permission.action_requested` event ID. It is never reused. No identifier grants authority. + +`PolicyGeneration` is an opaque equality token created and durably stored by the daemon. It changes whenever any input to the effective action policy changes, including permission profile, sandbox enforcement, filesystem or network policy, credential policy, project policy, or administrator ceiling. It is not a counter supplied by a client. + +Hosted and local device-grant generations remain separate from `PolicyGeneration`. The daemon checks all three at acceptance time. + +## Canonical action binding + +The daemon constructs this record only after typed tool input, paths, destinations, and policy effects have been normalized: + +```ts +interface PermissionActionBindingV1 { + readonly version: 1; + readonly sessionId: SessionId; + readonly operationId: OperationId; + readonly interactionId: PermissionInteractionId; + readonly capability: string; + readonly subject: { + readonly kind: "tool_call"; + readonly toolCallEventId: EventId; + readonly callId: string; + readonly toolName: string; + readonly canonicalInputHash: string; + }; + readonly effects: readonly PermissionEffect[]; + readonly policyGeneration: PolicyGeneration; + readonly sandbox: { + readonly securityMode: "sandboxed"; + readonly provider: string; + readonly policyHash: string; + }; + readonly allowedDecisions: readonly ["allow_once", "deny"]; + readonly expiresAt: number; +} +``` + +A `PermissionEffect` is a typed, normalized consequence. Initial variants are: + +```ts +type PermissionEffect = + | { readonly kind: "filesystem_read"; readonly canonicalPath: string } + | { readonly kind: "filesystem_write"; readonly canonicalPath: string } + | { readonly kind: "network_connect"; readonly scheme: string; readonly host: string; readonly port: number } + | { readonly kind: "process_execute"; readonly executable: string } + | { readonly kind: "capability_use"; readonly capability: string }; +``` + +Paths are canonicalized before this record is created. Network hosts use the daemon's canonical host representation. Effects are sorted by the dependency-free canonical JSON encoder's defined order. Duplicate effects are removed. Unknown effect kinds are rejected rather than converted to text. + +`canonicalInputHash` is the existing lowercase SHA-256 hash of the fully validated canonical tool input. Raw arguments remain in their existing canonical tool-call event and are not duplicated into the permission event. + +`policyHash` is the lowercase SHA-256 hash of the normalized effective policy record used for this decision. That record contains rules and credential identifiers, never credential values. The hash is audit binding, not authority. The current policy object remains authoritative. + +## Action digest + +`actionDigest` is lowercase hexadecimal SHA-256 over the exact dependency-free canonical UTF-8 encoding of: + +```text +{ type: "axl.permission-action", binding: PermissionActionBindingV1 } +``` + +The digest excludes transport IDs, device IDs, timestamps, descriptions, UI labels, and the digest itself. It is computed once by the daemon and stored with the canonical request event. + +The digest detects accidental or malicious substitution after endpoint authentication. It is not a signature, possession proof, or replacement for E2EE. + +`expiresAt` is the daemon-created absolute expiry for this interaction. Expiry never extends because a client reconnects or retries. + +Any change to the action, effects, sandbox, policy, or expiry produces a new interaction and digest. The old interaction becomes stale. A digest algorithm or encoding change requires a new binding version. + +## Canonical events + +Add new variants instead of changing historical permission-event meanings. + +```ts +interface PermissionActionRequestedPayload { + readonly binding: PermissionActionBindingV1; + readonly actionDigest: ActionDigest; + readonly description: string; +} + +interface PermissionActionResolvedPayload { + readonly interactionId: PermissionInteractionId; + readonly actionDigest: ActionDigest; + readonly policyGeneration: PolicyGeneration; + readonly decision: "allow_once" | "deny"; + readonly actor: + | { readonly kind: "local_attachment"; readonly attachmentId: string } + | { readonly kind: "remote_device"; readonly deviceId: DeviceId }; +} +``` + +The event types are `permission.action_requested` and `permission.action_resolved`. The request event is appended and synced before any client may answer. The resolved event is the single canonical winner and is appended before execution proceeds. + +Descriptions are presentation text and never participate in the digest. Events contain no credentials, relay tickets, E2EE material, or internal service credentials. + +## Remote response RPC + +Add a daemon RPC named `session.permission.respond`: + +```ts +interface RemotePermissionResponseV1 { + readonly version: 1; + readonly sessionId: SessionId; + readonly interactionId: PermissionInteractionId; + readonly actionDigest: ActionDigest; + readonly policyGeneration: PolicyGeneration; + readonly decision: "allow_once" | "deny"; +} +``` + +The ordinary RPC request ID and UUID idempotency key remain transport metadata. Both are required for a remote mutation. The response does not contain `deviceId`; the daemon uses only the identity established by successful endpoint authentication. + +A local client may use the same RPC with attachment authority. The daemon records the actual actor after authorization. + +## Authorization and acceptance order + +The daemon performs these steps in order: + +1. Bound and parse the outer frame. +2. Authenticate and open it through the injected E2EE boundary. +3. Establish the authenticated device identity. +4. Validate the plaintext RPC schema. +5. Load current hosted and local grants and their revocation generations. +6. Require the effective `approve_within_policy` scope. +7. Require an enforced sandbox and reject unsafe mode or bypass actions. +8. Load the exact pending interaction and reject it after its fixed expiry. +9. Compare session, interaction ID, action digest, and policy generation exactly. +10. Recompute the current policy ceiling and confirm `allow_once` remains an offered decision. +11. Apply the command journal's idempotency and request-hash rules. +12. Atomically accept the first unresolved response. +13. Append and sync `permission.action_resolved` with the authenticated actor. +14. Continue or deny the daemon-owned operation. +15. Seal the response through the endpoint E2EE boundary. + +Decryption establishes identity only. Steps 5 through 13 establish authority and durable acceptance. + +## Races and recovery + +- The first durably accepted response wins across local and remote clients. +- A retry with the same idempotency key and request hash returns the original result. +- Reusing the key for another response returns `idempotency_conflict`. +- A different key after resolution returns `permission_already_resolved` and the canonical resolution event ID. +- A changed policy generation returns `stale_policy` without resolving the interaction. +- A changed digest returns `action_mismatch` without revealing the current action. +- A revoked or narrowed device returns `unauthorized` and creates no acceptance record. +- If acceptance is synced but the resolution event is missing after a crash, restart reconciliation either appends the deterministic resolution or proves no action resumed. It never asks the user to guess. +- Revocation after durable acceptance does not cancel the already daemon-owned operation. A separately authorized interrupt is required. + +## Stable rejection classes + +```text +unknown_permission +permission_expired +permission_already_resolved +stale_policy +action_mismatch +decision_not_allowed +observer_forbidden +device_revoked +scope_forbidden +unsafe_remote_approval_forbidden +sandbox_bypass_forbidden +idempotency_conflict +``` + +Public errors remain bounded and do not echo tool input, paths, commands, policy records, credentials, or device secrets. + +## Required tests before implementation can ship + +- modified action digest, policy generation, session, or interaction fails +- observer, revoked device, narrowed hosted grant, and narrowed local grant fail +- unsafe sessions and sandbox bypasses fail +- remotely supplied device identity is impossible +- `allow_session` is rejected remotely +- simultaneous local and remote responses produce one canonical winner +- same-key retry replays and conflicting-key reuse fails +- restart between acceptance and resolution reconciles without executing twice +- policy changes invalidate every old response +- permission events and diagnostics contain no credentials or cryptographic material +- successful approval cannot exceed the current daemon policy ceiling + +## Release gate + +This draft does not enable remote approvals. Implementation starts only after protocol and security review. User release still requires Person 1's E2EE library, secure state storage, integrated revocation tests, lost-device tests, and independent security review. From 81937e9d5600f20b3f82b6a815c8efc1a6d21a5f Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sat, 12 Sep 2026 21:13:07 +0400 Subject: [PATCH 7/8] docs(remote): record rebased integration commit Signed-off-by: Lokesh --- ROADMAP.md | 2 +- docs/architecture/e2ee-transport-preflight.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3798e7e6..24d2d4c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2354,7 +2354,7 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin The transport-first remote-control slice is an approved exception to phase ordering. It may establish service boundaries, opaque framing, one-use ticket admission, bounded relay routing, daemon authorization behind a test-only fake E2EE adapter, and reusable SDK delivery machinery. It must not implement cryptography, select production identity or storage infrastructure, enable ordinary-session remote access, or advertise production remote control. -The integration base for this private slice is clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` on `feature/e2ee-transport`. Stop for architecture review after the documentation, separate service boundaries, versioned fixture contract, atomic ticket-consumption path, and first bounded relay slice land. +The private slice was created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` and rebased for integration onto clean `main` commit `57bd31b7e718a125fc51a0fcf3a554cb100ea708` on `feature/e2ee-transport`. Stop for architecture review after the documentation, separate service boundaries, versioned fixture contract, atomic ticket-consumption path, and first bounded relay slice land. #### Remote transport preflight diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index d96fda8d..1e60fbd5 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -7,7 +7,7 @@ Status: architecture review checkpoint ## Integration base -The private implementation branch is `feature/e2ee-transport`, created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687`. +The private implementation branch is `feature/e2ee-transport`. It was created from clean `main` commit `ea906d0295ba67f833c49ace408a9573551ea687` and rebased for integration onto clean `main` commit `57bd31b7e718a125fc51a0fcf3a554cb100ea708`. ## Scope From 9791c8c22fb3d0832f510f572d2d250ad9299d8c Mon Sep 17 00:00:00 2001 From: Lokesh Date: Sun, 13 Sep 2026 12:07:04 +0400 Subject: [PATCH 8/8] fix(relay): resolve transport review findings Signed-off-by: Lokesh --- docs/architecture/e2ee-transport-preflight.md | 22 +- packages/protocol/src/remote-transport.ts | 82 +++++- .../test/fixtures/internal-relay-api-v1.json | 18 ++ .../protocol/test/remote-transport.test.ts | 21 ++ services/control-plane/src/tickets.ts | 24 +- services/control-plane/test/tickets.test.ts | 30 ++- services/relay/README.md | 7 +- services/relay/lib/axl_relay/connection.ex | 59 ++++- services/relay/lib/axl_relay/frame.ex | 3 +- .../axl_relay/http_control_plane_client.ex | 6 +- .../relay/lib/axl_relay/route_registry.ex | 243 ++++++++++++------ services/relay/test/frame_test.exs | 3 +- .../relay/test/internal_contract_test.exs | 10 + services/relay/test/route_registry_test.exs | 98 ++++++- services/relay/test/websocket_relay_test.exs | 76 +++++- 15 files changed, 610 insertions(+), 92 deletions(-) diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md index 1e60fbd5..37c33ff8 100644 --- a/docs/architecture/e2ee-transport-preflight.md +++ b/docs/architecture/e2ee-transport-preflight.md @@ -69,6 +69,10 @@ Clients connect to `/v1/connect` with compression disabled. They do not put a ti The relay adds its own instance ID and calls the control plane. Proof bytes and proof verification are fake and test-only in this checkpoint. No production proof construction is implied. +After admission, the relay sends a `route_snapshot` control message with the connection's ephemeral source route and only opposite-role peers from the same installation. A device sees at most the current daemon route. The daemon sees authorized device routes and their opaque device IDs. `route_available` and `route_unavailable` messages update this view after reconnects. Devices never enumerate other devices. + +One daemon route is active per installation and one route is active per device ID. A newer authenticated connection replaces the older same-identity route. Routing permits only `device -> daemon` and `daemon -> device`; same-role and cross-installation delivery returns `forbidden_route`. + ## Binary relay framing `packages/protocol/test/fixtures/remote-transport-v1.json` is the byte-level cross-language fixture. Every integer is unsigned big-endian. UUIDs use their 16 RFC 9562 bytes. @@ -98,7 +102,7 @@ Receipt and failure frames instead contain one byte at offset 22. Receipt values 3 unauthorized 9 queue_full 4 forbidden_route 10 slow_consumer 5 ticket_expired 11 service_unavailable -6 ticket_consumed +6 ticket_consumed 12 ticket_revoked ``` These assignments must not be reordered. A new failure receives a new number or requires a transport-version change. @@ -119,6 +123,18 @@ A client may remove a mutation from its durable outbox only after `daemon_accept The first relay slice uses pinned Bandit, Plug, and WebSock Adapter production dependencies. They are approved for this boundary. Cowboy was evaluated and rejected after its locked version reported active security advisories. Credo, Dialyxir, and mix_audit are development-only checks. +## Heartbeats and half-open connections + +The relay sends a ping every 20 seconds and records inbound activity with a monotonic clock. A valid binary frame, ping, or pong updates liveness. Outbound pings do not. A connection closes with `idle_timeout` after 60 seconds without valid inbound activity. Ticket lease expiry is an independent hard deadline and is never extended by heartbeat traffic. + +## Slow consumers + +Each route has a 512 KiB application queue ceiling. Reaching the ceiling starts a 10-second saturation timer and further enqueue attempts fail with `queue_full`. If queued bytes do not fall to 256 KiB or less before the timer fires, the relay evicts the destination with `slow_consumer`. Bytes remain charged until the WebSocket adapter accepts the push. `forwarded` still does not prove endpoint or network receipt. Deployment must separately bound kernel socket buffers, and load tests must measure them. + +## Revocation races + +Every ticket stores the hosted grant generation observed at issuance. Atomic consumption rechecks the current generation and rejects a missing or changed grant with `ticket_revoked`. The admission result carries that generation. Relay revocation notifications close routes admitted at or before the revoked generation and prevent their stale re-registration. A missed relay notification still cannot authorize a daemon command because the daemon rechecks current grants before durable acceptance. + ## Reviewed limits ```text @@ -129,6 +145,10 @@ idle timeout: 60 seconds maximum ticket lifetime: 60 seconds ``` +## Review resolutions + +The architecture review selected role-filtered relay discovery, strict opposite-role topology, monotonic inbound-idle tracking, timed slow-consumer eviction, and grant-generation-bound ticket consumption. The implementation and cross-language fixtures now enforce those decisions. Socket-adapter acceptance remains distinct from network or endpoint receipt, and production socket-memory bounds remain a deployment and load-test requirement. + ## Review boundary Stop here after the documentation, CI boundaries, fixtures, ticket-consumption path, and first bounded relay slice pass. Daemon authorization, SDK outbox behavior, prekey storage, S3 transport, real E2EE integration, ordinary-session steering, and permission approvals require the next reviewed milestone. diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts index 37631054..4090c282 100644 --- a/packages/protocol/src/remote-transport.ts +++ b/packages/protocol/src/remote-transport.ts @@ -83,6 +83,7 @@ export interface ConsumeRelayTicketResult { readonly deviceId?: DeviceId; readonly sourceRouteId: RouteId; readonly role: "daemon" | "device"; + readonly grantGeneration: number; readonly leaseExpiresAt: number; readonly limits: RelayLimits; } @@ -121,6 +122,7 @@ export const RELAY_FAILURE_CODE_VALUES = Object.freeze({ queue_full: 9, slow_consumer: 10, service_unavailable: 11, + ticket_revoked: 12, } as const); export type RelayFailureCode = keyof typeof RELAY_FAILURE_CODE_VALUES; @@ -163,6 +165,19 @@ export interface OpaqueOutboxRecord { readonly state: "queued_local" | "sending" | "daemon_accepted"; } +export interface RelayPeerRoute { + readonly routeId: RouteId; + readonly role: "daemon" | "device"; + readonly deviceId?: DeviceId; +} + +export interface RelayDiscoveryMessage { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "route_snapshot" | "route_available" | "route_unavailable"; + readonly sourceRoute?: RelayPeerRoute; + readonly peers: readonly RelayPeerRoute[]; +} + export interface RelayRevocationNotification { readonly version: typeof INTERNAL_RELAY_API_VERSION; readonly installationId: InstallationId; @@ -444,7 +459,15 @@ export function parseInternalConsumeRelayTicketResult(value: unknown): ConsumeRe exact( candidate, "result", - ["version", "installationId", "sourceRouteId", "role", "leaseExpiresAt", "limits"], + [ + "version", + "installationId", + "sourceRouteId", + "role", + "grantGeneration", + "leaseExpiresAt", + "limits", + ], ["deviceId"], ); if (candidate.version !== INTERNAL_RELAY_API_VERSION) { @@ -462,6 +485,12 @@ export function parseInternalConsumeRelayTicketResult(value: unknown): ConsumeRe ...(deviceId === undefined ? {} : { deviceId }), sourceRouteId: parseRouteId(candidate.sourceRouteId, "result.sourceRouteId"), role: parsedRole, + grantGeneration: integer( + candidate.grantGeneration, + "result.grantGeneration", + 1, + Number.MAX_SAFE_INTEGER, + ), leaseExpiresAt: timestamp(candidate.leaseExpiresAt, "result.leaseExpiresAt"), limits: parseRelayLimits(candidate.limits, "result.limits"), }; @@ -473,6 +502,57 @@ export function encodeInternalConsumeRelayTicketResult( return { version: INTERNAL_RELAY_API_VERSION, ...result }; } +function parseRelayPeerRoute(value: unknown, path: string): RelayPeerRoute { + const candidate = object(value, path); + exact(candidate, path, ["routeId", "role"], ["deviceId"]); + const parsedRole = role(candidate.role, `${path}.role`); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, `${path}.deviceId`); + if (parsedRole === "device" && deviceId === undefined) fail(`${path}.deviceId`, "is required"); + if (parsedRole === "daemon" && deviceId !== undefined) fail(`${path}.deviceId`, "is not allowed"); + return { + routeId: parseRouteId(candidate.routeId, `${path}.routeId`), + role: parsedRole, + ...(deviceId === undefined ? {} : { deviceId }), + }; +} + +export function parseRelayDiscoveryMessage(value: unknown): RelayDiscoveryMessage { + const candidate = object(value, "discovery"); + exact(candidate, "discovery", ["version", "type", "peers"], ["sourceRoute"]); + if (candidate.version !== REMOTE_TRANSPORT_VERSION) { + fail("discovery.version", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + if ( + candidate.type !== "route_snapshot" && + candidate.type !== "route_available" && + candidate.type !== "route_unavailable" + ) { + fail("discovery.type", "is invalid"); + } + if (!Array.isArray(candidate.peers) || candidate.peers.length > 256) { + fail("discovery.peers", "must be an array of at most 256 routes"); + } + if (candidate.type === "route_snapshot" && candidate.sourceRoute === undefined) { + fail("discovery.sourceRoute", "is required for a snapshot"); + } + if (candidate.type !== "route_snapshot" && candidate.sourceRoute !== undefined) { + fail("discovery.sourceRoute", "is allowed only for a snapshot"); + } + return { + version: REMOTE_TRANSPORT_VERSION, + type: candidate.type, + ...(candidate.sourceRoute === undefined + ? {} + : { sourceRoute: parseRelayPeerRoute(candidate.sourceRoute, "discovery.sourceRoute") }), + peers: candidate.peers.map((peer, index) => + parseRelayPeerRoute(peer, `discovery.peers[${index}]`), + ), + }; +} + export function parseRelayRevocationNotification(value: unknown): RelayRevocationNotification { const candidate = object(value, "request"); exact( diff --git a/packages/protocol/test/fixtures/internal-relay-api-v1.json b/packages/protocol/test/fixtures/internal-relay-api-v1.json index 7a6596de..9a1622dd 100644 --- a/packages/protocol/test/fixtures/internal-relay-api-v1.json +++ b/packages/protocol/test/fixtures/internal-relay-api-v1.json @@ -14,6 +14,7 @@ "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "sourceRouteId": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", "role": "device", + "grantGeneration": 7, "leaseExpiresAt": 2000000000000, "limits": { "maxFrameBytes": 65535, @@ -23,6 +24,23 @@ } } }, + "discovery": { + "deviceSnapshot": { + "version": 1, + "type": "route_snapshot", + "sourceRoute": { + "routeId": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "role": "device", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + }, + "peers": [ + { + "routeId": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "role": "daemon" + } + ] + } + }, "revocation": { "request": { "version": 1, diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts index be0c0e89..51548afe 100644 --- a/packages/protocol/test/remote-transport.test.ts +++ b/packages/protocol/test/remote-transport.test.ts @@ -18,6 +18,7 @@ import { parseInternalConsumeRelayTicketResult, parseIssueRelayTicketRequest, parseRelayBinaryFrame, + parseRelayDiscoveryMessage, parseRelayRevocationNotification, ProtocolValidationError, RELAY_FAILURE_CODE_VALUES, @@ -46,6 +47,7 @@ const internalFixtures = JSON.parse( readFileSync(new URL("./fixtures/internal-relay-api-v1.json", import.meta.url), "utf8"), ) as { readonly consumeTicket: { readonly request: unknown; readonly result: unknown }; + readonly discovery: { readonly deviceSnapshot: unknown }; readonly revocation: { readonly request: unknown; readonly result: unknown }; }; @@ -104,6 +106,7 @@ test("keeps relay failure byte assignments stable", () => { queue_full: 9, slow_consumer: 10, service_unavailable: 11, + ticket_revoked: 12, }); }); @@ -122,6 +125,23 @@ test("enforces the complete frame bound before encoding", () => { ); }); +test("validates role-scoped route discovery messages", () => { + assert.deepEqual( + parseRelayDiscoveryMessage(internalFixtures.discovery.deviceSnapshot), + internalFixtures.discovery.deviceSnapshot, + ); + assert.throws( + () => + parseRelayDiscoveryMessage({ + version: 1, + type: "route_available", + sourceRoute: { routeId: "11111111-1111-4111-8111-111111111111", role: "daemon" }, + peers: [], + }), + (error) => error instanceof ProtocolValidationError && error.path === "discovery.sourceRoute", + ); +}); + test("keeps the deterministic fake E2EE adapter in test support", async () => { const daemonId = parseDeviceId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); @@ -174,6 +194,7 @@ test("validates ticket roles and the language-neutral internal contract", () => deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", sourceRouteId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", role: "device", + grantGeneration: 7, leaseExpiresAt: 2_000_000_000_000, limits: DEFAULT_RELAY_LIMITS, }); diff --git a/services/control-plane/src/tickets.ts b/services/control-plane/src/tickets.ts index bb8fc0cf..873a7ab6 100644 --- a/services/control-plane/src/tickets.ts +++ b/services/control-plane/src/tickets.ts @@ -26,7 +26,10 @@ export interface Clock { } export interface RelayTicketAuthorizer { - authorize(principal: AccountPrincipal, request: IssueRelayTicketRequest): Promise; + currentGeneration( + principal: AccountPrincipal, + request: IssueRelayTicketRequest, + ): Promise; } export interface RelayTicketProofVerifier { @@ -34,6 +37,8 @@ export interface RelayTicketProofVerifier { } export interface RelayTicketRecord extends IssueRelayTicketRequest { + readonly accountId: string; + readonly grantGeneration: number; readonly ticketDigest: string; readonly sourceRouteId: ConsumeRelayTicketResult["sourceRouteId"]; readonly issuedAt: number; @@ -59,6 +64,7 @@ export type RelayTicketErrorCode = | "forbidden_route" | "ticket_expired" | "ticket_consumed" + | "ticket_revoked" | "service_unavailable"; export class RelayTicketError extends Error { @@ -148,13 +154,19 @@ export class RelayTicketService { async issue(principal: AccountPrincipal, value: unknown): Promise { const request = parseIssueRelayTicketRequest(value); - if (!(await this.options.authorizer.authorize(principal, request))) { + const grantGeneration = await this.options.authorizer.currentGeneration(principal, request); + if (grantGeneration === undefined) { throw new RelayTicketError("forbidden_route", "Principal cannot access this route", 403); } + if (!Number.isSafeInteger(grantGeneration) || grantGeneration <= 0) { + throw new Error("Grant generation must be a positive safe integer"); + } const now = this.clock.now(); const ticket = this.randomToken(); const record: RelayTicketRecord = { ...request, + accountId: principal.accountId, + grantGeneration, ticketDigest: digestTicket(ticket), sourceRouteId: parseRouteId(this.randomId(), "sourceRouteId"), issuedAt: now, @@ -180,6 +192,13 @@ export class RelayTicketService { if (!(await this.options.proofVerifier.verify(candidate, request))) { throw new RelayTicketError("unauthorized", "Possession proof is invalid", 401); } + const currentGeneration = await this.options.authorizer.currentGeneration( + { accountId: candidate.accountId }, + candidate, + ); + if (currentGeneration === undefined || currentGeneration !== candidate.grantGeneration) { + throw new RelayTicketError("ticket_revoked", "Relay ticket grant is no longer current", 401); + } const consumed = await this.options.store.consume( ticketDigest, request.relayInstanceId, @@ -190,6 +209,7 @@ export class RelayTicketService { ...(consumed.deviceId === undefined ? {} : { deviceId: consumed.deviceId }), sourceRouteId: consumed.sourceRouteId, role: consumed.role, + grantGeneration: consumed.grantGeneration, leaseExpiresAt: consumed.expiresAt, limits: consumed.limits, }; diff --git a/services/control-plane/test/tickets.test.ts b/services/control-plane/test/tickets.test.ts index 33d73eba..177b302c 100644 --- a/services/control-plane/test/tickets.test.ts +++ b/services/control-plane/test/tickets.test.ts @@ -36,15 +36,17 @@ const fixture = JSON.parse( function createTicketService( clock: { now(): number } = { now: () => 1_900_000_000_000 }, + currentGeneration: () => number | undefined = () => 1, ): RelayTicketService { let routeCounter = 0; return new RelayTicketService({ store: new InMemoryRelayTicketStore(), authorizer: { - async authorize(principal, request) { - return ( - principal.accountId === "account-fixture" && request.installationId === installationId - ); + async currentGeneration(principal, request) { + return principal.accountId === "account-fixture" && + request.installationId === installationId + ? currentGeneration() + : undefined; }, }, proofVerifier: { @@ -121,6 +123,25 @@ test("rejects unauthorized issuance, invalid proof, and expired tickets", async ); }); +test("rejects a ticket when its grant generation changes before consumption", async () => { + let generation: number | undefined = 7; + const service = createTicketService(undefined, () => generation); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + generation = 8; + await assert.rejects( + service.consume({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + (error) => error instanceof RelayTicketError && error.code === "ticket_revoked", + ); +}); + test("serves authenticated public issuance and internal consumption without URL credentials", async (context) => { const service = createTicketService(); const handler = createControlPlaneHandler({ @@ -187,6 +208,7 @@ test("serves authenticated public issuance and internal consumption without URL deviceId, sourceRouteId: "cccccccc-cccc-4ccc-8ccc-000000000001", role: "device", + grantGeneration: 1, leaseExpiresAt: 1_900_000_060_000, limits: DEFAULT_RELAY_LIMITS, }); diff --git a/services/relay/README.md b/services/relay/README.md index f7a9aa54..4921482a 100644 --- a/services/relay/README.md +++ b/services/relay/README.md @@ -9,10 +9,11 @@ The first slice provides: - one-use ticket admission through an injected control-plane client - exact transport-v1 binary framing shared with TypeScript fixtures -- installation-scoped in-memory route registration -- bounded per-route pending bytes +- role-filtered route snapshots and updates without device-to-device enumeration +- installation-scoped `device <-> daemon` routing with same-identity replacement +- bounded per-route pending bytes and timed slow-consumer eviction - WebSocket compression disabled and a 65,535-byte frame ceiling -- heartbeat, idle, lease-expiry, revocation, and draining behavior +- explicit inbound heartbeat deadlines, lease expiry, generation-bound revocation, and draining - fail-closed admission and internal-authentication interfaces Production control-plane origins, service authentication, TLS termination, and deployment configuration remain unselected. Tests use deterministic fake adapters. diff --git a/services/relay/lib/axl_relay/connection.ex b/services/relay/lib/axl_relay/connection.ex index 6033e1f3..7d2a88c5 100644 --- a/services/relay/lib/axl_relay/connection.ex +++ b/services/relay/lib/axl_relay/connection.ex @@ -26,6 +26,7 @@ defmodule AxlRelay.Connection do route_id: nil, limits: nil, rate_window_started: System.monotonic_time(:millisecond), + last_inbound_at: nil, rate_frames: 0, rate_bytes: 0 }} @@ -50,7 +51,14 @@ defmodule AxlRelay.Connection do result.lease_expires_at - System.system_time(:millisecond) ) - {:ok, %{state | phase: :active, route_id: result.source_route_id, limits: result.limits}} + {:ok, + %{ + state + | phase: :active, + route_id: result.source_route_id, + limits: result.limits, + last_inbound_at: System.monotonic_time(:millisecond) + }} else {:error, code} -> close(code, state) false -> close(:ticket_expired, state) @@ -62,6 +70,7 @@ defmodule AxlRelay.Connection do with true <- byte_size(message) <= state.limits.max_frame_bytes, {:ok, %{kind: :send} = frame} <- Frame.decode(message), {:ok, rate_state} <- rate_limit(state, byte_size(message)) do + rate_state = %{rate_state | last_inbound_at: System.monotonic_time(:millisecond)} admitted = receipt(frame.attempt_id, :admitted) case RouteRegistry.forward( @@ -86,6 +95,10 @@ defmodule AxlRelay.Connection do def handle_in(_frame, state), do: close(:bad_frame, state) @impl true + def handle_control({_payload, opcode: opcode}, %{phase: :active} = state) + when opcode in [:ping, :pong], + do: {:ok, %{state | last_inbound_at: System.monotonic_time(:millisecond)}} + def handle_control({_payload, opcode: opcode}, state) when opcode in [:ping, :pong], do: {:ok, state} @@ -112,12 +125,32 @@ defmodule AxlRelay.Connection do end def handle_info(:heartbeat, %{phase: :active} = state) do - Process.send_after(self(), :heartbeat, state.limits.heartbeat_interval_ms) - {:push, {:ping, <<>>}, state} + now = System.monotonic_time(:millisecond) + + if now - state.last_inbound_at >= state.limits.idle_timeout_ms do + close(:idle_timeout, state) + else + Process.send_after(self(), :heartbeat, state.limits.heartbeat_interval_ms) + {:push, {:ping, <<>>}, state} + end + end + + def handle_info({:route_snapshot, own, peers}, state) do + {:push, {:binary, discovery("route_snapshot", own, peers)}, state} + end + + def handle_info({:route_available, peer}, state) do + {:push, {:binary, discovery("route_available", nil, [peer])}, state} + end + + def handle_info({:route_unavailable, peer}, state) do + {:push, {:binary, discovery("route_unavailable", nil, [peer])}, state} end def handle_info(:lease_expired, state), do: close(:unauthorized, state) def handle_info(:route_revoked, state), do: close(:unauthorized, state) + def handle_info(:route_replaced, state), do: close(:unauthorized, state) + def handle_info(:slow_consumer, state), do: close(:slow_consumer, state) def handle_info(:relay_draining, state), do: close(:service_unavailable, state) def handle_info(:admission_timeout, %{phase: :awaiting_admission} = state), @@ -171,6 +204,26 @@ defmodule AxlRelay.Connection do encoded end + defp discovery(type, own, peers) do + message = %{ + "version" => 1, + "type" => type, + "peers" => Enum.map(peers, &json_route/1) + } + + message = if own == nil, do: message, else: Map.put(message, "sourceRoute", json_route(own)) + message |> :json.encode() |> IO.iodata_to_binary() + end + + defp json_route(route) do + value = %{ + "routeId" => route.route_id, + "role" => Atom.to_string(route.role) + } + + if route.device_id == nil, do: value, else: Map.put(value, "deviceId", route.device_id) + end + defp close(code, state), do: {:stop, :normal, {1008, Atom.to_string(code)}, state} end diff --git a/services/relay/lib/axl_relay/frame.ex b/services/relay/lib/axl_relay/frame.ex index bd533099..e1905009 100644 --- a/services/relay/lib/axl_relay/frame.ex +++ b/services/relay/lib/axl_relay/frame.ex @@ -20,7 +20,8 @@ defmodule AxlRelay.Frame do 8 => :rate_limited, 9 => :queue_full, 10 => :slow_consumer, - 11 => :service_unavailable + 11 => :service_unavailable, + 12 => :ticket_revoked } @type relay_frame :: diff --git a/services/relay/lib/axl_relay/http_control_plane_client.ex b/services/relay/lib/axl_relay/http_control_plane_client.ex index 08cf5446..02eae8c6 100644 --- a/services/relay/lib/axl_relay/http_control_plane_client.ex +++ b/services/relay/lib/axl_relay/http_control_plane_client.ex @@ -74,7 +74,8 @@ defmodule AxlRelay.HttpControlPlaneClient do %{ "unauthorized" => :unauthorized, "ticket_expired" => :ticket_expired, - "ticket_consumed" => :ticket_consumed + "ticket_consumed" => :ticket_consumed, + "ticket_revoked" => :ticket_revoked }, code ) do @@ -92,6 +93,7 @@ defmodule AxlRelay.HttpControlPlaneClient do "installationId", "sourceRouteId", "role", + "grantGeneration", "leaseExpiresAt", "limits" ]) @@ -105,6 +107,7 @@ defmodule AxlRelay.HttpControlPlaneClient do true <- uuid?(result["sourceRouteId"]), role when role in ["daemon", "device"] <- result["role"], true <- valid_device?(role, result["deviceId"]), + generation when is_integer(generation) and generation > 0 <- result["grantGeneration"], lease when is_integer(lease) and lease >= 0 <- result["leaseExpiresAt"], {:ok, limits} <- validate_limits(result["limits"]) do {:ok, @@ -113,6 +116,7 @@ defmodule AxlRelay.HttpControlPlaneClient do device_id: result["deviceId"], source_route_id: result["sourceRouteId"], role: String.to_existing_atom(role), + grant_generation: generation, lease_expires_at: lease, limits: limits }} diff --git a/services/relay/lib/axl_relay/route_registry.ex b/services/relay/lib/axl_relay/route_registry.ex index 0e8017e7..6a528fb6 100644 --- a/services/relay/lib/axl_relay/route_registry.ex +++ b/services/relay/lib/axl_relay/route_registry.ex @@ -2,16 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 defmodule AxlRelay.RouteRegistry do - @moduledoc "In-memory, installation-scoped route table with bounded pending bytes." + @moduledoc "Role-scoped in-memory routes with bounded pending bytes and eviction." use GenServer - @type admission :: %{ - installation_id: String.t(), - device_id: String.t() | nil, - source_route_id: String.t(), - limits: %{max_queued_bytes: pos_integer()} - } + @default_slow_consumer_grace_ms 10_000 def start_link(options \\ []) do case Keyword.get(options, :name, __MODULE__) do @@ -20,68 +15,88 @@ defmodule AxlRelay.RouteRegistry do end end - def register(server \\ __MODULE__, pid, admission) do - GenServer.call(server, {:register, pid, admission}) - end + def register(server \\ __MODULE__, pid, admission), + do: GenServer.call(server, {:register, pid, admission}) - def unregister(server \\ __MODULE__, route_id) do - GenServer.call(server, {:unregister, route_id}) - end + def unregister(server \\ __MODULE__, route_id), + do: GenServer.call(server, {:unregister, route_id}) def forward(server \\ __MODULE__, source_route_id, destination_route_id, attempt_id, payload) do - GenServer.call( - server, - {:forward, source_route_id, destination_route_id, attempt_id, payload} - ) + GenServer.call(server, {:forward, source_route_id, destination_route_id, attempt_id, payload}) end - def delivered(server \\ __MODULE__, route_id, bytes) do - GenServer.cast(server, {:delivered, route_id, bytes}) - end + def delivered(server \\ __MODULE__, route_id, bytes), + do: GenServer.cast(server, {:delivered, route_id, bytes}) - def revoke(server \\ __MODULE__, notification) do - GenServer.call(server, {:revoke, notification}) - end + def revoke(server \\ __MODULE__, notification), + do: GenServer.call(server, {:revoke, notification}) - def drain(server \\ __MODULE__) do - GenServer.call(server, :drain) - end - - def snapshot(server \\ __MODULE__) do - GenServer.call(server, :snapshot) - end + def drain(server \\ __MODULE__), do: GenServer.call(server, :drain) + def snapshot(server \\ __MODULE__), do: GenServer.call(server, :snapshot) @impl true - def init(_options) do - {:ok, %{routes: %{}, monitors: %{}, generations: %{}, draining: false}} + def init(options) do + {:ok, + %{ + routes: %{}, + monitors: %{}, + generations: %{}, + draining: false, + slow_consumer_grace_ms: + Keyword.get(options, :slow_consumer_grace_ms, @default_slow_consumer_grace_ms) + }} end @impl true - def handle_call({:register, _pid, _admission}, _from, %{draining: true} = state) do - {:reply, {:error, :service_unavailable}, state} - end + def handle_call({:register, _pid, _admission}, _from, %{draining: true} = state), + do: {:reply, {:error, :service_unavailable}, state} def handle_call({:register, pid, admission}, _from, state) do route_id = admission.source_route_id - if Map.has_key?(state.routes, route_id) do - {:reply, {:error, :forbidden_route}, state} - else - monitor = Process.monitor(pid) - route = Map.merge(admission, %{pid: pid, monitor: monitor, queued_bytes: 0}) + cond do + Map.has_key?(state.routes, route_id) -> + {:reply, {:error, :forbidden_route}, state} - {:reply, :ok, - %{ - state - | routes: Map.put(state.routes, route_id, route), - monitors: Map.put(state.monitors, monitor, route_id) - }} + admission.grant_generation <= revoked_generation(state, admission) -> + {:reply, {:error, :ticket_revoked}, state} + + true -> + replacements = + Enum.filter(state.routes, fn {_id, route} -> same_identity?(route, admission) end) + + Enum.each(replacements, fn {_id, route} -> send(route.pid, :route_replaced) end) + + without_replaced = + Enum.reduce(replacements, state, fn {id, _route}, current -> + remove_route(current, id, false) + end) + + monitor = Process.monitor(pid) + + route = + Map.merge(admission, %{ + pid: pid, + monitor: monitor, + queued_bytes: 0, + saturation_token: nil + }) + + next = %{ + without_replaced + | routes: Map.put(without_replaced.routes, route_id, route), + monitors: Map.put(without_replaced.monitors, monitor, route_id) + } + + peers = visible_peers(next, route) + send(pid, {:route_snapshot, descriptor(route), Enum.map(peers, &descriptor/1)}) + Enum.each(peers, fn peer -> send(peer.pid, {:route_available, descriptor(route)}) end) + {:reply, :ok, next} end end - def handle_call({:unregister, route_id}, _from, state) do - {:reply, :ok, remove_route(state, route_id)} - end + def handle_call({:unregister, route_id}, _from, state), + do: {:reply, :ok, remove_route(state, route_id)} def handle_call( {:forward, source_route_id, destination_route_id, attempt_id, payload}, @@ -99,11 +114,11 @@ defmodule AxlRelay.RouteRegistry do destination == nil -> {:reply, {:error, :destination_offline}, state} - source.installation_id != destination.installation_id -> + source.installation_id != destination.installation_id or source.role == destination.role -> {:reply, {:error, :forbidden_route}, state} destination.queued_bytes + queued_bytes > destination.limits.max_queued_bytes -> - {:reply, {:error, :queue_full}, state} + {:reply, {:error, :queue_full}, mark_saturated(state, destination_route_id)} true -> send( @@ -111,14 +126,15 @@ defmodule AxlRelay.RouteRegistry do {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes} ) - next_state = - put_in( - state, - [:routes, destination_route_id, :queued_bytes], - destination.queued_bytes + queued_bytes - ) + next_bytes = destination.queued_bytes + queued_bytes + next = put_in(state, [:routes, destination_route_id, :queued_bytes], next_bytes) + + next = + if next_bytes >= destination.limits.max_queued_bytes, + do: mark_saturated(next, destination_route_id), + else: next - {:reply, :ok, next_state} + {:reply, :ok, next} end end @@ -130,21 +146,21 @@ defmodule AxlRelay.RouteRegistry do {:reply, :ok, state} else matching = - state.routes - |> Enum.filter(fn {_route_id, route} -> + Enum.filter(state.routes, fn {_route_id, route} -> route.installation_id == notification.installation_id and - (notification.device_id == nil or route.device_id == notification.device_id) + (notification.device_id == nil or route.device_id == notification.device_id) and + route.grant_generation <= notification.generation end) Enum.each(matching, fn {_route_id, route} -> send(route.pid, :route_revoked) end) - next_state = + next = Enum.reduce(matching, state, fn {route_id, _route}, current -> remove_route(current, route_id) end) {:reply, :ok, - %{next_state | generations: Map.put(next_state.generations, key, notification.generation)}} + %{next | generations: Map.put(next.generations, key, notification.generation)}} end end @@ -160,6 +176,8 @@ defmodule AxlRelay.RouteRegistry do %{ installation_id: route.installation_id, device_id: route.device_id, + role: route.role, + grant_generation: route.grant_generation, queued_bytes: route.queued_bytes }} end) @@ -174,32 +192,111 @@ defmodule AxlRelay.RouteRegistry do {:noreply, state} route -> - next_state = - put_in(state, [:routes, route_id, :queued_bytes], max(0, route.queued_bytes - bytes)) + queued = max(0, route.queued_bytes - bytes) + next = put_in(state, [:routes, route_id, :queued_bytes], queued) - {:noreply, next_state} + next = + if queued <= div(route.limits.max_queued_bytes, 2) do + put_in(next, [:routes, route_id, :saturation_token], nil) + else + next + end + + {:noreply, next} end end @impl true - def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do - case Map.pop(state.monitors, monitor) do - {nil, _monitors} -> + def handle_info({:slow_consumer_check, route_id, token}, state) do + case state.routes[route_id] do + %{saturation_token: ^token} = route -> + if route.queued_bytes > div(route.limits.max_queued_bytes, 2) do + send(route.pid, :slow_consumer) + {:noreply, remove_route(state, route_id)} + else + {:noreply, put_in(state, [:routes, route_id, :saturation_token], nil)} + end + + _other -> {:noreply, state} + end + end - {route_id, monitors} -> - {:noreply, %{state | routes: Map.delete(state.routes, route_id), monitors: monitors}} + def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do + case state.monitors[monitor] do + nil -> {:noreply, state} + route_id -> {:noreply, remove_route(state, route_id)} end end - defp remove_route(state, route_id) do + defp mark_saturated(state, route_id) do + case state.routes[route_id] do + nil -> + state + + %{saturation_token: nil} -> + token = make_ref() + + Process.send_after( + self(), + {:slow_consumer_check, route_id, token}, + state.slow_consumer_grace_ms + ) + + put_in(state, [:routes, route_id, :saturation_token], token) + + _route -> + state + end + end + + defp visible_peers(state, route) do + state.routes + |> Map.values() + |> Enum.filter(fn candidate -> + candidate.source_route_id != route.source_route_id and + candidate.installation_id == route.installation_id and candidate.role != route.role + end) + end + + defp descriptor(route) do + %{ + route_id: route.source_route_id, + role: route.role, + device_id: route.device_id + } + end + + defp same_identity?(left, right) do + left.installation_id == right.installation_id and left.role == right.role and + (left.role == :daemon or left.device_id == right.device_id) + end + + defp revoked_generation(state, admission) do + all = Map.get(state.generations, {admission.installation_id, :all}, 0) + device = Map.get(state.generations, {admission.installation_id, admission.device_id}, 0) + max(all, device) + end + + defp remove_route(state, route_id, notify \\ true) do case Map.pop(state.routes, route_id) do {nil, _routes} -> state {route, routes} -> Process.demonitor(route.monitor, [:flush]) - %{state | routes: routes, monitors: Map.delete(state.monitors, route.monitor)} + next = %{state | routes: routes, monitors: Map.delete(state.monitors, route.monitor)} + + notify_unavailable(next, route, notify) + next end end + + defp notify_unavailable(_state, _route, false), do: :ok + + defp notify_unavailable(state, route, true) do + Enum.each(visible_peers(state, route), fn peer -> + send(peer.pid, {:route_unavailable, descriptor(route)}) + end) + end end diff --git a/services/relay/test/frame_test.exs b/services/relay/test/frame_test.exs index 4cf56995..c7aaf953 100644 --- a/services/relay/test/frame_test.exs +++ b/services/relay/test/frame_test.exs @@ -40,7 +40,8 @@ defmodule AxlRelay.FrameTest do rate_limited: 8, queue_full: 9, slow_consumer: 10, - service_unavailable: 11 + service_unavailable: 11, + ticket_revoked: 12 ] for {code, value} <- codes do diff --git a/services/relay/test/internal_contract_test.exs b/services/relay/test/internal_contract_test.exs index 70104d9e..8654fc6c 100644 --- a/services/relay/test/internal_contract_test.exs +++ b/services/relay/test/internal_contract_test.exs @@ -20,10 +20,20 @@ defmodule AxlRelay.InternalContractTest do assert parsed.device_id == result["deviceId"] assert parsed.source_route_id == result["sourceRouteId"] assert parsed.role == :device + assert parsed.grant_generation == result["grantGeneration"] assert parsed.limits.max_frame_bytes == 65_535 assert parsed.limits.max_queued_bytes == 524_288 end + test "accepts the role-filtered discovery fixture" do + snapshot = @fixtures["discovery"]["deviceSnapshot"] + assert snapshot["version"] == 1 + assert snapshot["type"] == "route_snapshot" + assert snapshot["sourceRoute"]["role"] == "device" + assert [%{"role" => "daemon"}] = snapshot["peers"] + refute Map.has_key?(hd(snapshot["peers"]), "deviceId") + end + test "forms the admitted WebSocket message without relay-owned fields" do consume = @fixtures["consumeTicket"]["request"] diff --git a/services/relay/test/route_registry_test.exs b/services/relay/test/route_registry_test.exs index 8c4ed0cf..c5309ad0 100644 --- a/services/relay/test/route_registry_test.exs +++ b/services/relay/test/route_registry_test.exs @@ -24,6 +24,8 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.register(registry, source, %{ installation_id: @installation, device_id: nil, + role: :daemon, + grant_generation: 1, source_route_id: @source, limits: limits }) @@ -32,6 +34,8 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.register(registry, destination, %{ installation_id: @installation, device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, source_route_id: @destination, limits: limits }) @@ -60,7 +64,9 @@ defmodule AxlRelay.RouteRegistryTest do assert :ok = RouteRegistry.register(registry, other, %{ installation_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", - device_id: nil, + device_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + role: :device, + grant_generation: 1, source_route_id: other_route, limits: %{max_queued_bytes: 50} }) @@ -69,6 +75,84 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.forward(registry, @source, other_route, @attempt, <<1>>) end + test "rejects same-role routing and replaces an older device identity", %{registry: registry} do + parent = self() + second_device = spawn_link(fn -> forward_messages(parent, :second_device) end) + second_route = "66666666-6666-4666-8666-666666666666" + + assert :ok = + RouteRegistry.register(registry, second_device, %{ + installation_id: @installation, + device_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + role: :device, + grant_generation: 1, + source_route_id: second_route, + limits: %{max_queued_bytes: 50} + }) + + assert {:error, :forbidden_route} = + RouteRegistry.forward(registry, @destination, second_route, @attempt, <<1>>) + + replacement = spawn_link(fn -> forward_messages(parent, :replacement) end) + replacement_route = "77777777-7777-4777-8777-777777777777" + + assert :ok = + RouteRegistry.register(registry, replacement, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: replacement_route, + limits: %{max_queued_bytes: 50} + }) + + assert_receive {:destination, :route_replaced} + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + assert Map.has_key?(RouteRegistry.snapshot(registry).routes, replacement_route) + end + + test "evicts a queue that remains above half after saturation" do + registry = + start_supervised!( + Supervisor.child_spec( + {RouteRegistry, name: nil, slow_consumer_grace_ms: 10}, + id: make_ref() + ) + ) + + parent = self() + daemon = spawn_link(fn -> forward_messages(parent, :slow_daemon) end) + device = spawn_link(fn -> forward_messages(parent, :slow_device) end) + + assert :ok = + RouteRegistry.register(registry, daemon, %{ + installation_id: @installation, + device_id: nil, + role: :daemon, + grant_generation: 1, + source_route_id: @source, + limits: %{max_queued_bytes: 50} + }) + + assert :ok = + RouteRegistry.register(registry, device, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: @destination, + limits: %{max_queued_bytes: 50} + }) + + assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + assert {:error, :queue_full} = + RouteRegistry.forward(registry, @source, @destination, @attempt, <<4>>) + + assert_receive {:slow_device, :slow_consumer}, 100 + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + end + test "revocation closes matching routes and draining rejects admission", %{registry: registry} do assert :ok = RouteRegistry.revoke(registry, %{ @@ -80,6 +164,16 @@ defmodule AxlRelay.RouteRegistryTest do assert_receive {:destination, :route_revoked} refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + assert {:error, :ticket_revoked} = + RouteRegistry.register(registry, self(), %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: "88888888-8888-4888-8888-888888888888", + limits: %{max_queued_bytes: 50} + }) + assert :ok = RouteRegistry.drain(registry) assert_receive {:source, :relay_draining} @@ -87,6 +181,8 @@ defmodule AxlRelay.RouteRegistryTest do RouteRegistry.register(registry, self(), %{ installation_id: @installation, device_id: nil, + role: :daemon, + grant_generation: 1, source_route_id: "55555555-5555-4555-8555-555555555555", limits: %{max_queued_bytes: 50} }) diff --git a/services/relay/test/websocket_relay_test.exs b/services/relay/test/websocket_relay_test.exs index 8f0c5bf7..35ecdc21 100644 --- a/services/relay/test/websocket_relay_test.exs +++ b/services/relay/test/websocket_relay_test.exs @@ -4,7 +4,7 @@ defmodule AxlRelay.WebSocketRelayTest do use ExUnit.Case, async: false - alias AxlRelay.{Frame, Listener, RouteRegistry} + alias AxlRelay.{Connection, Frame, Listener, RouteRegistry} @daemon_route "11111111-1111-4111-8111-111111111111" @device_route "22222222-2222-4222-8222-222222222222" @@ -17,6 +17,24 @@ defmodule AxlRelay.WebSocketRelayTest do def consume_ticket(%{"ticket" => "unavailable"}, _relay_instance_id, _options), do: {:error, :service_unavailable} + def consume_ticket(%{"ticket" => "half-open"}, _relay_instance_id, options) do + {:ok, + %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + source_route_id: Keyword.fetch!(options, :device), + role: :device, + grant_generation: 1, + lease_expires_at: System.system_time(:millisecond) + 60_000, + limits: %{ + max_frame_bytes: 65_535, + max_queued_bytes: 524_288, + heartbeat_interval_ms: 10, + idle_timeout_ms: 30 + } + }} + end + def consume_ticket(%{"ticket" => ticket}, _relay_instance_id, options) when ticket in ["daemon", "device"] do role = if ticket == "daemon", do: :daemon, else: :device @@ -32,6 +50,7 @@ defmodule AxlRelay.WebSocketRelayTest do ), source_route_id: route_id, role: role, + grant_generation: 1, lease_expires_at: System.system_time(:millisecond) + 60_000, limits: %{ max_frame_bytes: 65_535, @@ -84,6 +103,9 @@ defmodule AxlRelay.WebSocketRelayTest do assert_eventually(fn -> map_size(RouteRegistry.snapshot(registry).routes) == 2 end) + expect_discovered_peer(daemon, @daemon_route, "device", @device_route) + expect_discovered_peer(device, @device_route, "daemon", @daemon_route) + assert {:ok, send_frame} = Frame.encode(%{ kind: :send, @@ -113,6 +135,34 @@ defmodule AxlRelay.WebSocketRelayTest do :gen_tcp.close(daemon) end + test "closes a half-open connection after the explicit inbound idle deadline" do + registry = + start_supervised!(Supervisor.child_spec({RouteRegistry, name: nil}, id: make_ref())) + + {:ok, state} = + Connection.init( + control_plane: FakeControlPlane, + control_plane_options: [device: @device_route], + relay_instance_id: "relay-test", + registry: registry + ) + + admission = + :json.encode(%{ + "version" => 1, + "ticket" => "half-open", + "connectionNonce" => "fixture-nonce", + "possessionProof" => "AAECA/8=" + }) + |> IO.iodata_to_binary() + + assert {:ok, active} = Connection.handle_in({admission, opcode: :binary}, state) + Process.sleep(35) + + assert {:stop, :normal, {1008, "idle_timeout"}, _state} = + Connection.handle_info(:heartbeat, active) + end + test "fails admission closed when the control plane is unavailable", %{ registry: registry, port: port @@ -133,6 +183,8 @@ defmodule AxlRelay.WebSocketRelayTest do RouteRegistry.register(registry, self(), %{ installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, source_route_id: route, limits: %{max_queued_bytes: 524_288} }) @@ -217,6 +269,28 @@ defmodule AxlRelay.WebSocketRelayTest do <<0x82, encoded_length::binary, mask::binary, masked::binary>> end + defp expect_discovered_peer(socket, source_route, peer_role, peer_route) do + assert %{ + "type" => "route_snapshot", + "sourceRoute" => %{"routeId" => ^source_route}, + "peers" => peers + } = receive_json_message(socket) + + if peers == [] do + assert %{ + "type" => "route_available", + "peers" => [%{"role" => ^peer_role, "routeId" => ^peer_route}] + } = + receive_json_message(socket) + else + assert [%{"role" => ^peer_role, "routeId" => ^peer_route}] = peers + end + end + + defp receive_json_message(socket) do + socket |> receive_binary_frame() |> :json.decode() + end + defp receive_binary_frame(socket) do {:ok, <<0x82, length>>} = :gen_tcp.recv(socket, 2, 2_000)