diff --git a/tools/public/fpagent/install.sh b/tools/public/fpagent/install.sh index e4d72a3..c94a47d 100755 --- a/tools/public/fpagent/install.sh +++ b/tools/public/fpagent/install.sh @@ -27,8 +27,12 @@ usage() { cat <"${tmp}/VERSION" + say "Running the installer (pass --enable-service to start at boot)..." -"${tmp}/install.sh" "$@" +"${tmp}/install.sh" "${FORWARD[@]}" diff --git a/tools/server.js b/tools/server.js index 900c122..294fa02 100644 --- a/tools/server.js +++ b/tools/server.js @@ -345,12 +345,9 @@ export function createApp(overrides = {}) { 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); + const releasePayload = async (res, giteaPath) => { try { - const r = await cfg.giteaFetch(`${giteaBase()}/api/v1/repos/${cfg.fpagentRepo}/releases/latest`, { - headers: giteaHeaders(), - }); + const r = await cfg.giteaFetch(`${giteaBase()}${giteaPath}`, { headers: giteaHeaders() }); if (!r.ok) { return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); } @@ -372,6 +369,22 @@ export function createApp(overrides = {}) { } catch { res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); } + }; + + app.get('/fpagent/releases/latest', (req, res) => { + if (!cfg.giteaToken) return installerNotConfigured(res); + return releasePayload(res, `/api/v1/repos/${cfg.fpagentRepo}/releases/latest`); + }); + + app.get('/fpagent/releases/tags/:tag', (req, res) => { + if (!cfg.giteaToken) return installerNotConfigured(res); + if (!SAFE_ASSET.test(req.params.tag)) { + return res.status(400).json({ ok: false, message: 'Unknown release tag.' }); + } + return releasePayload( + res, + `/api/v1/repos/${cfg.fpagentRepo}/releases/tags/${encodeURIComponent(req.params.tag)}` + ); }); app.get('/fpagent/releases/download/:tag/:name', async (req, res) => { diff --git a/tools/test/fpagent-install.test.js b/tools/test/fpagent-install.test.js index f87d4cc..739af5c 100644 --- a/tools/test/fpagent-install.test.js +++ b/tools/test/fpagent-install.test.js @@ -3,14 +3,17 @@ // laptop never needs credentials. import test from 'node:test'; import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFile, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; import { createApp, FPAGENT_REPO } from '../server.js'; import { startServer, stopServer, urlOf } from './helpers.js'; +const execFileAsync = promisify(execFile); + const SCRIPT = path.join( path.dirname(fileURLToPath(import.meta.url)), '..', @@ -49,6 +52,11 @@ async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {} res.end(JSON.stringify(RELEASE)); return; } + if (req.url.startsWith(`/api/v1/repos/${FPAGENT_REPO}/releases/tags/`)) { + 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')); @@ -98,6 +106,70 @@ test('latest release is proxied as tools-relative URLs, without the token', asyn } }); +test('a pinned tag is proxied from the forge tag endpoint', async () => { + const gitea = await startFakeGitea(); + const { server, base } = await bootFpagentApp(urlOf(gitea.server)); + try { + const r = await fetch(`${base}/fpagent/releases/tags/v1.0.0`); + assert.equal(r.status, 200); + assert.equal((await r.json()).tag, TAG); + assert.ok(gitea.calls[0].url.endsWith('/releases/tags/v1.0.0')); + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('an unsafe release tag is 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/tags/-bad`); + assert.equal(r.status, 400); + assert.equal(gitea.calls.length, 0); + } finally { + await stopServer(server); + await stopServer(gitea.server); + } +}); + +test('install.sh --tag asks for that tag, not latest', async () => { + const calls = []; + const gateway = await startServer((req, res) => { + calls.push(req.url); + if (req.url === '/releases/tags/v7.7.7') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + tag: 'v7.7.7', + assets: [{ name: 'fpagent-cafebabe-linux-x86_64.tar.gz' }], + }) + ); + return; + } + res.writeHead(404); + res.end(); + }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-tag-test-')); + try { + let failed = false; + try { + // Async so the in-process gateway can answer while the child runs. + await execFileAsync('bash', [SCRIPT, '--tag', 'v7.7.7', '--check'], { + env: { ...process.env, TMPDIR: tmp, FPAGENT_BASE_URL: urlOf(gateway) }, + }); + } catch { + failed = true; // the asset download 404s; the metadata request is what matters + } + assert.equal(failed, true); + assert.ok(calls.includes('/releases/tags/v7.7.7'), 'should request the pinned tag'); + assert.ok(!calls.includes('/releases/latest'), 'should not fall back to latest'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + await stopServer(gateway); + } +}); + test('bundles stream through this app; the client sends no credentials', async () => { const gitea = await startFakeGitea(); const { server, base } = await bootFpagentApp(urlOf(gitea.server));