Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web Push as a way to ingest messages #765

Open
AlexGustafsson opened this issue Feb 9, 2025 · 17 comments
Open

Web Push as a way to ingest messages #765

AlexGustafsson opened this issue Feb 9, 2025 · 17 comments
Assignees

Comments

@AlexGustafsson
Copy link

AlexGustafsson commented Feb 9, 2025

Web Push provides standardized means not only for web clients to subscribe to and receive messages, but for servers to send them.

Previously, the subscription mechanism and the authentication mechanism servers would use were well-defined. Recent developments have also resulted in the actual message format moving to a standardized format, pushed by WebKit. The feature is available in the latest version of Safari Technology Preview.

I propose supporting Web Push in gotify - as a way of ingesting messages in a standardized way, making gotify compatible with existing and future Web Push-aware servers, meaning users of said applications could use gotify without dedicated support in the applications themselves.

In the normal Web Push flow, a user would request a subscription via their browser vendor's infrastructure. In this case, my suggestion is for gotify to act as that vendor. By letting users create a Web Push subscription via gotify, they could get the same reply from gotify as they would from their browser, letting them set up their applications to send messages to gotify.

Gotify would then receive messages from the services and respond like a Web Push server, supporting the subset of features that is mandated and makes sense in gotify's context.

As for the message format, I propose to use the format specified by WebKit, moving towards standardization: https://github.com/WebKit/explainers/blob/main/DeclarativeWebPush/README.md. W3C discussion

Useful article for an overview of Web Push: https://web.dev/articles/push-notifications-web-push-protocol.

Relevant RFCs and articles

Message field mapping

Below is a rough field mapping between Gotify's current message format and the message format proposed for Web Push. Note that there are additional fields in the proposed Web Push format that could be used for features like the image URLs, android actions, but they would likely not be supported by Web Push-compatible servers without additional work on their end.

{
  "title": "My title",
  "message": "My message",
  "extras": {
    "client::display": {
      "contentType": "text/plain"
    },
    "client::notification": {
      "click": {
        "url": "https://gotify.net"
      },
      "bigImageUrl": "https://placekitten.com/400/300",
    },
    "android::action": {
      "onReceive": {
        "intentUrl": "https://gotify.net"
      }
    }
  }
}
{
  "title": "My title",
  "options": {
    "body": "My message"
  },
  "default_action_url": "https://gotify.net"
}
@AlexGustafsson AlexGustafsson added the a:feature New feature or request label Feb 9, 2025
@AlexGustafsson
Copy link
Author

Given that this requires some work to get done, I'd happily help out with a PoC to show the intended direction. But before I do that, it would be nice to understand if it's at all of interest within the intended scope of Gotify.

@AlexGustafsson
Copy link
Author

As an aside, Web Push would lay the foundation for supporting end-to-end encrypted content in the future. Right now, my idea would be for gotify to act as the message relayer and the recipient, meaning it would terminate the encryption used in Web Push.

@eternal-flame-AD
Copy link
Member

eternal-flame-AD commented Feb 9, 2025

Thanks for the thorough analysis. This would need some good amount work and require a lot of restructuring. I am good with standardization.

Nomenclature mapping as I understand:

  1. UA -> a "gotify client" with a client token
  2. push service -> gotify/server
  3. application server -> a "gotify application" with an application token

A couple question from me to make sure it is worth the effort:

  • I am a little confused about the trust model here. I believe Web Push was trying to build a tunnel where the push service was a mailman-like role who should not know anything about what's going on except who subscribed to whom. However in Gotify the UA needs to be authenticated against the push service, and push service decides on the authority of who can push to whom. So we have to write some kind of non-anonymous identification here.
  • Are there well known use of Web-Push usage outside the realm of PWA? The point I am trying to make here is I wonder how a regular user or adopter can take advantage of this standardization, IMO it is not practical to ask users to go to about:config and change their web push URL and client identification to gotify. If there is not a readily-usable subscription flow here (like you just type http://monitor.service/subscribe into the gotify Web UI and it auto-magically create an application for you) I doubt a standard Web-Push library can give you much extra ease of use.

Related but not the same thing: #344.

Edit: Over-interpreted the scope a little.

@eternal-flame-AD eternal-flame-AD removed their assignment Feb 9, 2025
@eternal-flame-AD
Copy link
Member

eternal-flame-AD commented Feb 9, 2025

@AlexGustafsson Expanding a little on how I imagine it can work and potentially make it useful as an independent push service:

Gotify impersonates as a WebPush UA and Service at the same time, and supports one or both workflows to subscribe to a Web-Push capable application:

  • The user just change their browser setting to use Gotify as their push service, and when the target web app request a push service the subscription gets handled by gotify, gotify create an app automatically and deliver this subscription to the user.
  • The user enter the URL of the target web-app manually into the Gotify WebUI, gotify subscribes on behalf of the user and subscribe to the Web-Push application server and translate the notifications into Gotify.

Did I understand it correctly? Feel free to correct me if I have misunderstood the intended use of this.

@AlexGustafsson
Copy link
Author

Gotify impersonates as a WebPush UA and Service at the same time [...].

  • The user enter the URL of the target web-app manually into the Gotify WebUI, gotify subscribes on behalf of the user and subscribe to the Web-Push application server and translate the notifications into Gotify.

Yes, this is the gist of it. Act as a UA and service. Don't involve browser settings. One additional clarification;

In the typical flow, the web app receives the subscription via the UA, then sends it to the application server. I'm not aware of any standard means used by application servers to handle this request (i.e. actually creating the subscription in the application server). As such, my current assumption is that the user would have to create a subscription in the Gotify Web UI, get the "PushSubscription object" and then provide it to their application server as they see fit. The win here would be that Gotify could act as a drop-in target for any service supporting web push, "shoehorning" its way in to existing flows.

Just a quick flow example through code, based on MDN's example: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription#example

-// Get a PushSubscription object from the browser
-const pushSubscription = (await serviceWorkerRegistration.pushManager.subscribe()).toJSON();
+// The user got the same JSON object from Gotify via its Web UI

// Create an object containing the information needed by the app server
const subscriptionObject = {
  ...pushSubscription,
  encoding: PushManager.supportedContentEncodings,
  /* other app-specific data, such as user identity */
};

// Stringify the object an post to the app server
fetch(`https://example.com/push/`, {
  method: "post",
  body: JSON.stringify(subscriptionObject);
});

@eternal-flame-AD
Copy link
Member

Awesome, I love this idea. Just one more thing:

If all it does is do a "virtual Web-Push subscription", why not build it as a dedicated library and in turn a gotify plugin or standalone server that just subscribes to the application server and transform all incoming requests into arbitrary actions?

I assume it is possible to just encrypt all the app-specific state required in the subscription URL itself and this service can be fully stateless to the point of deployable as a cloud function. Basically:

  • When an authorized client reach /subscribe, the gateway create an application in Gotify (optionally if possible use VAPID to populate title and logo automatically) and return the wrapped App token in the subscription information. Basically return a subcription URL that looks like /subscriptions/${aead(plaintext: backend_id . gotify_token, ad: VAPID.ecdsa_fingerprint)}, you can then have a static config file to map the backend ID to the actual gotify hostname or even arbitrary code to execute.
  • The client pass this information onto the application server. This step can be manually as you described but I think can potentially be WebPush-API compatible too with a browser addon or dom.push.serverURL in Firefox, this way as long as the application website does not whitelist push services they can deliver the notification straight to you without them explicitly supporting it.
  • When the application server send the subscription the gateway recompute the AD from VAPID then decrypt the URL to get the token back, translate the object and send a request to gotify. If the app is no longer there (maybe the user no longer wanted the notifications and deleted it) just return 410 or 404 or 401 or any Web-Push compliant manner.

I am open to collaboration regardless how you want to do this after I work on the OIDC issue, but if we are doing it here I have to be sure it is the right decision to put this logic in Gotify itself.

@AlexGustafsson
Copy link
Author

I've started playing around with a plugin just to get a sense of how it could be achieved and understanding how Gotify works, using plugin.Webhooker and plugin.Messenger to receive and create messages.

I think that we could get away with creating an application in Gotify and achieve something stateless if we do what you suggest. But as I understand it, it's out of the realms of what's possible as a plugin today? There are no means to create / list applications?

I agree with your points. But in the manual case, how would the user end up with a shared secret and a ECDH public which Gotify associates with the subscription (in order to decrypt messages it receives)?

Normally the browsers would get the endpoint from the server, but generate the keys itself. In our case, with Gotify acting as both the service and UA, I would assume the subscribe API could give the user the public key and shared key as extension headers in response to a call to /subscribe, X-Public-Key, X-Shared-Key?

Or we borrow Crypto-Key from the encryption RFC: https://datatracker.ietf.org/doc/html/draft-ietf-webpush-encryption-06#section-2. But there's still not any defined name for the auth parameter used for content encryption.

User requests a subscription:

POST /subscribe HTTP/1.1
Host: push.example.net

Gotify generates keys and returns a ”fat" endpoint containing all necessary information to statelessly "route" and decrypt any message it receives. It also returns the parts typically generated by the UA so that the user can easily relay them to their application of choice.

HTTP/1.1 201 Created
Date: Thu, 11 Dec 2014 23:56:52 GMT
Link: </push/zLQ3raZJfFBR0aqvOMsLrt54w4rJUsV........>; rel="urn:ietf:params:push"
Location: https://push.example.net/subscription/LBhhw0OohO-Wl4Oi971UG......
Crypto-Key: dh=BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8
Auth: KPKiPp3WxUsKI-Fkc3nxlQ

For a plugin to work, with just plugin.Webhooker, the necessary information to keep in the URL would just be aead(public key, shared secret). That, of course, wouldn't let users of Gotify understand what application actually sent the messages nor to unsubscribe. That would, as you say, work really well if the plugin would create an application for each subscription. If the application is removed, so is the subscription.

@eternal-flame-AD
Copy link
Member

eternal-flame-AD commented Feb 10, 2025

Regarding Coupling

You are free to do it either way, my personal suggestion is bottom up (get this working independently as a library first then connect with gotify) this for the reason of:

  1. This feature is pretty complex and from my standpoint looks experimental at this time and are subject to a lot of changes as we go.
  2. Behavior of gotify is pretty simple and deterministic and easy to abstract out. You can probably model gotify by just abstracting out API calls and write or even AI generate mock implementation for testing fairly quickly.
  3. Keeps your option open, if we end up deciding not merging we don't want you to end up with a genuine good idea stuck coupled with our codebase.

If you have a prototype and are comfortable feel free to share it here or invite me privately for a review.

Regarding Plugins

Gotify plugins are pretty much just executable code we pull in, trust and pass in a couple handles so you can register arbitrary routes, it is more of a "plug board" to wire internal features up (like register your own arbitrary route and translate messages back to format).

The primary reason I designed this plugin system is for the "last-mile" problem where every service has a custom webhook format or authentication and a plugin is more manageable than a custom server for the sole purpose of adopting a specific webhook service to the gotify format. I didn't really expect it to contain any highly complex logic.

Your case is probably possible with plugin but I suggest try standalone first and then think about whether it fits, it doesn't have to be in my opinion. Brain storm for what you think a user wants not what the API wants.

Regarding Encryption

I will have to think about this later when I have more time, but generally speaking I am not too interested in making this truly "E2E" for the same reason I didn't want gotify to be E2E (see #63). If you are dedicated to the gotify model of clients own other clients+applications don't spend time designing a system that provides more security than the current gotify model caps at, which in this specific case means I am leaning towards simplifying some elements of crypto in web-push in favor of a stateless deployment since we don't really have the concern of multiple consumers or a malicious push service here.

I have some background in auditing crypto systems but not exactly the web-push model so I have to learn more specifics before giving a reply. Deliberate weakening (altering characteristics) of an existing crypto system is a slippery slope and I want to get it right. I will get back to you, but it will likely involve some deterministic keying based on application server identity and have a security characteristic similar to gotify itself (no separation between clients but applications each are fully isolated).

I am looking at this technical overview and seeing how to adapt it:
https://blog.mozilla.org/services/2016/08/23/sending-vapid-identified-webpush-notifications-via-mozillas-push-service/. My current idea is a key derivation scheme that recovers the subscription key using an internal secret, subscription ID and the VAPID key fingerprint.

@eternal-flame-AD
Copy link
Member

eternal-flame-AD commented Feb 10, 2025

Regarding user workflow

Your current approach looks good. Let's keep our options open by making /subscribe as compliant as possible for interoperability.

My general vision for the benefit of this is we could potentially wire the browser PushManager API to you as well (and thus your notification will be delivered to gotify as opposed to that specific browser over their own push service).

So a user can just visit a website, click "allow notifications", the website JS use the PushManager API to tell us the subscription details, we instead of requesting a subscription from the browser push service, request a virtual subscription from gotify instead and pass it to the website.

I think this can drastically improve the usefulness of this feature by making most web push websites support gotify automatically, but it will likely require significant development effort because AFAIK Firefox is the only browser that has a semi-official way to use a custom push service, and on other browser we need to replace the PushManager object which isn't easy in third party service worker context. By making /subscribe compliant we keep this opportunity open but not dedicate too much effort at this stage yet.

@AlexGustafsson
Copy link
Author

Thanks for all the good input. I'll be looking at playing with it in a separate setting then, bringing gotify into the mix once some progress is made and it's clearer what the right direction is.

Mozilla doesn't seem to use the Web Push standard to subscribe to events from browsers. They're using something they call autoconnect instead, essentially a WebSocket sort of following a format they've had for many years (SimplePush). The socket is used for creating subscriptions, deleting subscriptions and receiving messages: https://mozilla-services.github.io/autopush-rs/architecture.html. I also find their take on the URL contents quite interesting: https://mozilla-services.github.io/autopush-rs/architecture.html#push-endpoint-length.

So to act as a drop-in replacement for Firefox users, we'd need to support that WebSocket and message format as well.

Edit: The below parts are left for completeness, leading up to me finding the above info.

I've done some tests with Firefox and from what I can tell, it's not really adhering to the spec? The setting you've referred to, I'm guessing is dom.push.serverURL, which by default is wss://push.services.mozilla.com/. It seems to be Firefox' channel for all things WebPush (subscriptions, messages, acknowledgements). I can't find anything else related to the push service endpoints.

By mitming the endpoint, I'm seeing messages like these (keys and ids exchanged for random but similar values):

// Outgoing, from the UA
{"channelID":"d9593f0f-cee5-4a59-8bea-309d8f70c812","messageType":"register","key":"hv+95/B6vaCXpk1gjZcQQnM7Q15J5QOu2//vqLiZU10="}
// Incoming, from the service
{"messageType":"register","channelID":"d9593f0f-cee5-4a59-8bea-309d8f70c812","status":200,"pushEndpoint":"https://updates.push.service
s.mozilla.com/wpush/v2/RwfvgYDn1Bt1ZIqb2iNQYB2pBE4xiexdmhx6h9UD01/Gn8rslSALHNur4/ZGXgaa6XkSKigTGiqCWVUsFxTNHKt1HW4wlXlawt0pSaeuziGchalliV1AmPrITm0leyJFxFkbd9kXz2c"}

The endpoint in the incoming message is exactly the one I'm seeing from the PushManager JS browser API when creating the subscription.

So I'm guessing there are two things here to solve for the browser/Firefox use case - acting as a spec-compliant service AND work as a drop-in replacement for Firefox' WebSocket, mapping the requests to the compliant service API (which for all we know could be what Mozilla is doing).

Worth noting is that the same WebSocket is also used for getting the actual events and acknowledging them. The message content seems to follow the same standards as the RFCs we've found. After going down a bit of a rabbit hole (I'm not accustomed to Mozilla's stuff) I found that there's a long history of push services and that they're using https://github.com/mozilla-services/autopush-rs these days. Of interest is probably their docs on the Web Push standard parts:

@AlexGustafsson
Copy link
Author

When I think about it, though, it will probably be much easier implementing support for Firefox in parallel anyway - to improve tooling and testing. By using Mozillas' services, one would be testing towards a correct service at all times, piggybacking from their "known-good" implementation, rather than just reading specs. So something like a step 1 where you act as a client WebSocket using the same APIs Firefox is to create subscriptions and receive messages, then as a step 2 you create a compliant application server to send messages that can be received by the client, still using Mozillas' backend and then, as a step 3, you replace Mozilla's backend with a compliant server. Then it's "just" a matter of packaging the components to fit the use case we're trying to solve. Be it a Gotify (existing subscriptions) -> Firefox push, a Firefox (new subscriptions) -> Gotify push or a "standard" Web Push -> Gotify push.

@eternal-flame-AD
Copy link
Member

From a planning perspective just to reiterate I think the major ROI here is (1) When you can just take an off the shelf Web-Push library to write your application server without explicit gotify support. (2) When a user can use the PushManager API to subscribe to a WP web app without explicit support for gotify

I have to look more into this but basically I think we only need to replicate the browser requests to the push service and application-side protocol. I agree with using a known good implementation as a reference or testing target but I encourage you to think out of the box for the internal implementation (how to encode the URL, etc) because our scale of deployment and privacy requirements are different.

Regarding the browser requests: yes I think that will require browser specific effort although I still think it is likely the resistance for Firefox will be lowest. Let's focus on ROI (1) first and deal with how to get the subscription information from the browser to gotify later.

@AlexGustafsson
Copy link
Author

AlexGustafsson commented Feb 16, 2025

I've written some code to try things out. It's a set of libraries and basic tools. It's all just a PoC and fairly hacky. There's basically zero dependencies, to learn and to not hide the scope of the required implementation. https://github.com/AlexGustafsson/web-push-poc

Besides the basic Web Push functionality of handling encryption and VAPID, it basically contains means to:

  • Act as a Firefox client (using autoconnect)
  • Act as a MITM for Firefox (just to see how the messages work)
  • Act as an application server to send messages to any push server
  • Act as a user agent and a push service

The last tool shows how a stateless service could be written to act as a user agent and a push service. It essentially proves that our first ROI point could be achieved rather easily. Granted, there are a lot of todos, but they're mostly for a production-grade implementation, nothing that I think is required in the scope of a quick and dirty PoC.

I've played around with a stateless endpoint token like we've discussed. It contains a version, a subscription id, the application server's public key and the user agent's private key. It should probably contain the authentication secret as well.

All fields but the version are encrypted, the version is included in the additional data. See https://github.com/AlexGustafsson/web-push-poc/blob/main/cmd/agent/token.go.

An example push endpoint looks like this:

http://localhost:8082/ASqtcMsNphyJ_fssZiwwYBmU9QK-4INWlCliW0atFIe2CMJjCxomv2XNBW8YKWsxrdLHAf47w9bEelxFYHPq85ZR93OGtMOcXd6j0VNwOMUR8m8pa84SS6Ujg-dv_n9Gl6X1M8_1dRTaUvBZcj5NTJiVAeOSCcQhHEE9sD-bGgiChUveVE5BVVA233QiNg

The token size is within 10B of Mozilla's tokens. They seem to be doing some sharding and sorting improvements and store the subscription (channel) id in a database. The application server's public key is included for quick verification of the sender.

In order to PoC ROI 2, I've just taken a quick look at Firefox' extension APIs. Like you said, we'd effectively need to replace the PushManager.subscribe function to invoke the "agent/service" implementation instead.

@eternal-flame-AD
Copy link
Member

Thanks for the update. I will get back to you next week , sorry I have been busy this week.

@eternal-flame-AD eternal-flame-AD self-assigned this Feb 21, 2025
@eternal-flame-AD
Copy link
Member

eternal-flame-AD commented Feb 26, 2025

@AlexGustafsson Sorry for the delay, I see you made some good progress on the push service to sender side .

I am able to make some progress on the browser side, I can now reliably get a web push application to callback to my server and I can verify their VAPID JWT now (So basically we have an opaque webhook where we can authenticate to an origin). My POC is here: https://github.com/eternal-flame-AD/autopush-proxy-poc

Do you have any real world sites or open source server that use DeclarativeWebPush you mentioned?

@eternal-flame-AD
Copy link
Member

eternal-flame-AD commented Feb 26, 2025

My thought about potentially automating this process (to make it just work on browser and 3rd party site) have two alternatives, one is to find a way to replace the PushManager API entirely, another one is to intercept network and search for the DH key.

I think either are possible, the first is complex (potentially detectable by fingerprinters/JS challenges) but reliable the second is easy but less reliable.

I think a major blocker for this (and the idea of ingesting DeclarativeWebpush in general) is still low adoption, I am having a lot of trouble finding test cases.

@eternal-flame-AD
Copy link
Member

eternal-flame-AD commented Feb 26, 2025

I think the encrypted Web Push format is only worth adopting if we can close the loop all the way around from the browser to the web app, and it is likely too big to maintain here.

However I am open to an architecture of making gotify support the plaintext DeclarativeWebhook format and a separate library to handle the encryption part (probably not owned by the Gotify org but I am open to collaborating with you outside).

I can see the benefit of a standardized and not overly complicated format even if the adoption is currently low.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

No branches or pull requests

2 participants