feat: Install a host with one pasted command (#351)

Lets a person put a machine on their team by pasting one command from
the dashboard, instead of copying a user session onto the machine.

## What changes

- **Install tokens** (`packages/core/src/machine/install-token.*`,
migration `0017`). Issued for one team, spent by their first use, expire
after an hour, stored only as a SHA-256 digest. Redeeming is one
conditional `UPDATE` inside the registration transaction: concurrent
redemptions of one token register exactly one machine, and a failed
registration leaves the token unspent.
- **`POST /machine/install-token`** (session): mints a token for a team
the caller belongs to, with the same team resolution and membership rule
as `/register`.
- **`POST /machine/install`** (public): spends a token and returns the
same id, slug and one-time secret as `/register`. Unknown, expired and
used tokens are refused identically.
- **`GET /install.sh`**: a POSIX script embedded at build time. It
checks the platform, asks where box images should live, downloads the
host agent at a pinned version, verifies `SHA256SUMS`, installs to
`~/.local/bin` for the calling user (never root), and hands over with
the token in the environment rather than argv.
- **`GET /install/:component/:version/:asset`**: redirects to a
one-minute signed URL on a private S3-compatible bucket, so every
download passes through a route that can be logged or turned off. The
SigV4 signer uses Web Crypto and is tested against AWS's published
example.

## Configuration

New optional settings: `RELEASES_BUCKET`, `RELEASES_ENDPOINT`,
`RELEASES_REGION`, `RELEASES_ACCESS_KEY_ID`,
`RELEASES_SECRET_ACCESS_KEY`. Without them, downloads return `503` and
everything else is unaffected. Scope the key to reads on that one
bucket.

## Tested

- Core: single use, a five-way race registering exactly one machine,
expired and unknown tokens refused alike.
- Signer: matches the AWS example signature.
- End to end on a real Linux host against a local stack: install script,
signed download from an S3 store that verifies SigV4 (a tampered
signature is refused), registration, and the agent coming online.
This commit is contained in:
Wanjohi
2026-09-28 08:07:04 +00:00
committed by GitHub
16 changed files with 4383 additions and 124 deletions
+2
View File
@@ -14,6 +14,7 @@ import { BillingApi } from './routes/billing.js';
import { EnrolmentApi } from './routes/enrolment.js'; import { EnrolmentApi } from './routes/enrolment.js';
import { GameApi } from './routes/game.js'; import { GameApi } from './routes/game.js';
import { IndexApi } from './routes/index.js'; import { IndexApi } from './routes/index.js';
import { InstallApi } from './routes/install.js';
import { LibraryApi } from './routes/library.js'; import { LibraryApi } from './routes/library.js';
import { MachineApi } from './routes/machine.js'; import { MachineApi } from './routes/machine.js';
import { OrganisationApi } from './routes/organisation.js'; import { OrganisationApi } from './routes/organisation.js';
@@ -40,6 +41,7 @@ app
const routes = app const routes = app
.route('/', IndexApi.route) .route('/', IndexApi.route)
.route('/', InstallApi.route)
.route('/user', UserApi.route) .route('/user', UserApi.route)
.route('/steam', SteamApi.route) .route('/steam', SteamApi.route)
.route('/library', LibraryApi.route) .route('/library', LibraryApi.route)
+81
View File
@@ -0,0 +1,81 @@
import { Env } from '@nestri/core/env';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
// The installer, embedded at build time so the script and the API that redeems
// its token always ship as one version.
import script from '../../install/install.sh' with { type: 'text' };
import { presignGet } from '../utils/presign';
/**
* The host installer and the binaries it downloads.
*
* The bucket behind these is never public. A download is answered with a
* one-minute signed URL for exactly the object asked for, so every download
* passes through here, where it can be logged, rate-limited or switched off.
*/
export namespace InstallApi {
/** What may be downloaded: one component, versions and asset names by shape. */
const COMPONENTS = new Set(['host']);
const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
const ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const SIGNED_SECONDS = 60;
export const route = new Hono()
.get(
'/install.sh',
describeRoute({
tags: ['Install'],
summary: 'The host installer',
description:
'A POSIX shell script that installs the host agent for the calling user and registers the machine with a one-time install token. Pipe it to `sh -s -- <token>`.',
responses: { 200: { description: 'The script' } }
}),
(c) =>
c.body(script, 200, {
'content-type': 'text/x-shellscript; charset=utf-8',
'cache-control': 'no-cache'
})
)
.get(
'/install/:component/:version/:asset',
describeRoute({
tags: ['Install'],
summary: 'Download an installable binary',
description:
'Redirects to a short-lived signed URL for one release asset. Used by the installer.',
responses: {
302: { description: 'Where to download it' },
404: { description: 'No such asset' }
}
}),
async (c) => {
const { component, version, asset } = c.req.param();
if (!COMPONENTS.has(component) || !VERSION.test(version) || !ASSET.test(asset)) {
return c.notFound();
}
const env = Env.get();
if (
!env.RELEASES_BUCKET ||
!env.RELEASES_ENDPOINT ||
!env.RELEASES_ACCESS_KEY_ID ||
!env.RELEASES_SECRET_ACCESS_KEY
) {
return c.json({ message: 'Downloads are not configured on this deployment.' }, 503);
}
const url = await presignGet(
{
endpoint: env.RELEASES_ENDPOINT,
bucket: env.RELEASES_BUCKET,
region: env.RELEASES_REGION,
accessKeyId: env.RELEASES_ACCESS_KEY_ID,
secretAccessKey: env.RELEASES_SECRET_ACCESS_KEY
},
`${component}/${version}/${asset}`,
SIGNED_SECONDS
);
return c.redirect(url, 302);
}
);
}
+122
View File
@@ -4,6 +4,7 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples'; import { Examples } from '@nestri/core/examples';
import { Identifier } from '@nestri/core/id'; import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index'; import { Machine } from '@nestri/core/machine/index';
import { InstallToken } from '@nestri/core/machine/install-token';
import { Organisation } from '@nestri/core/organisation/index'; import { Organisation } from '@nestri/core/organisation/index';
import { Team } from '@nestri/core/team/index'; import { Team } from '@nestri/core/team/index';
import { Member } from '@nestri/core/team/member'; import { Member } from '@nestri/core/team/member';
@@ -167,6 +168,127 @@ export namespace MachineApi {
}); });
} }
) )
.post(
'/install-token',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'Issue an install token',
description:
'Mint a one-time token that registers one host to a team when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
token: z
.string()
.meta({ description: 'Shown once. Pass it to the installer.' }),
expiresAt: z.iso.datetime()
})
)
}
},
description: 'A token for one host'
},
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
validator(
'json',
z.object({
teamId: z.string().optional().meta({
description: 'Team the host will belong to. Defaults to the caller\u2019s personal team'
})
})
),
async (c) => {
const { teamId } = c.req.valid('json');
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Issuing an install token requires a user session'
);
}
// Same resolution and the same membership rule as `/register`: a
// token is a deferred registration, so it may not reach a team the
// caller could not register into directly.
const owningTeam =
teamId ??
(actor.type === 'member'
? actor.properties.teamID
: await Team.ensurePersonal({ displayName: Actor.userID }));
if (teamId) {
const membership = await Member.findByTeamAndUser({ teamId, userId: Actor.userID });
if (!membership) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You are not a member of that team'
);
}
}
const issued = await InstallToken.create({ teamId: owningTeam, userId: Actor.userID });
return c.json({
data: { token: issued.token, expiresAt: issued.expiresAt.toISOString() }
});
}
)
.post(
'/install',
describeRoute({
tags: ['Machine'],
summary: 'Register a host with an install token',
description:
'Spend an install token and register the calling host to the team it was issued for. Needs no session: the token is the authority. The response is the same as registering directly, and the secret is likewise returned once.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
machineId: z.string().meta({ example: Examples.Machine.id }),
slug: z.string().meta({ example: Examples.Machine.slug }),
secret: z.string()
})
)
}
},
description: 'The host is registered'
},
401: ErrorResponses[401]
}
}),
validator(
'json',
z.object({
token: z.string().min(1),
label: z.string().min(1).max(64).meta({
description: 'Human-readable name for the host',
example: Examples.Machine.label
})
})
),
async (c) => {
const { token, label } = c.req.valid('json');
const registered = await InstallToken.redeem({ token, label });
if (!registered) {
// One answer for unknown, expired and already used.
throw new VisibleError(
'authentication',
ErrorCodes.Authentication.INVALID_TOKEN,
'This install token is not valid. Copy a fresh command from your dashboard.'
);
}
return c.json({ data: registered });
}
)
.patch( .patch(
'/:id', '/:id',
notPublic, notPublic,
+5
View File
@@ -0,0 +1,5 @@
// Files imported `with { type: 'text' }` arrive as their contents.
declare module '*.sh' {
const text: string;
export default text;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* A presigned S3 GET, by hand: AWS Signature Version 4 in query-string form.
*
* Written against Web Crypto rather than an SDK so it runs the same under
* every runtime this API is deployed on, and because a GET presign is the whole
* of what is needed — a dependency the size of an S3 client for one signature
* is a dependency the size of an S3 client.
*
* Path-style URLs (`<endpoint>/<bucket>/<key>`), which every S3-compatible
* store accepts and which need no DNS per bucket.
*/
const enc = new TextEncoder();
function hex(buf: ArrayBuffer): string {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
async function sha256(s: string): Promise<string> {
return hex(await crypto.subtle.digest('SHA-256', enc.encode(s)));
}
async function hmac(key: ArrayBuffer | Uint8Array, s: string): Promise<ArrayBuffer> {
const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, [
'sign'
]);
return crypto.subtle.sign('HMAC', k, enc.encode(s));
}
/** RFC 3986 encoding, which is what SigV4 means by "URI-encode". */
function rfc3986(s: string): string {
return encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
);
}
export type Bucket = {
endpoint: string;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
/** `<bucket>.<endpoint>/<key>` instead of `<endpoint>/<bucket>/<key>`. */
virtualHost?: boolean;
};
export async function presignGet(
b: Bucket,
key: string,
expiresSeconds: number,
now: Date = new Date()
): Promise<string> {
const endpoint = new URL(b.endpoint);
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
const day = amzDate.slice(0, 8);
const scope = `${day}/${b.region}/s3/aws4_request`;
const host = b.virtualHost ? `${b.bucket}.${endpoint.host}` : endpoint.host;
const encodedKey = key.split('/').map(rfc3986).join('/');
const path = b.virtualHost ? `/${encodedKey}` : `/${rfc3986(b.bucket)}/${encodedKey}`;
const query: [string, string][] = [
['X-Amz-Algorithm', 'AWS4-HMAC-SHA256'],
['X-Amz-Credential', `${b.accessKeyId}/${scope}`],
['X-Amz-Date', amzDate],
['X-Amz-Expires', String(expiresSeconds)],
['X-Amz-SignedHeaders', 'host']
];
const canonicalQuery = query
.map(([k, v]) => [rfc3986(k), rfc3986(v)] as const)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`)
.join('&');
const canonicalRequest = [
'GET',
path,
canonicalQuery,
`host:${host}\n`,
'host',
'UNSIGNED-PAYLOAD'
].join('\n');
const toSign = ['AWS4-HMAC-SHA256', amzDate, scope, await sha256(canonicalRequest)].join('\n');
let k = await hmac(enc.encode(`AWS4${b.secretAccessKey}`), day);
k = await hmac(k, b.region);
k = await hmac(k, 's3');
k = await hmac(k, 'aws4_request');
const signature = hex(await hmac(k, toSign));
return `${endpoint.protocol}//${host}${path}?${canonicalQuery}&X-Amz-Signature=${signature}`;
}
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env sh
# Nestri host installer — https://api.nestri.io/install.sh
#
# This file is the source of what that URL serves, kept in the public
# repository so anyone about to pipe it into a shell can read it first.
#
# curl -fsSL https://api.nestri.io/install.sh | sh -s -- <install-token>
#
# What it does, in order: check this is 64-bit Linux, ask where box images
# should live, download the host agent for this platform, verify it against the
# published SHA256SUMS, install it to ~/.local/bin, and hand over to
# the agent's own onboarding, which checks the machine, registers it with the
# token and starts the agent as a systemd user service. It never asks for sudo: the agent
# runs as the user who ran this.
#
# The token comes from the dashboard's Installation page. It registers one
# machine, works once and lapses after an hour.
set -eu
API="${NESTRI_API:-https://api.nestri.io}"
# Pinned, not "latest", so the script and the binary it installs are a pair
# somebody chose. Bump when cutting a release; NESTRI_HOST_VERSION overrides it.
DEFAULT_VERSION="0.1.0"
VERSION="${NESTRI_HOST_VERSION:-$DEFAULT_VERSION}"
BIN_DIR="${NESTRI_BIN_DIR:-$HOME/.local/bin}"
say() { printf '%s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
TOKEN="${1:-${NESTRI_INSTALL_TOKEN:-}}"
[ -n "$TOKEN" ] || die "no install token. Copy the full command from the dashboard's Installation page."
# --- platform ---------------------------------------------------------------
[ "$(uname -s)" = Linux ] || die "a host has to run Linux (with KVM); this is $(uname -s)."
case "$(uname -m)" in
x86_64|amd64) target=x86_64-unknown-linux-musl ;;
*) die "no host build for $(uname -m) yet." ;;
esac
[ "$(id -u)" -ne 0 ] || die "run this as the user that will run boxes, not as root."
# --- fetch ------------------------------------------------------------------
if command -v curl >/dev/null 2>&1; then
get() { curl -fsSL "$1" -o "$2"; }
elif command -v wget >/dev/null 2>&1; then
get() { wget -qO "$2" "$1"; }
else
die "need curl or wget"
fi
# --- where box images go ----------------------------------------------------
# Their own device, xfs or ext4, and never `/`: box images are large, and a
# filled root filesystem takes the whole machine down with it. The agent's
# preflight checks this again; asking here is so the default is a good guess.
tty_ok() { [ -e /dev/tty ] && (exec 3</dev/tty) 2>/dev/null; }
BOX_STORE="${NESTRI_BOX_STORE:-}"
if [ -z "$BOX_STORE" ]; then
guess="$(df -P -T -x tmpfs -x devtmpfs -x overlay 2>/dev/null \
| awk 'NR>1 && ($2=="xfs"||$2=="ext4") && $7!="/" && $7!~/^\/(boot|efi)/ {print $5, $7}' \
| sort -rn | awk 'NR==1 {print $2}')"
default="${guess:+$guess/nestri}"
if tty_ok; then
printf 'Where should box images go? (xfs or ext4, not /) [%s]: ' "${default:-none found}" >&2
read -r answer </dev/tty || answer=""
BOX_STORE="${answer:-$default}"
else
BOX_STORE="$default"
fi
[ -n "$BOX_STORE" ] || die "no xfs or ext4 filesystem besides / was found. Mount one, or set NESTRI_BOX_STORE."
fi
# --- download and verify ----------------------------------------------------
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT INT TERM
ASSET="nestri-host-$target"
BASE="$API/install/host/$VERSION"
say "Downloading the host agent $VERSION ($target)…"
get "$BASE/$ASSET" "$TMP/$ASSET" || die "download failed: $BASE/$ASSET"
# A checksum fetched from the same place as the binary is not a security
# boundary. It catches a truncated or corrupted download, which is the failure
# that actually happens; the download itself is over TLS from our API.
get "$BASE/SHA256SUMS" "$TMP/SHA256SUMS" || die "no SHA256SUMS for $VERSION"
want="$(grep -F " $ASSET" "$TMP/SHA256SUMS" | cut -d' ' -f1 | head -n1)"
[ -n "$want" ] || die "no checksum for $ASSET in SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
have="$(sha256sum "$TMP/$ASSET" | cut -d' ' -f1)"
else
have="$(shasum -a 256 "$TMP/$ASSET" | cut -d' ' -f1)"
fi
[ "$have" = "$want" ] || die "checksum mismatch — not installing
expected $want
got $have"
say "Checksum OK."
mkdir -p "$BIN_DIR"
chmod +x "$TMP/$ASSET"
mv "$TMP/$ASSET" "$BIN_DIR/nestri-host"
say "Installed $BIN_DIR/nestri-host"
say ""
# --- onboard ----------------------------------------------------------------
# The token goes through the environment rather than argv, so it is not in
# `ps` for the length of the run.
export NESTRI_INSTALL_TOKEN="$TOKEN" NESTRI_BOX_STORE="$BOX_STORE" NESTRI_API="$API"
if tty_ok; then
exec "$BIN_DIR/nestri-host" onboard </dev/tty
else
exec "$BIN_DIR/nestri-host" onboard
fi
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test';
import { presignGet } from '../app/utils/presign';
describe('presignGet', () => {
// The example in AWS's SigV4 query-string documentation, which publishes the
// signature it must produce. If this passes, every other signature is the
// same arithmetic with different inputs.
test('matches the published AWS example', async () => {
const url = await presignGet(
{
endpoint: 'https://s3.amazonaws.com',
bucket: 'examplebucket',
region: 'us-east-1',
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
virtualHost: true
},
'test.txt',
86400,
new Date('2013-05-24T00:00:00Z')
);
expect(url).toContain('https://examplebucket.s3.amazonaws.com/test.txt?');
expect(url).toEndWith(
'X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404'
);
});
test('path style puts the bucket in the path', async () => {
const url = await presignGet(
{
endpoint: 'https://objects.example.net',
bucket: 'releases',
region: 'europe-1',
accessKeyId: 'k',
secretAccessKey: 's'
},
'host/0.1.0/SHA256SUMS',
60
);
expect(url).toStartWith('https://objects.example.net/releases/host/0.1.0/SHA256SUMS?');
expect(url).toContain('X-Amz-Expires=60');
});
});
+2
View File
@@ -9,6 +9,8 @@
"$schema": "node_modules/wrangler/config-schema.json", "$schema": "node_modules/wrangler/config-schema.json",
"name": "nestri-api", "name": "nestri-api",
"main": "app/index.ts", "main": "app/index.ts",
// The installer is imported as text and served at /install.sh.
"rules": [{ "type": "Text", "globs": ["**/*.sh"], "fallthrough": false }],
"compatibility_date": "2026-09-05", "compatibility_date": "2026-09-05",
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"workers_dev": false, "workers_dev": false,
@@ -0,0 +1,18 @@
CREATE TABLE "install_token" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"team_id" char(30) NOT NULL,
"created_by_user_id" char(30) NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"redeemed_at" timestamp with time zone,
"machine_id" char(30)
);
--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "install_token_hash_unique" ON "install_token" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "install_token_team_idx" ON "install_token" USING btree ("team_id");
File diff suppressed because it is too large Load Diff
+131 -124
View File
@@ -1,125 +1,132 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1784801002476, "when": 1784801002476,
"tag": "0000_quick_dark_phoenix", "tag": "0000_quick_dark_phoenix",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1785312635128, "when": 1785312635128,
"tag": "0001_opposite_senator_kelly", "tag": "0001_opposite_senator_kelly",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1785379712946, "when": 1785379712946,
"tag": "0002_light_mesmero", "tag": "0002_light_mesmero",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1785382013687, "when": 1785382013687,
"tag": "0003_many_pyro", "tag": "0003_many_pyro",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1785588097470, "when": 1785588097470,
"tag": "0004_remove_user_download_add_game_download", "tag": "0004_remove_user_download_add_game_download",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1785909838801, "when": 1785909838801,
"tag": "0005_flaky_may_parker", "tag": "0005_flaky_may_parker",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1786205230097, "when": 1786205230097,
"tag": "0006_waitlist_verification_game_aliases", "tag": "0006_waitlist_verification_game_aliases",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1788460224524, "when": 1788460224524,
"tag": "0007_box_session_team_notnull", "tag": "0007_box_session_team_notnull",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1788547836146, "when": 1788547836146,
"tag": "0008_session_one_active_run_per_box", "tag": "0008_session_one_active_run_per_box",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1788555252186, "when": 1788555252186,
"tag": "0009_email_is_the_root_identity", "tag": "0009_email_is_the_root_identity",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "7", "version": "7",
"when": 1788590292860, "when": 1788590292860,
"tag": "0010_device_authorization_grant", "tag": "0010_device_authorization_grant",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1788607804606, "when": 1788607804606,
"tag": "0011_auth_state_in_postgres", "tag": "0011_auth_state_in_postgres",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "7", "version": "7",
"when": 1788691753961, "when": 1788691753961,
"tag": "0012_steam_enrolment_without_a_token", "tag": "0012_steam_enrolment_without_a_token",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "7", "version": "7",
"when": 1788725541386, "when": 1788725541386,
"tag": "0013_machine_endpoint_id", "tag": "0013_machine_endpoint_id",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "7", "version": "7",
"when": 1789680491539, "when": 1789680491539,
"tag": "0014_machine_public_label", "tag": "0014_machine_public_label",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "7", "version": "7",
"when": 1789762221718, "when": 1789762221718,
"tag": "0015_organisation_owns_fleet_hardware", "tag": "0015_organisation_owns_fleet_hardware",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "7", "version": "7",
"when": 1789765075037, "when": 1789765075037,
"tag": "0016_burn_counters_and_rate_segments", "tag": "0016_burn_counters_and_rate_segments",
"breakpoints": true "breakpoints": true
} },
] {
} "idx": 17,
"version": "7",
"when": 1790573670492,
"tag": "0017_install_token",
"breakpoints": true
}
]
}
+12
View File
@@ -51,6 +51,18 @@ export namespace Env {
POLAR_FREE_PRODUCT_ID: z.string().optional(), POLAR_FREE_PRODUCT_ID: z.string().optional(),
POLAR_SERVER: z.enum(['sandbox', 'production']).optional(), POLAR_SERVER: z.enum(['sandbox', 'production']).optional(),
/**
* Where installable binaries are kept: an S3-compatible bucket that is
* never public. Downloads are answered with a short-lived signed URL,
* so every one passes through a route that can be logged or turned off.
* Scope the key to this bucket and to reads.
*/
RELEASES_BUCKET: z.string().optional(),
RELEASES_ENDPOINT: z.string().optional(),
RELEASES_REGION: z.string().default('us-east-1'),
RELEASES_ACCESS_KEY_ID: z.string().optional(),
RELEASES_SECRET_ACCESS_KEY: z.string().optional(),
DATABASE_URL: z.string().optional() DATABASE_URL: z.string().optional()
}); });
+1
View File
@@ -13,6 +13,7 @@ export namespace Identifier {
userFingerprint: 'ufp', userFingerprint: 'ufp',
pairingCode: 'pai', pairingCode: 'pai',
machine: 'mch', machine: 'mch',
installToken: 'mit',
box: 'box', box: 'box',
session: 'ses', session: 'ses',
accessToken: 'pat', accessToken: 'pat',
@@ -0,0 +1,40 @@
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { TeamTable } from '../team/team.sql.js';
import { UserTable } from '../user/user.sql.js';
import { MachineTable } from './machine.sql.js';
/**
* A one-time credential that registers exactly one machine to one team.
*
* It exists so that installing on a host is a command a person pastes, rather
* than a user session copied onto a machine. It travels in that command, so it
* lands in shell history: that is why it is single-use, expires in minutes,
* and is stored only as a digest.
*/
export const InstallTokenTable = pgTable(
'install_token',
{
...id,
...timestamps,
teamId: ulid('team_id')
.notNull()
.references(() => TeamTable.id, { onDelete: 'cascade' }),
// Who asked for it. They become the machine's owner, as they would have
// by registering it with their own session.
createdByUserId: ulid('created_by_user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
expiresAt: utc('expires_at').notNull(),
redeemedAt: utc('redeemed_at'),
// The machine it made. Null until redeemed; kept afterwards so a support
// conversation can say which command produced which host.
machineId: ulid('machine_id').references(() => MachineTable.id, { onDelete: 'set null' })
},
(t) => [
uniqueIndex('install_token_hash_unique').on(t.tokenHash),
index('install_token_team_idx').on(t.teamId)
]
);
@@ -0,0 +1,68 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { InstallToken } from './install-token.js';
const sql = testDb();
const createdUserIds: string[] = [];
async function newOwner(label: string) {
const o = await Fixtures.owner(label);
createdUserIds.push(o.userId);
return o;
}
afterAll(async () => {
if (createdUserIds.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
});
describe('Install tokens', () => {
test('a token registers one machine to its team, owned by whoever issued it', async () => {
const owner = await newOwner('nit-ok');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
expect(token.startsWith('nit_')).toBe(true);
const registered = await InstallToken.redeem({ token, label: 'host' });
expect(registered?.secret.startsWith('msk_')).toBe(true);
const rows = await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
expect(rows[0]!.team_id).toBe(owner.teamId);
expect(rows[0]!.owner_user_id).toBe(owner.userId);
// Only the digest is kept, and the row records what it produced.
const tok = await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
expect(tok[0]!.token_hash).not.toBe(token);
expect(tok[0]!.machine_id).toBe(registered!.machineId);
});
test('a token is spent by its first use', async () => {
const owner = await newOwner('nit-once');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
expect(await InstallToken.redeem({ token, label: 'a' })).not.toBeNull();
expect(await InstallToken.redeem({ token, label: 'b' })).toBeNull();
});
test('two hosts racing one token register exactly one machine', async () => {
const owner = await newOwner('nit-race');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) => InstallToken.redeem({ token, label: `h${i}` }))
);
expect(results.filter(Boolean)).toHaveLength(1);
const machines = await sql`select id from machine where team_id = ${owner.teamId}`;
expect(machines).toHaveLength(1);
});
test('expired and unknown tokens are refused the same way', async () => {
const owner = await newOwner('nit-expired');
const { token } = await InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
await sql`update install_token set expires_at = now() - interval '1 minute' where team_id = ${owner.teamId}`;
expect(await InstallToken.redeem({ token, label: 'late' })).toBeNull();
expect(await InstallToken.redeem({ token: 'nit_nosuchtoken', label: 'x' })).toBeNull();
});
});
+108
View File
@@ -0,0 +1,108 @@
import { randomBytes } from 'node:crypto';
import { and, eq, gt, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
import { Database } from '../db/index.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { Machine } from './index.js';
import { InstallTokenTable } from './install-token.sql.js';
export namespace InstallToken {
/** 128 bits: guessing one inside its lifetime is not a strategy. */
const TOKEN_BYTES = 16;
/**
* How long a token lives. Long enough to copy a command, open a terminal on
* another machine and run it; short enough that one found in shell history
* tomorrow is worthless.
*/
export const TTL_MINUTES = 60;
function generate(): string {
return `nit_${randomBytes(TOKEN_BYTES).toString('base64url')}`;
}
async function digest(token: string): Promise<string> {
const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
return Array.from(new Uint8Array(d))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
/** Issue a token for `teamId`. The caller has already checked membership. */
export const create = fn(z.object({ teamId: z.string(), userId: z.string() }), async (input) => {
const token = generate();
const tokenHash = await digest(token);
const expiresAt = await Database.use(async (tx) =>
tx
.insert(InstallTokenTable)
.values({
id: Identifier.ascending('installToken'),
teamId: input.teamId,
createdByUserId: input.userId,
tokenHash,
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
})
.returning({ expiresAt: InstallTokenTable.expiresAt })
.then((rows) => rows[0]!.expiresAt)
);
return { token, expiresAt };
});
/**
* Spend a token and register the machine it was issued for.
*
* Spending is one conditional UPDATE, so two hosts presenting the same token
* at the same instant cannot both win — the loser sees no row and is refused.
* Refusal is `null` for every reason (unknown, expired, already used), so the
* answer teaches a caller nothing about which tokens exist.
*/
export const redeem = fn(
z.object({ token: z.string(), label: z.string().min(1).max(64) }),
async (input) => {
const tokenHash = await digest(input.token);
// One transaction, so a registration that fails leaves the token
// unspent rather than burning the only copy the person has.
return Database.transaction(async () => {
const spent = await Database.use(async (tx) =>
tx
.update(InstallTokenTable)
.set({ redeemedAt: sql`now()` })
.where(
and(
eq(InstallTokenTable.tokenHash, tokenHash),
isNull(InstallTokenTable.redeemedAt),
isNull(InstallTokenTable.timeDeleted),
gt(InstallTokenTable.expiresAt, sql`now()`)
)
)
.returning({
id: InstallTokenTable.id,
teamId: InstallTokenTable.teamId,
userId: InstallTokenTable.createdByUserId
})
.then((rows) => rows.at(0) ?? null)
);
if (!spent) return null;
const machine = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: spent.userId,
teamId: spent.teamId,
label: input.label
});
await Database.use(async (tx) =>
tx
.update(InstallTokenTable)
.set({ machineId: machine.id })
.where(eq(InstallTokenTable.id, spent.id))
);
return { machineId: machine.id, slug: machine.slug, secret: machine.secret };
});
}
);
}