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:
co-authored by
Claude Opus 5
parent
1f4459985a
commit
d1e8668440
@@ -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
|
||||
@@ -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.
|
||||
Executable
+51
@@ -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
|
||||
Executable
+56
@@ -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
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user