unmask

docs

JA4 acquisition, LB / CDN setup, supported distros, FAQ.

FAQ

Which topologies expose JA4?
JA4 availability depends on where TLS is terminated. nginx + unmask-plugin-nginx (native mode) gets it; so does the gateway container in front of any HTTP server (it terminates TLS itself). Where a load balancer or CDN in front terminates TLS, the JA4 reaches unmask only if that layer forwards it in a header. See JA4 acquisition for a per-topology diagram.
Some events show "(none)" as the JA4 verdict. Is that a problem?
No. A verdict is a label that a JA4 verdict rule attaches, so (none) mostly means that no rule matched — which is true of every ordinary browser. It also covers requests that carried no JA4 at all: plain-HTTP requests (there is no TLS handshake to fingerprint), and requests that arrive through a load balancer or CDN that terminated TLS without forwarding the fingerprint in a header (see JA4 acquisition). Since 0.1.35 every page shows a missing verdict as (none); earlier releases showed -, none or ok depending on the page.
The stealth column is showing red. Should I worry?
The stealth column (the funnel table on the Stats page) counts challenge loads that showed no automation traces (all flags clear) yet carried a block or observe JA4 verdict (bot / suspect) — typically a modern headless client that hides its browser-side traces but not its TLS handshake. A load is not a pass. Whether any of them went on to clear a CAPTCHA and receive a pass cookie is on the "JA4 bot/suspect × CAPTCHA passed" card of the same page, which should stay at zero.

What the verdict itself does: an observe verdict only labels the request, so it gets the ordinary challenge. A block verdict is served, by default, a chain that ends in a CAPTCHA, and a proof-of-work cookie does not satisfy it. A block verdict does not start a challenge by itself, however: the request falls through to the Operating mode actions, which challenge every such visitor by default (see the next question).
Which visitors are challenge targets by default?
By default every visitor that is not a bypass IP / bypass path / search bot is a challenge target (the no-match KnownBrowserAction / UnknownUAAction, when unset, both resolve to pow_only). PoW is automatic and needs no interaction, so the load on ordinary visitors is minimal. Search bots still pass safely: where a vendor publishes its crawler IP ranges, the genuine crawler is recognised by address (and a UA claiming to be it from anywhere else is not), and crawlers with no published range are rescued by their UA pattern — so no SEO accidents, and no open door either. See verified crawlers. For a lighter posture ("normal browsers pass, only curl / scrapers are challenged"), set KnownBrowserAction to pass under settings → Operating mode tab.

Know what that also lets through. A JA4 bot verdict does not challenge on its own; the request falls through to these same actions. With pass, a client that sends a browser user agent over a bot fingerprint therefore passes too, unless another rule catches it (the UA filter, header integrity, a country / ASN rule, a rate limit, a protected path).
Can I put unmask in front of Nextcloud, Jellyfin, Home Assistant or a Git forge?
Yes, as long as their apps and tools can get through. The challenge is solved by a browser running JavaScript; a sync client, a mobile app, a CalDAV client or git cannot run it, so behind unmask they receive the challenge page instead of an answer and fail. unmask is for what the public web reaches and scrapers take: websites, forums, wikis, the pages of a Git forge. A service only you and your devices use, such as Home Assistant, is better left outside it, on a hostname that does not pass through unmask. Where a service has to sit behind it, let its machine traffic through under settings → Bypass paths:
・Git forge (Gitea, Forgejo, GitLab, cgit): the Git over HTTP preset lets clone, fetch, push and Git LFS through by their Git endpoints, while the repository pages stay behind the challenge.
・Other apps: add the paths their clients call, for Nextcloud for instance ^/remote\.php/, ^/ocs/ and ^/status\.php$. The Bot hunt page shows the path of every challenged request.
・Jellyfin and similar media servers answer their apps at the site root, not under one prefix, so a path cannot separate the apps from the web pages; keep them outside unmask.
A bypassed path skips every check, rate limits and bans included, so bypass the endpoints clients need, not the whole site.
I'm behind a CDN / reverse proxy. Will decisions use the real client IP?
IP-based decisions (BAN / rate limit / country filter / bypass IPs) use the client address the web server sees. Behind a CDN / proxy / LB that is the upstream's address by default, so restore the real one from the forwarded header:
・nginx (the module on your own nginx): stock nginx set_real_ip_from (the trusted upstream ranges) and real_ip_header (CF-Connecting-IP for Cloudflare, X-Forwarded-For in general). This is plain nginx real_ip, not unmask-specific.
・Gateway container: you do not edit its nginx; add the balancer's ranges under settings → Network → Trusted LB / CDN (presets for common providers), and the visitor's address is read from X-Forwarded-For.

Without it, every request looks like the same upstream IP and rate limit / country filtering won't behave as intended. JA4 availability is a separate matter, decided by where TLS is terminated (a CDN that terminates TLS needs header forwarding). See JA4 acquisition and load-balancer setup.
I already run Nginx Proxy Manager, Traefik, Caddy or a Cloudflare Tunnel. Where does unmask go?
Use the gateway container, in one of two places:
・In front of your proxy: unmask takes ports 80 / 443 and terminates TLS, and your proxy becomes its upstream (settings → Gateway). unmask sees the TLS handshake, so every layer works, JA4 included.
・Behind your proxy: your proxy keeps TLS and forwards to unmask, which forwards to your apps. Add the proxy's address under settings → Network → Trusted LB / CDN so that decisions use the visitor's address from X-Forwarded-For. The handshake ends at your proxy, so the JA4 layer is off unless the proxy passes the fingerprint on in a header (JA4 acquisition); every other check works.
A Cloudflare Tunnel is the second case: Cloudflare terminates TLS, so point the tunnel at unmask and select Cloudflare under Trusted LB / CDN (it is a preset). Nginx Proxy Manager runs its own nginx build (OpenResty), which the unmask module does not target, so it takes the container like the others.
What happened to forward-auth mode?
forward-auth mode is frozen. Installs that use it (nginx auth_request or Apache mod_lua) keep working as they are and still get bug and security fixes; it still fails open while the daemon is down, and a location @unmask_daemon_down defined before the include still changes that. New features are built for the nginx module only — on your own nginx, or inside the gateway container — so new installs use one of those two. To move over:
・nginx with auth_request: install unmask-plugin-nginx, replace the forward-auth includes with include /var/lib/unmask/nginx/server.inc; inside server {} and include /var/lib/unmask/nginx/protect.inc; where you want protection, and restart nginx (a reload does not load a new module), as in the nginx module steps of the install guide.
・Apache with mod_lua: put the gateway container in front of Apache (the gateway container steps of the install guide), then remove the LuaHookAccessChecker line(s).
A legitimate user got challenged / blocked by mistake. What do I do?
First, the default action is a PoW / CAPTCHA, not an outright block, so a real user passes by solving the challenge (PoW is automatic, CAPTCHA is once). To reliably let a specific client or path through, loosen it narrowly:
・trusted IPs (internal / monitoring / LB egress) → settings → Bypass IPs
・specific paths (public APIs etc.) → settings → Bypass paths
・a JA4 misclassified as a bot → settings → JA4 verdict tab, set that verdict's action to observe / allow
・too strict overall → set KnownBrowserAction to pass under the Operating mode tab (normal browsers pass, only curl / scrapers are challenged; mind the caveat above)
The Bot hunt page shows the JA4 / fingerprint / reason for each request — check the cause first, then loosen only what's affected.
How do I try a fix before it ships, or install one specific version?
A fix you were asked to confirm is published first as a pre-release on the testing channel. Every install already has that channel configured and switched off, so trying one takes a single command — no new key, no repository file:
# RHEL / Rocky / Alma / CentOS
sudo dnf --enablerepo=unmask-testing update unmask

# Debian / Ubuntu -- the version you were given, for each unmask package you have
sudo apt update
sudo apt install unmask=<version> unmask-plugin-nginx=<version> unmask-web-nginx=<version>

# Alpine
sudo apk upgrade --repository https://unmask.sh/dl/testing/apk/main
Pre-releases are numbered to sort before the release they lead up to, for example 0.1.46-0.1.rc1, 0.1.46-0.2.rc2, then the release itself as 0.1.46-1 (on Alpine, 0.1.46_rc2-r0 comes before 0.1.46-r1). So there is nothing to undo afterwards: an ordinary update never takes you back to the previous release, and moves you onto the final one once it is out. What each pre-release carries is listed in the CHANGELOG under its own heading (for example [0.1.49-rc2]): the changes it brought and its fixes to the pre-release before it, on top of everything under the headings below it.

To install one particular file instead — when a machine's repository configuration predates the version you need, or on an older distro whose package manager has no per-command repository switch — point the package manager straight at it:
# RHEL / Rocky / Alma / CentOS  (works on yum too)
sudo dnf install -y https://unmask.sh/dl/testing/rpm/x86_64/RPMS/unmask-<version>.x86_64.rpm

# Debian / Ubuntu
curl -fsSLO https://unmask.sh/dl/testing/deb/pool/main/u/unmask/unmask_<version>_amd64.deb
sudo apt install -y ./unmask_<version>_amd64.deb
Replace testing with the stable paths (/dl/rpm/, /dl/deb/, /dl/apk/) for the current release. Those repositories hold only the current version; older releases (packages, static binaries and signed checksums.txt, the newest six) stay under unmask.sh/dl/releases/, and every version is on GitHub Releases. Companion packages pin the core to the exact build, so install unmask, unmask-plugin-nginx and unmask-web-nginx in the same command — one at a time will fail on the version pin.

On an EOL distro that cannot complete the HTTPS handshake: if the CA store is too old to verify unmask.sh (see below), the URLs above fail at the download, not at the install. Where the CA store cannot be updated, fetch the files on another machine, copy them over, and install them as local files.
# fetch elsewhere -> copy to the host -> install locally
sudo yum --nogpgcheck localinstall unmask-*.x86_64.rpm
That drops the signature check on the target, so verify the signature on the machine that downloaded them before copying (rpm --checksig, key at https://unmask.sh/dl/keys/RPM-GPG-KEY-unmask).

Alpine, installing by hand: the index is signed under the key name oss@unmask.sh-260509.rsa.pub, and apk silently treats the whole repository as untrusted — packages simply appear not to exist — if the file in /etc/apk/keys/ has any other name. Installing unmask-release places it correctly; if you are bootstrapping without it, fetch it under that exact name:
sudo wget -O /etc/apk/keys/oss@unmask.sh-260509.rsa.pub \
     https://unmask.sh/dl/keys/unmask.rsa.pub
Is unmask a WAF? Does it block vulnerability scans and attacks?
unmask is a bot-management gateway, not a WAF. A WAF inspects the contents of a request (SQLi / XSS and other payloads); unmask decides who is allowed to reach your app — by JA4 / PoW / CAPTCHA / behavioral signals — and turns automated clients away at the edge.

The security upside is a real side effect: most vulnerability scanners raking over wp-login.php, .env, and known-CVE paths, plus brute-force and credential stuffing, are non-browser clients that don't run JS. They hit the PoW / CAPTCHA gate at the door, so the number of attack attempts drops (lowering breach and data-leak risk). Even as AI makes finding flaws and launching attacks cheaper and faster, unmask keeps a cost on automated, high-volume access.

That said, unmask does not patch the vulnerabilities themselves and does not inspect payloads. A one-off attack from a real browser that solved the challenge, and applying patches for known CVEs, belong to other layers. unmask is one layer of defense-in-depth, meant to run alongside your WAF and patch process.
What is the PoW algorithm?
SHA-256 hashcash (challenge.js's built-in pure-JS SHA-256 implementation; it searches for a nonce whose hash starts with N leading-zero bits). Difficulty (leading-zero bits) is configurable in settings → Challenge tab (default 18 bits, typically ~1 s).

Know what difficulty can and cannot do: it taxes only clients that actually execute the challenge JavaScript. A client that keeps reusing a pass cookie it already earned — including across IP changes via the roaming re-bind — pays the solve once per credential, so raising the difficulty does not slow it down at all; it only taxes your first-time human visitors. A crawler you mean to keep out belongs in a UA rule with action deny (enforced ahead of the pass cookie), not behind a steeper PoW.
Migrating from SQLite to MariaDB/MySQL — or the reverse (changing the DB connection)
Switch via the admin reconfigure wizard or by editing config.yml directly. The steps are the same in either direction (SQLite → MariaDB/MySQL or MariaDB/MySQL → SQLite). Either way, past events / bans / users are not carried over to the target DB (your original DB is left untouched; if the target already holds unmask data it is adopted, if empty a fresh schema is created).

Option A — reconfigure wizard (easiest): log in as a superadmin and open /unmask/admin/setup/ — on a configured install it becomes the reconfigure wizard. At the DB step pick the target driver (SQLite / MariaDB/MySQL) and enter its details (MariaDB/MySQL: host/DB/user; SQLite: a file path) → create the first admin if the target is empty (or “skip” to adopt an existing one) → review → apply. Switching the DB rebinds the background workers, so the daemon restarts itself automatically (no manual step).

Option B — CLI / manual: (1) stop unmask (2) set config.yml db.driver to the target (mariadb / sqlite) with its connection info (MariaDB/MySQL: create the DB + user first; SQLite: set sqlite_path) (3) unmask doctor to verify the connection and flag any missing tables (4) unmask migrate to bootstrap the schema (5) unmask user create admin -role superadmin (6) start unmask.
The database keeps growing past the retention window, or doctor says the prune is not keeping up
The raw event log is pruned to events_retention_days by the daemon itself. Since 0.1.41 that prune runs hourly, in small paced chunks that hold the write lock for a second at most and yield at least as long in between, so recording, the hourly aggregate and BAN lookups keep flowing while it works; a run that stops on its budget continues an hour later. unmask doctor and the Log management tab warn when the oldest event is more than two days past the window or the prune has not completed for two days — the state a growing file used to show only in the daemon log.

A backlog that dwarfs the window (weeks of rows against a 7-day window, a retention lowered by a lot) is cleared fastest offline. Stop the daemon — the site keeps serving, fail-open — and run:
sudo systemctl stop unmask        # or: sudo service unmask stop
sudo unmask db-prune -config /etc/unmask/config.yml -mode rebuild -vacuum -analyze
sudo systemctl start unmask
-mode rebuild copies the rows inside the window into a fresh table and drops the rest (minutes, not the hours that deleting row by row costs on a large file with nine indexes), -vacuum hands the freed space back to the filesystem (it writes a copy of the live data next to the database first, so allow free disk of about the resulting size there, and some time), and -analyze refreshes the query-planner statistics. Since 0.1.43 the command keeps SQLite's temporary storage on disk, in the database's directory, and refuses a rebuild or a VACUUM when the free space there looks short (-skip-space-check overrides); before that it used memory, and on a multi-gigabyte file that was a copy of the result in RAM. The command refuses to run while the daemon answers on its socket. On MariaDB/MySQL use -mode delete. The WAL file (unmask.sqlite-wal) is a high-water mark by nature; since 0.1.41 it is capped at 64 MB after a checkpoint, and a daemon stop that is allowed to finish checkpoints and removes it (an init system that kills the daemon within seconds leaves that to recovery at the next start).
After an upgrade the admin says “A database update is waiting”. What is it, and when should I run it?
Since 0.1.49, a database change that is only an index over a large table is not applied when the daemon starts if it is expected to take more than about 20 seconds. Earlier releases built such an index before the daemon listened: on a database of several gigabytes that meant minutes without a daemon — the site kept serving (fail-open), but no challenge was shown and no event was recorded, and nothing said so. Now the daemon starts at once, runs normally without the index, and tells you the update is waiting: in the output of the package upgrade, in a notice at the top of every admin page, in unmask doctor and unmask migrate -status, and as the metric unmask_schema_update_pending. A small install never sees any of this: when the estimate is short, the update is applied at startup as before.

Run it when it suits you. A superadmin presses Run now in the notice, or on the host:
sudo unmask migrate           # in a container: docker exec <container> unmask migrate
sudo unmask migrate -status   # only list what is waiting, with the estimate
Both do the same thing, and the daemon keeps running. While the index is built the challenge works as usual and the admin can be read. On SQLite the build holds the database's write lock, so events and access-log counts from those minutes are kept and written once it has finished, and changes that write to the database itself — users, manual bans — wait until then; settings can be saved throughout. On MariaDB/MySQL nothing waits. The notice shows the elapsed time against the estimate, and a run can be cancelled: what was built so far is discarded and the update waits again.

Until you run it nothing is broken — the queries the index is for are as slow as they were before the upgrade, no more. The threshold is db.schema_update_defer_seconds in config.yml (default 20; a negative value applies everything at startup, as every earlier release did). A change to the tables themselves is always applied at startup.
What do the reasons in the Stats-tab "Forced CAPTCHA (reason breakdown)" card mean?
none=normal PoW (not forced) / ja4_bot=JA4 verdict bot / honeypot=honeypot trip / banned=a hit on your own BAN list (manual, honeypot and the other sources recorded on this install; the BAN management page shows each entry's source) / community_bans=the address or fingerprint is on the shared Community Bans feed under a challenge action (since 0.1.28) / protected=protected-path mode / rate_limit=rate-limit exceeded / header=header integrity (a Chromium UA carrying no Sec-CH-UA) / stale=browser version older than the stale-browser threshold / geo=country rule / asn=ASN rule / ua_target=the user agent matched the UA filter's black list (since 0.1.18) / test=debug path. If one reason dominates, you can act on it directly.
"(SSE connection error)" keeps appearing
Most often an LB / proxy in front cuts the idle connection (e.g. GCP LB's backend timeout, 30 s by default). A heartbeat is sent every 5 s. The "(reconnecting)" message means EventSource is auto-retrying. "Connection error" is only shown when it's fully closed.
I want the numbers, but I cannot query the database
unmask stats reads them out for you. Useful when the database is not something you can open by hand — an older distro whose sqlite3 predates WAL and refuses the file, a sqlite3 older than 3.38 (the one RHEL 8/9, Debian 11 and Ubuntu 22.04 ship), which from 0.1.52 answers “malformed database schema” at the stats page's indexes though the file is sound, a MariaDB/MySQL whose credentials live only in config.yml, or a host where handing out a database client is worse than handing out a read-only command.
# request composition for the last day
unmask stats

# one site, one week, by challenge phase
unmask stats -kind phase -since 7d -site www.example.com

# everything, for a support thread
unmask stats -kind all
-kind takes traffic / phase / verdict / ip / ua / ja4 / all, and -tsv emits tab-separated records for awk. It only reads — nothing it does can change the database.
Does unmask expose Prometheus metrics? How do I monitor it?

Yes. The unmask daemon exposes a Prometheus-format metrics endpoint at GET /unmask/metrics. A scraper polls it (typically every ~30 s) and you visualize the result in Grafana — challenge volume, the behavioral-score distribution, DB latency. It is emitted in the plain Prometheus text format directly from the Go standard library, so it adds no runtime dependency.

Keep it private. The endpoint carries bot-pressure stats (e.g. “100k challenges/day on your site”), so it is gated at the application layer. The default allowlist is loopback only (127.0.0.0/8 + ::1) — a same-host scraper works with no configuration. To scrape from a remote Prometheus, add only that scraper's IP / CIDR to metrics_allow_from under settings → Network. A request from any other address gets a 403. If you don't run Prometheus, ignore this — the endpoint exists but nothing will reach it.

Exposed metrics

Metric Type What it is
unmask_event_total
{phase, verdict}
counter (declared) Events in the trailing 24 h, broken down by challenge phase and JA4 verdict ((none) when an event has no verdict). The main “what is being challenged” signal. The endpoint declares it a counter, but the value is a rolling 24-hour count that falls as old events leave the window, so graph it as a gauge: rate() and increase() would read every fall as a counter reset.
unmask_event_unique_ip gauge Distinct source IPs seen across any phase in the trailing 24 h.
unmask_verify_score
_bucket{le} / _sum / _count
histogram Behavioral-score distribution from /api/verify (buckets 0.1 … 1.0). Counted in-process since the daemon started. Watch where humans vs. bots land relative to the 0.5 threshold.
unmask_captcha_math_no_input_total counter Correct answers to the built-in CAPTCHA's numeric-add fallback that arrived with no input events on the answer field, i.e. filled in by a script. Those passes are issued at proof-of-work grade instead of CAPTCHA grade. Counted in-process since the daemon started (since 0.1.33).
unmask_db_query_seconds
_sum / _count {op}
counter Latency (sum & count) of the two database queries behind this endpoint (op="metrics_phase_group" and op="metrics_unique_ip"), measured each time the endpoint re-reads them (at most every 30 s, see below) — a coarse database-health signal.
unmask_schema_update_pending gauge Database updates that wait for you to run them (since 0.1.49; see the question on database updates). 0 when nothing waits.
unmask_schema_update_running gauge 1 while such an update is being applied, 0 otherwise (since 0.1.49).

The DB-sourced metrics (event_total / unique_ip) are cached for 30 s, so a fast scrape interval will not add database load.

Scrape configuration

Via the site (recommended). nginx proxies all of /unmask/ to the daemon, so this works regardless of how the daemon binds (TCP or unix socket). Point Prometheus at your host and add its IP to metrics_allow_from:

# prometheus.yml
scrape_configs:
  - job_name: unmask
    scheme: https
    metrics_path: /unmask/metrics
    static_configs:
      - targets: ['your-host']

Same host (default, zero allowlist change). If Prometheus runs on the unmask host itself and the daemon binds TCP (default bind: 127.0.0.1), scrape loopback directly — the default allowlist already permits it:

metrics_path: /unmask/metrics
static_configs:
  - targets: ['127.0.0.1:9477']   # the daemon's server.port (9477 by default)

Health check

For a plain liveness probe (uptime monitors, load-balancer health checks, container orchestrators) use the unauthenticated health endpoint — it returns 200 with the body ok and exposes no stats:

curl -sf https://your-host/unmask/healthz   # -> ok

healthz is open by design (no allowlist) precisely because it reveals nothing; the stats live behind /unmask/metrics and its allowlist.

What if I leave admin_allowed_ips empty?
It's treated as "allow all" (all IPs permitted), so the admin is defended by authentication alone; unmask doctor warns about it. If non-empty, the daemon enforces the list in its own admin HTTP layer, on every admin page including the login and the reconfigure wizard, the same in every deployment mode; nothing about it is rendered into the nginx config. Edit it under settings → Network.
I switched to a third-party CAPTCHA and got locked out of the admin
When the unmask itself protected-path preset is on, /unmask/admin/ is covered by a CAPTCHA. Turnstile, hCaptcha and reCAPTCHA all require registering the domains they run on, so if the hostname you reach the admin by isn't in that CAPTCHA's allowed list, the CAPTCHA button won't render and you can't get in (common when you open the admin by a bare server hostname, or on a multi-host setup). To recover, set challenge.default.captcha.provider to builtin in /etc/unmask/config.yml and restart unmask — the built-in CAPTCHA works on every host, so you can log back in. On multi-host setups, built-in is the safe default.
What does unmask connect to on its own, and can I switch it off?
By default it sends nothing about your visitors. What goes out on its own, and the switch for each (all of it to unmask.sh; only the IP-geo download falls back to db-ip.com):
・Crawler IP ranges and browser versions, once a day: settings → About, “Keep the crawler IP ranges and browser versions current (daily download)”. While it is off, the data each release ships stays in use.
・Community Bans: registration at the first start, then the shared list every hour, carrying the install's token so the hub can count running installs. Off: subscribe mode off on the Community Bans tab, or only the count (publish_liveness). What the hub keeps is in its privacy policy.
・The IP-geo database (DB-IP Lite, mirrored on unmask.sh, db-ip.com as the fallback): downloaded after setup, then kept current monthly. Off: ipgeo.auto_fetch / auto_update on the Network tab.
・The update check when you open the admin, at most every 12 hours: settings → About.
Everything else happens only when you switch it on and point it somewhere yourself: sharing a ban with the hub (off by default; it sends the reported IP and JA4), notifications, the AI review of ban candidates with your own API key, a third-party CAPTCHA, the Advanced features (Web Bot Auth and Privacy Pass fetch the keys they verify against), and Let's Encrypt when the gateway container gets a certificate.
I want to install / update the IP-geolocation database (mmdb)
Usually there is nothing to do. Once setup is complete, the daemon downloads DB-IP Lite (CC BY 4.0, no signup), the country and the ASN database, to /var/lib/unmask/ipgeo/ by itself, and since 0.1.17 it keeps them current: it checks daily and, when the copy on disk is a month old, swaps in the newer monthly snapshot after verifying the download. No cron entry is needed (ipgeo.auto_fetch / auto_update / auto_update_asn, all on by default; turn them off on a host with no outbound access).

To fetch it right away, use the "📥 Download DB-IP Lite" button (it reads "Update DB-IP Lite" once a file is there) under settings → Network, where the Geo filter tab points you, or run sudo unmask install-ipgeo (-kind asn or -kind all for the ASN database). The button and the daemon's own updates load the new file without a restart; after install-ipgeo from the shell, restart unmask so a running daemon picks it up. With the nginx module, country and ASN rules are compiled into the rendered nginx config, so they follow a new database once the config is rendered again (any settings save, or unmask render-nginx) and nginx is reloaded.
I forgot to create a user after install
sudo unmask user create <username> -role superadmin -password <pw> (container install: docker exec unmask unmask user create … with the container name from the docker run example, or docker compose exec unmask unmask user create … under compose). Or use the wizard at /unmask/admin/setup/.
I forgot my password
sudo unmask user reset-password <username> -password <new>.
On CentOS 6 / 7, feed sync fails with certificate signed by unknown authority
An EOL distro's CA store can be too old to verify unmask's feed / auto-update over TLS, lacking Let's Encrypt's current root — not an unmask bug; unmask defers to the system trust store by design. Run sudo yum update ca-certificates, or add ISRG Root X1 / X2 to the trust store, then restart unmask. The bundled snapshot keeps rescuing search bots until sync recovers. If the trust store cannot be fixed, or the host has no outbound HTTPS at all, keep the ranges current as the next question describes.
The host cannot reach unmask.sh (no outbound HTTPS, or an old trust store). How do I keep the crawler IP ranges current?

unmask keeps the verified-crawler IP ranges (Googlebot, bingbot, GPTBot, ChatGPT-User, ClaudeBot, Amazonbot, …) fresh by pulling a feed over HTTPS. Two kinds of hosts cannot make that pull: machines whose trust store predates ISRG Root X1 (very old enterprise distros — the log shows x509: certificate signed by unknown authority and unmask doctor warns about crawler IP ranges), and machines with no outbound HTTPS at all. Here is the runbook for both.

What works with no sync at all

Every release ships a built-in snapshot of all vendor ranges inside the binary, and the release pipeline refuses to ship a stale one — so upgrading on your normal cadence keeps the ranges reasonably fresh on its own. Freshness still matters: vendors add ranges, and features that verify crawlers by address stand down (with a doctor warning) once the newest available data is more than 30 days old. From 0.1.49 the scheduled pulls can also be switched off on purpose (settings → About); a newer release's snapshot then replaces an older downloaded copy.

The offline update (0.1.33+)

# 1) on any machine that CAN reach unmask.sh:
curl -O https://unmask.sh/dl/feed/iprange/bypass-iprange-all.json

# 2) transfer the file to the restricted host (scp, USB, whatever fits)
scp bypass-iprange-all.json restricted-host:/tmp/

# 3) import it -- no network access, no temporary HTTP server:
unmask update-iprange -file /tmp/bypass-iprange-all.json

# 4) re-render and reload nginx
unmask render-nginx && nginx -t && nginx -s reload

The running daemon notices the refreshed files by itself within seconds (0.1.33+); the render + reload in step 4 puts the ranges into nginx's own maps.

On 0.1.32 and older, the daemon keeps serving its startup snapshot until restarted: run service unmask restart (or systemctl restart unmask) before render-nginx — otherwise the next settings save quietly re-renders the old ranges over your update. (Those versions also lack -file; point update-iprange -url at a loopback-only temporary HTTP server instead.)

Automating it

# on a bastion that reaches both sides, e.g. weekly:
curl -sO https://unmask.sh/dl/feed/iprange/bypass-iprange-all.json \
  && scp -q bypass-iprange-all.json restricted-host:/tmp/ \
  && ssh restricted-host 'unmask update-iprange -file /tmp/bypass-iprange-all.json \
       && unmask render-nginx && nginx -t && nginx -s reload'

A mirror inside your network (0.1.46+)

If the restricted host can reach a web server inside your network, mirror the two feed documents there and point the daemon at the copies. It then pulls them on its usual daily schedule, so nothing has to be imported by hand. The second document holds the current browser versions that the stale-browser check compares against.

# daily, on a machine that reaches unmask.sh -- each document with its signature:
for f in bypass-iprange-all.json browser-majors.json; do
  curl -fsSO https://unmask.sh/dl/feed/iprange/$f && curl -fsSO https://unmask.sh/dl/feed/iprange/$f.sig
done
# serve them from an internal web server, each .sig next to its document

# the daemon's config on the restricted host (/etc/unmask/config.yml on a package install), under nginx:
  sync_hub_url: "https://mirror.example.internal/unmask/bypass-iprange-all.json"
  browser_majors_hub_url: "https://mirror.example.internal/unmask/browser-majors.json"
  sync_require_signature: true   # refuse a bypass-IP copy that arrives without its .sig

Then restart unmask. The daemon fetches <url>.sig beside each document and checks it the same way it checks the published feed. The refreshed ranges reach nginx's own maps at its next reload, as they do from the published feed.

The feed is signed (0.1.33+)

Every feed document is published with a detached ed25519 signature (bypass-iprange-all.json.sig), produced on a separate signing host — the hub that serves the feed never holds the key. The daemon verifies the signature on every pull and refuses a document whose signature does not match. Since 0.1.49 it also refuses the published feed when the signature is missing, so the host that serves the feed cannot swap the list by withholding it; a mirror of your own is held to that with sync_require_signature. A transferred file is checked when its .sig sits next to it, and you can check one by hand before importing it:

unmask sign-feed -verify /tmp/bypass-iprange-all.json   # needs the .sig next to it

This is also what makes an insecure-transport escape hatch offerable: with sync_insecure_tls: true (or update-iprange -insecure-tls) the transport certificate check is waived, and in exchange the signature becomes mandatory — an unsigned or badly signed document is refused outright. Content verification stands in for the transport verification you switched off. Fixing the trust store is still the better answer where you can.

Why skipping certificate verification requires the signature

The ranges this feed carries are trust data: an address inside them bypasses the challenge entirely. Fetching them over an unverified connection, with nothing else checked, would let anyone on the network path inject their own addresses into your bypass list — a clean takeover of the one mechanism that must never be takeable. That is why there is no switch that only skips the certificate check: sync_insecure_tls (above) always brings the mandatory signature with it, so the content is verified in place of the connection. Where you can, the better answers are still the offline import above, or fixing the trust store itself (adding the ISRG Root X1 root certificate to the system CA bundle, which also un-breaks every other modern TLS endpoint on that host).