diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 4970304..0000000 --- a/.dockerignore +++ /dev/null @@ -1,12 +0,0 @@ -.git -node_modules -.next -*.sqlite -*.sqlite-* -clicker/.venv -clicker/__pycache__ -scripts/update.log -scripts/.last-check -scripts/*.png -.env* -*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0932d4e..0000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/README.md b/README.md index d02e217..bbe44f9 100644 --- a/README.md +++ b/README.md @@ -131,11 +131,19 @@ pick it up from the server. `chrome://extensions` → enable **Developer mode** → **Load unpacked** → select the `extension` folder. -Chrome does **not** reload an unpacked extension when its files change, and it -has no update channel for one either. The extension therefore checks the version -the dashboard ships on every poll and reloads *itself* when it is behind, which -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. +That is the development install. On a machine that just runs trades, install it +from our own update server instead — one elevated command, and Chrome then keeps +it updated by itself: + +```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 `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 -clicker and the extension pointing at it over HTTPS. The clicker and the -extension cannot move — one needs a desktop to send real input to, the other is -your browser. +The extension is signed and served from the droplet, which is what lets Chrome +update it on its own. Everything else — the dashboard, the clicker — stays on +the trading machine. ```bash -cp deploy/.env.example deploy/.env # set FIRMER_HOST -./deploy/bootstrap-tls.sh # first run: certificate, then deploy -./deploy/deploy.sh # every run after that +# on the droplet, in /opt/autofirmer +git pull && ./deploy/deploy.sh ``` -It shares the droplet's existing nginx and certbot rather than standing up its -own, and the database lives on a Docker volume. There is no login on it. See -[deploy/README.md](deploy/README.md) before opening it to the internet. +That packs `extension/` at the version in its `manifest.json` — **bump it or +nothing updates** — signs it, and publishes the CRX and update manifest. See +[deploy/README.md](deploy/README.md). --- @@ -341,12 +347,13 @@ node scripts\update-check.mjs ### What reloads itself, and what does not -- **The extension** reloads itself now. `/api/autobuyer/status` reports the - version the dashboard's own checkout ships; when the running build is older, - the extension calls `chrome.runtime.reload()` and comes back on the files the - update check pulled. It only ever runs 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. Clicking reload on its card is still the manual fallback. +- **The extension** updates itself now. `/api/autobuyer/status` reports the + version the dashboard's own checkout ships, and when the running build is + older the extension acts on it: a policy install asks Chrome to fetch the new + CRX from the update server, an unpacked one calls `chrome.runtime.reload()` + and comes back on the files the update check pulled. The unpacked path can + 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 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 diff --git a/app/api/autobuyer/cors.ts b/app/api/autobuyer/cors.ts index 4bd0170..e2af0c8 100644 --- a/app/api/autobuyer/cors.ts +++ b/app/api/autobuyer/cors.ts @@ -2,11 +2,8 @@ import { NextResponse } from 'next/server'; /** The Chromium extension calls these routes from a background service worker, * whose Origin is `chrome-extension://`. The id changes every time the - * unpacked extension is reloaded, so we allow any origin. - * - * 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". */ + * unpacked extension is reloaded, so we allow any origin — these routes are + * only ever reachable on the LAN behind the port-3000 firewall rule. */ export const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', diff --git a/deploy/.env.example b/deploy/.env.example index d2fab06..cba4ce9 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,6 +1,13 @@ -# 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 +# The hostname the update server answers on. It has to match the `update_url` in +# extension/manifest.json — Chrome checks the one baked into the extension, not +# 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 # issues the certificates, and its compose project owns the network this joins. diff --git a/deploy/README.md b/deploy/README.md index a788f35..9027018 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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://`. -- **Clicker** — `python runner.py --api https://`, 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. diff --git a/deploy/bootstrap-tls.sh b/deploy/bootstrap-tls.sh index 3280369..29dd261 100755 --- a/deploy/bootstrap-tls.sh +++ b/deploy/bootstrap-tls.sh @@ -1,5 +1,5 @@ #!/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 # 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" 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}" # 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 \ || 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:-}" -[ "$got" = "$ip" ] || fail "$FIRMER_HOST does not resolve to $ip — fix DNS and wait for it to propagate" +got=$(getent ahostsv4 "$EXT_HOST" 2>/dev/null | awk 'NR==1{print $1}') || true +echo " droplet: $ip" +echo " $EXT_HOST -> ${got:-}" +[ "$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 # 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" +envsubst '${EXT_HOST}' < nginx/ext-acme.conf.template > "$SHARED_STACK/nginx/conf.d/ext.conf" docker exec nginx nginx -t 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" \ run --rm --entrypoint certbot certbot \ certonly --webroot -w /var/www/certbot \ --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" log "Switching to the TLS config" diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 7c374db..e0de048 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,6 +1,6 @@ #!/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. +# Pack the current extension, serve it, and point the shared nginx at it. +# Safe to re-run any time — this is the normal "publish a release" command. set -euo pipefail 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" 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}" 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 +./pack-extension.sh -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" +log "Starting the file server" +docker compose up -d # 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 +# pulls and deploys; 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 + test -f "/le/live/$EXT_HOST/fullchain.pem" 2>/dev/null; then + template=nginx/ext.conf.template 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" - template=nginx/firmer-acme.conf.template + template=nginx/ext-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" +envsubst '${EXT_HOST}' < "$template" > "$SHARED_STACK/nginx/conf.d/ext.conf" log "Reloading the shared nginx" docker exec nginx nginx -t diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index dea6ff1..92e03ef 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,30 +1,23 @@ -# 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 +# A file server and nothing else: two static files, no application, no database. +# It joins the autotrader stack's network so the nginx already on 80/443 can +# proxy to it — this publishes no host port of its own. +name: autofirmer-updates services: - autofirmer: - build: - context: .. - dockerfile: Dockerfile - image: autofirmer:local - container_name: autofirmer + updates: + image: nginx:alpine + container_name: autofirmer-updates restart: unless-stopped - environment: - AUTOFIRMER_DB: /data/autotrader.sqlite volumes: - - autofirmer-data:/data + - ${UPDATES_DIR:-/opt/autofirmer-updates}/public:/srv:ro + - ./nginx/updates-static.conf:/etc/nginx/conf.d/default.conf:ro expose: - - "3000" + - "80" 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. + # to own it — bringing this down must not take the ingress with it. web: name: ${SHARED_NETWORK:-autotrader-vps_web} external: true - -volumes: - autofirmer-data: diff --git a/deploy/nginx/firmer-acme.conf.template b/deploy/nginx/ext-acme.conf.template similarity index 92% rename from deploy/nginx/firmer-acme.conf.template rename to deploy/nginx/ext-acme.conf.template index 39ca3ef..a9de7dc 100644 --- a/deploy/nginx/firmer-acme.conf.template +++ b/deploy/nginx/ext-acme.conf.template @@ -4,7 +4,7 @@ server { listen 80; listen [::]:80; - server_name ${FIRMER_HOST}; + server_name ${EXT_HOST}; location /.well-known/acme-challenge/ { root /var/www/certbot; diff --git a/deploy/nginx/ext.conf.template b/deploy/nginx/ext.conf.template new file mode 100644 index 0000000..db9e712 --- /dev/null +++ b/deploy/nginx/ext.conf.template @@ -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; + } +} diff --git a/deploy/nginx/firmer.conf.template b/deploy/nginx/firmer.conf.template deleted file mode 100644 index 1767912..0000000 --- a/deploy/nginx/firmer.conf.template +++ /dev/null @@ -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; - } -} diff --git a/deploy/nginx/updates-static.conf b/deploy/nginx/updates-static.conf new file mode 100644 index 0000000..80ee484 --- /dev/null +++ b/deploy/nginx/updates-static.conf @@ -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; + } +} diff --git a/deploy/pack-extension.sh b/deploy/pack-extension.sh new file mode 100755 index 0000000..86bf24b --- /dev/null +++ b/deploy/pack-extension.sh @@ -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:-}" +echo " version: $VERSION" +echo " crx: https://$EXT_HOST/autofirmer.crx" +echo " updates: https://$EXT_HOST/updates.xml" diff --git a/extension/background.js b/extension/background.js index 13ca60a..151a121 100644 --- a/extension/background.js +++ b/extension/background.js @@ -867,18 +867,47 @@ function compareVersions(a, b) { 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 - * once at startup and never looks again — but chrome.runtime.reload() does - * re-read it from disk, and the updater is already pulling new files into that - * folder every few minutes. This closes the last manual step between the two. + * Two different mechanisms, because there are two ways this extension gets + * installed. A policy install has a real update channel: Chrome polls the CRX + * on the droplet on its own schedule, and all we add is "check now" so a + * 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 - * ahead of this machine's pull would have us reload into the same version over - * and over. Hence one attempt per version pair: if we come back unchanged, say - * so and wait for the pull instead of restarting forever. A dashboard that is - * *behind* is left alone entirely — downgrading is not something we can do. */ + * An unpacked load has no channel at all — Chrome reads the folder once and + * never looks again — but chrome.runtime.reload() re-reads it from disk, which + * the update check has already pulled into. That can only ever run code that is + * already there, so a dashboard ahead of this machine's pull would have us + * 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) { const running = chrome.runtime.getManifest().version; const { updateAttempt } = await chrome.storage.local.get('updateAttempt'); @@ -893,11 +922,24 @@ async function maybeSelfUpdate(expected) { 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) { await setState({ - error: `Running v${running}, the dashboard ships v${expected}. Reloaded once ` - + `and the files on disk were still the old ones — waiting for the next ` - + `update. If this sticks, pull and reload the card in chrome://extensions.`, + error: `Running v${running}, the dashboard ships v${expected}. Reloaded once and ` + + `the files on disk were still the old ones — waiting for the next pull. ` + + `If this sticks, pull and reload the card in chrome://extensions.`, }); return; } diff --git a/extension/manifest.json b/extension/manifest.json index 26c7c26..4f1849e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,6 +2,10 @@ "manifest_version": 3, "name": "AutoFirmer Capture", "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.", "permissions": ["scripting", "tabs", "storage", "alarms"], "host_permissions": [ diff --git a/lib/db.ts b/lib/db.ts index 9f71356..ffd6496 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,11 +1,7 @@ import Database from 'better-sqlite3'; import path from 'path'; -// Next to the checkout by default, which is what every desktop instance wants. -// `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')); +const db = new Database(path.join(process.cwd(), 'autotrader.sqlite')); db.pragma('foreign_keys = ON'); diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index 5579de9..0000000 --- a/next.config.mjs +++ /dev/null @@ -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; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..40988d5 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + serverExternalPackages: ['better-sqlite3'], +}; + +export default nextConfig; diff --git a/scripts/install-extension-policy.ps1 b/scripts/install-extension-policy.ps1 new file mode 100644 index 0000000..696a9d0 --- /dev/null +++ b/scripts/install-extension-policy.ps1 @@ -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. +"@