Skip to content

Package ntfy as a Syncloud app - #1

Open
cyberb wants to merge 40 commits into
masterfrom
ntfy-app
Open

Package ntfy as a Syncloud app#1
cyberb wants to merge 40 commits into
masterfrom
ntfy-app

Conversation

@cyberb

@cyberb cyberb commented Aug 23, 2026

Copy link
Copy Markdown
Member

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-request authz endpoint already declares both strategies:

authn_strategies:
  - name: 'HeaderAuthorization'
    schemes: ['Basic']
  - name: 'CookieSession'

so one nginx location serves the browser (portal cookie) and the ntfy Android app (Authorization: Basic), both resolving against LDAP. Same mechanism platform/test/testapp exercises in test_testapp_basic_authorized.

Users are not managed inside ntfy. They are provisioned from Remote-User on first request, and membership of the syncloud group (backend/auth/initializer.go:19) in Remote-Groups maps 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.Everyone ACLs work as they always have, and auth-default-access: deny-all still refuses everything else.

Both locations strip Remote-User and Remote-Groups before proxying — without that, anyone could POST Remote-Groups: syncloud to an up* URL and become admin. There is an integration test for exactly that.

Build

ntfy pins mattn/go-sqlite3, so CGO_ENABLED=0 is not available. Built the way upstream's own cli-linux-server target 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 into server/site for go:embed.

Not done yet

  • Web Push (browser notifications with the tab closed) — needs a VAPID keypair generated at install and persisted in $SNAP_DATA.
  • Playwright e2e specs.
  • Not yet registered on CI, so nothing here has run end to end. drone lint --trusted passes on the generated yml.

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.
cyberb added 7 commits August 23, 2026 02:06
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.
@cyberb

cyberb commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

armv7 segfault: root-caused, fixed

The static ntfy binary segfaulted on entry on armv7 only, on any subcommand including --version. Ruled out one at a time by building and running each variant on the actual arm runner:

hypothesis result
toolchain / static linking as such a 2 MB cgo static binary with identical flags runs fine
netgo,osusergo build tags works with and without
GOARM level built at GOARM=6, still faults
ARM branch range / long PLT veneers built with -Wl,--long-plt, still faults
glibc CC=musl-gcc → 41.5 MB binary, runs

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 osusergo/netgo are already there to sidestep. ntfy/build.sh now smoke-runs the binary, so a build that cannot start fails at build time instead of reaching the snap.

CI

Build 13 green on all three pipelines: 16 tests × bookworm + buster × amd64 + arm64 + armv7.

test_index_requires_auth                          PASSED
test_index_with_device_credentials                PASSED
test_user_auto_provisioned_as_admin               PASSED
test_publish_and_poll_authenticated               PASSED
test_anonymous_topic_denied                       PASSED
test_unifiedpush_publish_is_anonymous             PASSED
test_unifiedpush_topic_cannot_be_read_anonymously PASSED
test_unifiedpush_topic_readable_by_device_user    PASSED
test_unifiedpush_header_cannot_be_spoofed         PASSED
test_matrix_gateway_discovery                     PASSED

One more fork commit

auth-default-access: deny-all is the fallback for named users, not just anonymous ones, so an auto-created non-admin Syncloud user would have been created in ntfy and then denied every topic — including the up* topics their own phone subscribes to. Every test passed regardless because the device owner is in the syncloud group and became an admin, and admin short-circuits Authorize. Added auth-user-auto-create-access to the fork (defaults to deny-all, so upstream behaviour is unchanged); the app sets read-write.

Before merge

syncloud_token is not set as a repo secret in Drone. The publish step is gated on master/stable so it does not run on this branch, but it will need adding.

cyberb added 7 commits August 23, 2026 09:37
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.
@cyberb

cyberb commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Screenshots

test/e2e now drives the ntfy web app with Playwright and writes a PNG plus an HTML dump of every page in desktop and mobile viewports to artifact/e2e/playwright/<viewport>/screenshot/ on each amd64 build. Five pages: all-notifications, subscribe dialog, topic, publish dialog, settings.

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 found

1. The app was unreachable in a browser. Authelia's auth-request endpoint answers 401 with a Location pointing at the portal and no WWW-Authenticate. We deliberately do not redirect, because the ntfy Android app would follow it and get portal HTML with a 200 instead of a clean 401 on bad credentials — but nothing then issued a challenge, so a browser hit a bare nginx 401 with no way to log in. nginx now sends WWW-Authenticate: Basic on the authenticated location only; the anonymous up* and Matrix paths do not get it.

The pytest suite could not have caught this: requests(auth=...) sends the header preemptively, while a browser waits for the challenge. test_index_challenges_browser_with_basic_auth covers it now.

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 src/app/splash.js removes on its own timer. The specs now wait for getByTestId('splash') to reach count 0. Only caught by comparing file sizes — 8 KB against 92–114 KB — and then looking at the image.

Fork commits for this

Four additive data-testid commits: nav items, subscribe/publish dialogs, the two nav drawers (both render the same list, so a bare nav id matches twice and Playwright refuses it in strict mode), the mobile menu toggle, and the splash element. No behaviour changes.

Known cosmetic issue

Screenshots carry a "Notifications are blocked" banner. use.permissions grants the permission but Chrome still reports Notification.permission === "denied" against the CI device's self-signed certificate. That is the likely cause and is not verified; it does not occur on a device with a real certificate. Noted in the README.

Also

syncloud_token is now set, so the publish step will work on master.

Verified on real hardware (borisarm64, arm64): both services active, ntfy listening on the unix socket only, / → 401, Matrix gateway → 200 with the right JSON, anonymous up* publish → 200, anonymous up* read → 401.

cyberb added 12 commits August 23, 2026 16:26
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.
cyberb added 13 commits August 24, 2026 01:41
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant