diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index 14f8710c..232f6651 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -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) diff --git a/apps/api/app/routes/install.ts b/apps/api/app/routes/install.ts new file mode 100644 index 00000000..935bc3d0 --- /dev/null +++ b/apps/api/app/routes/install.ts @@ -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 -- `.', + 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); + } + ); +} diff --git a/apps/api/app/text-modules.d.ts b/apps/api/app/text-modules.d.ts new file mode 100644 index 00000000..e6f356bd --- /dev/null +++ b/apps/api/app/text-modules.d.ts @@ -0,0 +1,5 @@ +// Files imported `with { type: 'text' }` arrive as their contents. +declare module '*.sh' { + const text: string; + export default text; +} diff --git a/apps/api/app/utils/presign.ts b/apps/api/app/utils/presign.ts new file mode 100644 index 00000000..d77fafd3 --- /dev/null +++ b/apps/api/app/utils/presign.ts @@ -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 (`//`), 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 { + return hex(await crypto.subtle.digest('SHA-256', enc.encode(s))); +} + +async function hmac(key: ArrayBuffer | Uint8Array, s: string): Promise { + 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; + /** `./` instead of `//`. */ + virtualHost?: boolean; +}; + +export async function presignGet( + b: Bucket, + key: string, + expiresSeconds: number, + now: Date = new Date() +): Promise { + 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}`; +} diff --git a/apps/api/install/install.sh b/apps/api/install/install.sh new file mode 100755 index 00000000..9e47d752 --- /dev/null +++ b/apps/api/install/install.sh @@ -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 -- +# +# 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/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/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 { + // 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'); + }); +}); diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index c201ccce..97e4a40a 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -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, diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 9e63ae84..35188a63 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -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() });