Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3392c7750 | ||
|
|
46027acec1 | ||
|
|
6d3909c334 | ||
|
|
b3b75ffac0 |
@@ -18,3 +18,4 @@ API_BASE="encrypted:BIJm9rXCjB90I6zZFK57kTTjx2KY0+V42NF8zk38YVcSNTu4PB5Dxdz6iOEz
|
|||||||
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
|
IMAGE_FOLDER="encrypted:BEGDDzGErTbitrApWXiq5TuUQrtJCcRq89fuagmNp92npdAubBxA1vtTQwwJ3OGKSPwDW1gQceczY3NWwaeGZbc46L1rut6oP+yE/BATMkJ/7IcoKn7S27By/00GmsbHjWqp9prtLOv5dCgCpw=="
|
||||||
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
|
NODERED_HTTP_USERNAME="encrypted:BCjXvS6rg+eCpV+S4/mypzw6Xu4vablBSqEZZeQN/oCfl7P7mVWzgaLYzNYOHit+4wP5soE1jw/jdk4OqBIiulcXWbkkEJPNvSqct9iL/rNfxufNuozOSFnii7j9bfW9dvjju2egpxNb16RHLtI+"
|
||||||
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
|
NODERED_HTTP_PASSWORD=encrypted:BKHA3R+VWxrEkpYhgPfXuZJroarXuIpdMSSI+cpEP6AcQS1vuE1alOtqD0sp5TFU0o5jMx9KWwE6L/WG9LdRQAyEJiotl93oYK46p1L3VgrRkUS+xpX5z5u5VYCLYdCfjhuCU75kGDmRaqId5fbgQ9NmIzJAvYUb2C4zEz0DADNy8/JHmfXKHVorexRAB0gJXoUPfLXk8gcJbdQPNoGobBIonngLREllEmGN
|
||||||
|
GITEA_RELEASE_INSTALL_KEY="encrypted:BNgr8Q5ZURg8EhuMNk7gdZy7DvzjC98tGRciLsQQFpAWnZ5eEGCM/S8DgJTPpQR1OA+L4LUUbW8QhBwsrUwjzqG3/Z8Tnqwma4bIwtXr1s9RERfimZGJ+1XEieY6b7hU3icl2aUBScdOYGFSfx4/Cj94Uz0onzeiMfzS9ZdGy6TUrVzXWCJsS9g="
|
||||||
|
|||||||
+9
-3
@@ -1,6 +1,6 @@
|
|||||||
# Image Cropper service credentials — the ONLY environment variables this app
|
# Image Cropper service credentials. Everything else (URLs, port, domain
|
||||||
# uses. Everything else (URLs, port, domain catalog) is hardcoded in source.
|
# catalog) is hardcoded in source. Without these the rest of the app works and
|
||||||
# Without these the rest of the app works and /cropper answers 503.
|
# /cropper answers 503.
|
||||||
#
|
#
|
||||||
# This repository uses dotenvx-encrypted env files (.env, .env.production);
|
# 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:
|
# 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_USERNAME=
|
||||||
NODERED_HTTP_PASSWORD=
|
NODERED_HTTP_PASSWORD=
|
||||||
CARVEKIT_API_KEY=
|
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=
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ API_BASE="encrypted:BFVQaDK474EqpNvSw6iiP8KYjRqyXOMQLL/m5lbQHI30QCrNoMfsxmC7W2mH
|
|||||||
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
|
IMAGE_FOLDER="encrypted:BDNYAfiof6lw9vYxB5jm3GZrtESoA+BNsrktU/YoWlV0HHdAOU+yWGX8FTRTq2/3Qv5AW02IO77Y7Fec9DGVHG6MmzEfuNVZ1Gz7e8XxGatmfp3X+p/REq04bZs4UXflh/YC8a9vYGpelpyVlw=="
|
||||||
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
|
NODERED_HTTP_USERNAME="encrypted:BApJWB9AcOzsbkS+GxJ24uaVBBD5rAO/q3z6EIhLDutOmiIcolwdl1kHJGgkkRGVKc/PvNlMD1fAYtEblEiMdSwJPKciV4gWdVNWnVVZHK/WFpP3Rj8JLBRq5ysfFgH2PBPHZFUAnBl3FbzVuJfc"
|
||||||
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
|
NODERED_HTTP_PASSWORD=encrypted:BGgnc8FDXIyZl/vjOwTiG2LLu1uwz72OCy78obR0h6yvm3NBv/pGr0ZeR1ZzDFBntN1WSg0qtroztpZxmdPcNgjO0/osmegUvNRM89uj6CsFtBe2SlDJLN66VH5ZGduB4m2CO0iNm14EZg3mLbkwCZv28Pb9uJqfPePt9EbK3Wwzu8IfJt2FImEe6nqdBng79Rbie4X7Z2WZX/m1nzcJb0WiMuzwGAT3e0aT
|
||||||
|
GITEA_RELEASE_INSTALL_KEY="encrypted:BOYECnGcqfZlj8pRvTldqB/UF6HnBlMCfOymdMd3eRvGFo+HwPRViboFteB/tdMAF9uxL5q80OrgB9qbC6LaDTa/ociZg2kVR+R00YB/ZEV4epamIZ1ZzXWrg8J9D4zTGkJJ5iRiqyE4qGoPxDhn3OhTrs5RfBl/7pJa6Wi9wTU+1xhT3oBJr7E="
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ tools/
|
|||||||
├── cropper.js # /cropper router (queue, review, save) — injectable clients
|
├── cropper.js # /cropper router (queue, review, save) — injectable clients
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── index.html # landing page shell (tiles injected per session)
|
│ ├── index.html # landing page shell (tiles injected per session)
|
||||||
|
│ ├── fpagent/
|
||||||
|
│ │ └── install.sh # public /fpagent/install.sh (proxies private releases)
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
│ ├── style.css # shared Carbon-styled layout + g100 theme
|
│ ├── style.css # shared Carbon-styled layout + g100 theme
|
||||||
│ ├── help.html # /help docs page
|
│ ├── 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
|
with reasons. Reads may use the cached `crop_image` verdict; saving re-verifies
|
||||||
live and uploads through the queue.
|
live and uploads through the queue.
|
||||||
|
|
||||||
Its service credentials are the **only** environment variables this app uses.
|
Its service credentials live in dotenvx-encrypted files at the repository root
|
||||||
They live in dotenvx-encrypted files at the repository root — `.env` for
|
— `.env` for development, `.env.production` for deployment. The fpagent
|
||||||
development, `.env.production` for deployment:
|
installer key is read from the same files (see
|
||||||
|
[Installing the agent](#installing-the-agent)):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
|
MINIO_ACCESS_KEY=… MINIO_SECRET_KEY=…
|
||||||
@@ -149,13 +152,31 @@ The `/fingerprint` wizard walks the operator through:
|
|||||||
travel with every `fprint-enroll` to fpagent.
|
travel with every `fprint-enroll` to fpagent.
|
||||||
3. **Save** — both templates are posted to a2loft with a fresh OTP.
|
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
|
## Configuration
|
||||||
|
|
||||||
Everything except the cropper credentials is hardcoded in `tools/server.js` by
|
Everything except the credentials is hardcoded in `tools/server.js` by design —
|
||||||
design — port `4173`, portal `https://portal.yrka.app`, PocketBase
|
port `4173`, portal `https://portal.yrka.app`, PocketBase `https://pb.itcass.net`,
|
||||||
`https://pb.itcass.net`, a2loft `https://apis.a2loft.net`, agent
|
a2loft `https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, forge
|
||||||
`http://127.0.0.1:9000`, and the domain catalog. Change them there; there are no
|
`https://sources.yrka.app` and the domain catalog. Change them there. The only
|
||||||
other environment variables or config files.
|
environment variables are the cropper credentials and the fpagent installer key
|
||||||
|
`GITEA_RELEASE_INSTALL_KEY` above.
|
||||||
|
|
||||||
## Deployment
|
## 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.
|
||||||
+136
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -119,11 +119,14 @@ session; the OTP SHALL NOT be accepted without one.
|
|||||||
### Requirement: Configuration is hardcoded
|
### Requirement: Configuration is hardcoded
|
||||||
The app SHALL hardcode all configuration in source: port `4173`, portal
|
The app SHALL hardcode all configuration in source: port `4173`, portal
|
||||||
`https://portal.yrka.app`, PocketBase `https://pb.itcass.net`, a2loft
|
`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
|
`https://apis.a2loft.net`, agent `http://127.0.0.1:9000`, and the fingerprint
|
||||||
no runtime configuration via environment variables or config files, with one
|
agent release forge `https://sources.yrka.app`. There SHALL be no runtime
|
||||||
exception: the Image Cropper's service credentials (Minio keys, Azure storage
|
configuration via environment variables or config files, with two exceptions:
|
||||||
connection string, nodered credentials, cleanup-service key) SHALL come from
|
the Image Cropper's service credentials (Minio keys, Azure storage connection
|
||||||
environment variables and SHALL NOT be stored in source.
|
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
|
#### Scenario: No configuration required to run
|
||||||
- **WHEN** the app is started with no environment variables or config files
|
- **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
|
- **THEN** the cropper routes use them without any change to the hardcoded
|
||||||
URL/port configuration
|
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
|
### Requirement: Carbon Design System UI
|
||||||
The tools pages SHALL be built on the Carbon Design System: Carbon web
|
The tools pages SHALL be built on the Carbon Design System: Carbon web
|
||||||
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
|
components (version-pinned via CDN) for buttons, notifications, toasts, tags,
|
||||||
|
|||||||
Executable
+102
@@ -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
@@ -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 PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh
|
||||||
export const A2LOFT_BASE = 'https://apis.a2loft.net'; // staff lookup + fingerprint save
|
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 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 = [
|
export const DOMAINS = [
|
||||||
{ code: 'uc', name: 'UNICAL' },
|
{ code: 'uc', name: 'UNICAL' },
|
||||||
{ code: 'mtr', name: 'Metropolitan Hotel' },
|
{ code: 'mtr', name: 'Metropolitan Hotel' },
|
||||||
@@ -272,6 +277,10 @@ export function createApp(overrides = {}) {
|
|||||||
agentUrl: AGENT_URL,
|
agentUrl: AGENT_URL,
|
||||||
domains: DOMAINS,
|
domains: DOMAINS,
|
||||||
ttlMs: DOMAIN_TTL_MS,
|
ttlMs: DOMAIN_TTL_MS,
|
||||||
|
giteaUrl: GITEA_URL,
|
||||||
|
fpagentRepo: FPAGENT_REPO,
|
||||||
|
giteaToken: GITEA_RELEASE_KEY,
|
||||||
|
giteaFetch: fetch,
|
||||||
fetch: fetch,
|
fetch: fetch,
|
||||||
now: Date.now,
|
now: Date.now,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -314,12 +323,82 @@ export function createApp(overrides = {}) {
|
|||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Cache-Control',
|
'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
|
); // 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 ────────
|
// ── session middleware: recover / refresh the YOUreka Auth session ────────
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
req.session = null;
|
req.session = null;
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user