Skip to content

Latest commit

 

History

History
1512 lines (1315 loc) · 92 KB

architecture.md

File metadata and controls

1512 lines (1315 loc) · 92 KB

Architecture

npanel is one process that does three things: it serves nsites over HTTP, it serves a dashboard for configuring which nsites those are, and, on threads of its own, it keeps local storage in sync with Nostr. It can also pass a hostname's requests on to a web server on another machine, through a tunnel that machine holds open.

        DNS ──► reverse proxy (TLS) ──► npanel :3000
                 or, with https_port ──► npanel :443, its own TLS
                                          │
                    ┌─────────────────────┴────────────────────┐
                    │  Host = dashboard_host?                  │
                    ├─────────────────────┬────────────────────┤
                    │ yes                 │ no                 │
                    ▼                     ▼                    │
              /api  + dashboard SPA   nsite from local store   │
                    │                     ▲                    │
                    │                     │                    │
                    ▼                     │                    │
                 SQLite ◄──── sync worker ┘                    │
                    ▲             │                            │
                    │ relays (manifests) ── Blossom (blobs) ───┘
                    │
                    └─ npanel :3001 ◄── proxy: may this name have a certificate?

   /_npanel/api/event on any site ──► analytics thread ──► analytics.sqlite3
   /_npanel/api/report on any site ──► errors thread ──► errors.sqlite3
   a crawler on a page ──► previews thread ──► the site's script ──► relays
   a site's onion address ──► Tor, built in, on its own thread ──► the same site
   a tunnel's hostname ──► its agent's connection ──► a web server on its machine
   with https_port: SQLite ◄── ACME, on its own thread ◄──► the CA, via :80 and :443

Request routing

Everything hangs off the Host header, checked in one middleware in src/app.rs. If it equals dashboard_host, the request goes to the axum API at /api and otherwise to the built dashboard SPA. A tunnel's hostname is passed to its agent. Any other hostname is served as an nsite.

Only Host is read — or, over HTTP/2, which has none, :authority, which is the same thing by another name (request_host). Ports and the trailing dot of an FQDN are stripped and the name is lowercased (request_hostname, src/hostname.rs, shared with onion connections so the two cannot drift). X-Forwarded-Host is deliberately ignored: npanel cannot tell a proxy's copy of that header from a client's, and a proxy that doesn't set one — nginx doesn't — would pass a visitor's straight through, letting a request for any site ask for the dashboard instead. A proxy that rewrites Host has to be configured not to; see deployment.

The scheme is a different matter. Behind a proxy it comes from X-Forwarded-Proto (request_scheme, src/app.rs), defaulting to https: the local request always says http, and redirects built from it would downgrade, so believing the proxy here costs at worst a redirect to the scheme the visitor was already using. Without a proxy — see TLS without a proxy — the listener that terminated TLS says https itself, and no forwarded header is read at all.

Both are settled in that one middleware and handed to the site server as a RequestOrigin — nothing downstream reads a header to work out where a request came from, which is what lets the same site-serving code be reached from a listener with entirely different rules about what may be trusted.

Nothing is exempt from that rule, which is why certificate authorization is not on this listener at all. Caddy's on_demand_tls ask is a plain request to the address it proxies to, carrying no hostname npanel could route on: as a path here it would have to answer on every hostname npanel serves, putting a certificate-issuing decision on the public internet where a stranger could spend a real host's hourly budget. So it gets a listener of its own on tls_check_port, serving one path and nothing else (src/tls_check.rs). It approves the hostnames npanel serves, plus the www names it only redirects — a redirect is an HTTPS response like any other, and without a certificate the browser never gets far enough to be told where to go. It is a bare handler rather than a router, since one route needs none.

A site only answers GET and HEAD; anything else is a 405.

A host may also answer for its www. When nothing is configured for www.example.com, npanel looks for example.com, and if that host has redirect_www set — the default — the request gets a 301 to the same path and query on the bare name. The redirect is offered only where the hostname is a root domain, which is a question the Public Suffix List answers and label counting does not: example.co.uk is one and foo.github.io is not. So src/psl.rs carries the list's ICANN section, and the dashboard's twin of src/hostname.rs applies the same list (via tldts) so it offers the switch exactly where the server will honour it. Folding www.docs.example.com into docs.example.com would be a guess about somebody else's zone, so npanel doesn't make it. A www host someone configured deliberately always wins over the redirect.

The 301 is sent with an hour of freshness. Browsers cache a permanent redirect with no freshness of its own until they restart, which would outlive an admin turning the redirect back off; crawlers still take the 301 itself as the permanent answer.

The dashboard is otherwise served as built, with one exception: index.html has its og:image and og:url tags rewritten from site-relative paths to absolute URLs built from dashboard_host (src/dashboard.rs). A built static file cannot know its own origin, and link-preview crawlers are inconsistent about resolving relative ones.

Where the files come from depends on the build. Every binary meant for a server — a release's, the Docker image's, one built by hand — comes from contrib/release/build.sh, which builds with the embedded-dashboard feature, and build.rs compiles every file of dashboard/dist into it, so a server and its dashboard can't come from different versions. Without the feature, in development, they are read off disk, from dashboard/dist under the working directory, beside the binary, or in the source tree, and the rewritten index.html is cached until its mtime changes, which is what lets a developer rebuild the dashboard under a running server.

Its text files are served compressed, negotiated the way a site's are below, with the same brotli and gzip settings and per-encoding ETags. The copies are made in memory rather than at build time: dist is also published as an nsite, which shouldn't carry them, and index.html is only final once it has been rewritten. Brotli at its slowest takes seconds over the larger chunks, so npanel compresses every file on the blocking pool when it starts, and a file asked for before its copies are ready goes out as it is, in the meantime. A file that changes on disk is compressed again the first time it is asked for.

The same build is also published as an nsite, where nothing serves the API on its origin. It finds its server at startup (dashboard/src/lib/server.ts): if /api/instance on its own origin answers as npanel, that is the server; otherwise it asks the reader for one, and remembers it. Every API response allows any origin, and NIP-98 binds a request to the API's URL rather than the page's, so a dashboard on another origin needs nothing else from the server.

TLS without a proxy

With https_port set npanel terminates TLS itself, and gets every certificate it serves from an ACME certificate authority (src/tls/). It is optional: the proxy path above is unchanged, and nothing about serving a site differs between the two beyond where the bytes are encrypted.

Which names get one is the same rule either way. src/tls/names.rs answers it name by name for Caddy's /tls-check and all at once for npanel's own client: the dashboard hostname, every enabled host, and the www of each root domain that redirects it. Two answers to one question can't drift if one function gives both.

Certificates are reconciled, like builds and onions. Startup, a host being added, renamed, enabled or disabled, a setting changing, and a timer all run the same routine on the TLS runtime's thread: read the names, read what is stored, serve exactly the stored certificates that are wanted and unexpired, then order any name that is due — one without a certificate, or one with a third of its lifetime left — one at a time. Nothing is remembered between two runs, so the runtime restarts as cheaply as the others.

A name that fails waits before its next attempt — two minutes, doubling to an hour — and the backoff is stored with it, so a restart doesn't start it over. That pacing is what keeps a name whose DNS points elsewhere within Let's Encrypt's five failed validations an hour. Each attempt validates by one challenge, alternating between TLS-ALPN-01 and HTTP-01, rather than trying both and spending two. A renewal that fails leaves the old certificate serving until it expires. The two hourly limits that meter /tls-check meter orders here, and the dashboard host is exempt from them in both.

npanel keeps the keys. Certificates, their private keys and the ACME account are rows in the database, kept per CA directory, as onion keys are. A database backup is a complete one, and a restart serves what is stored from the first connection, loaded before the listener opens. Switching to the staging CA and back finds production's certificates where they were.

Handshakes never wait. The listener's certificate resolver reads an ArcSwap map by SNI and nothing else; every change to it is made on the TLS runtime and swapped in whole. A name with no certificate — not yet, or not ever — gets no handshake at all rather than some other name's certificate. The listener offers h2 and http/1.1, and a CA validating TLS-ALPN-01 offers only acme-tls/1, which is answered with that name's challenge certificate and closed once the handshake is done.

Plain HTTP serves nothing. port answers HTTP-01 challenges and sends every name npanel would serve to the same path over HTTPS with a 308, kept for an hour like the www redirect; any other name is a 404, so the redirect can't be pointed elsewhere.

No proxy means no forwarded headers. A request off the TLS listener is handed to the app as Via::Tls, which fixes its scheme at https and its visitor at the address that connected (Peer::Visitor). X-Forwarded-For is the visitor's own to write there, so analytics no longer read it — as over Tor, where there is no address at all (Peer::Unknown).

The nsite model

A site is identified by a pubkey, a kind, and an identifier:

  • kind 15128 — a root site. One per pubkey; the identifier is always "".
  • kind 35128 — a named site, addressed by its d tag. A pubkey can publish many.

The manifest event lists the site's files as tags:

["path", "/index.html", "<sha256>"]
["path", "/assets/app.js", "<sha256>"]
["server", "https://blossom.example.com"]

Each sha256 is a Blossom blob, fetched from <server>/<sha256>. Where those servers are comes from, in order: the manifest's own server tags, the author's kind 10063 list, then the configured fallbacks (blob_sources, src/nostr/service.rs).

src/nsite_address.rs accepts every form a person might paste — npub, nprofile, naddr, an a tag, a NIP-5A label, a URL — and reduces it to { pubkey, kind, identifier }. The dashboard has a TypeScript twin of it (dashboard/src/shared/nsite-address.ts) so the address preview in the UI matches what the server will store; the two are changed together.

Sync

SyncWorker (src/sync.rs) is driven by three things:

  1. A live subscription to the configured relays, filtered to the authors and kinds of every enabled host. The authors' own write relays (kind 10002) are merged in, since that's where they actually publish. New deploys usually land within seconds.
  2. A periodic resync every resync_interval seconds, which re-fetches each site's newest manifest. This is the backstop for anything missed during relay downtime or a restart.
  3. Explicit triggers — adding or retargeting a host, or a site's Check for a new version button, which calls POST /api/hosts/:hostname/sync.

A manifest for a site nobody has configured is ignored. One that is configured is stored verbatim in site_events, its path tags are expanded into site_files, and the version is queued for deployment.

Deploying means downloading every blob the version references, four at a time, trying each candidate server in turn. Only when all of them are on disk does the version become ready.

What gets downloaded is the author's choice, not staff's, so it is bounded. Only a site's newest version is deployed — one that a newer manifest overtook while it waited fails as superseded — and a version over max_site_files or max_site_size fails without being retried. An author publishing manifests as fast as they can is downloaded one bounded version at a time, not one version per manifest. Superseded versions hold their blobs until the daily prune removes them (see pruning).

Downloads only go to public addresses, and there is no way to turn that off. Download targets are not npanel's to choose — they come from the manifest's own server tags, from the author's kind 10063 list, and from wherever those servers redirect — so without the rule, publishing a manifest would be enough to make npanel probe cloud metadata, a LAN admin interface, or a database's HTTP port, with the resulting status codes landing in a deploy log the author can read. Redirects are followed by hand, three at most, so each hop is resolved and checked rather than only the first. The check is the resolver the HTTP client connects through (PublicResolver, src/net.rs), so the address checked is the address dialed and a DNS-rebinding name gets no second answer.

Relays the author lists in their kind 10002 are held to the same rule, since they are just as much the author's to choose and each one is a WebSocket the deploy runtime opens. Only wss:// URLs on public addresses are used, and only the first five (vet_relay_hints, src/nostr/service.rs). As with downloads, the relay connection is checked where it is made: the pool resolves the name itself and dials the addresses it checked (src/nostr/pool.rs), so a rebinding name gets no second answer. The relays an admin configures are not checked against the address rule — pointing npanel at a relay on the LAN is theirs to decide — but they too must be wss://.

That last part is the important invariant: a host serves only ready versions. A deploy that is still downloading, or that failed outright, never replaces a working site — the previous version keeps serving. A brand-new host with no ready version returns 503 with a Retry-After instead of a broken page.

A deploy that fails is tried again — deploy_attempts times, three by default, waiting five seconds and then ten — before npanel leaves it to the resync timer. The queue orders versions by when they may next be attempted, so a version waiting out its backoff never delays one that is ready to go, and only an otherwise empty queue actually sleeps. Retries are free of any special bookkeeping because the blob store is content-addressed: whatever an attempt managed to download is still there, so the next one asks only for what is missing. The attempt count lives in memory and covers one run, so a resync or a settings change starts a fresh one; that is what lets a site recover by itself when the server holding its blobs comes back hours later.

The invariant survives shutdown, too. A deploy that stops with blobs it never attempted is put back to pending rather than being marked ready or failed — ready would serve a site with missing files and nothing retries a ready version, and failed would be a lie about the site.

Every step also writes to deploy_log, which the dashboard shows per version: which servers were tried, which blob failed and why, and the final verdict. A failed deploy explains itself without anyone SSHing into the box.

Builds

A ready version is not yet something a host serves. What a host serves is its build: the finished bundle for that host, made once and then frozen (HostBuilder, src/builds.rs). A build is a path map — the host's version chain flattened into one, plus every file npanel generates or rewrites for that host: a sitemap, a robots.txt with a line appended, the window.ENV script for a host with variables, the tracker script with analytics counted by it, and every page rewritten to load them — and everything in it is an ordinary stored blob with its compressed copies. So every body a site sends is a stored file, sent from a kept memory mapping or straight from disk, including the ones npanel wrote, and all the work of making them happens once, when the host is built, rather than on a request. Only headers are decided per request.

Every script npanel adds to a page — window.ENV and the tracker — is loaded at a URL versioned by its contents, /_npanel/script.js?v=<hash>. The path answers whatever the query says, and the tracker is cached for an hour like any other asset; the version is what lets a change reach visitors at once, since the pages naming it revalidate on every request and each change names a URL no cache has seen. An ETag alone would not do it: nothing asks for the script again until its hour is up. The env script is cached differently, for reasons of its own — see environment variables.

The invariant goes up a level with it: a host serves only its newest ready build. A version that finishes deploying goes live on a host once that host's build of it is ready, and a build that fails leaves the previous one serving and says why in the version's deploy log. A host that has never been built answers 503, exactly as one whose site has never deployed.

Builds are keyed by host rather than by version, because the output depends on the host: its hostname is named in the sitemap, and its settings decide what is generated at all. An onion gets a build of its own for the same reason — a sitemap fetched over Tor lists onion URLs. What the two share is stored once, being content-addressed; only the rows are doubled.

Hosts are reconciled, not tracked, the way Tor's onions are. A deploy finishing, a host being edited, an onion being minted, and startup all ask the same question: what should each host's build be made of? The answer — the builder's version, the origin, the version chain, and the flags that shape the output — is hashed into a fingerprint, and a host is rebuilt only where that differs from the build it is serving. Flipping a switch that only changes headers (csp, spa) therefore rebuilds nothing, and a new npanel whose builds would come out differently bumps BUILDER_VERSION and rebuilds everything at startup. A rename keeps the old build serving until the new one names the new hostname.

A build carries every file row it serves and references its versions by id only, so pruning the versions it was made from cannot take a working site down. Each host keeps its current build and the one before it — a request that looked up the old one a moment before the switch may still be resolving a path in it — and older ones are deleted as the new one goes live.

The deploy runtime

Sync and builds run on a Tokio runtime of their own, on threads of their own (src/deploy.rs), starting from the relay pool. Everything expensive about deploying a site happens there: checking the signature of every event a relay sends, downloading and hashing every blob, compressing at brotli's slowest setting, rewriting pages and generating files for a build, and the database writes that record each step. None of it is on the threads that serve sites, which go on answering requests for the whole of a large deploy.

The two runtimes share the settings (a change is broadcast to both) and the serving cache (the deploy runtime clears it when a build goes live), and write through the same pool of database connections. So very little has to cross the channel between them: the API says when a host was edited or someone pressed Check for a new version (DeployService).

None of what the API says has to arrive. Sync and builds are reconciled rather than tracked, so a runtime starting up does everything a missed message would have asked for: it resyncs every site and rebuilds every host whose build is out of date. That is what makes it cheap to restart. A panic that escapes it ends it and it is started again with backoff, and whatever is sent while it starts waits in the channel until it is ready. A panic inside one deploy or one build is caught where it happens and fails that version or that host, rather than the queue behind it.

The price is a second SQLite writer. WAL keeps reads from ever waiting on a write, but two writes still take turns, and every query runs on Tokio's blocking pool so a wait never holds up a runtime thread. Serving never writes, the API writes only when staff change something, and the deploy runtime's transactions are short — the longest inserts one build's file rows — so the worst it costs is a dashboard save waiting a moment, never a visitor.

Storage

SQLite via rusqlite, with SQLite compiled in, at data_dir/npanel.sqlite3. Migrations run at startup (src/db/migrations.rs). Analytics live in a database of their own beside it — see analytics. Connections are pooled (src/db/mod.rs); each one maps the database into memory and keeps its prepared statements. The pool is bounded at sixteen, and a query arriving when all are in use waits for one rather than opening another — opening a connection costs far more than any query npanel makes, and a burst of them, like every hostname missing the serving cache the moment a build goes live, would otherwise open a connection per query. Async callers wait their turn before reaching the blocking pool, so a burst queues as futures rather than as parked threads.

The tables:

Table Holds
users Who may log in, and their role
hosts Hostname → {pubkey, kind, identifier}, pin, enabled, hidden, www, spa, csp, seo, merge
host_grants Which staff may manage which host
site_events Every manifest seen, verbatim, with deploy status
site_files Path → sha256, per version
host_builds What each host serves: a frozen bundle, per host and name kind
host_build_files Path → sha256, per build, and where each file came from
blobs Size, sidecar sizes, and last-seen time per stored blob
settings Runtime configuration, JSON-encoded
audit_log Every change to a host, user, or setting
deploy_log Per-version deploy trace, capped at 500 entries
onion_services A host's Tor address and the key it is named by
nip05_names The NIP-05 names a host answers for
host_env The variables a host's pages read as window.ENV
tunnels Hostname → the key of the agent serving it, and whether it's on

Blobs live on the filesystem at data_dir/blobs/<ab>/<cd>/<sha256>, a two-level fanout that keeps directories small. Downloads stream into blobs/tmp/ while being hashed and are renamed into place only if the hash matches, so a truncated transfer or a lying server can't leave a bad blob behind, and readers never see a partial file. Leftover temp files are cleared at startup — they can only be the debris of a crash.

Because storage is content-addressed, a file shared between versions or between sites is stored once, and rolling back to an old version usually downloads nothing.

Compressed copies sit beside their blob as <sha256>.br and <sha256>.gz, written during the deploy that downloads it. Compressing once at deploy time rather than on every request is the whole point: a gateway serves the same bytes to everyone, so paying for brotli's slowest setting once buys a smaller transfer for every visitor afterwards, including the ones the proxy in front of npanel would never have reached.

Whether a blob is worth compressing is decided from the paths it is served under, because the store is keyed by hash and so can't tell markup from a JPEG — a blob is compressed if any path referencing it has a content type that is text underneath. A copy is kept only if it beats the blob by a real margin, and blobs under half a kilobyte are skipped: they arrive in one packet either way.

A sidecar is an attribute of its blob, not a blob of its own. Nothing references one by name, BlobStore.list() doesn't yield them — the name test that keeps stray files out of pruning excludes them, since they aren't 64 hex characters — and deleting a blob deletes them with it. Pruning therefore goes on working entirely in terms of hashes.

Once a deploy has settled which copies a blob has, their sizes are recorded on its blobs row — including the settled absence of one, which is -1 and not NULL, because "no copy is kept" and "nobody has looked yet" must not read the same. The sizes then ride along on the site_files lookup a request already makes, so serving answers Accept-Encoding without touching the disk; a NULL row falls back to statting the sidecars until the next deploy that references the blob — or one run of npanel compress-blobs — fills it in.

Pruning

Nothing would otherwise ever be deleted: every version of every site stays, with its files. So once a day, ten minutes after starting, and whenever keep_versions changes, the deploy runtime prunes (src/prune.rs). The prune-blobs command runs the same code on demand.

It forgets versions first, in one transaction: every version of a site no host points at, and each site's ready versions past the newest keep_versions. Only ready versions count, because they are what a site can roll back to. One that never got there and is older than the newest ready version can never be deployed — only a site's newest is — so it goes too. Anything newer may still be deploying and is left alone, as are the version a host serves, its pin, and every version a build names.

A merged build holds the files of every version in its chain, so forgetting versions frees nothing on that host until it is rebuilt with the shorter chain. Every prune therefore asks the builder to reconcile every merged host, which rebuilds only those whose chain actually changed — including after a prune-blobs run, which can't reach a running server's builder.

Then it deletes every blob nothing references. Listing the store is the slow part and holds no connection; the deleting happens five hundred blobs to a write transaction, and each blob is asked again, inside it, whether any version or build references it or it was recorded in the last hour. The file is gone before the transaction commits. That is what makes pruning safe beside a deploy that comes to need a blob pruning has picked: a manifest naming it either committed first, and is seen, or waits on the transaction, and its deploy finds the blob missing and downloads it again. A version is never marked ready on a blob pruning has just deleted. The hour is for a build, which stores the files it generates before it writes the rows naming them.

Serving a site

src/serve.rs handles a request in a few steps: find the host, find its build, resolve the path, send the blob.

Path resolution mirrors what static hosts do: exact match, then index.html for extensionless paths, then the site's own /404.html. Resolution reads host_build_files, never a manifest event — serving a file never parses Nostr.

The answer is then held in memory, keyed by build and path, which is what takes the last database query off a warm request. Nothing invalidates it and nothing has to: a build's files are written once and never edited, and a build id is never reused, so a rebuild asks a different question rather than changing the answer to this one. The blob sizes that ride along are the exception, since compress-blobs can record them long after a deploy, so a row that hasn't got them yet is left uncached rather than pinned to the stat it falls back to.

That last step is where a single-page app parts ways with a static site, so it is a per-host flag rather than a guess. By default a path the site has no file for is a 404, carrying /404.html if the site published one — the honest answer, and the one crawlers, uptime checks and curl -f are entitled to. With spa set, a document request (no extension, or .html) instead gets the site's shell under a 200: /404.html first, because NIP-5A names it as the fallback path and an nsyte build publishes its shell there for exactly that reason, then /index.html, for a build that expects the try_files every other static host does. A path whose last segment has an @ in it — /@bob@example.com, the way Nostr and fediverse clients route a NIP-05 name or a handle — is a document too: its dot is a domain's, not an extension. Requests for anything else keep their 404 in both modes — a script or stylesheet answered with a page of HTML and a 200 doesn't render the site, it turns a plain missing file into a parse error somewhere else. /.well-known/ skips every fallback, in both modes: what asks there is software probing for a capability — Universal Links, WebFinger, Matrix — and any page at all, let alone one under a 200, tells it the site has one. Those paths also get their content types by name (src/mime.rs), since the registered ones mostly have no extension. The flag is off for every existing host and every new one: folding www into a domain preserves what a response means and can default on, while turning a 404 into a 200 does not.

The merge_deployments flag, also off by default, changes what a build is made of rather than what resolution does. A merged host's build flattens a chain of versions — the one it serves, and every earlier ready version of the same site underneath it, newest first — taking each path from the newest version that has it (version_chain, src/sites.rs). The served version therefore wins everywhere it published a file, and a path it dropped goes on being answered by the deploy that last published it. Since resolution sees the flattened map, an exact match in a superseded version still beats an index.html in the served one, exactly as it would within a single version.

This is for builds that load modules by name at runtime. A Vite site names its chunks by content hash, and a page a visitor already has open goes on asking for the names of the deploy it was loaded from — which are the files the next deploy removes. Without merging, a deploy mid-session breaks every open page.

Nothing newer than the served version is in the chain, so a pinned host merges up to its pin rather than past it, and only ready versions are in it: an unfinished deploy's blobs may not be on disk, and merging must not undo the invariant that serving never reaches one. The chain is capped, because a build writes a row for every path anywhere in it. Pruning is what bounds it in practice: the daily prune forgets old versions first, so the next build reaches exactly as far back as the versions still in the database — and it deletes only blobs no remaining version or build references, so a build can never resolve a path to a blob pruning took away.

What merging deliberately doesn't touch is the generated sitemap.xml, which lists the served version's own pages. Keeping a withdrawn URL answering, for the pages and caches still asking for it, is not a reason to invite a crawler to index a page the site's latest deploy deleted.

Responses carry an ETag of the blob hash (so conditional requests are free and exact), Last-Modified from the event's created_at — capped at when npanel saw it, since an author can date a manifest however they please — and an Nsite-Location header naming the same page on Nostr, nsite://<naddr><path>. Range requests are supported, including suffix ranges.

Which of a blob's stored copies gets sent is settled before any of that. The client's Accept-Encoding is honored as the ranking it is — zero means refused — but ties are broken by size rather than by a preference of npanel's own, and ties are the case that happens: browsers offer their encodings at equal quality, so a client is sent the smallest copy it can read. Tor Browser reaching an onion offers only gzip, because Firefox withholds br from plain HTTP, and gets the gzip copy. A client that accepts nothing stored gets the blob; there is no 406, since for a static file a slower answer beats no answer.

Everything about the response then describes the copy that won, not the blob: its length, its ETag, and the bytes a Range counts from. The ETag has to be per encoding — one tag across all three would let a shared cache holding the brotli copy hand it to the next client whose If-None-Match matched, however little brotli that client speaks — and Vary: Accept-Encoding goes on every site response, including ones that negotiated nothing, because a cache has to be told before it stores the first response rather than after.

This is why the sample Caddyfile has no encode on the site block. There is nothing left for the proxy to compress, and a proxy could only ever help the visitors who arrive through it — not the ones on a site's onion address.

Sites are also served under a Content-Security-Policy. Which one is the site's to say: if its /index.html declares a policy in a <meta http-equiv> tag, that is what npanel sends, on every response for the host. A site that declares nothing gets the gateway's, which is the same policy mkstack puts in its index.html — a static site is exactly the thing with no business running an inline script, and script-src 'self' is what keeps a compromised deploy or a careless innerHTML from being an XSS on an origin your visitors, and their Nostr keys, trust.

Either way it goes out as a header rather than being left to the meta tag, because a meta tag is only read once the parser reaches it and doesn't apply to responses that aren't HTML. A declared policy replaces the gateway's rather than joining it, which means a site can widen its own — that is the author's call about their own hostname, and it reaches no other host. The policy is read from the build's /index.html on the first request for it and cached against the build, which is safe because a build is written once and never edited — and reading the build rather than the manifest means a document npanel rewrote and the header it goes out under can't disagree.

The csp flag, which defaults on, turns the header off entirely for one host — for a site that needs something neither policy allows. It is per host and there is no instance-wide equivalent, on purpose: a global off switch makes disarming every site at once easier than fixing the one site that needed it.

Sites also get their standing with search engines looked after, from data npanel already holds, and all of it made when the host is built. A site that published no /sitemap.xml gets one generated from the version's file list — its HTML paths in canonical form, dated by the version — and a site without /robots.txt gets one that points at the sitemap, which is how crawlers find it without anyone registering anything. The rule is the NIP-05 one in reverse: only where the site published no file at that exact path, and a site's own /404.html standing in for the missing path doesn't count as publishing one.

robots.txt is then the one exception to that rule, because standing in for a missing file turned out to cover almost nobody: nearly every nsite ships the boilerplate User-agent: * / Allow: /, which names no sitemap and blocks the generated one from ever appearing. So a published robots.txt that names no sitemap has npanel's Sitemap: line appended to it, with everything the site wrote copied through verbatim. That is a narrow thing to be allowed: Sitemap is a non-group directive, so it grants no crawler anything and cannot change what an Allow or Disallow above it means. A file that already names a sitemap is left alone — it has answered the question, and a second pointer would be npanel arguing with it — as is one where any group carries a blanket Disallow: /, where an invitation to index would be a strange thing to add on the site's behalf.

Both kinds of file go into the host's build as blobs of their own, so they are served like any other file: compressed, with an ETag of their own hash, and with ranges counted against the bytes actually sent. A published file is read at most 64 KB; anything larger is served as published. The Sitemap: line names the host being served, which is part of why builds are per host — six sites sharing one boilerplate blob each get their own appended copy.

Page responses carry a Link: rel="canonical" header naming the one spelling of the page, so /foo, /foo/, /foo.html and /foo/index.html are indexed as one page rather than four competing copies. The spelling kept is the clean one — extensions come off, and index.html collapses into its directory — because that is the URL static site generators actually link to: a VitePress build with cleanUrls publishes hacking.html, links to /hacking throughout, names that URL in its own sitemap and puts it in the page's og:url. Naming the file instead had npanel contradicting sites about their own addresses. It is safe rather than a guess because the clean URL is always servable here — resolution tries a .html sibling for exactly this reason — and only .html is stripped, since .htm has no such step. The sitemap uses the same function, so the two can't disagree. The header is only sent when the request path actually spells the file it resolved to, which keeps it off SPA-mode fallbacks: a shell answering for /any/client/route with a canonical of / would tell crawlers the whole app is one page.

All three hang off the seo flag, per host and defaulting on like csp. The off switch exists because the canonical header is an assertion: a site that declares <link rel="canonical"> tags of its own — hreflang sets, staging mirrors — would be contradicted by it, and npanel doesn't parse every document to check.

Caching splits by what a file is. A site's URLs are stable across deploys while the blobs behind them are not, so documents are sent no-cache and revalidate against the ETag on every request; assets, which a revalidated document links to by name, get an hour. Nothing is cached long enough to outlive a deploy.

Nothing is proxied at request time. Every byte comes from local disk, which is why a served site survives its relays and Blossom servers going offline.

How those bytes leave depends on the file's size. A body is a slice of the blob's kept memory mapping, which hyper hands the connection in one vectored write with the head, and a plain TCP connection (TcpIo, src/server.rs) sends a slice of a mapping of 64 KiB or more with sendfile(2) instead: the kernel moves the file's pages from the page cache to the socket, and nothing is copied through npanel. That is most connections in production, since the proxy in front speaks plain HTTP.

hyper has no way to be handed a file, so the connection recognises one by address (src/send_file.rs). Every large mapping is registered, by the range of memory it occupies, for as long as it exists; a slice the connection is asked to write that lies inside one is a range of that file, at the offset the address says. hyper writes a body to a connection that takes vectored writes without copying it, so the slice it passes is the mapping's own memory. The bytes are the file's either way, so anything that keeps a slice from being recognised — TLS npanel terminated itself, an onion, a hyper that copied bodies — changes how fast a file is sent and never what is sent. A test in src/server.rs counts what went by sendfile, so a hyper upgrade that starts copying bodies fails it rather than quietly losing the speed.

While a file goes out the socket is corked (TCP_CORK), from before the head until the file's last byte is handed over, which is nginx's tcp_nopush. Every connection has TCP_NODELAY on, so uncorked, the head would go out in a packet of its own and the file in whatever pieces sendfile hands the kernel: about a quarter more segments for a megabyte, each its own trip through the network stack. Corked, the head and the file fill whole segments together.

The 64 KiB line is measured. Below it, sendfile and a write from the mapping tie, and registering a file would only hold its descriptor open; from it, sendfile is 8% faster, and 30–55% from 128 KiB to 16 MiB. Each registered mapping holds its file open for as long as it is kept, so the server takes its hard limit on open files as its soft one at startup rather than living within systemd's 1024.

NIP-05 names

A host can also answer for a set of NIP-05 names, kept in nip05_names and served at /.well-known/nostr.json. Staff add them on the site's own tab in the dashboard, and anyone with a grant on the site can, because a name is a setting of that site rather than of the instance.

The rule at the path is simple: if the host has names, npanel serves them and the site's own nostr.json is shadowed; if it has none, the path resolves like any other, serving the deployed file or a 404. Removing the last name hands the path straight back. That way round because a nostr.json in a repository is a file every deploy has to remember to carry, and forgetting it quietly unnames everyone in it — whereas the database survives a deploy that knows nothing about it.

The names are answered before the deploy check, so one resolves the moment it is added, whether or not the site behind the hostname has ever finished deploying. They are the gateway's data, not the manifest's. A disabled host still answers for nobody, since that hostname isn't served at all.

A request naming a name gets that one, and a request naming none gets the whole list — which is what lets other tools discover who a domain hosts, and is the same list a static file would have handed over. A name the host doesn't have is an empty document under a 200 rather than a 404: the question was answered. Every site response is already Access-Control-Allow-Origin: *, which NIP-05 requires, because a browser-only client cannot tell a CORS refusal apart from a missing file.

Names live in a table of their own keyed by host id rather than hostname, so renaming a site carries them along and deleting it takes them with it — the same ON DELETE CASCADE reasoning as the onion keys. The next site to be given that hostname never made those assertions about anyone.

Environment variables

A host can also carry variables, which its pages read as window.ENV. Staff set them on the site's Environment page, a name and a value to a row, and as with NIP-05 names anyone with a grant on the site can.

A static site has no server to read configuration from, so the same build goes everywhere with whatever it was built with. This is the browser half of the pattern Remix documents: the server — here, the gateway — renders the values into a script that runs before the site's own, and a site written to look reads window.ENV.API_URL rather than a constant baked in at build time. One deploy can then be served on two hosts pointed at two backends, and a backend can move without a rebuild. A site that doesn't look is unaffected.

The script is part of the host's build (src/site_env.rs, src/builds.rs), made as the tracker is: stored as a blob at /_npanel/env.js, wrapped in a LibreJS license block, and loaded by every page, which is rewritten once to say so. The differences are all about running first. The tag goes at the start of the head rather than the end, and is a classic script rather than a module, so it blocks the parser and has set window.ENV before any script the site wrote starts. It also comes before any <base> that could send its path somewhere else. A <meta> Content Security Policy after it is widened all the same, since npanel sends a declared policy as a header, and a header covers the whole page.

The script assigns self.ENV, which on a page is window.ENV. In a worker there is no window but there is a self, so a site's service worker can importScripts the same file and read the same variables.

The tag loads the script at a versioned URL, like every script npanel adds (see builds), so a changed value reaches visitors at once.

Blocking the parser has a price: a browser puts no timeout on a script, so a request that hangs — on a connection that drops packets rather than failing — leaves the page blank for as long as it does. So the script is served with max-age=0, stale-while-revalidate for a year: a browser that has a copy runs it at once and refreshes it in the background, and never waits on the network for it. Only a first visit, or one after the browser evicted it, fetches the script before the page goes on, over the connection that has just delivered the page.

A page from the network loses nothing by this. It names the current version, and a changed value is a new URL nothing has cached, fetched there and then. The difference is for a site with an offline shell, whose service worker hands back a page naming the version it was installed with: that page runs the copy it has, and sees a change one load later — the same lag the shell's own code has when it updates. Offline, it runs the last values it saw.

Chrome and Edge 75, Firefox 68 and Safari 14 understand the directive. A browser that doesn't treats the script as no-cache, and waits on it every load as it waits on the page itself.

The variables are part of the build's fingerprint, so changing one rebuilds the host and nothing else. They enter the fingerprint only when there are some, so a host without them hashes exactly as it did before variables existed.

Everything in the script is public. It is served to every visitor, and nothing secret belongs in it — the dashboard says so beside the list.

Variables live in a table keyed by host id, like NIP-05 names, so a rename carries them along and deleting the host takes them with it.

Tor

With tor_enabled on, every enabled site is also published as a v3 onion service. Staff do nothing: adding a site mints an address for it, removing one takes its address away with it.

Tor is built in. npanel embeds Arti, the Tor Project's implementation in Rust, so there is no daemon to install, no control port to secure, and no second process to keep in step with this one. It runs on a Tokio runtime and a thread of its own (src/tor.rs), restarted like the deploy runtime if it dies: building circuits, the directory work Arti does whenever a new consensus arrives, and serving the sites' onion traffic all happen there, never on the threads serving clearnet visitors.

tor-netdoc comes from Soapbox's fork of Arti ([patch.crates-io] in Cargo.toml), on a branch named for the Arti release it patches. Upstream compares relay family members in constant time; path selection does that for every candidate relay against every member of its family, hundreds for large operators, and with a dozen sites the circuits that kept their services up cost both of the runtime's threads. The threads starved, circuits timed out, the pool of circuits kept ready for onion services grew to replace them, and the services were unreachable most of the time. The fork compares plain bytes; relay identities are public. Upgrading Arti means rebasing that patch onto the new release's tag as a new branch, and pointing Cargo.toml at it.

npanel keeps the keys and Tor keeps nothing. Arti's keystore is its in-memory one, and each service is launched with the key stored in onion_services. The key is what an address is made of, so storing it is what makes the address survive a restart. What Arti does keep — under data_dir/tor, its directory cache and its guards — makes coming back faster and keeps the same guards, which is safer; losing it costs a slower start and no address. Arti also records each service's introduction points, but the keys those were made with lived in memory and are gone after a restart, so that record is cleared as Tor starts and every service picks fresh ones, as a C Tor service that isn't detached does. Each restart therefore republishes every descriptor, and a visitor holding an old one fetches the new one on their own.

Arti's own logs, which go through tracing, are forwarded to npanel's as tor_log entries: its warnings always, its info lines at log_level = "debug". The exception is what comes from running onion services and the circuits kept ready for them, which is debug however severe Arti calls it: each line is one failure Arti retries, and a dozen healthy services make hundreds an hour. In their place npanel watches each service's state as Arti summarizes it, and warns with tor_onion_unreachable once one has been unreachable for ten minutes (UNREACHABLE_GRACE, src/tor.rs). Keys are stored as C Tor spells them in ADD_ONION (ED25519-V3: and the expanded secret key), which is how they were stored when npanel drove a separate Tor daemon, so every address from then carried over unchanged.

Arti finds a service's key and its state by a nickname, and each service is nicknamed after its own address. A nickname can only ever mean one key, so a host deleted and another created in its place can never inherit its state. After launching, npanel checks that Arti is answering at the address it was asked for, because a service launched without its key would quietly make up a new one.

Sites are reconciled, not tracked. Tor starting, adding or removing a host, and changing a setting all run the same routine: ask the database which hosts are enabled, and make the running services match. Nothing is remembered between two runs, so a reconcile interrupted halfway is repaired by the next one, and a service that fails to launch is retried with backoff.

Onion connections never reach a listener, and are served as the onion they arrived at. Arti hands npanel each stream directly, and it goes to hyper without touching a socket (Connections in src/server.rs), so there is no port for anything else to reach. Tor forwards a visitor's bytes unaltered, so every header in an onion request is the visitor's to choose, including Host. A stream belongs to one service, so a request whose Host names anything but that service's own address — the dashboard, another site, another onion — gets a 404. The dashboard and the JSON API cannot be reached through an onion however a request dresses itself up.

Each request states its RequestOrigin outright — scheme: "http", because there is no proxy and no TLS anywhere on this path — rather than reading X-Forwarded-Proto, which here would be a header the visitor wrote. Tor authenticates and encrypts the connection itself, which is what the scheme would otherwise be claiming.

Only a BEGIN for port 80 is accepted, and anything else is refused with DONE, as C Tor does, so that nothing about a refusal tells these services apart from any other.

One consequence elsewhere: .onion is in the Public Suffix List, so an onion address would otherwise read as a root domain and be offered a www redirect into nothing — isRootDomain excludes them.

Clearnet HTML responses carry Onion-Location, which is how Tor Browser offers the address to a visitor without anyone having to hand it out. It is sent only over HTTPS, because on a plain-HTTP hop anything in the path could rewrite it and point the visitor at an onion of its own choosing.

Tunnels

A tunnel is a hostname npanel serves by passing its requests to an agent on another machine, which passes them on to a web server there — what cloudflared does for Cloudflare. The agent is this same binary, run as npanel tunnel <server> <port> (src/tunnel/agent.rs); the gateway's half is src/tunnel/gateway.rs.

The agent dials out. It connects to the dashboard host over HTTPS, as a browser would, and asks to upgrade the connection (Upgrade: npanel-tunnel on /api/tunnel/connect). So it works from behind NAT and a firewall, and through the proxy in front of npanel, which carries an upgrade as it carries a WebSocket. Once the connection is upgraded the roles reverse: npanel is the HTTP/2 client and the agent the server. Each visitor's request is a stream of its own, so one slow response holds up nobody else's, and HTTP/2's flow control keeps a large upload or download from swamping the connection. A flow-control window caps a connection at its size per round trip, so both ends open with large ones: 16 MiB for a connection and 4 MiB for a stream, which is 80 MB/s for one download over a 50 ms link. They are fixed rather than hyper's adaptive window, which grows to fit the link but opens at 64 KiB, because h2 sizes its guard against floods of small DATA frames from the opening window: room for about 130 unread at once across the connection. Every WebSocket message is a small frame, and a few hundred sockets each sending one together closed the connection, with every socket on it. Half of 16 MiB is room for some 32,000. A window is also as much as one end will hold for the other before it is read, so it bounds what a slow visitor can leave buffered in npanel, as the adaptive window's own ceiling of 16 MiB did. The agent holds two connections, so either end can lose one without the site going dark. npanel sends each request down whichever has the fewest streams open, taking turns between equals, so a connection carrying a large download or holding many WebSockets isn't handed requests the others have room for. A connection may carry 10,000 streams at once: every open WebSocket and event stream holds one for as long as it's open, and hyper's default of 200 would cap a tunnel at a few hundred of them, each past it waiting until it timed out. An idle stream costs a few kilobytes, as does each direction of a WebSocket's copy, which goes through a 4 KiB buffer at each end rather than tokio's 8 KiB. Each connection is one task at each end, framing and encrypting every stream on it, so a busy tunnel spreads across more cores with more connections (--connections) or more agents sharing its key, up to tunnel_max_connections in all. A connection counts against that from the moment it is let in, so agents connecting at once can't slip past it together. Both ends ping every 20 seconds, under the idle timeout of most NATs and proxies, and give a connection up when a ping goes unanswered.

A WebSocket is a stream too. A visitor's Upgrade: websocket becomes an extended CONNECT stream (RFC 8441), which the agent opens to its origin as the ordinary HTTP/1.1 upgrade it was. Once the origin has switched protocols, each end copies bytes and parses nothing, so any subprotocol works. Nothing else that asks to upgrade is upgraded: it is passed on as the plain request it also is.

What is passed on. Hop-by-hop headers are dropped, as a proxy drops them, and the origin is told X-Forwarded-Host and X-Forwarded-Proto. It is told one visitor address, in both X-Forwarded-For and X-Real-IP: the one analytics would count (client_address, src/app.rs), which is the address that connected when npanel terminates TLS, and the proxy's word for it otherwise. Every other header that names a client — Forwarded, True-Client-IP, CF-Connecting-IP and their like — is removed. The origin sees every request arrive from the agent, which it is right to trust as a proxy, so a visitor's own copy of one of those reaching it would be believed: X-Real-IP: 127.0.0.1 would make a request from anywhere look local. Host is the hostname the visitor asked for, as any reverse proxy passes it on; the target is only where the agent dials. A tunnel is for production servers, which route, redirect and build URLs by Host, and an origin told its own address would send visitors to localhost. A development server that checks Host refuses the public name until it is configured to allow it, which is deliberate. An origin has 100 seconds to start answering, as on Cloudflare, counted from the last of the request body to reach it: an upload that takes ten minutes is the visitor's time, and a Blossom server answers only once it has the whole file. The clock starts over with every piece of the body that goes by, so an upload that stops moving, or an agent that stops taking it, gets an answer too. After that, response bodies stream for as long as they take, so a server-sent event stream stays open.

An agent is its key. The first time it is pointed at a server, the agent makes a secp256k1 key and keeps it under ~/.config/npanel/tunnels/, one per server and target. Every request it makes to the API is NIP-98-signed with it, and verify_nip98 checks it exactly as it checks a user's; the difference is that a tunnel is the row it is looked up in, not users. An agent with no tunnel asks to be authorized and is given a code, eight letters from a set no one misreads, which it prints with a link to /tunnels/authorize on the dashboard. An admin opens it, checks that the code matches, and gives the tunnel a hostname. The agent asks every two seconds, and connects as soon as it has been let in. From then on it connects with nobody's help.

Anyone can ask, so a stranger can send an admin a real link to a request of their own, and the code on the page will match whatever the stranger says it should. Making the admin type the code would not change that — the stranger would just say which code to type. So the page says what an approval gives away, and says where the request came from beside where the admin is: one from the admin's own network (the same IPv4 address, or IPv6 /64) is very likely the agent they just started. The agent builds the link it opens from the dashboard's hostname and a code it has checked, rather than opening a URL the server sends, and strips control characters from anything of the server's it prints.

A signed request can be used again for as long as its signature is fresh, a minute either way. Connecting is the one where that matters — a copy of the header from a proxy's log would let someone else serve the hostname — so each signature lets one connection in and is remembered until it is too old to be accepted anyway. It is the signature that is remembered, not the event id: an agent's connections signed in the same second are the same event.

Waiting agents are kept in memory, not the database: a code lasts fifteen minutes, sixty-four agents may wait at once, and a restart only means the agent asks again. Asking costs nothing but a key, so the list is bounded, the oldest is forgotten to make room, and one address may have only three waiting and ask ten times in ten minutes. An address asking as fast as it can then crowds out only its own requests.

Only admins authorize, and only the agent decides where traffic goes. Approving a tunnel lets whoever runs the agent serve anything they like on a domain, so it is an admin's decision, like adding a user. What an admin controls is which hostname reaches the agent, whether it is on, and whether its key works at all. Where the agent forwards to is set on its own machine, and shown in the dashboard only as the agent reports it. If the server could set it, a compromised npanel could make every agent probe the network it sits on.

A tunnel can't take a site's hostname, nor a site a tunnel's. The two live in tables of their own, because they have almost nothing in common: a tunnel has no versions, builds or flags. Triggers keep one namespace across both, so the database refuses a second claim to a hostname however it is written. Which hostnames are tunnels is reconciled like everything else: every change reads the enabled tunnels afresh into a map that requests read without waiting, and drops the connections of any tunnel no longer among them. Turning a tunnel off frees its hostname at once, and its agent waits to be turned back on. Deleting it does the same, and the agent stops, telling whoever runs it that the tunnel is gone. Tunnel hostnames get certificates by the same rule as sites' (src/tls/names.rs), but no www redirect and no onion address.

Analytics

A site with analytics on counts its visitors, and npanel is the analytics server: it implements the part of Plausible's that a site talks to and the part its dashboard reads, rather than sending anything anywhere else. There is no Plausible to install, no site to register in one, and nothing for a site's author to add — turning the switch on is the whole setup.

Collection. The host's build adds Plausible's own tracker (@plausible-analytics/tracker, unmodified) to every page, served from the site at /_npanel/script.js with the site's name baked in, reporting to /_npanel/api/event on the same origin. Same-origin is what keeps it inside the site's Content Security Policy and working over the site's onion address; the path is neutral because plausible and analytics are exactly what blocklists look for. See the builds section — the pages are rewritten once, when the host is built, and served like every other stored file.

The tag is an async module, so it runs whenever it arrives and nothing waits on it. A plain module would be deferred, and deferred scripts run in document order before DOMContentLoaded: on a connection that drops packets rather than failing, the request for the tracker would never end, and every deferred script after it — the site's own entry point, often — would never start. That is most likely exactly when it hurts, in a site's offline shell.

Counting on the server. A site with analytics_script off has no tracker, for sites whose visitors won't have one, and is counted from its page loads instead. Each GET for a page that a browser marks as one being opened — Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document, and no Sec-Purpose — becomes a pageview, a 304 included. Requests without those headers aren't counted: every current browser sends them, and most bots don't, which is the nearest the server gets to the filter the tracker is by only ever running in a browser. Everything after that is the tracker's path — the same bot and spam checks, the same visitor hash, the same visits — so the two are counted alike as far as a page load can say. A single-page app's moves between its routes, time on page and scroll depth never reach the server, so they aren't counted.

This is the one place counting touches the threads that serve sites, and it does so as little as it can: the serving thread copies the URL, referrer, user agent and address, and hands them to the analytics runtime without waiting (Analytics::page_load). The handoff takes a slot from the same bounded queue events wait in, and a page load that finds it full, or the runtime down, isn't counted rather than held. A site is counted one way only: events sent to a site counting on the server are dropped, and page loads on a site with the tracker aren't looked at. Counting both and matching them up would need every page load held until its tracker had had time to report, and still couldn't tell a visitor without JavaScript from a bot claiming to be a browser.

Isolation. Events are recorded on a runtime of their own, on a thread of its own (src/analytics/mod.rs). Serving sites is always the first priority, so nothing about counting visits runs on the threads that serve them: not the parsing, not the user-agent and location lookups, not the writes, and not the dashboard's queries. The listener that receives an event — the public one, or Tor's — reads its body, capped at 64 KiB, and hands it across whole. The runtime owns its database file and its caches outright; if it dies, another is started with backoff, and sites go on being served either way.

Ingestion follows Plausible's closely enough that anything written against its POST /api/event works here: the same fields and limits, the same 202 ok, x-plausible-dropped for events it chose not to keep, and the same 400 bodies (src/analytics/request.rs, ingest.rs). Each event is matched to a site by its d, and dropped with a 202 if that site isn't counting, if the user agent is a bot, or if the referrer is on Matomo's spam list. Browsers and operating systems are read with the same Matomo database Plausible uses; sources and channels are ports of Plausible's rules over its own data files; locations come from DB-IP's free City Lite database, downloaded into data_dir/geo the first time a site counts visits and refreshed monthly.

Privacy is Plausible's model. No cookie is set and nothing identifying is stored. A visitor is a 48-bit hash of their user agent, address and the site under a salt that is replaced every day at midnight UTC and deleted the day after, so one person is one visitor for one day on one site and cannot be linked further than that by anyone, npanel included. The address is used for that hash and the location lookup, and dropped.

Sessions are Plausible's too: a visit lasts until thirty minutes pass with no event, takes its source, location and device from the event that began it, and stops being a bounce on a second pageview or any interactive event. Engagement events — time on page and scroll depth — keep a visit alive without counting as anything in it. Open visits live in memory, where each event is matched to one, and are written back as they change.

Storage is SQLite, in data_dir/analytics.sqlite3, apart from npanel's own database so the tables that grow with every pageview are nowhere near the ones serving reads. Nothing is written per event: events collect in memory and are written with every changed visit in one transaction every two seconds, so a crash costs at most those seconds — for counting visitors, a fair price for never making a visitor's request wait on a disk. Both tables are clustered by site and time, because every question the dashboard asks is one site over one range of dates. Every in-memory structure is bounded — open visits, the write buffer, the user-agent cache — so a flood of events costs accuracy rather than memory.

Queries answer the dashboard's API with Plausible's semantics, not its SQL (src/analytics/query/). Periods, comparisons and time buckets are ports of Plausible's own functions, so the same query covers the same seconds. SQLite has no time zones, so bucketing moves each timestamp onto local wall time by the zone's offset — a CASE over the offsets in force across the range, found from the IANA data compiled into npanel, since a range can straddle a daylight-saving change — before formatting it the way the labels are. A question becomes at most a few grouped queries, one over events and one over visits, merged by their dimensions and ordered and paginated in Rust. Visitors are counted from pageviews when the question is about pages and from visits otherwise, and a visit-level metric broken down by page is about the visits that entered there, as in Plausible. Visits by the hour or minute are counted in every bucket they were active in.

Events reach their visit by id, through an explicitly ordered join: SQLite's planner, left to itself, starts from the visits and scans a site's events once for each. On a site of ten thousand visits a day a month's report takes a few hundred milliseconds to a few seconds, all of it on the analytics thread.

Errors

A site with errors on reports what its pages throw, and npanel is the error tracker: it implements enough of Sentry's ingestion that a Sentry SDK can report to it, and a dashboard of its own to read the results. As with analytics, there is nothing to install or register, and nothing for the site's author to add.

Collection. The host's build adds npanel's reporter (assets/errors/reporter.js) to every page, served from the site at /_npanel/errors.js, with the version the build serves baked in as its release. Unlike the tracker it is a classic script at the start of the head, not async: an error thrown while the page loads is the one most worth having, and a listener added afterwards never hears it. That makes it a script every page waits for, as it does for window.ENV, which is why it is a few kilobytes and nothing else. It listens for error and unhandledrejection, parses stack traces itself with the line formats Sentry's browser SDK reads, keeps the last 30 clicks, navigations, fetches and console warnings and errors as breadcrumbs, and reports each distinct error once a page, ten at most, with fetch(…, {keepalive: true}) and a text/plain body so there is no preflight. A report is cut to fit keepalive's 64 KiB, counted in bytes; since that allowance is shared by every request the page has in flight, one refused for it is sent again without. Objects logged or rejected are described one level deep, their first ten entries, so a page logging its whole state costs no more than one logging a word. Errors from cross-origin scripts, which browsers reduce to Script error., are dropped.

Ingestion is Sentry's envelope endpoint, answered at the reporter's /_npanel/api/report and at /_npanel/api/<project>/envelope/, where a Sentry SDK sends given the DSN https://<key>@<site>/_npanel/<project>. The site is the hostname's; the key and project mean nothing. Only event items are read — sessions, transactions and client reports are accepted and dropped — and only what the dashboard shows is kept of them: no user, no headers or cookies, no extra, every field capped, and Nostr secret keys (nsec1…, ncryptsec1…) replaced wherever they appear, since a Nostr client's error message or console line can carry one — as can the value of any URL parameter named like a secret (secret=, as in a bunker URI, #access_token=, api_key=…), which is replaced too (src/errors/envelope.rs). npanel's reporter replaces the same things before it sends, since a page's fragment and its state otherwise never leave the browser; the gateway's pass is still what covers Sentry's own SDKs. A site may send 60 reports a minute; past that it gets Sentry's 429 with X-Sentry-Rate-Limits, which an SDK backs off on, and what it sent is neither kept nor counted. Bots' reports are dropped.

Isolation is analytics': its own runtime on its own thread (src/errors/mod.rs), the listener reading the body — capped at 256 KiB — and handing it across whole, at most 64 waiting, the database and caches owned by the runtime, and restarts with backoff. A report for a site that isn't collecting is answered without its body being read, so those can't take the places of reports for sites that are.

Source maps. npanel has every file of every version a site deploys, so there is no upload step. A frame on the site's own origin is looked up in the files of the version the report names — its release, if that is a ready version of this host's site, or else what the host serves — through the host's version chain, so a merged host finds files from earlier deploys. A release naming a version npanel doesn't have for this site — pruned, or never its — maps nothing, since another version's map would put its lines somewhere they aren't. The script names its map with sourceMappingURL, inline or relative, or the map is the file beside it with .map appended. Maps are parsed with Sentry's own sourcemap crate, and the last few scripts and maps kept parsed, each by its own hash (src/errors/symbolicate.rs).

Anyone can send a report, so what one costs is bounded. Only scripts and pages are read, eight files at most per report, innermost frames first. Each file is parsed once however many reports want it at the same moment, and one file at a time across all reports, so a burst after a broken deploy is not one copy of a large map per report. npanel's database is asked only which files to read, and its connection given back before any is. Each mapped frame keeps where it was and where the map puts it, with the original function name and five lines of context either side. This happens as a report arrives rather than when it is read, because grouping needs it.

Grouping follows Sentry's order (src/errors/grouping.rs): the SDK's fingerprint if it sent one; otherwise each exception's type and the site's own frames the map placed, by original file and function; otherwise its type and message with numbers, hex, UUIDs and Nostr identifiers taken out. A minified bundle's names change with every deploy and the original's don't, so with maps one bug stays one issue across deploys; without them an issue groups by message instead.

Storage is SQLite, in data_dir/errors.sqlite3, apart from the others for the reason analytics are. Reports are written every two seconds in one transaction. An issue holds exact running counts — events, and visitors under the daily salts analytics use (in this database, so the two can't be joined), counted once a day each — with hourly counts beside it, and keeps the report itself, deflated, for its first event and its latest 100. An issue's status is unresolved, resolved or ignored; a resolved one that happens again counts and stays resolved. Everything older than errors_retention_days is pruned hourly, with the issues of hosts that no longer exist.

Link previews

A Nostr web client is usually a static app: one index.html that reads the path — /npub1…, /nevent1…, /naddr1… — and fetches the event from relays in the browser. A crawler doesn't run it, so every link to a post unfurls as the app's own name, and search engines index the same empty shell under every URL. A site with link_previews on that publishes a preview script has those pages answered for crawlers with whatever its script makes of them (src/link_previews/). npanel has no idea of its own what a page about a Nostr event should say: the site does, and a site without a script gets nothing.

What qualifies. A GET for a page the script's routes name — every page, if it names none — where the site's answer is an HTML page under a 200, from a User-Agent matching link_preview_crawler_pattern, on a site with link_previews on and a script. With the spa flag that is the shell; without it, a site that publishes a page at that path. A path with an nsec in any segment never qualifies: running a script for it would put a key in a cache. Everything else is served exactly as it would be, and every response on such a page carries Vary: User-Agent — with whatever request headers the script reads — so a shared cache doesn't hand one audience's page to the other.

What a crawler gets. The site's own page, with what its head says about the page — its title, description, Open Graph and Twitter tags, canonical link — replaced by what the script returned, and its article at the top of the body. The page is otherwise as published, so it is still the site's — its styles, its icon, its scripts. A preview with no title is titled as the site — the page's og:site_name, or its title up to the first separator — and one with no image shows the site's own og:image. A page the script has nothing for, returning null or throwing, is served as published.

The article is visible, not hidden. A search engine reads text it can see, and treats text it can't as an attempt to deceive it. It goes before whatever the app renders into, so a crawler that runs scripts sees both.

The script

A site publishes one self-contained module at /.well-known/npanel/preview.js (src/link_previews/script/), served like any other file there: it's public on Blossom anyway. It imports nothing — a static import or import() alike fails — so what it uses is bundled into it, and npanel knows nothing of any other file. A deploy of a site with link previews on loads the script as a run would, beside the build rather than in its way, and says in the version's deploy log that it found it, or why it doesn't load; a script that fails leaves the site without previews. With link previews off the script isn't run at all, not even to check it: it is the author's code, and nothing runs it until staff say it may. Serving loads it the same way, once per script, to read its routes, and its top level has two seconds; only a crawler waits for that, and anyone else is sent the page at once, varying by User-Agent unless the script is known not to preview it.

export default {
  // URLPattern pathnames; a group may carry its own regular expression.
  routes: ["/:id(n(?:pub|profile|ote|event|addr)1[02-9ac-hj-np-z]+){/}?"],
  async preview(request, { nostr, signal }) {
    const pubkey = pubkeyOf(new URL(request.url).pathname);
    const [profile] = await nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal });
    if (!profile) return null;
    return { title: "…", description: "…", body: "<article>…</article>", image: draw(profile) };
  },
};

Which pages it previews. routes, when there is one, is a list of up to 32 URLPattern pathnames. A named group may carry a regular expression, as :id(n…) above, so a route can name what it is about and not every path of its shape. The expressions are Rust's regex, which matches in linear time, held to the crawler pattern's size limit; they're the same as JavaScript's for anything a path needs, but have no lookaround or backreferences. They're matched against the path as the crawler sent it, percent-encoding and all. Routes that don't compile fail the deploy check, and the site has no previews: a mistake there doesn't become every page.

What it's given. The crawler's request, a GET without a body, as a browser's is — its method, its headers, the page's URL without the query a crawler adds (one page, one cached preview), and as its signal the run's deadline — and an object shaped like Nostrify's NRelay: query(filters), req(filters) (the stored events, then EOSE), and an event() that refuses, since a preview publishes nothing. The script decodes the path itself, NIP-05 names included; npanel reads only the relay hints in its NIP-19 segments, to ask besides the configured relays. Of the request's headers, only Accept, Accept-Language and User-Agent are passed on: a script can fetch anywhere, and anything else — credentials, and whatever a proxy or a CDN in front says about the visitor — would be the script's to send there. Copying the request — clone(), new Request(request) — doesn't count as reading its headers.

What it returns. Any of:

Field What npanel writes
title <title>, og:title, twitter:title
description description, og:description, twitter:description
body the <article> a crawler reads, as markup, cleaned by an HTML sanitizer of anything that runs. Links that leave the site get rel="nofollow ugc noopener"; links to the site's own pages are left for a crawler to follow
image og:image, twitter:image: a URL, taken relative to the page; a PNG, JPEG, GIF or WebP as a Uint8Array, ArrayBuffer or Blob; or a promise of one
type og:type, website when it's missing
twitter twitter:card: summary for an image shown small, summary_large_image (the default) for one shown large
video, audio og:video, og:audio, from { url, type, width, height }
published article:published_time, from seconds or a Date
canonical <link rel="canonical"> and og:url: a URL on the same site, taken relative to the page, for a page that is one of several naming the same thing. The page's own URL when it's missing or elsewhere
jsonLd a schema.org object, written so no value can close its script element

Or null, for nothing. npanel writes only what's returned, and never a guess.

The image is drawn behind the page. A promise isn't waited for: the page goes out as soon as the handler returns, naming its image as /_npanel/preview/<the page's path>?v=…, and the drawing finishes on its own. The version is a hash of everything the handler was given until it returned: the script, the URL, the request headers it read, and the filters and URLs it asked for with what they found. So an image never changes under its name, one at its version is cached for a week, and a platform that caches images by URL fetches a new one when anything it's drawn from changes. Anything fetched only after the handler returns doesn't count, so what the image depends on should be fetched before. Any client may fetch the image: the services that fetch a preview's image seldom say they're the crawler that read the page. One asked for at another version, or none, is the page's current one, for five minutes, drawn again if it was let go since.

The web it runs in is a browser's, as far as a preview needs (prelude.js): OffscreenCanvas and its 2D context — paths, clip, drawImage, getImageData and putImageData, gradients, composite operations, fillText and measureText — createImageBitmap of a PNG, JPEG, GIF, WebP or SVG, convertToBlob to PNG or JPEG, fetch, Blob, Headers, Request, Response, URL, TextEncoder, atob, AbortSignal, setTimeout and console, whose lines go to npanel's log at debug. Shadows, filters, patterns and conic gradients throw rather than draw something wrong, as does anything else that isn't there. An SVG drawn as an image draws nothing it names elsewhere, as in a browser: a script fetches what it draws. Date stands still at when the run started, its local time is UTC, and Math.random is seeded by the URL, so one input gives one image.

Text is drawn in the gateway's fonts, the ones installed on its system, read by each process scripts run in as it starts; a site ships none. serif, sans-serif and monospace are what fontconfig says they are, or else DejaVu, Noto or Liberation, whichever is installed. A character the font asked for lacks — an emoji, usually — is drawn in one that has it, as a browser does. A script names a font by its family, and can't reach a file. The Docker image installs DejaVu and Noto Color Emoji, and the deployment guide has a systemd install do the same, and says how to add others. Fonts installed later are read once npanel restarts, and since an image's version doesn't name the fonts, images already drawn stay as they were until they expire or the cache is cleared.

Limits

Escaping is the security boundary. Anyone can send a crawler's User-Agent, so the page is served on the site's origin to whoever asks, and what a script writes is made of what anyone can publish to a relay. So the article is sanitized, every other field is escaped where it's written, URLs are kept only when they're http(s), and the JSON-LD escapes <, > and &. The page goes out under the site's Content Security Policy like any other, and nothing npanel adds to it is a script that runs.

A script runs in a process that can do next to nothing (src/link_previews/script/sandbox.rs). It's a stranger's code, run by QuickJS — a C engine — and drawn with decoders of pictures and SVG, and a bug in any of those is a bug in whatever process they run in. The gateway's holds the database: every TLS and onion key, the ACME account, and what every host serves. So scripts run in processes of their own, npanel's own binary started again as npanel preview-worker, with none of its configuration or open files. Once a worker has read the fonts it confines itself: on Linux, Landlock and seccomp leave it no file to open, no socket to make or connect, no program to run and no process to signal but itself, and its memory is capped at a gigabyte. Everything a script asks of the world — relays, fetch, its log — is a message to the gateway, which decides and does it, and what a script returns is held to every limit again in the gateway, as from anyone else. The gateway keeps the deadline as well: a worker that hasn't answered in time is killed, wherever it is — in a promise that never settles, or a decoder that never returns — and one that crashes takes nothing else with it. A kernel without Landlock, or a container that forbids it, is logged at startup as link_preview_unconfined.

A script is held to npanel's limits. Each run gets a QuickJS runtime of its own, in one of as many workers as there are cores: 64 MB of memory, ten seconds to return and ten more to draw, past which it is stopped wherever it is. It may query relays eight times, for fifty events in all — each filter naming ids, authors or a tag, and its limit honoured however many a relay sends. It may fetch sixteen https:// URLs, through the public-addresses-only resolver blob downloads use, never at an address that isn't public whether a name or written out, following redirects only to https://, 8 MB each and 32 MB in all, counted as they arrive; it reads no file. Its canvases and decoded images may hold 16 million pixels between them, none over 2400 pixels a side for a canvas or 4096 for a picture. The script may be 4 MB.

A run starts in a few milliseconds. Workers are kept between runs — for ten minutes idle, or a thousand runs — so a run seldom waits for one to start, and a fresh runtime costs well under a millisecond; what costs is the code. The prelude and a script are compiled once a worker — the prelude by its first run, a script when it first runs there — and later runs load their QuickJS bytecode, which takes a tenth of the time parsing does. What a script does at its top level still runs every time, so a script should bundle only what it uses: Ditto's bundle once spent a tenth of a second building AES tables for ciphers its previews never call. convertToBlob encodes after the handler has had the chance to return, so a handler that returns its image as a promise sends the page without waiting for the encoding, the slowest part of drawing. bench_preview (docs/development.md) times each part.

Serving never waits long. Scripts' lookups run on a runtime and a thread of their own, with a relay pool of their own, restarted with backoff like the analytics runtime. A request asks memory first; a preview there is sent at once, and a stale one is sent while a fresh one is made behind it. Otherwise it hands the lookup across and waits two and a half seconds at most. A lookup that can't start — link_preview_concurrency are running, or the runtime is starting — or doesn't finish in time sends the page as published. Neither is ever an error: the worst a visitor sees is the site. A lookup that outlives its request goes on, and fills the cache for the next.

Relays are spared too. A crawl asks for hundreds of pages at once, and npanel's deploys depend on the same relays, so scripts' queries that arrive within fifty milliseconds of each other share one subscription per relay, and are sorted out from the answer: identical filters are sent once, and filters for authors' newest replaceable or addressable events of the same kinds become one filter. Every lookup of one page at once is the same lookup. Runs are metered by link_preview_misses_per_minute; cached previews never count, so the meter is exactly what a stranger requesting made-up ids can cost, and no one site may spend more than half of a minute's, so a stranger spending one site's leaves the rest for the others. A lookup over it is answered from whatever is cached. Queries go to the configured relays and the path's own hints and, when those have nothing, to the outboxes of the authors the filters name. Every relay an author names is held to the rule deploys follow: wss://, public addresses, five at most. A relay connection idle for half a minute is closed and forgotten, so the relays a stranger's paths name don't pile up.

Storage. Previews are kept in memory, up to 32 MB of them, and in data_dir/previews.sqlite3 for link_preview_ttl seconds — a file apart from the gateway's database, so clearing it and VACUUMing it touch nothing else, and what it takes up is its size. It's a cache, so its layout is versioned as a whole, and one an older npanel left is thrown away. A preview is kept by the script, the URL, and the values of the request headers the script read, which are remembered too; two builds with the same script share them. A script having nothing for a page is remembered for ten minutes at most, since it may only mean the relays were slow; for the same reason, a run that has nothing where it had something keeps what it had and tries again in ten minutes, as does a run that fails. Past link_preview_max_entries, the least recently made rows go. Drawn images are kept by version, 64 MB of them in memory and 512 MB in data_dir/preview-images/.

Settings → Link Previews shows what is cached and how lookups have gone, runs one page's script again from its URL, and clears the lot, images included.

Authentication

NIP-98: the client signs a kind 27235 event naming the method and URL, and sends it base64-encoded in Authorization: Nostr <event>. npanel verifies the signature, checks the event is less than 60 seconds old, that the method matches, and — for requests with a body — that it carries a payload tag matching the body's sha256. The tag is required rather than checked only when present: it is the one thing tying a body to the signature, and without it a header seen once could be replayed within the minute with any body at all.

The signed u tag is compared against dashboard_host, not against the URL this process sees. Behind a proxy those differ: the client signs https://panel.example.com/api/hosts while the process receives http://127.0.0.1:3000/api/hosts. Comparing the raw request URL would reject every legitimate request. Host and path must match exactly; scheme and port are tolerated.

A valid signature proves identity, not authorization. npanel is not permissionless, so a pubkey with no row in users gets 403. Admins see and change everything; staff see only the hosts they've been granted, and only admins manage users, grants, settings, tunnels, and the audit log. A tunnel agent signs the same way with a key of its own, and is looked up among the tunnels rather than the users (see tunnels).

What a host is named is a weaker secret than what it holds, so it is governed separately. With public_site_list on, npanel will list its hostnames — to anyone at /api/sites, and to staff alongside the hosts they manage — while everything else about a host still needs a grant. The setting is off by default, and a host marked hidden is left out of both regardless.

Two things happen before any of that. Request bodies are capped at 1 MB — the payload check hashes the body, which means reading it before knowing who is asking, and anyone can sign an event. And every dashboard and API response is sent with frame-ancestors 'none', since the SPA's own CSP is a <meta> tag and those cannot express it.

Live settings

Settings (src/settings.rs) caches settings in memory and publishes each new set of values on a watch channel. A subscriber compares the fields it cares about with the last values it saw, so one that falls behind skips to the newest values rather than missing a change, and a field it names is one the compiler has checked exists. Subscribers reconfigure in place: the relay pool clears its list caches, the subscription restarts — so a relay that is gone is asked nothing more and its connection closes itself once idle — the resync timer re-arms, and adding Blossom fallbacks or raising the size limit immediately retries every version that isn't ready — because that's often exactly what a failed deploy was waiting for.

The deploy, analytics and Tor runtimes read the same settings and receive the same change notices, so their subscribers reconfigure exactly as the rest do. The previews runtime reads them afresh for every lookup, so a change applies to the next one with nothing to reconfigure.

Nothing in the settings UI requires a restart, which is the point: the config file's seven settings are the only things that do.