Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"**/runner-release",
"**/runner-release/**"
],
"message": "src/harness is pure: the runner-release verifier (Node crypto) imports it, never the other way."
"message": "src/harness is pure: src/runner-release imports it, never the other way."
}
]
}
Expand Down Expand Up @@ -220,7 +220,7 @@
"**/runner-release",
"**/runner-release/**"
],
"message": "Runner releases are verified in the main process (src/runner-release); the renderer and preload never import it."
"message": "src/runner-release uses Node built-ins (the main process and the runner import it); the renderer and preload never do."
}
]
}
Expand Down Expand Up @@ -257,7 +257,7 @@
"**/channel",
"**/channel/**"
],
"message": "src/runner-release may import only src/harness, itself and node:crypto."
"message": "src/runner-release may import only src/harness, itself and the Node built-ins .eslintrc.json allows it (no child_process: nothing here runs a package)."
}
]
}
Expand All @@ -266,7 +266,11 @@
"error",
{
"allow": [
"node:crypto"
"node:crypto",
"node:fs",
"node:path",
"node:stream",
"node:zlib"
]
}
]
Expand Down
5 changes: 5 additions & 0 deletions scripts/package-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
* config.sh run.sh svc.sh VERSION README.md LICENSE
* bin/node bin/node.LICENSE bin/puck-runner.cjs
*
* That layout, with its modes, is RUNNER_PACKAGE_ENTRIES in
* src/harness/runner-releases.ts: the entries below must match it, since
* src/runner-release/archive.ts unpacks nothing else (a unit test reads
* this script's output through it).
*
* The Node runtime is the pinned release below, downloaded once into
* .cache/runner-node/ and checked against the pinned sha256 before use.
* Archives are written by the runner's own ustar writer with fixed owners
Expand Down
120 changes: 120 additions & 0 deletions src/harness/runner-releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
* never re-serialises a manifest to check it. The manifest names SHA256SUMS
* by digest, so the two agree or the release is refused.
*
* A signed release travels through other documents (a server's listing)
* as a record carrying the manifest's exact bytes and its signature, both
* in standard base64 (`SignedRunnerRelease`). The reader here checks the
* encoding and its bounds; the bytes go to the verifier unchanged.
*
* Each package unpacks into the fixed layout `RUNNER_PACKAGE_ENTRIES`, as
* scripts/package-runner.mjs writes it; src/runner-release/archive.ts
* accepts nothing else.
*
* Only erasable TypeScript here: the build scripts load this file with
* Node's type stripping.
*/
Expand All @@ -39,6 +48,10 @@ export const ED25519_SIGNATURE_BYTES = 64;
export const MAX_MANIFEST_BYTES = 256 * 1024;
/** Most bytes a SHA256SUMS file may have. */
export const MAX_SUMS_BYTES = 16 * 1024;
/** Most signed releases one listing carries. */
export const MAX_RELEASE_RECORDS = 16;
/** Most bytes a whole release listing may have on the wire; readers refuse a larger body before parsing it. */
export const MAX_RELEASE_METADATA_BYTES = 4 * 1024 * 1024;

/**
* How a runner build treats packages, compiled in by scripts/build-runner.mjs.
Expand Down Expand Up @@ -74,6 +87,45 @@ export function runnerPackageFile(target: RunnerTarget, version: string): string
return `puck-runner-${target.os}-${target.arch}-${version}.tar.gz`;
}

/** The Git tag of a release, and so the GitHub Release its assets hang off (RELEASE.md). */
export const runnerReleaseTag = (version: string): string => `v${version}`;

/**
* The one mapping from Node's platform and architecture names to the
* manifest's: `darwin` is `macos` in package names. Null for anything
* runners do not run on.
*/
export function runnerTargetFor(platform: string, arch: string): RunnerTarget | null {
const os: RunnerOs | null = platform === 'linux' ? 'linux' : platform === 'darwin' ? 'macos' : null;
const a: RunnerArch | null = arch === 'x64' || arch === 'arm64' ? arch : null;
return os && a ? { os, arch: a } : null;
}

export interface RunnerPackageEntry {
/** Relative path; a directory's ends with '/'. */
name: string;
type: 'file' | 'dir';
/** The permission bits the packager writes and the archive reader restores; never setuid, setgid or sticky. */
mode: number;
}

/**
* What a package holds, in the order scripts/package-runner.mjs writes it:
* nine regular files and `bin/`, nothing else, with these exact modes.
*/
export const RUNNER_PACKAGE_ENTRIES: readonly RunnerPackageEntry[] = [
{ name: 'config.sh', type: 'file', mode: 0o755 },
{ name: 'run.sh', type: 'file', mode: 0o755 },
{ name: 'svc.sh', type: 'file', mode: 0o755 },
{ name: 'VERSION', type: 'file', mode: 0o644 },
{ name: 'README.md', type: 'file', mode: 0o644 },
{ name: 'LICENSE', type: 'file', mode: 0o644 },
{ name: 'bin/', type: 'dir', mode: 0o755 },
{ name: 'bin/node', type: 'file', mode: 0o755 },
{ name: 'bin/node.LICENSE', type: 'file', mode: 0o644 },
{ name: 'bin/puck-runner.cjs', type: 'file', mode: 0o644 },
];

export interface RunnerReleaseAsset {
os: RunnerOs;
arch: RunnerArch;
Expand Down Expand Up @@ -313,3 +365,71 @@ export function readVersionProbe(text: string): RunnerVersionProbe {
if (!isPositiveSafeInteger(runnerProtocol)) throw malformed(`The runner reports protocol ${JSON.stringify(runnerProtocol)}.`);
return { version, trustMode, runnerProtocol };
}

/**
* A signed release as a listing carries it: runner-release.json's exact
* bytes and its detached signature, each standard base64 with padding.
*/
export interface SignedRunnerRelease {
manifest: string;
signature: string;
}

/** Base64 text of a maximal manifest: the byte bound, accounting for the encoding. */
export const MAX_MANIFEST_BASE64_CHARS = Math.ceil(MAX_MANIFEST_BYTES / 3) * 4;
const SIGNATURE_BASE64_CHARS = Math.ceil(ED25519_SIGNATURE_BYTES / 3) * 4;
const BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const RECORD_KEYS = ['manifest', 'signature'] as const;

/**
* Decodes canonical base64: the standard alphabet with padding, no
* whitespace, and exactly one encoding of the bytes (a re-encoding must
* give the text back). `maxChars` bounds the text before it is decoded.
*/
function decodeBase64(value: unknown, maxChars: number, what: string): Uint8Array {
if (typeof value !== 'string') throw malformed(`${what} must be a base64 string.`);
if (value.length > maxChars) throw malformed(`${what} is over ${maxChars} characters of base64.`);
if (!BASE64_RE.test(value)) throw malformed(`${what} is not standard base64.`);
const binary = atob(value);
if (btoa(binary) !== value) throw malformed(`${what} is not canonical base64.`);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}

/** Standard base64 with padding, the encoding readSignedRunnerRelease accepts. */
function encodeBase64(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x2000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x2000));
return btoa(binary);
}

/**
* Reads one signed release record strictly: exactly its two fields, the
* manifest at most MAX_MANIFEST_BYTES once decoded and the signature
* exactly ED25519_SIGNATURE_BYTES. The bytes come back as sent, for the
* verifier (src/runner-release/verify.ts); nothing here trusts them.
*/
export function readSignedRunnerRelease(value: unknown): { manifest: Uint8Array; signature: Uint8Array } {
if (!isObject(value)) throw malformed('A signed release record must be an object.');
exactKeys(value, RECORD_KEYS, 'A signed release record');
const manifest = decodeBase64(value.manifest, MAX_MANIFEST_BASE64_CHARS, "A signed release record's manifest");
if (manifest.length > MAX_MANIFEST_BYTES) throw malformed(`A signed release record's manifest is over ${MAX_MANIFEST_BYTES} bytes.`);
const signature = decodeBase64(value.signature, SIGNATURE_BASE64_CHARS, "A signed release record's signature");
if (signature.length !== ED25519_SIGNATURE_BYTES) {
throw malformed(`A signed release record's signature is ${signature.length} bytes, not ${ED25519_SIGNATURE_BYTES}.`);
}
return { manifest, signature };
}

/** Reads a listing's records: an array of at most MAX_RELEASE_RECORDS, each read strictly. */
export function readSignedRunnerReleases(value: unknown): { manifest: Uint8Array; signature: Uint8Array }[] {
if (!Array.isArray(value)) throw malformed('Signed release records must be an array.');
if (value.length > MAX_RELEASE_RECORDS) throw malformed(`A listing carries at most ${MAX_RELEASE_RECORDS} signed releases; this one has ${value.length}.`);
return value.map(readSignedRunnerRelease);
}

/** The record for a manifest's exact bytes and its signature. */
export function formatSignedRunnerRelease(manifest: Uint8Array, signature: Uint8Array): SignedRunnerRelease {
return { manifest: encodeBase64(manifest), signature: encodeBase64(signature) };
}
4 changes: 3 additions & 1 deletion src/main/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* read leniently (src/harness/server-api.ts).
*/

import { MAX_RELEASE_METADATA_BYTES } from '../../harness/runner-releases';
import {
readInstance,
readReleases,
Expand Down Expand Up @@ -103,6 +104,7 @@ export async function forgetInstance(envId: string): Promise<void> {
await authed('DELETE', `/v1/instances/${encodeURIComponent(envId)}`);
}

/** The server's runner releases, read within the listing bound (src/runner-release/download.ts). */
export async function releases(): Promise<RunnerReleases> {
return readReleases(await serverRequest('GET', '/v1/runner/releases'));
return readReleases(await serverRequest('GET', '/v1/runner/releases', { maxBodyBytes: MAX_RELEASE_METADATA_BYTES, refuseContentEncoding: false }));
}
21 changes: 19 additions & 2 deletions src/main/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { shell } from 'electron';
import { readBoundedBody } from '../../runner-release/download';

export const SERVER_URL_ENV = 'PUCK_SERVER_URL';
export const DEFAULT_SERVER_URL = 'http://localhost:8765';
Expand Down Expand Up @@ -80,9 +81,22 @@ export interface RequestOptions {
body?: unknown;
token?: string;
timeoutMs?: number;
/**
* Bounds the answer through the runner-release transport: a longer body
* is refused before parsing. Release listings set it; other requests
* read as before.
*/
maxBodyBytes?: number;
/** With maxBodyBytes. Listings pass false and keep a content encoding. */
refuseContentEncoding?: boolean;
}

/** One JSON request; resolves with the parsed body (null for 204), throws ServerApiError otherwise. */
/**
* One JSON request; resolves with the parsed body (null for 204).
* A non-2xx answer is ServerApiError, and an unreachable server is
* ServerUnreachableError. A bounded body (`maxBodyBytes`) throws
* RunnerDownloadError from the runner-release transport before parsing.
*/
export async function serverRequest<T = Record<string, unknown>>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
const base = serverUrl();
const headers: Record<string, string> = { Accept: 'application/json' };
Expand All @@ -99,7 +113,10 @@ export async function serverRequest<T = Record<string, unknown>>(method: string,
} catch (err) {
throw new ServerUnreachableError(base, err);
}
const text = await res.text().catch(() => '');
const text =
opts.maxBodyBytes === undefined
? await res.text().catch(() => '')
: new TextDecoder().decode(await readBoundedBody(res, opts.maxBodyBytes, { refuseContentEncoding: opts.refuseContentEncoding }));
let parsed: unknown = null;
if (text) {
try {
Expand Down
15 changes: 11 additions & 4 deletions src/puck-runner/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
*
* Errors carry the server's status and code. Two codes mean "stop and act":
* `runner-removed` (403: the runner was removed; exit for good) and
* `runner-outdated` (426: update first).
* `runner-outdated` (426: update first). A release listing over the bound
* throws RunnerDownloadError (src/runner-release/download.ts) before parsing.
*/

import { Readable } from 'node:stream';
import type { GithubGrant } from '../harness/daemon-protocol';
import { MAX_RELEASE_METADATA_BYTES } from '../harness/runner-releases';
import { readBoundedBody } from '../runner-release/download';
import { signAssertion, type RunnerKey } from './identity';

const REQUEST_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -92,7 +95,8 @@ export class ServerApi {
private readonly fetchImpl: Fetch = fetch,
) {}

private async call<T>(method: string, path: string, opts: { body?: unknown; token?: string } = {}): Promise<T> {
/** `maxBytes` bounds the answer through the runner-release transport; a longer body is refused unparsed. Release listings set it. */
private async call<T>(method: string, path: string, opts: { body?: unknown; token?: string; maxBytes?: number; refuseContentEncoding?: boolean } = {}): Promise<T> {
let res: Response;
try {
res = await this.fetchImpl(this.baseUrl + path, {
Expand All @@ -107,7 +111,10 @@ export class ServerApi {
} catch (err) {
throw new ApiError(0, 'unreachable', `Cannot reach the Puck server at ${this.baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
}
const text = await res.text();
const text =
opts.maxBytes === undefined
? await res.text()
: new TextDecoder().decode(await readBoundedBody(res, opts.maxBytes, { refuseContentEncoding: opts.refuseContentEncoding }));
let body: Record<string, unknown> = {};
try {
body = text ? (JSON.parse(text) as Record<string, unknown>) : {};
Expand Down Expand Up @@ -147,7 +154,7 @@ export class ServerApi {
}

releases(): Promise<Releases> {
return this.call('GET', '/v1/runner/releases');
return this.call('GET', '/v1/runner/releases', { maxBytes: MAX_RELEASE_METADATA_BYTES, refuseContentEncoding: false });
}

/** Streams a download; only URLs on this server are fetched. */
Expand Down
6 changes: 3 additions & 3 deletions src/puck-runner/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import * as fs from 'node:fs';
import * as os from 'node:os';
import { runnerTargetFor } from '../harness/runner-releases';
import { ApiError, RunnerRemovedError, ServerApi, type Fetch, type RegisterResponse } from './api';
import type { DockerRunner } from './docker/client';
import { dockerHealth } from './docker/health';
Expand Down Expand Up @@ -62,10 +63,9 @@ export interface Platform {
arch: 'x64' | 'arm64';
}

/** This machine as a package target (the one mapping is runnerTargetFor in src/harness/runner-releases.ts). */
export function currentPlatform(platform: NodeJS.Platform = process.platform, arch: string = process.arch): Platform | null {
const o = platform === 'linux' ? 'linux' : platform === 'darwin' ? 'macos' : null;
const a = arch === 'x64' || arch === 'arm64' ? arch : null;
return o && a ? { os: o, arch: a } : null;
return runnerTargetFor(platform, arch);
}

const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._()-]{0,63}$/;
Expand Down
Loading
Loading