mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-28 21:52:26 +03:00
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:
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user