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
-12
View File
@@ -1,12 +0,0 @@
.git
node_modules
.next
*.sqlite
*.sqlite-*
clicker/.venv
clicker/__pycache__
scripts/update.log
scripts/.last-check
scripts/*.png
.env*
*.tsbuildinfo
-67
View File
@@ -1,67 +0,0 @@
# syntax=docker/dockerfile:1
#
# The hosted dashboard only. The clicker cannot run in here — it drives a real
# mouse and keyboard and needs a desktop session — and neither can the
# extension. Both stay on the Windows machine and talk to this over HTTPS.
#
# Debian rather than Alpine: better-sqlite3 publishes glibc prebuilds, and a
# musl image would compile it from source on every build.
# ── deps ─────────────────────────────────────────────────────────────────────
FROM node:24-slim AS deps
WORKDIR /app
# Only used if npm cannot find a prebuilt better-sqlite3 for this Node ABI.
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
# ── prod deps ────────────────────────────────────────────────────────────────
# A separate, dev-free install for the runtime image.
FROM node:24-slim AS proddeps
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ── build ────────────────────────────────────────────────────────────────────
FROM node:24-slim AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ── run ──────────────────────────────────────────────────────────────────────
FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
AUTOFIRMER_DB=/data/autotrader.sqlite
RUN groupadd -g 1001 nodejs && useradd -u 1001 -g nodejs -m nextjs
# The SQLite database lives here, on a named volume. Anything written inside the
# image is thrown away on the next deploy, and WAL mode also needs to create
# -wal and -shm files alongside the database, so the directory must be writable.
RUN mkdir -p /data && chown nextjs:nodejs /data
COPY --from=proddeps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --chown=nextjs:nodejs package.json next.config.mjs ./
COPY --chown=nextjs:nodejs public ./public
# /api/autobuyer/status reads the version out of this manifest and the extension
# reloads itself to match, so the folder has to travel with the server.
COPY --chown=nextjs:nodejs extension ./extension
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=10s --start-period=20s --retries=5 \
CMD node -e "fetch('http://127.0.0.1:3000/api/autobuyer/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["npm", "start"]
+29 -22
View File
@@ -131,11 +131,19 @@ pick it up from the server.
`chrome://extensions` → enable **Developer mode** → **Load unpacked** → `chrome://extensions` → enable **Developer mode** → **Load unpacked** →
select the `extension` folder. select the `extension` folder.
Chrome does **not** reload an unpacked extension when its files change, and it That is the development install. On a machine that just runs trades, install it
has no update channel for one either. The extension therefore checks the version from our own update server instead — one elevated command, and Chrome then keeps
the dashboard ships on every poll and reloads *itself* when it is behind, which it updated by itself:
re-reads the folder from disk. Clicking the reload icon on its card still works
and is the thing to try when something looks stuck. ```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1
```
See [deploy/README.md](deploy/README.md). Either way the extension notices when
the dashboard is running newer code than it is: a policy install asks Chrome to
fetch the new CRX, an unpacked one reloads itself from disk. Clicking the reload
icon on its card still works and is the thing to try when something looks
stuck.
Adding a new firm also means adding its host to `host_permissions` in Adding a new firm also means adding its host to `host_permissions` in
`extension/manifest.json` and reloading. Without it the extension cannot read `extension/manifest.json` and reloading. Without it the extension cannot read
@@ -282,22 +290,20 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
--- ---
## Hosting the dashboard on a server ## Publishing extension updates
The dashboard can run on the droplet instead of on the trading machine, with the The extension is signed and served from the droplet, which is what lets Chrome
clicker and the extension pointing at it over HTTPS. The clicker and the update it on its own. Everything else — the dashboard, the clicker — stays on
extension cannot move — one needs a desktop to send real input to, the other is the trading machine.
your browser.
```bash ```bash
cp deploy/.env.example deploy/.env # set FIRMER_HOST # on the droplet, in /opt/autofirmer
./deploy/bootstrap-tls.sh # first run: certificate, then deploy git pull && ./deploy/deploy.sh
./deploy/deploy.sh # every run after that
``` ```
It shares the droplet's existing nginx and certbot rather than standing up its That packs `extension/` at the version in its `manifest.json` — **bump it or
own, and the database lives on a Docker volume. There is no login on it. See nothing updates** — signs it, and publishes the CRX and update manifest. See
[deploy/README.md](deploy/README.md) before opening it to the internet. [deploy/README.md](deploy/README.md).
--- ---
@@ -341,12 +347,13 @@ node scripts\update-check.mjs
### What reloads itself, and what does not ### What reloads itself, and what does not
- **The extension** reloads itself now. `/api/autobuyer/status` reports the - **The extension** updates itself now. `/api/autobuyer/status` reports the
version the dashboard's own checkout ships; when the running build is older, version the dashboard's own checkout ships, and when the running build is
the extension calls `chrome.runtime.reload()` and comes back on the files the older the extension acts on it: a policy install asks Chrome to fetch the new
update check pulled. It only ever runs code already on disk, so if it reloads CRX from the update server, an unpacked one calls `chrome.runtime.reload()`
before the pull lands it says so in the popup and waits rather than restarting and comes back on the files the update check pulled. The unpacked path can
in a loop. Clicking reload on its card is still the manual fallback. only run code already on disk, so if it reloads before the pull lands it says
so in the popup and waits rather than restarting in a loop.
- **The scheduler is fine now.** It persists to the settings table and resumes - **The scheduler is fine now.** It persists to the settings table and resumes
after a restart, so an update no longer silently stops automated trading. after a restart, so an update no longer silently stops automated trading.
- **The runner** does not. It is a long-lived Python process; the update check - **The runner** does not. It is a long-lived Python process; the update check
+2 -5
View File
@@ -2,11 +2,8 @@ import { NextResponse } from 'next/server';
/** The Chromium extension calls these routes from a background service worker, /** The Chromium extension calls these routes from a background service worker,
* whose Origin is `chrome-extension://<id>`. The id changes every time the * whose Origin is `chrome-extension://<id>`. The id changes every time the
* unpacked extension is reloaded, so we allow any origin. * unpacked extension is reloaded, so we allow any origin — these routes are
* * only ever reachable on the LAN behind the port-3000 firewall rule. */
* That was harmless while this only ever answered on the LAN behind the
* port-3000 firewall rule. The hosted dashboard has no such fence — see
* deploy/README.md, "No login". */
export const CORS_HEADERS = { export const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
+10 -3
View File
@@ -1,6 +1,13 @@
# The hostname the dashboard answers on. Needs an A record pointing at the # The hostname the update server answers on. It has to match the `update_url` in
# droplet before bootstrap-tls.sh can get a certificate for it. # extension/manifest.json — Chrome checks the one baked into the extension, not
FIRMER_HOST=firmer.example.com # this — and needs an A record pointing at the droplet before bootstrap-tls.sh
# can get a certificate for it.
EXT_HOST=ext.example.com
# Where the signing key and the served files live. Outside the checkout on
# purpose: the private key must never be committed, and a release should survive
# `git clean`.
UPDATES_DIR=/opt/autofirmer-updates
# Where the autotrader stack lives. Its nginx is the shared ingress, its certbot # Where the autotrader stack lives. Its nginx is the shared ingress, its certbot
# issues the certificates, and its compose project owns the network this joins. # issues the certificates, and its compose project owns the network this joins.
+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 Chrome has no update channel for an unpacked extension: it reads the folder off
real mouse and keyboard and the extension runs inside your Chrome, so both stay disk once and never looks again. This is the supported alternative — the
on the Windows machine — they just point at the droplet instead of at extension is signed into a CRX, an update manifest is published next to it, and
`localhost`. Chrome installs and updates it from there like any other extension.
``` ```
Windows box 159.223.129.19 droplet 159.223.129.19 Windows box
clicker (runner.py) ──────┐ key.pem ──signs──> autofirmer.crx ──HTTPS──> Chrome (policy install)
Chrome + extension ───────┴──HTTPS──> nginx ──> autofirmer:3000 ──> /data/autotrader.sqlite updates.xml <──polls───
``` ```
This stack deliberately owns none of the ingress. The droplet already runs one Nothing here serves the dashboard. That still runs on the trading machine, next
nginx on 80/443 for `master.juicerroom.com` and `git.juicerroom.com`, from to the clicker, exactly as before.
`/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:
- The rendered `firmer.conf` is untracked in the autotrader repo. It survives ## The pieces
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. |---|---|
| `/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 - `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 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 refuses to reload on a bad config, and will not install the TLS server block
block until the certificate actually exists on disk. until the certificate actually exists.
## First run ## First run
```bash ```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 # DNS check, certificate, then deploy.sh
``` ```
`bootstrap-tls.sh` uses the autotrader stack's certbot container and Let's `bootstrap-tls.sh` borrows the autotrader stack's certbot container and Let's
Encrypt account, and reads `ACME_EMAIL` from its `.env` rather than keeping a Encrypt account, reading `ACME_EMAIL` from its `.env` rather than keeping a
second copy. The A record has to point at the droplet first, or the challenge second copy. The A record has to point at the droplet first.
cannot be answered.
## Shipping a change ## Publishing a release
```bash ```bash
git pull && ./deploy/deploy.sh git pull && ./deploy/deploy.sh
``` ```
The build runs before anything is swapped, so a broken commit leaves the running `deploy.sh` packs whatever is in `extension/` at the version its `manifest.json`
container serving. The database is on the `autofirmer-data` volume and is never declares, so **bump that version or nothing updates** — Chrome compares versions
touched by a deploy. 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 ## Installing it on a machine
`https://<FIRMER_HOST>`.
- **Clicker** — `python runner.py --api https://<FIRMER_HOST>`, or edit the
argument in the PM2 config that starts it.
Both keep working against `http://localhost:3000` instead; nothing here forces From an elevated PowerShell on the Windows box:
the hosted one.
## 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 Then remove the unpacked copy from `chrome://extensions` — it now has the same
without breaking the two clients: the extension and the clicker poll id, and Chrome will not load both — and restart Chrome. `-Remove` undoes it;
`/api/autobuyer/*` unattended with nowhere to put a password. Anyone who reaches `-Browser Edge` or `-Browser Brave` writes the same entry for those instead.
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.
## Updating the extension 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
The extension can no longer be updated by this stack alone, and it is worth is for), and there it reloads itself from disk rather than asking Chrome for an
being clear about why. Chrome has no update channel for an unpacked extension: update.
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.
+8 -8
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# First run only: get a certificate for FIRMER_HOST, then hand off to deploy.sh. # First run only: get a certificate for EXT_HOST, then hand off to deploy.sh.
# #
# This uses the autotrader stack's certbot container and its Let's Encrypt # This uses the autotrader stack's certbot container and its Let's Encrypt
# account, so it agrees to the Subscriber Agreement the same way that stack's # account, so it agrees to the Subscriber Agreement the same way that stack's
@@ -13,7 +13,7 @@ fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in" [ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a set -a; . ./.env; set +a
: "${FIRMER_HOST:?set FIRMER_HOST in .env}" : "${EXT_HOST:?set EXT_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}" SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
# ACME_EMAIL is not duplicated here: it belongs to the account that already # ACME_EMAIL is not duplicated here: it belongs to the account that already
@@ -27,24 +27,24 @@ log "Checking DNS"
ip=$(curl -fsS --max-time 5 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null \ ip=$(curl -fsS --max-time 5 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null \
|| curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null) \ || curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null) \
|| fail "could not determine this droplet's public IP" || fail "could not determine this droplet's public IP"
got=$(getent ahostsv4 "$FIRMER_HOST" 2>/dev/null | awk 'NR==1{print $1}') || true got=$(getent ahostsv4 "$EXT_HOST" 2>/dev/null | awk 'NR==1{print $1}') || true
echo " droplet: $ip" echo " droplet: $ip"
echo " $FIRMER_HOST -> ${got:-<unresolved>}" echo " $EXT_HOST -> ${got:-<unresolved>}"
[ "$got" = "$ip" ] || fail "$FIRMER_HOST does not resolve to $ip — fix DNS and wait for it to propagate" [ "$got" = "$ip" ] || fail "$EXT_HOST does not resolve to $ip — fix DNS and wait for it to propagate"
# The challenge is served over plain HTTP by the shared nginx, which needs a # The challenge is served over plain HTTP by the shared nginx, which needs a
# server block for this name before it will answer for it. # server block for this name before it will answer for it.
log "Rendering the HTTP-only config so the challenge can be served" log "Rendering the HTTP-only config so the challenge can be served"
envsubst '${FIRMER_HOST}' < nginx/firmer-acme.conf.template > "$SHARED_STACK/nginx/conf.d/firmer.conf" envsubst '${EXT_HOST}' < nginx/ext-acme.conf.template > "$SHARED_STACK/nginx/conf.d/ext.conf"
docker exec nginx nginx -t docker exec nginx nginx -t
docker exec nginx nginx -s reload docker exec nginx nginx -s reload
log "Requesting a certificate for $FIRMER_HOST" log "Requesting a certificate for $EXT_HOST"
docker compose --project-directory "$SHARED_STACK" -f "$SHARED_STACK/docker-compose.yml" \ docker compose --project-directory "$SHARED_STACK" -f "$SHARED_STACK/docker-compose.yml" \
run --rm --entrypoint certbot certbot \ run --rm --entrypoint certbot certbot \
certonly --webroot -w /var/www/certbot \ certonly --webroot -w /var/www/certbot \
--email "$ACME_EMAIL" --agree-tos --no-eff-email \ --email "$ACME_EMAIL" --agree-tos --no-eff-email \
--non-interactive -d "$FIRMER_HOST" \ --non-interactive -d "$EXT_HOST" \
|| fail "certbot failed — check DNS and that port 80 reaches this droplet" || fail "certbot failed — check DNS and that port 80 reaches this droplet"
log "Switching to the TLS config" log "Switching to the TLS config"
+12 -18
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Rebuild the dashboard image, restart the container, point the shared nginx at # Pack the current extension, serve it, and point the shared nginx at it.
# it. Safe to re-run any time — this is the normal "ship a change" command. # Safe to re-run any time — this is the normal "publish a release" command.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@@ -9,44 +9,38 @@ fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in" [ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a set -a; . ./.env; set +a
: "${FIRMER_HOST:?set FIRMER_HOST in .env}" : "${EXT_HOST:?set EXT_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}" SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
command -v envsubst >/dev/null || fail "envsubst missing — apt-get install -y gettext-base" command -v envsubst >/dev/null || fail "envsubst missing — apt-get install -y gettext-base"
[ -d "$SHARED_STACK/nginx/conf.d" ] || fail "no nginx/conf.d under $SHARED_STACK" [ -d "$SHARED_STACK/nginx/conf.d" ] || fail "no nginx/conf.d under $SHARED_STACK"
log "Building and starting" ./pack-extension.sh
docker compose up -d --build
log "Waiting for the container to report healthy" log "Starting the file server"
for _ in $(seq 1 30); do docker compose up -d
state=$(docker inspect autofirmer --format '{{.State.Health.Status}}' 2>/dev/null || echo missing)
[ "$state" = healthy ] && break
sleep 2
done
[ "${state:-}" = healthy ] || fail "autofirmer is $state — check: docker logs autofirmer"
# Rendered into the *other* stack's conf.d, because that is the directory its # Rendered into the *other* stack's conf.d, because that is the directory its
# nginx has mounted. The file is untracked there, and survives that stack's # nginx has mounted. The file is untracked there, and survives that stack's
# deploys and pulls; only its bootstrap-tls.sh wipes conf.d, and re-running this # pulls and deploys; only its bootstrap-tls.sh wipes conf.d, and re-running this
# script puts it back. # script puts it back.
# #
# Held back until the certificate exists: nginx refuses to load a server block # Held back until the certificate exists: nginx refuses to load a server block
# whose ssl_certificate is missing, and a broken file in that directory would # whose ssl_certificate is missing, and a broken file in that directory would
# also abort the autotrader deploys that reload the same nginx. # also abort the autotrader deploys that reload the same nginx.
if docker run --rm -v autotrader-vps_letsencrypt:/le alpine \ if docker run --rm -v autotrader-vps_letsencrypt:/le alpine \
test -f "/le/live/$FIRMER_HOST/fullchain.pem" 2>/dev/null; then test -f "/le/live/$EXT_HOST/fullchain.pem" 2>/dev/null; then
template=nginx/firmer.conf.template template=nginx/ext.conf.template
else else
log "No certificate for $FIRMER_HOST yet — rendering the HTTP-only config" log "No certificate for $EXT_HOST yet — rendering the HTTP-only config"
log "Run ./bootstrap-tls.sh once DNS points here, then this script again" log "Run ./bootstrap-tls.sh once DNS points here, then this script again"
template=nginx/firmer-acme.conf.template template=nginx/ext-acme.conf.template
fi fi
log "Rendering $template" log "Rendering $template"
# Named explicitly so envsubst leaves nginx's own $host, $remote_addr and the # Named explicitly so envsubst leaves nginx's own $host, $remote_addr and the
# rest alone. # rest alone.
envsubst '${FIRMER_HOST}' < "$template" > "$SHARED_STACK/nginx/conf.d/firmer.conf" envsubst '${EXT_HOST}' < "$template" > "$SHARED_STACK/nginx/conf.d/ext.conf"
log "Reloading the shared nginx" log "Reloading the shared nginx"
docker exec nginx nginx -t docker exec nginx nginx -t
+11 -18
View File
@@ -1,30 +1,23 @@
# The dashboard, alongside the autotrader stack rather than instead of it: the # A file server and nothing else: two static files, no application, no database.
# droplet already runs one nginx on 80/443, and this joins that network so the # It joins the autotrader stack's network so the nginx already on 80/443 can
# same ingress can proxy to it. Nothing here publishes a host port. # proxy to it — this publishes no host port of its own.
name: autofirmer name: autofirmer-updates
services: services:
autofirmer: updates:
build: image: nginx:alpine
context: .. container_name: autofirmer-updates
dockerfile: Dockerfile
image: autofirmer:local
container_name: autofirmer
restart: unless-stopped restart: unless-stopped
environment:
AUTOFIRMER_DB: /data/autotrader.sqlite
volumes: volumes:
- autofirmer-data:/data - ${UPDATES_DIR:-/opt/autofirmer-updates}/public:/srv:ro
- ./nginx/updates-static.conf:/etc/nginx/conf.d/default.conf:ro
expose: expose:
- "3000" - "80"
networks: [web] networks: [web]
networks: networks:
# Created by the autotrader stack. `external` so this compose file never tries # Created by the autotrader stack. `external` so this compose file never tries
# to own it — bringing this stack down must not take the ingress with it. # to own it — bringing this down must not take the ingress with it.
web: web:
name: ${SHARED_NETWORK:-autotrader-vps_web} name: ${SHARED_NETWORK:-autotrader-vps_web}
external: true external: true
volumes:
autofirmer-data:
@@ -4,7 +4,7 @@
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name ${FIRMER_HOST}; server_name ${EXT_HOST};
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
+45
View File
@@ -0,0 +1,45 @@
# The extension update server — rendered into the autotrader stack's nginx/conf.d
# by deploy.sh. Edit the template, not the rendered file.
server {
listen 80;
listen [::]:80;
server_name ${EXT_HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${EXT_HOST};
ssl_certificate /etc/letsencrypt/live/${EXT_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${EXT_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
# Two public files: the update manifest and the signed CRX. Both are meant to
# be readable by anyone — Chrome fetches them with no cookies and no way to
# authenticate, which is why there is no auth here. The CRX is signed, and
# carries no credentials; the private key stays on the droplet.
location / {
proxy_pass http://autofirmer-updates/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-52
View File
@@ -1,52 +0,0 @@
# AutoFirmer dashboard — rendered into the autotrader stack's nginx/conf.d by
# deploy.sh. Edit the template, not the rendered file.
server {
listen 80;
listen [::]:80;
server_name ${FIRMER_HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${FIRMER_HOST};
ssl_certificate /etc/letsencrypt/live/${FIRMER_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${FIRMER_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
# A capture posts a whole page's HTML, which runs to megabytes on the firm
# dashboards. nginx's 1m default would reject those with a 413.
client_max_body_size 32m;
# No basic auth, at the owner's request and because it could not work here
# anyway: the extension and the clicker poll /api/autobuyer/* unattended and
# have nowhere to put a password. Anyone who reaches this host can queue
# runs, read the settings table including the 2captcha key, and spend money.
location / {
proxy_pass http://autofirmer:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+25
View File
@@ -0,0 +1,25 @@
# Inside the little file-server container. Not the public-facing config — that
# is ext.conf.template, rendered into the shared nginx.
server {
listen 80;
root /srv;
# Replaces the whole MIME table, which is fine when the whole site is two
# files. Chrome is happy to take a CRX as octet-stream, but the updater logs
# are much easier to read when the type is right.
types {
application/x-chrome-extension crx;
text/xml xml;
}
default_type application/octet-stream;
# The update manifest is the thing Chrome polls to notice a release; a cached
# copy is a release that does not land. The CRX itself is named by version
# inside that manifest, so caching it would be safe — but it is one small
# file fetched once per update, and a single rule is easier to trust.
add_header Cache-Control "no-store" always;
location / {
autoindex off;
}
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Sign extension/ into a CRX and write the update manifest Chrome polls.
#
# The signing key is the extension's identity: the id Chrome derives from it is
# baked into every installed copy and into the Windows policy. Sign with a
# different key and you have published a different extension that no browser
# will ever accept as an update — so this refuses to mint one by accident.
set -euo pipefail
cd "$(dirname "$0")"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a
: "${EXT_HOST:?set EXT_HOST in .env}"
UPDATES_DIR="${UPDATES_DIR:-/opt/autofirmer-updates}"
KEY="$UPDATES_DIR/key.pem"
PUBLIC="$UPDATES_DIR/public"
EXTENSION="$(cd .. && pwd)/extension"
[ -f "$KEY" ] || fail "no signing key at $KEY. A new one would change the extension id and orphan every install. See deploy/README.md before generating one."
mkdir -p "$PUBLIC"
VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$EXTENSION/manifest.json")
[ -n "$VERSION" ] || fail "could not read the version out of the manifest"
# The id is the first 16 bytes of the SHA-256 of the public key, each hex digit
# shifted from 0-9a-f into a-p. Derived from the *manifest* here so it can be
# checked against what the packer writes: if the manifest's `key` and the
# signing key are not a pair, updates silently never apply, and this is the only
# moment that mismatch is visible.
manifest_id() {
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("key",""))' "$EXTENSION/manifest.json" \
| base64 -d 2>/dev/null | openssl dgst -sha256 -binary | xxd -p -c32 | cut -c1-32 | tr '0-9a-f' 'a-p'
}
log "Packing v$VERSION"
# Node lives in a container so the droplet needs no toolchain of its own. The
# key is mounted read-only: crx3 writes a new one when it cannot find the file,
# and that is the one failure mode worth making impossible.
docker run --rm \
-v "$EXTENSION:/ext:ro" \
-v "$KEY:/key.pem:ro" \
-v "$PUBLIC:/out" \
node:24-slim sh -c "cd /tmp && npm i --silent crx3@1 >/dev/null 2>&1 && \
node_modules/.bin/crx3 -p /key.pem -o /out/autofirmer.crx -x /out/updates.xml \
--crxURL https://$EXT_HOST/autofirmer.crx --appVersion '$VERSION' -- /ext" \
|| fail "packing failed"
EXPECTED=$(manifest_id)
if [ -n "$EXPECTED" ] && ! grep -q "appid=\"$EXPECTED\"" "$PUBLIC/updates.xml"; then
fail "the manifest's \`key\` and $KEY are not a pair — the update manifest advertises a different extension than the one people have installed"
fi
log "Published"
echo " id: ${EXPECTED:-<no key in manifest>}"
echo " version: $VERSION"
echo " crx: https://$EXT_HOST/autofirmer.crx"
echo " updates: https://$EXT_HOST/updates.xml"
+55 -13
View File
@@ -867,18 +867,47 @@ function compareVersions(a, b) {
return 0; return 0;
} }
/** Reload ourselves when the dashboard ships a newer build than the one running. /** How we were installed: 'admin' when Chrome policy pulled us from the update
* server, 'development' when somebody loaded the folder unpacked. getSelf is
* the one chrome.management call that needs no permission. */
async function installType() {
try {
const self = await chrome.management.getSelf();
return self.installType || 'development';
} catch {
return 'development';
}
}
/** chrome.runtime.requestUpdateCheck as a promise; null if the call is not
* available at all. */
function requestUpdateCheck() {
return new Promise((resolve) => {
try {
chrome.runtime.requestUpdateCheck((result) => {
if (chrome.runtime.lastError) return resolve(null);
resolve(result?.status || 'no_update');
});
} catch {
resolve(null);
}
});
}
/** Get onto the newer build the dashboard is advertising.
* *
* Chrome has no update channel for an unpacked extension — it reads the folder * Two different mechanisms, because there are two ways this extension gets
* once at startup and never looks again — but chrome.runtime.reload() does * installed. A policy install has a real update channel: Chrome polls the CRX
* re-read it from disk, and the updater is already pulling new files into that * on the droplet on its own schedule, and all we add is "check now" so a
* folder every few minutes. This closes the last manual step between the two. * release lands in seconds rather than within five hours.
* *
* It can only ever run code that is already on disk, so a dashboard that is * An unpacked load has no channel at all — Chrome reads the folder once and
* ahead of this machine's pull would have us reload into the same version over * never looks again — but chrome.runtime.reload() re-reads it from disk, which
* and over. Hence one attempt per version pair: if we come back unchanged, say * the update check has already pulled into. That can only ever run code that is
* so and wait for the pull instead of restarting forever. A dashboard that is * already there, so a dashboard ahead of this machine's pull would have us
* *behind* is left alone entirely — downgrading is not something we can do. */ * reload into the same version forever. Hence one attempt per version pair:
* come back unchanged and it says so and waits. A dashboard that is *behind* is
* left alone either way — downgrading is not something we can do. */
async function maybeSelfUpdate(expected) { async function maybeSelfUpdate(expected) {
const running = chrome.runtime.getManifest().version; const running = chrome.runtime.getManifest().version;
const { updateAttempt } = await chrome.storage.local.get('updateAttempt'); const { updateAttempt } = await chrome.storage.local.get('updateAttempt');
@@ -893,11 +922,24 @@ async function maybeSelfUpdate(expected) {
return; return;
} }
if ((await installType()) !== 'development') {
const status = await requestUpdateCheck();
// Chrome swaps the extension out from under us when the CRX arrives, so
// there is nothing to do afterwards but say what was asked for.
await setState({
error: status === 'throttled'
? `Running v${running}, v${expected} is published — Chrome is rate-limiting `
+ `update checks and will pick it up shortly.`
: `Running v${running}, asked Chrome to fetch v${expected} (${status || 'no answer'}).`,
});
return;
}
if (updateAttempt && updateAttempt.expected === expected && updateAttempt.running === running) { if (updateAttempt && updateAttempt.expected === expected && updateAttempt.running === running) {
await setState({ await setState({
error: `Running v${running}, the dashboard ships v${expected}. Reloaded once ` error: `Running v${running}, the dashboard ships v${expected}. Reloaded once and `
+ `and the files on disk were still the old ones — waiting for the next ` + `the files on disk were still the old ones — waiting for the next pull. `
+ `update. If this sticks, pull and reload the card in chrome://extensions.`, + `If this sticks, pull and reload the card in chrome://extensions.`,
}); });
return; return;
} }
+4
View File
@@ -2,6 +2,10 @@
"manifest_version": 3, "manifest_version": 3,
"name": "AutoFirmer Capture", "name": "AutoFirmer Capture",
"version": "0.12.0", "version": "0.12.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
"update_url": "https://ext.juicerroom.com/updates.xml",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.", "description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"], "permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [ "host_permissions": [
+1 -5
View File
@@ -1,11 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
// Next to the checkout by default, which is what every desktop instance wants. const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
// `AUTOFIRMER_DB` moves it onto a mounted volume for the hosted dashboard, where
// the working directory is inside a container image and is replaced on every
// deploy — a database written there would be a fresh empty one each time.
const db = new Database(process.env.AUTOFIRMER_DB || path.join(process.cwd(), 'autotrader.sqlite'));
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
-9
View File
@@ -1,9 +0,0 @@
/** @type {import('next').NextConfig} */
// Plain JS, not TypeScript: `next start` in the production image has no
// devDependencies, and a .ts config makes Next stop and install TypeScript —
// with yarn, into a read-only-by-intent node_modules — before it will boot.
const nextConfig = {
serverExternalPackages: ['better-sqlite3'],
};
export default nextConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ['better-sqlite3'],
};
export default nextConfig;
+101
View File
@@ -0,0 +1,101 @@
<#
.SYNOPSIS
Install the AutoFirmer extension from our own update server, via policy.
.DESCRIPTION
Chrome will not side-load an extension on Windows, and it will not update an
unpacked one. The supported way to run an extension you host yourself is an
enterprise policy: ExtensionInstallForcelist names the extension id and the
update manifest, and from then on Chrome installs it, keeps it updated on its
own schedule, and re-installs it if it goes missing.
Needs an elevated PowerShell — the policy lives under HKLM.
After this runs:
* Remove the unpacked copy from chrome://extensions. It has the same id
now, and Chrome will not load both.
* Restart Chrome, or open chrome://policy and press "Reload policies".
* A force-installed extension cannot be disabled or removed from the
extensions page. Re-run this with -Remove to take it off.
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1
#>
[CmdletBinding()]
param(
[ValidateSet('Chrome', 'Edge', 'Brave')]
[string]$Browser = 'Chrome',
# Derived from the signing key on the droplet. It only changes if the key does.
[string]$Id = 'lkjbblagjjbldlkeaaffhiopdlhniaan',
[string]$UpdateUrl = 'https://ext.juicerroom.com/updates.xml',
[switch]$Remove
)
$ErrorActionPreference = 'Stop'
$roots = @{
Chrome = 'HKLM:\SOFTWARE\Policies\Google\Chrome'
Edge = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge'
Brave = 'HKLM:\SOFTWARE\Policies\BraveSoftware\Brave'
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
if (-not ([Security.Principal.WindowsPrincipal]$identity).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this from an elevated PowerShell — the policy key is under HKLM.'
}
$key = Join-Path $roots[$Browser] 'ExtensionInstallForcelist'
$entry = "$Id;$UpdateUrl"
if ($Remove) {
if (-not (Test-Path $key)) { Write-Host "Nothing to remove — no forcelist for $Browser."; exit 0 }
$props = Get-ItemProperty $key
$found = $false
foreach ($name in ($props.PSObject.Properties.Name | Where-Object { $_ -notlike 'PS*' })) {
if ($props.$name -like "$Id;*") {
Remove-ItemProperty -Path $key -Name $name
Write-Host "Removed $name ($($props.$name))"
$found = $true
}
}
if (-not $found) { Write-Host "Nothing to remove — $Id was not in the $Browser forcelist." }
Write-Host "`nRestart $Browser to let the change take effect."
exit 0
}
if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }
# The forcelist is a list expressed as numbered values, so adding to it means
# finding a free number rather than overwriting whatever is already there —
# another extension may well be in the list.
$props = Get-ItemProperty $key
$existing = $props.PSObject.Properties |
Where-Object { $_.Name -notlike 'PS*' -and $_.Value -like "$Id;*" }
if ($existing) {
Set-ItemProperty -Path $key -Name $existing.Name -Value $entry
Write-Host "Updated $Browser forcelist entry $($existing.Name):"
} else {
$used = $props.PSObject.Properties.Name |
Where-Object { $_ -match '^\d+$' } | ForEach-Object { [int]$_ }
$slot = if ($used) { (($used | Measure-Object -Maximum).Maximum + 1) } else { 1 }
New-ItemProperty -Path $key -Name "$slot" -Value $entry -PropertyType String -Force | Out-Null
Write-Host "Added $Browser forcelist entry ${slot}:"
}
Write-Host " $entry"
Write-Host @"
Next:
1. Remove the unpacked copy from chrome://extensions (same id — both cannot load).
2. Restart $Browser, or open chrome://policy and press "Reload policies".
3. Check chrome://extensions: the card should say "Installed by enterprise policy".
From here $Browser updates it on its own. The extension also asks for a check as
soon as the dashboard reports a newer version, so a release normally lands in
seconds rather than waiting out Chrome's own cycle.
"@