CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# Changelog
Change history for unmask. Format follows [Keep a Changelog](https://keepachangelog.com/).
Versioning follows [Semantic Versioning](https://semver.org/).
## Conventions
- Each entry starts with `(YYYY-MM-DD)` — the date the change landed.
- Within a release, entries are sorted by date descending (newest at top).
- An entry is a bold one-line title plus one to three sentences: what was
wrong or what is new, what changes for the operator, and — for a security
entry — how it was reachable and which release closes it. About 40–70
words. The reasoning behind a change belongs in the commit message.
## [Unreleased]
### Changed
- (2026-11-11) **The rpm and deb packages no longer install nginx.** `unmask-plugin-nginx` and `unmask-web-nginx` required `nginx`; on a host whose nginx no package installed, `dnf` and `apt` met that with the distribution's, beside the one in use. nginx is now a suggestion: install it first on a host without one. Where `dnf` brought nginx in for an earlier unmask, `sudo dnf mark install nginx` keeps `dnf autoremove` off it.
### Fixed
- (2026-11-11) **After a host nginx upgrade, the module is re-picked before nginx tests its configuration.** The re-pick ran as a step of `nginx.service`, after the `nginx -t` of the distributions' own units; that test failed on the module left from the old version, so nginx did not start and the re-pick never ran. It is now a unit of its own, `unmask-place-module.service`, started before anything of nginx's service.
- (2026-11-11) **An nginx built without the realip module is told so, and keeps starting.** unmask's include files read `$realip_remote_addr`. An nginx configured without `--with-http_realip_module` -- a plain `./configure` -- refused its whole configuration once they were included, in an error that names no file: the fail-safe did not act, and nginx did not start. The packages now wire nothing into such an nginx and name the missing option.
- (2026-11-11) **An nginx linked into `/usr/local/sbin` is found by the packages.** `apt` and `rpm` run a package's scripts with a PATH that leaves `/usr/local` out, so an nginx built by hand and linked there was reported as not installed -- while the nginx service's own start saw it and wired half of unmask. The scripts now look there too when the PATH they are given has no nginx.
### Security
- (2026-11-11) **A revoked admin session stays revoked.** Every admin page and the setup wizard now make one account check: the wizard opened for the cookie of a deleted superadmin, and a lookup that failed kept any session -- a client could make it fail by hanging up on its own request. When the database does not answer, the request is refused and the session kept.
- (2026-11-11) **A passed challenge never leaves the site.** The page went on to the address it was opened at as a path alone, and a browser reads a path that begins with `//` as another site's address: a link to `https://site//other.example/` sent a visitor who passed the challenge to other.example. The page, its relay and the re-bind bounce now go on to the full address on the site.
- (2026-11-11) **A request cannot write fields of its own into the access-log line.** The daemon reads it field by field, and a Host header with spaces -- nginx before 1.21.1 accepts one -- could name a honeypot hit and an address to ban. The Host, a forwarded JA4 and the trap URL are now logged without spaces, and a line cut short is dropped unread. Reload nginx after upgrading.
- (2026-11-11) **A Web Bot Auth or Privacy Pass header no longer skips the site's own checks.** The verification detour counted any pass from the daemon's check as a verified credential, including the pass a proof-of-work cookie earns, so adding a junk `Signature-Input` or `PrivateToken` header skipped CAPTCHA-grade rules and the header checks. Only the daemon's answer for a verified signature or token counts now.
## [0.1.57-rc1] - 2026-11-11 (testing build)
### Added
- (2026-11-10) **An nginx built from source can run the module.** For an nginx no package installed -- built by hand, or by a hosting panel -- the plugin placed nothing. The packages keep the module and their include files in their own directories, edit nothing of that nginx, and name the two `include` lines to add to its `nginx.conf`. It must be built `--with-compat`; the plugin says when it is not.
- (2026-11-10) **An nginx built from source can be built again without stopping on unmask.** A panel that installs another nginx build tests the new configuration before it starts anything; a module built for the old binary, or a site including `server.inc` after the panel rewrote `nginx.conf`, failed that test, and no nginx ran. unmask now watches the binary: when it changes, the module comes out and its include files are rendered empty until the next start can load them.
### Changed
- (2026-11-10) **The image mirror's `latest` follows the published release.** `ghcr.io/unmask-sh/unmask:latest` moved when a release was tagged -- before its packages had passed the release checks, and while the release page was still a draft. Tagging now pushes the image under its version only; `latest` is pointed at that image, by digest, once the published release has verified. `unmask.sh/unmask` was never ahead of a release.
### Fixed
- (2026-11-10) **Compiled into nginx (`--add-module`), the module no longer breaks an nginx with a static OpenSSL.** The module's build file named `-lcrypto` for that build too, ahead of the `libcrypto.a` of an nginx configured `--with-openssl=DIR`: the binary mixed two OpenSSL releases and died of a segfault at its first start, with no build error. Compiled in, the module now leaves OpenSSL to nginx. The packaged module is unchanged, byte for byte.
- (2026-11-10) **An nginx with a static OpenSSL gets a module the system can load.** For an nginx with OpenSSL linked in, the plugin picked the module by nginx's own OpenSSL: a static 1.1.1 on a distribution that only has `libcrypto.so.3` got a module whose library could not be found. The module calls the OpenSSL inside nginx either way, so it is now picked by the libcrypto the system has.
- (2026-11-10) **The daemon shares its sockets with the group nginx's workers run in.** The group was the first of a fixed list that exists on the host -- `www-data` on every Debian, whatever nginx runs as -- so a worker of another group could not write the access-log socket, and its lines never reached the dashboard. The `user` of `nginx.conf` now decides, then the binary's configured group, then a group the service was given; the list comes last.
- (2026-11-10) **The fail-safe's last step: include files with nothing in them.** When `nginx -t` still failed on unmask's own files with the module taken out -- `http.inc` not included, or included after the sites -- the fail-safe had nothing left to do and nginx did not start. `server.inc` and `protect.inc` are then rendered without a directive: the sites serve as they would without unmask, and `unmask doctor` names the cause.
- (2026-11-10) **`unmask doctor` no longer reports the plugin's own directory as residue.** Since 0.1.56 the plugin notes where it placed the module in `/var/lib/unmask/plugin`, a directory that is root's by design. `unmask doctor` listed it under data ownership as left by a command run as root -- on every host with the plugin -- and advised a `chown` that would hand the note to the daemon's user. The check leaves that directory out.
## [0.1.56] - 2026-11-10
### Security
- (2026-11-06) **Forward-auth: a visitor could get another address banned.** The decision endpoint also answered on `/unmask/api/<site>/check`, a path no configuration used and the copy-paste nginx snippet did not block, and it believed the caller's `X-Original-IP`. One request naming another address on a honeypot URL banned that address. The alias is gone, and the snippet now overwrites the `X-Original-*` headers itself. The native module and the packaged forward-auth configuration were not affected.
- (2026-11-06) **The admin's scripts run under an enforced Content-Security-Policy.** Every admin response now enforces `script-src 'nonce-...'`: the pages' scripts carry a per-response nonce, the inline `on*` handlers became listeners, and a script injected through an escaping mistake no longer runs -- nor does a script file of the protected site, which shares the admin's origin. The browser posts what it refuses to `/unmask/admin/csp-report`, logged as rate-limited `admin csp violation:` lines. The install wizard carries the admin's security headers.
### Added
- (2026-11-06) **A `pow_difficulty` outside 8-24 in config.yml is reported.** The settings page refuses such a number, but one written into the file by hand ran at the built-in 18 without a word, so an operator who meant to raise the difficulty stayed at the default. The daemon now logs it when it loads the file and `unmask doctor` warns, naming the key and the number.
- (2026-11-06) **`/unmask/readyz` and `unmask_db_up` say whether the daemon has its database.** `/unmask/healthz` answers 200 whenever the process runs, so nothing outside could see a daemon running without its database. `/unmask/readyz` answers 503 in that state, `/metrics` exports `unmask_db_up` (and no longer fails the scrape there), the example rules gain two alerts, the dashboard a tile, and `unmask doctor` asks the running daemon. healthz is unchanged.
- (2026-11-06) **A pattern in config.yml that will not work is reported.** The settings page refuses a pattern that does not compile or that nginx's regex engine could take exponential time on. A config.yml written by hand or by a configuration manager never passes that page, and such a row was used as written until `nginx -t` failed. Loading the file now logs each one, and `unmask doctor` warns with it; nothing is rewritten or dropped.
### Changed
- (2026-11-06) **`unmask_event_total` is a real counter.** It was declared a counter but exported the last 24 hours of the event table, so it fell as rows aged out and `rate()` and `increase()` over it -- what the shipped dashboard uses -- miscounted. It now counts the rows each daemon has written since it started, read from memory. A rule that read its raw value as "events in 24 hours" needs `increase(...[24h])`.
- (2026-11-06) **The unique-address gauge reads the rollup, and two dashboard panels are put right.** `unmask_event_unique_ip` scanned a day of raw events on scrape and exported 0 when that failed; it is now an estimate from the hourly rollup, left out when the rollup cannot answer. `unmask_db_query_seconds` is declared a summary, matching its samples. The dashboard's passed-challenges tile asked for a phase that no longer exists, and its latency panel was not an average.
### Fixed
- (2026-11-08) **Path patterns of the form `([^/]+/)*` are accepted again.** The nested-repetition check of 0.1.55 refused every repetition over a group that itself repeats, and so caught the common path shape whose inner repetition stops at a slash it cannot match. That shape splits a request one way only and is linear, so the settings page, `unmask doctor` and the start-up check accept it; `(.*/)*` and `([^/]+/?)*` stay refused.
- (2026-11-07) **The `release-verify` check reads rpm signatures with the key it imported.** On the Ubuntu runner, rpm keeps its database per user, so the release key imported with sudo was not the one `rpm -Kv` consulted: every signature read as unknown, and the step ended before it could say so. Both commands now use one named database, and the step prints what it saw.
- (2026-11-06) **A daemon started before its database comes back by itself.** A daemon that could not open its database at start (a host that boots it ahead of MariaDB) ran without statistics, bans or an admin UI until someone restarted it, and sent every admin page to a setup wizard nobody could enter. It now keeps serving challenges, retries the database, and restarts itself in place when it answers; admin pages answer 503 and say so meanwhile.
- (2026-11-06) **A daemon without its database no longer breaks on a beacon, a roaming rebind or an alert mail.** While it ran without a database, each challenge page's beacon and each roaming rebind ended in a failed request and a stack trace in the log, and an alert mail raised meanwhile ended the process. Now the event goes unrecorded, the rebind is refused, and the alert goes to the webhook and `notifications.mail_to`.
- (2026-11-06) **Nodes sharing one MariaDB no longer double-count the hourly statistics.** Each node folds new events into the hourly rollup once a minute; two nodes folding at the same moment read the same position and both added the same events, inflating the stats pages' counts. A fold now holds a named lock on the database, and a node that finds it taken skips that turn. SQLite installs are unaffected.
- (2026-11-06) **Removing the nginx plugin no longer leaves a `load_module` behind that stops nginx.** The removal looked for `50-mod-unmask.conf` in a fixed list of directories, and the fail-safe in a different one. A conf in a directory on neither list stayed and named a module file that was gone, so nginx failed on `dlopen()` at its next start. Both now remove it from where it was put, and a `load_module` you wrote yourself is left and named.
- (2026-11-06) **Uninstalling names the lines to take out of your configuration, and runs `nginx -t`.** The `include` lines for `server.inc` and `protect.inc` in your own server blocks outlive the packages, and nginx does not load its configuration with them. The removal now lists them, file and line, and prints what `nginx -t` answers; it does not reload or restart nginx. Removing the plugin alone also unlinks `http.inc`. The README has an Uninstall section.
- (2026-11-06) **On Alpine the plugin finds nginx's module directory.** busybox awk rejected the program that finds the directory nginx.conf loads modules from (`awk: bad regex` during the install), so on Alpine the `load_module` line always went into nginx.conf itself. New installs get `/etc/nginx/modules/50-mod-unmask.conf`; an existing install keeps its line and gets no second one. To go back to an older release on Alpine, remove the plugin and install the older package.
- (2026-11-06) **The deb installs on a system without `adduser`.** A minimal Debian 13 or Ubuntu 24.04 system has no `adduser`. The package's preinstall called it regardless, printed "not found" and exited 0; apt reported success, and the daemon could not start for want of its user. The account is now made with `useradd` there, and an install that still ends without the user fails with a line saying what to do.
- (2026-11-06) **The modules for older systems are built with the same hardening as the rest.** The module builds for OpenSSL 1.1, OpenSSL 1.0 and glibc 2.12 hosts had no stack protector and were not `BIND_NOW` (the glibc 2.12 one had no RELRO either), although the current build had all three. Every build path now takes its options from one script and reads them back out of the `.so` it made. Restart nginx on those hosts after the upgrade.
- (2026-11-06) **`http.inc` is no longer world-readable for a moment during an upgrade.** The package's postinstall set every rendered file to 0644 and then `http.inc`, which holds the key that signs passes, back to 0640. Between the two commands any local user could read it, on every upgrade. Each file now goes straight to its own mode, and `http.inc` stays 0640 throughout.
- (2026-11-06) **Passwords shorter than eight characters are refused everywhere.** The setup wizard and the CLI already refused them; the profile page, the users page and the mailed reset link did not, although the reset page said "at least 8". Each now checks before it writes: a refused update on the users page changes nothing else, and a refused reset keeps its link usable.
## [0.1.55] - 2026-11-06
### Fixed
- (2026-11-06) **Custom JA4 rules open onto two lines.** On one line with the name, the verdict and two pickers, the pattern field -- the one that must hold a whole fingerprint -- shrank to a few characters. The name and pattern now take the first line and the rest the second, and confirming a row keeps its verdict and action pill in view instead of losing them until a reload.
- (2026-11-06) **The module loads on Alpine again.** 0.1.54 compiled the plugin with fortified libc calls, which exist only in glibc; Alpine's musl nginx loads the same glibc-built file through gcompat, found no `__snprintf_chk`, and the fail-safe stripped the module (protection off, admin reachable, `unmask doctor` named it). The module is now built without `_FORTIFY_SOURCE`; the stack protector and full RELRO stay. Restart nginx after the upgrade.
## [0.1.54] - 2026-11-06
_Tagged but not published: the plugin built for this version did not load on Alpine (see 0.1.55, which carries every change below)._
### Added
- (2026-11-03) **`db.mysql_tls` turns on TLS for the MariaDB connection.** The connection to MariaDB ran in the clear whatever the server offered. `db.mysql_tls` in config.yml takes `true` (verify the server's certificate), `skip-verify`, `preferred`, or the path of the CA's PEM file; left empty, nothing changes.
- (2026-11-03) **A privacy notice link on the CAPTCHA card.** Set `branding.default.privacy_url` in config.yml (or per site) and the card's note links to your privacy notice in the visitor's language. The branding help lists what the challenge page sends to the event log and for how long, as material for that notice.
### Changed
- (2026-11-03) **The pass budget counts the passes the daemon sees.** It used to count only the solves a page reported (the beacon or the `/bvj` issue), which a client that mints its own cookies never sends. Now every pass an address presents in the nginx access log is counted too, each pass once whichever trace brings it; an address inside a relay's range is left out.
- (2026-11-03) **Packages and the image carry the license notices, and releases are attested.** Every rpm, deb and apk, and the container image, now ship LICENSE, NOTICE and the third-party notices (debs a copyright file). The release workflow attests the provenance of the binaries, the SBOM and the image, `gh attestation verify` checks it, and release-verify does so after each publish.
- (2026-11-03) **The README gains configuration, several-nodes, metrics and CAPTCHA-provider sections.** The main `config.yml` keys, what running several nodes needs, the `/unmask/metrics` endpoint with the Grafana dashboard, and the switch to Turnstile, hCaptcha or reCAPTCHA (with what the built-in CAPTCHA does not stop) are now in the README; eight statements that no longer matched the code were corrected.
- (2026-11-03) **The admin's account menu, setup labels and top bar work from the keyboard and on a phone.** The account menu (sign-out included) is a button that opens with Enter or Space, moves with the arrow keys and closes with Escape; the setup wizard's labels are tied to their fields; and the top bar no longer overflows at 390 pixels.
- (2026-11-03) **The challenge page reads at AA contrast on every theme, and holds still under reduced motion.** The note, the reference id, the brand footer, the "connecting" line and two buttons sat below the 4.5:1 WCAG AA asks of body text; every pair now measures 4.5:1 or better on all five themes. With prefers-reduced-motion the spinner, the terminal caret, the cat and the paper rule stop; the progress bar still fills.
- (2026-11-03) **The challenge page speaks to screen readers.** The verifying and verified lines and the failure text are announced as they change, the CAPTCHA card is a named region whose checkbox takes focus when it appears, the arithmetic field is labelled by its question, and the spinners are hidden from the accessibility tree.
- (2026-11-03) **The settings help says what the proof-of-work stops.** The difficulty's help and the known-browser posture's read as if the proof-of-work caught impersonating bots. Any client that reads the page can solve it; what stops at it is a bot that does not run the page's JavaScript, and the pass-reuse cap and pass budget are what stop one that keeps solving. Both texts say so now.
- (2026-11-03) **Beacons carry the page's path, not its query.** The challenge page's beacons sent the full address of the page the visitor asked for, query included, into the event log; they now send the path alone, bounded, and an event too large for its column keeps the keys that tie it to its session and rule instead of losing everything.
- (2026-11-03) **The daemon's line per request can be switched off.** `unmask serve` logs one line per HTTP request it serves (method, path, status, duration). `server.access_log: "off"` in config.yml turns it off; it stays on by default. On a forward-auth install the line is per request of the site, and a journal that rate-limits the daemon drops its warnings with them.
- (2026-11-03) **Admin pages answer failures with a fixed message.** The profile, audit log and setup gate returned the database's or template's error text in the response body; it now goes to the journal and the response says what failed in plain words.
- (2026-11-03) **JA4 details: the `s2` version code, HTTP/3's `q` without touching the connection's string, and the retry policy written down.** SSL 2.0's version code is emitted where the specification lists it; the `q` an HTTP/3 request carries is composed per request instead of rewriting the connection's stored fingerprint; a second ClientHello after a HelloRetryRequest keeps the first one's JA4, as packet-capture tools do. Requires an nginx restart.
- (2026-11-03) **A configuration-defined `$client_ja4` or `$unmask_*` variable is taken over with a notice.** When a `map` or `geo` in nginx.conf already defines a variable the module provides (a degraded render left in place, for example), the module logs a notice at configuration time and keeps the variable non-cacheable instead of silently replacing the handler. Requires an nginx restart.
- (2026-11-03) **The TLS message callback uses less stack, and `$unmask_client_net` is computed once per request.** The parsed ClientHello (about 9 KB) now lives in worker memory rather than on the callback's stack, the builder's scratch arrays are half their size, and the rate-limit key is kept in the request context instead of being allocated on every evaluation. No change in behaviour. Requires an nginx restart.
- (2026-11-03) **A per-response nonce on the admin's shared scripts, with a report-only script policy.** Every admin response now carries a `Content-Security-Policy-Report-Only` header of `script-src 'self' 'nonce-...'`, and the scripts the shared partials emit carry that nonce. Nothing is blocked yet; the browser console lists the inline scripts still to move under the nonce before the directive joins the enforced policy.
- (2026-11-03) **A dropped daemon runs with its own groups.** When `unmask` starts as root and drops to the daemon user (the container image, a hand-started service), it now takes that user's supplementary groups from the group database, as a service manager does, instead of keeping root's. The container's daemon user is a member of nginx's group, so the log socket is shared by group there too.
- (2026-11-03) **Threat model: the pass's binding and the name-only rescue.** SECURITY.md now says what the 64-bit signature protects and within which binding a pass may be presented again, and that the crawlers unmask rescues as shipped are recognised by their address ranges: a User-Agent pattern added by hand to the name-only list is trusted on the name alone.
- (2026-11-03) **The UA filter tab is two fifths of its size.** Each crawler group's Detail dialog carried every crawler as a rendered row with its own select: 600 KB and seven hundred controls for dialogs that stay closed on most visits. The rows travel as data and are built the first time a dialog opens.
- (2026-11-03) **Every settings control carries its label.** Text inputs, selects and the rows of the preset tables are associated with their label (`for`/`id`, or `aria-label` on a table cell), so a screen reader names the field and a click on the label focuses it.
- (2026-11-03) **UPGRADING.md reads as a table again, and says how to roll back.** The release-by-release table keeps a word or two per cell, each release's section carries the detail under the same headings, and a new section gives the rollback steps for rpm, deb, apk and the container, starting from the backup, since the schema only moves forward.
- (2026-11-03) **The README states the requirements and what is stored.** The nginx versions and `--with-compat`, the operating systems, SQLite or MariaDB, HTTP/3, a load balancer or CDN in front, several nodes and the cost; the quick start ends with a way to see the challenge fire; a section says what the daemon records and for how long. The proof-of-work paragraph says what it stops: fetchers that never run the page.
- (2026-11-03) **Japanese labels.** A hundred-odd admin labels that still read in English on the Japanese pages, among them sign-in, columns, theme names, SMTP fields and settings tabs, are in Japanese.
- (2026-11-03) **The Grafana dashboard has a health row, and alerting rules ship beside it.** The module stripped by its fail-safe, a render waiting for its reload, a ban list nginx cannot read, a schema update left waiting, and dropped events or access-log lines, as stat panels and as a Prometheus rules file under `/usr/share/unmask/grafana/`.
- (2026-11-03) **Every module in the plugin package can be rebuilt from the repository.** The recipes for the OpenSSL 1.0 (CentOS 7) and glibc 2.12 (CentOS 6) builder containers are now in `build-docker/`, beside the OpenSSL 1.1 one, with their base images pinned by digest; `make build-module-multi-all` builds them on first use, and the module-matrix workflow builds every shipped nginx version in all three containers.
- (2026-11-03) **The daemon binary is built the same way everywhere, and proved reproducible.** CI and the release workflow build `unmask-linux-*` through the Makefile recipe a release uses, and `make verify-reproducible` rebuilds it from a copy of the tree with a cold cache and fails unless the bytes match. A tag whose version differs from the Makefile's fails the release instead of shipping a mislabelled binary.
- (2026-11-03) **Published releases are checked after publishing.** A workflow runs when a release goes live: `checksums.txt` must be signed with the release key and name every asset, every rpm must carry the release signature, the daemon inside the main rpm must be the raw binary published beside it, and the debs, apks, SBOM and compose example must be present and well-formed. `make release` now lists the SBOM in `checksums.txt`.
- (2026-11-03) **CONTRIBUTING lists how to run each part of the tests, and a question form joins the issue templates.** `go test` runs with a timeout in CI, the Makefile and the contributor notes alike; the bug form asks for the version and the install path, and a second form takes questions.
- (2026-11-03) **The proof-of-work solves faster on the same hardware.** The hashing loop built a string and three arrays for every nonce; it now writes the nonce's digits into one kept buffer and hashes only the block that changes, with the fixed part of the seed compressed once. The hash is the same, so cookies from the old loop and the new verify alike, and a visitor's wait is shorter in proportion.
### Fixed
- (2026-11-03) **The plugin places the module built for exactly the host's nginx, or none.** nginx refuses a module built for a neighbouring patch release, so the "closest match" the placer picked never loaded and only reached the fail-safe through a failed `nginx -t`. With no module for the host's version the placer names it, lists the bundled versions and the ways out, and exits 1; the postinstall says so.
- (2026-11-03) **A module stripped by the fail-safe is a critical alert.** When nginx could not load the module and the fail-safe left the site unprotected, the admin's list had a row and nothing else heard of it. The event now goes to the webhook and by mail as critical, the recovery as info, and `unmask doctor` reports an active fail-safe as an error and says whether that alert can reach anyone.
- (2026-11-03) **The daemon's loops survive a panic.** The event flusher, the ban manager and the access-log counters wrapped their whole loop in one recover, so a single panic ended that loop for the daemon's lifetime, silently. Each iteration now recovers on its own: the one that panicked is dropped, the loop retries with a growing wait, and a loop stuck on panics is reported by webhook and mail.
- (2026-11-03) **The settings page no longer walks the whole ASN database on every render.** The provider counts the UA and ASN tabs show were computed by reading the ASN database end to end on each request; they are now kept by the database file's identity. A page whose build outruns the server's timeout gets an explicit error instead of a page cut off without its save button.
- (2026-11-03) **The ban list's directories are opened for nginx's worker only under `/var/lib/unmask`.** Writing the ban file, the daemon walked up the file's directories adding search permission for others, so an operator who pointed `ban_file_path` under a home directory had that home opened too. The walk stays under `/var/lib/unmask`; elsewhere the daemon logs what the worker needs and changes nothing.
- (2026-11-03) **A browser too old to run the challenge is told so.** The challenge script needs a browser of 2017 or later; an older one failed to parse it and sat on the spinner for ever, with no beacon. The page's own bootstrap now waits five seconds for the script to start and otherwise stops the spinner and says, in English and Japanese, that this browser cannot show the page.
- (2026-11-03) **Every language names the checkbox and the site.** Thirteen of the eighteen languages showed "I'm not a robot" in English under a card otherwise in their language, and a site without a name read "this site" in every language but Japanese. Each language now carries its own label and its own word for the site, and the tab's title follows the card's heading.
- (2026-11-03) **GeoIP reloads no longer race the lookups.** The daemon's IP-to-country reader closed and reopened its mmdb files under the lookups that run for every access-log line and event, so a settings save, an `install-ipgeo` or the daily auto-update could unmap a file a lookup was reading. Lookups now hold a read lock the reload takes exclusively; a lookup after Close answers empty.
- (2026-11-03) **The GeoIP answer cache has a ceiling.** The reader cached every distinct address it ever resolved and released them only on a reload, so memory grew with the address pool a crawl brought. The cache now keeps two generations of a fixed size: an address asked for again survives the turnover, one seen once leaves, and the memory stays bounded whatever the traffic.
- (2026-11-03) **A settings save cannot lose the CLI's change any more.** The daemon held the config file's lock inside the save only, so `unmask upgrade-review` running between its load and its save had its change overwritten, or lost its own. The whole load-change-save now runs under the lock, in the settings handlers and the setup wizard alike, matching the CLI.
- (2026-11-03) **The setup wizard no longer swaps the live database handle in place.** Completing the wizard onto a different database wrote the new handle under every request and worker still reading the old one. The handle is left alone; the re-exec the wizard already scheduled binds everything to the new database, and the done page still asks for the restart.
- (2026-11-03) **Background loops stop before the database closes.** At shutdown the hourly rollups, the retention prune, the WAL watch and the other loops kept running against a database the daemon had already closed, filling the journal with errors. Every loop now runs under one context the shutdown ends and waits for, up to 20 s, before the handles close; an interrupted prune or rollup resumes from its cursor.
- (2026-11-03) **MariaDB connections have their own pool size and timeouts.** The pool was sized from this process's CPUs as for SQLite, and a server that did not answer the connect blocked `unmask serve` until the operating system's TCP timeout. MariaDB now gets 16 connections by default (`db.max_conns` under the custom profile, up to 64), a 10 s connect timeout, a 30 s write timeout and a bounded first ping.
- (2026-11-03) **Forward-auth keeps a bypass matcher per site.** The compiled bypass, protected-path and honeypot matchers were cached for one site at a time, so a multi-site forward-auth install whose `/api/check` calls interleaved rebuilt them on nearly every request, under a lock. The cache now holds every site of a settings generation (up to 256) and is dropped as a whole when settings change.
- (2026-11-03) **The ban list is read on a change, not on every request.** Each nginx worker used to `stat()` the ban file on every request and read it whole, with no size bound, the moment it changed. The module now checks once a second per worker at most, reads the file into worker-lifetime memory, and refuses a file over 64 MiB, keeping the previous list. Requires an nginx restart.
- (2026-11-03) **A `_bv` cookie wrapped in quotes, or written with spaces around `=`, verifies.** The module's cookie splitter required the exact `_bv=value` shape browsers produce; a proxy or an SDK that sends `_bv="value"` or `_bv = value` had every request challenged again. The splitter now takes both, as nginx's own header parser does, and skips a bare `_bv` without hiding the pair after it. Requires an nginx restart.
- (2026-11-03) **A validity window of 0 means the default on both cookie kinds.** `unmask_bv_pow_valid_seconds 0` read as the default while `unmask_bv_captcha_valid_seconds 0` accepted only cookies issued in the same second, and a value past 2^31 was truncated on one path. Both directives now treat 0 as the default, as the daemon's settings do, and cap the window at ten years with a warning at configuration time. Requires an nginx restart.
- (2026-11-03) **The compose redirect keeps the whole original query.** In compose mode the ACCESS-phase handler appended the original URI to `_orig=` unencoded, so a protected URL whose query held `&` lost everything after it when the visitor returned from the challenge, and `;` and `+` were misread. Those delimiters are now percent-encoded in `_orig`. Requires an nginx restart.
- (2026-11-03) **The main-request marker survives an internal redirect.** The module's "is this the main request" test rested on a per-request context that nginx clears on `error_page` and `try_files` redirects, so `$unmask_has_signed_agent` read `0` for a signed main request after one. The marker now lives with the request's memory pool, which an internal redirect keeps and a subrequest shares without being mistaken for the main request. Requires an nginx restart.
- (2026-11-03) **The post-install banner names the native module.** A new install printed the forward-auth include as "Recommended" on every host, plugin or not, while the README installs the module. The banner now names the include that works on the host: the native one when the plugin is there, with forward-auth as the other way in, and the matching protect.inc.
- (2026-11-03) **A save from the admin keeps the comments in config.yml.** Saving from the web UI rewrote the file from the struct and dropped every comment: what config-init wrote beside each value, and the operator's own notes. The comments are carried onto the keys written again; a file with none gets a one-line header, once.
- (2026-11-03) **The hunt and settings pages fit a phone.** Both lacked the viewport meta every other admin page has, so a handset drew them at desktop width.
- (2026-11-03) **The snippets name the current path of the native include.** The forward-auth and Apache snippets still pointed at `/etc/unmask/native/protect.inc`, retired when the rendered includes moved under `/var/lib/unmask/nginx`.
- (2026-11-03) **The compose example starts without a checkout.** It named `build: .` beside the published image, so `docker compose up` on a host without the Dockerfile failed on the missing build context; the build line is now a commented option.
- (2026-11-03) **The built-in CAPTCHA help describes the scoring that runs.** The threshold's help listed five averaged axes, among them the smoothness of scrolls and the rhythm of keystrokes, which the scorer never reads. It now lists the actual deductions (self-dispatched events, no input, a click under half a second, a one-point or straight trail, a stepped trail, a zero window) and what the default 0.5 means in those terms.
### Security
- (2026-11-03) **The global posture demands a CAPTCHA-grade pass.** A site whose operating-mode bucket ends in a CAPTCHA (`pow_then_captcha` or `captcha_only`) took a proof-of-work cookie as a pass, so a client that read the challenge page and minted the cookie itself never met the CAPTCHA. The posture now keys on the CAPTCHA grade, only where it decided the request: a protected path, a UA rule, a network or fingerprint rule and a honeypot keep their own chain and grade. Forward-auth and the roaming re-bind read the same answer.
- (2026-11-03) **The admin's own key.** The admin session, its CSRF tokens and the sign-in form's token now derive from a new `secret.admin_secret`, which only the daemon holds and no rendered nginx file carries. They derived from `bv_secret`, which every nginx node has in `http.inc`, so one readable `http.inc` could sign a superadmin session for every node. Every operator signs in again once; `unmask doctor` checks the rendered files.
- (2026-11-03) **The HTTPS-redirect exemptions no longer skip the ban and the hard deny.** With `nginx.https_redirect` on, a request matching an exemption (a health checker's user-agent, the ACME path, a custom rule) left the server rewrite phase before the ban and deny gates, so a banned or denied client could name `GoogleHC` and reach the ordinary challenge. The exemption now withholds only the redirect. Re-render and reload nginx.
- (2026-11-03) **JA4 bans hold behind a trusted load balancer.** The native plugin matched the ban file's `ip|ja4` and `|ja4` rows on its own TLS handshake's fingerprint, which behind a balancer forwarding JA4 in a header is the balancer's, while the daemon writes the rows from the forwarded value; those bans never matched there. The plugin now keys on `$unmask_ban_ja4`, defined on `$effective_ja4` by the render. Requires an nginx restart.
- (2026-11-03) **The CAPTCHA's proof-of-load token is the client's.** The `ct` a challenge page carries is now bound to the client it was served to, with the pass cookie's bind key and host (a relay's range for a client inside one) and the connection's TLS fingerprint; `/api/verify` and `/api/captcha/new` refuse it from anyone else. A fingerprint may differ by what a browser's own does: a resumed session, or HTTP/3 against HTTP/2.
- (2026-11-03) **A CAPTCHA chain owes its proof-of-work.** The page's token signs the chain served. On `pow_then_captcha` the verify takes no CAPTCHA answer, behavioral or arithmetic, without the proof-of-work solved for that page, which the page now sends with the answer; `captcha_only` owes none, as its CAPTCHA is meant to come first. A `pow_only` page earns a proof-of-work pass at most. `?_force=` is honoured only on the test pages.
- (2026-11-03) **The stale-browser, header-integrity and UA-TLS tiers demand a CAPTCHA-grade pass.** Each tier escalated only a request that would otherwise pass, and a proof-of-work cookie counted as passing, so the CAPTCHA the tier serves was never reached by a client that solved, or minted, the proof-of-work. A tier whose action ends in a CAPTCHA now keys on the CAPTCHA grade; forward-auth mirrors the stale and header tiers.
- (2026-11-03) **`/run/unmask` is shared with the web server's group only.** The runtime directory was 0755 beside sockets that are 0660, so any local user could reach the log socket's directory. It is now 0750, owned by the same group as the sockets in it, on the service unit, the init script and the container alike; nginx's worker, a member of that group, traverses it as before.
- (2026-11-03) **Webhook text escapes what a visitor wrote.** A ban's reason, a visitor's URI or user agent went into the Slack, Discord and generic bodies as they came, so a crafted request could ring `<!channel>` or `@everyone` in the operator's channel. Slack entities are escaped, Discord mentions are switched off, control characters are dropped, and each field and body keeps within the service's length.
- (2026-11-03) **The AI tools redact webhook URLs and credential-shaped values.** The configuration the AI advisor and the ask page hand to a model masked keys named like secrets but not `webhook_url`, `critical_url` or a URL carrying a token, so a Slack hook (a secret in itself) reached the provider. Keys that carry a hook or a credential, and values shaped like one, are now said to be set and nothing more.
- (2026-11-03) **Patterns with nested repetition are refused at save.** Every pattern the admin accepts for nginx (UA filters, protected and bypass paths, custom rules, AI-proposed rules) was checked with Go's linear-time engine but runs under nginx's PCRE, where a nested quantifier such as `(a+)+` can take exponential time on a crafted request. Such patterns, and patterns over 1024 bytes, are now refused with a reason.
- (2026-11-03) **The challenge page carries a Content-Security-Policy of its own.** Each serve gets a nonce for the page's inline scripts, `blob:` workers for the proof-of-work, and the origin of the CAPTCHA provider you picked; an operator's own copy of the page from before the change is served without a policy. The step-back relay runs under a nonce too; README's nginx section says what a site's own policy has to allow.
- (2026-11-03) **The built-in CAPTCHA takes more than a fetch and a POST.** An answer within two seconds of the page being served is refused, each address gets a bounded number of answers per five minutes, the arithmetic question is bound to the page it was drawn for, and the verify answer no longer reports the score. The admin description says plainly that a headless browser can pass the built-in check.
- (2026-11-03) **A rule name with a quote no longer breaks the challenge page.** The entry behind a challenge (a literal UA filter pattern, the organisation text of an ASN rule) was written into the page's inline script between quotes; one with a quote or a backslash ended the script early, and every visitor that rule matched was turned away. Both values are JSON-encoded now, and the save refuses those characters.
- (2026-11-03) **The chain-mode hint in the challenge query is taken only from an internal route.** `?chm=` on a direct hit of the public challenge route let a visitor pick a lighter chain than the posture, and have the serve record it as the one offered; it is now honoured only when nginx routed the request here from another page. The gate's requirement was never affected.
- (2026-11-03) **Go 1.27.3 and the current x/net in the released binaries.** The 0.1.53 binaries were built with Go 1.27.1 and x/net 0.57, which govulncheck reports with thirteen reachable vulnerabilities, among them the HTTP/2 server and multipart parsing that any client reaches through the challenge and API routes. go.mod now names Go 1.27.3 and the release workflow builds with it; the scan finds nothing reachable.
- (2026-11-03) **Outbound connections refuse the cloud metadata addresses and loopback.** A webhook, feed or AI endpoint set by an administrator could point the daemon at the cloud's metadata service, which hands out instance credentials, or at services on the host itself. Every such connection is now judged on the resolved address, after DNS and on each redirect; private networks stay reachable unless `outbound.deny_private` is set, and `outbound.allow_loopback` re-admits loopback.
- (2026-11-03) **The login lockout outlives a restart and is shared across nodes.** The per-address lockout (ten failures, fifteen minutes) and the forgot-password cap counted in each daemon's memory: a restart forgot them, and behind a load balancer N nodes allowed N times the failures. The counters now live in the database (migration 0050), so every node reads the same count and a lock survives the daemon.
- (2026-11-03) **nginx's access-log socket is shared by group, not world-writable.** The daemon bound `/run/unmask/log.sock` at mode 0666, so any local process could write lines into the traffic counts. It is now 0660 owned by the web server's group (`nginx_log.socket_group`, detected when unset); where no group can be applied it stays open, and the daemon log and `unmask doctor` say so.
- (2026-11-03) **Template arguments formatted into an HTML message are escaped.** The admin's `tf` template function formatted its arguments into an i18n message as they came, and templates that render such a message with markup put the argument into the page unescaped: a hostname, a path or the update check's version string could carry markup. `tf` now escapes string arguments; arguments that are HTML already pass through typed.
- (2026-11-03) **Two more log lines escape what came off the wire.** The ban manager's CDN-egress line wrote the client address, the vendor's name and the ban's description as they came, and the overview's budget line wrote the host filter unescaped, so a newline in either could start a line that looked like the daemon's own. Both now escape control characters the way the other request-derived log values do.
- (2026-11-03) **The sign-in form carries a CSRF token.** The sign-in POST was exempt from the CSRF check, so a page on another origin could sign a visitor into an account of its choosing. The form's GET sets a nonce cookie and the form embeds a token derived from it; a POST without them is answered with the form again.
- (2026-11-03) **The module `.so` is built hardened.** The Makefile, the CI matrix and the Docker image build the module with a stack protector, fortified libc calls and full RELRO with `BIND_NOW`, so the module's GOT is read-only once loaded. The module parses TLS ClientHello bytes and the client's cookie; none of this changes its behaviour. Requires an nginx restart to run the new file.
- (2026-11-03) **The nginx source is verified on every build path.** `tools/fetch-nginx-src.sh` downloads the tarball, checks it against the pinned sha256 list and unpacks it for the Makefile, the builder containers, CI and the e2e image alike; a version without a pin no longer builds anywhere.
- (2026-11-03) **The notice bell's loading and failed lines go in as text.** The bell built its two own lines as markup around an attribute's value through innerHTML; the value is the server's translated string, so nothing was exposed, but the lines now go in as text.
## [0.1.53] - 2026-11-03
### Added
- (2026-10-28) **Alerts can go to two webhooks by severity.** A second webhook URL on the notifications tab takes the alerts whose severity reaches a floor -- the over-block breaker by default, or the warnings too -- and the first keeps the rest, so one Slack channel holds what needs a person now and another the record. Empty, nothing changes. The generic payload carries the severity.
- (2026-10-24) **No automatic ban lands on a CDN's origin-facing address.** A scan sent to a CDN hostname reaches the origin from a CloudFront address and trips the honeypot, and the ban then stops every fetch the site's own CDN makes from that address. The addresses CloudFront, Cloudflare and Fastly fetch with get no honeypot, pass-budget or address-wide ban; bans already on them are left out and marked.
- (2026-10-24) **A CloudFront preset, and on native nginx the visitor's address from X-Forwarded-For.** With CloudFront in the trusted LB / CDN set and the new switch on, http.inc carries set_real_ip_from, real_ip_header X-Forwarded-For and real_ip_recursive on, as the gateway container does: the bans, the rate limits, the cookies and the log see the visitor behind the CDN, and a scan through it is banned on its own address.
- (2026-10-24) **An opt-in reverse check for the UA-TLS tier: a Chromium name over a fingerprint that is not Chromium's.** A script wearing a Chrome name over Go, Python or curl trips it. Off by default, because antivirus software and corporate proxies that inspect TLS re-originate the session with a stack of their own under the browser's unchanged name, and everyone behind one would meet the screen. Settings → UA filter.
- (2026-10-24) **`unmask backup` writes an archive of config.yml and the database.** Going back to an older version needs the database from before the upgrade, and nothing produced that copy. The new command archives config.yml and, on SQLite, a consistent snapshot taken with VACUUM INTO while the daemon keeps running; on MariaDB it takes config.yml and points at mysqldump. `unmask backup -h` has the restore steps.
- (2026-10-23) **A client that solves the proof-of-work far more often than a person is banned for a day.** The reuse cap counts the requests a pass is used for, so a client that solves again on every page and carries no cookie never reaches it. A new pass budget on the rate-limit tab counts the solves per address and JA4 fingerprint; over 500 a day the pair is banned for 24 hours with a CAPTCHA-only action (source rate_limit_abuse), announced like every ban. On by default, held for review on an install that reviews its upgrades.
- (2026-10-23) **A User-Agent that contradicts the TLS fingerprint is escalated to a CAPTCHA.** A Chromium fingerprint under a Firefox or Safari name, or a TLS 1.3 session under a browser version from before TLS 1.3 (Chrome before 65, Firefox before 60, Safari before 12), is a contradiction no genuine browser produces. The new UA-TLS consistency tier on the UA-filter tab challenges such a request when it would otherwise pass, never denies, and is on by default (held for review on an install that reviews its upgrades).
- (2026-10-19) **A budget for requests passed on a crawler's name alone.** A crawler whose vendor publishes no address range passes on its User-Agent string, which anyone can write. A new per-address budget (UA filter tab, 30 requests a minute by default) counts those requests; over it the request meets a CAPTCHA like any rate limit, so the name buys nothing further from that address.
- (2026-10-19) **The UA filter shows what each crawler name let through.** Each crawler's Detail row reads "passed by name, 24h: n requests / m addresses" when the last day let anything through on its name alone, and `unmask doctor` reports the day's total and warns past a few hundred, so a name being abused is visible where it is set to normal checks or stopped.
- (2026-10-19) **A pass cookie follows a relay's published egress range.** Inside a range from the relay egress list, nginx's new `$unmask_bind_key` names the range, the daemon issues the cookie and the PoW seed against it, and the plugin verifies with the same variable, so a relay that moves the visitor's address within the range no longer re-challenges. Update the plugin together with the daemon.
- (2026-10-20) **The ask page can stop a question.** While a question is out, the send button gives way to a stop button. Stopping ends the request, and with it the daemon's call to the provider, so no more tokens go to a question the operator has given up on. The turn reads "stopped", the question comes back to the field, and the history keeps the turn with a retry.
- (2026-10-20) **The stats page names what fired behind each challenge reason.** The forced-challenge card breaks every reason down by the entry that raised it -- the custom rule, UA filter row, ASN or country rule, trap, protected path, stale browser, ban source or JA4 verdict -- each linked. The reason badge on the hunt rows, the CAPTCHA-pass list and the realtime page opens the entry in a popover.
- (2026-10-20) **The ask page's history pages.** The history tab lists fifty turns a page, newest first, with the page's range in its note and a previous and next page below the turns; a turn deleted from a page leaves you on that page. The list used to stop at the newest fifty, with no way to the older turns.
- (2026-10-20) **The relay list names Zscaler's cloud proxies.** Behind Zscaler, the page a visitor reads and the form they post from it can leave by different addresses of one data centre, and a pass cookie bound to the address fell off between them. Inside Zscaler's published ranges, listed by unmask.sh beside iCloud Private Relay, the cookie now follows the visitor across the proxy as a whole.
- (2026-10-21) **The relay list names Netskope, Cato Networks and Cisco Umbrella.** Their cloud proxies, like Zscaler's, send one visitor's requests from several addresses, so a pass cookie bound to one address fell off between a page and the next. Inside the ranges each publishes, gathered by unmask.sh, the cookie now follows the visitor across the proxy as a whole.
- (2026-10-22) **Link-preview fetchers are verified by name and calling network.** A country or ASN rule challenged the X, Facebook and LINE fetchers like any crawler passed on its name, and shared links lost their cards. The name together with the network they call from now clears those two axes; the country tab shows what a rule would stop before the save, and the notification list says when previews meet the challenge.
- (2026-10-25) **A module for nginx 1.30.6.** The plugin package carries the module built for nginx 1.30.6 beside the one for 1.30.0. nginx loads a module only for its own exact version, so a host that moves to 1.30.6 before it takes this package starts nginx without the module, through the placer's fail-safe, until it does.
- (2026-10-26) **The setup wizard has a first-run options step.** Before finishing it lists the communication with unmask.sh (the shared ban feed and its install token, the reporter's country, the GeoIP downloads, the daily crawler-range sync, the update check), each on by default with a switch, prefills the admin hostname as an allowlist entry, and offers to start in monitor mode. Scripted installs keep the defaults.
- (2026-10-26) **A disabled nginx reload timer ships with unmask-web-nginx.** unmask still never reloads a running nginx by itself. An operator who wants it automated runs `systemctl enable --now unmask-nginx-reload.timer`: every five minutes it reloads nginx when a render changed the configuration and `nginx -t` passes, at most once per ten minutes, and never restarts it. The reload notice and UPGRADING.md name it.
- (2026-10-26) **Every admin route is checked for its authorization by a test.** A new test walks the routes registered under `/admin/` and drives each through the real middleware: without a session every one answers the login page, 401 or 403, and with a viewer's session every POST, PUT, PATCH and DELETE is refused, apart from signing out, the own profile and the playground evaluation.
- (2026-10-26) **A reload marker beside the rendered files.** Every render that changes a file writes `.reload-needed` with its render id, and the daemon removes it once nginx's access log shows that render running. Tooling that applies reloads can watch the file, and `unmask_nginx_config_stale` on /unmask/metrics reads it. Written only where the access log reaches the daemon.
- (2026-10-26) **The notification list reports the fail-safe, and /unmask/metrics exposes the nginx state.** The bell gets a row when the plugin's fail-safe strips the module, with the cause place-module recorded, and one when it is back. Three gauges join the metrics for alert rules: `unmask_native_failsafe_active`, `unmask_nginx_config_stale` and `unmask_ban_file_worker_readable`, the check behind the admin's ban-list bar.
- (2026-10-26) **doctor warns when HTTP/3 connections get no JA4.** HTTP/3 needs nginx 1.29.0 or newer built with OpenSSL 3.5.1 or newer, or quictls, for the module to see the handshake; otherwise nginx's QUIC compatibility layer replaces the callback the module reads and JA4 decisions do not apply there. doctor names the `listen ... quic` sockets and the fix.
- (2026-10-26) **IPv6 bans widen to the /64; the ban file carries prefix rows.** Three automatic bans on distinct addresses of one IPv6 /64 within a day add an ip-only ban on the /64, with the tipping ban's action and lifetime, unless a bypass range overlaps it. An operator can ban a prefix under the ip-only scope too: the module matches such rows by mask after the exact ones.
- (2026-10-26) **Shared bans: limits on what one pull can switch on.** A ja4_only entry on a fingerprint current browsers send is never applied, a new ja4_only entry waits a day after it is first seen promoted, and one pull applies at most 200 new entries. The shared-bans page shows what is held back and why.
- (2026-10-26) **/unmask/metrics counts what the ingest let go.** Three counters join the page for alert rules: access-log lines the receiver dropped at a full queue, event rows a batch write dropped because the database refused their data, and events the batch writer's backlog cap dropped.
- (2026-10-29) **The stats page counts the passes behind each address.** The reuse card shows how many distinct pass cookies an address presented in a day and the busiest hour of one cookie, from a new access-log field (`bv=`) once nginx runs the rendered configuration. The reuse cap counts per address and cannot tell a shared address from a client swapping cookies. Table `unmask_pass_ip_day` (migration 0049), kept 15 days.
- (2026-10-30) **Each ranking in the reuse card explains itself.** The five tables of the cookie reuse card -- requests on CAPTCHA, PoW and re-bound cookies, the passes behind each address and the busiest hour of one -- each carry a "?" beside the heading saying what the table counts and how to read its rows, so the card's one explainer no longer has to cover them all.
- (2026-10-30) **The reuse cap counts one pass at a time.** Its first tier is now the pass itself: the plugin's `$unmask_bv_id` names the pass that verified, and one pass holds 500 requests an hour, so the people behind an office proxy or a carrier NAT each get a budget of their own. The address's budget stays as the second tier; a new pass does not reset it.
- (2026-10-30) **The admin asks for a restart when the module changed.** Every access-log line now carries the hash of the module file nginx loaded (`mo=`, the module's `$unmask_module_id`). While the file on disk is newer than the one running -- a plugin upgrade not yet restarted -- the reload notice names the restart in place of the reload, which would keep the old module.
- (2026-10-30) **Cookie signatures can move to HMAC-SHA256.** The daemon and the module now verify both HMAC-SHA1, which every release so far signed with, and HMAC-SHA256; `secret.sig_hash: sha256` switches what is issued once every nginx runs this release's module, and the cookies signed before stay good for their window. A later release makes it the default.
- (2026-10-30) **The module says when another module took its TLS callback.** An SSL context holds one message callback, so a module that sets it after unmask leaves every request without a JA4 while nothing else looks wrong. nginx's error log now says so once a minute, `unmask doctor` reports it, and SECURITY.md gains a threat model that names this and the other limits.
- (2026-10-29) **The critical webhook can take a format of its own.** The second webhook URL posted in the first hook's format, so a critical channel on Discord beside an info channel on Slack got a body it could not read. A format select beside the critical URL picks slack, discord or generic, follows the first hook's when left alone, and the critical test button sends in it.
### Changed
- (2026-11-01) **The reload timer leaves a master that needs a restart alone.** When an OS update replaces a library nginx has loaded, the workers a reload forks crash on the old copy. The timer's script now checks the master's mappings first and, with a replaced library there, logs that a restart is needed instead of reloading. The reload notice says the timer acts only when a render waits.
- (2026-10-25) **A save button shows it was pressed.** Once a form posts, the button that sent it spins and every button of that form is disabled until the next page loads, so a save that takes a few seconds no longer looks ignored and a second click cannot send it twice. The saves that stay on the page, such as the map's home point, spin the same way.
- (2026-10-25) **On the ask history, a folded question and a folded answer open on their own, and each button says how many lines it hides.** One button used to open both; now the question has its own under its bubble and the answer keeps its own, and a folded one reads "Show in full (12 more lines)", counted from the lines as rendered, so it follows the window's width.
- (2026-10-25) **On the gateway container, a saved setting no longer hands you a reload command.** The banners after a save, a rule added from the hunt, an upgrade review applied and a rule saved from the ask page said to run nginx's reload, with the container's self-reload in brackets. On the container they now say that its nginx reloads itself within seconds, and nothing else.
- (2026-10-25) **The copy button lives inside the command's box and says what it does.** The ❐ next to a command the admin asks you to run now sits at the end of the command's own chip, level with the text, as a quiet glyph. Hovering it shows "Copy this command", and after a click the label says the copy happened beside the green tick.
- (2026-10-25) **The ask history folds a long question too.** A pasted log or configuration made the history one question tall. It now opens on its first lines the way a long answer does, and the turn's one button shows the whole of whichever is folded and folds it back.
- (2026-10-25) **The bans table fits a laptop window.** Its columns do not wrap, and they added up to more than a 1440-pixel window holds, so the table scrolled sideways. The edit and unban buttons are icons with a hover label, the dates drop the year, the operator who placed a manual ban sits beside its source pill, and a ban on its source's default action carries a mark in place of the word.
- (2026-10-24) **The ask page takes a question of up to 10,000 characters, and a field over its limit says how long it is.** The question was capped at 2,000 characters, short of a pasted log or report. Every field with a character limit now adds the current count to its note ("At most 2,000 characters. Now 2,345 characters."), so the operator knows how much to cut.
- (2026-10-24) **Every place that names a site marks a host that is not a defined site.** The bot hunt's log rows coloured such a host amber; the stats page's path tables and heading, the site list, the notices and the hunt's live rows now do the same, so a spoofed Host or an unregistered vhost stands out wherever it appears, not only in the raw log.
- (2026-10-24) **The commands the admin tells you to run have a copy button.** Beside `sudo nginx -t && sudo nginx -s reload` in the saved banners, the rule dialogs, the upgrade review and the reload notice's help, beside the restart hint, the About tab's update command and the setup page's token command, a ❐ puts the command on the clipboard, so it need not be selected by hand.
- (2026-10-24) **The module says what it could not do.** It used to be silent: a ban file nginx could not read enforced no ban, an empty cookie secret rejected every cookie, a ClientHello the parser declined left its connection without a JA4, and the error log said nothing. Each condition now writes one line, at most once a minute per worker.
- (2026-10-24) **The pass budget counts an IPv6 client by its /48.** A client holds a /64 at the least and a server a /48, so counting per address let one machine draw on tens of thousands of budgets; the pass cookie is bound to the /64 already, and the budget folds to the /48.
- (2026-10-24) **The challenge page warms the CAPTCHA provider's connection only when it will use one.** Every challenge page opened a connection to `challenges.cloudflare.com`, whether or not Turnstile was the configured provider. The warm-up links are written in at serve time, for Turnstile only.
- (2026-10-24) **The CSRF token no longer rides in a form's query string.** The admin's upload forms mirrored the token into their action URL, which put it in the request line and so in every access log along the way. The server reads it from the multipart body, so the mirror and the query-string acceptance are gone; the header and the hidden field remain the two ways in.
- (2026-10-24) **The repository's own documents and package descriptions say what ships today.** The README opens with a quick start -- the release package, the three nginx packages, the include and the setup wizard -- and names Apache as the frozen forward-auth path; `UPGRADING.md` lists, release by release since 0.1.50, the nginx restarts, sign-outs, removed settings keys and schema updates an upgrade asks for. The package descriptions no longer call forward-auth the default, and the third-party notices match `admin/go.mod`.
- (2026-10-23) **The cookie-reuse ranking counts what the reuse cap counts, and says how often the cap fired.** Its count was every logged request carrying the cookie, static files and the challenge page's own parts included, and read against the cap it ran several times past it. A "judged" column, the requests the gate judged, now leads and orders the ranking; the whole stands beside it, and an "over the cap" column counts the CAPTCHAs the cap raised for the address.
- (2026-10-19) **A crawler known only by its name no longer slips past the traps and the limits.** Such a crawler used to be exempt from everything; now its name clears only the challenge a browser would get. A honeypot it trips, a community listing, a country, network or custom rule, a protected path, a deny and every rate limit apply to it as to anyone, and the name counts only for a GET or HEAD. Crawlers verified by a published range keep the full exemption.
- (2026-10-19) **The CAPTCHA's failure text says what failed.** A verify whose request never completed reads E-NET, one answered with something other than JSON carries the status (E-503, a block page or a daemon that is down), and an external widget's failure E-WIDGET. Each also leaves an error beacon with the same classification, so the hunt page shows what the visitor saw.
- (2026-10-19) **Keyboard-only visitors score as present.** Reaching the box with Tab and ticking it with Space produces no mouse or touch event, which the behavioural score read as a page nobody touched and sent straight to the arithmetic fallback on every visit. Key presses now count as input, and the page counts them on keydown so Tab and Space register.
- (2026-10-19) **Silent re-binding allows ten moves an hour, up from four.** A phone roaming between networks rarely needed more, but a two-exit NAT or a relay that moves its egress now and then does. The lifetime cap of sixteen re-binds per solve is unchanged, so one solve still cannot be stretched across a proxy pool.
- (2026-10-20) **The reload notice says why the configuration was written, and what changed.** "nginx is still running the previous configuration" now names the cause: the settings tab that was saved, the IP range sync (the relay egress list, the bypass sources), the GeoIP update, or an install. An automatic update gets its own calmer wording. The help tip lists the blocks that moved, with the lines changed, added and removed.
- (2026-10-20) **The ask page's history is kept until you delete it.** It used to be pruned after ninety days. A turn is a few kilobytes and the record is your own, so it stays; since answers can quote addresses and user agents, the retention tab gains "Ask history retention" for installs that want it dropped after a number of days (0, the default, keeps it).
- (2026-10-20) **The realtime strip reads from the challenges to the bypassed.** The tiles run requests, challenges served, blocked, passed on a cookie, solved, refused, rate-limited, bypassed: the blocked after the challenges it is taken from, stopped beside let through, and the bypassed last, the one figure the judged scope leaves out of the requests. The map's legend ends on the bypassed too.
- (2026-10-20) **A form posted from a new address goes through.** A request with a body could not take the silent re-bind, whose bounce returns as a GET: a visitor whose address changed between the page and the post got a JSON error and lost their input. The route re-binds it in place and answers 307, so the browser sends the request again. A refused post goes back to its form page.
- (2026-10-20) **The realtime strip opens on how the judged requests split.** The tiles now run requests, passed on a cookie, challenges served, blocked, solved, refused, rate-limited, bypassed: the passed beside the requests they are the largest share of, the blocked figure right after the serves it is taken from, the refused beside the rate limit, and the bypassed last, the one figure the judged scope leaves out of the requests.
- (2026-10-23) **A landing-page section that runs past its budget logs one line saying why.** Next to the per-card lines it already wrote, the log now carries the window, the site and host filters, whether the hourly rollup was readable -- and so whether the cards read it or scanned the event table -- and the time waited. That is what decides the cards' path, and what a timeout report was missing.
- (2026-10-26) **The module says at the error level when it enforces nothing.** An unreadable ban file and an empty `unmask_bv_secret` were warnings, which the nginx.conf most distributions ship does not record. They are errors now, each worker checks the ban file once at start, and an HTTP/3 request without a JA4 is reported once a minute (nginx's QUIC compatibility layer hides the handshake from the module).
- (2026-10-26) **The container build verifies the nginx source, and the release tools are pinned.** The image's module stage checks the nginx tarball against the sha256 pinned in `nginx-module/nginx-sha256sums.txt` before unpacking it, as the plugin build already did. The Makefile names the nfpm version the packages are built with, and the compose example starts with `TZ: UTC`.
- (2026-10-26) **`unmask backup` includes the uploaded logos.** The archive now carries the logos uploaded on the page-design tab under `branding/` (every file in `/var/lib/unmask/branding`, plus a logo the config names elsewhere), so a restore brings the challenge page back as it looked. The usage text lists them and the restore steps put them back.
- (2026-10-26) **Third-party notices name FlagKit.** The country flags the admin shows are FlagKit's PNG assets (madebybowtie/FlagKit, MIT, Copyright 2016 Bowtie AB): 255 of the 261 files are the upstream set byte for byte and six were drawn here. THIRD-PARTY-NOTICES.md carries the license text and NOTICE names the source.
- (2026-10-26) **Ban checks answer from memory; repeated trips cost nothing.** The daemon keeps the live ban list in memory, rebuilt on every write of the ban file, so the per-line check no longer queries the database. A client already banned the same way is not rewritten, re-flushed or re-announced on every trap hit, and writes within 200 ms become one write of the ban file.
- (2026-10-26) **A request that asks for neither language is served in English.** The challenge, deny and admin pages fell back to Japanese when the browser's Accept-Language named neither ja nor en; the fallback is English now. A chosen language, the cookie or ja in Accept-Language, is unchanged.
- (2026-10-29) **A rejected admin request reads a bare 403.** The admin IP and Host allowlists answered a visitor they turned away with the address they had taken them for and the setting that would admit them; the echoed address let anyone test which forwarded header the daemon trusts. The body is now just Forbidden, and the daemon log keeps the address, the path and the way back in after a self-lockout.
- (2026-10-29) **The map's streams add up the way the counts do.** A stream's width followed the logarithm of its count, so ten hits drew at half the width of a thousand, and a continent of small sources outshone a single source five times their sum. The weight is now the square root of the count, and the labels and the top-sources line carry each source's share.
### Fixed
- (2026-10-27) **A link's query reaches the page as written after a challenge.** After a pass, the page was asked for with its query rewritten -- a space as +, a slash as %2F, a bare flag given = -- so a campaign link with such a query also lost the visitor's referrer. The query is now left exactly as the link had it.
- (2026-10-27) **The step back to the page keeps the loading screen up.** Keeping the visitor's referrer goes through a relay page, which stayed blank until the site answered -- seconds on a slow site -- where the challenge page's loading screen used to stay. The relay now paints that screen as it was -- background, CAPTCHA card, spinner, logo, progress bar and text -- in the same places.
- (2026-10-26) **A visit that passes a challenge keeps where it came from.** The challenge page left with a script navigation, which made itself the referrer of the page it loaded: analytics counted a search or a link from another site as a direct visit, and the site's log lost the source. It now steps back to the history entry the visitor arrived on, which the browser fetches again with the original referrer.
- (2026-10-26) **A pass now ends when its solve does, re-binds included.** The credential that carries a pass to a new address was honoured for the longer of the two solve windows, and each re-bound pass got a fresh CAPTCHA window, so a seven-day proof-of-work kept a browser passing for up to four weeks, even on the address it was solved from. Both now end with the solve.
- (2026-10-26) **A failed webhook test says why.** The notification tab's webhook and mail tests showed a JSON parse error on failure: nginx's daemon-down hook swapped the daemon's answer, reason and all, for its own 503. After the next nginx reload the daemon's answers reach the page, and a failed test or alert names what the webhook answered, without the hook's URL, which carries its secret, in the page or the log.
- (2026-10-26) **With protect.inc at server scope, visitors not yet passed no longer get a 503 while the daemon is down.** In compose mode, protect.inc ran again on the way into the challenge and cleared the flag that the replay of the original page keyed on, so nothing was replayed. The replay now keys on a mark only a protected pass sets; bans still hold during an outage.
- (2026-10-26) **A directory URL no longer gets a raw 502 while the daemon is down.** The fail-open replay of the original page lost its mark on any further internal redirect -- a directory's index file, a try_files fallback -- and was challenged into the stopped daemon again. The mark now lasts the whole request, from the next nginx reload.
- (2026-10-25) **Firefox 158 is no longer challenged as a Chrome in disguise.** Firefox 158 sends the same fifteen cipher suites as Chrome, and the UA-TLS tier's family check, which knew Chromium's stack by its cipher hash alone, contradicted every Firefox 158 user and sent them to the CAPTCHA. The check now needs one of Chromium's extension hashes as well; a Firefox keeps its own.
- (2026-10-24) **A site's own proof-of-work difficulty is what the module verifies.** The challenge page served each site the difficulty it configured, while the module held every cookie to the install-wide default: a site set below it looped its visitors on a proof-of-work the verifier rejected. The daemon now renders a per-host map of the values that differ, and the module reads it before the directive.
- (2026-10-24) **The JA4 of a hello with no ciphers or no extensions follows the specification.** An empty cipher list, or an extension list that is empty once SNI and ALPN are set aside, is twelve zeros in the JA4 specification, and a signature_algorithms extension with no algorithm adds no underscore; the module hashed the empty string instead, a fingerprint no other implementation produces, so a shared ban keyed on one never matched.
- (2026-10-24) **The ban file loads in sorted order whatever its size, and the gate reads its fail-open flag by name.** The loader's insertion sort walked a large unsorted file for seconds inside a request, and its out-of-memory fallback could leave an action beside the wrong key; the compose gate took any value longer than two characters as "daemon down". Rows now sort together, and the gate reads the field after the colon.
- (2026-10-24) **The UA-TLS consistency tier's version check reads a QUIC session too.** Over HTTP/3 the fingerprint carries a `q` prefix where a TCP session has `t`, and the "TLS 1.3 under a pre-TLS 1.3 version" check matched only `t13`, so the contradiction went unseen on HTTP/3.
- (2026-10-24) **Two settings writes at the same moment could lose one.** Most handlers loaded config.yml, changed their copy and saved it with only the publish under the lock, so a save that overlapped another writer's -- the hub's token write-back, a second operator -- overwrote it with an older copy. Every write now loads, changes and saves under one lock, and the saved copy is what the daemon runs.
- (2026-10-23) **The challenge page's pass reports are no longer dropped from a busy address.** The beacon's per-address limit counted the daemon's own serve rows, so an address challenged twenty times in five minutes could report nothing, and a client re-solving the proof-of-work on every page was all but invisible to the hunt and the funnels. The limit now counts beacons only, a pass the request's own cookie vouches for is outside it, the /bvj issue records a pass whose beacon never arrived, and the hunt shows how many beacons an address lost.
- (2026-10-20) **The PoW cookie reuse ranking's "show more" opens its own rows.** On the stats page the cookie-reuse card holds the CAPTCHA, PoW and re-bound rankings one under the other, and every "show more" there opened the card's first table, so the PoW ranking's rows past the tenth never appeared. Each button now opens the ranking right above it.
- (2026-10-19) **A request a rate limit refused no longer counts as passed.** The access-log feed marked a request the rate route answered -- a CAPTCHA or a deny page -- as a pass, because the challenge decision itself had been zero; the composition card, the live strip and the crawler drill-down counted a refused request among the passes. It now counts as served, like a challenge.
- (2026-10-19) **A visitor whose requests leave through more than one address can solve the CAPTCHA again.** The proof-of-load, arithmetic and beacon tokens were bound to the client address, so a two-uplink NAT or a relay that changes its egress between requests failed every attempt, and the arithmetic fallback with it. They are now time-bound and single-use instead; the pass cookie itself stays bound as before.
- (2026-10-23) **The landing page's request-count card no longer walks the whole minute table.** Its read of the unsettled hours had no upper bound, and on a database without planner statistics SQLite answered it by walking every row of the per-minute table through the kind index: tens of seconds from a cold disk, and the card timed out on every open. The read is bounded now and goes through the primary key.
- (2026-10-23) **`unmask doctor` and the stats page read the event table's id range in two seeks.** `SELECT MIN(id), MAX(id)` in one statement is outside SQLite's min/max optimization and scanned the whole id index: the doctor's "could not read the rollup cursor" on a large database, and the same read behind the stats page's raw-scan check and the AI advisor's event-count guard. Each end is its own statement now.
- (2026-10-26) **A reload after removing the nginx module no longer crashes the new workers.** The module registered a free callback with OpenSSL for its per-connection JA4 data; once `load_module` was removed and nginx reloaded, as the package's removal advice says, the callback pointed into unmapped memory and every new worker died on its first TLS close. The data now lives in the connection pool and no callback is registered.
- (2026-10-26) **JA4 follows the specification for an empty SNI extension and for very long cipher or extension lists.** The indicator was `i` for a server_name extension that carried no host_name entry, and a hello with more than 256 ciphers or extensions hashed differently from other implementations; the lists now hold 1024 entries and 256 signature algorithms. Real browsers' fingerprints do not change.
- (2026-10-26) **The native fail-safe renders a configuration without the module instead of dropping http.inc.** A vhost that includes server.inc could not start nginx after the fail-safe, since server.inc names a log format from http.inc. When place-module.sh strips the module it now re-renders the configuration without it: nginx starts, the admin UI stays reachable, every file carries a banner, and protection is off until the module is back.
- (2026-10-26) **Ban addresses are stored the way nginx spells them.** An IPv6 address typed or stored in another spelling (upper case, expanded zeros, a v4-mapped form) never matched nginx's `$remote_addr`. Every entry point now normalises the address the way inet_ntop prints it, and the daemon rewrites stored rows once at start, dropping a duplicate whose fixed spelling another row already has.
- (2026-10-26) **Admin cookies no longer ride to the protected site.** The session and CSRF cookies were issued on Path=/, so every request to the site on the same origin carried the admin's session to the upstream application. They are scoped to the base path now (`/unmask`), where every request is the daemon's own. Reachable by anyone able to read the application's request logs.
- (2026-10-26) **The CSRF token is derived from the session.** The check was a double-submit cookie compared with the form field, which a cookie planted from a sibling hostname could satisfy for a signed-in operator. The token is now an HMAC over the session's signature under a key derived from bv_secret, and the cookie only carries it to the page. Needs a sibling host under the same registrable domain.
- (2026-10-26) **Logout ends every session of the account.** Logging out only asked the browser to drop its cookie; a copy of it stayed valid for the rest of its life, thirty days with "remember me". Logout now advances the account's session generation, so the cookie is refused everywhere from then on, and the audit row is recorded again (it was lost on this route).
- (2026-10-26) **admin_allowed_hosts guards every admin page.** The Host allowlist was read only by the pages before a session exists (login, password reset); an authenticated session reached every page under any name nginx proxies through. AuthMiddleware applies the same check, so the list means what its description says.
- (2026-10-26) **Password-reset links are built on a configured address, never on the request's Host.** With admin_allowed_hosts empty, the reset link took the Host header of whoever asked, so a forged request could have a victim deliver the token elsewhere. The new nginx.admin_public_url names the admin's address; without it the first allowed hostname stands in, else the mail carries only the path. Tokens are stored hashed; unused links must be re-sent.
- (2026-10-26) **The settings file is written under a lock.** A save from the admin and `unmask upgrade-review --apply` could write the same fixed temp file at once and leave a torn config.yml. The daemon and the CLI now serialize on config.yml.lock, write through a temp file of their own name, and fsync the directory after the rename.
- (2026-10-26) **The render record no longer carries the secret.** The record of what a render changed (`.render-cause`, behind the "nginx has not reloaded" notice) keyed each block by its first words, so a change of the unmask_bv_secret line put the key's first 42 digits into a world-readable file and into the notice. The block is named by the directive alone now, old records are cleaned at start, the file is 0640.
- (2026-10-26) **The webhook URL is hidden from the AI assistant's settings outline.** The outline the assistant reads masked fields by the look of their name, while the settings export kept its own list of credentials; the notification webhook URL was only on the latter and went to the model with the notifications tab. Both read one table now.
- (2026-10-26) **Event writes no longer lose a batch to one over-long value.** Every string column is cut to its width and made valid UTF-8: MariaDB's strict mode refused a long `_bv` cookie and rolled back the whole batch, which was retried with the row still in it. A batch refused for one row's data is written row by row, dropping only that row. Migration 0048 widens `cookie_bv` on MariaDB.
- (2026-10-26) **The access-log receiver no longer falls behind nginx.** One goroutine read the log socket and handled each line, including a honeypot trip's ban write; while it was busy the socket's queue (bounded by `net.unix.max_dgram_qlen`, not SO_RCVBUF) filled and nginx dropped lines. The receiver now only reads and queues; a worker handles the lines in order. Dropped lines are counted and logged.
- (2026-10-26) **Varying the Host header no longer grows the per-site tables without limit.** The minute buckets were keyed by `$host` as logged, so on a catch-all vhost each distinct value cost sketches and rows of its own. Past 256 distinct sites in a minute the rest count under `(other)`, and in "defined" mode a Host off the list counts there at once.
- (2026-10-26) **The stats page no longer reads a card's result while a late card writes it.** The page waits for its cards against a deadline and renders what came back; a card still running past it wrote its result into the slices the template was reading. Results go into one struct under a lock now, and the page renders from a copy taken after the wait.
- (2026-10-26) **The mmdb auto-update and WAL watch loops survive a panic in one pass.** Both wrapped the whole goroutine in a single recovery, so the first panic ended the loop for the rest of the daemon's life with one log line to say so. Each pass recovers on its own now: the panic is logged and the next pass runs.
- (2026-11-01) **A check that has run out of time says so.** A visitor who returned to the checkbox after fifteen minutes was refused for a stale page token; the arithmetic fallback was refused the same way, and the page drew "undefined + undefined = ?". The card now says the check has expired and reloads once on its own, or offers a reload button, and the arithmetic wording is translated. A token issued just before midnight UTC verifies after it.
### Security
- (2026-10-24) **nginx enforced no ban where its worker is not the unmask user.** The module reads the ban list from nginx's worker, which runs as nginx.conf's `user`; the data directory was 0750, so the worker never reached the list and every ban stayed on paper. The directory is 0751 now, and the doctor, the install and every admin page say when the worker still cannot read the list.
- (2026-10-24) **The module verifies a request's cookies once.** Every `$unmask_bv_*` variable is read by several maps across nginx's phases, and each read walked the Cookie header and ran its HMACs again with a fresh sixteen-entry budget, so one request cost up to seven times what the budget was meant to bound. The cookie's kind, the entry that matched and the ban-file lookup are now computed once per request and kept in the request's context.
- (2026-10-24) **The arithmetic CAPTCHA hands out a question only to a client that loaded the page.** `/api/captcha/new` issued a question and a proof-of-load token to anyone, so a script cleared the built-in CAPTCHA in two requests and collected a fourteen-day pass with no page ever served. The question now requires the page's own token and hands it back for the answer, and one address draws at most the beacon limit of questions in five minutes.
- (2026-10-24) **A solved CAPTCHA counts toward the pass budget and draws on a reuse budget of its own.** The reuse cap did not count a CAPTCHA pass at all, so one scripted answer bought unmetered requests for fourteen days, and the pass budget counted proof-of-work solves only. A CAPTCHA pass now counts as a solve, and its holder's requests draw on a budget of five times the reuse cap (zone `unmask_reuse_captcha`).
- (2026-10-24) **A honeypot trip a page could have caused bans nobody.** A trap URL is a string anyone can put in an `<img>` on another site or in a link in a post, and the browser that loaded it was banned for a day with no way out, since a ban precedes every cookie. A trip arriving as a subresource, from a cross-site navigation or with a Referer is answered as the trap says and bans nobody; a scanner's bare request is banned as before.
- (2026-10-24) **A crawler name the configuration verifies by its vendor's range keeps the honeypot exemption only when that range passed it.** A scanner wearing "Googlebot" from any address was never banned for a trap it walked into, because the exemption read the name alone. For a name with a published range the request must have been passed on that range; a name without range data keeps the exemption, as banning it could cost a ranking.
- (2026-10-24) **A run of fingerprints banned from one address bans the address.** An automatic ban is scoped to the address and JA4 pair, and a client that varied its TLS ClientHello per connection arrived with a fresh pair, a fresh budget and a fresh ban every time. Three fingerprints banned from one address within a day now place an ip-only ban, and the daemon's own ban check matches ip-only and JA4-only rows as the module does.
- (2026-10-24) **A regex pattern with a quote could end its nginx map key.** Path, honeypot and redirect-exemption patterns were written into the rendered config as typed, so a regex containing `"` closed the quoted key and nginx read the rest as directives, while a backslash reached PCRE one short. Every pattern in every list is now escaped for nginx's reader, a pattern with a line break is refused on save, and the rendered files change once, so the reload notice shows after the update.
- (2026-10-24) **The admin session is signed with a key derived from bv_secret, not bv_secret itself.** The same value is written into http.inc for nginx's workers, so anyone able to read that file could forge a superadmin session. The session key is now derived from bv_secret (HKDF-SHA256) and signs nothing else; config.yml is unchanged. Every operator signs in once more after the update.
- (2026-10-23) **A proof-of-work seed no longer passes as a signed cookie.** The seed the challenge page hands every visitor is signed over the same message as a pass cookie, with the word `pow_seed` where the cookie carries its kind, so its first sixteen digits verified as a pass of an unknown kind: a client could present it without hashing once, for the CAPTCHA cookie's window. Both verifiers now accept only the kinds the daemon issues.
- (2026-10-23) **A configured third-party CAPTCHA is the only way through the verify endpoint.** With a provider set, a client that left the provider's token out of its request was judged by the built-in behavioral score or the math fallback instead -- what the operator had chosen the provider to replace. Such a request is refused now; a provider whose site key is unset still falls back to built-in, as the page does.
- (2026-10-23) **Community Bans entries are shape-checked before they become nginx map keys.** The pulled feed's addresses and JA4 strings were quoted and written as keys, and inside quotes `default`, `~` and `include` keep their meaning to nginx: a feed carrying them would have challenged everyone, or stopped nginx from starting, on every subscribed install at once. An entry is written only as a parsed address and a JA4 of the expected shape.
- (2026-10-23) **A custom rule's pattern and a trusted-LB label stay inside their quoted key and comment line.** The pattern escaping doubled no backslash, so a regex ending in `\"` closed its key early and nginx read the rest of the pattern as directives, as root; a line break in a custom LB label did the same from its comment line. Both took the admin role. The renderer escapes the backslash first and keeps the label to one line.
- (2026-10-23) **The forward-auth snippets set the client address and the cookie's binding key themselves.** Behind the shipped Apache snippet a client-sent `X-Real-IP` reached the daemon as the visitor's address, so the admin IP allowlist, the login lockout and the `/metrics` allowlist read what the client wrote; both snippets also passed a client-sent `X-Unmask-Bind-Key`, the key an issued pass cookie is bound to. Apache now sets `X-Real-IP` from the connection, and both snippets clear the key.
- (2026-10-23) **A request with many `_bv` cookies no longer costs the worker a hash per entry.** The sixteen-entry cap applied to each `_bv` value, and a client could send any number of values, so a few kilobytes of Cookie header bought an HMAC and a SHA-256 per 35 bytes, repeated as the templates read the variable. The budget is per request now, in the module and the daemon alike.
- (2026-10-23) **Password checks run a few at a time.** A login attempt costs an argon2id run of 64 MiB, and nothing bounded how many ran at once; the lockout counted an attempt only after its check finished, so a burst of parallel attempts from one address could take the daemon's memory past what the host has. At most four run concurrently now, the rest wait, and one that cannot start within ten seconds gets a 503 with Retry-After.
- (2026-10-23) **The login lockout counts an IPv6 client by its /64.** The lockout keyed on the exact address, and an IPv6 client holds a whole /64 to rotate through, so the ten-failure limit never tripped for it. Failures are counted per /64 now; IPv4 is unchanged, and the limit is still never per account.
- (2026-10-23) **The `/metrics` allowlist reads the client address the way the admin UI does.** It took `X-Real-IP` as sent, so a client that reached the daemon other than through the rendered nginx config could name a loopback address and read the traffic figures. Forwarded headers now count only from a trusted proxy, as for the admin pages.
- (2026-10-23) **A password-reset link is built from a host the allowlist names.** The link in the reset mail took its host from the request and matched the admin host allowlist by exact text: an `exact:` or `subdomain:` entry produced an unusable link, and with no allowlist a request sent with another host put that host in the mail. Forwarded host and scheme are honored only from a trusted proxy, entries are read as the UI reads them, and with no usable entry no mail goes out (the log says so).
- (2026-10-23) **A password change or an account suspension ends that account's other sessions.** Sessions were signed cookies the server could not revoke, so one copied from a browser stayed valid after the password was changed, for up to thirty days. Each account now carries a session generation (schema update 0046) that the cookie names; a password change or suspension advances it, and the session that made the change is reissued. Existing sessions are signed out once by this upgrade.
- (2026-10-23) **Audit rows and settings snapshots carry no secrets.** Every settings save wrote the whole configuration before and after into the audit log, secrets included -- the cookie-signing key, the AI and SMTP credentials -- and the admin role's audit page showed old and new values in its diff. Rows now hold the redacted form (a save that changed only a secret reads "(no changes)"), a restore keeps the live secrets, and the redaction also covers the CAPTCHA provider keys, the notification webhook URL and the test-page password.
- (2026-10-23) **A test mail uses the saved SMTP password only for the saved server.** The test took the host from the form and, with the password field empty, the saved password with it, so one click could deliver that password to a server of the admin role's choosing. The saved password now goes only to the saved host, port and username; any other server needs the password typed in.
- (2026-10-23) **Changing the SMTP host or the AI provider's endpoint drops the stored credential.** Saved alone, the change would have sent the old password or API key to the new host with the next alert or consultation. The credential is cleared unless a new one is entered with the change, and the settings page says so after the save.
- (2026-10-23) **A Privacy Pass token is accepted once.** The verifier kept no record of the tokens it had passed, so one token lifted from a client that holds a genuine one passed again from any address, for as long as the issuer's key stayed trusted -- ahead of the CAPTCHA-grade rules, since the token path is a veto pass. Each token's nonce is now remembered for a day in the daemon's memory; a second presentation is refused and the visitor meets the ordinary challenge. Privacy Pass is opt-in.
- (2026-10-23) **Two bans placed in the same second both take effect.** The module reloaded its ban file only when the modification time changed, at one-second resolution, while the daemon flushes the file on every ban it places: the second ban in a second was not applied until a later write landed in a different second. The file's size and inode are compared as well.
- (2026-10-21) **A POST to robots.txt and the other fetched-file presets is judged.** The default-on bypass presets (static files, `/.well-known/`, browser metadata, health checks) passed every method, so anyone could send a request body past the challenge; on a site that routes unknown paths to one script, that script parsed it. They now pass GET, HEAD and OPTIONS only; your own bypass paths still pass every method.
- (2026-10-25) **The container image runs nginx 1.30.6.** It ran 1.28.3, and the 1.28 branch has no fix for most of this year's nginx advisories, among them CVE-2026-42945 and CVE-2026-9256 in rewrite and CVE-2026-42533 in map with a regex, both of which the gateway uses on every request. 0.1.53 closes it; the host packages, which load into your own nginx, are unchanged.
## [0.1.52] - 2026-10-19
### Added
- (2026-10-17) **The realtime tab has a blocked tile.** Beside the challenge, refused and solved tiles, a tile counts what the dashboard's headline counts -- challenges served plus refusals, minus the proof-of-work and CAPTCHA answers and the visitors who walked away -- over the last thirty minutes and the last minute, a bar a minute. It is the total of what the map draws by default.
- (2026-10-17) **The dashboard gets a week tab, and loses its line of now.** Beside "Today (24 h)" a "Week (7 days)" tab draws the same section -- the blocked headline, the composition, the tiles, the crawler table and the reasons -- over the last seven days, from the same rollups, with its refresh button. The line above the day section that repeated the realtime tab's figures is gone.
- (2026-10-17) **The stats page charts the bot requests unmask blocked, by day.** A new 30-day card shows the landing page's headline figure as one bar a day -- challenges fired minus the proof-of-work and CAPTCHA answers minus the visitors who walked away -- with the 30-day total and a table of the terms, so the trend of what was stopped is visible beside the request and challenge breakdowns.
- (2026-10-17) **An account can be kept from changing its own password.** A viewer login shared by a team broke the day one of its users changed the password. The users page has a checkbox per account: with it set, the profile's password form gives way to a note and the forgot-password link issues nothing, while an administrator still sets the password there, and the CLI can create such an account.
- (2026-10-16) **Ask AI counts a window instead of reading it.** A new tool counts the event log over a window under the hunt's filters -- by phase, hour by hour, with the distinct addresses -- so how many, and whether a rule made a difference, is answered from the whole window. The row tool now says what span of the window its rows cover and whether more exist, and no longer carries each event's raw payload.
- (2026-10-16) **A local address shows a flag of its own.** An address from a private network, loopback or link-local range showed the same "??" as a public address the country database could not place. It now carries its own flag, a house, wherever an address is shown with one, and the stats page's country rankings list local addresses and unplaceable ones as two rows, named for what they are.
- (2026-10-16) **The audit log records installs, updates and schema updates.** Beside the operator's actions, the log now says when the software itself changed: the first start of a new database, a start as another version than the last one (from and to, with the build), and the schema updates a start or `unmask migrate` applied, with how long each took. A restart as the same version writes nothing.
- (2026-10-16) **The admin has a notification list: a bell in the top bar.** Every alert the daemon sends by webhook or mail (bans, bursts, the over-block breaker, the advisor's digest) is also kept for the admin, whatever the notification switches say. The bell counts what the account has not opened; the panel and the page write each line in the reader's language, bans folded into one line per hour.
- (2026-10-17) **Ask AI's history says which proposed rules were made.** A "Create this rule" button in an answer now reads "✓ Rule created" with the rule's name, and opens the custom-rules tab at that rule, once the rule is on the list -- the one added from that button, even if the dialog changed it, or one with the same conditions. Removed, the rule is offered again.
- (2026-10-18) **SysVinit hosts start the daemon again when it dies.** On systemd and OpenRC the service manager restarts the daemon after a fault; on RHEL 6 / CentOS 6 nothing did, and every challenge page waited until an operator ran `service unmask start`. The init script now runs `unmask serve -supervise`: a small parent that keeps the daemon as its child, hands on the stop, and restarts it after five seconds, giving up after ten crashes in ten minutes.
- (2026-10-18) **The realtime tab has an effects switch, on by default.** The flash of a changed figure, the colour of a new row and the map's particles and pulse followed the operating system's reduce-motion setting, so on a browser set that way the page looked frozen while its numbers moved. The effects now run whatever the system asks for; a switch beside auto-refresh turns them off, and the choice is kept in the browser.
### Changed
- (2026-10-19) **The crawler dialog's badge legend folds into a popover.** In a group's Detail dialog on the UA filter tab, the seven-line note on the 🛡 badge sat above the list and left it a few rows tall. The note is now a popover behind a one-line hint, and the list takes the room, with a floor of eight lines even on a short screen.
- (2026-10-18) **The country rankings show a two-letter code on every row.** The two charts named the address the database could not place and the local one in words, so those rows' bars started further right than the rest. Every row now shows a code -- `??` and `XL` for those two -- and the popover over either says what it counts: addresses the IP-geo database has no entry for, or private, loopback and link-local ones.
- (2026-10-18) **The realtime tab counts benign crawlers as judged, like the dashboard.** The strip filed a listed crawler the configuration let through with the bypassed requests, so "judged only" left it out while the dashboard's judged traffic counted it as a benign bot. The strip now keeps it in the judged requests, shows how many beside the request tile's headline, and the map can draw them.
- (2026-10-18) **The request tiles explain their arithmetic.** The tiles beside them count what the challenge did, and summing those does not give the request count: a fire includes the solves, and the benign crawlers and the unchallenged requests have no tile. The help of the dashboard's request tile and of the realtime tab's now says how the figure divides, and which neighbours are outcomes rather than parts.
- (2026-10-18) **The request figures follow the judged scope.** With the non-human card on the traffic unmask judged, the dashboard's request tile shows that count too and names the bypassed requests it leaves out, and a segment taken out of the card's share reads "excluded" instead of a blank. The realtime tab gains the same switch for its request tile, judged only by default.
- (2026-10-18) **The non-human traffic card opens on the traffic unmask judged.** Its share was computed over every request in the access log, the bypassed ones too, which diluted it with traffic unmask never evaluated. The card now opens on the requests unmask judged, with "All traffic" one click away; an operator who had picked a view keeps it.
- (2026-10-18) **The dashboard's headline says what it counts.** Under the number of bot requests unmask blocked, the line read "requests in the last 24 hours", which could be taken for the site's whole traffic. It now reads "requests blocked in the last 24 hours", and the week's view says the same for its seven days.
- (2026-10-17) **Bans can be placed and lifted while the database is being updated.** A manual ban during an index build used to be refused until the build had finished. It is now kept in memory like an automatic one -- enforced at once, listed as a row to come, written when the lock is free -- and a ban lifted meanwhile stops at once too. Only editing a ban waits.
- (2026-10-17) **A database update refuses only the changes that rewrite rows.** While an index builds, only user accounts, the profile, the advisor's dismissals and the AI chat's history edits wait, told why at once; the hunt page's rules, the map's position and a question to the AI go through, as the settings always did. The build runs at low CPU and I/O priority, so the pages stay quick.
- (2026-10-16) **The realtime map puts Japan in the middle for a Japanese admin, and its counts carry a thousands separator.** Read in Japanese, the map is centred on Japan with the Pacific whole, as a map printed in Japan is; in English it stays centred on Greenwich. The continents are two shades lighter than the sea, and every count on the map and in its popovers separates its thousands.
- (2026-10-16) **The "nginx has not reloaded" bar is one line.** The notice that nginx is still running an earlier configuration took two lines on every page, the second spelling out the commands. It now says what is the case and since when on one line; the way to apply the configuration, and how the notice knows, open from a question mark beside it.
- (2026-10-16) **The audit log is kept by default, and its retention and the notification list's are set on the retention tab.** `audit_retention_days`, which deleted the admin-action log past 90 days, is gone: the log is kept until the operator sets `audit_log_retention_days` (0 = keep), the notification list has `notice_retention_days` of its own, and a config file still carrying the old key is told so at startup.
- (2026-10-16) **The menu offers a viewer the pages the role can open.** An account with the viewer role sees the dashboard, the statistics and bot hunt, not BAN management and the settings, which refuse it with a 403; the dashboard's tiles and links and the BAN pages' tabs follow the same rule. Bot hunt opens for the role without its BAN and rule buttons; the settings page now takes admin.
- (2026-10-14) **The stats page answers in seconds on a large database.** Its per-visitor cards -- reload loops, verification failures, CAPTCHA passes, cookie failures, rebinds, rate-limited requests -- read indexes of their own rows, reads of a time window find the address, phase and verdict in the date index, and the funnel no longer waits on its rate-limit row. A large database builds the indexes when you run the update.
- (2026-10-16) **The realtime map shows what unmask blocked, and what it refused.** The legend switches each kind of request on or off, blocked alone by default -- the dashboard's blocked count, country by country. A country's popover still splits all its requests, and a line under the map lists the last thirty minutes' refusals by reason, each linking to bot hunt, with the rules that matched most.
- (2026-10-17) **A challenge burst is measured against the hour before.** The burst notification, and its line in the admin's notification list, now come when five minutes bring ten times the challenges of an average five minutes in the hour before, and at least the configured minimum, which used to be the whole test. A busy site's ordinary traffic no longer fills the list, and one surge gives one notice an hour.
- (2026-10-17) **The realtime map is the one printed where the server is, without Antarctica.** Its middle followed the reader's language; it now follows the server's country: Greenwich for most of the world, the Pacific for East Asia and Oceania, Japan in the middle of a Japanese server's map. Antarctica, which sends no traffic, is cut off at 60°S, so the map takes less height.
- (2026-10-18) **Ask AI counts a window from the hourly rollup, and refuses a scan it cannot finish.** A count without a filter is answered from the rollup at once, with the addresses estimated; a filtered count reads the window's events, and a window of more than about two million is refused with the way out instead of being cut off by the tool's deadline. The settings search renders each tab without the reads only its page needs.
- (2026-10-18) **Ask AI sends less, and reads the rest from the provider's cache.** A question's context is now the conversation on the page since it was opened, not the last ten turns of the history whatever the question. With Anthropic the system prompt, the tools and each round's transcript are marked for the prompt cache, so a round reads them back at a tenth of the rate; the turn shows how much came from the cache, and the estimates price it.
- (2026-10-18) **The SQLite library is the current release.** The driver moves from modernc.org/sqlite v1.51.0 (May, SQLite 3.53.1) to v1.60.1 (SQLite 3.53.4, with its libc and allocator): four months of fixes, among them the dozens of memory-safety corrections SQLite 3.53.2 to 3.53.4 took from fuzzing, and the driver's own hardening of the page cache and of interrupting a query from another goroutine.
### Removed
- (2026-10-16) **The realtime tiles no longer compare the last minute with the one before.** The bars under each tile already show the last thirty minutes a minute at a time, which says more than the difference between two of them. The reading the page refreshes from, `/admin/api/now`, no longer carries the previous minute.
### Fixed
- (2026-10-18) **A page's time budget now reaches every row of a query, not only the first.** The SQLite driver watches a query's deadline while the first row is found and no longer afterwards, so a query that streams rows ran on past its budget: on a disk kept busy by an index build, for hours. The watch now lasts to the last row, and the query stops at its deadline.
- (2026-10-18) **A schema update started from a shell holds the daemon's writes from its first moment.** The daemon judged a run going only after reading the database, and under an index build on a slow disk those reads took minutes: its writers waited out the busy timeout at every write, and events, bans and the access-log feed suffered. The run's lock is looked at first now, and the hold follows it.
- (2026-10-18) **The update notice and the dashboard answer at once while an index builds.** The notice's poll read the database on every page's turn and stood in line behind the build; the dashboard's figures waited with it, for hours. The poll now answers from what the daemon knows, one at a time per page, and the dashboard's section says it is paused until the update has finished.
- (2026-10-18) **Nothing is dropped while the events kept through an update are written back.** The backlog was written in one long stretch while new events overflowed the queue, bans and notices waited out the lock, and the access-log socket lost lines. The backlog now goes out in small chunks, the queue drained between them; an automatic ban no longer waits for the lock, and a notice that failed is tried again.
- (2026-10-17) **The week tab's request figures no longer time out on a large database.** The composition behind the headline was summed from the per-minute table over the whole window, a row per site, kind and minute, and seven days of a many-site install ran past the page's budget. The settled hours now come from the hourly rollup; only the minutes around them are read live.
- (2026-10-16) **The Community Bans impact figure reads an index.** The "past 30 days" count on the Community Bans tab read every serve event of the window, which on a large database outlasted its budget and left the tab without a figure. Schema update 0039 adds a small index of the rows it counts; a large install leaves it to the operator, as the card indexes do.
- (2026-10-16) **A feed hit from a client that cannot take the HTML page is named.** A script or crawler the shared feed caught that asked for JSON rather than the challenge page was recorded under no reason and told none, so the Community Bans tab's figure missed it. The JSON answer now resolves the hit from the same matcher as the page and records community_bans.
- (2026-10-17) **Addresses open their popover on the realtime tab's recent detections and in the bot hunt's live tail.** The recent table lost the popover at its first redraw, ten seconds after the tab opened, and the live tail had never had one. Both now show the same reverse DNS, country and network details as the bot hunt's table.
- (2026-10-17) **The realtime tab's refused tile counts every refusal, beside the challenges.** It counted only honeypot trips and bans whose action is deny, while the deny page of an ASN, country, UA, JA4 or custom rule went under the challenges served. Both tiles now count the page the daemon answered with: the refused tile matches the breakdown under the map, and the challenge tile holds challenges only.
- (2026-10-18) **The realtime tab's countdown ends when the figures change.** The bar beside the countdown ran most of a second behind: the figures changed with a fifth of it still to fill, and the count then sat at 0 for a second. Both now run out as the next reading is taken, and the recent-detections table is read with the strip, not on a clock of its own.
- (2026-10-18) **SQLite reads are no longer memory-mapped.** Every connection mapped the first few dozen megabytes of the database, and dropped and re-made that map whenever the write-ahead log had changed, for next to no gain on a database of gigabytes: the page cache is the cache that matters. Memory-mapped reads are off, and the performance tab no longer counts an mmap allowance beside the cache.
## [0.1.51] - 2026-10-16
### Added
- (2026-10-13) **iCloud Private Relay users count where they are.** Country databases place many of the relay's addresses in the wrong country, so an iPhone in Japan could count, and meet a country rule, as a foreign visitor. The relay's own egress list now comes, signed, from unmask.sh each day: statistics, bot hunt and the country rules go by its country, and network rules and the advisor leave relay addresses alone.
- (2026-10-13) **Each reason on the dashboard opens its settings.** The card of why requests were challenged or denied gains a gear on every row that opens the settings behind that reason, at the card that sets it where a tab holds several: header integrity and outdated browsers under the UA filter, the reuse cap under rate limit, the test pages under challenge. Banned opens the ban list.
- (2026-10-13) **The admin pages say when nginx has not loaded the saved settings.** Saving renders nginx's configuration and leaves the reload to you; until then the change is not live, and nothing said so. Every access-log line now carries the id of the configuration nginx loaded, and every admin page shows a notice while nginx logs an older one. The doctor the admin runs gives the same verdict.
- (2026-10-12) **The advisor page shows what its runs cost.** The AI bar adds an estimated cost to the last run's token line and to the 30-day total, each run priced at its own model's list rate, the ask page's questions included. As on the ask page, it is marked as an estimate and the provider's statement stays the bill.
- (2026-10-12) **Ask AI adds a rule in place; the effort pick names its default.** "Create this rule" opens the proposed rule in a dialog, the custom-rules tab's own card, and adds it without leaving the page, warning when a rule with the same conditions exists. The effort's default now says what runs, and the advisor page picks its model and effort too, keeping a review per model.
- (2026-10-12) **Ask AI shows what an answer cost, and its addresses open the popover.** An answer's token line now carries its cost, estimated from the provider's published list prices, and the history tab sums the month for every account; both are marked as estimates, the provider's statement being the bill. An address an answer names opens the hunt page's popover -- reverse DNS, country, ASN -- on hover or click.
- (2026-10-10) **Custom rules: several conditions, one action.** A new settings tab takes rules: condition lines (addresses, JA4 fingerprints, countries, networks, user agent, path, host; all must hold, any value of a line matches), one action (count only, PoW, CAPTCHA, PoW then CAPTCHA, deny) and an optional per-address rate limit with its own over-limit answer. The first rule that holds decides, after bans and bypasses. The hunt and the assistant propose rules.
- (2026-10-10) **The assistant names the exact setting.** Asked where or how something is configured, it now reads the settings pages as the operator sees them -- the tab and its path, the section heading, the field's label and current value -- and the config.yml key, and answers with those rather than "the settings page". What unmask does not write, such as nginx's own real_ip directives, it says so.
- (2026-10-10) **The map places the server on its own.** Without a set position, the server is placed by its address: a global one on an interface, or, behind NAT, the one unmask.sh's address check reports when a button in the dialog asks it (api/ip returns the caller's address, nothing else). A City geo database gives the point; a Country one the country's centre. The dialog also takes a position by hand.
- (2026-10-10) **The dashboard opens with a "right now" strip that refreshes itself.** Seven tiles say what the last minute brought -- requests, passes, bypasses, challenges served, solves, denials, rate limits -- each with a five-minute sparkline and the change on the minute before, read every five seconds from memory. A switch turns the refresh off; the last figures stay up while a reading is fetched, and a figure that moved flashes.
- (2026-10-10) **A map of where requests come from, drawn live.** Under the strip, the last minute's requests flow as light from their source countries to this server: width, glow and particle count follow the count on a log scale, particles are coloured by outcome, and pointing at a source shows its flag, name, count and split. An admin sets the server's position from the card; until then only sources are shown.
- (2026-10-10) **The dashboard's recent detections keep up on their own.** The table redraws from the server every ten seconds under the same switch as the strip above it; a row the page had not seen flashes green and settles, and the rows keep their session chains and popovers across a redraw.
- (2026-10-10) **Ask the configured model about this install.** A new page, linked from every admin page's header, takes a question in plain words and answers it from the install's own data: the model reads through read-only tools (overview, rankings, event log, one address, bans, the live strip, settings summary, doctor) and changes nothing. Same provider settings as the AI advisor; history per account; answers link the admin pages they name.
- (2026-10-07) **The over-block alert arrives in its reader's language.** An account gets it in the language it last saw the admin in -- the one picked at the top right, or the browser's -- recorded as it uses the admin. Addresses listed under Recipients, and accounts that have not opened the admin yet, get the language chosen on the notifications tab: English by default, or Japanese. Other alert mail stays in English.
### Changed
- (2026-10-15) **Pickers that inherit a default name the chain.** The UA filter's crawler groups and blocklist presets, and the JA4 verdict and honeypot tabs' presets and rows, say which chain "inherit" resolves to and follow the tab's default as it changes; an unset honeypot default reads as the install's default chain. The crawler group's picker moves ahead of the count, so the count and Detail line up on every row.
- (2026-10-14) **Crawlers in the UA filter's presets can be set one by one.** Each group is set to Pass, Normal checks or Stop (formerly White, Neutral, Black), which also covers crawlers a later list adds; its Detail sets any crawler to follow the group or take its own mode, so one crawler can be stopped in a passing group. This replaces `upstream_disabled`: crawlers still listed there pass again, and the log names them.
- (2026-10-12) **The dashboard's day names who crawled and why visitors were stopped.** The crawler table is back under the composition: who the known crawlers were, and whether a search engine was challenged. A new card counts the day's challenge and deny pages by the reason behind each, every reason opening bot hunt on it. It replaces the ten latest detections, which stay live on the realtime tab.
- (2026-10-12) **Model pickers list the newest version first.** The AI settings tab, the ask page and the advisor page order models by family name, with each family's versions newest first (Claude Opus 5.5 before 5 and 4.8, gpt-5.4 before gpt-5.2), whatever order the provider lists them in. The configured model keeps its place in that order and is the one selected.
- (2026-10-11) **The overview names what monitor mode let through.** The "other" segment's breakdown gets a monitor-mode line -- requests judged and let through, which going live would have challenged or denied -- and the line that read "passed while monitoring" now says what it counted all along: requests riding a cookie the over-block breaker handed out.
- (2026-10-10) **The dashboard's day section no longer redraws itself.** Its figures moved by a few per minute, and a page that keeps changing under the operator cannot be read; the realtime tab is the one that moves. The section's header now shows when its figures were computed and a button redraws it on demand, with the same flash on the figures that changed.
- (2026-10-10) **The model picker keeps the list it fetched, sorted.** Fetching the models from the saved provider used to fill the picker for that visit only; the list is now kept beside the daemon's state and the picker opens on it, with when it was fetched, until the provider or endpoint changes. Every provider's list is sorted by ID. The AI tab's text now covers the Ask page too.
- (2026-10-10) **Realtime becomes a dashboard tab and reads thirty minutes.** The "right now" strip, the map of sources and the recent detections leave the day's view for a Realtime tab beside it, as analytics products keep them apart. Each tile shows the last thirty minutes with a bar a minute and the last minute under it; the map draws the same. The day's tab keeps a line of now, linking there.
- (2026-10-10) **The ask page keeps its history on a tab of its own.** The page opens on the question box alone and stacks this visit's answers under it; the history tab lists every turn newest first and dated, long answers folded to their first lines with a button for the rest, each with icons to copy question and answer or delete the turn, and the clear of everything.
- (2026-10-10) **The dashboard's 24-hour section is one card that redraws every minute.** The composition bar leads it; the request total from the access log joins the row of tiles under it (challenges served, passed by PoW, passed by CAPTCHA, abandoned, current bans). The AI / crawler table moved to the stats page, which has long had it with a range selector, and the hourly chart is left to that page too.
- (2026-10-09) **The ban candidates page answers at once; the list is computed behind it.** Extracting candidates is a pass over every event in the window, and the page waited for it -- on a large install past the server's write timeout, so it showed nothing. A request now shows the list it has with its age, or says the list is being computed and reloads when it lands.
- (2026-10-09) **The bot hunt's rankings each get their own budget, and the pages of one session share them.** The four ranking tables ran on one shared ten-second budget, so a slow table starved those after it, and every page of a paging session computed all four again. Each table now has its own budget under a page cap, and a page carrying page 1's freeze id reuses page 1's tables.
- (2026-10-09) **Every field with a character limit says so as you type.** A field that had a limit used to stop taking characters at it without a word, or the save cut the value afterwards. Now the field turns red the moment the text runs past the limit, the limit is written under it, and the save waits until it fits: a honeypot rule's title, an ASN rule's label, the branding site name and footer, a snapshot's name, the labels and reason in the bot hunt dialogs, a community comment, and the community hub name, which also says its rule instead of being cleared.
- (2026-10-09) **A honeypot ban's reason names the rule and the whole URL.** The ban list said only which host and path tripped a trap. The reason now leads with the honeypot rule that fired -- a preset's name, or a custom row's title -- and writes the URL with its scheme, so a SQL-injection probe reads differently from a WordPress scan without opening the honeypot tab. A custom rule's title is held to 80 characters: the field turns red as it runs past the limit, says why, and the save waits. On a module install the scheme reaches the daemon with the next render and reload.
- (2026-10-07) **The admin asks in its own dialog, and names what an action is about.** Confirmations, prompts and error messages open in the admin's modal instead of the browser's box. Deleting a site's settings names the site in the question and in the message after it; unbanning, user changes, audit restores and community deletes name their target too.
- (2026-10-07) **The over-block alarm watches for visitors stuck at the challenge.** It divided challenges by addresses, so one scanner using a browser's user-agent could set it off. It now counts addresses challenged again on the same page right after passing, or failing verification: at least three, and half of those that ran the challenge, in ten minutes or an hour (`min_stuck_ips` and `stuck_percent`, replacing `min_serves` and `max_serves_per_ip`).
- (2026-10-07) **The over-block alert mail says what is happening and where to look.** It was one line of internal terms and a ratio. It now states, in a text part and an HTML part, how many visitors could not get through and why, names the addresses, says that protection is unchanged, and lists what to check, with a link to the bot hunt when the admin's address is known.
- (2026-10-07) **Presets say how their patterns match, and when a release changed them.** Each preset's patterns now carry the same chip as a custom row (regex for every shipped preset), and an "updated" label joins "since" where a later release changed the preset. The SQL-injection preset reads since v0.1.0, updated v0.1.50; the upgrade review still holds its new default from v0.1.50.
- (2026-10-07) **The schema update notice says what a stop costs, and can stop a run from a shell.** Like the compaction card, the notice shows the update's steps, the held events against their limit, and what stopping it loses: nothing during the index build, only the statistics at the end. A superadmin can now stop an `unmask migrate` typed into a shell, and both buttons ask in a modal.
- (2026-10-07) **The compaction card shows each step and the time it has left.** One figure covered the copy and its write-back, so a half-done copy read as a quarter. Each step now has its own percentage and the time left at its speed so far; the held events show their limit, and the plan says "about" rather than "up to", with how long the daemon can hold them.
- (2026-10-07) **A superadmin can stop a compaction started from a shell.** The card offered to stop only a run its daemon had started. It now stops an `unmask db-vacuum` typed into a shell too, after checking the process, and says what stopping costs: nothing while VACUUM runs, only the statistics at the last step. The final write into the file cannot be stopped.
### Removed
- (2026-10-11) **Pass-through mode.** Two switches on the operating-mode tab both meant "stop nobody". This one handed out a pass cookie, so the measurement it was turned on to collect decayed and the cookies outlived the mode, and it never stopped a deny or a ban. Monitor mode is the one switch now; a leftover `global.passthrough` in config.yml is ignored with a warning, and the over-block breaker keeps its own `auto_passthrough`.
### Fixed
- (2026-10-16) **The Community Bans impact figure settles on a large database.** The "past 30 days" count on the Community Bans tab could stay at "computing…" where its scan outlasted the budget, with every poll starting the scan over. A scan that does not finish is now tried again an hour later, and the page says so. The figure counts the requests the feed sent to a challenge; before, it read zero.
- (2026-10-15) **The live map stays light on a busy site.** With traffic from a hundred and more countries, the realtime map's animation could stall the admin page. The map is now painted once per reading, and only the particles move, on a layer of their own drawn without canvas shadows; the animation also pauses while the map is scrolled out of view.
- (2026-10-15) **Ask AI works with OpenAI's newest models.** Asked on an OpenAI model such as gpt-6.1-sol, the page failed at once: the model takes tool calls together with a reasoning effort only on OpenAI's Responses API, and the page spoke the older chat API. Questions to OpenAI's own endpoint now go over the Responses API, with nothing stored on OpenAI's side; a self-hosted OpenAI-compatible server is spoken to as before.
- (2026-10-14) **Path exemptions and protected paths are decided on the path nginx serves.** Bypass, country- and network-exempt and protected-path rules matched the raw request line, so an encoded form such as /static/%2e%2e/page met the /static/ bypass yet was served as /page, past the challenge. They now match the decoded, normalized path without its query string; honeypot and custom-rule paths still see the raw request. Native installs need an nginx reload.
- (2026-10-14) **Admin pages answer within their time limits on a large database.** The SQLite driver let a query run to its end whatever its deadline, so a page waited for its slowest card while that query held a connection: on a database larger than memory the dashboard did not answer for minutes. Deadlines now stop the query, and the reasons card gives up early on its first day.
- (2026-10-14) **Doctor no longer counts the Lua module's LuaJIT notice as an nginx error.** nginx built with the Lua module against a LuaJIT other than OpenResty's writes an [alert] at every configuration load, so doctor always warned about its error log there. That line is now left out and named in the OK, and a real fatal line is quoted whole enough to read.
- (2026-10-13) **The realtime page's help opens on hover.** The question marks beside the realtime tiles opened their help only when clicked: each tile's label cut off whatever reached past it, the help included, so hovering showed nothing. The label now shortens only its own text, and hovering opens the help as it does on the other pages.
- (2026-10-13) **A challenge is counted only where one was served.** The access log carried the challenge decision for every request, so a site that protects its pages but not its images counted each image fetched without a pass cookie as challenged: in the request breakdown, by country and in the non-human share. Those requests now count as let through unjudged. Native installs need an nginx reload.
- (2026-10-13) **The stats page's country charts say what they count.** The one in the challenge-serves card was headed "period total", which read as a count of addresses and made a country that sent many requests but met few challenges look like it slipped through; it now reads "challenges served".
- (2026-10-13) **The Linode provider no longer takes in Apple's iCloud Private Relay.** The ASN catalog found Linode by a name the ASN databases also give the networks the relay leaves from, so turning it on, or blocking every data center, challenged or denied the iPhones and Macs behind the relay. It now finds Linode by its AS number, and the advisor's hosting check reads the same.
- (2026-10-13) **A GeoIP update reaches nginx's country and ASN rules.** The monthly snapshot replaced the address data the daemon reads, but the ranges written into nginx's configuration for country and ASN rules stayed the previous snapshot's until something else rendered it. The update now renders, and the admin pages say when nginx needs a reload to take it.
- (2026-10-13) **The stats page names every reason a CAPTCHA was forced.** Its forced-CAPTCHA card counted shared ban list hits, UA filter patterns and custom rules as "unknown", and explained the pass-cookie reuse cap's row with the unknown row's text. Each now has its own row and explanation. Hours counted before the update keep those reasons under "unknown".
- (2026-10-13) **A custom rule's deny is recorded as the rule's.** A request one of your custom rules denied was recorded as "axis_deny", so bot hunt and the dashboard's reasons card could not tell it from other denies. It is now recorded as custom_rule, as a rule's challenge is, with the rule's id.
- (2026-10-12) **A custom rule's challenge is recorded as the rule's.** The challenge page recorded no reason for a visitor one of your custom rules sent there, so bot hunt and the dashboard filed the rule's work under the site's default posture. It now records custom_rule, and bot hunt's reason filter offers it, along with four other reasons it was missing.
- (2026-10-12) **JavaScript errors from Brave on iOS fold with foreign-script noise.** Brave's built-in wallet sets window.ethereum fields after every page load and throws where a page has none. WebKit reports that injected code against the page's own address at line 1, so the dashboard listed it as a challenge failure, from a browser it called Safari. Rows already stored move too, and the browser now reads Brave.
- (2026-10-11) **Monitor mode works on an nginx-module install.** It looped: the daemon answered the challenge route with a cookie-less redirect back to the page, and nginx, judging by the cookie alone, sent first-time visitors back again. The mode is now rendered into nginx: a watched site passes the gate, the rate zones and the deny and ban rewrites, while the judgement is logged and counted. Takes effect at render-nginx + reload.
- (2026-10-11) **The over-block breaker sends visitors back to the page they asked for.** Its pass-through read the original URL under a query name nothing sends, so a visitor it let through landed on the front page instead of the page they had requested. It now reads the `_orig` every dispatcher carries.
- (2026-10-11) **The hunt keeps its filters when the period changes.** The one-hour, six-hour and 24-hour links, and the custom period, now carry every active filter -- address, fingerprint, user agent, Ref ID, phase, reason, network and rows per page -- so a narrowed log stays narrowed while the operator looks at another period. The clear link still clears them.
- (2026-10-10) **The hunt's network (ASN) ranking lists thirty rows like the other rankings.** It stopped at twenty, which read as a different page from the addresses, fingerprints and user agents beside it; the per-address resolution that once made it the heavier query is cached, so the same length costs little.
- (2026-10-10) **The map's position dialog showed the source's name percent-encoded.** The line saying what the automatic position was worked out from read %e5%85%b1... instead of the words: the attribute carrying the text was named like a URL, and the template engine encoded it as one. Renamed, and a test now refuses such attribute names across every page.
- (2026-10-10) **A long answer on the ask page was lost on the way.** The server closes a response after a minute, sized for the dashboard, and an answer that took longer (the model's rounds through the tools do) was cut before it was sent: stored, but the page said the question had failed. The send now keeps its connection for the question's whole budget.
- (2026-10-10) **The bans table's reason column gives way before the table scrolls.** The reason was capped at a fixed width, so on a laptop-sized window the table scrolled sideways inside its card while the reason could have been shorter. The reason now takes the width the other columns leave, down to a floor, and the popover carries the rest; a wide window shows the whole URL.
- (2026-10-09) **The built-in icon is 192 px, not the 1.3 MB source artwork.** The icon the admin pages, the favicon and the challenge page's credit line load was the 1254 px original, so a page showing it at 14 px downloaded 1.3 MB. It is now the same mark at 192 px, 33 kB.
- (2026-10-09) **The bot hunt's JA4 ranking reads the window, not its whole index.** For a one-hour window the ranking walked the entire JA4 index -- on a large install hundreds of megabytes, a minute cold -- because the planner preferred the index's ready-made grouping over the date range, with or without statistics. It is now pinned to the date index like the IP and network rankings.
- (2026-10-09) **The stats page's hour-range scans seek the date index without planner statistics.** The funnel and verdict cards' raw scans (ranges under a day, and a day until the hourly rollup is ready) walked the whole verdict index on a database that never ran ANALYZE, which past a few hundred megabytes is most of them. They now seek the date index unless a site or host narrows them.
- (2026-10-09) **A funnel that could not be read no longer takes the stats page down.** The page answered 500 when the funnel query failed, while every other card degrades to "could not load". On a large install the funnel's raw day-long scan cannot finish in its budget after a restart or on a filtered view, so the page was gone when wanted. It renders with the funnel named in the banner.
- (2026-10-09) **Rule labels are counted in characters, and the ASN tab says its limit.** A label typed in Japanese on the bot hunt, or a settings snapshot's name, was cut at a third of its stated length, sometimes in the middle of a character. On the ASN tab a label or an organisation name was shortened to 80 characters without a word, which could change what an organisation rule matched. Both now count characters; the ASN tab turns the field red at 200 as you type and the save waits. The password hint said 72 characters; the limit has been 1,024 bytes since argon2id.
- (2026-10-07) **The bot hunt shows an abandon's details in one place.** Hovering an abandon row opened the browser's tooltip on top of the row's popover, two boxes over each other. The tooltip is gone; where the visitor left, how long after the page appeared and what followed now sit as a footnote in the popover, as on a collapsed session's timeline.
- (2026-10-07) **The AI advisor's switch reads in the admin's language again.** Its label and description sat in the other language's table, so the Japanese admin showed them in English and the English admin in Japanese. The Web Bot Auth key-cache label is translated too, and a test now fails on any pair of texts swapped between the two languages.
- (2026-10-07) **The upgrade review no longer holds a SQL-injection preset the operator had turned on.** v0.1.50 turned the preset on by default and holds that change on installs that review upgrades, but it also held it on installs whose operator had enabled it while it was opt-in, switching their protection off until the review. Their go-ahead now counts.
- (2026-10-07) **A compaction whose final checkpoint ran long is no longer recorded as failed.** After VACUUM, the run copies the database out of the write-ahead log for at most ten minutes; on a large file on a slow disk, the limit stopped the checkpoint and recorded the committed compaction as failed. The limit now grows with the compaction's own time, and running out leaves the rest to the daemon.
## [0.1.50] - 2026-10-07
### Added
- (2026-10-06) **doctor flags rule patterns stored with their marker twice.** A pattern saved as `contains:contains:Bot` -- the marker typed into the box as well as picked -- matches nothing it was written for while reading correctly at a glance. The form has stored a single marker since 0.1.49, but a list not saved since keeps such rows. doctor names them, and title columns whose length differs from their list's.
### Changed
- (2026-10-06) **The schema update notice says what the run is doing.** Besides the time taken, the admin notice names the migration being built and how far through the list the run is, or that the index is built and what follows; how many events the daemon holds in memory, against how many it can; and when the run has gone past its estimate. The terminal prints the same stages.
- (2026-10-06) **Index build estimates allow for a fragmented database file.** Where a quarter of a SQLite file is free pages -- doctor's cue for a compaction -- its tables lie scattered and a build reads them a page at a time. The built-in estimate now grows with the file's size over its live data, up to fourfold; `migrate -status` and the upgrade notice point to `unmask db-vacuum -plan`.
- (2026-10-06) **A compaction builds the query statistics a database lacks.** On a large SQLite database nothing built them -- `unmask migrate` only does on a small one, and `db-analyze` holds the write lock while the daemon writes -- so the stats and hunt pages read whole indexes. `unmask db-vacuum` now builds them after compacting, while the daemon's writes are still held; the plan and the estimate count them in.
- (2026-10-06) **The SQL-injection honeypot preset is on by default.** Its patterns need SQL syntax, not SQL words, so ordinary searches pass; a probe bans its sender's address and JA4 like any honeypot trip, and a person who trips it by accident can still clear the challenge. An install that reviews new enforcement holds it until reviewed; a site whose search echoes SQL itself can switch it off.
### Fixed
- (2026-10-06) **A solved CAPTCHA gets its holder past the reuse cap.** The cap counted every pass, the one its own CAPTCHA gave included, so the next request from an address over the cap met the CAPTCHA again: behind an office proxy or a carrier NAT over the budget, nobody could get through. A solved CAPTCHA is no longer counted; a proof-of-work reused past the budget is still stopped.
- (2026-10-06) **The SQL-injection honeypot preset now catches encoded probes.** nginx matches honeypot patterns against the request URI as sent, where a space arrives as %20 or + and a quote or a bracket often as %27 or %28; the patterns expected literal spaces, so UNION SELECT, ' OR '1'='1, WAITFOR DELAY and DROP TABLE probes went through.
- (2026-10-06) **`unmask migrate` ends when its indexes are built.** After the last index it went on filling in a column that every event has been written with for a long time, walking the events table out of order with the daemon's writes still held, silent and deaf to an interrupt: on a large, fragmented database, for many minutes after the index was done. That pass is gone.
- (2026-10-06) **An interrupt stops `unmask migrate` at any point, and a finished run is recorded as finished.** The work after the builds stops at the first Ctrl-C, not the second. The end of the run gets a deadline of its own; a long run was left on record as running, which the admin notice would later have called an interrupted update. Records left that way are no longer read so.
- (2026-10-06) **A rule added from the hunt page keeps its title on its own row.** The hunt page appended the new row's title, on/off flag and chain to the list's columns as they stood, so where a column was shorter or longer than the list -- rows added or removed by hand in config.yml -- they landed on another row. The columns are now fitted to the rows first.
- (2026-10-07) **The About tab shows a testing build as one.** An install on 0.1.50-rc1 showed 0.1.50, and the version check took the rc for its final release, so once 0.1.50 shipped it would have said up to date. The rc tag now shows, and an rc sorts below its release.
## [0.1.49] - 2026-10-04
### Added
- (2026-10-01) **Old browser versions stand out in the hunt log and the stats page.** A UA cell shows the version a browser claims, but whether Chrome 109 is last month's release or one from years ago was left to the reader. A Chrome, Edge or Firefox ten or more releases behind the current one now has its name highlighted, with the count in a small badge on its corner, and Internet Explorer says EOL. A reading aid: no decision uses it.
- (2026-10-01) **A cap on reusing one pass.** A visitor who passed the challenge was counted by no rate limit while its cookie lived, so a scraper running a real browser could solve one proof-of-work and fetch without limit on every node. The cap counts only requests carrying a valid pass, per address: by default 10,000 a day, 2,000 at once. Over it, every request needs a CAPTCHA, or is denied.
- (2026-09-30) **A bypass preset for Git over HTTP.** Behind unmask, `git clone`, fetch, push and Git LFS against a Git forge failed, because the git client cannot solve a challenge. The new preset under Bypass paths, off by default, lets those requests through by their Git endpoints, while the forge's web pages stay behind the challenge.
- (2026-09-30) **The crawler IP range and browser version downloads can be switched off.** A checkbox on the About tab (`sync_disabled`) stops the two daily pulls from unmask.sh, so every call unmask makes on its own now has a switch. The data the release ships stays in use, and a newer release's snapshot now replaces an older downloaded copy.
- (2026-09-30) **The database can be compacted without stopping the daemon.** `unmask db-vacuum`, or the button on the retention tab, runs SQLite's VACUUM while the challenge keeps working: the daemon holds its writes in memory and writes them afterwards. It shows beforehand the space it gives back, the disk it needs, how long it takes and the events it holds.
- (2026-09-29) **A long index build no longer holds up the daemon's start.** A schema update that builds an index over a large events table ran before the daemon listened, so an upgrade could leave it away for minutes unannounced. The daemon now estimates the build and, past 20 seconds, starts without it; the admin pages carry a notice with the estimate and a button for a superadmin. `db.schema_update_defer_seconds` moves the threshold.
- (2026-09-29) **The challenge keeps working while a schema update runs.** The update runs as a process of its own, from `unmask migrate` or the admin UI's button. On SQLite, where the build holds the write lock, events and automatic bans are kept and written when it ends, the bans enforced meanwhile; settings can still be saved, while changes to users and manual bans wait. A superadmin can cancel a run.
- (2026-09-29) **A waiting schema update is announced everywhere an operator looks.** `unmask migrate -status` lists what is pending with an estimate, `unmask doctor` gains a schema line, and `/metrics` carries `unmask_schema_update_pending` and `unmask_schema_update_running`. A package upgrade that leaves an update waiting says so as the last thing it prints, with the command to run. The container applies what is quick at start and leaves the rest the same way.
- (2026-09-29) **Bot hunt shows where a session came from even when the view is filtered.** The referer is recorded on the serve, so a log filtered to the passes showed "-" for every session. Clicking the phase now loads the session's referer, and the date popover then shows the same value; until then a row shows its own, as before. Silent rebinds record their own referer.
- (2026-09-30) **Bluesky link cards pass out of the box.** Bluesky builds a posted link's card with its own fetcher, Cardyb, which the crawler list did not carry, so the fetch got the challenge page and the post showed no card. It joins the supplement next to Mattermost, Chatwork, Webex and Notion, and can be switched off per pattern like any other.
- (2026-09-29) **Mattermost link previews pass out of the box.** Mattermost fetches a pasted link's preview from its own server as `Mattermost-Bot`, which the crawler list did not carry, so the fetch was challenged and the link rendered bare. It joins the supplement next to Chatwork, Webex and Notion, and can be switched off per pattern like any other.
### Changed
- (2026-10-03) **The settings menu stays in view and scrolls on its own.** The menu beside the settings pages is taller than most screens and moved with the page, so its upper entries were out of reach from the lower part of a long tab. It now keeps its place with a scroll of its own, holds its position from tab to tab, and shows the current tab's entry on arrival.
- (2026-10-02) **A request stopped for its rate is answered 429 Too Many Requests.** The CAPTCHA and the deny page of a rate-limit zone, the pass reuse cap and an ASN or country rate rule now carry 429 with Retry-After instead of 403, so monitoring that counted them as 403 sees 429. Every other challenge stays 403: a page to pass, which clients do not retry on their own.
- (2026-10-01) **forward-auth mode is frozen.** The docs and the admin now describe two ways to deploy: the nginx module, on your own nginx or inside the gateway container. Installs that use forward-auth keep working and still get bug and security fixes, but new features are built for the nginx module only. The FAQ says how to move over.
### Fixed
- (2026-10-04) **The 30-day cards load on a large install.** A card reading the hourly rollup asks for one kind of row, but the table was keyed by the hour, so each 30-day card walked every kind; on a large database on a slow disk three ran past their deadline. An index by kind confines each read to its own rows. On a large rollup its build waits for the operator.
- (2026-10-03) **Japanese and Korean on the challenge page no longer break inside a word.** Left to the browser's default, the first-visit message could wrap with a Japanese word cut in two across the lines. Both languages now wrap only at spaces, after punctuation and at phrase marks placed in the longer Japanese sentences, on the proof-of-work page and the CAPTCHA card alike.
- (2026-10-02) **A bind of `::` listens.** The settings page took `::`, and the daemon joined it to the port as `:::9477` and failed at its next restart. An IPv6 bind now works with or without brackets, in the listener, the rendered nginx upstream, the CLI and doctor. The form takes IP addresses only, and the post-install note no longer suggests a bind that carries the port.
- (2026-10-01) **A marker typed into a pattern box is no longer stored twice.** The settings form adds the mode marker (`exact:`, `contains:`) when it saves, so one also typed into the box was kept twice and the rule then matched only text beginning with the marker: an allowlist row rescued nothing. Saving now keeps one marker on every pattern list, and re-saving a tab repairs rows stored that way.
- (2026-10-01) **The maintenance commands find a daemon that listens on a unix socket.** With `bind: unix:/path` -- or a `socket_mode` left in the config after a switch back to TCP -- `unmask db-prune` took the running daemon for stopped and worked beside it, and `unmask db-vacuum` began compacting without waiting for the daemon to hold its writes. Both now look where the daemon listens.
- (2026-10-01) **nginx reaches the daemon with SELinux enforcing.** With SELinux enforcing on a RHEL-family host, nginx was refused every access-log line it sent to the daemon, whatever the label of the socket file: the dashboard's traffic, crawler and country figures stayed empty, honeypot bans from the access log never fired, and a unix-socket bind answered 502. The daemon now labels its sockets for the web server; the policy is left alone.
- (2026-10-01) **A restart of the daemon no longer cuts nginx's access-log connection.** nginx's workers stay connected to the log socket, so a daemon that bound a new one on every start lost each worker's first line after a restart, and every line logged while it was down. Where systemd is 236 or later the socket now outlives the restart: what nginx logs meanwhile waits in it, and the next start reads on.
- (2026-10-01) **Saving the rate-limit tab returns to the rate-limit tab.** The save named the tab `rate_limit`, while its address is `rate-limit`, so the redirect after a save landed on the settings overview, away from the rows just edited. Every settings form now comes back to its own tab, and a test checks each of them.
- (2026-10-01) **Web Bot Auth and Privacy Pass are no longer described as forward-auth only.** Their settings said the check ran only in forward-auth mode, but the nginx module sends a request that carries a signature or a token, and is about to be challenged, to the daemon for the check. Both work with the module on your own nginx and in the gateway container.
- (2026-09-30) **`db-prune -vacuum` asks for the disk it really needs.** It checked for room for one copy of the live data, but in WAL mode the copy is written a second time into the write-ahead log. It now asks for 2.2 times the live data, and with the daemon running it refuses and points to `unmask db-vacuum`, which compacts without stopping it.
- (2026-09-30) **Community Bans enforces only the entries the hub has promoted.** The hub lists every report, promoted or not, and marked the feed as version 1, which the daemon read as "enforce every entry": a single unpromoted report reached every subscriber. The hub now says version 2, and the daemon enforces the promoted entries whatever the version.
- (2026-09-30) **The admin's help describes what unmask does.** Several texts had fallen behind: the shared list's default action (pow_then_captcha, not captcha_only), what turning off the country code or the liveness count leaves behind, when the ban candidates are re-sent to the model, and that a JA4 block verdict alone challenges only under forward-auth.
- (2026-09-30) **The setup token's location is shown correctly.** The wizard's token step told operators to read `/etc/unmask/.setup-token`, a path no install has written since 0.1.9, and unmask-web-apache looked there too, so a new install printed no token. The wizard now names the file the daemon actually reads, and the Apache banner uses `/var/lib/unmask/.setup-token` and appears only while setup is pending.
- (2026-09-30) **The Gateway tab describes the one-container gateway.** Its help still spoke of a separate nginx container and a shared volume, and said the upstream could not be changed there. Since 0.1.48 one container holds nginx and the daemon, and the upstream, the hostnames and the certificates are all set on that tab.
- (2026-09-30) **The ban list is written by one writer at a time.** A ban added from the access log and the manager's periodic refresh could write the list's temporary file at the same moment, so nginx could load a list mixing the two. Writes of the list now take turns.
- (2026-09-29) **Events and access-log counts are no longer lost at a stop or a failed write.** The daemon ended as soon as it began to shut down, before the last flush of queued events had run. A flush of the access-log counts whose statement or commit failed dropped everything it carried; they are now kept for the next try.
- (2026-09-29) **Switching the database in the setup wizard keeps the performance settings.** The wizard asks for the connection only, but it replaced the whole database section of `config.yml`, so the memory profile, the cache size and the connection pool set beside it went back to their defaults. Run again on the database already in use, with those set, it no longer takes it for a switch and restarts the daemon.
- (2026-09-29) **unmask-web-nginx prints its setup banner only while setup is pending.** The first-time setup banner came on every upgrade, forty lines after everything the unmask package had printed, and it read the setup token from a path the package stopped writing in 0.1.9, so a new install never saw its token.
- (2026-09-29) **Native mode keeps its events after an upgrade on SELinux hosts without semanage.** Upgrading unmask-web-nginx deleted the drop-in that relabels `/run/unmask` for nginx, so from the daemon's next restart nginx could not write the log socket and the dashboard counted nothing. The drop-in now stays until the package is removed. An upgrade from 0.1.48 or earlier still runs the old removal step once; the daemon now labels its socket itself, so nothing needs doing.
- (2026-09-29) **A package upgrade restarts the daemon once.** The install script started the daemon and then restarted it as a guarantee, and unmask-web-nginx restarted it again on SELinux hosts. The daemon applies pending schema updates as it starts, so a later restart killed a slow one part way. One restart, falling back to a start, now covers every case on systemd, OpenRC and SysVinit.
- (2026-09-29) **The hourly prune no longer stops at the first table that fails.** The aggregate tables are pruned one after another, and an error on one ended the run: the tables listed after it were not trimmed that hour, and not at all if the error kept returning. Every table now gets its turn, the failures are reported together, and the run is recorded for `unmask doctor` to read.
- (2026-09-29) **doctor no longer reports a prune problem right after an upgrade.** `DB aggregate windows` warned that the hourly prune was not trimming a table whenever one held more than its window, which is also how the first hour looks after upgrading from a version that did not prune that table. It now reads the prune's own record and tells a run still to come from a failed one.
- (2026-09-29) **URLs and referers in bot hunt show `&` as written.** The log stored the character in its escaped form and displayed that form unchanged, so a URL with two query parameters, or a referer from a search engine, read `\u0026` where the `&` belonged. Paths and referers are now decoded before display, and the URL popover's open and copy actions act on the real address.
### Security
- (2026-10-02) **The published crawler IP feed is refused without its signature.** The feed is signed on a host that does not serve it, yet a daemon that found no signature fell back to trusting TLS: whoever controlled the serving host could swap the pass-list by deleting one file. Pulls of the published feed now fail without a valid signature; your own `sync_hub_url` and `update-iprange -file` are unchanged.
## [0.1.48] - 2026-09-27
### Changed
- (2026-09-27) **The gateway is one container.** The unmask image now holds nginx (the official image with the unmask module) and the daemon together, supervised by one entrypoint: a `docker run` or a single compose service puts it in front of any HTTP server. The separate `unmask.sh/nginx` image and the `/run/unmask` volume are gone; an existing container install moves by taking the new compose file and setting `nginx.upstream_addr` in its config.yml to `127.0.0.1:9477` (config and data volumes carry over). A daemon restart costs no outage -- nginx lets traffic through while it is away -- and an nginx exit restarts the container.
## [0.1.47] - 2026-09-27
### Changed
- (2026-09-26) **Built with Go 1.27, fixed in one place.** The Go version now lives in go.mod alone, and the packages, the container images, CI and the release all read it. Before, the images were built on a Go 1.25 base, which has since left support, and the packages with whatever Go the build host had. Nothing changes in how unmask is installed or configured.
- (2026-09-26) **The challenge page's logo can be given a height.** Settings > Page design gains "Logo height (px)", also per site. The page showed a logo at the image's own pixel size, so one exported at 2x for sharp screens came out twice as large as meant, and nothing could shrink it. Blank keeps the old behaviour; the deny pages and the live preview follow the same value.
- (2026-09-26) **The container image is `unmask.sh/unmask`, its compose service `unmask`, and its config file `config.yml`.** The image was named `admin`, which reads as an optional management UI; it is the whole daemon, what the `unmask` package installs on a host. To move an existing container install: fetch the new compose file, rename `admin.yml` to `config.yml` in the config volume, and set `nginx.upstream_addr` there to `unmask:9477`.
## [0.1.46] - 2026-09-24
### Changed
- (2026-09-24) **Trying a pre-release on Alpine works on an existing install.** The note the release package writes and its README said `apk add --repository …`, which leaves an installed unmask exactly as it was. Both now say `apk upgrade --repository https://unmask.sh/dl/testing/apk/main`, which moves the companion packages too; upgrading the release package replaces the old note.
- (2026-09-24) **The two feeds the daemon pulls on its own can be pointed elsewhere.** `nginx.sync_hub_url` and `nginx.browser_majors_hub_url` override where the bypass-IP document and the browser baselines come from, for a self-hosted mirror. Unset, nothing changes. The test suites now use them, so CI no longer fetches either document from unmask.sh.
- (2026-09-23) **Upgrading no longer asks for an nginx restart unless the module changed.** The plugin package used to replace the nginx module on every upgrade, even with identical bytes, and a replaced module needs a restart to load. It now leaves an unchanged module in place, and says which case applied.
- (2026-09-23) **Pre-release builds on the testing channel sort before the release they precede.** An rc is packaged as `0.1.46-0.1.rc1` (apk `0.1.46_rc1-r0`), so `dnf --enablerepo=unmask-testing` installs it, a plain update never takes it back to the previous release, and the final `0.1.46-1` replaces it on its own.
- (2026-09-22) **The hub can tell how many installs are running.** Subscribing installs now identify themselves on the hourly feed pull, so the hub records when each one last checked in; before, it saw only registrations and reports. Optional, and covered by privacy v1.1. If you share bans, the settings page asks you to re-accept the terms once, and sharing pauses until you do.
- (2026-09-21) **The challenge page checks for a usable cookie jar before asking for a proof of work.** A browser that cannot store the pass cookie used to solve the puzzle first and be told only afterwards. It now stops at load, and the screen names the in-app-browser case and shows the address to open elsewhere. The cookie-error text now covers all eighteen languages.
- (2026-09-20) **The release script checks the version the landing pages advertise.** Nothing wrote that value, so nothing noticed it standing still while releases went out, and a search engine reads it. Preflight now refuses to start when either landing page disagrees with the version the checkout ships.
## [0.1.45] - 2026-09-19
### Changed
- (2026-09-18) **The advisor says so when a consultation has nothing to re-send.** Asking again when no candidate's evidence has moved calls no model and changed almost nothing on screen, which read as a dead button. The bar now answers in as many words.
- (2026-09-17) **A consultation no longer reads the week for every fingerprint it asks about.** What a fingerprint ban would hit is one number -- the addresses that completed the challenge with it -- and it is now read from the completions alone, through an index on the fingerprint and the phase. The fingerprint's verdict rides along on the candidate instead of costing a second pass.
- (2026-09-16) **doctor warns when the database has outgrown its box, and when a container gateway is still on the published sample's defaults.** A database past half the host's memory can no longer serve a whole-table read from cache. A gateway brought up without an override file proxies to the bundled example app and answers only for localhost, while still passing its health check.
- (2026-09-15) **Advisor: an address in a cloud network that carries several clients is read as a proxy, not a farm.** Passing the challenge from a hosting network scored as automation whatever was behind the address. When several user agents and several fingerprints answer from one address, it is a gateway or a VPN exit with people behind it, and the row is informational instead.
- (2026-09-14) **Advisor: a client the challenge nearly holds is ranked with the ones it holds.** One completion in a thousand no longer counts as getting through. Under one percent of the challenges served, and no more than a hundred, and the row scores and sorts like a contained one instead of above every contained row. The list names which of the three it is.
- (2026-09-14) **Advisor: a consultation takes a fraction of the time.** The candidates go to the model in batches of six, three at a time, and "ask again" re-sends only rows whose evidence stepped -- a first pass, a new signal, a count moved by more than a quarter -- instead of every row whose counts drifted. The bundle carries three user agents per row and asks for shorter reasoning, and the fingerprint candidates' collateral is read in one grouped pass instead of one fingerprint at a time.
## [0.1.44] - 2026-09-13
### Added
- (2026-09-10) **doctor reports the hourly aggregate and the aggregate tables' windows; the stats page says "aggregating" instead of running a 30-day scan it cannot finish.** The window check found a second unpruned table, the country tally, now trimmed too. On a large database with no completed aggregate pass, the 30-day serve cards show "aggregating" at once rather than timing out.
### Changed
- (2026-09-13) **Advisor: an AI pick carries a score and sits where it puts it.** A nominated row had no score and always sat at the end. It is now scored like a candidate: the engine's rules on its evidence (volume, hosting network, scanner paths, a herd) plus the model's priority as a signal weight (high 3, medium 2, low 1), and the list is ordered by that score.
- (2026-09-10) **Advisor: the model no longer nominates actors the challenge already stops.** The pool the model sees is ranked by volume, so contained herds with zero passes kept being picked. The prompt now says a nomination is for an actor that gets through, and the engine drops a nomination whose pool row has no passes and sits under the cost floor; stored picks are held to it too.
- (2026-09-10) **Advisor: a candidate being re-analysed keeps its last answer on screen.** When a click sends a candidate back to the model because its counts or window changed, the row used to drop its previous priority and reasoning and show only the spinner until the new answer arrived. It now shows the spinner above the previous answer, dimmed, and swaps in the replacement when it lands.
### Fixed
- (2026-09-13) **A banned client arriving during a daemon restart got a 503 instead of the block.** The ban page's location was meant to fail closed with a bare 403 while the daemon is down, but nginx keeps the first error_page written for a code, and the proxy template's fail-open hook preceded it, so the request fell through to a 503. The hook now sits only in the locations that fail open.
- (2026-09-13) **Advisor: every row shows its sample paths.** Most rows' "要求パス例" were empty: one shared sample of the newest 400 events over every candidate went to the busiest few, and fingerprint rows and the model's picks were never read at all. Each row now samples its own newest events -- an address through its index, a fingerprint newest first -- and a pick gets its paths on the page.
- (2026-09-13) **Advisor: the model reads the row's evidence in the row's own terms.** The bundle now carries the challenge by chain with every difference taken, the escalation reasons, the user agents and paths with their counts, and the reverse DNS -- for the pool rows too -- and the prompt asks for reasoning that names the deciding numbers and the kind of rule that would target the client.
- (2026-09-13) **Advisor: each row names its kind -- IP or JA4 -- in front of the target.** An address with a flag and a fingerprint hash looked alike until read; a small chip now says which it is, so the row's meaning (a ban on one address, or on every client with that TLS stack) reads at a glance.
- (2026-09-13) **Advisor: the model's pick wears a filled badge, and each signal reads on one line.** The ai_pick badge among the signals was easy to miss in the same pale style as the rest: it is filled purple now. A signal's reading sits beside its badge instead of on a second line.
- (2026-09-13) **Advisor: user agents and paths open the same popover as the hunt log, and paths carry their hits.** A user agent's popover now heads with its summary and pins under it; a path lists its hits, opens its full address with the hits in the heading and offers the hunt log's Open / Copy buttons. The column lists the three most requested paths and how many more there were.
- (2026-09-13) **Advisor: a row lists the client's five most frequent user agents with their counts, under its network.** A row showed one arbitrary user agent -- the alphabetical maximum of the window -- which hid an address rotating them and a fingerprint herd's spread of browsers. They now sit under the origin in the target cell, with how many more there were; the model reads the same list.
- (2026-09-13) **Advisor: the traffic cell reads the challenge by chain and by the rule that escalated the client.** A JA4 herd row read "JS 0 · PoW 0 · CAPTCHA 0" because its query counted only serves and passes; fingerprint candidates carry their stages. Each chain shows presented, passed and not completed (pow_then_captcha per gate), the serves split by escalation reason, and JS how many left without running it.
- (2026-09-12) **Stats: the 30-day serve cards read settled hours from the hourly rollup and scan only the rest.** After a restart the cards ignored the rollup until a full pass had completed, scanning 30 days of raw events and timing out on a large install. The fold sizes its chunks to the host and stops when its budget is up, and each rollup runs under its own budget.
- (2026-09-12) **Advisor: an AI pick row shows its stages and pass kinds, and keeps them current.** A nominated row read "JS 0 · PoW 0 · CAPTCHA 0" with an empty "()": the pool row's stages were not copied and it had no pass-kind breakdown. Both are carried now and refreshed each run, a pick that left the pool is dropped, and each breakdown has its own line.
- (2026-09-11) **Setup wizard: opening it on a large database no longer counts every event row.** The reconfigure summary counted the whole event table with no time limit, which took minutes on a 25-million-row install and timed the page out. The figure is now the id span, every wizard query has a two-second budget, and a database that does not answer is shown as existing with the figure unknown.
- (2026-09-10) **Stats: a failed 30-day card shows dashes and says so, instead of zeros and a "check your nginx include" hint; a browser that left mid-page is no longer reported as failed cards.** The per-minute request tally behind those cards is now pruned to the same 32-day window as every other aggregate; it had been growing without bound.
## [0.1.43] - 2026-09-09
### Changed
- (2026-09-09) **Overview: the KPI row is the challenge's own funnel, counted in requests throughout.** The abandonment tile leads with how many requests loaded a challenge and left, with the share and its denominator underneath, so no tile is a bare percentage. The row also says what it is a breakdown of, since the card above splits all traffic by what it is.
- (2026-09-09) **Overview: the PoW and CAPTCHA pass tiles count the requests the gate admitted.** The headline is the solves plus the requests that returned on the cookie a solve minted, the same unit as "challenge fired" beside it, with both shares on the line below. Without the access-log feed it is the solves alone, and the tile says so.
### Added
- (2026-09-09) **The write-ahead log is watched, trimmed and reported.** A write-ahead log should stay at 64 MB, but long-running readers keep checkpoints from completing and it only grows, leaving a stop minutes of work no init system waits for. The daemon now trims a large log every five minutes as far as readers allow, doctor and the retention tab warn while it stays large, and a stop gets more time.
### Fixed
- (2026-09-09) **`unmask db-prune` no longer needs the size of the result in RAM.** The command kept SQLite's temporary storage in memory, as the daemon does, and a VACUUM or an index build over a large events table is a copy of its result there. It now keeps that storage on disk beside the database, copies without sorting, and refuses a rebuild or VACUUM when the free space looks short.
## [0.1.42] - 2026-09-09
### Changed
- (2026-09-09) **Advisor: a client the challenge already contains ranks under the ones that pass, and needs thousands of requests to reach the default view.** A contained candidate (no passes) scores 3 whatever its signals and 6 only past the cost floor (3,000 challenges served or 10,000 requests in the window), where the digest picks it up too. With no model configured, the page says where to switch one on.
### Fixed
- (2026-09-09) **The CAPTCHA page's "I'm not a robot" box no longer greets a returning visitor already ticked.** Press Back onto the challenge and the browser restores the tick, but a restored tick fires no change event -- the box sat there checked and the page did nothing until the visitor unticked and reticked it. The box now opts out of form-state restoration and is cleared before the handler is wired.
- (2026-09-08) **Deleting a site's settings on the theme or challenge tab returns to the Default scope, and the picker says where a remaining record lives.** The delete used to return to the deleted host's own scope, which the picker lists, so the host looked stuck. The theme and challenge records are independent: each tab deletes only its own, and a host kept by the other tab says so in the picker.
## [0.1.41] - 2026-09-08
### Added
- (2026-09-08) **`unmask db-prune`: clear an events backlog at once.** With the daemon stopped it removes every row past the retention window; `-mode rebuild` copies the rows to keep into a fresh table and drops the rest (minutes, not the hours of row-by-row deletes), and `-vacuum` / `-analyze` finish the job. It refuses to run while the daemon answers on its socket; `-force` prunes online at the daemon's own pace.
### Changed
- (2026-09-08) **The events prune paces itself and runs hourly.** Rows go oldest-first in chunks that grow while deletes commit quickly and halve when one runs long, with a pause between chunks at least as long as the lock was held, so inserts and the hourly aggregate get through. A busy chunk is retried, a run that hits its budget continues an hour later, and the WAL file is capped at 64 MB.
### Fixed
- (2026-09-08) **A locked database no longer sends a live install to the setup wizard.** Every admin page, the login page included, redirected to `/admin/setup/` while a prune chunk held the write lock, because a failed user-count query read as "no admin yet". Once an admin has been seen the gate passes the request on; before that, a database that cannot answer is a 503 with Retry-After -- never the wizard.
- (2026-09-08) **A beacon whose insert hit the busy timeout is no longer lost.** The challenge page's beacon wrote its event directly so the next request could find it; when the write lock was taken for longer than the timeout the row was dropped with a log line. It now goes to the batch writer instead, which retains and retries.
- (2026-09-08) **doctor and the retention tab say when the prune is not keeping up.** Both read the oldest event against the window and the prune's own last-run record: an oldest row more than two days past the window, or a prune that has not completed for two days or ended with an error, is a WARN that names the numbers and the way out (`unmask db-prune`).
- (2026-09-08) **Branding / challenge tab: the per-site "reset to default" button no longer fails with a csrf mismatch.** The branding form is multipart (it carries the logo), so the CSRF shim put the token in the form's action query -- but this button posts to its own `formaction`, which the shim left undecorated, so every click was refused. The shim now decorates that url too, and the server reads a multipart body's token as a last resort.
- (2026-09-08) **A pattern saved as "contains" or "exact" now means the same thing on every wire.** The rendered nginx maps resolved the marker, but the forward-auth matchers, the serve-side protected-path lookup, the honeypot action resolver and the HTTPS-redirect exemptions compiled the stored text verbatim -- a `contains:/feed/` bypass path matched nothing in forward-auth mode. Every wire resolves the marker first now, and the save-time checks accept a literal with a parenthesis.
- (2026-09-08) **Settings: the IP, ASN, country and load-balancer lists no longer carry the pattern-mode chip, and every pattern list shows the mode in its rows.** The chip belongs to regex fields; on lists of addresses and codes a new bypass-IP row saved as `exact:10.2.201.0/24` and was refused. Where the mode is a real choice, the confirmed row now shows the badge the chip shows, so a list reads without opening each row.
- (2026-09-08) **Settings > Sites: a new site no longer saves as `contains`.** The pattern-mode chip was drawn on the plain value lists too, and a blank row starts in "contains" mode, so `contains:` rode into the hostname and the site normaliser, reading host:port, kept the word before the colon. The chip now renders only where a mode set is named, and a site value with a marker or a scheme is refused by name.
## [0.1.40] - 2026-09-08
### Security
- (2026-09-06) **Web Bot Auth: a signature must cover `@authority`.** A signed request whose covered components leave out the host it was sent to would verify on every other site for as long as it stayed fresh -- the bot's identity, replayed. The verifier now rejects such a signature up front; every issuer seen so far covers `@authority`, as the architecture draft requires.
- (2026-09-06) **Admin: the pages before login carry the same protective headers as the pages after it.** Login, setup and password reset now send `X-Frame-Options: DENY`, a Content-Security-Policy that forbids framing, `<base>` retargeting, plugins and cross-origin form targets, `nosniff` and `Referrer-Policy: same-origin`; the authenticated pages gain the same policy additions.
### Added
- (2026-09-06) **AI advisor: what the last 30 days cost, in tokens.** Under the last-run line the page now adds up every model call of the past 30 days -- requests, input and output tokens, failures -- across every window and model, so the number can be held against the provider's bill. No prices: the invoice is the truth.
- (2026-09-05) **AI advisor: one automatic retry on a transient provider failure.** A timeout, a 429 or a 5xx from the model endpoint is retried once after a short pause, inside the same run; a bad key, an unknown model or a refusal is not. Nothing changes on the page except that fewer clicks end in a failure line.
- (2026-09-05) **`unmask doctor` reads nginx's error log.** The tail of the log is scanned for a variable read before it is set -- the warning behind the health-check noise 0.1.39 shipped with -- and for [emerg] / [crit] / [alert] lines. When the log is not readable by the daemon user, the check says which command to run instead of staying quiet.
### Fixed
- (2026-09-06) **Bot hunt: the ranking cards no longer scroll; each shows its top 10 and a button reveals the rest.** The 18rem scroll boxes cost every card the width of a scrollbar, and the content-sized ASN card was the one squeezed. A card now lists ten rows, "show all" reveals the others and the choice is remembered in the browser; the UA card yields a little width to the ASN card.
- (2026-09-06) **Settings > Sites refuses two hostnames that would share one nginx variable.** The rendered config folds `.`, `-` and `:` into `_`, so `shop.example.com` and `shop-example.com` would define the same variable twice and nginx would refuse the whole configuration. The form now names both hosts and asks for a rename instead.
- (2026-09-05) **AI advisor: a failed request no longer wipes the last answer, and is reported once.** When the model call failed (a timeout, an overloaded endpoint) the failure replaced the stored answer: every review vanished from the rows and an error banner greeted each reload, next to the same message in the bar. The answer now stays, the failure is one dated line in the bar with the reason, the next success clears it, and the model call may take up to five minutes instead of ninety seconds.
- (2026-09-05) **Native / gateway: no more `using uninitialized "unmask_failopen"` warning on every health check.** The fail-open variables were initialised after the HTTPS-redirect exemptions, and an exemption is a rewrite-phase `break` that stops the rest of the server's rewrite directives. A load-balancer probe that went on to a protected location then read them uninitialised and nginx logged a warning per probe. The initialisation now comes first. Behaviour is unchanged; nginx reload after re-render.
## [0.1.39] - 2026-09-04
### Added
- (2026-09-03) **Advisor: a scheduled pass that tells you about new candidates.** Settings > AI advisor can run the candidate extraction on a timer and send what is new since the last run through the alert channels already configured under Settings > notifications. A scanner that arrives overnight is on record by morning instead of waiting for someone to open the page. Off by default, and the scheduled pass never calls a model -- it should not spend your tokens while nobody is watching.
- (2026-09-03) **Advisor: let a model prioritise the candidates and explain them (optional).** Settings > AI advisor connects the advisor page to Anthropic, any OpenAI-compatible endpoint, or a local Ollama with your own key. Off by default; while it is off nothing leaves the host. The model sees the candidate rows and the top of the window's rankings (with origin network, country and reverse DNS) -- never raw logs, cookies or payloads -- and may propose extra candidates from that pool, but it can only name actors that were actually observed: anything else is discarded. The page never calls the model by itself: an "Ask the model" button does, and the answer stays on the page for that window, with its age, until you ask again. Every row says whether the client is already contained by the challenge or is passing it, because only the latter needs you. Applying a ban stays a human click.
- (2026-09-03) **Advisor: mechanical ban candidates with the evidence attached.** A new admin tab (/admin/advisor/) extracts ban candidates from the recent event window -- addresses that hammer challenges without ever running the JavaScript, scanner-path rakers, hosting networks wearing a browser user agent, and single fingerprints fanning out across many addresses. Every row shows its evidence, with the same address popover, user-agent popover and BAN dialog as bot hunt, and a magnifier that opens the target's raw events; a dismissal is remembered. Nothing is ever applied automatically.
- (2026-09-03) **`unmask mcp` — plug your own AI assistant into the install's data.** A read-only Model Context Protocol server over stdio: six tools covering stats, the hunt event log, per-IP lookup (rDNS / GeoIP / ASN / bans), the BAN list, doctor and a redacted settings summary. unmask itself never calls an LLM — the operator's assistant connects in (remotely via plain ssh), so the no-outbound-calls posture is unchanged. No mutations: the assistant reads and proposes; changes stay with the human.
### Fixed
- (2026-09-04) **Gateway: a pasted certificate's id is checked before its files are touched.** The id names the stored pair (`gateway-<id>.crt` / `.key`); the form handler read and wrote those files before the validation that rejects a separator in the id ran. Reachable only by a signed-in superadmin, and the daemon writes as its own unprivileged user, so the reach was the daemon's own directories. The check now runs first, and CodeQL's four path-injection findings on the file close with it.
- (2026-09-03) **The forward-auth snippet no longer mis-describes its daemon-down default.** A comment in the shipped `/etc/unmask/forward-auth/server.inc` still said the default was a 503 -- the actual default has long been fail-open (a redirect to `/`, flowing through to the backend), as the FAQ and the install page state. The comment now matches, with 503 shown as the fail-closed override. The README also gains the release-signing key fingerprints for out-of-band verification.
## [0.1.38] - 2026-09-01
### Added
- (2026-08-31) **Gateway: nothing to edit in the compose file.** The first start serves a self-signed certificate the admin generates, so the wizard is reachable over https right away; the upstream (where passing requests go), the trusted proxies, the hostnames and the real certificate are then all set under Settings > Gateway. The environment variables remain as first-boot seeds, and `UNMASK_UPSTREAM` on the nginx service still works as a fallback.
- (2026-09-01) **Gateway tab: what it listens on, and one list of trusted proxies.** Listen on http and/or https -- https unticked is a load balancer in front terminating TLS (no :443, no certificate), http unticked is https only. The proxies whose X-Forwarded-For names the visitor are the Network tab's trusted LB / CDN set, presets included. Sections that do not apply are disabled, not hidden.
- (2026-08-30) **Gateway: hostnames and certificates, managed independently.** Settings > Gateway asks two separate questions: which hostnames the gateway answers for (any, or a list -- any other Host is refused at the TLS handshake), and which certificates it has, each from Let's Encrypt, pasted, or files, each with its own domains (read off the SANs of a pasted certificate). nginx picks the certificate by SNI; the first one is the default. TLS can also end at a load balancer in front, in which case the gateway serves :80 only and needs no certificate. A 0.1.37 gateway config migrates on load.
### Fixed
- (2026-08-30) **A renewed certificate file reaches the gateway on its own.** The nginx container watches the certificate files the gateway is configured with, not only the rendered includes, and reloads when one changes -- a renewal mounted in from the host needs no reload by hand.
- (2026-08-30) **The gateway works behind a load balancer that terminates TLS.** :80 used to redirect everything to https, so a balancer forwarding on :80 got a redirect loop and its health checks marked the node down. :80 now carries the same https redirect as a native install -- keyed on X-Forwarded-Proto, exempting health-check user agents -- and serves forwarded https traffic like :443. `UNMASK_TRUSTED_PROXIES` names the balancer's ranges so the visitor's address comes from X-Forwarded-For.
## [0.1.37] - 2026-08-30
### Added
- (2026-08-30) **Container images, and a gateway that needs no nginx of your own.** `unmask.sh/admin` and `unmask.sh/nginx` (the official nginx image with the module) are served from unmask.sh like the packages, and mirrored on GHCR. With `UNMASK_UPSTREAM` set, the nginx container terminates TLS, so JA4 works, and proxies what passes to any HTTP server; the compose file is at `unmask.sh/dl/docker/`.
- (2026-08-30) **Settings > Gateway: the vhost name and its certificate, managed in the admin.** On a gateway install the tab holds the hostname and the certificate source -- Let's Encrypt through nginx's own ACME module, a pasted purchased certificate (checked as a pair, key stored readable by nobody else and never shown again), or files -- and shows what :443 actually serves. Environment variables only seed the first boot.
- (2026-08-30) **The live tail shows the flag, the network, the user agent and the path.** Each streamed event now carries the country and ASN its address resolves to and the same short user-agent reading the static rows use, so a line reads like a table row: flag, address, network, phase, fingerprint, verdict, UA, path. The path and UA are clipped at the end of the line with the full value on hover.
- (2026-08-29) **doctor notices when an install's history has split in two.** With `server.host_id` unset an install records under its OS hostname, so a rename splits its history into two names with nothing saying they are the same node. doctor now warns when the database holds several host ids and none is pinned here; the fix is `server.host_id` on each node.
### Fixed
- (2026-08-30) **The setup wizard no longer dead-ends when the token sits at the legacy path.** The token step was shown only when `/var/lib/unmask/.setup-token` existed, while every wizard POST accepted `/etc/unmask/.setup-token` as well, so an install with only the legacy file skipped the token step and then bounced the admin-user step back to itself with no message. Both use one lookup now.
- (2026-08-30) **The live tail no longer drops and reconnects every minute.** A vhost-level `proxy_read_timeout 60` -- a common default -- was inherited by the admin location and closed the SSE stream at 60s regardless of its 5-second heartbeat, so the tail flickered "(reconnecting)" once a minute for as long as it was open. The rendered location now sets its own hour-long read and send timeout. Native installs: `render-nginx` + reload.
- (2026-08-29) **The bot-hunt date column is back to 13rem.** 0.1.36 moved the host pill out of the cell and then widened the column to 18rem, so on every row without a site badge -- most rows on a single-site install -- the timestamp sat beside a hand's width of empty space before the IP. The width is what it was before any of this; the cell keeps clipping, so a badge that does not fit under an unusually wide font is cut rather than painted over the IP, and its full value is in the popover.
## [0.1.36] - 2026-08-28
### Fixed
- (2026-08-27) **The bot-hunt log's date cell painted over the IP beside it.** The column is fixed-width and never clipped, so a third element (the host pill, shown only after a host rename) spilled onto the next cell. The node's host id moved into the timestamp popover as a labelled row, the column is wider, and the cell now clips -- which holds whatever monospace font the viewer's machine resolves.
- (2026-08-25) **Security: the challenge page could redirect a visitor off-site.** `/unmask/challenge/?_test_redirect=` was guarded by three character checks that a URL parser defeats -- it strips TAB/CR/LF before parsing, so `/%09/evil.example` navigated to `//evil.example`. Reachable on every install with default settings; five Go-side redirects shared the same guard. All now resolve the value against the page's own origin. Request-supplied values are also stripped of control characters before reaching the daemon's log.
- (2026-08-25) **Security: alert mail could have a header injected through it.** Header values were written unfiltered, and a notification subject carries the address or site an event came from -- so whoever caused the event chose part of a mail header, and a CRLF in it became a new header or the body. Every header value is now filtered where it is written: CR/LF become spaces, other control characters are dropped.
- (2026-08-23) **doctor no longer says the packaged challenge asset is the one being served.** Since 0.1.32 the embedded assets are authoritative and a copy under `/usr/share/unmask/challenge/` reaches nobody, but doctor kept its pre-0.1.32 wording claiming the opposite. It now says the copy on disk is unused, names the two ways to make it count, and is an informational line rather than a warning.
- (2026-08-23) **Unpinning a popover left it stranded on screen.** Clicking a pinned popover again returned it to hover form without registering it as one, so no gesture could close it. Two clicks on a bot-hunt phase reached it. The unpin path now goes through the same registration every hover opening does.
## [0.1.35] - 2026-08-23
### Added
- (2026-08-21) **The bot-hunt timeline shows which TLS fingerprint each phase actually presented.** A beacon is its own connection and can carry a different JA4 than the challenge it answers, so a row could show a bot-verdict pill beside a fingerprint matching no rule. The serve-time fingerprint now rides every beacon, a `⇄` marks rows whose connection differed, and the session timeline gains a fingerprint column. Display-only: enforcement never reads the echo.
### Changed
- (2026-08-21) **Every unset axis action now resolves to `pow_then_captcha`.** header-integrity, stale-browser, manual bans and the community feed used to fall back to `captcha_only`; they now match the honeypot and rate-limit default. Same clients stopped, same pass grade -- but the proof-of-work leg first splits the outcome into cannot-run-JS / headless solver / person. An explicit `captcha_only` is unchanged. Installs that never set these pick the change up on upgrade.
- (2026-08-21) **The statistics tables' user-agent columns read like the hunt log's.** A platform glyph and a short reading (`Windows 10 · Chrome 126`) replace several hundred pixels of raw `Mozilla/5.0 (…)` that distinguished nothing; the full agent stays one click away. Applies to the cookie-reuse rankings, the CAPTCHA pass/fail tables and the stealth card.
- (2026-08-21) **The absence of a verdict is one token everywhere: `(none)`.** The same unlabelled request showed as `ok`, `none`, `(none)` or `-` depending on the page -- and `ok` asserted an evaluation nobody performed. Every verdict column now renders it as `(none)`, sorted after every named verdict; the funnel's permanently empty `ok` row is gone.
### Fixed
- (2026-08-21) **A rebind lineage could be forgotten while its CAPTCHA cookie was still valid.** The 8-day cutoffs on the cookie-reuse table and on rebind lineages predate unmask's own 7-day PoW / 14-day CAPTCHA defaults, so a pruned lineage let the next rebind reset its per-lineage limits. Both windows are now 15 days.
## [0.1.34] - 2026-08-21
### Fixed (security)
- (2026-08-20) **Security: a proof-of-work cookie no longer satisfies a bot-verdict JA4 rule.** A fingerprint marked `bot` was CAPTCHA-gated only for clients arriving bare; any PoW cookie -- minted under another fingerprint, or before the rule existed -- passed for the cookie's lifetime. Seen live with a residential-proxy herd. The JA4 axis now joins the CAPTCHA-grade gate on both wires. Native installs: `render-nginx` + reload.
### Changed
- (2026-08-20) **The stats range picker goes below a day: 1h / 3h / 6h / 12h.** Judging a rule changed ten minutes ago against a 24h window reads mostly pre-change traffic. Sub-day windows bypass the hourly rollup (whose grain would leak up to an hour on either side) and scan raw events on exact timestamps.
- (2026-08-20) **A bot-verdict fingerprint with no configured chain inherits the operating default on every consumer.** Forward-auth hardcoded `captcha_only` while native served the operating default, and with the grade gate that disagreement is a challenge loop. One resolver (`EffectiveJA4BotChain`) now serves the check, the serve, the gate and the render. An explicit `pow_only` remains the opt-out.
### Fixed
- (2026-08-20) **A pass rate stops rounding up to `100.0%` beside a non-zero Challenged column.** `100.0%` and `0.0%` are reserved for the exact values; anything short reads `>99.9%` or `<0.1%`.
- (2026-08-20) **A bot-hunt ranking card no longer stretches the row it sits in.** The address card, holding far more distinct values than its neighbours, set the row height and pushed the event log down the page. Overflow now scrolls inside the card with the header fixed.
- (2026-08-20) **Four app user-agent labels now read in English.** `Google アプリ` and three others were Japanese literals in the classifier, so they rendered as Japanese in an English admin. Japanese belongs in i18n.
## [0.1.33] - 2026-08-20
- (2026-08-20) **Security: a headless browser could clear the behavioural CAPTCHA at full marks.** The check read the mouse trail's shape and ignored its timing, so a driver tracing a curve scored 1.0. It now reads how the movement was built -- a script interpolating a path repeats one step length, a hand does not -- and a real automation-driven Chrome scores 0.4. Untrusted (page-dispatched) input and touchless phones with a cursor are also marked down.
- (2026-08-20) **The numeric-add fallback no longer hands CAPTCHA grade to scripts.** It asked for arithmetic, the one task a headless browser does best. A pass with no input events behind it (a script assigning `.value`) is now issued at proof-of-work grade, so it fails exactly where an operator demanded a CAPTCHA. Challenge pages cached before this keep the full grade.
- (2026-08-20) **A refusal says which axis refused.** Every deny logged as `axis_deny`; they now read `geo_deny` / `asn_deny` / `ua_deny` / `ja4_deny` / `community_deny` and carry the matching entry. The hunt reason filter can select them.
- (2026-08-19) **Alerting reaches the operator, and admin accounts grow a lifecycle.** Notification settings become their own tab (webhook, mail, SMTP server), an invitation is a settings link rather than a shared password, and an account can be disabled without being deleted. `doctor` warns when alerting is configured but cannot deliver.
- (2026-08-19) **`mysql` is accepted as a driver name.** The backend has always been both — one wire protocol, one driver — but a hand-written `driver: mysql` failed to open, and would have mis-branched the per-driver code had it opened. The alias normalises at load; the labels that still said only MariaDB now say MariaDB/MySQL.
- (2026-08-18) **The hub feeds are signed.** Each bypass-range document carries a detached ed25519 signature made on a host other than the one serving it, and the daemon refuses a mismatch or a document older than the one it holds. `sync_insecure_tls` waives the transport check but makes the signature mandatory.
- (2026-08-18) **Range updates without a network.** `update-iprange -file` imports a local copy of the aggregated document; `unmask sign-feed` signs and verifies one for self-hosted hubs. A running daemon now notices override files another process wrote, so a CLI sync is no longer rolled back by the next settings save.
- (2026-08-18) **Bypass presets follow the user-agent policy you already set.** A range preset added in a later release sat unchecked forever on installs that had saved their list, leaving its vendor on name-only rescue. A vendor whose crawlers already pass by name now gets its published ranges enabled automatically -- address verification, so only impostors change fate. Requires unanimity and fresh data; never written into your config.
- (2026-08-18) **The retention page stops blaming the wrong card, and stops timing out.** A log-ingest timeout used to flag the (correct) prune stats; flags are per card now, and the row count is an O(1) estimate rather than a scan that outran the query budget.
- (2026-08-18) **The release refuses to ship a stale range snapshot.** Refreshing the compiled-in ranges was a manual step that went six weeks unrun, so installs that cannot sync refused genuine crawlers. A snapshot older than a week now fails the release.
- (2026-08-19) **An admin account can be disabled without deleting it.** A suspended account refuses sign-in (indistinguishably from a wrong password), loses its session on the next request and drops out of alert-mail recipients; role, email and history stay. The last enabled superadmin cannot be suspended, and you cannot suspend yourself.
- (2026-08-19) **New admin users can be invited by mail, with no password ever travelling by mail.** The account is created under a random password nobody sees and the invitee gets a one-time setup link (72 hours) to choose their own. A per-row "send setup link" button doubles as admin-side password recovery. Requires SMTP and an email; every send is audit-logged.
- (2026-08-19) **Alert mail can go to an explicit address list.** `notifications.mail_to` names the alert recipients directly, separating the account contact from the alert destination; left empty, alerts still go to admin users' emails. While set, the per-user opt-out is not consulted.
- (2026-08-19) **Each notification channel can be paused.** Webhook and alert mail get their own toggle, so muting no longer means blanking a URL or host. Pausing alert mail leaves the SMTP transport alone, so password-reset mail keeps working.
- (2026-08-19) **`__hostname__` in notification labels.** `notifications.site_label` and `smtp.from_name` expand it to the install's host id at startup and on save, so a fleet ships one config fragment and an alert's label matches the events host column.
- (2026-08-19) **doctor: over-block alert deliverability.** With the breaker armed (its default) and neither a webhook URL nor an SMTP host configured, doctor now warns that a trip shows nowhere but the daemon log and the overview banner — the shape of the 2026-06-08 incident, where a challenge loop over-blocked visitors for ~14h before anyone looked.
### Changed
- (2026-08-19) **The over-block breaker counts browser-grade traffic only.** Serves with no User-Agent or a confirmed-bot JA4 verdict no longer feed its ratio: a scanner swarm is not a visitor stuck in a loop, and its volume drowned the signal (a 2026-07-04 trip was 170 UA-less probes).
### Fixed
- (2026-08-19) **`starttls: false` sent mail over STARTTLS anyway and failed on the relay's certificate.** The plaintext path used stdlib `smtp.SendMail`, which upgrades whenever the server advertises STARTTLS and then verifies the certificate -- fatal for the self-signed localhost relay the setting exists for. Plaintext now never upgrades.
- (2026-08-19) **Every mail send blocked up to ~10s on a DNS lookup.** The EHLO name was resolved as CNAME("localhost") through DNS — the wrong name to advertise (EHLO names the client) and a resolver-timeout stall on hosts with no answer for it. It is now the host's own name.
-->
## [0.1.32] - 2026-08-18
- (2026-08-16) **A CAPTCHA pass is a pass, not proof of a person.** The overview's CAPTCHA tile summed every request carrying a CAPTCHA-grade cookie and called it humans. It now separates solves from returning cookie traffic, both tiles explain what they count, and the report's `ok` column is named as a TLS-fingerprint grade.
- (2026-08-16) **Truncation is measured in a way every browser agrees with.** `scrollWidth` never reported overflow for table cells on Firefox, so the underline and full-value popover were missing there. A DOM Range measures the content now; every clamped cell shows the same dotted underline, and the popover assets are cache-busted.
- (2026-08-16) **Admin tables come in two sizes, on purpose.** Four drifted font sizes across the hunt, bans and statistics tables become two deliberate tiers, and a request line hundreds of characters long is clipped in its cell instead of setting the table's width.
- (2026-08-16) **A hunt row always names its site.** The site badge was suppressed on single-site installs as redundant, but "redundant" assumed the reader knew the install was single-site; the row now says where the event happened, every time.
- (2026-08-15) **The timeline says which clock each line came from.** Times reconstructed from the browser's own intervals are marked `≈` with a legend; server-stamped lines carry no mark, so the mark means something.
- (2026-08-15) **An abandon pill says whether the visitor ever got in.** It now answers the operator's actual question: did this client later pass (`✓`) or not (`∅`), at the front of the pill where it survives overflow.
- (2026-08-15) **A ranking that could not be read says so.** A hunt ranking that exceeded the statement timeout rendered as empty -- indistinguishable from "nothing matched" mid-incident. Each failed ranking now says it was unavailable.
- (2026-08-15) **A geo or ASN rule's chain is the chain that gets served.** On native installs the challenge endpoint attributed a serve to the rule that fired and then served the site's default chain -- `captcha_only` came out as `pow_then_captcha`. The serve now uses the same resolution that named the axis.
- (2026-08-15) **The built-in challenge assets are what runs, unless you say otherwise.** A copy under `/usr/share/unmask/challenge/` silently outranked the embedded assets, so an upgrade could ship a new page and an old overlay kept serving. Embedded wins now; a custom page needs the explicit `challenge_html_path` / `challenge_js_path` setting, and a stray overlay is warned about at startup.
## [0.1.31] - 2026-08-15
- (2026-08-15) **A session that ran exactly as served was reported as having diverged.** Any CAPTCHA on a chain other than `captcha_only` counted as a departure, so `pow_then_captcha` was flagged the moment it reached its second leg. The line now reads the reason each CAPTCHA beacon already records, so only a real escalation is called out.
- (2026-08-15) **What a visitor did after abandoning rides inside the abandon pill.** The separate chip wrapped onto its own line and read as another step; it is now one character at the front: `↩` when a request followed, `∅` when none did.
- (2026-08-14) **A honeypot ban says which host was probed, not just which path.** `/cgi-bin/…` exists on every vhost, so the path alone could not say which site was scanned. Both deployment modes now record the host, budgeted so a kilobyte of path cannot push it out.
- (2026-08-14) **A ban reason that is really a request line no longer sets the width of the whole table.** Honeypot paths are injection probes hundreds of characters long; the reason is now clamped with the full value one hover away, and address / fingerprint / action columns no longer wrap.
- (2026-08-14) **The bot-hunt log orders a session by when the browser sent each beacon, not when the server received it.** Phases leaving in the same JavaScript tick raced over the wire and could show a CAPTCHA before the proof of work that led to it. Beacons carry the page's sequence number; the absolute clock stays the server's.
- (2026-08-14) **Security: a geo or ASN rule set to a CAPTCHA was satisfied by a proof-of-work cookie.** The pass-cookie gate consulted the user-agent chain, protected paths and the community feed but never the network axes, on either deployment mode. Both now fold geo and ASN into the same grade requirement; rate-limit-mode rules are unaffected.
- (2026-08-14) **An ASN rule matching on organisation rendered a config nginx refuses to load.** An organisation name can contain a space and the geo block emitted it unquoted; the render succeeded and `nginx -t` failed afterwards. Values are quoted now.
- (2026-08-14) **The built-in CAPTCHA is the recommended one.** An external provider must be told which domains may use it, and an admin panel on an unregistered host fails the check -- locking the operator out of the page they would fix it on.
- (2026-08-13) **The statistics page offers only the spans it can fill.** A range longer than the data on hand looked like an outage; the choices now stop at the oldest hour actually stored.
- (2026-08-13) **Self-measured figures are out of the shipped files.** The rendered nginx configuration and the challenge page served to every visitor carried request counts, address-pool sizes and node names from our own deployment, used as evidence in code comments. The arguments never needed the numbers, and they are not ours to publish on someone else's install.
## [0.1.30] - 2026-08-12
> Anthropic's crawlers are verified by address now. Anthropic used to be the
> one major AI vendor that published no crawler IP ranges, which left ClaudeBot
> as the one major AI crawler this project could not verify: every request
> wearing the name passed on the user-agent string alone. That gap closed --
> claude.com/crawling/bots.json exists -- and the difference it makes is not
> subtle: on a site with the preset on, the overwhelming majority of requests
> wearing the name turn out to come from somewhere else entirely, scattered
> cloud addresses probing for `.aws/credentials` and `.env` under the borrowed
> name. This release also adds `unmask stats`, a read-only report for the
> operator whose database is exactly the thing they cannot open.
### Added
- (2026-08-12) **Anthropic's crawlers are verified by their published IP ranges.** A new `claude` preset carries the official list for ClaudeBot, Claude-User and Claude-SearchBot; with it on, only an address inside the ranges passes, as Googlebot / GPTBot / Applebot already work. Fresh installs get it by default; existing installs keep their saved list until they opt in.
- (2026-08-12) **`unmask stats`: the numbers, without opening the database.** A read-only aggregate dump for hosts where a DB client is unavailable (CentOS 6's sqlite3 refuses WAL files). Six reports over one window, `-site` scoping, and `-tsv` for `awk`.
### Fixed
- (2026-08-12) **The over-block breaker no longer trips on a scanner farm.** Web-shell probes from a few cloud addresses produce exactly the serves-per-address ratio it watched for while never loading a challenge page. It now requires the loads to corroborate the ratio.
- (2026-08-12) **The traffic-composition card stopped hiding its breakdown behind a wrong diagnosis.** A small skew from minute-bucketed counters against hourly terms tripped a double-counting warning that replaced the whole breakdown. The residual is now shown honestly; the warning is reserved for a counter feed that is off entirely.
- (2026-08-12) **A hunt row names its Host when the reader cannot infer it.** A row outside every declared site showed an address with no context. Ghost hosts carry an amber badge, and collapsing a session keeps the phase cell's badges.
- (2026-08-12) **`unmask doctor` no longer tells you to run it with sudo.** doctor drops privileges before that check ran, so the advice was unreachable; the permission notes now name the actual remedy (group membership for the paths).
## [0.1.29] - 2026-08-11
> A fix for anyone running behind forward-auth on nginx: a fresh install would
> challenge you for your own admin login, refuse the credential that challenge
> minted, and challenge you again -- with no way out from the browser. Nothing
> had to be misconfigured to reach it. Native (nginx module) and Apache
> forward-auth deployments were never affected.
### Fixed
- (2026-08-11) **A fresh forward-auth install on nginx locked you out of its own admin.** The default "unmask itself" preset demanded a CAPTCHA-grade pass for `/unmask/admin/`, but the challenge recovered the URI through a helper that drops anything under the unmask mount, never learned the request was protected, and served a proof-of-work screen -- solved, refused, served again, forever. The protected rule now resolves from a helper that keeps those URIs.
- (2026-08-11) **A challenge is never served that the gate would refuse.** If a CAPTCHA-grade pass is required, the chain now ends in a CAPTCHA whatever route picked it; escalation keeps the proof-of-work leg, so it is never weaker than what was chosen.
- (2026-08-11) **Collapsing a session in the bot hunt threw away its badges.** The rate-limit zone, the LB-misconfiguration warning, the returned/gone marker and the reload counter were all dropped when a session had a second row. All four now survive the collapse.
## [0.1.28] - 2026-08-11
> The community feed now enforces on every deployment. It shipped enforced by
> a set of nginx map lookups, which meant a forward-auth install -- Apache, or
> nginx without the module -- pulled the shared list, showed it in the admin,
> and blocked nothing with it. The same release settles what a feed hit and a
> black-listed user-agent actually cost, in one place each, so both deployment
> modes answer the same way.
### Fixed
- (2026-08-11) **Security: the community feed enforced nothing on forward-auth deployments.** Enforcement lived in the nginx map files, which Apache and forward-auth nginx never read -- such an install listed every entry as enforced while every listed client walked through. The daemon now builds its matcher from those same map files, so a hit is enforced in-process.
- (2026-08-11) **A community-feed hit was reported as nothing at all.** It was folded into the protected-path variable and arrived with `force_reason=none`. It is now its own axis end to end, reported as `community_bans` with the match kind.
- (2026-08-11) **A community-feed hit could re-challenge forever on a PoW-only install.** The hit raised the CAPTCHA-grade requirement but never chose the screen, so a `pow_only` install minted a cookie the next request refused. One value now drives both.
- (2026-08-11) **A black-listed user-agent met a different challenge depending on the deployment mode.** Native inherited the install's default chain; forward-auth kept a fixed `captcha_only` from before the picker existed. All consumers resolve through one function now, so forward-auth with the picker unset serves the default chain.
- (2026-08-11) **Deleting old events could starve the ban lookup.** Retention issued one unbounded `DELETE` that exceeded its budget, rolled back and retried every run while holding locks the ban lookup needs. Pruning now runs in bounded batches that keep what they committed and checkpoints the WAL.
- (2026-08-11) **Port 80 (https) behind a load balancer.** Hunt read the port from the connection to nginx, which behind a TLS-terminating load balancer is the backend hop, not the visitor's. It now records the forwarded port and shows the accepted one beside it when they differ.
- (2026-08-11) **Inherited labels went stale while you were editing.** Changing a tab's default left every "unset" option, the new-row scaffold and the view pills naming the old value until a reload. Fixed on the protected-paths, ua-filter, country and ASN tabs.
### Changed
- (2026-08-11) **The community feed's action is yours to pick.** A hit was hardcoded to a CAPTCHA; it now takes `pow_only` / `captcha_only` / `pow_then_captcha` / `deny` on the Community Bans tab, applied identically on both wires. Default `captcha_only`. Verified search bots, bypass IPs/paths and a valid pass cookie are still consulted first.
- (2026-08-11) **Protected paths take a default mode.** A row or preset left unset follows a tab-level default instead of a fixed value, the same way the country and ASN tabs already work.
### Removed
- (2026-08-11) **`bans.community_bans_default_action`.** It looked like the knob for what a feed hit costs, but community entries are never written to the local ban list, so nothing ever read it. The setting on the Community Bans tab replaces it.
## [0.1.27] - 2026-08-10
> A protected path that asks for a CAPTCHA now gets one. The gate that guards
> unmask's own admin login shipped on by default and could be satisfied by a
> proof-of-work cookie -- the exact credential a headless solver mints for
> itself. The rest of the release is retention: raw events are the only thing
> the retention window still governs, so its default drops to a week and a busy
> install stops growing toward tens of gigabytes.
### Fixed
- (2026-08-10) **Security: a protected path whose mode ends in a CAPTCHA was satisfied by a proof-of-work cookie.** The pass-cookie exemption never consulted the protected axis, so a headless solver's PoW cookie walked through the captcha-graded gate on the product's own admin login. Both deployment modes now require CAPTCHA grade for `captcha` and `pow_then_captcha` paths; `pow` paths are unchanged.
### Changed
- (2026-08-10) **A protected path's mode is the challenge it runs.** The served chain used to come from three inheritance layers an unrelated tab could change. A path now carries one value that is both match mode and action: `pow`, `captcha`, or `pow_then_captcha` (new, default). `strict` is gone.
- (2026-08-10) **Raw-event retention defaults to 7 days.** Raw events back only the bot-hunt log, support lookups and rankings; every long-range chart reads fixed 32-day aggregates. On a high-volume install 30 days runs to tens of gigabytes. Existing installs keep their stored value.
### Added
- (2026-08-10) **The crawler trend and declared sites' funnels survive a short retention.** Both still followed the raw-event window; the trend now uses the fixed 32-day window, and declared sites get their own funnel aggregates (no backfill).
- (2026-08-10) **The retention tab projects the disk.** Observed growth per day, the size the current setting converges to, and free space on the volume, with a warning when the projection fills the disk.
- (2026-08-10) **Hunt shows where a visitor came from.** The `Referer` of the request that was challenged (and, on forward-auth, of one that passed) is recorded and shown in the URL cell's popover -- context for reading whether a person navigating in from your own pages hit a challenge. Empty on most rows, since bots rarely send one.
## [0.1.26] - 2026-08-09
### Added
- (2026-08-09) **Upgrade review: an upgrade never silently tightens what you block.** On the `review` policy (default for fresh installs) a new default-on enforcement preset stays inert until you acknowledge it; rescue presets are never held. A dashboard banner and `unmask upgrade-review` apply them. Existing installs default to `apply`.
- (2026-08-09) **A GCP load-balancer health-check stats-exclude preset.** One opt-in toggle drops the probe ranges (35.191.0.0/16, 130.211.0.0/22) from statistics and bypasses the challenge -- a failed check drops the node.
### Changed
- (2026-08-09) **A shipped preset is active from the release that ships it.** The old opt-in gate keyed on a drifting `seen_version`, so nodes silently ran different rulesets. Presets now apply at their declared default on every install; `seen_version` remains only as the "NEW" badge.
- (2026-08-09) **Every preset in the settings UI carries a version.** A few presets — the trusted-LB and HTTPS-redirect-exempt groups, and the stats-exclude toggles — rendered without the "since vX.Y.Z" label the other presets show; they now carry it (and a NEW badge when added after your last save), so a preset a later release adds is always identifiable.
### Fixed
- (2026-08-09) **A client disconnect no longer logs as an insert error.** On a busy node a client often drops before the async `unmask_event` (or audit) insert finishes, cancelling the request context; that abort was logged as an error — noise, not a fault. It is now logged only when the context is still live.
## [0.1.25] - 2026-08-09
> Mostly the settings surface this release: text you typed into a rule survives a
> rejected save instead of vanishing, every tab lives at its own URL, the
> admin-host allowlist speaks in hosts rather than raw regex, and the overview's
> composition card is yours to slice.
### Changed
- (2026-08-09) **The admin-host allowlist speaks in hosts, not generic patterns.** "contains" was a substring match that would admit `example.com.attacker.com`. The modes are now **exact**, **subdomain** (end-anchored) and **regex** (full-string); a legacy contains entry reads as exact.
- (2026-08-08) **Each settings tab has its own URL.** The tab was a query parameter (`?tab=network`); it is now a path segment (`/admin/settings/network/`), matching the stats and per-site pages, so a tab can be linked, bookmarked and opened in a new window as itself.
- (2026-08-08) **Every segment of the overview's composition card is a filter.** The segments of the traffic-composition bar can each be clicked in or out of the denominator, so the headline percentage recomputes against exactly the slice you want to read; the selection persists in a cookie, and the card now shares the legend behaviour the stats charts already had.
### Fixed
- (2026-08-08) **A rejected rule-list save no longer discards what you typed.** Every settings tab now keeps your input on rejection, opens only the rows you changed, and marks the offending row with the reason.
- (2026-08-07) **The admin-host allowlist honours its own mode toggle, and rejects a pattern it cannot use.** A regex host was read literally and an invalid one silently dropped, leaving the gate open under a green "saved" banner. Invalid values are now rejected at save with the reason.
- (2026-08-08) **The composition breakdown reads as figures and stays put when pinned.** Its popover rows now separate their label from their number, keep their styling when the popover is pinned to the page, and reserve the room a toggled-off segment needs so the legend no longer reflows as you click.
## [0.1.24] - 2026-08-07
> One production report drove most of this release: a distributed crawler that
> solved a single challenge and walked the credential across hundreds of
> addresses -- the same crawler whose deny fix shipped in 0.1.21. Passing by
> re-binding was invisible, because every individual request was a legitimate
> pass, and it was cheap, because one solve priced the whole fleet. This
> release makes it visible everywhere it shows up, and makes a CAPTCHA posture
> actually cost a CAPTCHA.
### Fixed
- (2026-08-07) **The overview page slowed with the size of the event log.** Its 24h counters scanned the raw event table on every load -- 20 seconds cold on a large node. They now read the hourly rollup; figures are unchanged, the window is hour-aligned.
- (2026-08-06) **A pass earned by re-binding onto a new address was reported as a CAPTCHA solve.** The plugin classified a cookie by shape, and a re-bound entry has the same shape as a solved CAPTCHA. Both wires now report the signed kind; re-bound passes appear in the composition's residue and the reuse ranking.
### Changed
- (2026-08-06) **Security: a rule whose chain ends in a CAPTCHA is satisfied only by a CAPTCHA-grade pass.** A UA deliberately put behind a CAPTCHA walked through on a proof-of-work cookie, and a silent re-bind extended that pass to new addresses. Each solve now records its grade into the credential; the exemption requires that grade on both wires, and a re-bind checks the lineage's grade.
- (2026-08-06) **A cookie minted while enforcement was off no longer reads as a solve.** Monitoring-mode passes inflated the pass counts and satisfied the exemption after enforcement was turned on. They are their own kind now, never sufficient for a grade requirement.
### Added
- (2026-08-07) **Paths on the stats page say which host they belong to.** Path rankings carried the path alone, which on a multi-site install answers less than it seems; each row now names the site and carries the same popover as hunt, with the full URL ready to open.
- (2026-08-07) **The pass KPIs name the requests an existing cookie admitted.** Each pass card carries a second line counting requests admitted by cookies of that kind, in its own units.
- (2026-08-06) **How far one solved challenge has travelled is now visible.** The stats page ranks credential lineages by the distinct addresses a single solve was re-bound onto, with refused re-binds and cap status -- ranked by spread, because the case worth finding never surfaces by volume.
## [0.1.23] - 2026-08-06
> 0.1.22 was published to the pre-release (testing) channel only and never
> reached stable; everything it carried ships here. A build superseded while
> in testing keeps its number and stays there -- one version never means two
> different files.
### Fixed
- (2026-08-06) **A schema migration could wedge an install forever when the database already carried part of its work.** A duplicate column aborted the file before its `CREATE INDEX`, the version was never recorded, and every restart retried into the same error. Duplicate column / key errors now count as that statement already done; anything else still aborts.
- (2026-08-06) **The abandonment rate counted rule-targeted crawlers, so on a bot-heavy site it read as a catastrophe.** Departures a rule caused are the rule working. The rate is now computed over visitors no rule pointed at; the same window reads 4.8% instead of near-total.
- (2026-08-06) **Promoting an rpm out of the pre-release channel could not tell the identical build from a different one.** Rpms are re-signed with a fresh timestamp on every index, so bytes never matched twice. The payload digest is compared now; deb and apk stay byte-compared.
### Changed
- (2026-08-06) **Rendering the same settings twice no longer rewrites the files.** Every render rewrote every file, so mtimes meant nothing and the stale-config check called all seven fleet nodes stale after a no-op upgrade. A render whose output differs only in its stamps leaves the file alone.
### Added
- (2026-08-06) **doctor now notices a rendered config that nginx never loaded.** Saving settings renders the conf and deliberately does not reload nginx, and every check read healthy in that gap. doctor reads the newest worker's start time from `/proc` and warns when the config changed after it.
- (2026-08-06) **A leftover `challenge_targets.all` gets a warning that names the consequence.** Removed in 0.1.19, it produced only "unrecognized keys (ignored)" -- on one install describing a downgrade. The warning now names the replacement: a UA row with action deny.
- (2026-08-06) **Publishing verifies what is now being served, from the URLs a client would use.** An apk index once reached unmask.sh unsigned, which empties the repository. The publish now fetches the signatures clients verify, inspects the apk index for its `.SIGN` entry, and installs from the live repository in a container per format. It also refuses an unresolvable signing key or a mixed-version tree.
- (2026-08-06) **Reindexing stable cannot silently replace a build that testing confirmed.** Every artifact is compared against the testing tree first (payload digest for rpm, bytes for deb/apk); replacing a confirmed build on purpose needs `UNMASK_ALLOW_TESTING_MISMATCH=1`.
- (2026-08-06) **`unmask version` names the commit it was built from.** Two binaries that differ were indistinguishable once installed; the commit is now stamped at link time and printed beside the version.
## [0.1.21] - 2026-08-06
### Added
- (2026-08-05) **A pre-release channel, so a fix can be confirmed by its reporter before it ships.** Promotion copies the artifact rather than rebuilding it. `unmask-release` configures the channel inactive on every install; enable it by name (`--enablerepo=unmask-testing`, an apt pin, `apk --repository`). See https://unmask.sh/docs/faq/#install-specific-version.
### Fixed
- (2026-08-05) **Security: "deny" did not deny.** Every axis except the ban was consulted only when the visitor held no pass cookie, so a crawler that solved the proof of work once per address rode through a UA row set to deny for a week. A resolved deny is now enforced beside the ban, ahead of anything that reads the cookie, on both wires. Rescues still win.
- (2026-08-05) **The overview counted some requests twice, and could not say how much of the traffic was human.** A request with a pass cookie that also matched a bypass rule counted as both. The classification is one decision now, the human share counts requests holding a pass cookie, and the unattributed remainder gets its own segment. Parts now sum exactly to the total.
- (2026-08-05) **A rescued crawler was counted as nothing in forward-auth mode.** Only three of four buckets were recorded on `/api/check` nodes, so the benign share sat at zero all day.
- (2026-08-05) **Turning on Web Bot Auth made every request to the vhost pay for the whole decision chain.** Its gate was keyed on an eagerly built value, so static-file locations computed the JA4 fingerprint and every axis. Measured 56k -> 3.9k req/s; now 42-54k. Privacy Pass had the same shape.
## [0.1.20] - 2026-08-05
### Fixed
- (2026-08-05) **A visitor who left while the challenge page was holding lost the solve they had already made.** The 0.1.19 display hold ran before the pass cookie was written, so closing the tab during unmask's own pause meant a fresh challenge -- one node's abandonment jumped several times over. The wait now happens after the cookie is written.
## [0.1.19] - 2026-08-05
### Added
- (2026-08-04) **The traffic-composition card can be read against the traffic unmask actually judged.** Bypassed requests -- 56% of a day on one node -- sat in the denominator and halved every other share. The card now names its denominator and offers both: all traffic, or judged only.
- (2026-08-04) **The challenge page's presentation is a setting.** The visible style holds the page a configurable minimum (`min_display_ms`, default 800) with "✓ Verified"; the invisible style shows nothing until `invisible_reveal_ms` (default 1200), so a fast device sees no interstitial at all. CAPTCHA and error screens appear immediately, and the hold is never counted into solve-time metrics.
### Changed
- (2026-08-04) **Lowering the proof-of-work difficulty now warns that it breaks the site until nginx reloads.** The native module verifies cookies against the rendered difficulty, so dropping it daemon-side alone loops visitors about four times before one solve happens to pass. `render-nginx` now prints the share of solves that will be refused and the reload command. Lower the gate first, then the daemon.
### Fixed
- (2026-08-04) **The rate-limit funnel rollup wedged forever on the first hour containing a verdict-less event.** A NULL `ja4_verdict` errored the scan, the cursor never advanced, and the dashboard fell back to raw-scanning an ever-growing window. The fix un-wedges existing installs on its own.
- (2026-08-04) **On an install that challenges every visitor, hunt attributed every challenge to the operator's UA rules.** Under the challenge-everything toggle every UA read `ua_target`; the reason now names a rule only when a pattern actually matched.
### Removed
- (2026-08-04) **`challenge_targets.all` is gone; the challenge-everything posture is the Operating-mode buckets' job.** The invisible key duplicated the bucket actions and fed the attribution bug above. A default install behaves identically; `known_browser_action: pass` set beside it is now honoured.
## [0.1.18] - 2026-08-04
### Fixed
- (2026-08-03) **Every visitor was held 1.5 seconds before the challenge let them through, and the wait was entirely artificial.** A display floor meant for the test pages shipped to production because a placeholder had drifted from the constant substituted into it, and a second 800ms padded the redirect. PoW p50 1508ms -> 389ms; abandonment on the same node 7.53% -> 2.76%.
- (2026-08-03) **Browsers without `TextEncoder` could not solve the proof of work and looped on the challenge forever.** EdgeHTML has it on neither the window nor a worker, so the PoW worker died on its first message. UTF-8 is encoded by hand now, and a test fails on any worker callee left behind.
- (2026-08-03) **The dashboard could report "0 human visitors" for a site that had them.** A listed crawler fetching a bypassed path was counted in two segments, so the human remainder went negative and was floored to zero. Bypass now wins that overlap, and an uncomputable remainder reads "—" with the reason.
- (2026-08-03) **A visitor who merely changed IP was re-challenged instead of being silently re-bound.** The roaming rebind runs only on the plain path, and a new escalation-reason attribution filled that field in before the gate that reads it. Caught by the end-to-end suite, not by unit tests.
### Added
- (2026-08-03) **A pattern can say that it means itself.** Every rule field was a regular expression, so a User-Agent pasted verbatim matched nothing. Each pattern now declares how it is read -- regex, contains, or exact -- via a chip in the field, on every pattern list. Unmarked patterns stay regexes.
- (2026-08-03) **A UA block-list rule can pin its own action.** The list ran one chain for every pattern in it, so a rule that needed `captcha_only` could only get there by moving every other rule with it.
- (2026-08-03) **Hunt says why a challenge fired, for the axis that fires most of them.** A challenge raised by the operator's UA rule was recorded as "none"; it now reads `ua_target` and is filterable. The serve event records which chain it offered, and every phase pill has a popover.
- (2026-08-03) **Requests a bypass rule let through are counted apart from people.** A repository or API behind a bypass path used to land in the human share -- 30% of all traffic on one install. Both deployment modes feed the same counter.
- (2026-08-03) **Acting on a hunt row no longer leaves the page.** The UA and network buttons open a dialog like BAN beside them; the UA dialog proposes the identifying token rather than the whole string, counts what the pattern would match, and refuses one that does not match the UA it was built from.
### Changed
- (2026-08-03) **Hunt's identifier columns abbreviate until there is room, and expand when a card is folded away.** The JA4 was clipped at 25 of its 36 characters server-side, so widening its card revealed nothing.
- (2026-08-03) **A UA pattern added from hunt is validated the way the settings form validates one.** That path rejected nothing, and a self-identifying crawler's `(+https://…)` is not a valid regex -- nginx would have refused the whole configuration at the next reload.
## [0.1.17] - 2026-08-03
### Added
- (2026-08-02) **The dashboard says what a site's traffic is made of, in requests.** The old "non-human traffic" figure counted distinct clients that failed a challenge, missing every crawler let through on purpose. It now reads as three shares of one total -- bots passed on purpose, bots stopped, and the rest -- in requests, because a crawler from a few addresses and a bot spread over thousands rank in opposite order by client count.
- (2026-08-02) **Every custom rule records when it was added and when it was last edited.** The audit log is pruned on a retention window, so older rules had nothing left to say when they arrived. The edit date is stamped only when a confirmed edit actually changed something.
- (2026-08-02) **Hunt can drill from a network into the requests behind it.** The ASN is resolved at render time, not stored, so the drill-down resolves the window's addresses and filters on the network. Without an ASN database it shows nothing rather than the unfiltered log.
- (2026-08-02) **Rank cards fold away, and the choice sticks.** Any card folds to a labelled strip and hands its width to the ones still open; the user-agent card can then switch to the raw string.
- (2026-08-02) **The managed geo databases keep themselves current.** Country and ASN can each update monthly; a download is swapped in only after it verifies as a readable database. Custom paths are never touched.
- (2026-08-02) **`unmask` drops to the daemon's user when run as root.** `install-ipgeo` under sudo left root-owned databases and every country lookup stopped resolving, while doctor read them as root and reported them fine. doctor now checks ownership too, and flags a deployed challenge asset that no longer matches the binary.
### Changed
- (2026-08-02) **The proof of work runs in a Web Worker.** Browsers clamp a background tab's timers to one second, so a visitor who switched tabs waited roughly a minute -- fleet data showed a cluster at 41-70 seconds. A worker needs no yielding; the UI-thread loop stays as a fallback.
- (2026-08-02) **Every path field states whether it takes a regular expression, with examples.** Seven path settings disagreed (rate-limit zones matched literally, one list was case-sensitive); they are regular expressions now, uniformly.
- (2026-08-02) **Bot hunt ranks the networks traffic came from, not just the addresses.** A bot renting thousands of addresses in one hosting AS never surfaces in a per-IP list. The new "top networks" card orders by distinct IPs, shows whether an ASN rule already covers each, and links uncovered rows into the ASN tab with the number filled in. Omitted when no ASN database is installed.
- (2026-08-02) **Bulk network lookups stopped paying for data they discard.** Network-only lookups skip the country database and the shared cache, keeping the new ranking off the critical path and the cache bounded.
### Fixed
- (2026-08-02) **A visitor that sends no User-Agent was recorded -- and classified -- as unmask's own fetcher.** Forward-auth reports an absent UA as an empty header, and the resolver fell back to the subrequest's own UA (LuaSocket under Apache). On the install where this surfaced, a large share of events carried that name -- exactly the scanner population the check exists to catch.
- (2026-08-02) **Observe mode no longer reports itself as "no attacks".** The headline counts challenges that fired, and observe mode fires none, so an install being scanned continuously showed a calm dashboard. It now reports what would have been stopped, and says which question it is answering.
- (2026-08-02) **A confirmed settings row stopped offering controls, and stopped changing when one was touched.** The action dropdown stayed editable on a confirmed row and opening it toggled the enabled switch; confirming also rewrote the summary as plain text. Fixed.
- (2026-08-02) **The Apache hook fired on internal redirects and sub-requests.** One visitor request could be judged several times, and a request the server generated for itself was judged as if it had come from outside.
- (2026-08-02) **The hunt log's user-agent column reads four cases correctly.** A version without a trailing dot, an in-app browser named after its engine, a crawler summarised as "bot" from its URL, and a crawler that failed its address check described as challenged.
- (2026-08-02) **Redirect exemptions kept restamping their own date.** The row posted a fixed value rather than what it held, so every save of that tab recorded the rule as added that day.
## [0.1.16] - 2026-08-01
### Added
- (2026-08-01) **The default rate limit is now three parallel axes -- per-IP, per-JA4, per-IP+JA4 -- each a row with its own threshold.** A bot fleet rotating addresses never accumulates per-IP but tends to share a TLS fingerprint, so a per-JA4 counter keeps counting. The JA4 row ships off (one fingerprint covers every user of one browser build); enabling it adopts 600 r/min and a challenge action. The finer key must carry the lower threshold, or the coarser counter always trips first. Existing config files load unchanged.
- (2026-08-01) **Named zones can count against their own key.** A path-scoped zone now takes ip / ja4 / ip+ja4 in a new column (empty = follow the default), so `/api/` can throttle per fingerprint while everything else stays per-address. The zone table's help carries the same crowd-key caveat as the default card.
- (2026-08-01) **The admin login and password-reset forms throttle per address -- in the application, where it actually fires.** The nginx rate-limit preset never counted a request, because anyone holding a pass cookie is exempt from challenge-mode zones. Repeated failed logins now answer 429 with Retry-After, and password-reset requests get their own budget, on both deploy modes.
### Changed
- (2026-08-01) **Every value list in the settings renders as confirmed rows, with a per-row off switch, a note, and reorder arrows.** The admin allowlist, admin hostnames, metrics allowlist, defined sites and trusted-LB list were permanently editable inputs on forms that save whole -- and a mistyped allowlist locks you out. A switched-off row is absent from every consumer; the self-lockout guard judges the enabled subset.
- (2026-08-01) **The hunt log's user-agent column tells apps, browsers and bot kinds apart at a glance.** Browser and platform marks, in-app requests named after the app rather than its engine, and bot claims split into two badges: a listed crawler (amber, vendor known) versus a self-declared bot (grey, nothing to check against). The pager caption counts sessions on screen.
- (2026-08-01) **Settings tabs, smaller strokes.** Public test pages default to on with the site picker wired; the performance tab gains named profiles and shows what "auto" resolves to on this machine; the geo tab shows country names; the PoW cookie-lifetime hint renders on load.
### Fixed
- (2026-08-01) **Rate-limit zones enforce identically on both wires.** Path patterns were a case-insensitive regex on nginx but literal bytes on forward-auth; a zone with no patterns was declared but never applied natively; overlapping zones counted first-match-only on forward-auth while nginx stacks them. All three now agree, and a deny trip outranks a challenge trip.
- (2026-08-01) **Hunt paging, round two.** The boundary margin was sized to how many rows a session has, not how far they spread among concurrent traffic, so sessions kept arriving decapitated; it now covers the measured spread. The 1000-rows option works (limits clamp instead of resetting), and the fragment marker names all three causes, including the serve landing on another node behind a load balancer.
- (2026-08-01) **Saving a geo or ASN row no longer wipes the pills out of its confirmed view.** Confirming an edit rewrote the whole cell as plain text, which destroyed the country name and both action pills until the next full page load. The confirm now rewrites only the value.
## [0.1.15] - 2026-08-01
### Added
- (2026-07-31) **Crawlers that publish their egress ranges are verified by address rather than by name -- as standing policy, on by default.** The per-row opt-in could be pinned months earlier and quietly keep a vendor UA passing on the string alone. While the new UA-filter switch is on, every range-backed bot with live presets is judged by address alone; a bot whose preset is disabled keeps its UA rescue rather than losing every path. Switching it off restores the per-row choices.
- (2026-07-31) **Each row of the three network allowlists can carry a note.** Six months on, nobody remembers which office VPN `203.0.113.7` let in, and the safe-looking cleanup is how an operator locks themselves out. Purely for memory; nothing matches on it.
- (2026-07-30) **A collapsed challenge session shows how long the whole exchange took.** The popover carries first event to last, so "cleared PoW in 1.4s" is read directly.
- (2026-07-30) **Apple push-notification previews pass out of the box -- behind a TLS-fingerprint guard.** Subscribers' own devices fetch the article to build the rich preview, from residential IPs with no vendor ranges to verify. The `notification-preview` preset rescues the UA only when the request's JA4 carries Apple's cipher-suite hash (JA4_b, stable across CFNetwork builds), so a spoofer must mimic Apple's TLS stack rather than copy a string.
### Changed
- (2026-07-30) **The hunt log shows each user agent as "platform · browser version" instead of the raw string's first few inches.** `Windows 10 · Chrome 126`, `iOS 26.5 · Safari`; the full string is in the popover, and a UA that does not parse as a browser keeps its raw form.
- (2026-07-30) **A refused roaming rebind appears inside the session it interrupts instead of as a stray row.** `bv_rebind_reject` rows carried no session token; the hunt now folds the refusal into the session that follows, so one line tells the story.
- (2026-07-30) **The site-scoped challenge preview moved from `/unmask/challenge/<site>/` to `/unmask/test/site/<site>/`.** The old address put an operator test surface in the production namespace and forced dotted host ids into that path's grammar -- the cause of 0.1.13's PoW loop. `/unmask/challenge/` now has exactly one shape. The old URL is gone, not redirected; every link to it is generated by unmask's own UI.
### Fixed
- (2026-07-31) **Paging through the hunt log is no longer corrupted by the log's own growth.** Sessions straddling a page boundary arrived cut in half, and "next" repeated rows that new events had pushed down. Each page reads past both edges and assigns every session to one page; page 1 pins the newest event id so paging walks the log as it stood.
- (2026-07-31) **Looking up a support ref no longer reads the whole event table.** A `LIKE` over the JSON payload took 53 seconds on 3.4M rows, on the path an operator walks while a blocked visitor waits. Migration 0025 adds an indexed `ref_id`: 9ms. Not backfilled; the gap closes within days on the retention window.
- (2026-07-30) **Per-site checkboxes show the value that is actually in effect.** "off here" and "inherit" were confused both ways, so unchecking a flag came back ticked and saving an untouched form silently pinned it off. Checkboxes now render the resolved value, and an untouched form stores nothing.
- (2026-07-30) **A per-site logo appears in the theme previews, not just the thumbnail beside the upload field.** The preview iframes resolved branding by the admin's own Host, so they showed the wrong site's identity and no logo, which read as "the save did not take". Per-site previews now load the site-scoped route.
## [0.1.14] - 2026-07-30
### Changed
- (2026-07-29) **SQLite memory is budgeted for the whole daemon instead of per connection, so unmask fits on a 1GB VPS.** `cache_size` and `mmap_size` are per-connection and the pool opens up to 8, so "128MB + 256MB" was really 8x each -- 3.7GB RSS peak on a synthetic 2.2GB database. Both are now derived from the memory the process may use (cgroup, `/proc/meminfo`, then `sysinfo`) and split across a pool sized to the CPUs: 447MB peak for 16% more wall-clock.
### Added
- (2026-07-29) **A Performance tab, so the resource dials are visible and adjustable.** Shows the detected memory limit and CPU count, what is in effect, and what each preset -- Conservative / Standard / Generous / Custom -- would use on this host. A resource dial, not a speed dial: a larger cache does not reduce the rows an index walk reads. Write-batching settings moved here from Log management.
### Fixed
- (2026-07-29) **The header-integrity axis no longer charges genuine pre-2021 browsers for a header they never had.** `Sec-CH-UA` shipped in Chromium 89, so every older browser was flagged permanently -- and the behavioural CAPTCHA is hardest on exactly the old handsets this caught. The axis now stays silent below Chromium 89 on both deploy modes.
## [0.1.13] - 2026-07-29
### Changed
- (2026-07-28) **A per-site setting no longer freezes everything else about that site.** Setting one knob used to seed the site with a complete copy of the global record, so later global changes never reached it. A site record now holds only what you set; unset fields are marked on the form, and turning a flag off for one site is expressible.
- (2026-07-28) **The challenge page's theme, colours and credit line moved to the design settings, where the logo already lived.** They sat in the challenge-behaviour record, so choosing a theme pinned a site's PoW and cookie settings to a snapshot. Existing config files are moved across on load; a record that had already drifted is kept as-is.
- (2026-07-28) **The header-integrity axis is on by default, and it outranks the stale-browser tier.** A Chrome / Edge / Opera UA with no `Sec-CH-UA` over HTTPS contradicts itself, and what that catches is almost purely non-human. Clamped to a challenge, never a hard block; `header_integrity: false` turns it off. Both deploy modes now report a simultaneous trip as `header`.
### Added
- (2026-07-29) **The audit log records where each admin action came from.** Behind a load balancer the operator's real address was written down nowhere, which blocked setting an admin IP allowlist and answering "which of these logins was not us". Every audited action, failed logins included, now carries the client IP.
- (2026-07-28) **You can delete a site's settings, not just switch them off.** Nothing in the UI removed the record, so a site configured once kept an entry forever. Both the design and challenge tabs offer a delete when something is stored.
- (2026-07-28) **The cookie-reuse ranking covers PoW cookies, not just CAPTCHA ones.** A client riding one proof-of-work solve fired no events and was invisible everywhere -- precisely the shape of a scraper that found the cheap door. The PoW ranking carries a JA4 count: shared egress shows many fingerprints, one client riding one solve shows exactly one. PoW rows are kept 8 days.
- (2026-07-28) **An abandoned challenge says whether the visitor went back or left for good.** Browsers refuse to say which gesture ended a page, but the server can tell: each departure row reports whether the same client sent anything within 30 seconds, shown as **stayed** or **gone**.
- (2026-07-28) **The abandonment report separates when the visitor left from when unmask noticed.** A handler delayed by the PoW holding the main thread recorded a mid-solve departure as "left right after passing". The beacon now carries the browser's own event time (`left_at_ms`) and the delay.
- (2026-07-28) **You can see visitors who gave up on the challenge instead of failing it.** A visitor who closed the tab mid-challenge left no trace, indistinguishable from a bot that never ran the JavaScript. The page now reports an `abandon` phase with the step and the wait, via `pagehide` + `visibilitychange`; a successful pass suppresses it. Grey in the hunt log, with a filter.
- (2026-07-28) **Link-preview bots that are ordinary in Japan pass out of the box.** The upstream crawler list carries Slack / Twitter / Discord unfurlers but not Chatwork, so links pasted into chat rendered bare. unmask ships a supplement (Chatwork LinkPreview, WebexTeams, NotionEmbedder), switchable per pattern like any other.
### Fixed
- (2026-07-28) **`unmask doctor` reported nginx config as out of date when it was not.** The freshness check re-rendered without the hub-pulled crawler ranges, so every node that had ever pulled them was told it was stale. It now renders the same way, and a genuine difference names the line on each side.
- (2026-07-28) **Previewing a site from the test page looped on the PoW forever.** The post-pass redirect's site-segment pattern disallowed dots while the router accepted them, so `/unmask/challenge/shop.example.jp/` reloaded itself: solve, challenge, solve. The two patterns now agree.
- (2026-07-28) **A per-site logo could be saved correctly and still look like it had failed.** The thumbnail was fetched from the host-resolved route, so editing site A on host B got a 404. It now uses the site-scoped route.
- (2026-07-28) **The scope picker did not show the host you had just added.** The scope being edited is now always in the list, normalized the same way the save handler normalizes it.
- (2026-07-28) **A per-site save whose override toggle was off reported success while discarding the form.** When the disabling script had not run, the values -- logo upload included -- were thrown away behind a "saved" banner. That save now stops and says what to do.
## [0.1.12] - 2026-07-28
### Fixed
- (2026-07-27) **The header-integrity axis works behind a TLS-terminating load balancer.** It required HTTPS on HTTP/2·3 as seen by nginx itself, and an LB re-originates the backend hop as plain HTTP/1.1 -- so on LB-fronted deployments it never fired, silently (5000/5000 backend requests HTTP/1.1). With a trusted LB configured it now keys off `X-Forwarded-Proto`; within minutes it was catching spoofed-Chromium scrapers.
### Added
- (2026-07-27) The overview's challenge-serves / PoW-pass / CAPTCHA-pass KPI tiles now carry the same colours as the "all requests" chart's buckets (a legend dot in the label, a darker matching tone on the number), so the tiles read as that chart's buckets at a glance and the overview, dashboard and stats views speak one colour language.
- (2026-07-27) **The stale-library warning fires only when the running code actually differs from what is on disk.** Reinstalling the same build leaves an unlinked mapping whose bytes are current; four of seven fleet hosts were that. Each candidate is now compared against the current file.
- (2026-07-27) **unmask warns when a reload cannot apply what you changed, because nginx is still running on replaced libraries.** `nginx -s reload` does not re-exec the master, so after a library upgrade fresh workers are forked from a broken image and can die with SIGSEGV. `doctor` and `render-nginx` now read the master's memory map, name the replaced files, and point at `systemctl restart nginx`.
- (2026-07-27) The escalation axis is now visible *per row*, not only as totals: the CAPTCHA pass report's recent-passers list shows each pass's force reason, and the CAPTCHA-failure IP ranking lists the distinct axes behind each IP's failures. Both cards read as aggregate pills on top plus the axis on every row.
- (2026-07-27) The CAPTCHA pass report and the CAPTCHA-failure IP ranking gain a by-axis breakdown (which escalation reason the solved / failed CAPTCHAs came from). Read together they show each axis's CAPTCHA effectiveness: a bot-facing axis should pile up failures; a pile of *passes* on one axis means a solver is walking through it.
- (2026-07-27) The bot-hunt raw log shows each event's escalation reason in its own column next to phase (violet badge; the axis is an attribute of the visit, not a phase) and can filter by it — so "show me everything the header-integrity axis escalated" is one click.
- (2026-07-27) The challenge funnel gains `header` / `asn` / `geo` pseudo-rows (alongside the existing `rate_limit` one), so you can see the *pass rate* of a header-integrity / ASN / country challenge — i.e. that axis's false-positive rate — not just its count. The challenge page now carries the force reason through every phase beacon, so the whole serve→load→pass chain is attributed to the axis that raised it.
- (2026-07-27) The "Forced CAPTCHA (reason breakdown)" dashboard card now attributes challenges raised by the header-integrity axis (`header`) and by the ASN / country filters (`asn` / `geo`) instead of folding them into the generic `none` bucket — so an operator can see those axes actually firing. A rate overage still shows as `rate_limit`; the new buckets are the *direct* (non-rate) challenges.
- (2026-07-27) The header-integrity axis (Sec-CH-UA absence) can now escalate to `pow_then_captcha`, not only `pow_only` / `captcha_only` — a stronger, still-clearable challenge chain for operators who want it. `deny` stays unavailable for this axis (a stripped header is a legitimate state).
### Changed
- (2026-07-27) The challenge funnel's per-row rate is now a **pass rate over everyone served** (`bv_total / serve`, replacing the old PoW-only rate): of everyone an axis sent to a challenge, how many ultimately came out with a pass cookie. The denominator includes clients whose JS never ran — mostly bots — so the number reads low on bot-facing rows and high on human-facing ones, which is exactly its job: a proxy for that axis's false-positive rate. The CAPTCHA rate stays `captcha_passed / captcha` (solver detection, an orthogonal question).
## [0.1.11] - 2026-07-27
### Added
- (2026-07-27) **Verify a claimed crawler by reverse DNS, not just its User-Agent.** Googlebot / Bingbot / YandexBot / Applebot / Baiduspider claims can be confirmed by PTR -> vendor domain -> forward-confirm. Asynchronous (no request latency), skipped when a range preset already covers the crawler, toggleable per crawler on the Bypass-IPs tab.
- (2026-07-27) **Throttle a network or country instead of blocking it.** ASN and country rules gain a per-minute rate; the rule's action applies only to the overage. Native renders one `limit_req` zone per throttled entry with verified crawlers exempt; forward-auth counts live.
- (2026-07-27) **Exempt a path from the country or ASN decision only.** RSS/Atom feeds are pulled from datacenters a country or ASN policy would sweep up. A per-axis exempt path drops just that axis while every other check keeps running.
- (2026-07-27) **Challenge a Chromium browser that is missing its client-hint header (opt-in).** A UA claiming Chrome / Edge / Opera that carries no `Sec-CH-UA` over HTTPS on HTTP/2·3 is flagged -- a real Chromium always sends it there. Never touches plain HTTP, HTTP/1.1, Firefox / Safari; clamped to a challenge. Off by default.
- (2026-07-25) **`unmask doctor` warns when config.yml has been hand-edited without re-rendering.** A direct edit does nothing until `render-nginx` and a reload (a factor in the 2026-07-21 incident). doctor compares the loaded conf against a fresh render, ignoring the per-render stamps.
### Changed
- (2026-07-27) **The stale-browser tier's lag is set per browser.** Chrome and Firefox thresholds are now independent, each defaulting to the built-in value.
- (2026-07-25) **Block or challenge traffic by network (ASN), not just by country.** Challenge or deny AS16509 (Amazon), AS14061 (DigitalOcean) or a bulletproof host while the residential ISPs of the same country pass. Same actions as the country axis, both modes, evaluated after the bypass-IP check so Googlebot / GPTBot are never caught. Requires an ASN mmdb.
### Fixed
- (2026-07-25) **Hover popovers no longer vanish mid-hover and go dead until you leave and re-enter.** The watchdog derived the trigger by hit-testing the entry point, so moving off a child inside the cell dismissed the popover. Every hover popover now names its real trigger element (21 call sites).
## [0.1.10] — 2026-07-25
### Changed
- (2026-07-25) **Both deploy modes share one daemon upstream, `upstream unmask_daemon`, in upstream.conf; the down hook is `@unmask_daemon_down`.** Native said `upstream unmask`, forward-auth kept the legacy `unmask_admin`. No aliases are kept. **Forward-auth upgrade note:** an edited `server.inc` or a custom `@unmask_admin_down` override must move to the new names; `nginx -t` points at leftovers.
- (2026-07-25) **FHS cleanup: the nginx module survives an immutable /usr, the Apache Lua payload moved out of /etc, the vts example to /usr/share/doc.** On a read-only /usr the placer falls back to `/var/lib/unmask/plugin/` instead of failing silently. `/etc/httpd/unmask.lua` is now a symlink to `/usr/share/unmask/snippets/`; documented paths keep working.
- (2026-07-25) **SQLite page cache raised from 20MB to 128MB.** Production event databases run multi-GB, where a 20MB cache thrashed on every cold hunt / stats scan; 128MB is still modest next to the existing 256MB mmap window and the OS reclaims it under memory pressure.
### Fixed
- (2026-07-25) **Saving settings from the web UI no longer deletes config sections owned by other programs.** `feed_server` and any unrecognized top-level key were silently dropped by the next save. They are now carried over verbatim under a "not managed by unmask" banner.
- (2026-07-25) **The forward-auth decision endpoint no longer rebuilds its bypass matchers on every request.** The cache key was the address of a per-call copy that never matched, so every `/api/check` re-assembled ~100 matchers under a lock. It now keys on the settings snapshot.
- (2026-07-24) **The test-page site picker no longer serves a challenge that can't be passed, and shows the previewed site's logo.** The preview embedded the site's PoW difficulty but the host verified it, so a differing site looped forever; the difficulty now stays the host's. A site-scoped `/branding/<site>/logo` route serves the logo.
## [0.1.9] — 2026-07-24
### Added
- (2026-07-23) **The test pages can exercise a specific site's challenge end-to-end.** The admin test page gains a Site picker that serves the force-PoW / force-CAPTCHA pages via the site-scoped route, while the pass cookie stays bound to the host you are on. An opt-in shows the picker on the public test pages too (off by default: it reveals which sites have custom settings).
- (2026-07-23) **The admin tells you when the daemon cannot write its own database.** Challenges keep serving on a read-only DB while stats and events record nothing -- the classic cause is a root-owned `unmask.sqlite` from `unmask migrate` as root. The retention tab runs a live write probe from inside the daemon, and `doctor` gains the matching check.
### Fixed
- (2026-07-23) **The setup-wizard token moved from /etc/unmask to /var/lib/unmask.** Transient state, not configuration. The old path is still read as a fallback so a mid-setup upgrade finishes unchanged.
- (2026-07-23) **Per-site challenge logos actually work, and uploaded logos moved out of /etc.** Every upload was written to the same file, so a site's logo silently replaced the Default's. Each record now gets its own file under `/var/lib/unmask/branding/`; existing configs keep working.
## [0.1.8] — 2026-07-22
### Added
- (2026-07-22) **The UA-filter checkbox and the Bypass-IP range presets are independent axes, and the UA-filter tab shows each range-backed crawler's true rescue path.** One tab's state used to change the other's behaviour invisibly. Now the UA checkbox means "pass by UA string" and the range presets mean "pass by vendor IP"; a live 4-state badge (IP-verified / UA+IP / UA only / no rescue) shows the effect before saving. No install's behaviour changes until an operator saves the tab.
- (2026-07-22) **The AI-crawler drill-down shows that a range-verified crawler's "served" count is spoofed traffic.** Genuine Googlebot is rescued by range before it can be challenged, so what remains under its UA is forgeries. Such rows carry a 🛡 badge and the column is renamed "challenge". Display-only.
- (2026-07-21) **The stale-browser tier's current-major baselines auto-follow an unmask.sh feed.** unmask.sh aggregates the vendors' version feeds daily into one document; each install subscribes and resolves operator-pinned first, else the newer of hub and built-in -- a hub outage can never drag a baseline below what the binary shipped with. The tab's fields are now explicit automatic / manual.
- (2026-07-22) **Bot-hunt rankings filter the raw log in one click, gain a User-Agent search, and hide once a filter is active.** Each IP / JA4 / UA row folds a 🔍 into its count; a UA substring box pulls every request carrying a spoofed crawler string. Rankings hide under a value filter because they are window-wide and would quietly disagree with it.
- (2026-07-21) **The stale-browser tier covers Firefox too.** Same lag over Firefox's built-in baseline; the current Firefox ESR major is always exempt (it legitimately trails by up to ~15 majors). Safari stays out of scope: its numbering jumped and is pinned to the OS.
### Fixed
- (2026-07-22) **A protected path's mode actually picks the challenge screen it serves.** The mode was rendered into the nginx map but never drove the page, and forward-auth passed no mode at all. Both now follow it. Admin HTML also gains `Cache-Control: no-store`, so a redeployed dashboard appears on the next load instead of after a hard reload.
- (2026-07-21) **Saving a settings tab no longer silently rewrites settings you did not touch.** A browser-faithful no-op save on every tab flushed out a family of bugs: the geo tab's save had never worked (400), the ua-filter save forced `challenge_targets.all` off, and every default-action dropdown persisted its display fallback -- which pinned `pow_then_captcha` across the fleet. Dropdowns now offer an explicit "(unset)" that round-trips, and blank number fields follow the default.
- (2026-07-21) **The UA black-list's default action no longer leaks onto every challenge.** It applied to every challenge the native page served, so saving anything on the ua-filter tab walked current Chrome into the CAPTCHA leg meant for black-listed UAs. It now fires only on an actual challenge-target match; forward-auth honours the same setting.
## [0.1.7] — 2026-07-21
> **Upgrade note**: crawler UA patterns whose vendors publish official IP ranges (Google, Bing, DuckDuckGo, OpenAI, Perplexity, Apple, Amazon) are **no longer rescued by their User-Agent string alone** once the matching IP-range presets are enabled. Genuine crawlers keep passing — their published ranges are folded into the bypass-IP allowlist (on by default) — but a spoofed Googlebot from outside Google's ranges now gets the challenge. For installs upgrading from 0.1.6 or earlier this applies immediately to the vendors whose presets shipped in 0.1 (Google, Bing, DuckDuckBot, OpenAI, PerplexityBot); the four presets added in this release (Applebot, Amazonbot, DuckAssistBot, Perplexity-User) stay off until reviewed in settings (NEW badge). To restore the old UA-only behavior for a vendor, disable its IP-range preset on the Bypass IPs tab — that vendor then falls back to UA-only rescue, exactly as before.
### Changed
- (2026-07-21) **The admin dashboard is dramatically faster on large multi-site installs.** The 30-day charts scanned the full per-site per-minute history on every load; they now read hourly install-wide rollups a background pass maintains. A stats page that took six to eight seconds returns in about two.
- (2026-07-20) **The stats page's rate-limit card** now lists the top 30 source IPs and the top 30 hit paths (each with its query-string breakdown), collapsed to 10 rows apiece behind a show-more expander so the common case stays compact.
- (2026-07-16) **Security: a spoofed crawler User-Agent no longer walks through the challenge.** During the 2026-07-15 incident a 94k-IP botnet crawled with a fake `Googlebot` UA and was waved through on the string alone. For range-publishing vendors the UA patterns are dropped from the rescue and the official IP ranges carry it; if a vendor's preset is disabled it reverts to UA-only rescue, never to "no rescue".
- (2026-07-16) `PresetIsNew` / `VersionLess` now compare the patch segment, so presets added by a patch-step release (v0.1.6 → v0.1.7) correctly show the NEW badge and stay off until reviewed; previously only major.minor were compared and a patch-added preset silently skipped the review gate. Preset `AddedIn` values are now written in full `vMAJOR.MINOR.PATCH` form (a present segment must be numeric — no lenient fallback), and the settings UI's "since" labels show the same 0.0.1-granularity version.
### Added
- (2026-07-19) **A browser pinned to an outdated Chrome version can be escalated to a CAPTCHA (off by default).** A scraper across thousands of residential IPs solved the PoW headlessly; its one tell was a frozen `Chrome/139` against stable 150. The stale-browser tier escalates a Chromium-family major at least N behind current stable (`stale_browser_lag`, default 10). Firefox / Safari untouched; bypass IPs and crawlers keep their rescue.
- (2026-07-19) **Stats-exclude IPs can carry a title, like the bypass list.** Monitoring probes and fleet addresses become unidentifiable months later. Titles are stored in a parallel array; existing configs load unchanged.
- (2026-07-16) **Four new official IP-range presets**: Apple (Applebot), Amazon (Amazonbot), DuckDuckGo (DuckAssistBot) and Perplexity (Perplexity-User). On by default for fresh installs; existing installs enable them from the NEW-badged rows.
- (2026-07-16) `unmask doctor` now checks crawler IP-range freshness: when UA rescue is riding the vendor ranges, it warns if the synced range files are missing (never synced from the hub) or older than 30 days — a stale snapshot can eventually challenge genuine crawlers arriving from newly added vendor IPs.
### Fixed
- (2026-07-20) **The Community Bans tab no longer hangs on first load.** The 30-day impact figure walks every serve row through the ban matcher; it now computes in the background behind a spinner.
- (2026-07-20) **The retention tab no longer times out on a multi-gigabyte database.** The stored-event count is an O(1) id-range estimate ("≈"), and a figure that cannot be read shows "??" rather than a misleading zero.
## [0.1.6] — 2026-07-15
> **Upgrade note**: this release fixes a critical connection leak in the nginx module's compose flow (0.1.5, nginx 1.17.6+). After upgrading, **restart nginx** (`systemctl restart nginx`) — a plain reload does not load the replaced module, and any connections already orphaned by 0.1.5 are only released by a restart.
### Fixed
- (2026-07-15) **Compose mode leaked every challenged connection, exhausting nginx's fd budget and taking the node down within hours.** The ACCESS-phase handler returned `NGX_DONE` from an internal redirect without finalizing, so each challenged connection was orphaned holding its fd until `worker_connections` ran out and every port timed out. All three native production nodes went down within ~2 hours. The redirect is now paired with `ngx_http_finalize_request(r, NGX_DONE)`.
## [0.1.5] — 2026-07-14
> **Upgrade note**: this release updates the nginx module (`ngx_http_unmask_module.so`) for the first time since 0.1.2. After upgrading the packages, **restart nginx** (`systemctl restart nginx`) — a plain reload does not load the replaced module and can leave nginx running a config/module mix. On nginx 1.17.6+ the rendered config also switches from the classic to the compose rate-limit flow automatically at the next render; no configuration change is needed, and older nginx keeps the classic flow.
### Fixed
- (2026-07-14) **Security: on AlmaLinux / RHEL 8 the nginx plugin read the wrong request fields, and on a compose-mode host protected locations let every bot through.** Those distributions patch `ngx_http_headers_in_t`, shifting every later struct member by eight bytes with no signature change. `r->main` read as NULL, so protected locations served bots straight through and solved cookies were never found. The plugin now reaches request state only through nginx's own entry points.
- (2026-07-14) **On a SELinux-enforcing host without `semanage`, native mode silently recorded zero events; and every daemon restart rewrote inherited honeypot bans as hard `deny`.** The postinstall's SELinux block was gated on `semanage`, absent on minimal RHEL 8; it now falls back to `chcon` plus a boot drop-in. Separately, the per-source action resolver was registered after the first ban-file flush, so on every restart challengeable bans became 403s. Seeded before `Start()` now.
- (2026-07-14) **A honeypot probe on the plaintext port no longer earns a whole-IP auto-ban.** With `https_redirect` on, a `:80` trap hit produced a fingerprint-less log line and an `ip_only` ban broader than the precise one its HTTPS visit earns. Redirect-only lines are left to the HTTPS path.
- (2026-07-10) **The admin no longer redirect-loops when the CSRF cookie expires before the session (~30 days after login).** The backfill 303-redirected to the same URL, and Chrome withholds `SameSite=Strict` cookies across a cross-site redirect chain, so the loop never terminated. The cookie is issued and the page rendered directly; the CSRF cookie becomes `SameSite=Lax`. Workaround until deployed: open `/unmask/admin/login` and log in again.
- (2026-07-09) **The stats and bot-hunt pages no longer get slower as events accumulate.** SQLite ships no planner statistics until `ANALYZE` runs, so range queries scanned whole indexes. New `unmask db-analyze` builds them (`migrate` does so on a small DB; `doctor` warns when missing). Bot-hunt IP ranking 1.5s -> 5ms. MariaDB unaffected.
- (2026-07-09) **The nginx module no longer floods `error.log` with "uninitialized unmask_compose variable" warnings.** The variable is registered with a default handler resolving to "off".
- (2026-07-08) **Forward-auth mode bypasses the challenge for stats-exclude IPs, matching native -- without making them unbannable.** A monitoring probe was challenged on the forward-auth node but passed natively. The ban allowlist stays narrower: stats-exclusion is a dashboard filter, not a ban-policy grant.
### Added
- (2026-07-14) **The dashboard shows what the Community Bans feed actually stopped.** A new card reports the traffic hub-derived bans blocked over 30 days, from a cached background pass.
### Changed
- (2026-07-14) **The bot-hunt log lets you pick how many rows to show**, and the challenge page's "protected by unmask" credit now sends Japanese visitors to the Japanese landing page instead of the English one.
- (2026-07-10) **The stats page's data-source badge explains itself, and compose mode is gated on nginx 1.17.6+.** "from access log" with a popover per mode. Compose needs `limit_req_dry_run` and `limit_req_status` (1.17.6), and a deny zone on older nginx used to emit a directive that fails `nginx -t`. The daemon now probes `nginx -v`; `nginx.rate_compose_mode` (`auto` / `always` / `never`) overrides.
- (2026-07-08) **`unmask events` prints a `reason=` column** (`rate_limit` / `ja4_bot` / `honeypot` / `banned` / `protected` / `test` / `none`), so a rate-limit block is distinguishable from a baseline challenge at the CLI. `force_reason` is now populated consistently across the SSE, paged and `--ref` endpoints.
## [0.1.4] — 2026-07-08
### Added
- (2026-07-05) **Stats-exclude "private networks" preset.** Drops RFC1918 / loopback / link-local from statistics in one click. Off by default on purpose: stats-exclude also bypasses the challenge, so an intranet site would lose protection.
- (2026-07-04) **`unmask doctor` probes :80 as a load-balancer health checker and warns on a redirect.** A 301 to a health check is a failed check, so the LB drops the node. Fires only when the health-check exemption was turned off.
### Changed
- (2026-07-05) **Custom IP / host list fields are structured rows, not newline textareas.** Admin-IP, admin-host, metrics, stats-exclude and defined-sites lists become add/delete rows.
- (2026-07-04) **Network-tab wording fixes.** The IP-geo "not loaded" hint pointed at the wrong side ("the button on the right" -> "on the left"; the download button is to its left), and the trusted-LB custom-row placeholders now localize (the technical tokens CIDR / X-Client-JA4 stay as-is).
- (2026-07-04) **The last row of a custom rule list can be deleted.** An empty list is a legitimate state; the Add button starts a new one.
- (2026-07-04) **The HTTPS-redirect exemptions now use the same preset / custom two-badge layout as the trusted-LB section**, and the trusted-LB custom list no longer opens with a blank row by default (click Add to insert one).
### Fixed
- (2026-07-05) **The "already BANned" pill was clipped in the hunt table.** The 4rem actions column was too narrow for the ~6rem pill; widened, and the label moved to i18n.
## [0.1.3] — 2026-07-04
### Fixed
- (2026-07-04) **The theme-tab preview showed the actual site instead of the challenge for operators who had already passed it.** The roaming-rebind guard did not exclude `_preview`, so a valid `_bvj` cookie navigated the iframe to the site.
- (2026-07-04) **The over-block banner showed a literal `—` entity.** The banner renders through the escaping template pipeline (unlike the safeHTML popovers), so the HTML entity was shown verbatim; use the literal em dash.
### Added
- (2026-07-04) **Configurable HTTPS-redirect exemptions (`nginx.https_redirect_exempt`).** Two presets ship on: ACME HTTP-01 (by path) and load-balancer health checks (by user-agent: `GoogleHC` / `ELB-HealthChecker` / `kube-probe` / Azure). An un-exempted 301 is a failed health check that drops the node -- how `https_redirect` on a GCP-fronted node stopped recording.
- (2026-07-04) **A preview-language selector on the theme settings tab.** All 18 shipped locales; honoured only in a preview context, so a real visitor's detection is unchanged.
- (2026-07-04) **`unmask doctor` warns when `nginx.https_redirect` is enabled but the rendered `server.inc` has no 301 block** (the setting was turned on without re-rendering) — the same stale-render class as the existing bv_secret check.
## [0.1.2] — 2026-07-03
### Added
- (2026-07-03) **HTTP -> HTTPS redirect option (`nginx.https_redirect`, off by default).** Emitted before the ban / honeypot / challenge gates, since a no-TLS request carries no JA4. Keys off `X-Forwarded-Proto` behind an LB; exempts ACME HTTP-01.
- (2026-07-03) **Bypass-path presets declare per-preset factory defaults.** Machine-access presets (ACME, robots.txt, health checks) default on; anything that could be a protection target stays off. The config stores only deviations.
- (2026-07-03) **A guard test for undefined `$unmask_*` template variables.** nginx expands an undefined variable to the empty string without erroring, so a map removed while its uses remain silently no-ops the feature that referenced it — `nginx -t` and the render tests both stay green. The new test cross-checks every referenced variable against the template and C-module definitions.
### Fixed
- (2026-07-03) **The access-log parser dropped `hpuri` and `ua` on TLS-resumption lines.** `$effective_ja4` renders as `-` with no fingerprint, and the parser's charset refused it, un-anchoring every later field. The placeholder is now accepted.
- (2026-07-03) **The events table stacked a native tooltip on top of the datetime popover.** The shared datetime formatter sets a local/UTC `title` on every cell; the popover shows the same detail, so the title is stripped on cells that carry it. The popover also gains Host / Port rows.
- (2026-07-03) **The sites tab implied DEFINED acceptance mode limits recording.** Reworded: events are recorded for every Host in either mode — the mode only affects display (picker suggestions and ghost detection).
## [0.1.1] — 2026-07-02
### Fixed
- (2026-07-02) **A reinstall could leave the daemon running the old, deleted binary, causing an admin redirect loop.** The postinstall now ends every init path with a final-guarantee restart, so upgrade / reinstall / remove-install always converges on the new binary.
- (2026-07-02) **`protect.inc` included at server scope self-DoS'd the site.** The gate's rewrite also caught the `/unmask/` machinery, so the challenge could never complete and every human looped while bots still got 403. `protect.inc` now exempts the machinery, and `doctor` warns on a server-scope include.
- (2026-07-02) **The plugin postinstall printed `unrecognized libcrypto` on nginx built against a statically-linked OpenSSL.** Detection now falls back to `nginx -V` and then to the newest system libcrypto.
- (2026-06-19) **`ja4_mismatch` false positives from HTTP/2 <-> HTTP/3 drift on roaming rebind.** A browser presents a different JA4 over QUIC than over TCP, so `_bvj` refused rebinds on the other transport -- daily, and none were bots. `_bvj` now stores up to 3 JA4s and matches any.
- (2026-06-19) **Stats queries warn instead of returning a silent zero on timeout.** A 0 reads as "no traffic", not "could not compute". Cards now show a warning or "—".
- (2026-06-18) **A roaming `_bvj` whose fingerprint had drifted stranded the holder on repeated PoW.** It was never re-minted, so each roam failed the JA4 / UA match. It now re-mints whenever the stored fingerprint no longer matches.
- (2026-06-15) **The Daily-Unique-IPs card timed out on large databases.** It merged ~65k per-minute HLL sketches in Go (~15s); they are now pre-merged hourly, so the card reads ~720 sketches plus a short tail.
- (2026-06-15) **Two `nginx -t` failures from unmask's rendered config.** The variable count exceeded nginx's 1024 default (now `variables_hash_max_size 2048`), and the community-bans `map_hash_*` directive was a fatal duplicate on Alpine 3.23's stock conf, silently disabling native mode. Both guarded.
- (2026-06-13) **Several setup-wizard papercuts.** Enter no longer triggers Skip, passwords are match-checked client-side, a duplicate username is rejected at the user step, and errors are localized.
- (2026-06-13) **Admin authentication hardening.** Session signature widened to 128-bit, the setup password is argon2id-hashed the instant it is submitted, reset-token consumption is atomic so a token cannot be redeemed twice, and an unknown role denies instead of admitting everyone.
- (2026-06-13) **Data-integrity and input-sanitization hardening.** A `UNIQUE` index on user email, manual bans reject `|` and newline so they cannot smuggle a ban-file line, operator paths are sanitized before entering an nginx `include`, logo upload is atomic, and feed-URL logging strips userinfo.
- (2026-06-13) **The challenge primitives are hardened against replay and overflow.** The CAPTCHA math token is IP-bound with a 15-minute window; the auto-generated `_bv` secret persists to a `0600` sidecar so `render-nginx` and the daemon share one key; a native int64 overflow on 19-digit input is removed; the rate-limiter uses the monotonic clock with a hard map cap.
- (2026-06-13) **The admin web surface is hardened against XSS and cookie leakage.** Uploaded SVG logos ship a `sandbox` CSP, the challenge page's preview overrides require a same-origin `/admin/` referrer, the flash cookie carries `Secure`, and payload extraction is escape-aware.
- (2026-06-13) **A manual ban could silently widen a honeypot ban into a
JA4-wide block (DB-3).** The ban list keyed UNIQUE(ip, ja4), so manually
banning an (ip, ja4) that a honeypot had already auto-banned overwrote the
existing row — including its scope. An `ip_ja4` ban rewritten to `ja4_only`
silently expands "this one device" into "every IP presenting this JA4", the
exact ranking accident the search-bot rescue (CLAUDE.md #4) guards against,
with no signal to the operator. Scope now joins the conflict/UNIQUE key so a
honeypot `ip_ja4` ban and a manual `ja4_only` ban on the same (ip, ja4)
coexist as separate rows (the native plugin already matches each scope
independently). Existing databases migrate in place, rows preserved.
- (2026-06-12) **The forgot-password endpoint is now rate-limited like
login (AUTH-5).** The per-IP admin-login zone (5/min, CAPTCHA on trip)
covered only `/admin/login`, leaving `/admin/forgot-password` open: a flood
could spam reset emails and clobber a pending reset token (each request
overwrites the previous), blocking a legitimate reset. Both auth-credential
endpoints now share the one zone.
- (2026-06-12) **An operator could be locked out of changing their own
password (AUTH-4).** The profile form hard-capped new passwords at 72 bytes
(a bcrypt leftover) while every other path caps at 1024 (argon2), so after an
operator reset another user's >72-char passphrase they couldn't set their own
through the profile form. Both paths now share `user.MaxPasswordLen`.
- (2026-06-12) **A search/AI crawler landing on a honeypot URI could be
auto-banned, and a freshly added bypass IP was ignored until restart (M-3 /
DB-4).** The ban manager's bypass allowlist was a literal-IP map snapshotted
at startup — it held no preset crawler ranges (Googlebot / Bingbot / GPTBot)
and no CIDRs, and never refreshed. So a crawler range tripping a honeypot got
banned (the exact ranking accident the search-bot rescue exists to prevent),
and toggling a preset / adding a bypass IP in the admin UI didn't protect it
until the next restart. The allowlist is now the same preset+CIDR matcher the
native geo block and forward-auth path use, injected over live settings so a
bypass change applies immediately.
- (2026-06-12) **The placer's verify fail-safe no longer strips a healthy
native module over an error that merely mentions an unmask path.** The
"is this nginx -t failure unmask's fault?" classifier matched any error
containing `unmask` — including an OPERATOR vhost whose
`include /etc/unmask/forward-auth/server.inc` (the documented forward-auth
wiring) pointed at a file that did not exist at that instant. That is
exactly the mid-transaction state on a remove→reinstall: the plugin's
postinstall verify runs BEFORE unmask-web-nginx lays its files back down,
so the fail-safe deleted the just-placed `.so` and autoload conf — and the
strip could not even fix the error it reacted to (the operator's include
stays). Found on the install matrix (AlmaLinux 8 carried such a vhost).
The classifier now keys on unmask's OWN wiring artifacts only
(`ngx_http_unmask*`, `00-unmask*`, `50-mod-unmask.conf`,
`/var/lib/unmask/nginx/`); anything else is a host config error that the
operator (or the rest of the same package transaction) resolves.
- (2026-06-12) **The module placer no longer duplicates an
operator-managed `load_module` — which used to escalate into the fail-safe
stripping a healthy module.** `place-module.sh` (run before every nginx
start via the service drop-in) dropped its `50-mod-unmask.conf` autoload
unconditionally whenever a main-scope include dir existed. If the operator
already loaded the module themselves — a hand-added `load_module` line in
nginx.conf, or their own conf file in that include dir — nginx saw the
directive twice, `nginx -t` failed with "module is already loaded", and the
placer's verify fail-safe classified that as an unmask-caused breakage and
stripped the module wiring **including the placed `.so`**, leaving the
operator's own (now dangling) `load_module` pointing at a deleted file:
nginx could not start at all. Caught by the 9-distro install matrix (every
rpm/deb distro's native mode failed this way once the ExecStartPre re-pick
ran on restart). The placer (and the slim plugin postinstall) now detect a
foreign `load_module ngx_http_unmask_module` in nginx.conf or any other
conf in the include dir, skip dropping their own, and remove a stale drop
of ours — self-healing an already-duplicated setup on the next start.
Overwriting our own previous drop (the normal re-pick path) is unaffected.
- (2026-06-12) **A headless browser could clear the behavioral check
without ever seeing the math fallback.** The behavioral score penalized a
short `mouseTrail` by a flat -0.3, so a headless Chromium (Playwright /
Puppeteer) that reports `hasMouseEvents=true`, a non-zero `windowSize` and an
unhurried `clickAt` scored 0.7 and passed on behavioral signal alone — its
`.click()` synthesizes the click without the human mousemove run, so the
trail is just the single click coordinate. A trail of ≤1 point (mouse events
claimed, yet no actual movement) is now penalized -0.6, dropping that score
to 0.4 so the math fallback engages; a merely short trail (2-4 points, a fast
but real cursor move) keeps the soft -0.3. The fallback is math, not denial,
so a fast human with a near-empty trail solves an addition rather than being
locked out, and a human-like trail is unaffected.
- (2026-06-12) **The cookie_minute v1→kind/cnt migration is now safe to
re-run, so an interrupted MariaDB upgrade can't double historical stats.**
The copy INSERTs run in a transaction, but the table rename and the final
`DROP TABLE …_v1` are DDL, which auto-commits OUTSIDE that transaction on
MariaDB — so a dropped connection in the gap between COMMIT and DROP left the
v1 table in place, and the next startup re-ran the copy on top of the
already-committed rows, doubling every cookie_minute bucket the dashboard
aggregates. The copy now clears the destination inside its transaction
before re-inserting (safe: the migration runs at startup before the daemon
serves, and Migrate aborts on error, so a lingering v1 means the table holds
only migration output). Covered by a re-run test that doubles the rows
without the guard.
- (2026-06-12) **The English community-bans "not applied" tooltip no
longer shows raw `%d` / `%s`.** Two catalog strings
(`community_bans.below_threshold_title` / `reports_only_title`) carried
`fmt.Sprintf` placeholders, but the badge renders them through the plain
(non-formatting) `t` template helper, so English readers saw the literal
`currently %d` and a bogus `href="%s"` link in the hover popover; the
Japanese strings were already placeholder-free. Rewrote the English to be
self-contained. A new locale test (`TestLocaleFormatVerbParity`) now fails
the build if any key's ja/en strings carry mismatched format verbs, so this
class of drift can't return.
- (2026-06-12) **The live settings hot-swap is race-free.** A request in flight during a save could observe a torn settings struct and mis-evaluate a decision. It is now an `atomic.Pointer`, tested under `go test -race`.
- (2026-06-11) **A config that omits secret.bv_secret no longer passes
doctor while silently breaking the site.** Load() fills an empty bv_secret
with a per-process random key that is never persisted, so render-nginx and
the daemon sign / verify _bv with different keys and every visitor loops on
the challenge — yet `unmask doctor` checked the post-Load value (a
healthy-looking 24-byte string) and reported a false green. Load() now logs
a loud WARNING when it has to fabricate the key, and doctor reads the RAW
config so a missing secret is an [ERR], not an [OK]. Only hand-rolled
configs are affected (package install runs config-init; docker persists one).
- (2026-06-11) **A fresh box no longer contacts the hub before setup is
finished.** community-bans register / pull and the managed-mmdb auto-fetch
fired on the first daemon start whenever a config path was set — before the
operator had opened the install wizard — so an unconfigured box POSTed its
public IP + version + publish-country flag to unmask.sh/api/feed/register,
and an air-gapped box logged alarming register / fetch failures, both
contradicting the README's opt-in framing. All three are now gated on setup
completion (an admin user existing); the wizard's post-completion auto
re-exec starts them on the next boot. The default subscribe mode is
unchanged — only the timing of the first contact moves to after the operator
has actually set the box up. Verified: a DB-connected box with no admin user
makes zero hub calls; creating the admin user and restarting starts them.
- (2026-06-11) **Event writes are no longer dropped on a transient DB
error.** The async event flusher logged an insertBulk failure and then
cleared the batch, permanently losing those unmask_event rows on a brief
SQLite-busy or MariaDB blip. It now retains the batch and retries on the
next tick (matching nginxlog's flushOnce), bounded so a persistent outage
can't grow it without limit — overflow drops the oldest events and counts
them in a droppedOnError metric kept distinct from the queue-full drop
counter.
- (2026-06-11) **Saving settings on a dev / source build no longer
NEW-badges every preset and drops them from the rendered conf.** Every
settings save stamps `seen_version: v<admin version>`; dev builds carry a
git hash there (`v6f94983`), which the version parser mapped to v0.0
(= oldest). All preset groups (AddedIn >= v0.1) then counted as
"not yet seen": forced-off NEW checkboxes on every preset tab (re-saving
would wipe the enabled list), and enabled presets silently skipped at
render time — the JA4 verdict map rendered empty and honeypot /
bypass-path preset patterns vanished from http.inc. An unparseable
seen_version now means "runs tip" (= nothing is new) at all 10 gate
sites (`PresetIsNew`), and saves keep the previous seen_version unless
`v<version>` parses as a release number.
- (2026-06-10) **Web Bot Auth actually works in native mode.** Three fatal flaws in the signed-route -- a header gate that also fired in the verification subrequest, a phantom proxy endpoint, and a filesystem `try_files` on proxied vhosts -- meant the daemon was never consulted (the dlvr.it incident). Redesigned around `@unmask_signed_continue`; a daemon outage degrades to the normal challenge. New `web_bot_auth.allow_private_networks`.
### Added
- (2026-06-28) **Apache forward-auth captures and LB-gates a forwarded JA4, at parity with nginx.** Nothing verified that a forwarded `X-Client-JA4` came via a trusted LB, so a direct client could forge one. The Lua hook forwards the raw TCP peer, and the daemon honours the JA4 only when that peer is a configured trusted LB. The never-implemented `ja4_source` / `trusted_forward_auth_proxies` keys are removed.
- (2026-06-27) **The native plugin emits a `q`-prefix JA4 for HTTP/3 (QUIC) requests.** Labelling a QUIC ClientHello `t…` mis-fingerprinted every HTTP/3 visitor. Guarded to nginx >= 1.25 with QUIC.
- (2026-06-26) **Privacy Pass / Apple Private Access Token verification: an attested client can skip the challenge.** unmask acts as an origin (verifier only) for token type `0x0002` (Blind RSA, RFC 9577/9578). Pure Go, opt-in, and behind the Advanced switch, so inert until enabled.
- (2026-06-22) **`trust_forwarded_ja4` is a network-tab checkbox for forward-auth mode.** A trusted loopback peer does not make a client-sent value trustworthy, so honouring it is its own opt-in; it was config-only.
- (2026-06-20) **Custom `[from,to]` date range on the stats dashboard and the hunt page.** These views were fixed windows, so a past incident could not be scoped to its day.
- (2026-06-19) **Two CAPTCHA watchlist cards: a pass report and a cookie-reuse ranking.** Investigative signals, not verdicts -- passing a CAPTCHA is not proof of a bot.
- (2026-06-19) **Roaming-rebind refusals are visible in the hunt log.** A refused silent `_bv` rebind left no trace; every refusal is now a `bv_rebind_reject` event with its reason. This surfaced the HTTP/2 <-> HTTP/3 false positive fixed below.
- (2026-06-19) **Unix-socket admin bind is fully operator-usable.** `socket_group` auto-detects the web server's group instead of hard-coding `nginx`; `socket_mode` supports `0660` / `0666`; `admin_allowed_ips` works over a socket via the forwarded `X-Real-IP`.
- (2026-06-18) **A support "Ref ID" on every challenge / deny / ban page, with a reverse lookup.** A blocked visitor can quote a short 16-hex id; `unmask events --ref <id>` and the hunt page resolve it to the exact serve event and its decision context.
- (2026-06-18) **Branded "blocked" deny page across native, nginx forward-auth and Apache.** A ban deny used to be a bare `return 403`; both it and the rate-limit deny now render a themed, localized page in 18 languages. `/unmask/_ban` fails closed during a daemon outage so a ban holds.
- (2026-06-17) **Native rate-limit "deny mode": a hard cap that returns 403, and a valid `_bv` does not buy past it.** Flooding is flooding, so deny zones count cookie holders; only trusted sources stay exempt. On protected paths a new compose mode runs `limit_req` in dry-run so the deny zone beats the protected CAPTCHA (nginx 1.17.1+; compiled out on older nginx).
- (2026-06-14) **The `_bv` cookie and the native rate-limiter bind to the IPv6 /64, not the /128.** Privacy extensions rotate a client's address within its /64 several times a day, so a /128 binding re-challenged on every rotation and multiplied the rat