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:
co-authored by
Claude Opus 5
parent
ece6aab9cf
commit
035f93d220
+10
-3
@@ -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.
|
||||
|
||||
+70
-54
@@ -1,81 +1,97 @@
|
||||
# Hosting the dashboard on the droplet
|
||||
# The extension update server
|
||||
|
||||
The dashboard is the only piece that can live on a server. The clicker drives a
|
||||
real mouse and keyboard and the extension runs inside your Chrome, so both stay
|
||||
on the Windows machine — they just point at the droplet instead of at
|
||||
`localhost`.
|
||||
Chrome has no update channel for an unpacked extension: it reads the folder off
|
||||
disk once and never looks again. This is the supported alternative — the
|
||||
extension is signed into a CRX, an update manifest is published next to it, and
|
||||
Chrome installs and updates it from there like any other extension.
|
||||
|
||||
```
|
||||
Windows box 159.223.129.19
|
||||
clicker (runner.py) ──────┐
|
||||
Chrome + extension ───────┴──HTTPS──> nginx ──> autofirmer:3000 ──> /data/autotrader.sqlite
|
||||
droplet 159.223.129.19 Windows box
|
||||
key.pem ──signs──> autofirmer.crx ──HTTPS──> Chrome (policy install)
|
||||
updates.xml <──polls───
|
||||
```
|
||||
|
||||
This stack deliberately owns none of the ingress. The droplet already runs one
|
||||
nginx on 80/443 for `master.juicerroom.com` and `git.juicerroom.com`, from
|
||||
`/opt/autotrader/deploy`; this joins that network, and `deploy.sh` renders a
|
||||
server block into that stack's `nginx/conf.d`. Two consequences worth knowing:
|
||||
Nothing here serves the dashboard. That still runs on the trading machine, next
|
||||
to the clicker, exactly as before.
|
||||
|
||||
- The rendered `firmer.conf` is untracked in the autotrader repo. It survives
|
||||
that stack's pulls and deploys. It does **not** survive its
|
||||
`bootstrap-tls.sh`, which clears `conf.d` — re-run `./deploy.sh` here if that
|
||||
ever happens.
|
||||
## The pieces
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `/opt/autofirmer-updates/key.pem` | The signing key. **The extension's identity.** |
|
||||
| `/opt/autofirmer-updates/public/` | `autofirmer.crx` and `updates.xml`, served as-is |
|
||||
| `deploy/pack-extension.sh` | Signs `extension/` and writes both files |
|
||||
| `deploy/docker-compose.yml` | An `nginx:alpine` that serves that folder and nothing else |
|
||||
| `deploy/nginx/ext.conf.template` | Rendered into the autotrader stack's `conf.d` |
|
||||
| `scripts/install-extension-policy.ps1` | The Windows side: one registry entry |
|
||||
|
||||
Extension id: `lkjbblagjjbldlkeaaffhiopdlhniaan`, derived from the public half of
|
||||
the signing key and repeated in three places that must agree — `key` in
|
||||
`extension/manifest.json`, `appid` in `updates.xml`, and the forcelist entry in
|
||||
the policy script. `pack-extension.sh` refuses to publish when the first two
|
||||
have drifted apart, because the failure otherwise is silent: Chrome simply never
|
||||
sees an update that applies to it.
|
||||
|
||||
**Losing `key.pem` means losing the extension.** A new key is a new id, which
|
||||
means a new policy entry on every machine and a fresh install rather than an
|
||||
update. It exists in one place, deliberately — it is not in the repo, and a
|
||||
compromised copy would let someone else publish an extension your browsers
|
||||
would trust and install.
|
||||
|
||||
## Ingress
|
||||
|
||||
This stack owns no ingress. The droplet already runs one nginx on 80/443 for
|
||||
`master.juicerroom.com` and `git.juicerroom.com`, from `/opt/autotrader/deploy`;
|
||||
this joins that network, and `deploy.sh` renders a server block into that
|
||||
stack's `nginx/conf.d`. Two consequences:
|
||||
|
||||
- The rendered `ext.conf` is untracked in the autotrader repo. It survives that
|
||||
stack's pulls and deploys, but not its `bootstrap-tls.sh`, which clears
|
||||
`conf.d` — re-run `./deploy.sh` here if that ever happens.
|
||||
- `deploy.sh` reloads the shared nginx, so a mistake in the template would be a
|
||||
mistake in front of the autotrader dashboard too. It runs `nginx -t` first and
|
||||
refuses to reload on a bad config, and it will not install the TLS server
|
||||
block until the certificate actually exists on disk.
|
||||
refuses to reload on a bad config, and will not install the TLS server block
|
||||
until the certificate actually exists.
|
||||
|
||||
## First run
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set FIRMER_HOST
|
||||
cp .env.example .env # set EXT_HOST
|
||||
./bootstrap-tls.sh # DNS check, certificate, then deploy.sh
|
||||
```
|
||||
|
||||
`bootstrap-tls.sh` uses the autotrader stack's certbot container and Let's
|
||||
Encrypt account, and reads `ACME_EMAIL` from its `.env` rather than keeping a
|
||||
second copy. The A record has to point at the droplet first, or the challenge
|
||||
cannot be answered.
|
||||
`bootstrap-tls.sh` borrows the autotrader stack's certbot container and Let's
|
||||
Encrypt account, reading `ACME_EMAIL` from its `.env` rather than keeping a
|
||||
second copy. The A record has to point at the droplet first.
|
||||
|
||||
## Shipping a change
|
||||
## Publishing a release
|
||||
|
||||
```bash
|
||||
git pull && ./deploy/deploy.sh
|
||||
```
|
||||
|
||||
The build runs before anything is swapped, so a broken commit leaves the running
|
||||
container serving. The database is on the `autofirmer-data` volume and is never
|
||||
touched by a deploy.
|
||||
`deploy.sh` packs whatever is in `extension/` at the version its `manifest.json`
|
||||
declares, so **bump that version or nothing updates** — Chrome compares versions
|
||||
and ignores a CRX that is not newer than what it has.
|
||||
|
||||
## Pointing the desktop at it
|
||||
The browsers pick it up on Chrome's own schedule, roughly every five hours. They
|
||||
also pick it up within seconds if the dashboard is running, because the
|
||||
extension asks Chrome for a check as soon as the dashboard reports a newer
|
||||
version than the one it is running.
|
||||
|
||||
- **Extension** — open its popup and set the dashboard URL to
|
||||
`https://<FIRMER_HOST>`.
|
||||
- **Clicker** — `python runner.py --api https://<FIRMER_HOST>`, or edit the
|
||||
argument in the PM2 config that starts it.
|
||||
## Installing it on a machine
|
||||
|
||||
Both keep working against `http://localhost:3000` instead; nothing here forces
|
||||
the hosted one.
|
||||
From an elevated PowerShell on the Windows box:
|
||||
|
||||
## No login
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1
|
||||
```
|
||||
|
||||
There is none, at the owner's request, and basic auth could not be bolted on
|
||||
without breaking the two clients: the extension and the clicker poll
|
||||
`/api/autobuyer/*` unattended with nowhere to put a password. Anyone who reaches
|
||||
the host can queue runs, read the settings table — the 2captcha key included —
|
||||
and spend money. If that changes, the options are an IP allowlist in the nginx
|
||||
template, or a shared secret that both clients learn to send.
|
||||
Then remove the unpacked copy from `chrome://extensions` — it now has the same
|
||||
id, and Chrome will not load both — and restart Chrome. `-Remove` undoes it;
|
||||
`-Browser Edge` or `-Browser Brave` writes the same entry for those instead.
|
||||
|
||||
## Updating the extension
|
||||
|
||||
The extension can no longer be updated by this stack alone, and it is worth
|
||||
being clear about why. Chrome has no update channel for an unpacked extension:
|
||||
it reads the folder off disk once and never looks again. What the dashboard
|
||||
does is *publish* the version its own checkout ships, at
|
||||
`/api/autobuyer/status`. The extension compares that against its own manifest
|
||||
and calls `chrome.runtime.reload()`, which re-reads the folder from disk.
|
||||
|
||||
So the files still arrive the way they always did — `scripts/update-check.mjs`
|
||||
pulling on the Windows box every five minutes. If the droplet is ahead of that
|
||||
pull, the extension reloads once, notices nothing changed, and waits instead of
|
||||
looping; the popup says as much.
|
||||
Development still works the old way: load `extension/` unpacked on a machine
|
||||
with no policy entry. It keeps the same id (that is what `key` in the manifest
|
||||
is for), and there it reloads itself from disk rather than asking Chrome for an
|
||||
update.
|
||||
|
||||
@@ -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:-<unresolved>}"
|
||||
[ "$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:-<unresolved>}"
|
||||
[ "$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"
|
||||
|
||||
+12
-18
@@ -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
|
||||
|
||||
+11
-18
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+61
@@ -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"
|
||||
Reference in New Issue
Block a user