mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-30 22:52:25 +03:00
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:
@@ -14,6 +14,7 @@ import { BillingApi } from './routes/billing.js';
|
||||
import { EnrolmentApi } from './routes/enrolment.js';
|
||||
import { GameApi } from './routes/game.js';
|
||||
import { IndexApi } from './routes/index.js';
|
||||
import { InstallApi } from './routes/install.js';
|
||||
import { LibraryApi } from './routes/library.js';
|
||||
import { MachineApi } from './routes/machine.js';
|
||||
import { OrganisationApi } from './routes/organisation.js';
|
||||
@@ -40,6 +41,7 @@ app
|
||||
|
||||
const routes = app
|
||||
.route('/', IndexApi.route)
|
||||
.route('/', InstallApi.route)
|
||||
.route('/user', UserApi.route)
|
||||
.route('/steam', SteamApi.route)
|
||||
.route('/library', LibraryApi.route)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
import { InstallToken } from '@nestri/core/machine/install-token';
|
||||
import { Organisation } from '@nestri/core/organisation/index';
|
||||
import { Team } from '@nestri/core/team/index';
|
||||
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(
|
||||
'/:id',
|
||||
notPublic,
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
// Files imported `with { type: 'text' }` arrive as their contents.
|
||||
declare module '*.sh' {
|
||||
const text: string;
|
||||
export default text;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
Executable
+111
@@ -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
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "nestri-api",
|
||||
"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_flags": ["nodejs_compat"],
|
||||
"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
@@ -1,125 +1,132 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784801002476,
|
||||
"tag": "0000_quick_dark_phoenix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785312635128,
|
||||
"tag": "0001_opposite_senator_kelly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785379712946,
|
||||
"tag": "0002_light_mesmero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1785382013687,
|
||||
"tag": "0003_many_pyro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1785588097470,
|
||||
"tag": "0004_remove_user_download_add_game_download",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785909838801,
|
||||
"tag": "0005_flaky_may_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786205230097,
|
||||
"tag": "0006_waitlist_verification_game_aliases",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1788460224524,
|
||||
"tag": "0007_box_session_team_notnull",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1788547836146,
|
||||
"tag": "0008_session_one_active_run_per_box",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1788555252186,
|
||||
"tag": "0009_email_is_the_root_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1788590292860,
|
||||
"tag": "0010_device_authorization_grant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1788607804606,
|
||||
"tag": "0011_auth_state_in_postgres",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788691753961,
|
||||
"tag": "0012_steam_enrolment_without_a_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788725541386,
|
||||
"tag": "0013_machine_endpoint_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1789680491539,
|
||||
"tag": "0014_machine_public_label",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1789762221718,
|
||||
"tag": "0015_organisation_owns_fleet_hardware",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1789765075037,
|
||||
"tag": "0016_burn_counters_and_rate_segments",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784801002476,
|
||||
"tag": "0000_quick_dark_phoenix",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1785312635128,
|
||||
"tag": "0001_opposite_senator_kelly",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1785379712946,
|
||||
"tag": "0002_light_mesmero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1785382013687,
|
||||
"tag": "0003_many_pyro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1785588097470,
|
||||
"tag": "0004_remove_user_download_add_game_download",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1785909838801,
|
||||
"tag": "0005_flaky_may_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1786205230097,
|
||||
"tag": "0006_waitlist_verification_game_aliases",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1788460224524,
|
||||
"tag": "0007_box_session_team_notnull",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1788547836146,
|
||||
"tag": "0008_session_one_active_run_per_box",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1788555252186,
|
||||
"tag": "0009_email_is_the_root_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1788590292860,
|
||||
"tag": "0010_device_authorization_grant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1788607804606,
|
||||
"tag": "0011_auth_state_in_postgres",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788691753961,
|
||||
"tag": "0012_steam_enrolment_without_a_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788725541386,
|
||||
"tag": "0013_machine_endpoint_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1789680491539,
|
||||
"tag": "0014_machine_public_label",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1789762221718,
|
||||
"tag": "0015_organisation_owns_fleet_hardware",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1789765075037,
|
||||
"tag": "0016_burn_counters_and_rate_segments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1790573670492,
|
||||
"tag": "0017_install_token",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,6 +51,18 @@ export namespace Env {
|
||||
POLAR_FREE_PRODUCT_ID: z.string().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()
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export namespace Identifier {
|
||||
userFingerprint: 'ufp',
|
||||
pairingCode: 'pai',
|
||||
machine: 'mch',
|
||||
installToken: 'mit',
|
||||
box: 'box',
|
||||
session: 'ses',
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user