The plugins' signed releases, run on the first XGS 3300, 2026-11-07
Until 2026-11-07 every plugin's self-update.py asked GitHub's API, or npuforge.org's copy of it, for
its latest release and the release's commit, and installed that commit's tarball as it came: the copy's
answer chose what the Plugins tab of XGS: Updates installed, as it had for this project's own update
until signed releases (1072,
G3 and U5).
Now a plugin's release is installed only as its owner signed it, as this project's is. Each release
carries three more files: the tag's tree as an archive, release.json naming the tag, its commit and
date and the archive's SHA-256 and size, and release.json.sig, the manifest's Ed25519 signature, made
with the project's release key by the owner's private signing repository. A plugin's own release
workflow makes the release a draft, with its package, and that repository signs and publishes it. Each
plugin installs the public key beside its self-update.py, fingerprint
13ed7f86a4a7669bdaee666d01894443e151da4051c1f500cb6406d7888fd33b. A check reads the latest release's
manifest from GitHub's download of it, not the API, and offers the release only when the signature
verifies and the release is not dated before what is installed. The update confirms the tag's commit
with GitHub's list of references, and installs the archive the manifest names only when its SHA-256
and size are the signed ones.
os-netreport went first (its pull request 43,
with its own page).
This page is the eight that followed - os-fanctl, os-frontpanel, os-linkhealth, os-watchdog,
os-kernelfollow, os-blocklists, os-portguard and os-fallbackcrypto - all at once, and then the last
two, opnsense-arabic and opnsense-rtl, whose self-update.py sits at the top of their repository
(the last two).
How it was run
The first XGS 3300 (vendor version 23), 01:40 to 01:47 UTC. Each plugin's branch was installed by its
own install/install.sh, as a hand install does. Every step after that went through the Plugins tab
itself: its Check, and its Update all.
A release cannot be put up on GitHub for a test, so each plugin read its latest release from a lab
place, as a test firewall may (releases-from, file://). The archive was made by git archive from
the tag, with its manifest, and signed with a throwaway key whose public half stood in for the release
key while the test ran. Each plugin's installed copy was said to be the release before it, so that the
latest was offered. Each latest release carries the self-update.py from before signing, so the update
left each plugin as it had been before the test. The log of every step is kept with the measurements
(plugins-old3300.log).
What it did
GitHub's own latest releases, published before signing, are not offered: the latest release is not signed yet: it has no release.json. Every installed plugin will say this until its next release, the first signed one.

The lab releases, signed with the throwaway key, while only the real release key was installed: not offered - the signature of the latest release does not verify with any release key of this firewall.

The throwaway key installed, and one byte of each archive changed after signing. All eight were
offered, since each manifest was as signed. Update all stopped each one once its archive was down:
the archive of v1.1.6 is not the one signed: 330461 bytes, SHA-256 bab56dee351636d7, where 330461
bytes, SHA-256 9c1541a37a62b02b were signed; nothing was installed, and the same for the other seven.
Updated 0 of 8 in 21 s. No installed.json changed, and nothing was left in /var/tmp. The bars stop
at Check for updates: the tab follows the files an update leaves in /var/tmp, and an archive read
from a local place in under half a second was gone before it looked; the message is the update's own.


Everything as signed: all eight offered and installed by Update all in 32 s, each one updated to
installed.json naming the signed commit with
the date signed. The fan controller, the front panel's client and watchdogd were running afterwards,
and the appliance was not restarted.

| Plugin | Release | Archive, bytes | Commit |
|---|---|---|---|
| os-fanctl | v1.1.6 | 330461 | 877803880c9a |
| os-frontpanel | v1.0.6 | 141501 | 7a2300fd17ec |
| os-linkhealth | v1.0.7 | 167263 | 27ad63267c09 |
| os-watchdog | v1.0.2 | 32324 | aae739e071e7 |
| os-kernelfollow | v1.0.3 | 45771 | 12554c5b17b0 |
| os-blocklists | v1.0.1 | 45908 | 05bdc59d2507 |
| os-portguard | v1.0.1 | 38033 | 8498e3f5f64a |
| os-fallbackcrypto | v1.0.1 | 36189 | c68414407d80 |
| opnsense-arabic | v1.0.6 | 748814 | 694a3f6b6838 |
| opnsense-rtl | v1.1 | 14865 | 5dd540ee4d7e |
The lab files, the lab places and the branches' trees were taken away after. The release-keys
directories went too, since the self-update.py installed again reads none. Each plugin's
self-update.py was the file it had been before the test, by its MD5.
Without a firewall, a plugin's test-self-update.py makes releases and signs them with throwaway keys: 42 checks of what a check takes and refuses and what an update installs. Each plugin's pull request for this change added it to that plugin's checks, and all 42 passed in each of the ten before it was run here.
The last two
opnsense-arabic keeps its self-update.py and its keys in /usr/local/share/opnsense-arabic, and
opnsense-rtl in /root/opnsense-rtl; each one's install.sh puts them there. Neither had a release
workflow - their releases were made by hand - so each was given one that makes the draft from an
annotated tag and starts the signing. They ran the same way on the same appliance, 01:57 to 02:01
UTC: GitHub's own latest releases, not signed yet, not offered; signed with the throwaway key while
only the release key was installed, not offered; one byte of each archive changed, both updates
stopped with nothing installed (Updated 0 of 2 in 4 s); as signed, both installed by Update all in
10 s. With them, all eleven plugins of the family had installed a release as signed on this
appliance.


A plugin that is not here
Until then the Plugins tab's Install took a missing plugin's self-update.py from
raw.githubusercontent.com at its latest release's tag, as it came, and ran it as root (1072, G3).
Now plugins.py reads that release's signed manifest and verifies it with this project's own
release key. It downloads the archive the manifest names and checks its SHA-256 and size, then takes
only the plugin's self-update.py and the release keys beside it out of that archive. That
self-update.py checks the release again with those keys before it installs anything.
Run on the same appliance, 02:21 to 02:28 UTC, with that change's plugins.py put in place.
os-portguard was taken away for the test by its own install/uninstall.sh (its settings stay) and put
back after.
GitHub's own latest release of os-portguard, published before signing, is listed with the reason, and Install is not offered: its latest release is not signed yet: it has no release.json.

Then a lab release, v1.0.1 by the commit GitHub's tag names. Its archive was os-portguard's main as
merged that day (its pull request 11), which holds the signed self-update.py, with the throwaway key
in place of the release key; it was signed with that key, which stood in beside the release key while
the test ran.
- One byte of the archive changed: Install refused at once - the archive of its latest release,
v1.0.1, is not the one signed; nothing was taken from it; nothing was installed - and nothing was
left in
/var/tmp. - As signed: Install installed it in 7 s - updated to v1.0.1 (8498e3f5f64a), signed with lab-key-b20d1046.pub - and the update's log named the archive as the one signed, 41015 bytes.

Afterwards os-portguard v1.0.1 was installed again from its tag, and its self-update.py was the file
it had been, by its MD5. The lab's files, places and key were taken away, and main's plugins.py was
put back. Without an appliance, tools/test-plugin-install.py runs 20 checks of the same, with
throwaway keys.
The releases
The same day nine of them were released, each signed with the release key: os-netreport 1.0.6, os-fanctl 1.1.7, os-frontpanel 1.0.7, os-linkhealth 1.0.8, os-watchdog 1.0.3, os-kernelfollow 1.0.4, os-blocklists 1.0.2, os-portguard 1.0.2 and os-fallbackcrypto 1.0.2, published from 11:44 to 12:15 UTC. Each release's workflow built its package and made the release a draft; the owner's signing repository signed it and published it, its archive, its manifest and the manifest's signature beside the package. opnsense-arabic and opnsense-rtl carry the same change and have no release of it yet.
Two faults on the way, both in the release workflows, none in what an appliance runs:
- os-netreport's run started no signing and still said success:
gh workflow runasked for the signing repository's default branch, which its token may not read. Its signing was started by hand; in all eleven plugins the start now names the branch, and a start that fails fails the run. - Seven tags pushed within a minute started seven signings. The signing repository signs one at a time, and GitHub keeps only the newest of the runs waiting, so four releases stayed drafts until one look over the family signed them, at 12:15.
Then each lab appliance took them through XGS: Updates' Plugins tab, Check and Update all: nine plugins in
65 s on each XGS 3300, eight in 59 s on the XGS 136, which has no front panel. What was installed ran the
self-update.py from before signed releases, so each took its release the old way, once. Afterwards each
plugin carries the release key, and its new self-update.py, asked to check, verifies the published release
with it.


The discussion of this page is held in the project's GitHub Discussions. Showing it here loads giscus from giscus.app and GitHub, and writing in it needs a GitHub account - what that means.