Compare commits

...
4 Commits
Author SHA1 Message Date
adebisi-fa e3392c7750 docs(openspec): archive fpagent-release-installer and sync specs 2026-10-02 09:53:35 +01:00
adebisi-fa 46027acec1 chore(env): provision GITEA_RELEASE_INSTALL_KEY 2026-10-02 09:53:35 +01:00
adebisi-fa 6d3909c334 docs(fpagent): document the one-line installer and its key 2026-10-02 09:53:35 +01:00
adebisi-fa b3b75ffac0 feat(fpagent): public installer and private-release proxy
Serve /fpagent/install.sh (also curl|bash) and proxy the private
fingerprint-agent releases through /fpagent/releases/* using
GITEA_RELEASE_INSTALL_KEY, so a field laptop needs no forge credentials.
The installer picks the offline bundle for the host codename when present,
verifies the published sha256, then hands off to the bundle's own installer.
2026-10-02 09:53:35 +01:00
15 changed files with 862 additions and 17 deletions
+1
View File
@@ -18,3 +18,4 @@ API_BASE="encrypted:BIJm9rXCjB90I6zZFK57kTTjx2KY0+V42NF8zk38YVcSNTu4PB5Dxdz6iOEz
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
GITEA_RELEASE_INSTALL_KEY="encrypted:BNgr8Q5ZURg8EhuMNk7gdZy7DvzjC98tGRciLsQQFpAWnZ5eEGCM/S8DgJTPpQR1OA+L4LUUbW8QhBwsrUwjzqG3/Z8Tnqwma4bIwtXr1s9RERfimZGJ+1XEieY6b7hU3icl2aUBScdOYGFSfx4/Cj94Uz0onzeiMfzS9ZdGy6TUrVzXWCJsS9g="
+9 -3
View File
@@ -1,6 +1,6 @@
# Image Cropper service credentials — the ONLY environment variables this app
# uses. Everything else (URLs, port, domain catalog) is hardcoded in source.
# Without these the rest of the app works and /cropper answers 503.
# Image Cropper service credentials. Everything else (URLs, port, domain
# catalog) is hardcoded in source. Without these the rest of the app works and
# /cropper answers 503.
#
# This repository uses dotenvx-encrypted env files (.env, .env.production);
# the private keys live in .env.keys (gitignored). To set up or change a value:
@@ -16,3 +16,9 @@ AZURE_STORAGE_CONNECTION_STRING=
NODERED_HTTP_USERNAME=
NODERED_HTTP_PASSWORD=
CARVEKIT_API_KEY=
# Token the server uses to read the private fpagent releases from the forge
# (https://sources.yrka.app). It stays server-side: /fpagent/releases/* proxies
# the bundles for the public installer at /fpagent/install.sh. Without it the
# installer answers 503.
GITEA_RELEASE_INSTALL_KEY=
+1
View File
@@ -18,3 +18,4 @@ API_BASE="encrypted:BFVQaDK474EqpNvSw6iiP8KYjRqyXOMQLL/m5lbQHI30QCrNoMfsxmC7W2mH
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
GITEA_RELEASE_INSTALL_KEY="encrypted:BOYECnGcqfZlj8pRvTldqB/UF6HnBlMCfOymdMd3eRvGFo+HwPRViboFteB/tdMAF9uxL5q80OrgB9qbC6LaDTa/ociZg2kVR+R00YB/ZEV4epamIZ1ZzXWrg8J9D4zTGkJJ5iRiqyE4qGoPxDhn3OhTrs5RfBl/7pJa6Wi9wTU+1xhT3oBJr7E="
+29 -8
View File
@@ -42,6 +42,8 @@ tools/
├── cropper.js # /cropper router (queue, review, save) — injectable clients
├── public/
│ ├── index.html # landing page shell (tiles injected per session)
│ ├── fpagent/
│ │ └── install.sh # public /fpagent/install.sh (proxies private releases)
│ └── assets/
│ ├── style.css # shared Carbon-styled layout + g100 theme
│ ├── help.html # /help docs page
@@ -119,9 +121,10 @@ record, crop the photo, optionally **Clean background**, then accept or reject
with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
live and uploads through the queue.
Its service credentials are the **only** environment variables this app uses.
They live in dotenvx-encrypted files at the repository root — `.env` for
development, `.env.production` for deployment:
Its service credentials live in dotenvx-encrypted files at the repository root
— `.env` for development, `.env.production` for deployment. The fpagent
installer key is read from the same files (see
[Installing the agent](#installing-the-agent)):
```sh
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
@@ -149,13 +152,31 @@ The `/fingerprint` wizard walks the operator through:
travel with every `fprint-enroll` to fpagent.
3. **Save** — both templates are posted to a2loft with a fresh OTP.
### Installing the agent
`/fpagent/install.sh` is a public installer (also served for `curl | bash`). It
fetches the newest bundle from the private
[`fingerprint-agent`](https://sources.yrka.app/YOUreka/fingerprint-agent/releases)
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:
```sh
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 # report only
```
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
`/fpagent/releases/*`. Without that key the installer answers `503`.
## Configuration
Everything except the cropper credentials is hardcoded in `tools/server.js` by
design — port `4173`, portal `https://portal.yrka.app`, PocketBase
`https://pb.itcass.net`, a2loft `https://apis.a2loft.net`, agent
`http://127.0.0.1:9000`, and the domain catalog. Change them there; there are no
other environment variables or config files.
Everything except the credentials is hardcoded in `tools/server.js` by design —
port `4173`, portal `https://portal.yrka.app`, PocketBase `https://pb.itcass.net`,
a2loft `https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, forge
`https://sources.yrka.app` and the domain catalog. Change them there. The only
environment variables are the cropper credentials and the fpagent installer key
`GITEA_RELEASE_INSTALL_KEY` above.
## Deployment
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,85 @@
# Design
## Context
See proposal.md — Why. The `fingerprint-agent` repository and its releases are
private: an unauthenticated request to the forge's releases API answers `404`.
Releases already publish, per build, a generic bundle
(`fpagent-<version>-linux-x86_64.tar.gz`), per-Ubuntu offline bundles
(`fpagent-<version>-ubuntu-<codename>-x86_64.tar.gz`) with the libfprint `.deb`
closure vendored, and a `.sha256` beside each. Every bundle already contains
the fpagent binary, the udev rule, and its own `install.sh` that handles
libfprint, udev, `plugdev` and the systemd service. The tools app is an Express
server with a hardcoded-configuration policy whose only sanctioned environment
variables were the Image Cropper credentials.
## Goals / Non-Goals
**Goals:**
- A field laptop installs or updates fpagent with one public command.
- The forge credential never reaches the field laptop.
- Reuse the bundle's installer rather than reimplementing hardware setup.
**Non-Goals:**
- Replacing the bundle installer, or the forge's own release tooling.
- Authenticating the installer: it is intentionally public.
- Supporting architectures other than x86_64 (the bundle installer already
requires it).
## Decisions
1. **Proxy the release through the app; do not embed the key in the script.**
The credential lives in `GITEA_RELEASE_INSTALL_KEY` and the app fetches the
private release server-side. The alternative — rendering the key into the
served script so the client could call the forge directly — publishes a
repository credential to every installer and any `curl`; rejected.
2. **Two thin routes, no proxy framework.** `/fpagent/releases/latest` returns
a trimmed `{tag, assets:[{name,url}]}` with same-origin URLs, and
`/fpagent/releases/download/:tag/:name` streams one asset. Both are
registered before the session middleware so they stay public and do no
per-request PocketBase work. A single combined "give me the right tarball"
route was considered but needs the server to guess the client's OS; the
client knows its own codename, so selection happens in the script.
3. **The installer delegates to the bundle's own `install.sh`.** All hardware
and dependency logic already exists and is versioned with the binary;
reimplementing it in the hosted script would duplicate it and drift. The
hosted script only fetches, verifies, extracts, and forwards flags.
4. **Bundle selection prefers the codename offline bundle.** When the host's
`/etc/os-release` `VERSION_CODENAME` matches a published offline bundle, use
it (no network needed for the dependency closure); otherwise fall back to
the generic online bundle, whose installer apt-gets libfprint.
5. **Checksum verification, not signature verification.** The release publishes
sha256 only, so the installer verifies sha256 over TLS from the same origin
it trusts for the script itself. GPG would require release signing that does
not exist yet; deferred.
6. **Asset names are validated server-side** against a conservative pattern,
so a crafted tag or name cannot walk the forge path or reach another host.
## Risks / Trade-offs
- [A compromised tools app serves a malicious installer] → the app is already
trusted to serve the tools pages and holds the operator session; the
installer is not a new trust boundary. TLS to `tools.yrka.app` is the same
trust as the app.
- [The forge changes its bundle naming] → selection falls back to the generic
bundle, and a release with no matching asset fails loudly rather than
installing something wrong.
- [The app buffers an asset in memory] → bundles are tens of MB; acceptable,
and requests are rare. Streaming can replace it if that changes.
## Migration Plan
Add `GITEA_RELEASE_INSTALL_KEY` to `.env`/`.env.production` (already supplied)
and deploy the app; the installer and proxy are new routes and change no
existing behavior. Rollback = remove the routes; nothing else depends on them.
## Open Questions
None.
@@ -0,0 +1,49 @@
# Proposal: fpagent-release-installer
## Why
Field laptops need `fpagent` installed and kept current, but the
`fingerprint-agent` repository is private: its releases require a forge token
that operators do not have. Today an operator must obtain a bundle out of band
and run its installer by hand. A public one-line installer hosted by the tools
app removes that step while keeping the forge credential server-side.
## What Changes
- Add a public installer script served at `/fpagent/install.sh`, intended to be
piped into `bash`. It fetches the latest release, selects the bundle matching
the host (the Ubuntu-codename offline bundle when present, else the generic
online bundle), verifies the published sha256, extracts it, and runs the
bundle's own installer, forwarding flags such as `--enable-service`,
`--check` and `--uninstall`.
- Add same-origin proxy routes `/fpagent/releases/latest` and
`/fpagent/releases/download/:tag/:name` that read the private release from the
forge using the `GITEA_RELEASE_INSTALL_KEY` environment variable and stream
assets to the client. The token and the forge host are never exposed.
- Extend the app's sanctioned environment configuration to include the installer
key, alongside the existing Image Cropper credentials.
## Capabilities
### New Capabilities
- `fpagent-installer`: the public installer surface — the download/verify/handoff
script and the token-guarded same-origin release proxy that backs it.
### Modified Capabilities
- `tools-app`: the hardcoded-configuration requirement now sanctions a second
environment variable (the fpagent installer key) in addition to the Image
Cropper credentials.
## Impact
- `tools/server.js`: `GITEA_URL` / `FPAGENT_REPO` constants, the installer key
read, and two public routes registered before the session middleware.
- `tools/public/fpagent/install.sh`: new installer script.
- `tools/test/fpagent-install.test.js`: proxy and script tests.
- `.env` / `.env.production` hold the key; `.env.example` and `README.md`
document the install flow.
- Depends on the private `fingerprint-agent` releases and their bundle naming
(`fpagent-<version>-{linux-x86_64|ubuntu-<codename>-x86_64}.tar.gz`) plus the
matching `.sha256` assets.
@@ -0,0 +1,136 @@
# fpagent-installer Specification
## Purpose
Lets a field laptop install the latest private `fpagent` release with a single
public command, without ever handling the forge credential — the tools app
proxies the private release and serves the installer.
## ADDED 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 fetch the latest
release metadata, choose a bundle for the host, verify the bundle's published
sha256 checksum, extract it, and hand off to the extracted bundle's own
installer. 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: 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 `--enable-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 and installs nothing
### Requirement: Release metadata endpoint
The app SHALL expose a same-origin endpoint that returns the latest release's
tag and the names of its downloadable assets as same-origin URLs. The app SHALL
authenticate to the private forge using the configured release key, SHALL omit
asset names that are not valid asset names, and SHALL NOT reveal the release
key or the forge host to clients.
#### Scenario: Latest release is returned
- **WHEN** a client requests the latest-release endpoint and the forge returns
a release
- **THEN** the response carries the release tag and each asset's name and a
same-origin URL under `/fpagent/releases/download/`
#### Scenario: Unsafe asset names are omitted
- **WHEN** the forge release lists an asset whose name is not a valid asset
name
- **THEN** that asset does not appear in the response
#### Scenario: Release key is not exposed
- **WHEN** any client fetches the latest-release endpoint
- **THEN** the response body contains neither the release key nor the forge host
#### Scenario: Installer not configured
- **WHEN** the release key is not configured
- **THEN** the endpoint answers that the installer is not configured
#### Scenario: Forge unavailable
- **WHEN** the forge cannot be reached or returns an error for the latest
release
- **THEN** the endpoint answers with a gateway error
### Requirement: Release asset download proxy
The app SHALL stream a requested release asset to the client through a
same-origin route, authenticating the upstream request with the release key so
the client sends no credentials. It SHALL reject asset names that are not valid
asset names and SHALL report a missing asset as not found.
#### Scenario: Asset is streamed without client credentials
- **WHEN** a client requests a release asset by tag and name without any
credential
- **THEN** the app fetches it from the forge with the release key and streams
its bytes to the client
#### Scenario: Invalid asset name is rejected
- **WHEN** a client requests an asset whose tag or name is not a valid asset
name
- **THEN** the app rejects the request without contacting the forge
#### Scenario: Missing asset
- **WHEN** the forge reports the requested asset as not found
- **THEN** the app answers with not found
#### Scenario: Installer not configured
- **WHEN** the release key is not configured
- **THEN** the endpoint answers that the installer is not configured
### Requirement: Installer credential stays server-side
The app SHALL read the private-forge release key from the
`GITEA_RELEASE_INSTALL_KEY` environment variable. The key SHALL NOT appear in
the installer script, in any release metadata response, or in any streamed
asset.
#### Scenario: Served installer carries no credential
- **WHEN** a client fetches `/fpagent/install.sh`
- **THEN** the response contains no release key
#### Scenario: Credential supplied by the environment
- **WHEN** the release key is supplied in the environment
- **THEN** the installer and proxy endpoints use it without any change to the
hardcoded configuration
@@ -0,0 +1,34 @@
# tools-app Specification
## MODIFIED Requirements
### Requirement: Configuration is hardcoded
The app SHALL hardcode all configuration in source: port `4173`, portal
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the fingerprint
agent release forge `https://sources.yrka.app`. There SHALL be no runtime
configuration via environment variables or config files, with two exceptions:
the Image Cropper's service credentials (Minio keys, Azure storage connection
string, nodered credentials, cleanup-service key) SHALL come from environment
variables and SHALL NOT be stored in source; and the Fingerprint Agent
installer's release key (`GITEA_RELEASE_INSTALL_KEY`) SHALL come from the
environment and SHALL NOT be stored in source.
#### Scenario: No configuration required to run
- **WHEN** the app is started with no environment variables or config files
- **THEN** it serves on port 4173 with the hardcoded external endpoints
#### Scenario: Cropper credentials from the environment
- **WHEN** the cropper credentials are supplied as environment variables
- **THEN** the cropper routes use them without any change to the hardcoded
URL/port configuration
#### Scenario: Installer key from the environment
- **WHEN** the fpagent installer release key is supplied as an environment
variable
- **THEN** the installer and release-proxy routes use it without any change to
the hardcoded URL/port configuration
@@ -0,0 +1,23 @@
# Tasks
- [x] 1. Server: `GITEA_URL`/`FPAGENT_REPO` constants, the installer key read
from `GITEA_RELEASE_INSTALL_KEY`, and two public proxy routes registered
before the session middleware: `GET /fpagent/releases/latest` (trimmed
`{tag, assets:[{name,url}]}`, same-origin URLs, unsafe names dropped) and
`GET /fpagent/releases/download/:tag/:name` (validated names, streamed with
the key, 404/502 mapping, 503 when unconfigured).
- [x] 2. Installer script `tools/public/fpagent/install.sh`: fetch metadata,
prefer the host-codename offline bundle over the generic one, download and
verify sha256, extract, and forward all options to the bundle's `install.sh`.
Served uncached at `/fpagent/install.sh`.
- [x] 3. Tests (`tools/test/fpagent-install.test.js`): latest proxy shape and
token non-disclosure, asset streaming with upstream auth, invalid-name
rejection, missing-asset 404, unconfigured 503, public script serving, and
`bash -n` on the script.
- [x] 4. Docs/config: `GITEA_RELEASE_INSTALL_KEY` in `.env.example`, installer
and configuration notes in `README.md`.
- [x] 5. Verified: 55 tests pass (48 existing + 7 new); routes exercised live
against the forge (latest release `v1.0.0`, proxied checksum matches the
publisher's, invalid name refused); the installer run end to end against a
fake release base — codename bundle chosen, checksum pass and fail, options
forwarded to the extracted bundle installer.
+136
View File
@@ -0,0 +1,136 @@
# fpagent-installer Specification
## Purpose
Lets a field laptop install the latest private `fpagent` release with a single
public command, without ever handling the forge credential — the tools app
proxies the private release and serves the installer.
## 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 fetch the latest
release metadata, choose a bundle for the host, verify the bundle's published
sha256 checksum, extract it, and hand off to the extracted bundle's own
installer. 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: 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 `--enable-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 and installs nothing
### Requirement: Release metadata endpoint
The app SHALL expose a same-origin endpoint that returns the latest release's
tag and the names of its downloadable assets as same-origin URLs. The app SHALL
authenticate to the private forge using the configured release key, SHALL omit
asset names that are not valid asset names, and SHALL NOT reveal the release
key or the forge host to clients.
#### Scenario: Latest release is returned
- **WHEN** a client requests the latest-release endpoint and the forge returns
a release
- **THEN** the response carries the release tag and each asset's name and a
same-origin URL under `/fpagent/releases/download/`
#### Scenario: Unsafe asset names are omitted
- **WHEN** the forge release lists an asset whose name is not a valid asset
name
- **THEN** that asset does not appear in the response
#### Scenario: Release key is not exposed
- **WHEN** any client fetches the latest-release endpoint
- **THEN** the response body contains neither the release key nor the forge host
#### Scenario: Installer not configured
- **WHEN** the release key is not configured
- **THEN** the endpoint answers that the installer is not configured
#### Scenario: Forge unavailable
- **WHEN** the forge cannot be reached or returns an error for the latest
release
- **THEN** the endpoint answers with a gateway error
### Requirement: Release asset download proxy
The app SHALL stream a requested release asset to the client through a
same-origin route, authenticating the upstream request with the release key so
the client sends no credentials. It SHALL reject asset names that are not valid
asset names and SHALL report a missing asset as not found.
#### Scenario: Asset is streamed without client credentials
- **WHEN** a client requests a release asset by tag and name without any
credential
- **THEN** the app fetches it from the forge with the release key and streams
its bytes to the client
#### Scenario: Invalid asset name is rejected
- **WHEN** a client requests an asset whose tag or name is not a valid asset
name
- **THEN** the app rejects the request without contacting the forge
#### Scenario: Missing asset
- **WHEN** the forge reports the requested asset as not found
- **THEN** the app answers with not found
#### Scenario: Installer not configured
- **WHEN** the release key is not configured
- **THEN** the endpoint answers that the installer is not configured
### Requirement: Installer credential stays server-side
The app SHALL read the private-forge release key from the
`GITEA_RELEASE_INSTALL_KEY` environment variable. The key SHALL NOT appear in
the installer script, in any release metadata response, or in any streamed
asset.
#### Scenario: Served installer carries no credential
- **WHEN** a client fetches `/fpagent/install.sh`
- **THEN** the response contains no release key
#### Scenario: Credential supplied by the environment
- **WHEN** the release key is supplied in the environment
- **THEN** the installer and proxy endpoints use it without any change to the
hardcoded configuration
+14 -5
View File
@@ -119,11 +119,14 @@ session; the OTP SHALL NOT be accepted without one.
### Requirement: Configuration is hardcoded
The app SHALL hardcode all configuration in source: port `4173`, portal
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
`https://apis.a2loft.net`, and agent `http://127.0.0.1:9000`. There SHALL be
no runtime configuration via environment variables or config files, with one
exception: the Image Cropper's service credentials (Minio keys, Azure storage
connection string, nodered credentials, cleanup-service key) SHALL come from
environment variables and SHALL NOT be stored in source.
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the fingerprint
agent release forge `https://sources.yrka.app`. There SHALL be no runtime
configuration via environment variables or config files, with two exceptions:
the Image Cropper's service credentials (Minio keys, Azure storage connection
string, nodered credentials, cleanup-service key) SHALL come from environment
variables and SHALL NOT be stored in source; and the Fingerprint Agent
installer's release key (`GITEA_RELEASE_INSTALL_KEY`) SHALL come from the
environment and SHALL NOT be stored in source.
#### Scenario: No configuration required to run
- **WHEN** the app is started with no environment variables or config files
@@ -134,6 +137,12 @@ environment variables and SHALL NOT be stored in source.
- **THEN** the cropper routes use them without any change to the hardcoded
URL/port configuration
#### Scenario: Installer key from the environment
- **WHEN** the fpagent installer release key is supplied as an environment
variable
- **THEN** the installer and release-proxy routes use it without any change to
the hardcoded URL/port configuration
### Requirement: Carbon Design System UI
The tools pages SHALL be built on the Carbon Design System: Carbon web
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
+102
View File
@@ -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 <<EOF
Usage: curl -fsSL ${BASE}/install.sh | bash -s -- [installer options]
Downloads the latest fpagent release, verifies it, and runs its installer.
Options are forwarded to that installer:
--check preflight and report only; change nothing
--offline never touch the network; fail if dependencies are missing
--enable-service install and enable a systemd service (starts at boot)
--service-user NAME user for the service (default: the invoking user)
--install-path PATH binary destination (default: /usr/local/bin/fpagent)
--uninstall remove the binary, the udev rule and the service
-h, --help the installer's help
Environment:
FPAGENT_BASE_URL override the download base (default: ${BASE})
EOF
}
for arg in "$@"; do
case "$arg" in
-h | --help)
usage
exit 0
;;
esac
done
for cmd in curl tar sha256sum python3; do
command -v "$cmd" >/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" "$@"
+80 -1
View File
@@ -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;
+161
View File
@@ -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' });
});