diff --git a/tools/public/fpagent/install.sh b/tools/public/fpagent/install.sh new file mode 100755 index 0000000..f1ec1fd --- /dev/null +++ b/tools/public/fpagent/install.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# install.sh — download and install the latest YOUreka Fingerprint Agent +# (fpagent) release on this machine. +# +# Hosted at https://tools.yrka.app/fpagent/install.sh and meant to be piped +# straight into bash on a field laptop: +# +# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash +# +# It downloads the bundle, verifies its sha256, then hands off to the bundle's +# own installer, which handles libfprint, the udev rule, plugdev and the +# systemd service. Every flag is forwarded to that installer, e.g.: +# +# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --enable-service +# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check +# +# The private release is reached through the tools app, so no credentials are +# needed here. +set -euo pipefail + +BASE="${FPAGENT_BASE_URL:-https://tools.yrka.app/fpagent}" + +say() { printf '%s\n' "$*"; } +die() { printf '[FAIL] %s\n' "$*" >&2; exit 1; } + +usage() { + cat </dev/null 2>&1 || die "$cmd is required but not installed" +done + +# Offline bundles ship the libfprint .deb closure for a specific Ubuntu +# release; prefer the one matching this machine, else fall back to the generic +# online bundle (whose installer apt-get's the dependency). +codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')" + +say "Fetching the latest Fingerprint Agent release..." +meta="$(curl -fsSL "${BASE}/releases/latest")" || die "could not fetch the release list from ${BASE}" + +readout="$(python3 -c ' +import json, sys +codename = sys.argv[1] +release = json.loads(sys.argv[2]) +assets = [a["name"] for a in release.get("assets", [])] + +def pick(suffix): + return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "") + +name = pick("-ubuntu-" + codename + "-x86_64.tar.gz") if codename else "" +if not name: + name = pick("-linux-x86_64.tar.gz") +print(release.get("tag", "") + "\t" + name) +' "$codename" "$meta")" || die "could not parse the release metadata" + +tag="${readout%%$'\t'*}" +asset="${readout#*$'\t'}" +[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in the latest release (x86_64 required)" + +say " release ${tag}" +say " bundle ${asset}" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +get() { curl -fsSL "$1" -o "$2" || die "download failed: $1"; } + +say "Downloading and verifying..." +get "${BASE}/releases/download/${tag}/${asset}" "${tmp}/${asset}" +get "${BASE}/releases/download/${tag}/${asset}.sha256" "${tmp}/${asset}.sha256" +(cd "$tmp" && sha256sum -c "${asset}.sha256") || die "checksum verification failed for ${asset}" + +tar xzf "${tmp}/${asset}" -C "$tmp" || die "could not extract ${asset}" +[ -x "${tmp}/install.sh" ] || die "the bundle does not contain install.sh" + +say "Running the installer (pass --enable-service to start at boot)..." +"${tmp}/install.sh" "$@" diff --git a/tools/server.js b/tools/server.js index 148f883..900c122 100644 --- a/tools/server.js +++ b/tools/server.js @@ -20,6 +20,11 @@ export const PORTAL_URL = 'https://portal.yrka.app'; // web-auth + openfga/verif export const PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh export const A2LOFT_BASE = 'https://apis.a2loft.net'; // staff lookup + fingerprint save export const AGENT_URL = 'http://127.0.0.1:9000'; // fpagent Socket.IO +export const GITEA_URL = 'https://sources.yrka.app'; // fpagent release forge (private) +export const FPAGENT_REPO = 'YOUreka/fingerprint-agent'; +// The Gitea token used to read the private fpagent releases. The operator's +// laptop never sees it: /fpagent/* proxies the bundles through this server. +const GITEA_RELEASE_KEY = process.env.GITEA_RELEASE_INSTALL_KEY || ''; export const DOMAINS = [ { code: 'uc', name: 'UNICAL' }, { code: 'mtr', name: 'Metropolitan Hotel' }, @@ -272,6 +277,10 @@ export function createApp(overrides = {}) { agentUrl: AGENT_URL, domains: DOMAINS, ttlMs: DOMAIN_TTL_MS, + giteaUrl: GITEA_URL, + fpagentRepo: FPAGENT_REPO, + giteaToken: GITEA_RELEASE_KEY, + giteaFetch: fetch, fetch: fetch, now: Date.now, ...overrides, @@ -314,12 +323,82 @@ export function createApp(overrides = {}) { setHeaders: (res, filePath) => { res.setHeader( 'Cache-Control', - filePath.endsWith('.html') ? 'no-store' : 'public, max-age=31536000, immutable' + filePath.endsWith('.html') || filePath.endsWith('.sh') + ? 'no-store' + : 'public, max-age=31536000, immutable' ); }, }) ); // assets only; '/' is session-aware + // ── fpagent installer: public proxy for the private release bundles ─────── + // The install script (public/fpagent/install.sh) runs on a field laptop with + // no Gitea credentials, so this server reads the private release with the + // token and streams the bundle back. The token and forge host never leave + // here. Registered before the session middleware: the installer is public. + const giteaBase = () => cfg.giteaUrl.replace(/\/+$/, ''); + const giteaHeaders = () => (cfg.giteaToken ? { authorization: `token ${cfg.giteaToken}` } : {}); + const SAFE_ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + const installerNotConfigured = (res) => + res.status(503).json({ + ok: false, + message: 'The Fingerprint Agent installer is not configured on this deployment.', + }); + + app.get('/fpagent/releases/latest', async (req, res) => { + if (!cfg.giteaToken) return installerNotConfigured(res); + try { + const r = await cfg.giteaFetch(`${giteaBase()}/api/v1/repos/${cfg.fpagentRepo}/releases/latest`, { + headers: giteaHeaders(), + }); + if (!r.ok) { + return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); + } + const release = await r.json(); + const tag = String(release?.tag_name || ''); + const assets = (Array.isArray(release?.assets) ? release.assets : []) + .map((a) => String(a?.name || '')) + .filter((name) => SAFE_ASSET.test(name)) + .map((name) => ({ + name, + url: `/fpagent/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`, + })); + if (!tag || assets.length === 0) { + return res + .status(502) + .json({ ok: false, message: 'The Fingerprint Agent release has no downloadable bundles.' }); + } + res.set('Cache-Control', 'no-store').json({ ok: true, tag, assets }); + } catch { + res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); + } + }); + + app.get('/fpagent/releases/download/:tag/:name', async (req, res) => { + if (!cfg.giteaToken) return installerNotConfigured(res); + const { tag, name } = req.params; + if (!SAFE_ASSET.test(tag) || !SAFE_ASSET.test(name)) { + return res.status(400).json({ ok: false, message: 'Unknown release asset.' }); + } + try { + const r = await cfg.giteaFetch( + `${giteaBase()}/${cfg.fpagentRepo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`, + { headers: giteaHeaders() } + ); + if (!r.ok) { + return res + .status(r.status === 404 ? 404 : 502) + .json({ ok: false, message: 'Release asset not available.' }); + } + res + .set('Content-Type', r.headers.get('content-type') || 'application/octet-stream') + .set('Cache-Control', 'no-store') + .send(Buffer.from(await r.arrayBuffer())); + } catch { + res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); + } + }); + // ── session middleware: recover / refresh the YOUreka Auth session ──────── app.use(async (req, res, next) => { req.session = null; diff --git a/tools/test/fpagent-install.test.js b/tools/test/fpagent-install.test.js new file mode 100644 index 0000000..5564d44 --- /dev/null +++ b/tools/test/fpagent-install.test.js @@ -0,0 +1,161 @@ +// The /fpagent installer surface: a public script, and a proxy that reads the +// private Gitea release with GITEA_RELEASE_INSTALL_KEY so the operator's +// laptop never needs credentials. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createApp, FPAGENT_REPO } from '../server.js'; +import { startServer, stopServer, urlOf } from './helpers.js'; + +const TOKEN = 'release-install-secret'; +const TAG = 'v9.9.9'; +const ARCHIVE = 'fpagent-deadbee-linux-x86_64.tar.gz'; +const UBUNTU_ARCHIVE = 'fpagent-deadbee-ubuntu-jammy-x86_64.tar.gz'; +const RELEASE = { + tag_name: TAG, + assets: [ + { name: ARCHIVE }, + { name: `${ARCHIVE}.sha256` }, + { name: UBUNTU_ARCHIVE }, + { name: 'not a safe name' }, + { name: '../escape' }, + ], +}; + +// Fake Gitea: refuses without the token, serves the release and its assets. +async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) { + const calls = []; + const server = await startServer((req, res) => { + calls.push({ url: req.url, auth: req.headers.authorization }); + if (req.headers.authorization !== `token ${TOKEN}`) { + res.writeHead(404); + res.end(); + return; + } + if (req.url === `/api/v1/repos/${FPAGENT_REPO}/releases/latest`) { + res.writeHead(releaseStatus, { 'content-type': 'application/json' }); + res.end(JSON.stringify(RELEASE)); + return; + } + if (req.url.startsWith(`/${FPAGENT_REPO}/releases/download/`)) { + res.writeHead(downloadStatus, { 'content-type': 'application/gzip' }); + res.end(Buffer.from('bundle-bytes')); + return; + } + res.writeHead(404); + res.end(); + }); + return { server, calls }; +} + +async function bootFpagentApp(giteaUrl, overrides = {}) { + const app = createApp({ + giteaUrl, + fpagentRepo: FPAGENT_REPO, + giteaToken: TOKEN, + cropperClients: null, + ...overrides, + }); + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + return { server, base: urlOf(server) }; +} + +test('latest release is proxied as tools-relative URLs, without the token', async () => { + const gitea = await startFakeGitea(); + const { server, base } = await bootFpagentApp(urlOf(gitea.server)); + try { + const r = await fetch(`${base}/fpagent/releases/latest`); + assert.equal(r.status, 200); + const body = await r.json(); + assert.equal(body.tag, TAG); + // Unsafe asset names are dropped; the rest point back at this app. + assert.deepEqual( + body.assets.map((a) => a.name), + [ARCHIVE, `${ARCHIVE}.sha256`, UBUNTU_ARCHIVE] + ); + for (const a of body.assets) { + assert.equal(a.url, `/fpagent/releases/download/${TAG}/${a.name}`); + } + assert.ok(!JSON.stringify(body).includes(TOKEN)); + assert.equal(gitea.calls[0].auth, `token ${TOKEN}`); // server authenticated upstream + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('bundles stream through this app; the client sends no credentials', async () => { + const gitea = await startFakeGitea(); + const { server, base } = await bootFpagentApp(urlOf(gitea.server)); + try { + const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`); + assert.equal(r.status, 200); + assert.equal(Buffer.from(await r.arrayBuffer()).toString(), 'bundle-bytes'); + const assetCall = gitea.calls.find((c) => c.url.includes('/releases/download/')); + assert.equal(assetCall.auth, `token ${TOKEN}`); + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('unsafe asset names are rejected before reaching the forge', async () => { + const gitea = await startFakeGitea(); + const { server, base } = await bootFpagentApp(urlOf(gitea.server)); + try { + const r = await fetch(`${base}/fpagent/releases/download/${TAG}/-bad`); + assert.equal(r.status, 400); + assert.equal(gitea.calls.length, 0); + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('a missing bundle surfaces as 404', async () => { + const gitea = await startFakeGitea({ downloadStatus: 404 }); + const { server, base } = await bootFpagentApp(urlOf(gitea.server)); + try { + const r = await fetch(`${base}/fpagent/releases/download/${TAG}/${ARCHIVE}`); + assert.equal(r.status, 404); + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('without GITEA_RELEASE_INSTALL_KEY the installer reports 503', async () => { + const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' }); + try { + const r = await fetch(`${base}/fpagent/releases/latest`); + assert.equal(r.status, 503); + } finally { + await stopServer(server); + } +}); + +test('install.sh is served publicly at /fpagent/install.sh', async () => { + const { server, base } = await bootFpagentApp('https://sources.yrka.app', { giteaToken: '' }); + try { + const r = await fetch(`${base}/fpagent/install.sh`); + assert.equal(r.status, 200); + assert.match(await r.text(), /FPAGENT_BASE_URL/); + } finally { + await stopServer(server); + } +}); + +test('install.sh is valid bash', () => { + const script = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'public', + 'fpagent', + 'install.sh' + ); + execFileSync('bash', ['-n', script], { stdio: 'pipe' }); +});