Let the extension update itself, and host the dashboard

Chrome has no update channel for an unpacked extension, so every pull left one
manual step: click reload on its card. The dashboard now publishes the version
its own checkout ships at /api/autobuyer/status, and the extension reloads
itself when it is behind — chrome.runtime.reload() re-reads the folder from
disk, which the update check has already pulled into. It only ever runs code
that is on disk, so a reload that changes nothing is reported and waited out
rather than repeated; a dashboard that is behind is ignored.

Also adds the deploy stack for running the dashboard on the droplet, next to
the autotrader one: it joins that nginx's network and renders a server block
into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves
the SQLite file onto a volume, since the working directory in a container is
replaced on every deploy.

And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a
modal that closes by going display:none could never satisfy it. The extension
grew a visibility probe for this — locate cannot answer it, because absent and
hidden are both errors there and a wait gate has to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-08-31 12:30:59 -05:00
co-authored by Claude Opus 5
parent 1f4459985a
commit d1e8668440
18 changed files with 637 additions and 28 deletions
+11
View File
@@ -0,0 +1,11 @@
# The hostname the dashboard answers on. Needs an A record pointing at the
# droplet before bootstrap-tls.sh can get a certificate for it.
FIRMER_HOST=firmer.example.com
# 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.
SHARED_STACK=/opt/autotrader/deploy
# The docker network that stack created. Only set this if you renamed the
# project — the default matches `docker network ls`.
SHARED_NETWORK=autotrader-vps_web
+81
View File
@@ -0,0 +1,81 @@
# Hosting the dashboard on the droplet
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`.
```
Windows box 159.223.129.19
clicker (runner.py) ──────┐
Chrome + extension ───────┴──HTTPS──> nginx ──> autofirmer:3000 ──> /data/autotrader.sqlite
```
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:
- 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.
- `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.
## First run
```bash
cp .env.example .env # set FIRMER_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.
## Shipping a change
```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.
## Pointing the desktop at it
- **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.
Both keep working against `http://localhost:3000` instead; nothing here forces
the hosted one.
## No login
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.
## 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.
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# First run only: get a certificate for FIRMER_HOST, then hand off to deploy.sh.
#
# 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
# own bootstrap did (certbot --agree-tos):
# https://letsencrypt.org/repository/
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
: "${FIRMER_HOST:?set FIRMER_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
# ACME_EMAIL is not duplicated here: it belongs to the account that already
# holds the other certificates on this box.
[ -f "$SHARED_STACK/.env" ] || fail "no .env under $SHARED_STACK"
ACME_EMAIL=$(grep -E '^ACME_EMAIL=' "$SHARED_STACK/.env" | cut -d= -f2-)
[ -n "$ACME_EMAIL" ] || fail "ACME_EMAIL is not set in $SHARED_STACK/.env"
# Let's Encrypt rate-limits failures, so check DNS before spending an attempt.
log "Checking DNS"
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) \
|| fail "could not determine this droplet's public IP"
got=$(getent ahostsv4 "$FIRMER_HOST" 2>/dev/null | awk 'NR==1{print $1}') || true
echo " droplet: $ip"
echo " $FIRMER_HOST -> ${got:-<unresolved>}"
[ "$got" = "$ip" ] || fail "$FIRMER_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
# server block for this name before it will answer for it.
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"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
log "Requesting a certificate for $FIRMER_HOST"
docker compose --project-directory "$SHARED_STACK" -f "$SHARED_STACK/docker-compose.yml" \
run --rm --entrypoint certbot certbot \
certonly --webroot -w /var/www/certbot \
--email "$ACME_EMAIL" --agree-tos --no-eff-email \
--non-interactive -d "$FIRMER_HOST" \
|| fail "certbot failed — check DNS and that port 80 reaches this droplet"
log "Switching to the TLS config"
exec ./deploy.sh
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Rebuild the dashboard image, restart the container, point the shared nginx at
# it. Safe to re-run any time — this is the normal "ship a change" command.
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
: "${FIRMER_HOST:?set FIRMER_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
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"
log "Building and starting"
docker compose up -d --build
log "Waiting for the container to report healthy"
for _ in $(seq 1 30); do
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
# 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
# script puts it back.
#
# 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
# also abort the autotrader deploys that reload the same nginx.
if docker run --rm -v autotrader-vps_letsencrypt:/le alpine \
test -f "/le/live/$FIRMER_HOST/fullchain.pem" 2>/dev/null; then
template=nginx/firmer.conf.template
else
log "No certificate for $FIRMER_HOST yet — rendering the HTTP-only config"
log "Run ./bootstrap-tls.sh once DNS points here, then this script again"
template=nginx/firmer-acme.conf.template
fi
log "Rendering $template"
# Named explicitly so envsubst leaves nginx's own $host, $remote_addr and the
# rest alone.
envsubst '${FIRMER_HOST}' < "$template" > "$SHARED_STACK/nginx/conf.d/firmer.conf"
log "Reloading the shared nginx"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
log "Status"
docker compose ps
+30
View File
@@ -0,0 +1,30 @@
# The dashboard, alongside the autotrader stack rather than instead of it: the
# droplet already runs one nginx on 80/443, and this joins that network so the
# same ingress can proxy to it. Nothing here publishes a host port.
name: autofirmer
services:
autofirmer:
build:
context: ..
dockerfile: Dockerfile
image: autofirmer:local
container_name: autofirmer
restart: unless-stopped
environment:
AUTOFIRMER_DB: /data/autotrader.sqlite
volumes:
- autofirmer-data:/data
expose:
- "3000"
networks: [web]
networks:
# 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.
web:
name: ${SHARED_NETWORK:-autotrader-vps_web}
external: true
volumes:
autofirmer-data:
+16
View File
@@ -0,0 +1,16 @@
# HTTP only, for the window before a certificate exists: nginx will not start
# with an ssl_certificate that is not on disk yet, so the real config cannot go
# in until certbot has run. Rendered by bootstrap-tls.sh.
server {
listen 80;
listen [::]:80;
server_name ${FIRMER_HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 503;
}
}
+52
View File
@@ -0,0 +1,52 @@
# 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;
}
}