Conversation
ntfy is an HTTP pub-sub notification service and the reference UnifiedPush distributor, which lets Android apps already on the device (Element, Tusky, FindMyDevice) receive push through this box instead of through Google. Auth is delegated to the platform's Authelia over nginx auth_request rather than handled inside ntfy, which supports neither LDAP nor OIDC. The platform's auth-request authz endpoint accepts a portal cookie and an Authorization: Basic header on the same endpoint, so the browser and the ntfy Android app both authenticate with device credentials against the same nginx location. Users are never created or stored by hand: the fork at cyberb/ntfy provisions them from Remote-User on first request and maps the syncloud group in Remote-Groups to the ntfy admin role. Two locations are deliberately anonymous, because their protocols give the sender nowhere to put credentials: UnifiedPush endpoints under /up<12 chars>, where the application server only ever receives a capability URL and the payload is RFC 8291 ciphertext, and the Matrix push gateway. Both strip the Remote-* headers before proxying so that an identity cannot be asserted through them, and auth-default-access is deny-all so nothing else is reachable anonymously. The upstream binary is built from the fork with CGO, since ntfy pins mattn/go-sqlite3, and linked fully static with osusergo/netgo so one binary runs on bookworm and buster.
The snap does not build the mkdocs site, but server/server.go embeds server/docs unconditionally, and an empty directory is not embeddable.
nginx treats { and } as block delimiters, so a location regex containing a
repetition count has to be quoted, otherwise it reads {12}$ as a directive.
ntfy defaults listen-http to :80 and was binding it alongside the unix socket.
The documented way to disable the TCP listener is the '-' sentinel; an empty
string falls back to the default.
Smoke-run the built binary in the build image to tell a bad build apart from a
bad runtime environment, which the armv7 segfault needs to distinguish.
auth-default-access is deny-all, so the nginx location that lets UnifiedPush publishes through unauthenticated was reaching ntfy and then being refused at the ACL layer. UnifiedPush application servers hold no credentials, so the Everyone principal needs an explicit write-only grant on the up* prefix. Write only, so a leaked endpoint cannot be used to read the topic back.
The up* location regex is anchored, so /upXXXX/json does not match it and an anonymous read is refused by Authelia with 401 rather than reaching ntfy's ACL. The property under test holds, one layer earlier than the test assumed. auth-user-auto-create-access is needed because auth-default-access is deny-all: without an explicit grant a non-admin Syncloud user would be created in ntfy and then denied every topic, including the up* topics their own phone subscribes to. The device owner is in the syncloud group and became an admin, whose authorize short-circuit hid this.
The 42MB statically linked binary segfaults on entry on armv7, while a 2MB static binary built with identical flags on the same runner is fine. The toolchain, the build tags, GOARM and long PLT veneers were each ruled out by building and running the variants there; only the libc was left. Linking the same source with musl-gcc produces a working binary of the same size. Applied to every arch rather than conditionally to armv7, so there is one build recipe, and because a static musl link avoids the glibc NSS behaviour that osusergo and netgo are already there to sidestep.
armv7 segfault: root-caused, fixedThe static ntfy binary segfaulted on entry on armv7 only, on any subcommand including
glibc's static startup path breaks on 32-bit ARM at this binary size, which is why the small static binary was fine and the 42 MB one was not, and why only the 32-bit arch was affected. ntfy is that large because it links Firebase, gRPC, OpenTelemetry, Stripe and the Google API client. Fixed by linking against musl on all arches rather than special-casing armv7 — one build recipe, and a static musl link avoids the same glibc NSS behaviour CIBuild 13 green on all three pipelines: 16 tests × bookworm + buster × amd64 + arm64 + armv7. One more fork commit
Before merge
|
There were no screenshots in the CI artifacts at all. Runs on amd64 only, after the pytest step has installed the app, in desktop and mobile viewports, writing PNG and HTML for each page under artifact/e2e/playwright/<project>/screenshot. The app serves ntfy's upstream React UI rather than a UI of our own, so the test ids the specs select by are added in the fork. Authelia guards every path and returns 401 rather than redirecting to a portal, so the browser context carries httpCredentials instead of driving a login form.
Authelia's auth-request endpoint answers 401 with a Location pointing at the portal and no WWW-Authenticate, expecting the proxy to redirect. We do not redirect, because the ntfy Android app would follow it and receive portal HTML with a 200 instead of a clean 401 on bad credentials. Without a challenge header nothing prompted for credentials at all, so a browser reached a bare nginx 401 page with no way in. Playwright's httpCredentials waits for the challenge and so never authenticated either, which is how this surfaced. The pytest suite missed it because requests(auth=...) sends the header preemptively rather than on challenge; covered now by asserting the header. Retry the web build from a clean node_modules: npm ci intermittently reports success while omitting the arch-specific rolldown native binding, which failed the arm64 build after passing on the same lockfile twice.
Both nav drawers render the same list, so a bare nav test id matches twice and Playwright refuses it in strict mode. Waiting for the visible nav item also waits out the splash screen, which the first screenshot had captured instead of the app.
The account nav entry renders only when ntfy holds its own session token. Here Authelia owns identity and ntfy never has one, so the page is not reachable and the spec was asserting a page this deployment does not have.
The first desktop screenshot was the splash logo over a fully mounted app: the nav was in the DOM and visible to Playwright, but a static overlay that src/app/splash.js removes on its own timer was still painted over it. Later tests happened to pass only because clicking waits for actionability, which gave the splash time to go.
Every screenshot carried a 'Notifications are blocked' banner, which is a headless default rather than anything a user would see.
Screenshots
The account page is deliberately absent. Its nav entry renders only when ntfy holds its own session token, and here Authelia owns identity, so the page is not reachable in this deployment. Two real bugs this found1. The app was unreachable in a browser. Authelia's The pytest suite could not have caught this: 2. A screenshot captured the splash screen over a fully mounted app. React had mounted and every nav test id was present and visible, but ntfy paints a static overlay that Fork commits for thisFour additive Known cosmetic issueScreenshots carry a "Notifications are blocked" banner. Also
Verified on real hardware ( |
Every spec called page.goto('/') before doing anything, so each one entered the
app by URL and re-authenticated from scratch. That bypasses the nav the way a
real user never does, and it hides routing and auth bugs between pages. The five
screenshots now come from a single session: one goto, then clicks and waits, with
Escape to dismiss a dialog the way a user would.
Adds a Playwright test for the missing challenge header, which until now was only
covered by pytest, where requests(auth=...) sends credentials preemptively and so
cannot see it. It runs without httpCredentials and asserts the 401 carries
WWW-Authenticate, which is what a browser needs to prompt.
test.use with httpCredentials undefined does not clear the value from the config, Playwright reads it as unspecified and the config wins, so the test authenticated and saw 200. A context created directly inherits no credentials.
A context from browser.newContext() inside a test still inherits httpCredentials from the config, so the supposedly credential-free check authenticated and saw 200. The failure screenshot was byte-identical in size to the logged-in page, which is what gave it away. request.newContext() takes only what it is given, so the unauthenticated case is unambiguous, and a second case covers credentials being accepted so a blanket 401 cannot pass both.
Three attempts at asserting the challenge have seen 200 where pytest sees 401 against the same device in the same build, so record what the browser container actually resolves and receives rather than guessing again.
Playwright's httpCredentials defaults to send: unauthorized, meaning it issues the request, takes the 401, retries with credentials and surfaces only the final 200. The challenge was being emitted correctly the whole time and swallowed by the client, which is why curl in the same container saw 401 while the test saw 200. Wrong credentials make the retry fail too, so the 401 and its header reach the assertion.
The drawer stays open after navigating on mobile, and its modal overlay then intercepts clicks on the menu toggle, so tapping the toggle unconditionally hung until the test timed out. A user only taps it when the menu is not already showing.
ntfy leaves the mobile drawer open after navigating, so the topic and settings screenshots were of the menu with the page greyed out behind it. The test passed because the URL had changed, which is exactly the sort of thing only looking at the image catches. Escape is what MUI wires the drawer's onClose to, and matches dismissing the menu on a phone.
The location hardcoded the 12 characters ntfy-android currently generates, so a client using a different length would have its UnifiedPush endpoint fall through to the authenticated location and be refused, with no way for the sender to authenticate. The ACL grant is already up* write-only, so the narrower pattern protected nothing the ACL was not already deciding.
A browser was getting nginx's native Basic auth dialog because the 401 carried only a challenge. Authelia hands back the portal URL in a Location header on its own 401, so an unauthenticated request, recognised by having no Authorization header, is now redirected there. A client that did send credentials still gets a plain 401 with the challenge, which is what the ntfy Android app needs. The login page was never covered: every e2e spec authenticated with httpCredentials, so the browser never visited the portal and the whole sign-in journey went untested, which is why the dialog survived to be noticed by hand. Credentials move out of the shared use block, since a context created inside a test inherits them and cannot then be signed out, and into the specs that want to be signed in. The new spec signs in through the portal and follows the redirect back into the app, screenshotting both.
The platform test image has been a multi-arch manifest since rootfs 26.07.01, so the arch suffix is gone and the pin moves to 26.08.01. Bitwarden still carries the old per-arch names and I mirrored it rather than following the guide, which says not to mirror older apps. Every step now calls a committed script, per the same guide. version.sh, package.sh and test/run.sh replace inline command lists. Fetch the fork as a branch tarball rather than a git clone. The clone existed only so that git rev-parse could supply a version string, which the build number already provides, and it pulled git into images that do not otherwise need it. The e2e suite loses its defaults: a missing environment variable now throws instead of quietly testing localhost as some other user. run.sh passes the domain and credentials as arguments, so there is one place they are defined. The HTTP-level auth specs are gone. They asserted status codes with an HTTP client, which pytest already does at the right layer, and one of them took three attempts to write correctly because Playwright's httpCredentials retries a 401 internally. What remains is a single browser journey that opens one page, signs in through the Authelia portal and reaches the rest by clicking.
The step wrote DRONE_BUILD_NUMBER to a file for later steps to read, but drone injects it into every step, so the file and the dependency between steps were both unnecessary. The porting guide already says not to add it; package.sh now takes the version as its second argument and writes the version file itself, as the guide and mattermost do.
The fork now uses modernc.org/sqlite, so the binary is pure Go. Drops the musl toolchain install and the static linking flags that existed only to work around glibc static linking segfaulting on armv7.
Activation intermittently failed with 'snap platform has auto-refresh change in progress', on different distro and arch legs between runs with no code change, which is the race the porting guide describes. Both halves of its remedy: the platform service entrypoint sets refresh.hold before the app installs, and the test settles snapd first, aborting any refresh already under way rather than waiting for one that outlives any retry budget.
The browser could not resolve the app domain while the pytest step against the same device passed in the same build. I had removed these checks as cleanup after they diagnosed the same class of problem once already; keeping them.
The browser follows the 401 redirect to auth.<domain>, but drone creates only one service container, named after the app, so the portal hostname does not exist in CI. Playwright reports the failure against the original URL, which hides that the redirect target is what failed to resolve. pytest never hit this because it does not follow redirects. Map the portal and bare domain to the device address, as add_host_alias does on the python side.
toHaveCount(0) is satisfied while the element does not exist yet, so after the portal redirect it passed against the login page mid-navigation and the screenshot then caught ntfy's splash. Assert the app is loaded first, so the absence of the splash is checked against a page that would have one.
The app had no way to log out: Authelia owns the session and ntfy's own logout sits behind a profile icon that only appears when ntfy holds a session of its own. The fork adds a nav entry pointing at the platform's logout, and the journey now ends by clicking it and checking the portal comes back.
The install hook generates a VAPID keypair once and keeps it in SNAP_DATA, since regenerating it invalidates every browser subscription, and templates it into server.yml along with the subscription store. Testing it needs a push service, because the browser subscribes with its vendor's rather than with ntfy. Firefox takes its push endpoint from a preference, so the Playwright projects move to Firefox and point dom.push.serverURL at a small server that speaks the autopush handshake: hello, register, and relaying an HTTP POST to the socket as a notification. It runs beside the tests and hands out its own address so ntfy can reach it. The spec asserts the push server received a VAPID-signed delivery rather than that a notification appeared on screen, which keeps the boundary at what this packaging owns: generating the keys, configuring them, and ntfy signing and sending.
Matches how the other repos do fakers: redirect has dns-faker, device-faker and ses-faker, mail has redirect-faker, each its own module built with CGO_ENABLED=0 go build. Running it as a detached step also removes the address discovery the node version needed. Drone gives a detached step its name as a network alias, so naming it push.<distro>.com makes it reachable by name from both the browser and the device, which is how mail reaches its redirect faker.
The faker recorded no connection at all, so the browser never attempted a subscription and the failure is upstream of it. Print whether the origin is a secure context, whether the service worker and push APIs exist, and whether a registration happened, and allow service workers on an origin with a self-signed certificate, which is what the test device serves.
ntfy only delivers web push to a fixed set of well-known services, so the faker's endpoint is refused with 'web push endpoint unknown'. That check is an SSRF guard and should stay strict by default, so the test opts the device into this one endpoint rather than the app shipping it, and the packaged config is unchanged.
The web push spec copied them without closeDrawer, so on mobile the open drawer swallowed the click on the settings page and the test timed out, which is the same failure the journey spec had already been fixed for. One implementation now, with closeDrawer kept separate from clickNav because Escape closes a dialog if one is open.
The faker answered 201 for everything, including channels it had seen unregistered, so ntfy kept delivering to them: the log showed a second push to the desktop channel during the mobile run. A real push service answers 410, which is what tells ntfy to forget the subscription, and the faker was hiding a path ntfy handles correctly. Deliveries now record the status and the spec asserts the successful one is 201, so a faker that starts refusing everything cannot pass.
… ntfy The test reached into the device, appended an endpoint to the app's config and restarted the service, so the app under test was no longer the app that ships. It also needed a fork change that widened an SSRF guard purely for testing, now reverted. ntfy accepts https on any single-label subdomain of mozaws.net, which is where Mozilla's own autopush servers live, and drone gives a detached step its name as a network alias. Naming the step push.mozaws.net makes the device resolve it with no change to the device at all, and the faker serves TLS on that name so the endpoint it hands out is one ntfy already trusts by its shipped configuration.
WIP. First cut of the ntfy app, for review before CI is registered.
What this is
ntfy is an HTTP pub-sub notification service — publish with
curl -d "message" https://ntfy.<device>/topic, receive in a browser, a script, or the Android app. It is also the reference UnifiedPush distributor, which is the reason it is worth having on a Syncloud box: Element, Tusky, FindMyDevice and the Nextcloud Android app can then receive push through this device instead of through Google.Auth
ntfy supports neither LDAP nor OIDC (#1596 is open and unimplemented), so authentication is delegated to the platform's Authelia over nginx
auth_request.The platform's
auth-requestauthz endpoint already declares both strategies:so one nginx location serves the browser (portal cookie) and the ntfy Android app (
Authorization: Basic), both resolving against LDAP. Same mechanismplatform/test/testappexercises intest_testapp_basic_authorized.Users are not managed inside ntfy. They are provisioned from
Remote-Useron first request, and membership of thesyncloudgroup (backend/auth/initializer.go:19) inRemote-Groupsmaps to the ntfy admin role and is re-synced on every request, so a demotion in Syncloud demotes in ntfy too.That needs cyberb/ntfy@auth-user-header-autocreate — the server side of ntfy#1579 (the maintainer's own WIP) plus auto-provisioning, group-to-role mapping, and one deliberate divergence described below. The web-app half of #1579 is not taken: the proxy rejects unauthenticated requests before ntfy sees them, so the redirect-to-portal machinery is unnecessary, and it carries that PR's unresolved service-worker and IndexedDB bugs.
The two anonymous locations
Both protocols give the sender nowhere to put credentials, so they cannot be authenticated:
/up<12 chars>— a UnifiedPush application server (your Matrix homeserver, someone else's Mastodon instance) only ever receives a URL. Authorization is the URL's entropy as an RFC 8030 capability URL, and the payload is RFC 8291 ciphertext, so the device cannot read it either./_matrix/push/v1/notify— the Matrix push gateway.Upstream #1579 returns 401 when the header is missing, which makes these unreachable and silently kills push. The fork falls through to the anonymous visitor instead, so
user.EveryoneACLs work as they always have, andauth-default-access: deny-allstill refuses everything else.Both locations strip
Remote-UserandRemote-Groupsbefore proxying — without that, anyone could POSTRemote-Groups: syncloudto anup*URL and become admin. There is an integration test for exactly that.Build
ntfy pins
mattn/go-sqlite3, soCGO_ENABLED=0is not available. Built the way upstream's owncli-linux-servertarget does: cgo,-tags sqlite_omit_load_extension,osusergo,netgo,-ldflags "-linkmode=external -extldflags=-static". Fully static, one binary per arch across bookworm and buster. The React web app is built separately in a node image and moved intoserver/siteforgo:embed.Not done yet
$SNAP_DATA.drone lint --trustedpasses on the generated yml.