Files
Brandon LiandClaude Opus 5 95c073d4c2 Publish the CRX from a timer, and refuse an unbumped version
Mirrors the autotrader stack's auto-deploy: fetch every minute, act when master
moves, lock against overlap, rewrite .last-check every run so a silent failure
is visible. Only extension/ and deploy/ changes publish - repacking for a docs
edit would republish an identical CRX every minute.

The guard is the part that earns its place. Chrome ignores a CRX that is not
strictly newer, so shipping extension changes without bumping manifest.json is
a no-op that looks exactly like success until someone notices the fix never
arrived. That now fails loudly instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:10:21 -05:00

181 lines
8.0 KiB
Markdown

# The extension update server
Chrome has no update channel for an unpacked extension: it reads the folder off
disk once and never looks again. This is the supported alternative — the
extension is signed into a CRX, an update manifest is published next to it, and
Chrome installs and updates it from there like any other extension.
```
droplet 159.223.129.19 Windows box
key.pem ──signs──> autofirmer.crx ──HTTPS──> Chrome (policy install)
updates.xml <──polls───
```
Nothing here serves the dashboard. That still runs on the trading machine, next
to the clicker, exactly as before.
## The pieces
| | |
|---|---|
| `/opt/autofirmer-updates/key.pem` | The signing key. **The extension's identity.** |
| `/opt/autofirmer-updates/public/` | `autofirmer.crx` and `updates.xml`, served as-is |
| `deploy/pack-extension.sh` | Signs `extension/` and writes both files |
| `deploy/docker-compose.yml` | An `nginx:alpine` that serves that folder and nothing else |
| `deploy/nginx/ext.conf.template` | Rendered into the autotrader stack's `conf.d` |
| `scripts/install-extension-policy.ps1` | The Windows side: one registry entry |
Extension id: `lkjbblagjjbldlkeaaffhiopdlhniaan`, derived from the public half of
the signing key and repeated in three places that must agree — `key` in
`extension/manifest.json`, `appid` in `updates.xml`, and the forcelist entry in
the policy script. `pack-extension.sh` refuses to publish when the first two
have drifted apart, because the failure otherwise is silent: Chrome simply never
sees an update that applies to it.
**Losing `key.pem` means losing the extension.** A new key is a new id, which
means a new policy entry on every machine and a fresh install rather than an
update. It exists in one place, deliberately — it is not in the repo, and a
compromised copy would let someone else publish an extension your browsers
would trust and install.
## Ingress
This stack owns no ingress. The droplet already runs one nginx on 80/443 for
`master.juicerroom.com` and `git.juicerroom.com`, from `/opt/autotrader/deploy`;
this joins that network, and `deploy.sh` renders a server block into that
stack's `nginx/conf.d`. Two consequences:
- The rendered `ext.conf` is untracked in the autotrader repo. It survives that
stack's pulls and deploys, but not its `bootstrap-tls.sh`, which clears
`conf.d` — re-run `./deploy.sh` here if that ever happens.
- `deploy.sh` reloads the shared nginx, so a mistake in the template would be a
mistake in front of the autotrader dashboard too. It runs `nginx -t` first and
refuses to reload on a bad config, and will not install the TLS server block
until the certificate actually exists.
## First run
```bash
cp .env.example .env # set EXT_HOST
./bootstrap-tls.sh # DNS check, certificate, then deploy.sh
```
`bootstrap-tls.sh` borrows the autotrader stack's certbot container and Let's
Encrypt account, reading `ACME_EMAIL` from its `.env` rather than keeping a
second copy. The A record has to point at the droplet first.
## Publishing a release
Automatic. `autofirmer-publish.timer` fetches every minute and publishes when
master moves — the same shape as the autotrader stack's auto-deploy next door.
Install it once:
```bash
./deploy/install-autopublish.sh # as root
```
Only changes under `extension/` or `deploy/` trigger a publish; anything else is
pulled and left alone, because repacking for a README edit would republish a
byte-identical CRX every minute.
**Bump `version` in `extension/manifest.json` for every release.** Chrome ignores
a CRX that is not strictly newer than the copy it has, so republishing at the
same version is a silent no-op — the kind discovered days later, when a fix
everyone believed had shipped turns out never to have left the building.
`auto-publish.sh` refuses that case outright and logs why, rather than letting
it pass quietly.
By hand, any time:
```bash
git pull && ./deploy/deploy.sh
```
Watch it with `tail -f deploy/publish.log`, and `cat deploy/.last-check` for the
heartbeat — that file is rewritten on every run, whether or not anything came
down, which is the only way to tell "running, nothing to do" from "not running".
The browsers pick it up on Chrome's own schedule, roughly every five hours. They
also pick it up within seconds if the dashboard is running, because the
extension asks Chrome for a check as soon as the dashboard reports a newer
version than the one it is running.
## Installing it on a machine
`setup-windows.bat` and `setup-linux.sh` both offer this as their last step, so
a fresh instance needs nothing extra. To do it by hand, or on a machine that was
set up before this existed:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1
```
```bash
sudo scripts/install-extension-policy.sh
```
Both configure every Chromium-family browser they find, and narrowing that to
one is `-Browser Chrome`.
### The machine has to be managed
This is the constraint the whole approach turns on, and it is worth stating
plainly. On Windows, Chromium force-installs an extension from outside its own
web store **only** when the instance is joined to an Active Directory domain,
joined to Entra ID, or enrolled in Chrome Enterprise Core. Edge says the same of
the Microsoft Store. On an ordinary machine the policy is read and then blanked:
```
Value ["[BLOCKED]lkjbblagjjbldlkeaaffhiopdlhniaan;https://ext.juicerroom.com/updates.xml"]
Error Error at ExtensionInstallForcelist[0]: Invalid extension ID.
Warning This computer is not detected as enterprise managed so policy can only
automatically install extensions hosted on the Chrome Webstore.
```
The "invalid extension ID" is just `[BLOCKED]` being parsed as the id. Nothing
about the CRX, the update manifest or the hive is wrong — the machine is what
Chrome is refusing. Both scripts check for this and write nothing rather than
leaving a permanent policy error and a "Managed by your organization" badge
behind; `-Force` overrides.
So the update server is only half a solution on an unmanaged box. The other half
is one of:
- **Load `extension/` unpacked.** No update channel, but it reloads itself from
disk when the dashboard reports a newer version, and the updater has already
pulled those files. Works today, any browser, nothing to enrol.
- **Enrol Chrome in Chrome Enterprise Core** (free, and it does not require a
domain-joined machine): one `CloudManagementEnrollmentToken` makes the browser
managed, and everything here then works as designed. Generate a token in the
Admin console under Devices > Chrome > Managed browsers, then:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\enroll-chrome-management.ps1 -Token <token>
```
```bash
sudo scripts/enroll-chrome-management.sh <token>
```
Both need admin/root — the token is machine-scoped — and both are Chrome only;
Chromium has no cloud-management support and Edge is not covered by any of
this. Once enrolled you can either keep using `install-extension-policy`, or
force-install the extension from the Admin console instead, which reaches
every enrolled machine at once.
- **Publish to a web store.** Store-hosted extensions update themselves with no
policy at all — at the price of a listing and a review on every release.
The Windows script writes to the current user's policy hive and needs no
elevation; `-Scope Machine` covers every account on the box and does need an
elevated shell. Linux has no per-user equivalent — Chrome only reads managed
policy from `/etc` — so that one is root-only and machine-wide. Both take
`-Remove` / `--remove`.
Afterwards, remove the unpacked copy from `chrome://extensions` — it now has the
same id, and Chrome will not load both — and restart the browser.
Development still works the old way: load `extension/` unpacked on a machine
with no policy entry. It keeps the same id (that is what `key` in the manifest
is for), and there it reloads itself from disk rather than asking Chrome for an
update.