Ship the extension as a signed CRX from our own update server

The dashboard-hosting stack from the previous commit is gone; it was answering
a question nobody asked. What was actually needed is Chrome's real update
channel, which an unpacked extension does not have and cannot be given.

So the extension is now signed into a CRX with an update manifest beside it,
served from the droplet, and installed on the trading machines by policy
(ExtensionInstallForcelist). From there Chrome installs it, updates it on its
own schedule, and puts it back if it goes missing. The manifest gained the
`key` that fixes the extension id, so an unpacked development load keeps the
same identity as the published build.

The self-update check now branches on how it was installed: policy installs ask
Chrome to check the update server now rather than waiting out its five-hour
cycle, and unpacked loads keep the reload-from-disk path. chrome.management
.getSelf() tells the two apart and needs no permission to do it.

The signing key lives at /opt/autofirmer-updates/key.pem on the droplet and
nowhere else. It is the extension's identity: a different key is a different
extension, and no browser would take it as an update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 12:50:01 -05:00
co-authored by Claude Opus 5
parent ece6aab9cf
commit 035f93d220
20 changed files with 443 additions and 288 deletions
+70 -54
View File
@@ -1,81 +1,97 @@
# Hosting the dashboard on the droplet
# The extension update server
The dashboard is the only piece that can live on a server. The clicker drives a
real mouse and keyboard and the extension runs inside your Chrome, so both stay
on the Windows machine — they just point at the droplet instead of at
`localhost`.
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.
```
Windows box 159.223.129.19
clicker (runner.py) ──────┐
Chrome + extension ───────┴──HTTPS──> nginx ──> autofirmer:3000 ──> /data/autotrader.sqlite
droplet 159.223.129.19 Windows box
key.pem ──signs──> autofirmer.crx ──HTTPS──> Chrome (policy install)
updates.xml <──polls───
```
This stack deliberately owns none of the 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 worth knowing:
Nothing here serves the dashboard. That still runs on the trading machine, next
to the clicker, exactly as before.
- The rendered `firmer.conf` is untracked in the autotrader repo. It survives
that stack's pulls and deploys. It does **not** survive its
`bootstrap-tls.sh`, which clears `conf.d` — re-run `./deploy.sh` here if that
ever happens.
## 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 it will not install the TLS server
block until the certificate actually exists on disk.
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 FIRMER_HOST
cp .env.example .env # set EXT_HOST
./bootstrap-tls.sh # DNS check, certificate, then deploy.sh
```
`bootstrap-tls.sh` uses the autotrader stack's certbot container and Let's
Encrypt account, and reads `ACME_EMAIL` from its `.env` rather than keeping a
second copy. The A record has to point at the droplet first, or the challenge
cannot be answered.
`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.
## Shipping a change
## Publishing a release
```bash
git pull && ./deploy/deploy.sh
```
The build runs before anything is swapped, so a broken commit leaves the running
container serving. The database is on the `autofirmer-data` volume and is never
touched by a deploy.
`deploy.sh` packs whatever is in `extension/` at the version its `manifest.json`
declares, so **bump that version or nothing updates** — Chrome compares versions
and ignores a CRX that is not newer than what it has.
## Pointing the desktop at it
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.
- **Extension** — open its popup and set the dashboard URL to
`https://<FIRMER_HOST>`.
- **Clicker** — `python runner.py --api https://<FIRMER_HOST>`, or edit the
argument in the PM2 config that starts it.
## Installing it on a machine
Both keep working against `http://localhost:3000` instead; nothing here forces
the hosted one.
From an elevated PowerShell on the Windows box:
## No login
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1
```
There is none, at the owner's request, and basic auth could not be bolted on
without breaking the two clients: the extension and the clicker poll
`/api/autobuyer/*` unattended with nowhere to put a password. Anyone who reaches
the host can queue runs, read the settings table — the 2captcha key included —
and spend money. If that changes, the options are an IP allowlist in the nginx
template, or a shared secret that both clients learn to send.
Then remove the unpacked copy from `chrome://extensions` — it now has the same
id, and Chrome will not load both — and restart Chrome. `-Remove` undoes it;
`-Browser Edge` or `-Browser Brave` writes the same entry for those instead.
## Updating the extension
The extension can no longer be updated by this stack alone, and it is worth
being clear about why. Chrome has no update channel for an unpacked extension:
it reads the folder off disk once and never looks again. What the dashboard
does is *publish* the version its own checkout ships, at
`/api/autobuyer/status`. The extension compares that against its own manifest
and calls `chrome.runtime.reload()`, which re-reads the folder from disk.
So the files still arrive the way they always did — `scripts/update-check.mjs`
pulling on the Windows box every five minutes. If the droplet is ahead of that
pull, the extension reloads once, notices nothing changed, and waits instead of
looping; the popup says as much.
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.