gorush icon indicating copy to clipboard operation
gorush copied to clipboard

Support multi-tenant config

Open marius-bardan opened this issue 3 years ago • 3 comments

This should address #99 and #365

marius-bardan avatar Mar 25 '21 13:03 marius-bardan

We are also currently in need of this feature. As such we ended up creating our own patch a couple years ago, however at the time there appeared to be some reluctance to incorporate this feature.

So our approach might be a bit more limited than the above but it also has less config and a few other advantages. Our use case is that we have multiple white-labelled apps, in the case of FCM this is easily solved as you can use the same token for multiple apps. However for iOS you need to generate a separate cert for each app. Our solution was to place all the certs in the same pem file and then read the Subject Name to pull out the correct key. However this means the JWT approach for iOS is not supported. (Im not sure if it could be supported, I simply haven't tried)

We then pass the bundle-id as a prefix to the topic in the gorush request (Maybe in the case of the JWT token, we could just pass it in, instead of an id. And maybe we create a new key called "tenant" instead). This has a slight advantage over the URL path taking in this PR, because it mean the 3rd-Party app can just make one request to GoRush, and its GoRush's job to split the request. i.e.({"notifications":[{"topic":"app-a-id.topic"},{"topic":"app-b-id.topic"}]}) In the case that no cert with that ID exists, we fallback to the cert defined at the top of the pem.

Anyway, theres not much point to this post beyond just documenting one of the ways we solved this issue and some of the advantages it has. (It suited us well, as we mounted the cert file in docker, so if we needed to add another white-label, we didn't need to change the gorush config, we simply added the cert to the pem file.

I wonder if this could be duplicated in this PR, essentially maybe mount a directory thats list all the cert file named by the app bundle id i.e. "my.app.pem"? (Should we have an equivalent for FCM/JWT?). And maybe move this PR away from using a different URL endpoint, and instead support adding a bundleID into the list of notifications, so the third party server cans still just make one request and have gorush deal with fanning it out.

diff --git a/gorush/global.go b/gorush/global.go
index b97130d..6a7f3db 100644
--- a/gorush/global.go
+++ b/gorush/global.go
@@ -15,7 +15,7 @@ var (
 	// QueueNotification is chan type
 	QueueNotification chan PushNotification
 	// ApnsClient is apns client
-	ApnsClient *apns2.Client
+	ApnsClientMap map[string]*apns2.Client
 	// FCMClient is apns client
 	FCMClient *fcm.Client
 	// LogAccess is log server request log
diff --git a/gorush/notification_apns.go b/gorush/notification_apns.go
index c4fe903..3205d36 100644
--- a/gorush/notification_apns.go
+++ b/gorush/notification_apns.go
@@ -9,6 +9,8 @@ import (
 	"path/filepath"
 	"sync"
 	"time"
+	"regexp"
+	"io/ioutil"
 
 	"github.com/mitchellh/mapstructure"
 	"github.com/sideshow/apns2"
@@ -33,7 +35,7 @@ func InitAPNSClient() error {
 	if PushConf.Ios.Enabled {
 		var err error
 		var authKey *ecdsa.PrivateKey
-		var certificateKey tls.Certificate
+		var certificateKeys map[string]tls.Certificate
 		var ext string
 
 		if PushConf.Ios.KeyPath != "" {
@@ -41,9 +43,9 @@ func InitAPNSClient() error {
 
 			switch ext {
 			case ".p12":
-				certificateKey, err = certificate.FromP12File(PushConf.Ios.KeyPath, PushConf.Ios.Password)
+				certificateKeys[""], err = certificate.FromP12File(PushConf.Ios.KeyPath, PushConf.Ios.Password)
 			case ".pem":
-				certificateKey, err = certificate.FromPemFile(PushConf.Ios.KeyPath, PushConf.Ios.Password)
+				certificateKeys, err = FromBundledPemFile(PushConf.Ios.KeyPath, PushConf.Ios.Password)
 			case ".p8":
 				authKey, err = token.AuthKeyFromFile(PushConf.Ios.KeyPath)
 			default:
@@ -65,9 +67,9 @@ func InitAPNSClient() error {
 			}
 			switch ext {
 			case ".p12":
-				certificateKey, err = certificate.FromP12Bytes(key, PushConf.Ios.Password)
+				certificateKeys[""], err = certificate.FromP12Bytes(key, PushConf.Ios.Password)
 			case ".pem":
-				certificateKey, err = certificate.FromPemBytes(key, PushConf.Ios.Password)
+				certificateKeys[""], err = certificate.FromPemBytes(key, PushConf.Ios.Password)
 			case ".p8":
 				authKey, err = token.AuthKeyFromBytes(key)
 			default:
@@ -81,6 +83,7 @@ func InitAPNSClient() error {
 			}
 		}
 
+		ApnsClientMap = make(map[string]*apns2.Client)
 		if ext == ".p8" {
 			if PushConf.Ios.KeyID == "" || PushConf.Ios.TeamID == "" {
 				msg := "You should provide ios.KeyID and ios.TeamID for P8 token"
@@ -90,9 +93,11 @@ func InitAPNSClient() error {
 				TeamID: PushConf.Ios.TeamID,
 			}
 
-			ApnsClient, err = newApnsTokenClient(token)
+			ApnsClientMap[""], err = newApnsTokenClient(token)
 		} else {
-			ApnsClient, err = newApnsClient(certificateKey)
+			for k, v := range certificateKeys {
+				ApnsClientMap[k], err = newApnsClient(v)
+			}
 		}
 
 		if err != nil {
@@ -105,6 +110,30 @@ func InitAPNSClient() error {
 	return nil
 }
 
+func FromBundledPemFile(filename string, password string) (map[string]tls.Certificate, error) {
+	var certBundleRegex = regexp.MustCompile(`(?ms)(^-*BEGIN CERTIFICATE-*$)(.*?)(^-*END RSA PRIVATE KEY-*$)`)
+
+	var certificateMap map[string]tls.Certificate
+	certificateMap = make(map[string]tls.Certificate)
+	bytes, err := ioutil.ReadFile(filename)
+	if err != nil {
+		return certificateMap, err
+	}
+
+	var certs = certBundleRegex.FindAllStringSubmatch(string(bytes), -1)
+	var cert tls.Certificate
+	var k string
+	// Reverse iterate. So last cert processed is on top
+	for i := range certs { i = len(certs) - 1 - i
+		cert, err = certificate.FromPemBytes([]byte(certs[i][0]), password)
+		k = cert.Leaf.Subject.Names[0].Value.(string)
+		certificateMap[k] = cert
+	}
+	certificateMap[""] = cert
+
+	return certificateMap, err
+}
+
 func newApnsClient(certificate tls.Certificate) (*apns2.Client, error) {
 	var client *apns2.Client
 
@@ -320,7 +349,12 @@ func GetIOSNotification(req PushNotification) *apns2.Notification {
 	return notification
 }
 
+var topicToBundleId= regexp.MustCompile(`^(.*)(\.voip|\.complication|\.pushkit\.fileprovider)$`)
 func getApnsClient(req PushNotification) (client *apns2.Client) {
+	var bundleID = topicToBundleId.ReplaceAllString(req.Topic, `$1`)
+	var ApnsClient, exists = ApnsClientMap[bundleID]
+	if !exists { ApnsClient = ApnsClientMap[""] }
+
 	if req.Production {
 		client = ApnsClient.Production()
 	} else if req.Development {

McPo avatar Mar 25 '21 16:03 McPo