feat(api): serve the host installer and its downloads

GET /install.sh serves a POSIX script, embedded in the API at build time
so the script and the routes that redeem its token ship together. It
checks the platform, asks where box images should live, downloads the
host agent at a pinned version, verifies it against SHA256SUMS, installs
it for the calling user 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 switched off. The SigV4 signer is written
against Web Crypto and checked against AWS's published example.
This commit is contained in:
Wanjohi
2026-09-28 09:12:23 +03:00
parent 54dbe8628a
commit 065af689b3
8 changed files with 351 additions and 0 deletions
+2
View File
@@ -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)
+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);
}
);
}
+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",
"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,