Compare commits

..
4 Commits
Author SHA1 Message Date
adebisi-fa 8a13fb527e docs(openspec): archive fingerprint-token-auth after the production pass
The wizard's a2loft calls now authenticate with the session token and carry no
OTP. Field pass in production: the lookup and both fingerprint saves succeeded
with no OTP, so task 3.2 is done. Merged the fingerprint-wizard and tools-app
deltas into the main specs and archived the change.
2026-10-06 19:19:29 +01:00
adebisi-fa 8d42faf707 docs(openspec): archive fpagent-installer-multiarch and sync specs
Bundle selection is now architecture-aware, not just codename-aware, and the
no-compatible-bundle failure names the host architecture. Merged into the
fpagent-installer spec and archived the change.
2026-10-06 19:19:23 +01:00
adebisi-fa e432585f04 docs(fpagent): document 32-bit bundles and the default service
The installer section still showed --enable-service as required and named no
architecture. Note that x86_64 and i386 are both published, that the codename
bundle is preferred, and that the service and self-update timer are on by
default unless --no-service or --no-updates is passed.
2026-10-06 19:19:16 +01:00
adebisi-fa fa33c6e179 feat(fpagent): pick the installer bundle for the host architecture
The hosted installer only ever chose the x86_64 bundle, so a 32-bit field
laptop running the public one-liner failed with "no compatible bundle" even
though fingerprint-agent v1.5.0 publishes fpagent-<version>-linux-i386.tar.gz.

Resolve the host architecture from uname -m (x86_64 or i386, else fail before
any download), then prefer -ubuntu-<codename>-<arch>.tar.gz and fall back to
-linux-<arch>.tar.gz. The version regex now derives the git hash for every
supported architecture, so a 32-bit install records and reports it too.

The help text matches the bundle installer: the systemd service and the
self-update timer are on by default, with --no-service, --no-updates and
--update-base documented.
2026-10-06 19:19:07 +01:00
17 changed files with 471 additions and 61 deletions
+10 -7
View File
@@ -161,22 +161,25 @@ is claimed or a scan does not complete.
### Installing the agent ### Installing the agent
`/fpagent/install.sh` is a public installer (also served for `curl | bash`). It `/fpagent/install.sh` is a public installer (also served for `curl | bash`). It
fetches the newest bundle from the private fetches the newest bundle for the host architecture — x86_64 or 32-bit i386,
preferring the bundle built for the machine's Ubuntu codename — from the private
[`fingerprint-agent`](https://sources.yrka.app/YOUreka/fingerprint-agent/releases) [`fingerprint-agent`](https://sources.yrka.app/YOUreka/fingerprint-agent/releases)
forge, verifies its sha256, then runs the bundle's own installer, which handles forge, verifies its sha256, then runs the bundle's own installer, which handles
libfprint, the udev rule, `plugdev` and the systemd service. On a field laptop: libfprint, the udev rule, `plugdev` and the systemd service. On a field laptop:
```sh ```sh
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --enable-service curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # report only curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # report only
curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --tag v1.0.0 --enable-service curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --tag v1.0.0
``` ```
Rerunning it is the upgrade path: it installs the newest release (or `--tag` Rerunning it is the upgrade path: it installs the newest release (or `--tag`
for a pinned one), the bundle installer reports the version change, and with for a pinned one), the bundle installer reports the version change, and it
`--enable-service` it restarts a running service so the new binary takes effect. restarts a running service so the new binary takes effect. The version is the
The version is the git hash carried in the asset name, stamped next to the git hash carried in the asset name, stamped next to the binary
binary (`/usr/local/bin/fpagent.version`) and compared on the next run. (`/usr/local/bin/fpagent.version`) and compared on the next run. The systemd
service and the self-update timer are on by default; pass `--no-service` or
`--no-updates` to leave them off.
The forge is private, so the operator's laptop needs no credentials: the app The forge is private, so the operator's laptop needs no credentials: the app
reads the release with `GITEA_RELEASE_INSTALL_KEY` and proxies it through reads the release with `GITEA_RELEASE_INSTALL_KEY` and proxies it through
@@ -25,5 +25,6 @@
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes. - [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
(66/66 pass.) (66/66 pass.)
- [ ] 3.2 Field pass against a real domain (a2loft deployed): look up a - [x] 3.2 Field pass against a real domain (a2loft deployed): look up a
reference and save both fingerprints with no OTP. reference and save both fingerprints with no OTP. Verified in production:
the lookup and both fingerprint saves succeeded with no OTP.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-06
@@ -0,0 +1,72 @@
# Design: fpagent-installer-multiarch
## Context
The hosted installer (`tools/public/fpagent/install.sh`) runs on the field
laptop and proxies the private release through `tools.yrka.app`. It resolves
the release's asset names with a small `python3` snippet and hardcodes the
x86_64 suffix. fingerprint-agent v1.5.0 added an i386 build:
- online: `fpagent-<version>-linux-x86_64.tar.gz`, `fpagent-<version>-linux-i386.tar.gz`
- offline: `fpagent-<version>-ubuntu-<release>-x86_64.tar.gz` (x86_64 only)
The bundle's own installer already refuses a binary whose ELF class does not
match the host, so the hosted installer only has to pick the right asset.
## Goals / Non-goals
- Goal: on any supported host (x86_64, i386) pick the release bundle built for
that architecture, and record its version.
- Non-goal: changing `tools/server.js`. It proxies whatever the release lists,
and asset filtering/naming is unchanged.
- Non-goal: i386 offline (codename) bundles. fingerprint-agent ships those
x86_64-only; i386 hosts fall back to the generic online bundle whose
installer apt-get's libfprint.
- Non-goal: adding a production environment knob for the architecture. The
host's own `uname -m` is the source of truth.
## Decisions
### Architecture from `uname -m`, resolved once
Map `x86_64|amd64` → `x86_64`, `i386|i486|i586|i686` → `i386`, anything else →
fail before any download. This mirrors `deploy/install.sh` in fingerprint-agent
so the hosted and bundled installers agree on what is supported.
### Selection order: codename bundle, then generic, both arch-scoped
With `codename` from `/etc/os-release`:
1. `-ubuntu-<codename>-<arch>.tar.gz`
2. `-linux-<arch>.tar.gz`
The codename attempt is skipped when the host has no codename. Because offline
bundles are x86_64-only, an i386 host simply finds no codename asset and falls
to step 2. The suffix match keeps one architecture's bundle from ever matching
another's (`-linux-i386` does not end with `-linux-x86_64`).
### Version regex derived from the architecture
The version is the git hash embedded in the asset name. The existing regex only
listed the x86_64 endings, so it produced an empty version for an i386 bundle
and nothing was recorded. The regex must accept the same set of endings the
selection can produce: `linux-x86_64`, `linux-i386` and
`ubuntu-<codename>-x86_64`.
### Tests stub `uname`
The installer takes the architecture from the host, so a test cannot assert the
i386 path on an x86_64 CI box by running the real `uname`. A test prepends a
temp directory holding a fake `uname` to `PATH`, which is contained and needs no
production-only override. Assertions are made on the gateway requests the script
makes (which asset it asks for), the way the existing `--tag` test does.
## Risks / trade-offs
- A 64-bit userland on a 32-bit kernel is exotic; `uname -m` is the documented
choice for the bundled installer, so the hosted one follows it rather than
trying a second heuristic. `ponytail:` if such hosts appear, add a
`getconf LONG_BIT` fallback in both installers at once.
- The generated `install.sh --help` text duplicates the bundle installer's
flags list. It can drift again; keeping it short and pointing at the bundle
installer's own help limits the cost.
@@ -0,0 +1,47 @@
# Proposal: fpagent-installer-multiarch
## Why
The hosted installer only ever picks the x86_64 bundle. fingerprint-agent
v1.5.0 publishes a 32-bit `-linux-i386` bundle and its own installer accepts
i386 hosts, so a 32-bit field laptop running the public one-liner fails with
"no compatible bundle" and installs nothing, even though a matching release
asset exists.
## What Changes
- The installer detects the host architecture (`uname -m` → `x86_64` or
`i386`) and selects the matching bundle: the distro-codename bundle when one
exists for that architecture, else the generic `-linux-<arch>` bundle.
- An unsupported architecture fails with a message that names the host machine,
and installs nothing.
- The version parsed from the asset name accepts the `-linux-i386` suffix, so a
32-bit install records and reports its version the same way x86_64 does.
- The installer's help text matches the bundle installer: the systemd service
and the self-update timer are on by default, so `--no-service`,
`--no-updates` and `--update-base` are documented instead of
`--enable-service` being described as required to get a service.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `fpagent-installer`: bundle selection becomes architecture-aware (not just
distro-codename aware), the no-compatible-bundle failure names the host
architecture, and the version is derived for every published architecture.
## Impact
- `tools/public/fpagent/install.sh`: architecture detection, bundle pick,
version regex, help text.
- `tools/test/fpagent-install.test.js`: architecture selection and version
parsing tests.
- `README.md`: the installer section (flags and architectures).
- Depends on the `fingerprint-agent` bundle naming
`fpagent-<version>-linux-<arch>.tar.gz`, where `<arch>` is `x86_64` or
`i386` (v1.5.0 and newer). No change to `tools/server.js`: the server proxies
whatever assets the release lists.
@@ -0,0 +1,103 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Public installer endpoint
The app SHALL serve an installer script at `/fpagent/install.sh` that is
reachable without authentication. When run, the script SHALL determine the
host's architecture, fetch the latest release metadata, choose the bundle for
the host architecture, verify the bundle's published sha256 checksum, extract
it, and hand off to the extracted bundle's own installer. It SHALL prefer a
bundle built for the host's distro codename when one exists for that
architecture, and otherwise use the generic bundle for that architecture. The
script SHALL be served uncached.
#### Scenario: Installer is fetched anonymously
- **WHEN** a client requests `/fpagent/install.sh` without a session
- **THEN** the app returns the installer script and does not redirect to login
#### Scenario: Host-appropriate bundle is chosen
- **WHEN** the latest release offers both a bundle for the host's Ubuntu
codename and a generic bundle
- **THEN** the installer uses the codename bundle
#### Scenario: Generic bundle is the fallback
- **WHEN** the latest release offers no bundle matching the host's Ubuntu
codename
- **THEN** the installer uses the generic bundle
#### Scenario: Bundle matches the host architecture
- **WHEN** the latest release offers a bundle for the host architecture and a
bundle for another architecture
- **THEN** the installer chooses the bundle for the host architecture
- **AND** it never chooses a bundle for another architecture
#### Scenario: 32-bit host uses the 32-bit bundle
- **WHEN** the installer runs on an i386 host and the release offers only a
generic 32-bit bundle
- **THEN** the installer uses that generic 32-bit bundle
#### Scenario: Checksum is verified before extraction
- **WHEN** the installer downloads a bundle and its published checksum
- **THEN** it verifies the checksum before extracting and refuses to install
when the checksum does not match
#### Scenario: Options are forwarded to the bundled installer
- **WHEN** the installer is invoked with options such as `--no-service` or
`--check`
- **THEN** it passes those options through to the extracted bundle's installer
#### Scenario: No compatible bundle
- **WHEN** the latest release contains no bundle for the host architecture
- **THEN** the installer exits with a failure message that names the host
architecture and installs nothing
#### Scenario: Unsupported architecture
- **WHEN** the installer runs on a machine whose architecture is neither
x86_64 nor i386
- **THEN** it exits with a failure message and installs nothing
### Requirement: Installed version is tracked and upgrades are reported
The installer SHALL treat the git hash in the bundle's asset name as the
version, for every architecture it can install. It SHALL hand that version to
the bundle installer, which SHALL record it beside the installed binary and
SHALL report an upgrade with the previous and new versions on the next run.
Uninstalling SHALL remove the recorded version.
#### Scenario: Version is derived from the asset name
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Version is derived for a 32-bit bundle
- **WHEN** the installer selects a generic bundle named
`fpagent-<hash>-linux-i386.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Upgrade is reported
- **WHEN** the recorded version differs from the version being installed
- **THEN** the bundle installer reports the change from the old to the new
version
#### Scenario: First install records the version
- **WHEN** no version has been recorded yet
- **THEN** the bundle installer records the new version and reports no upgrade
#### Scenario: Uninstall removes the recorded version
- **WHEN** the installer is asked to uninstall
- **THEN** the recorded version is removed
@@ -0,0 +1,27 @@
# Tasks
- [x] 1. Hosted installer (`tools/public/fpagent/install.sh`): resolve the host
architecture from `uname -m` (`x86_64`/`i386`, else die) before any download,
and pass it to the asset picker. Verify: `bash -n install.sh`; a stubbed
`uname` printing `i686` reaches the metadata fetch, and one printing `aarch64`
dies without fetching.
- [x] 2. Hosted installer: pick `-ubuntu-<codename>-<arch>.tar.gz` first, then
`-linux-<arch>.tar.gz`, and make the "no compatible bundle" message name the
host architecture. Verify: with a release offering only
`fpagent-<hash>-linux-i386.tar.gz`, a stubbed i686 host requests that asset;
with only `-linux-x86_64`, the same host fails and installs nothing.
- [x] 3. Hosted installer: derive the version from the asset name for every
supported architecture (`linux-x86_64`, `linux-i386`,
`ubuntu-<codename>-x86_64`). Verify: an i386 asset name yields the git hash,
not an empty string.
- [x] 4. Hosted installer: correct the help text — service and self-update are
on by default; list `--no-service`, `--no-updates`, `--update-base`, and keep
the note that options are forwarded to the bundle installer.
Verify: `install.sh --help` mentions the three flags and no longer implies
`--enable-service` is required.
- [x] 5. Tests (`tools/test/fpagent-install.test.js`): stub `uname` on `PATH`
and assert i386 selection, cross-architecture rejection, and i386 version
parsing. Verify: `node --test tools/test/fpagent-install.test.js` passes.
- [x] 6. Docs (`README.md`): installer section notes x86_64 and i386 bundles
and the on-by-default service/self-update. Verify: reading the section, no
flag claims contradict `install.sh --help`.
+26 -24
View File
@@ -114,16 +114,16 @@ and SHALL display the agent version reported on connect.
### Requirement: Two-finger enrollment flow ### Requirement: Two-finger enrollment flow
The wizard SHALL enroll a profile by: selecting a permitted domain, looking The wizard SHALL enroll a profile by: selecting a permitted domain, looking
up staff by reference (OTP-validated through the a2loft proxy), displaying up staff by reference (token-validated through the a2loft proxy), displaying
the staff details, capturing the right thumb and right index finger, and the staff details, capturing the right thumb and right index finger, and
saving both fingerprints through the a2loft proxy with an OTP. The wizard saving both fingerprints through the a2loft proxy authenticated with the
SHALL offer per-finger recapture until save, SHALL require both fingers session token. The wizard SHALL offer per-finger recapture until save, SHALL
before save, and SHALL reset domain-independent state (reference, staff require both fingers before save, and SHALL reset domain-independent state
details, captured artifacts) when starting a new enrollment. (reference, staff details, captured artifacts) when starting a new
enrollment.
#### Scenario: Complete enrollment #### Scenario: Complete enrollment
- **WHEN** the operator looks up staff, captures both fingers, and saves - **WHEN** the operator looks up staff, captures both fingers, and saves
with an OTP
- **THEN** both fingerprints are saved through the a2loft proxy and the - **THEN** both fingerprints are saved through the a2loft proxy and the
wizard reports success wizard reports success
@@ -137,24 +137,6 @@ details, captured artifacts) when starting a new enrollment.
- **THEN** the reference, staff details, and captured finger artifacts are - **THEN** the reference, staff details, and captured finger artifacts are
cleared while the permitted domain list persists cleared while the permitted domain list persists
### Requirement: OTP validated on every a2loft call
The wizard SHALL collect an OTP for each a2loft call — staff lookup and
fingerprint save — and pass it to the same-origin proxy; a2loft validates it.
Submission SHALL be blocked while the OTP is missing.
#### Scenario: Lookup with OTP
- **WHEN** the operator submits a reference with an OTP
- **THEN** the wizard calls the staff-lookup proxy with that OTP and
displays the returned staff details
#### Scenario: Save requires a fresh OTP
- **WHEN** the operator saves the captured fingerprints
- **THEN** the wizard sends the OTP entered for the save call
#### Scenario: Missing OTP blocks submission
- **WHEN** the OTP field is empty for a lookup or save
- **THEN** the corresponding submit control is disabled
### Requirement: Scanner availability is shown and gates capture ### Requirement: Scanner availability is shown and gates capture
The wizard SHALL consume fpagent's `fprint-device-status` The wizard SHALL consume fpagent's `fprint-device-status`
`{available, device, captureAgentId}` event and SHALL distinguish the agent `{available, device, captureAgentId}` event and SHALL distinguish the agent
@@ -195,3 +177,23 @@ guarantee a capture will succeed.
- **WHEN** the agent socket disconnects - **WHEN** the agent socket disconnects
- **THEN** the wizard shows the agent offline and does not present the reader as - **THEN** the wizard shows the agent offline and does not present the reader as
available available
### Requirement: Token authenticated on every a2loft call
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
save — with the signed-in operator's PocketBase session token, sent by the
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
NOT collect an OTP.
#### Scenario: Lookup uses the session token
- **WHEN** the operator submits a reference
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
session token
#### Scenario: Save uses the session token
- **WHEN** the operator saves the captured fingerprints
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
the session token
#### Scenario: No OTP field
- **WHEN** the wizard page is served
- **THEN** it offers no OTP input for lookup or save
+40 -10
View File
@@ -11,10 +11,13 @@ proxies the private release and serves the installer.
### Requirement: Public installer endpoint ### Requirement: Public installer endpoint
The app SHALL serve an installer script at `/fpagent/install.sh` that is The app SHALL serve an installer script at `/fpagent/install.sh` that is
reachable without authentication. When run, the script SHALL fetch the latest reachable without authentication. When run, the script SHALL determine the
release metadata, choose a bundle for the host, verify the bundle's published host's architecture, fetch the latest release metadata, choose the bundle for
sha256 checksum, extract it, and hand off to the extracted bundle's own the host architecture, verify the bundle's published sha256 checksum, extract
installer. The script SHALL be served uncached. it, and hand off to the extracted bundle's own installer. It SHALL prefer a
bundle built for the host's distro codename when one exists for that
architecture, and otherwise use the generic bundle for that architecture. The
script SHALL be served uncached.
#### Scenario: Installer is fetched anonymously #### Scenario: Installer is fetched anonymously
@@ -33,6 +36,19 @@ installer. The script SHALL be served uncached.
codename codename
- **THEN** the installer uses the generic bundle - **THEN** the installer uses the generic bundle
#### Scenario: Bundle matches the host architecture
- **WHEN** the latest release offers a bundle for the host architecture and a
bundle for another architecture
- **THEN** the installer chooses the bundle for the host architecture
- **AND** it never chooses a bundle for another architecture
#### Scenario: 32-bit host uses the 32-bit bundle
- **WHEN** the installer runs on an i386 host and the release offers only a
generic 32-bit bundle
- **THEN** the installer uses that generic 32-bit bundle
#### Scenario: Checksum is verified before extraction #### Scenario: Checksum is verified before extraction
- **WHEN** the installer downloads a bundle and its published checksum - **WHEN** the installer downloads a bundle and its published checksum
@@ -41,14 +57,21 @@ installer. The script SHALL be served uncached.
#### Scenario: Options are forwarded to the bundled installer #### Scenario: Options are forwarded to the bundled installer
- **WHEN** the installer is invoked with options such as `--enable-service` or - **WHEN** the installer is invoked with options such as `--no-service` or
`--check` `--check`
- **THEN** it passes those options through to the extracted bundle's installer - **THEN** it passes those options through to the extracted bundle's installer
#### Scenario: No compatible bundle #### Scenario: No compatible bundle
- **WHEN** the latest release contains no bundle for the host architecture - **WHEN** the latest release contains no bundle for the host architecture
- **THEN** the installer exits with a failure message and installs nothing - **THEN** the installer exits with a failure message that names the host
architecture and installs nothing
#### Scenario: Unsupported architecture
- **WHEN** the installer runs on a machine whose architecture is neither
x86_64 nor i386
- **THEN** it exits with a failure message and installs nothing
### Requirement: Release metadata endpoint ### Requirement: Release metadata endpoint
@@ -175,16 +198,23 @@ tag that is not a valid release tag without contacting the forge.
### Requirement: Installed version is tracked and upgrades are reported ### Requirement: Installed version is tracked and upgrades are reported
The installer SHALL treat the git hash in the bundle's asset name as the version. The installer SHALL treat the git hash in the bundle's asset name as the
It SHALL hand that version to the bundle installer, which SHALL record it beside version, for every architecture it can install. It SHALL hand that version to
the installed binary and SHALL report an upgrade with the previous and new the bundle installer, which SHALL record it beside the installed binary and
versions on the next run. Uninstalling SHALL remove the recorded version. SHALL report an upgrade with the previous and new versions on the next run.
Uninstalling SHALL remove the recorded version.
#### Scenario: Version is derived from the asset name #### Scenario: Version is derived from the asset name
- **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz` - **WHEN** the installer selects a bundle named `fpagent-<hash>-<platform>.tar.gz`
- **THEN** it uses `<hash>` as the version - **THEN** it uses `<hash>` as the version
#### Scenario: Version is derived for a 32-bit bundle
- **WHEN** the installer selects a generic bundle named
`fpagent-<hash>-linux-i386.tar.gz`
- **THEN** it uses `<hash>` as the version
#### Scenario: Upgrade is reported #### Scenario: Upgrade is reported
- **WHEN** the recorded version differs from the version being installed - **WHEN** the recorded version differs from the version being installed
+14 -9
View File
@@ -17,6 +17,7 @@ Cropper (`/cropper`) and to the Fingerprint Agent page (`/fingerprint`).
- **WHEN** a user opens the app root URL - **WHEN** a user opens the app root URL
- **THEN** the page shows a link to the Image Cropper pointing at - **THEN** the page shows a link to the Image Cropper pointing at
`/cropper` and a link to `/fingerprint` `/cropper` and a link to `/fingerprint`
### Requirement: YOUreka Auth login establishes a cookie session ### Requirement: YOUreka Auth login establishes a cookie session
The app SHALL authenticate operators through the YOUreka portal web-auth The app SHALL authenticate operators through the YOUreka portal web-auth
flow: create a web auth session at the portal with a local callback URL, flow: create a web auth session at the portal with a local callback URL,
@@ -100,17 +101,21 @@ portal.
### Requirement: a2loft proxies inject operator identity ### Requirement: a2loft proxies inject operator identity
The app SHALL proxy staff lookup and fingerprint-save calls to The app SHALL proxy staff lookup and fingerprint-save calls to
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL `https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
attach the operator's phone from the server-side session and forward the authenticate the operator with the signed-in session's PocketBase token, sent
OTP supplied by the page as query parameters (`phone`, `otp`), preserving as an `Authorization: Bearer` credential, preserving the a2loft request and
the a2loft request and response shapes. The proxies SHALL require a response shapes. The proxies SHALL require a session; a request without one
session; the OTP SHALL NOT be accepted without one. SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
require an OTP.
#### Scenario: Staff lookup via proxy #### Scenario: Staff lookup via proxy
- **WHEN** the page requests staff details for a domain and reference with - **WHEN** the page requests staff details for a domain and reference
an OTP - **THEN** the app calls a2loft's identity endpoint with the session token as a
- **THEN** the app calls a2loft's identity endpoint with the operator's bearer credential and returns a2loft's response unchanged
phone from the session and the supplied OTP, and returns a2loft's
response unchanged #### Scenario: Fingerprint save via proxy
- **WHEN** the page saves fingerprints for a domain and reference
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
token as a bearer credential
#### Scenario: Unauthenticated proxy call rejected #### Scenario: Unauthenticated proxy call rejected
- **WHEN** a proxy route is called without a valid session - **WHEN** a proxy route is called without a valid session
+24 -9
View File
@@ -11,7 +11,7 @@
# own installer, which handles libfprint, the udev rule, plugdev and the # own installer, which handles libfprint, the udev rule, plugdev and the
# systemd service. Every flag is forwarded to that installer, e.g.: # 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 -- --service-user alice
# curl -fsSL https://tools.yrka.app/fpagent/install.sh | bash -s -- --check # 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 # The private release is reached through the tools app, so no credentials are
@@ -35,12 +35,18 @@ Options:
Options below are forwarded to the bundled installer: Options below are forwarded to the bundled installer:
--check preflight and report only; change nothing --check preflight and report only; change nothing
--offline never touch the network; fail if dependencies are missing --offline never touch the network; fail if dependencies are missing
--enable-service install and enable a systemd service (starts at boot) --no-service do not install the systemd service (also disables updates)
--no-updates do not install the self-update timer
--update-base URL release base for self-update
(default: https://tools.yrka.app/fpagent)
--service-user NAME user for the service (default: the invoking user) --service-user NAME user for the service (default: the invoking user)
--install-path PATH binary destination (default: /usr/local/bin/fpagent) --install-path PATH binary destination (default: /usr/local/bin/fpagent)
--uninstall remove the binary, the udev rule and the service --uninstall remove the binary, the udev rule and the service
-h, --help the installer's help -h, --help the installer's help
The systemd service and the self-update timer are installed and enabled by
default; --no-service and --no-updates turn them off.
Environment: Environment:
FPAGENT_BASE_URL override the download base (default: ${BASE}) FPAGENT_BASE_URL override the download base (default: ${BASE})
EOF EOF
@@ -85,6 +91,15 @@ find "$tmpbase" -maxdepth 1 -mindepth 1 -type d -uid "$(id -u)" \
# online bundle (whose installer apt-get's the dependency). # online bundle (whose installer apt-get's the dependency).
codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')" codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release 2>/dev/null | tr -d '"')"
# Releases are named for the target architecture; only these two are published.
# Mirrors the bundled installer's own preflight so both reject the same hosts.
host_machine="$(uname -m)"
case "$host_machine" in
x86_64 | amd64) arch="x86_64" ;;
i386 | i486 | i586 | i686) arch="i386" ;;
*) die "unsupported architecture: ${host_machine} (x86_64 or i386 required)" ;;
esac
if [ "$RELEASE_TAG" = "latest" ]; then if [ "$RELEASE_TAG" = "latest" ]; then
meta_url="${BASE}/releases/latest" meta_url="${BASE}/releases/latest"
else else
@@ -96,25 +111,25 @@ meta="$(curl -fsSL "$meta_url")" || die "could not fetch the release list from $
readout="$(python3 -c ' readout="$(python3 -c '
import json, re, sys import json, re, sys
codename = sys.argv[1] codename, arch = sys.argv[1], sys.argv[2]
release = json.loads(sys.argv[2]) release = json.loads(sys.argv[3])
assets = [a["name"] for a in release.get("assets", [])] assets = [a["name"] for a in release.get("assets", [])]
def pick(suffix): def pick(suffix):
return next((n for n in assets if n.endswith(suffix) and not n.endswith(".sha256")), "") 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 "" name = pick("-ubuntu-" + codename + "-" + arch + ".tar.gz") if codename else ""
if not name: if not name:
name = pick("-linux-x86_64.tar.gz") name = pick("-linux-" + arch + ".tar.gz")
# The git hash is baked into the asset name (bundle.sh --version) and into # The git hash is baked into the asset name (bundle.sh --version) and into
# the binary itself; it is the version we record and later compare against. # the binary itself; it is the version we record and later compare against.
m = re.match(r"^fpagent-(.+)-(?:linux-x86_64|ubuntu-[a-z0-9]+-x86_64)\.tar\.gz$", name) m = re.match(r"^fpagent-(.+)-(?:linux|ubuntu-[a-z0-9]+)-" + re.escape(arch) + r"\.tar\.gz$", name)
version = m.group(1) if m else "" version = m.group(1) if m else ""
print(release.get("tag", "") + "\t" + name + "\t" + version) print(release.get("tag", "") + "\t" + name + "\t" + version)
' "$codename" "$meta")" || die "could not parse the release metadata" ' "$codename" "$arch" "$meta")" || die "could not parse the release metadata"
IFS=$'\t' read -r tag asset version <<<"$readout" IFS=$'\t' read -r tag asset version <<<"$readout"
[ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (x86_64 required)" [ -n "$tag" ] && [ -n "$asset" ] || die "no compatible bundle in release ${RELEASE_TAG} (${arch} required)"
say " release ${tag}" say " release ${tag}"
say " bundle ${asset}" say " bundle ${asset}"
+103
View File
@@ -37,6 +37,17 @@ const RELEASE = {
], ],
}; };
// A directory holding a fake `uname` that reports the given machine, so the
// installer's architecture-sensitive picker runs on any CI host. PATH is
// prepended with it; everything else resolves as usual.
function fakeUname(machine) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-uname-'));
fs.writeFileSync(path.join(dir, 'uname'), `#!/bin/sh\nprintf '%s\\n' '${machine}'\n`, {
mode: 0o755,
});
return dir;
}
// Fake Gitea: refuses without the token, serves the release and its assets. // Fake Gitea: refuses without the token, serves the release and its assets.
async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) { async function startFakeGitea({ releaseStatus = 200, downloadStatus = 200 } = {}) {
const calls = []; const calls = [];
@@ -231,6 +242,98 @@ test('install.sh is served publicly at /fpagent/install.sh', async () => {
} }
}); });
test('install.sh picks the bundle for the host architecture', async () => {
const calls = [];
const gateway = await startServer((req, res) => {
calls.push(req.url);
if (req.url === '/releases/latest') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
tag: 'v9.9.9',
assets: [
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz' },
{ name: 'fpagent-deadbee-linux-x86_64.tar.gz.sha256' },
{ name: 'fpagent-cafebabe-linux-i386.tar.gz' },
{ name: 'fpagent-cafebabe-linux-i386.tar.gz.sha256' },
],
})
);
return;
}
res.writeHead(404);
res.end();
});
const unameDir = fakeUname('i686');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
try {
let out = '';
try {
// Async so the in-process gateway can answer while the child runs.
const r = await execFileAsync('bash', [SCRIPT], {
env: {
...process.env,
PATH: `${unameDir}:${process.env.PATH}`,
TMPDIR: tmp,
FPAGENT_BASE_URL: urlOf(gateway),
},
});
out = r.stdout + r.stderr;
} catch (e) {
out = `${e.stdout ?? ''}${e.stderr ?? ''}`; // the asset download 404s
}
assert.match(out, /cafebabe/, 'the i386 version should come from the asset name');
assert.ok(
calls.some((u) => u.includes('fpagent-cafebabe-linux-i386.tar.gz')),
'should request the i386 bundle'
);
assert.ok(
!calls.some((u) => u.includes('x86_64')),
'should never request another architecture\u2019s bundle'
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(unameDir, { recursive: true, force: true });
await stopServer(gateway);
}
});
test('install.sh refuses an unsupported architecture before fetching', async () => {
const calls = [];
const gateway = await startServer((req, res) => {
calls.push(req.url);
res.writeHead(404);
res.end();
});
const unameDir = fakeUname('aarch64');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fpagent-arch-test-'));
try {
let out = '';
let failed = false;
try {
const r = await execFileAsync('bash', [SCRIPT], {
env: {
...process.env,
PATH: `${unameDir}:${process.env.PATH}`,
TMPDIR: tmp,
FPAGENT_BASE_URL: urlOf(gateway),
},
});
out = r.stdout + r.stderr;
} catch (e) {
failed = true;
out = `${e.stdout ?? ''}${e.stderr ?? ''}`;
}
assert.equal(failed, true, 'the installer should reject an unsupported host');
assert.match(out, /unsupported architecture: aarch64/);
assert.equal(calls.length, 0, 'should fail before touching the network');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(unameDir, { recursive: true, force: true });
await stopServer(gateway);
}
});
test('install.sh is valid bash', () => { test('install.sh is valid bash', () => {
execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' }); execFileSync('bash', ['-n', SCRIPT], { stdio: 'pipe' });
}); });