Jelajahi Sumber

Add alert action URLs

Rod Hynes 4 tahun lalu
induk
melakukan
ba7eaab9c2

+ 8 - 2
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -119,7 +119,7 @@ public class PsiphonTunnel {
         default public void onActiveAuthorizationIDs(List<String> authorizations) {}
         default public void onTrafficRateLimits(long upstreamBytesPerSecond, long downstreamBytesPerSecond) {}
         default public void onApplicationParameter(String key, Object value) {}
-        default public void onServerAlert(String reason, String subject) {}
+        default public void onServerAlert(String reason, String subject, List<String> actionURLs) {}
         default public void onExiting() {}
     }
 
@@ -980,9 +980,15 @@ public class PsiphonTunnel {
                     notice.getJSONObject("data").getString("key"),
                     notice.getJSONObject("data").get("value"));
             } else if (noticeType.equals("ServerAlert")) {
+                JSONArray actionURLs = notice.getJSONObject("data").getJSONArray("actionURLs");
+                ArrayList<String> actionURLsList = new ArrayList<String>();
+                for (int i=0; i<actionURLs.length(); i++) {
+                    actionURLsList.add(actionURLs.getString(i));
+                }
                 mHostService.onServerAlert(
                     notice.getJSONObject("data").getString("reason"),
-                    notice.getJSONObject("data").getString("subject"));
+                    notice.getJSONObject("data").getString("subject"),
+                    actionURLsList);
             }
 
             if (diagnostic) {

+ 1 - 1
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -284,7 +284,7 @@ WWAN or vice versa or VPN state changed
  @param reason The reason for the alert.
  @param subject Additional context or classification of the reason; blank for none.
  */
-- (void)onServerAlert:(NSString * _Nonnull)reason :(NSString * _Nonnull)subject;
+- (void)onServerAlert:(NSString * _Nonnull)reason :(NSString * _Nonnull)subject :(NSArray * _Nonnull)actionURLs;
 
 @end
 

+ 6 - 1
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -1083,10 +1083,15 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
             [self logMessage:[NSString stringWithFormat: @"ServerAlert notice missing data.reason or data.subject: %@", noticeJSON]];
             return;
         }
+        id actionURLs = [notice valueForKeyPath:@"data.actionURLs"];
+        if (![actionURLs isKindOfClass:[NSArray class]]) {
+            [self logMessage:[NSString stringWithFormat: @"ServerAlert notice missing data.actionURLs: %@", noticeJSON]];
+            return;
+        }
 
         if ([self.tunneledAppDelegate respondsToSelector:@selector(onServerAlert::)]) {
             dispatch_sync(self->callbackQueue, ^{
-                [self.tunneledAppDelegate onServerAlert:reason:subject];
+                [self.tunneledAppDelegate onServerAlert:reason:subject:actionURLs];
             });
         }
     }

+ 3 - 2
psiphon/common/protocol/protocol.go

@@ -486,8 +486,9 @@ type RandomStreamRequest struct {
 }
 
 type AlertRequest struct {
-	Reason  string `json:"reason"`
-	Subject string `json:"subject"`
+	Reason     string   `json:"reason"`
+	Subject    string   `json:"subject"`
+	ActionURLs []string `json:"action"`
 }
 
 func DeriveSSHServerKEXPRNGSeed(obfuscatedKey string) (*prng.Seed, error) {

+ 11 - 2
psiphon/notice.go

@@ -808,7 +808,7 @@ func NoticeActiveAuthorizationIDs(diagnosticID string, activeAuthorizationIDs []
 
 	// Never emit 'null' instead of empty list
 	if activeAuthorizationIDs == nil {
-		activeAuthorizationIDs = make([]string, 0)
+		activeAuthorizationIDs = []string{}
 	}
 
 	singletonNoticeLogger.outputNotice(
@@ -892,12 +892,21 @@ func NoticeApplicationParameters(keyValues parameters.KeyValues) {
 // reported at most once per session.
 func NoticeServerAlert(alert protocol.AlertRequest) {
 
+	// Never emit 'null' instead of empty list
+	actionURLs := alert.ActionURLs
+	if actionURLs == nil {
+		actionURLs = []string{}
+	}
+
 	// This key ensures that each distinct server alert will appear, not repeat,
 	// and not interfere with other alerts appearing.
 	repetitionKey := fmt.Sprintf("ServerAlert-%+v", alert)
 	outputRepetitiveNotice(
 		repetitionKey, "", 0,
-		"ServerAlert", 0, "reason", alert.Reason, "subject", alert.Subject)
+		"ServerAlert", 0,
+		"reason", alert.Reason,
+		"subject", alert.Subject,
+		"actionURLs", actionURLs)
 }
 
 // NoticeBursts reports tunnel data transfer burst metrics.

+ 39 - 5
psiphon/server/psinet/psinet.go

@@ -45,11 +45,12 @@ const (
 type Database struct {
 	common.ReloadableFile
 
-	Sponsors             map[string]*Sponsor        `json:"sponsors"`
-	Versions             map[string][]ClientVersion `json:"client_versions"`
-	DefaultSponsorID     string                     `json:"default_sponsor_id"`
-	ValidServerEntryTags map[string]bool            `json:"valid_server_entry_tags"`
-	DiscoveryServers     []*DiscoveryServer         `json:"discovery_servers"`
+	Sponsors               map[string]*Sponsor        `json:"sponsors"`
+	Versions               map[string][]ClientVersion `json:"client_versions"`
+	DefaultSponsorID       string                     `json:"default_sponsor_id"`
+	DefaultAlertActionURLs map[string][]string        `json:"default_alert_action_urls"`
+	ValidServerEntryTags   map[string]bool            `json:"valid_server_entry_tags"`
+	DiscoveryServers       []*DiscoveryServer         `json:"discovery_servers"`
 
 	fileModTime time.Time
 }
@@ -63,6 +64,7 @@ type Sponsor struct {
 	ID                  string                `json:"id"`
 	HomePages           map[string][]HomePage `json:"home_pages"`
 	MobileHomePages     map[string][]HomePage `json:"mobile_home_pages"`
+	AlertActionURLs     map[string][]string   `json:"alert_action_urls"`
 	HttpsRequestRegexes []HttpsRequestRegex   `json:"https_request_regexes"`
 }
 
@@ -100,6 +102,7 @@ func NewDatabase(filename string) (*Database, error) {
 			database.Sponsors = newDatabase.Sponsors
 			database.Versions = newDatabase.Versions
 			database.DefaultSponsorID = newDatabase.DefaultSponsorID
+			database.DefaultAlertActionURLs = newDatabase.DefaultAlertActionURLs
 			database.ValidServerEntryTags = newDatabase.ValidServerEntryTags
 			database.DiscoveryServers = newDatabase.DiscoveryServers
 			database.fileModTime = fileModTime
@@ -195,6 +198,37 @@ func homepageQueryParameterSubstitution(
 		"client_asn=XX", "client_asn="+clientASN, 1)
 }
 
+// GetAlertActionURLs returns a list of alert action URLs for the specified
+// alert reason and sponsor.
+func (db *Database) GetAlertActionURLs(
+	alertReason, sponsorID, clientRegion, clientASN string) []string {
+
+	db.ReloadableFile.RLock()
+	defer db.ReloadableFile.RUnlock()
+
+	// Prefer URLs from the Sponsor.AlertActionURLs. When there are no sponsor
+	// URLs, then select from Database.DefaultAlertActionURLs.
+
+	actionURLs := []string{}
+
+	sponsor := db.Sponsors[sponsorID]
+	if sponsor != nil {
+		for _, URL := range sponsor.AlertActionURLs[alertReason] {
+			actionURLs = append(
+				actionURLs, homepageQueryParameterSubstitution(URL, clientRegion, clientASN))
+		}
+	}
+
+	if len(actionURLs) == 0 {
+		for _, URL := range db.DefaultAlertActionURLs[alertReason] {
+			actionURLs = append(
+				actionURLs, homepageQueryParameterSubstitution(URL, clientRegion, clientASN))
+		}
+	}
+
+	return actionURLs
+}
+
 // GetUpgradeClientVersion returns a new client version when an upgrade is
 // indicated for the specified client current version. The result is "" when
 // no upgrade is available. Caller should normalize clientPlatform.

+ 30 - 0
psiphon/server/psinet/psinet_test.go

@@ -62,6 +62,9 @@ func TestDatabase(t *testing.T) {
                         "url" : "DEFAULT-MOBILE-HOME-PAGE-URL?client_region=XX&client_asn=XX"
                      }]
                 },
+                "alert_action_urls" : {
+                    "ALERT-REASON-1" : ["SPONSOR-ALERT-1-ACTION-URL?client_region=XX"]
+                },
                 "https_request_regexes" : [{
                     "regex" : "REGEX-VALUE",
                     "replace" : "REPLACE-VALUE"
@@ -78,6 +81,11 @@ func TestDatabase(t *testing.T) {
 
         "default_sponsor_id" : "SPONSOR-ID",
 
+        "default_alert_action_urls" : {
+            "ALERT-REASON-1" : ["DEFAULT-ALERT-1-ACTION-URL?client_region=XX"],
+            "ALERT-REASON-2" : ["DEFAULT-ALERT-2-ACTION-URL?client_region=XX"]
+        },
+
         "valid_server_entry_tags" : {
             "SERVER-ENTRY-TAG" : true
         },
@@ -132,6 +140,28 @@ func TestDatabase(t *testing.T) {
 		})
 	}
 
+	alertActionURLTestCases := []struct {
+		alertReason      string
+		sponsorID        string
+		expectedURLCount int
+		expectedURL      string
+	}{
+		{"ALERT-REASON-1", "SPONSOR-ID", 1, "SPONSOR-ALERT-1-ACTION-URL?client_region=CLIENT-REGION"},
+		{"ALERT-REASON-1", "UNCONFIGURED-SPONSOR-ID", 1, "DEFAULT-ALERT-1-ACTION-URL?client_region=CLIENT-REGION"},
+		{"ALERT-REASON-2", "SPONSOR-ID", 1, "DEFAULT-ALERT-2-ACTION-URL?client_region=CLIENT-REGION"},
+		{"ALERT-REASON-2", "UNCONFIGURED-SPONSOR-ID", 1, "DEFAULT-ALERT-2-ACTION-URL?client_region=CLIENT-REGION"},
+		{"UNCONFIGURED-ALERT-REASON", "SPONSOR-ID", 0, ""},
+	}
+
+	for _, testCase := range alertActionURLTestCases {
+		t.Run(fmt.Sprintf("%+v", testCase), func(t *testing.T) {
+			URLs := db.GetAlertActionURLs(testCase.alertReason, testCase.sponsorID, "CLIENT-REGION", "")
+			if len(URLs) != testCase.expectedURLCount || (len(URLs) > 0 && URLs[0] != testCase.expectedURL) {
+				t.Fatalf("unexpected URLs: %d %+v, %+v", testCase.expectedURLCount, testCase.expectedURL, URLs)
+			}
+		})
+	}
+
 	versionTestCases := []struct {
 		currentClientVersion         string
 		clientPlatform               string

+ 22 - 6
psiphon/server/server_test.go

@@ -32,6 +32,7 @@ import (
 	"net/url"
 	"os"
 	"path/filepath"
+	"reflect"
 	"regexp"
 	"strconv"
 	"strings"
@@ -653,11 +654,12 @@ type runServerConfig struct {
 }
 
 var (
-	testSSHClientVersions   = []string{"SSH-2.0-A", "SSH-2.0-B", "SSH-2.0-C"}
-	testUserAgents          = []string{"ua1", "ua2", "ua3"}
-	testNetworkType         = "WIFI"
-	testCustomHostNameRegex = `[a-z0-9]{5,10}\.example\.org`
-	testClientFeatures      = []string{"feature 1", "feature 2"}
+	testSSHClientVersions                = []string{"SSH-2.0-A", "SSH-2.0-B", "SSH-2.0-C"}
+	testUserAgents                       = []string{"ua1", "ua2", "ua3"}
+	testNetworkType                      = "WIFI"
+	testCustomHostNameRegex              = `[a-z0-9]{5,10}\.example\.org`
+	testClientFeatures                   = []string{"feature 1", "feature 2"}
+	testDisallowedTrafficAlertActionURLs = []string{"https://example.org/disallowed"}
 )
 
 var serverRuns = 0
@@ -1153,8 +1155,15 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 				}
 
 			case "ServerAlert":
+
 				reason := payload["reason"].(string)
-				if reason == protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC {
+				actionURLsPayload := payload["actionURLs"].([]interface{})
+				actionURLs := make([]string, len(actionURLsPayload))
+				for i, value := range actionURLsPayload {
+					actionURLs[i] = value.(string)
+				}
+				if reason == protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC &&
+					reflect.DeepEqual(actionURLs, testDisallowedTrafficAlertActionURLs) {
 					sendNotificationReceived(serverAlertDisallowedNoticesEmitted)
 				}
 
@@ -1933,12 +1942,17 @@ func pavePsinetDatabaseFile(
                 }
             }
         },
+        "default_alert_action_urls" : {
+            "%s": %s
+        },
         "valid_server_entry_tags" : {
             %s
         }
     }
 	`
 
+	actionURLsJSON, _ := json.Marshal(testDisallowedTrafficAlertActionURLs)
+
 	validServerEntryTagsJSON := ""
 	for _, serverEntryTag := range validServerEntryTags {
 		if len(validServerEntryTagsJSON) > 0 {
@@ -1952,6 +1966,8 @@ func pavePsinetDatabaseFile(
 		defaultSponsorID,
 		sponsorID,
 		expectedHomepageURL,
+		protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC,
+		actionURLsJSON,
 		validServerEntryTagsJSON)
 
 	err := ioutil.WriteFile(psinetFilename, []byte(psinetJSON), 0600)

+ 37 - 11
psiphon/server/tunnelServer.go

@@ -1226,7 +1226,7 @@ type sshClient struct {
 	preHandshakeRandomStreamMetrics      randomStreamMetrics
 	postHandshakeRandomStreamMetrics     randomStreamMetrics
 	sendAlertRequests                    chan protocol.AlertRequest
-	sentAlertRequests                    map[protocol.AlertRequest]bool
+	sentAlertRequests                    map[string]bool
 }
 
 type trafficState struct {
@@ -1318,7 +1318,7 @@ func newSshClient(
 		stopRunning:                      stopRunning,
 		stopped:                          make(chan struct{}),
 		sendAlertRequests:                make(chan protocol.AlertRequest, ALERT_REQUEST_QUEUE_BUFFER_SIZE),
-		sentAlertRequests:                make(map[protocol.AlertRequest]bool),
+		sentAlertRequests:                make(map[string]bool),
 	}
 
 	client.tcpTrafficState.availablePortForwardCond = sync.NewCond(new(sync.Mutex))
@@ -2682,7 +2682,7 @@ func (sshClient *sshClient) runAlertSender() {
 				break
 			}
 			sshClient.Lock()
-			sshClient.sentAlertRequests[request] = true
+			sshClient.sentAlertRequests[fmt.Sprintf("%+v", request)] = true
 			sshClient.Unlock()
 		}
 	}
@@ -2694,7 +2694,7 @@ func (sshClient *sshClient) runAlertSender() {
 // not block until the queue exceeds ALERT_REQUEST_QUEUE_BUFFER_SIZE.
 func (sshClient *sshClient) enqueueAlertRequest(request protocol.AlertRequest) {
 	sshClient.Lock()
-	if sshClient.sentAlertRequests[request] {
+	if sshClient.sentAlertRequests[fmt.Sprintf("%+v", request)] {
 		sshClient.Unlock()
 		return
 	}
@@ -2706,20 +2706,46 @@ func (sshClient *sshClient) enqueueAlertRequest(request protocol.AlertRequest) {
 }
 
 func (sshClient *sshClient) enqueueDisallowedTrafficAlertRequest() {
-	sshClient.enqueueAlertRequest(protocol.AlertRequest{
-		Reason: protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC,
-	})
+
+	reason := protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC
+	actionURLs := sshClient.getAlertActionURLs(reason)
+
+	sshClient.enqueueAlertRequest(
+		protocol.AlertRequest{
+			Reason:     protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC,
+			ActionURLs: actionURLs,
+		})
 }
 
 func (sshClient *sshClient) enqueueUnsafeTrafficAlertRequest(tags []BlocklistTag) {
+
+	reason := protocol.PSIPHON_API_ALERT_UNSAFE_TRAFFIC
+	actionURLs := sshClient.getAlertActionURLs(reason)
+
 	for _, tag := range tags {
-		sshClient.enqueueAlertRequest(protocol.AlertRequest{
-			Reason:  protocol.PSIPHON_API_ALERT_UNSAFE_TRAFFIC,
-			Subject: tag.Subject,
-		})
+		sshClient.enqueueAlertRequest(
+			protocol.AlertRequest{
+				Reason:     reason,
+				Subject:    tag.Subject,
+				ActionURLs: actionURLs,
+			})
 	}
 }
 
+func (sshClient *sshClient) getAlertActionURLs(alertReason string) []string {
+
+	sshClient.Lock()
+	sponsorID, _ := getStringRequestParam(
+		sshClient.handshakeState.apiParams, "sponsor_id")
+	sshClient.Unlock()
+
+	return sshClient.sshServer.support.PsinetDatabase.GetAlertActionURLs(
+		alertReason,
+		sponsorID,
+		sshClient.geoIPData.Country,
+		sshClient.geoIPData.ASN)
+}
+
 func (sshClient *sshClient) rejectNewChannel(newChannel ssh.NewChannel, logMessage string) {
 
 	// We always return the reject reason "Prohibited":