Просмотр исходного кода

Merge pull request #541 from rod-hynes/master

Add AlertRequest/NoticeServerAlert
Rod Hynes 6 лет назад
Родитель
Сommit
13b45c842e

+ 5 - 0
MobileLibrary/Android/PsiphonTunnel/PsiphonTunnel.java

@@ -97,6 +97,7 @@ public class PsiphonTunnel {
         default public void onStoppedWaitingForNetworkConnectivity() {}
         default public void onActiveAuthorizationIDs(List<String> authorizations) {}
         default public void onApplicationParameter(String key, Object value) {}
+        default public void onServerAlert(String reason, String subject) {}
         default public void onExiting() {}
     }
 
@@ -780,6 +781,10 @@ public class PsiphonTunnel {
                 mHostService.onApplicationParameter(
                     notice.getJSONObject("data").getString("key"),
                     notice.getJSONObject("data").get("value"));
+            } else if (noticeType.equals("ServerAlert")) {
+                mHostService.onServerAlert(
+                    notice.getJSONObject("data").getString("reason"),
+                    notice.getJSONObject("data").getString("subject"));
             }
 
             if (diagnostic) {

+ 8 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -296,6 +296,14 @@ Swift: @code func onInternetReachabilityChanged(_ currentReachability: Reachabil
  */
 - (void)onActiveAuthorizationIDs:(NSArray * _Nonnull)authorizations;
 
+/*!
+ Called when tunnel-core receives an alert from the server.
+ @param reason The reason for the alert.
+ @param subject Additional context or classification of the reason; blank for none.
+ Swift: @code func onServerAlert(_ reason: String, _ subject: String) @endcode
+ */
+- (void)onServerAlert:(NSString * _Nonnull)reason :(NSString * _Nonnull)subject;
+
 @end
 
 /*!

+ 14 - 0
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.m

@@ -1001,6 +1001,20 @@ typedef NS_ERROR_ENUM(PsiphonTunnelErrorDomain, PsiphonTunnelErrorCode) {
             });
         }
     }
+    else if ([noticeType isEqualToString:@"ServerAlert"]) {
+        id reason = [notice valueForKeyPath:@"data.reason"];
+        id subject = [notice valueForKeyPath:@"data.subject"];
+        if (![reason isKindOfClass:[NSString class]] || ![subject isKindOfClass:[NSString class]]) {
+            [self logMessage:[NSString stringWithFormat: @"ServerAlert notice missing data.reason or data.subject: %@", noticeJSON]];
+            return;
+        }
+
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(onServerAlert::)]) {
+            dispatch_sync(self->callbackQueue, ^{
+                [self.tunneledAppDelegate onServerAlert:reason:subject];
+            });
+        }
+    }
 
     else if ([noticeType isEqualToString:@"InternalError"]) {
         internalError = TRUE;

+ 1 - 1
psiphon/LookupIP.go

@@ -62,7 +62,7 @@ func LookupIP(ctx context.Context, host string, config *DialConfig) ([]net.IP, e
 		}
 
 		if GetEmitNetworkParameters() {
-			NoticeAlert("retry resolve host %s: %s", host, err)
+			NoticeWarning("retry resolve host %s: %s", host, err)
 		}
 
 		return bindLookupIP(ctx, host, dnsServer, config)

+ 9 - 0
psiphon/common/protocol/protocol.go

@@ -59,6 +59,10 @@ const (
 	PSIPHON_API_CONNECTED_REQUEST_NAME = "psiphon-connected"
 	PSIPHON_API_STATUS_REQUEST_NAME    = "psiphon-status"
 	PSIPHON_API_OSL_REQUEST_NAME       = "psiphon-osl"
+	PSIPHON_API_ALERT_REQUEST_NAME     = "psiphon-alert"
+
+	PSIPHON_API_ALERT_DISALLOWED_TRAFFIC = "disallowed-traffic"
+	PSIPHON_API_ALERT_UNSAFE_TRAFFIC     = "unsafe-traffic"
 
 	// PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME may still be used by older Android clients
 	PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME = "psiphon-client-verification"
@@ -384,6 +388,11 @@ type RandomStreamRequest struct {
 	DownstreamBytes int `json:"d"`
 }
 
+type AlertRequest struct {
+	Reason  string `json:"reason"`
+	Subject string `json:"subject"`
+}
+
 func DeriveSSHServerKEXPRNGSeed(obfuscatedKey string) (*prng.Seed, error) {
 	// By convention, the obfuscatedKey will often be a hex-encoded 32 byte value,
 	// but this isn't strictly required or validated, so we use SHA256 to map the

+ 22 - 19
psiphon/config.go

@@ -390,10 +390,6 @@ type Config struct {
 	// is used. This value is typical overridden for testing.
 	FetchUpgradeRetryPeriodMilliseconds *int
 
-	// EmitBytesTransferred indicates whether to emit periodic notices showing
-	// bytes sent and received.
-	EmitBytesTransferred bool
-
 	// TrustedCACertificatesFilename specifies a file containing trusted CA
 	// certs. When set, this toggles use of the trusted CA certs, specified in
 	// TrustedCACertificatesFilename, for tunneled TLS connections that expect
@@ -430,14 +426,26 @@ type Config struct {
 	// distributed or displayed to users. Default is off.
 	EmitDiagnosticNetworkParameters bool
 
-	// RateLimits specify throttling configuration for the tunnel.
-	RateLimits common.RateLimits
+	// EmitBytesTransferred indicates whether to emit periodic notices showing
+	// bytes sent and received.
+	EmitBytesTransferred bool
 
 	// EmitSLOKs indicates whether to emit notices for each seeded SLOK. As
 	// this could reveal user browsing activity, it's intended for debugging
 	// and testing only.
 	EmitSLOKs bool
 
+	// EmitTapdanceLogs indicates whether to emit gotapdance log messages
+	// to stdout. Note that gotapdance log messages do not conform to the
+	// Notice format standard. Default is off.
+	EmitTapdanceLogs bool
+
+	// EmitServerAlerts indicates whether to emit notices for server alerts.
+	EmitServerAlerts bool
+
+	// RateLimits specify throttling configuration for the tunnel.
+	RateLimits common.RateLimits
+
 	// PacketTunnelTunDeviceFileDescriptor specifies a tun device file
 	// descriptor to use for running a packet tunnel. When this value is > 0,
 	// a packet tunnel is established through the server and packets are
@@ -472,11 +480,6 @@ type Config struct {
 	// Required for the exchange functionality.
 	ExchangeObfuscationKey string
 
-	// EmitTapdanceLogs indicates whether to emit gotapdance log messages
-	// to stdout. Note that gotapdance log messages do not conform to the
-	// Notice format standard. Default is off.
-	EmitTapdanceLogs bool
-
 	// TransformHostNameProbability is for testing purposes.
 	TransformHostNameProbability *float64
 
@@ -879,7 +882,7 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 
 	// Emit notices now that notice files are set if configured
 	for _, msg := range noticeMigrationAlertMsgs {
-		NoticeAlert(msg)
+		NoticeWarning(msg)
 	}
 	for _, msg := range noticeMigrationInfoMsgs {
 		NoticeInfo(msg)
@@ -1041,7 +1044,7 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 
 	config.clientParameters, err = parameters.NewClientParameters(
 		func(err error) {
-			NoticeAlert("ClientParameters getValue failed: %s", err)
+			NoticeWarning("ClientParameters getValue failed: %s", err)
 		})
 	if err != nil {
 		return errors.Trace(err)
@@ -1124,7 +1127,7 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		for _, migration := range migrations {
 			err := common.DoFileMigration(migration)
 			if err != nil {
-				NoticeAlert("Config migration: %s", errors.Trace(err))
+				NoticeWarning("Config migration: %s", errors.Trace(err))
 			} else {
 				NoticeInfo("Config migration: moved %s to %s", migration.OldPath, migration.NewPath)
 			}
@@ -1134,18 +1137,18 @@ func (config *Config) Commit(migrateFromLegacyFields bool) error {
 		if config.MigrateObfuscatedServerListDownloadDirectory != "" {
 			files, err := ioutil.ReadDir(config.MigrateObfuscatedServerListDownloadDirectory)
 			if err != nil {
-				NoticeAlert("Error reading OSL directory %s: %s", config.MigrateObfuscatedServerListDownloadDirectory, errors.Trace(err))
+				NoticeWarning("Error reading OSL directory %s: %s", config.MigrateObfuscatedServerListDownloadDirectory, errors.Trace(err))
 			} else if len(files) == 0 {
 				err := os.Remove(config.MigrateObfuscatedServerListDownloadDirectory)
 				if err != nil {
-					NoticeAlert("Error deleting empty OSL directory %s: %s", config.MigrateObfuscatedServerListDownloadDirectory, errors.Trace(err))
+					NoticeWarning("Error deleting empty OSL directory %s: %s", config.MigrateObfuscatedServerListDownloadDirectory, errors.Trace(err))
 				}
 			}
 		}
 
 		f, err := os.Create(migrationCompleteFilePath)
 		if err != nil {
-			NoticeAlert("Config migration: failed to create %s with error %s", migrationCompleteFilePath, errors.Trace(err))
+			NoticeWarning("Config migration: failed to create %s with error %s", migrationCompleteFilePath, errors.Trace(err))
 		} else {
 			NoticeInfo("Config migration: completed")
 			f.Close()
@@ -1873,7 +1876,7 @@ func migrationsFromLegacyFilePaths(config *Config) ([]common.FileMigration, erro
 
 		files, err := ioutil.ReadDir(config.MigrateObfuscatedServerListDownloadDirectory)
 		if err != nil {
-			NoticeAlert("Migration: failed to read directory %s with error %s", config.MigrateObfuscatedServerListDownloadDirectory, err)
+			NoticeWarning("Migration: failed to read directory %s with error %s", config.MigrateObfuscatedServerListDownloadDirectory, err)
 		} else {
 			for _, file := range files {
 				if oslFileRegex.MatchString(file.Name()) {
@@ -1907,7 +1910,7 @@ func migrationsFromLegacyFilePaths(config *Config) ([]common.FileMigration, erro
 
 		files, err := ioutil.ReadDir(upgradeDownloadDir)
 		if err != nil {
-			NoticeAlert("Migration: failed to read directory %s with error %s", upgradeDownloadDir, err)
+			NoticeWarning("Migration: failed to read directory %s with error %s", upgradeDownloadDir, err)
 		} else {
 
 			for _, file := range files {

+ 31 - 20
psiphon/controller.go

@@ -197,7 +197,7 @@ func (controller *Controller) Run(ctx context.Context) {
 	if !controller.config.DisableLocalSocksProxy {
 		socksProxy, err := NewSocksProxy(controller.config, controller, listenIP)
 		if err != nil {
-			NoticeAlert("error initializing local SOCKS proxy: %s", err)
+			NoticeWarning("error initializing local SOCKS proxy: %s", err)
 			return
 		}
 		defer socksProxy.Close()
@@ -206,7 +206,7 @@ func (controller *Controller) Run(ctx context.Context) {
 	if !controller.config.DisableLocalHTTPProxy {
 		httpProxy, err := NewHttpProxy(controller.config, controller, listenIP)
 		if err != nil {
-			NoticeAlert("error initializing local HTTP proxy: %s", err)
+			NoticeWarning("error initializing local HTTP proxy: %s", err)
 			return
 		}
 		defer httpProxy.Close()
@@ -275,7 +275,7 @@ func (controller *Controller) Run(ctx context.Context) {
 // SignalComponentFailure notifies the controller that an associated component has failed.
 // This will terminate the controller.
 func (controller *Controller) SignalComponentFailure() {
-	NoticeAlert("controller shutdown due to component failure")
+	NoticeWarning("controller shutdown due to component failure")
 	controller.stopRunning()
 }
 
@@ -393,7 +393,7 @@ fetcherLoop:
 				break retryLoop
 			}
 
-			NoticeAlert("failed to fetch %s remote server list: %s", name, err)
+			NoticeWarning("failed to fetch %s remote server list: %s", name, err)
 
 			retryPeriod := controller.config.GetClientParameters().Get().Duration(
 				parameters.FetchRemoteServerListRetryPeriod)
@@ -461,7 +461,7 @@ loop:
 			if err == nil {
 				reported = true
 			} else {
-				NoticeAlert("failed to make connected request: %s", err)
+				NoticeWarning("failed to make connected request: %s", err)
 			}
 		}
 
@@ -585,7 +585,7 @@ downloadLoop:
 				break retryLoop
 			}
 
-			NoticeAlert("failed to download upgrade: %s", err)
+			NoticeWarning("failed to download upgrade: %s", err)
 
 			timeout := controller.config.GetClientParameters().Get().Duration(
 				parameters.FetchUpgradeRetryPeriod)
@@ -643,7 +643,7 @@ loop:
 			}
 
 		case failedTunnel := <-controller.failedTunnels:
-			NoticeAlert("tunnel failed: %s", failedTunnel.dialParams.ServerEntry.GetDiagnosticID())
+			NoticeWarning("tunnel failed: %s", failedTunnel.dialParams.ServerEntry.GetDiagnosticID())
 			controller.terminateTunnel(failedTunnel)
 
 			// Clear the reference to this tunnel before calling startEstablishing,
@@ -698,7 +698,7 @@ loop:
 				err := connectedTunnel.Activate(controller.runCtx, controller)
 
 				if err != nil {
-					NoticeAlert("failed to activate %s: %s",
+					NoticeWarning("failed to activate %s: %s",
 						connectedTunnel.dialParams.ServerEntry.GetDiagnosticID(), err)
 					discardTunnel = true
 				} else {
@@ -706,7 +706,7 @@ loop:
 					// calls registerTunnel -- and after checking numTunnels; so failure is not
 					// expected.
 					if !controller.registerTunnel(connectedTunnel) {
-						NoticeAlert("failed to register %s: %s",
+						NoticeWarning("failed to register %s: %s",
 							connectedTunnel.dialParams.ServerEntry.GetDiagnosticID(), err)
 						discardTunnel = true
 					}
@@ -866,7 +866,7 @@ func (controller *Controller) registerTunnel(tunnel *Tunnel) bool {
 		if activeTunnel.dialParams.ServerEntry.IpAddress ==
 			tunnel.dialParams.ServerEntry.IpAddress {
 
-			NoticeAlert("duplicate tunnel: %s", tunnel.dialParams.ServerEntry.GetDiagnosticID())
+			NoticeWarning("duplicate tunnel: %s", tunnel.dialParams.ServerEntry.GetDiagnosticID())
 			return false
 		}
 	}
@@ -1338,7 +1338,7 @@ func (controller *Controller) launchEstablishing() {
 				controller.protocolSelectionConstraints,
 				initialCount,
 				count)
-			NoticeAlert("skipping initial limit tunnel protocols")
+			NoticeWarning("skipping initial limit tunnel protocols")
 			controller.protocolSelectionConstraints.initialLimitProtocolsCandidateCount = 0
 
 			// Since we were unable to satisfy the InitialLimitTunnelProtocols
@@ -1437,7 +1437,7 @@ func (controller *Controller) getTactics(done chan struct{}) {
 		GetTacticsStorer(),
 		controller.config.GetNetworkID())
 	if err != nil {
-		NoticeAlert("get stored tactics failed: %s", err)
+		NoticeWarning("get stored tactics failed: %s", err)
 
 		// The error will be due to a local datastore problem.
 		// While we could proceed with the tactics request, this
@@ -1450,7 +1450,7 @@ func (controller *Controller) getTactics(done chan struct{}) {
 		iterator, err := NewTacticsServerEntryIterator(
 			controller.config)
 		if err != nil {
-			NoticeAlert("tactics iterator failed: %s", err)
+			NoticeWarning("tactics iterator failed: %s", err)
 			return
 		}
 		defer iterator.Close()
@@ -1465,13 +1465,13 @@ func (controller *Controller) getTactics(done chan struct{}) {
 
 			serverEntry, err := iterator.Next()
 			if err != nil {
-				NoticeAlert("tactics iterator failed: %s", err)
+				NoticeWarning("tactics iterator failed: %s", err)
 				return
 			}
 
 			if serverEntry == nil {
 				if iteration == 0 {
-					NoticeAlert("tactics request skipped: no capable servers")
+					NoticeWarning("tactics request skipped: no capable servers")
 					return
 				}
 
@@ -1484,7 +1484,7 @@ func (controller *Controller) getTactics(done chan struct{}) {
 				break
 			}
 
-			NoticeAlert("tactics request failed: %s", err)
+			NoticeWarning("tactics request failed: %s", err)
 
 			// On error, proceed with a retry, as the error is likely
 			// due to a network failure.
@@ -1516,7 +1516,7 @@ func (controller *Controller) getTactics(done chan struct{}) {
 		err := controller.config.SetClientParameters(
 			tacticsRecord.Tag, true, tacticsRecord.Tactics.Parameters)
 		if err != nil {
-			NoticeAlert("apply tactics failed: %s", err)
+			NoticeWarning("apply tactics failed: %s", err)
 
 			// The error will be due to invalid tactics values from
 			// the server. When ApplyClientParameters fails, all
@@ -1645,7 +1645,7 @@ func (controller *Controller) establishCandidateGenerator() {
 
 	applyServerAffinity, iterator, err := NewServerEntryIterator(controller.config)
 	if err != nil {
-		NoticeAlert("failed to iterate over candidates: %s", err)
+		NoticeWarning("failed to iterate over candidates: %s", err)
 		controller.SignalComponentFailure()
 		return
 	}
@@ -1710,7 +1710,7 @@ loop:
 
 			serverEntry, err := iterator.Next()
 			if err != nil {
-				NoticeAlert("failed to get next candidate: %s", err)
+				NoticeWarning("failed to get next candidate: %s", err)
 				controller.SignalComponentFailure()
 				break loop
 			}
@@ -1786,7 +1786,18 @@ loop:
 
 		// Trigger RSL, OSL, and upgrade checks after failing to establish a
 		// tunnel in the first round.
-		controller.triggerFetches()
+		//
+		// No fetches are triggered when TargetServerEntry is specified. In that
+		// case, we're only trying to connect to a specific server entry; and, the
+		// iterator will complete immediately since there is only one candidate,
+		// triggering fetches unnecessarily.
+		//
+		// TODO: in standard iterator case, should we wait for all candidates to
+		// _complete_ before triggering fetches? Currently, the iterator loop
+		// exits with one round of candidates in flight.
+		if controller.config.TargetServerEntry == "" {
+			controller.triggerFetches()
+		}
 
 		// After a complete iteration of candidate servers, pause before iterating again.
 		// This helps avoid some busy wait loop conditions, and also allows some time for

+ 13 - 13
psiphon/dataStore.go

@@ -96,7 +96,7 @@ func CloseDataStore() {
 
 	err := activeDatastoreDB.close()
 	if err != nil {
-		NoticeAlert("failed to close database: %s", errors.Trace(err))
+		NoticeWarning("failed to close database: %s", errors.Trace(err))
 	}
 
 	activeDatastoreDB = nil
@@ -315,7 +315,7 @@ func PromoteServerEntry(config *Config, ipAddress string) error {
 		bucket := tx.bucket(datastoreServerEntriesBucket)
 		data := bucket.get(serverEntryID)
 		if data == nil {
-			NoticeAlert(
+			NoticeWarning(
 				"PromoteServerEntry: ignoring unknown server entry: %s",
 				ipAddress)
 			return nil
@@ -690,7 +690,7 @@ func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
 				// In case of data corruption or a bug causing this condition,
 				// do not stop iterating.
 				serverEntry = nil
-				NoticeAlert(
+				NoticeWarning(
 					"ServerEntryIterator.Next: json.Unmarshal failed: %s",
 					errors.Trace(err))
 			}
@@ -704,7 +704,7 @@ func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
 		if serverEntry == nil {
 			// In case of data corruption or a bug causing this condition,
 			// do not stop iterating.
-			NoticeAlert("ServerEntryIterator.Next: unexpected missing server entry")
+			NoticeWarning("ServerEntryIterator.Next: unexpected missing server entry")
 			continue
 		}
 
@@ -776,7 +776,7 @@ func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
 
 			if err != nil {
 				// Do not stop.
-				NoticeAlert(
+				NoticeWarning(
 					"ServerEntryIterator.Next: update server entry failed: %s",
 					errors.Trace(err))
 			}
@@ -826,7 +826,7 @@ func MakeCompatibleServerEntry(serverEntry *protocol.ServerEntry) *protocol.Serv
 func PruneServerEntry(config *Config, serverEntryTag string) {
 	err := pruneServerEntry(config, serverEntryTag)
 	if err != nil {
-		NoticeAlert(
+		NoticeWarning(
 			"PruneServerEntry failed: %s: %s",
 			serverEntryTag, errors.Trace(err))
 		return
@@ -951,7 +951,7 @@ func scanServerEntries(scanner func(*protocol.ServerEntry)) error {
 			if err != nil {
 				// In case of data corruption or a bug causing this condition,
 				// do not stop iterating.
-				NoticeAlert("scanServerEntries: %s", errors.Trace(err))
+				NoticeWarning("scanServerEntries: %s", errors.Trace(err))
 				continue
 			}
 			scanner(serverEntry)
@@ -981,7 +981,7 @@ func CountServerEntries() int {
 	})
 
 	if err != nil {
-		NoticeAlert("CountServerEntries failed: %s", err)
+		NoticeWarning("CountServerEntries failed: %s", err)
 		return 0
 	}
 
@@ -1016,7 +1016,7 @@ func CountServerEntriesWithConstraints(
 	})
 
 	if err != nil {
-		NoticeAlert("CountServerEntriesWithConstraints failed: %s", err)
+		NoticeWarning("CountServerEntriesWithConstraints failed: %s", err)
 		return 0, 0
 	}
 
@@ -1049,7 +1049,7 @@ func ReportAvailableRegions(config *Config, constraints *protocolSelectionConstr
 	})
 
 	if err != nil {
-		NoticeAlert("ReportAvailableRegions failed: %s", err)
+		NoticeWarning("ReportAvailableRegions failed: %s", err)
 		return
 	}
 
@@ -1301,7 +1301,7 @@ func CountUnreportedPersistentStats() int {
 	})
 
 	if err != nil {
-		NoticeAlert("CountUnreportedPersistentStats failed: %s", err)
+		NoticeWarning("CountUnreportedPersistentStats failed: %s", err)
 		return 0
 	}
 
@@ -1336,7 +1336,7 @@ func TakeOutUnreportedPersistentStats(config *Config) (map[string][][]byte, erro
 				var jsonData interface{}
 				err := json.Unmarshal(key, &jsonData)
 				if err != nil {
-					NoticeAlert(
+					NoticeWarning(
 						"Invalid key in TakeOutUnreportedPersistentStats: %s: %s",
 						string(key), err)
 					bucket.delete(key)
@@ -1490,7 +1490,7 @@ func CountSLOKs() int {
 	})
 
 	if err != nil {
-		NoticeAlert("CountSLOKs failed: %s", err)
+		NoticeWarning("CountSLOKs failed: %s", err)
 		return 0
 	}
 

+ 2 - 2
psiphon/dataStore_badger.go

@@ -141,14 +141,14 @@ func (b *datastoreBucket) get(key []byte) []byte {
 		if err != badger.ErrKeyNotFound {
 			// The original datastore interface does not return an error from
 			// Get, so emit notice.
-			NoticeAlert("get failed: %s: %s",
+			NoticeWarning("get failed: %s: %s",
 				string(keyWithPrefix), errors.Trace(err))
 		}
 		return nil
 	}
 	value, err := item.Value()
 	if err != nil {
-		NoticeAlert("get failed: %s: %s",
+		NoticeWarning("get failed: %s: %s",
 			string(keyWithPrefix), errors.Trace(err))
 		return nil
 	}

+ 4 - 4
psiphon/dataStore_bolt.go

@@ -65,7 +65,7 @@ func datastoreOpenDB(rootDataDirectory string) (*datastoreDB, error) {
 			break
 		}
 
-		NoticeAlert("tryDatastoreOpenDB failed: %s", err)
+		NoticeWarning("tryDatastoreOpenDB failed: %s", err)
 
 		// The datastore file may be corrupt, so, in subsequent iterations, set the
 		// "reset" flag and attempt to delete the file and try again.
@@ -99,7 +99,7 @@ func tryDatastoreOpenDB(rootDataDirectory string, reset bool) (retdb *datastoreD
 	filename := filepath.Join(rootDataDirectory, "psiphon.boltdb")
 
 	if reset {
-		NoticeAlert("tryDatastoreOpenDB: reset")
+		NoticeWarning("tryDatastoreOpenDB: reset")
 		os.Remove(filename)
 	}
 
@@ -157,7 +157,7 @@ func tryDatastoreOpenDB(rootDataDirectory string, reset bool) (retdb *datastoreD
 			if tx.Bucket(obsoleteBucket) != nil {
 				err := tx.DeleteBucket(obsoleteBucket)
 				if err != nil {
-					NoticeAlert("DeleteBucket %s error: %s", obsoleteBucket, err)
+					NoticeWarning("DeleteBucket %s error: %s", obsoleteBucket, err)
 					// Continue, since this is not fatal
 				}
 			}
@@ -179,7 +179,7 @@ func (db *datastoreDB) isDatastoreFailed() bool {
 
 func (db *datastoreDB) setDatastoreFailed(r interface{}) {
 	atomic.StoreInt32(&db.isFailed, 1)
-	NoticeAlert("Datastore failed: %s", errors.Tracef("panic: %v", r))
+	NoticeWarning("Datastore failed: %s", errors.Tracef("panic: %v", r))
 }
 
 func (db *datastoreDB) close() error {

+ 6 - 6
psiphon/dataStore_files.go

@@ -169,7 +169,7 @@ func (tx *datastoreTx) bucket(name []byte) *datastoreBucket {
 		// The original datastore interface does not return an error from Bucket,
 		// so emit notice, and return zero-value bucket for which all
 		// operations will fail.
-		NoticeAlert("bucket failed: %s", errors.Trace(err))
+		NoticeWarning("bucket failed: %s", errors.Trace(err))
 		return &datastoreBucket{}
 	}
 	return &datastoreBucket{
@@ -203,7 +203,7 @@ func (b *datastoreBucket) get(key []byte) []byte {
 	if err != nil {
 		// The original datastore interface does not return an error from Get,
 		// so emit notice.
-		NoticeAlert("get failed: %s", errors.Trace(err))
+		NoticeWarning("get failed: %s", errors.Trace(err))
 		return nil
 	}
 	if valueBuffer == nil {
@@ -286,7 +286,7 @@ func (b *datastoreBucket) cursor() *datastoreCursor {
 	}
 	fileInfos, err := ioutil.ReadDir(b.bucketDirectory)
 	if err != nil {
-		NoticeAlert("cursor failed: %s", errors.Trace(err))
+		NoticeWarning("cursor failed: %s", errors.Trace(err))
 		return &datastoreCursor{}
 	}
 	return &datastoreCursor{
@@ -328,12 +328,12 @@ func (c *datastoreCursor) currentKey() []byte {
 	}
 	info := c.fileInfos[c.index]
 	if info.IsDir() {
-		NoticeAlert("cursor failed: unexpected dir")
+		NoticeWarning("cursor failed: unexpected dir")
 		return nil
 	}
 	key, err := hex.DecodeString(info.Name())
 	if err != nil {
-		NoticeAlert("cursor failed: %s", errors.Trace(err))
+		NoticeWarning("cursor failed: %s", errors.Trace(err))
 		return nil
 	}
 	return key
@@ -372,7 +372,7 @@ func (c *datastoreCursor) current() ([]byte, []byte) {
 		err = std_errors.New("unexpected nil value")
 	}
 	if err != nil {
-		NoticeAlert("cursor failed: %s", errors.Trace(err))
+		NoticeWarning("cursor failed: %s", errors.Trace(err))
 		return nil, nil
 	}
 	c.lastBuffer = valueBuffer

+ 4 - 4
psiphon/dialParameters.go

@@ -171,7 +171,7 @@ func MakeDialParameters(
 
 	dialParams, err := GetDialParameters(serverEntry.IpAddress, networkID)
 	if err != nil {
-		NoticeAlert("GetDialParameters failed: %s", err)
+		NoticeWarning("GetDialParameters failed: %s", err)
 		dialParams = nil
 		// Proceed, without existing dial parameters.
 	}
@@ -213,7 +213,7 @@ func MakeDialParameters(
 
 		err = DeleteDialParameters(serverEntry.IpAddress, networkID)
 		if err != nil {
-			NoticeAlert("DeleteDialParameters failed: %s", err)
+			NoticeWarning("DeleteDialParameters failed: %s", err)
 		}
 		dialParams = nil
 	}
@@ -693,7 +693,7 @@ func (dialParams *DialParameters) Succeeded() {
 	NoticeInfo("Set dial parameters for %s", dialParams.ServerEntry.GetDiagnosticID())
 	err := SetDialParameters(dialParams.ServerEntry.IpAddress, dialParams.NetworkID, dialParams)
 	if err != nil {
-		NoticeAlert("SetDialParameters failed: %s", err)
+		NoticeWarning("SetDialParameters failed: %s", err)
 	}
 }
 
@@ -718,7 +718,7 @@ func (dialParams *DialParameters) Failed(config *Config) {
 		NoticeInfo("Delete dial parameters for %s", dialParams.ServerEntry.GetDiagnosticID())
 		err := DeleteDialParameters(dialParams.ServerEntry.IpAddress, dialParams.NetworkID)
 		if err != nil {
-			NoticeAlert("DeleteDialParameters failed: %s", err)
+			NoticeWarning("DeleteDialParameters failed: %s", err)
 		}
 	}
 }

+ 2 - 2
psiphon/exchange.go

@@ -76,7 +76,7 @@ import (
 func ExportExchangePayload(config *Config) string {
 	payload, err := exportExchangePayload(config)
 	if err != nil {
-		NoticeAlert("ExportExchangePayload failed: %s", errors.Trace(err))
+		NoticeWarning("ExportExchangePayload failed: %s", errors.Trace(err))
 		return ""
 	}
 	return payload
@@ -99,7 +99,7 @@ func ExportExchangePayload(config *Config) string {
 func ImportExchangePayload(config *Config, encodedPayload string) bool {
 	err := importExchangePayload(config, encodedPayload)
 	if err != nil {
-		NoticeAlert("ImportExchangePayload failed: %s", errors.Trace(err))
+		NoticeWarning("ImportExchangePayload failed: %s", errors.Trace(err))
 		return false
 	}
 	return true

+ 12 - 12
psiphon/httpProxy.go

@@ -236,7 +236,7 @@ func (proxy *HttpProxy) ServeHTTP(responseWriter http.ResponseWriter, request *h
 		go func() {
 			err := proxy.httpConnectHandler(conn, request.URL.Host)
 			if err != nil {
-				NoticeAlert("%s", errors.Trace(err))
+				NoticeWarning("%s", errors.Trace(err))
 			}
 		}()
 	} else if request.URL.IsAbs() {
@@ -310,7 +310,7 @@ func (proxy *HttpProxy) urlProxyHandler(responseWriter http.ResponseWriter, requ
 		err = std_errors.New("missing origin URL")
 	}
 	if err != nil {
-		NoticeAlert("%s", errors.Trace(FilterUrlError(err)))
+		NoticeWarning("%s", errors.Trace(FilterUrlError(err)))
 		forceClose(responseWriter)
 		return
 	}
@@ -318,12 +318,12 @@ func (proxy *HttpProxy) urlProxyHandler(responseWriter http.ResponseWriter, requ
 	// Origin URL must be well-formed, absolute, and have a scheme of "http" or "https"
 	originURL, err := url.ParseRequestURI(originURLString)
 	if err != nil {
-		NoticeAlert("%s", errors.Trace(FilterUrlError(err)))
+		NoticeWarning("%s", errors.Trace(FilterUrlError(err)))
 		forceClose(responseWriter)
 		return
 	}
 	if !originURL.IsAbs() || (originURL.Scheme != "http" && originURL.Scheme != "https") {
-		NoticeAlert("invalid origin URL")
+		NoticeWarning("invalid origin URL")
 		forceClose(responseWriter)
 		return
 	}
@@ -497,7 +497,7 @@ func (proxy *HttpProxy) relayHTTPRequest(
 	}
 
 	if err != nil {
-		NoticeAlert("%s", errors.Trace(FilterUrlError(err)))
+		NoticeWarning("%s", errors.Trace(FilterUrlError(err)))
 		forceClose(responseWriter)
 		return
 	}
@@ -516,7 +516,7 @@ func (proxy *HttpProxy) relayHTTPRequest(
 		}
 
 		if err != nil {
-			NoticeAlert("URL proxy rewrite failed for %s: %s", key, errors.Trace(err))
+			NoticeWarning("URL proxy rewrite failed for %s: %s", key, errors.Trace(err))
 			forceClose(responseWriter)
 			response.Body.Close()
 			return
@@ -560,21 +560,21 @@ func (proxy *HttpProxy) relayHTTPRequest(
 			response.StatusCode,
 			http.StatusText(response.StatusCode))
 		if err != nil {
-			NoticeAlert("write status line failed: %s", errors.Trace(err))
+			NoticeWarning("write status line failed: %s", errors.Trace(err))
 			conn.Close()
 			return
 		}
 
 		err = responseWriter.Header().Write(conn)
 		if err != nil {
-			NoticeAlert("write headers failed: %s", errors.Trace(err))
+			NoticeWarning("write headers failed: %s", errors.Trace(err))
 			conn.Close()
 			return
 		}
 
 		_, err = io.Copy(conn, response.Body)
 		if err != nil {
-			NoticeAlert("write body failed: %s", errors.Trace(err))
+			NoticeWarning("write body failed: %s", errors.Trace(err))
 			conn.Close()
 			return
 		}
@@ -586,7 +586,7 @@ func (proxy *HttpProxy) relayHTTPRequest(
 		responseWriter.WriteHeader(response.StatusCode)
 		_, err = io.Copy(responseWriter, response.Body)
 		if err != nil {
-			NoticeAlert("%s", errors.Trace(err))
+			NoticeWarning("%s", errors.Trace(err))
 			forceClose(responseWriter)
 			return
 		}
@@ -606,12 +606,12 @@ func forceClose(responseWriter http.ResponseWriter) {
 func hijack(responseWriter http.ResponseWriter) net.Conn {
 	hijacker, ok := responseWriter.(http.Hijacker)
 	if !ok {
-		NoticeAlert("%s", errors.TraceNew("responseWriter is not an http.Hijacker"))
+		NoticeWarning("%s", errors.TraceNew("responseWriter is not an http.Hijacker"))
 		return nil
 	}
 	conn, _, err := hijacker.Hijack()
 	if err != nil {
-		NoticeAlert("%s", errors.Tracef("responseWriter hijack failed: %s", err))
+		NoticeWarning("%s", errors.Tracef("responseWriter hijack failed: %s", err))
 		return nil
 	}
 	return conn

+ 3 - 3
psiphon/meekConn.go

@@ -906,7 +906,7 @@ func (meek *MeekConn) relay() {
 				return
 			default:
 			}
-			NoticeAlert("%s", errors.Trace(err))
+			NoticeWarning("%s", errors.Trace(err))
 			go meek.Close()
 			return
 		}
@@ -1186,7 +1186,7 @@ func (meek *MeekConn) relayRoundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 				return 0, errors.Trace(err)
 			default:
 			}
-			NoticeAlert("meek round trip failed: %s", err)
+			NoticeWarning("meek round trip failed: %s", err)
 			// ...continue to retry
 		}
 
@@ -1236,7 +1236,7 @@ func (meek *MeekConn) relayRoundTrip(sendBuffer *bytes.Buffer) (int64, error) {
 			receivedPayloadSize += readPayloadSize
 
 			if err != nil {
-				NoticeAlert("meek read payload failed: %s", err)
+				NoticeWarning("meek read payload failed: %s", err)
 				// ...continue to retry
 			} else {
 				// Round trip completed successfully

+ 2 - 2
psiphon/net.go

@@ -466,12 +466,12 @@ func ResumeDownload(
 
 			tempErr := os.Remove(partialFilename)
 			if tempErr != nil && !os.IsNotExist(tempErr) {
-				NoticeAlert("reset partial download failed: %s", tempErr)
+				NoticeWarning("reset partial download failed: %s", tempErr)
 			}
 
 			tempErr = os.Remove(partialETagFilename)
 			if tempErr != nil && !os.IsNotExist(tempErr) {
-				NoticeAlert("reset partial download ETag failed: %s", tempErr)
+				NoticeWarning("reset partial download ETag failed: %s", tempErr)
 			}
 
 			return 0, "", errors.Tracef(

+ 25 - 21
psiphon/notice.go

@@ -35,6 +35,7 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/buildinfo"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/stacktrace"
 )
 
@@ -101,15 +102,12 @@ func GetEmitNetworkParameters() bool {
 //
 // Notices are encoded in JSON. Here's an example:
 //
-// {"data":{"message":"shutdown operate tunnel"},"noticeType":"Info","showUser":false,"timestamp":"2006-01-02T15:04:05.999999999Z07:00"}
+// {"data":{"message":"shutdown operate tunnel"},"noticeType":"Info","timestamp":"2006-01-02T15:04:05.999999999Z07:00"}
 //
 // All notices have the following fields:
 // - "noticeType": the type of notice, which indicates the meaning of the notice along with what's in the data payload.
 // - "data": additional structured data payload. For example, the "ListeningSocksProxyPort" notice type has a "port" integer
 // data in its payload.
-// - "showUser": whether the information should be displayed to the user. For example, this flag is set for "SocksProxyPortInUse"
-// as the user should be informed that their configured choice of listening port could not be used. Core clients should
-// anticipate that the core will add additional "showUser"=true notices in the future and emit at least the raw notice.
 // - "timestamp": UTC timezone, RFC3339Milli format timestamp for notice event
 //
 // See the Notice* functions for details on each notice meaning and payload.
@@ -201,11 +199,10 @@ func setNoticeFiles(
 }
 
 const (
-	noticeShowUser       = 1
-	noticeIsDiagnostic   = 2
-	noticeIsHomepage     = 4
-	noticeClearHomepages = 8
-	noticeSyncHomepages  = 16
+	noticeIsDiagnostic   = 1
+	noticeIsHomepage     = 2
+	noticeClearHomepages = 4
+	noticeSyncHomepages  = 8
 )
 
 // outputNotice encodes a notice in JSON and writes it to the output writer.
@@ -218,7 +215,6 @@ func (nl *noticeLogger) outputNotice(noticeType string, noticeFlags uint32, args
 	obj := make(map[string]interface{})
 	noticeData := make(map[string]interface{})
 	obj["noticeType"] = noticeType
-	obj["showUser"] = (noticeFlags&noticeShowUser != 0)
 	obj["data"] = noticeData
 	obj["timestamp"] = time.Now().UTC().Format(common.RFC3339Milli)
 	for i := 0; i < len(args)-1; i += 2 {
@@ -289,7 +285,7 @@ func (nl *noticeLogger) outputNotice(noticeType string, noticeFlags uint32, args
 // A NoticeInteralError handler must not call a Notice function.
 func makeNoticeInternalError(errorMessage string) []byte {
 	// Format an Alert Notice (_without_ using json.Marshal, since that can fail)
-	alertNoticeFormat := "{\"noticeType\":\"InternalError\",\"showUser\":false,\"timestamp\":\"%s\",\"data\":{\"message\":\"%s\"}}\n"
+	alertNoticeFormat := "{\"noticeType\":\"InternalError\",\"timestamp\":\"%s\",\"data\":{\"message\":\"%s\"}}\n"
 	return []byte(fmt.Sprintf(alertNoticeFormat, time.Now().UTC().Format(common.RFC3339Milli), errorMessage))
 
 }
@@ -379,10 +375,10 @@ func NoticeInfo(format string, args ...interface{}) {
 		"message", fmt.Sprintf(format, args...))
 }
 
-// NoticeAlert is an alert message; typically a recoverable error condition
-func NoticeAlert(format string, args ...interface{}) {
+// NoticeWarning is a warning message; typically a recoverable error condition
+func NoticeWarning(format string, args ...interface{}) {
 	singletonNoticeLogger.outputNotice(
-		"Alert", noticeIsDiagnostic,
+		"Warning", noticeIsDiagnostic,
 		"message", fmt.Sprintf(format, args...))
 }
 
@@ -560,8 +556,8 @@ func NoticeActiveTunnel(diagnosticID, protocol string, isTCS bool) {
 // NoticeSocksProxyPortInUse is a failure to use the configured LocalSocksProxyPort
 func NoticeSocksProxyPortInUse(port int) {
 	singletonNoticeLogger.outputNotice(
-		"SocksProxyPortInUse",
-		noticeShowUser, "port", port)
+		"SocksProxyPortInUse", 0,
+		"port", port)
 }
 
 // NoticeListeningSocksProxyPort is the selected port for the listening local SOCKS proxy
@@ -574,7 +570,7 @@ func NoticeListeningSocksProxyPort(port int) {
 // NoticeHttpProxyPortInUse is a failure to use the configured LocalHttpProxyPort
 func NoticeHttpProxyPortInUse(port int) {
 	singletonNoticeLogger.outputNotice(
-		"HttpProxyPortInUse", noticeShowUser,
+		"HttpProxyPortInUse", 0,
 		"port", port)
 }
 
@@ -651,14 +647,14 @@ func NoticeSessionId(sessionId string) {
 //
 func NoticeUntunneled(address string) {
 	singletonNoticeLogger.outputNotice(
-		"Untunneled", noticeShowUser,
+		"Untunneled", 0,
 		"address", address)
 }
 
 // NoticeSplitTunnelRegion reports that split tunnel is on for the given region.
 func NoticeSplitTunnelRegion(region string) {
 	singletonNoticeLogger.outputNotice(
-		"SplitTunnelRegion", noticeShowUser,
+		"SplitTunnelRegion", 0,
 		"region", region)
 }
 
@@ -666,7 +662,7 @@ func NoticeSplitTunnelRegion(region string) {
 // user may have input, for example, an incorrect address or incorrect credentials.
 func NoticeUpstreamProxyError(err error) {
 	singletonNoticeLogger.outputNotice(
-		"UpstreamProxyError", noticeShowUser,
+		"UpstreamProxyError", 0,
 		"message", err.Error())
 }
 
@@ -826,7 +822,7 @@ func NoticePruneServerEntry(serverEntryTag string) {
 // duration was exceeded.
 func NoticeEstablishTunnelTimeout(timeout time.Duration) {
 	singletonNoticeLogger.outputNotice(
-		"EstablishTunnelTimeout", noticeShowUser,
+		"EstablishTunnelTimeout", 0,
 		"timeout", timeout)
 }
 
@@ -848,6 +844,14 @@ func NoticeApplicationParameters(keyValues parameters.KeyValues) {
 	}
 }
 
+// NoticeServerAlert reports server alerts. Each distinct server alert is
+// reported at most once per session.
+func NoticeServerAlert(alert protocol.AlertRequest) {
+	outputRepetitiveNotice(
+		"ServerAlert", fmt.Sprintf("%+v", alert), 0,
+		"ServerAlert", noticeIsDiagnostic, "reason", alert.Reason, "subject", alert.Subject)
+}
+
 type repetitiveNoticeState struct {
 	message string
 	repeats int

+ 1 - 1
psiphon/packetTunnelTransport.go

@@ -223,7 +223,7 @@ func (p *PacketTunnelTransport) UseTunnel(tunnel *Tunnel) {
 			// Note: DialPacketTunnelChannel will signal a probe on failure,
 			// so it's not necessary to do so here.
 
-			NoticeAlert("dial packet tunnel channel failed : %s", err)
+			NoticeWarning("dial packet tunnel channel failed : %s", err)
 			// TODO: retry?
 			return
 		}

+ 11 - 11
psiphon/remoteServerList.go

@@ -109,7 +109,7 @@ func FetchCommonRemoteServerList(
 	// ETag so we won't re-download this same data again.
 	err = SetUrlETag(canonicalURL, newETag)
 	if err != nil {
-		NoticeAlert("failed to set ETag for common remote server list: %s", errors.Trace(err))
+		NoticeWarning("failed to set ETag for common remote server list: %s", errors.Trace(err))
 		// This fetch is still reported as a success, even if we can't store the etag
 	}
 
@@ -182,7 +182,7 @@ func FetchObfuscatedServerLists(
 		downloadFilename)
 	if err != nil {
 		failed = true
-		NoticeAlert("failed to download obfuscated server list registry: %s", errors.Trace(err))
+		NoticeWarning("failed to download obfuscated server list registry: %s", errors.Trace(err))
 		// Proceed with any existing cached OSL registry.
 	} else if newETag != "" {
 		updateCache = true
@@ -197,7 +197,7 @@ func FetchObfuscatedServerLists(
 		// Lookup SLOKs in local datastore
 		key, err := GetSLOK(slokID)
 		if err != nil && atomic.CompareAndSwapInt32(&emittedGetSLOKAlert, 0, 1) {
-			NoticeAlert("GetSLOK failed: %s", err)
+			NoticeWarning("GetSLOK failed: %s", err)
 		}
 		return key
 	}
@@ -230,7 +230,7 @@ func FetchObfuscatedServerLists(
 		oslFileSpec, err := registryStreamer.Next()
 		if err != nil {
 			failed = true
-			NoticeAlert("failed to stream obfuscated server list registry: %s", errors.Trace(err))
+			NoticeWarning("failed to stream obfuscated server list registry: %s", errors.Trace(err))
 			break
 		}
 
@@ -263,7 +263,7 @@ func FetchObfuscatedServerLists(
 			downloadFilename)
 		if err != nil {
 			failed = true
-			NoticeAlert("failed to download obfuscated server list file (%s): %s", hexID, errors.Trace(err))
+			NoticeWarning("failed to download obfuscated server list file (%s): %s", hexID, errors.Trace(err))
 			continue
 		}
 
@@ -275,7 +275,7 @@ func FetchObfuscatedServerLists(
 		file, err := os.Open(downloadFilename)
 		if err != nil {
 			failed = true
-			NoticeAlert("failed to open obfuscated server list file (%s): %s", hexID, errors.Trace(err))
+			NoticeWarning("failed to open obfuscated server list file (%s): %s", hexID, errors.Trace(err))
 			continue
 		}
 		// Note: don't defer file.Close() since we're in a loop
@@ -288,7 +288,7 @@ func FetchObfuscatedServerLists(
 		if err != nil {
 			file.Close()
 			failed = true
-			NoticeAlert("failed to read obfuscated server list file (%s): %s", hexID, errors.Trace(err))
+			NoticeWarning("failed to read obfuscated server list file (%s): %s", hexID, errors.Trace(err))
 			continue
 		}
 
@@ -302,7 +302,7 @@ func FetchObfuscatedServerLists(
 		if err != nil {
 			file.Close()
 			failed = true
-			NoticeAlert("failed to store obfuscated server list file (%s): %s", hexID, errors.Trace(err))
+			NoticeWarning("failed to store obfuscated server list file (%s): %s", hexID, errors.Trace(err))
 			continue
 		}
 
@@ -311,7 +311,7 @@ func FetchObfuscatedServerLists(
 		err = SetUrlETag(canonicalURL, newETag)
 		if err != nil {
 			file.Close()
-			NoticeAlert("failed to set ETag for obfuscated server list file (%s): %s", hexID, errors.Trace(err))
+			NoticeWarning("failed to set ETag for obfuscated server list file (%s): %s", hexID, errors.Trace(err))
 			continue
 			// This fetch is still reported as a success, even if we can't store the ETag
 		}
@@ -334,13 +334,13 @@ func FetchObfuscatedServerLists(
 
 		err := os.Rename(downloadFilename, cachedFilename)
 		if err != nil {
-			NoticeAlert("failed to set cached obfuscated server list registry: %s", errors.Trace(err))
+			NoticeWarning("failed to set cached obfuscated server list registry: %s", errors.Trace(err))
 			// This fetch is still reported as a success, even if we can't update the cache
 		}
 
 		err = SetUrlETag(canonicalURL, newETag)
 		if err != nil {
-			NoticeAlert("failed to set ETag for obfuscated server list registry: %s", errors.Trace(err))
+			NoticeWarning("failed to set ETag for obfuscated server list registry: %s", errors.Trace(err))
 			// This fetch is still reported as a success, even if we can't store the ETag
 		}
 	}

+ 33 - 26
psiphon/server/server_test.go

@@ -848,6 +848,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	clientConfig.LocalSocksProxyPort = localSOCKSProxyPort
 	clientConfig.LocalHttpProxyPort = localHTTPProxyPort
 	clientConfig.EmitSLOKs = true
+	clientConfig.EmitServerAlerts = true
 
 	if !runConfig.omitAuthorization {
 		clientConfig.Authorizations = []string{clientAuthorization}
@@ -926,14 +927,16 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		t.Fatalf("error creating client controller: %s", err)
 	}
 
+	connectedServer := make(chan struct{}, 1)
 	tunnelsEstablished := make(chan struct{}, 1)
 	homepageReceived := make(chan struct{}, 1)
 	slokSeeded := make(chan struct{}, 1)
-	clientConnectedNotice := make(chan map[string]interface{}, 1)
 
 	numPruneNotices := 0
 	pruneServerEntriesNoticesEmitted := make(chan struct{}, 1)
 
+	serverAlertDisallowedNoticesEmitted := make(chan struct{}, 1)
+
 	psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
 		func(notice []byte) {
 
@@ -946,6 +949,9 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 			switch noticeType {
 
+			case "ConnectedServer":
+				sendNotificationReceived(connectedServer)
+
 			case "Tunnels":
 				count := int(payload["count"].(float64))
 				if count >= numTunnels {
@@ -969,10 +975,10 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 					sendNotificationReceived(pruneServerEntriesNoticesEmitted)
 				}
 
-			case "ConnectedServer":
-				select {
-				case clientConnectedNotice <- payload:
-				default:
+			case "ServerAlert":
+				reason := payload["reason"].(string)
+				if reason == protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC {
+					sendNotificationReceived(serverAlertDisallowedNoticesEmitted)
 				}
 			}
 		}))
@@ -1022,7 +1028,8 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		close(timeoutSignal)
 	}()
 
-	waitOnNotification(t, tunnelsEstablished, timeoutSignal, "tunnel establish timeout exceeded")
+	waitOnNotification(t, connectedServer, timeoutSignal, "connected server timeout exceeded")
+	waitOnNotification(t, tunnelsEstablished, timeoutSignal, "tunnel established timeout exceeded")
 	waitOnNotification(t, homepageReceived, timeoutSignal, "homepage received timeout exceeded")
 
 	expectTrafficFailure := runConfig.denyTrafficRules || (runConfig.omitAuthorization && runConfig.requireAuthorization)
@@ -1064,17 +1071,24 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		}
 	}
 
-	// Test: await SLOK payload
+	// Test: await SLOK payload or server alert notice
+
+	time.Sleep(1 * time.Second)
 
 	if !expectTrafficFailure {
 
-		time.Sleep(1 * time.Second)
 		waitOnNotification(t, slokSeeded, timeoutSignal, "SLOK seeded timeout exceeded")
 
 		numSLOKs := psiphon.CountSLOKs()
 		if numSLOKs != expectedNumSLOKs {
 			t.Fatalf("unexpected number of SLOKs: %d", numSLOKs)
 		}
+
+	} else {
+
+		// Note: in expectTrafficFailure case, timeoutSignal may have already fired.
+
+		waitOnNotification(t, serverAlertDisallowedNoticesEmitted, nil, "")
 	}
 
 	// Test: await expected prune server entry notices
@@ -1082,12 +1096,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	// Note: will take up to PsiphonAPIStatusRequestShortPeriodMax to emit.
 
 	if expectedNumPruneNotices > 0 {
-
-		waitOnNotification(
-			t,
-			pruneServerEntriesNoticesEmitted,
-			timeoutSignal,
-			"prune server entries timeout exceeded")
+		waitOnNotification(t, pruneServerEntriesNoticesEmitted, nil, "")
 	}
 
 	if runConfig.doDanglingTCPConn {
@@ -1110,19 +1119,13 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	stopServer()
 	stopServer = nil
 
+	// Test: all expected server logs were emitted
+
 	// TODO: stops should be fully synchronous, but, intermittently,
 	// server_tunnel fails to appear ("missing server tunnel log")
 	// without this delay.
 	time.Sleep(100 * time.Millisecond)
 
-	// Test: all expected logs/notices were emitted
-
-	select {
-	case <-clientConnectedNotice:
-	default:
-		t.Fatalf("missing client connected notice")
-	}
-
 	select {
 	case logFields := <-serverConnectedLog:
 		err := checkExpectedLogFields(runConfig, false, false, logFields)
@@ -1926,10 +1929,14 @@ func sendNotificationReceived(c chan<- struct{}) {
 }
 
 func waitOnNotification(t *testing.T, c, timeoutSignal <-chan struct{}, timeoutMessage string) {
-	select {
-	case <-c:
-	case <-timeoutSignal:
-		t.Fatalf(timeoutMessage)
+	if timeoutSignal == nil {
+		<-c
+	} else {
+		select {
+		case <-c:
+		case <-timeoutSignal:
+			t.Fatalf(timeoutMessage)
+		}
 	}
 }
 

+ 109 - 13
psiphon/server/tunnelServer.go

@@ -70,6 +70,7 @@ const (
 	MAX_AUTHORIZATIONS                    = 16
 	PRE_HANDSHAKE_RANDOM_STREAM_MAX_COUNT = 1
 	RANDOM_STREAM_MAX_BYTES               = 10485760
+	ALERT_REQUEST_QUEUE_BUFFER_SIZE       = 16
 )
 
 // TunnelServer is the main server that accepts Psiphon client
@@ -1169,6 +1170,8 @@ type sshClient struct {
 	stopTimer                            *time.Timer
 	preHandshakeRandomStreamMetrics      randomStreamMetrics
 	postHandshakeRandomStreamMetrics     randomStreamMetrics
+	sendAlertRequests                    chan protocol.AlertRequest
+	sentAlertRequests                    map[protocol.AlertRequest]bool
 }
 
 type trafficState struct {
@@ -1243,6 +1246,8 @@ func newSshClient(
 		runCtx:                 runCtx,
 		stopRunning:            stopRunning,
 		stopped:                make(chan struct{}),
+		sendAlertRequests:      make(chan protocol.AlertRequest, ALERT_REQUEST_QUEUE_BUFFER_SIZE),
+		sentAlertRequests:      make(map[protocol.AlertRequest]bool),
 	}
 
 	client.tcpTrafficState.availablePortForwardCond = sync.NewCond(new(sync.Mutex))
@@ -1653,14 +1658,21 @@ func (sshClient *sshClient) runTunnel(
 		sshClient.handleSSHRequests(requests)
 	}()
 
-	// Start OSL sender
+	// Start request senders
 
 	if sshClient.supportsServerRequests {
+
 		waitGroup.Add(1)
 		go func() {
 			defer waitGroup.Done()
 			sshClient.runOSLSender()
 		}()
+
+		waitGroup.Add(1)
+		go func() {
+			defer waitGroup.Done()
+			sshClient.runAlertSender()
+		}()
 	}
 
 	// Start the TCP port forward manager
@@ -2393,6 +2405,70 @@ func (sshClient *sshClient) sendOSLRequest() error {
 	return nil
 }
 
+// runAlertSender dequeues and sends alert requests to the client. As these
+// alerts are informational, there is no retry logic and no SSH client
+// acknowledgement (wantReply) is requested. This worker scheme allows
+// nonconcurrent components including udpgw and packet tunnel to enqueue
+// alerts without blocking their traffic processing.
+func (sshClient *sshClient) runAlertSender() {
+	for {
+		select {
+		case <-sshClient.runCtx.Done():
+			return
+
+		case request := <-sshClient.sendAlertRequests:
+			payload, err := json.Marshal(request)
+			if err != nil {
+				log.WithTraceFields(LogFields{"error": err}).Warning("Marshal failed")
+				break
+			}
+			_, _, err = sshClient.sshConn.SendRequest(
+				protocol.PSIPHON_API_ALERT_REQUEST_NAME,
+				false,
+				payload)
+			if err != nil && !isExpectedTunnelIOError(err) {
+				log.WithTraceFields(LogFields{"error": err}).Warning("SendRequest failed")
+				break
+			}
+			sshClient.Lock()
+			sshClient.sentAlertRequests[request] = true
+			sshClient.Unlock()
+		}
+	}
+}
+
+// enqueueAlertRequest enqueues an alert request to be sent to the client.
+// Only one request is sent per tunnel per protocol.AlertRequest value;
+// subsequent alerts with the same value are dropped. enqueueAlertRequest will
+// not block until the queue exceeds ALERT_REQUEST_QUEUE_BUFFER_SIZE.
+func (sshClient *sshClient) enqueueAlertRequest(request protocol.AlertRequest) {
+	sshClient.Lock()
+	if sshClient.sentAlertRequests[request] {
+		sshClient.Unlock()
+		return
+	}
+	sshClient.Unlock()
+	select {
+	case <-sshClient.runCtx.Done():
+	case sshClient.sendAlertRequests <- request:
+	}
+}
+
+func (sshClient *sshClient) enqueueDisallowedTrafficAlertRequest() {
+	sshClient.enqueueAlertRequest(protocol.AlertRequest{
+		Reason: protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC,
+	})
+}
+
+func (sshClient *sshClient) enqueueUnsafeTrafficAlertRequest(tags []BlocklistTag) {
+	for _, tag := range tags {
+		sshClient.enqueueAlertRequest(protocol.AlertRequest{
+			Reason:  protocol.PSIPHON_API_ALERT_UNSAFE_TRAFFIC,
+			Subject: tag.Subject,
+		})
+	}
+}
+
 func (sshClient *sshClient) rejectNewChannel(newChannel ssh.NewChannel, logMessage string) {
 
 	// We always return the reject reason "Prohibited":
@@ -2776,34 +2852,50 @@ func (sshClient *sshClient) isPortForwardPermitted(
 
 	tags := sshClient.sshServer.support.Blocklist.LookupIP(remoteIP)
 	if len(tags) > 0 {
+
 		sshClient.logBlocklistHits(remoteIP, "", tags)
+
 		if sshClient.sshServer.support.Config.BlocklistActive {
+			// Actively alert and block
+			sshClient.enqueueUnsafeTrafficAlertRequest(tags)
 			return false
 		}
 	}
 
 	// Don't lock before calling logBlocklistHits.
+	// Unlock before calling enqueueDisallowedTrafficAlertRequest/log.
+
 	sshClient.Lock()
-	defer sshClient.Unlock()
+
+	allowed := true
 
 	// Client must complete handshake before port forwards are permitted.
 	if !sshClient.handshakeState.completed {
-		return false
+		allowed = false
 	}
 
-	// Traffic rules checks.
-
-	switch portForwardType {
-	case portForwardTypeTCP:
-		if sshClient.trafficRules.AllowTCPPort(remoteIP, port) {
-			return true
-		}
-	case portForwardTypeUDP:
-		if sshClient.trafficRules.AllowUDPPort(remoteIP, port) {
-			return true
+	if allowed {
+		// Traffic rules checks.
+		switch portForwardType {
+		case portForwardTypeTCP:
+			if !sshClient.trafficRules.AllowTCPPort(remoteIP, port) {
+				allowed = false
+			}
+		case portForwardTypeUDP:
+			if !sshClient.trafficRules.AllowUDPPort(remoteIP, port) {
+				allowed = false
+			}
 		}
 	}
 
+	sshClient.Unlock()
+
+	if allowed {
+		return true
+	}
+
+	sshClient.enqueueDisallowedTrafficAlertRequest()
+
 	log.WithTraceFields(
 		LogFields{
 			"type": portForwardType,
@@ -3102,9 +3194,13 @@ func (sshClient *sshClient) handleTCPChannel(
 
 		tags := sshClient.sshServer.support.Blocklist.LookupDomain(hostToConnect)
 		if len(tags) > 0 {
+
 			sshClient.logBlocklistHits(nil, hostToConnect, tags)
+
 			if sshClient.sshServer.support.Config.BlocklistActive {
+				// Actively alert and block
 				// Note: not recording a port forward failure in this case
+				sshClient.enqueueUnsafeTrafficAlertRequest(tags)
 				sshClient.rejectNewChannel(newChannel, "port forward not permitted")
 				return
 			}

+ 24 - 6
psiphon/serverApi.go

@@ -241,7 +241,7 @@ func (serverContext *ServerContext) doHandshakeRequest(
 		err = protocol.ValidateServerEntryFields(serverEntryFields)
 		if err != nil {
 			// Skip this entry and continue with the next one
-			NoticeAlert("invalid handshake server entry: %s", err)
+			NoticeWarning("invalid handshake server entry: %s", err)
 			continue
 		}
 
@@ -273,7 +273,7 @@ func (serverContext *ServerContext) doHandshakeRequest(
 			handshakeResponse.HttpsRequestRegexes)
 
 		for _, notice := range regexpsNotices {
-			NoticeAlert(notice)
+			NoticeWarning(notice)
 		}
 	}
 
@@ -509,7 +509,7 @@ func makeStatusRequestPayload(
 
 	persistentStats, err := TakeOutUnreportedPersistentStats(config)
 	if err != nil {
-		NoticeAlert(
+		NoticeWarning(
 			"TakeOutUnreportedPersistentStats failed: %s", errors.Trace(err))
 		persistentStats = nil
 		// Proceed with transferStats only
@@ -565,7 +565,7 @@ func putBackStatusRequestPayload(payloadInfo *statusRequestPayloadInfo) {
 	if err != nil {
 		// These persistent stats records won't be resent until after a
 		// datastore re-initialization.
-		NoticeAlert(
+		NoticeWarning(
 			"PutBackUnreportedPersistentStats failed: %s", errors.Trace(err))
 	}
 }
@@ -574,7 +574,7 @@ func confirmStatusRequestPayload(payloadInfo *statusRequestPayloadInfo) {
 	err := ClearReportedPersistentStats(payloadInfo.persistentStats)
 	if err != nil {
 		// These persistent stats records may be resent.
-		NoticeAlert(
+		NoticeWarning(
 			"ClearReportedPersistentStats failed: %s", errors.Trace(err))
 	}
 }
@@ -1009,6 +1009,8 @@ func HandleServerRequest(
 	switch name {
 	case protocol.PSIPHON_API_OSL_REQUEST_NAME:
 		return HandleOSLRequest(tunnelOwner, tunnel, payload)
+	case protocol.PSIPHON_API_ALERT_REQUEST_NAME:
+		return HandleAlertRequest(tunnelOwner, tunnel, payload)
 	}
 
 	return errors.Tracef("invalid request name: %s", name)
@@ -1033,7 +1035,7 @@ func HandleOSLRequest(
 		duplicate, err := SetSLOK(slok.ID, slok.Key)
 		if err != nil {
 			// TODO: return error to trigger retry?
-			NoticeAlert("SetSLOK failed: %s", errors.Trace(err))
+			NoticeWarning("SetSLOK failed: %s", errors.Trace(err))
 		} else if !duplicate {
 			seededNewSLOK = true
 		}
@@ -1049,3 +1051,19 @@ func HandleOSLRequest(
 
 	return nil
 }
+
+func HandleAlertRequest(
+	tunnelOwner TunnelOwner, tunnel *Tunnel, payload []byte) error {
+
+	var alertRequest protocol.AlertRequest
+	err := json.Unmarshal(payload, &alertRequest)
+	if err != nil {
+		return errors.Trace(err)
+	}
+
+	if tunnel.config.EmitServerAlerts {
+		NoticeServerAlert(alertRequest)
+	}
+
+	return nil
+}

+ 1 - 1
psiphon/socksProxy.go

@@ -134,7 +134,7 @@ loop:
 		default:
 		}
 		if err != nil {
-			NoticeAlert("SOCKS proxy accept error: %s", err)
+			NoticeWarning("SOCKS proxy accept error: %s", err)
 			if e, ok := err.(net.Error); ok && e.Temporary() {
 				// Temporary error, keep running
 				continue

+ 9 - 9
psiphon/splitTunnel.go

@@ -175,7 +175,7 @@ func (classifier *SplitTunnelClassifier) IsUntunneled(targetAddress string) bool
 	ipAddr, ttl, err := tunneledLookupIP(
 		dnsServerAddress, classifier.dnsTunneler, targetAddress)
 	if err != nil {
-		NoticeAlert("failed to resolve address for split tunnel classification: %s", err)
+		NoticeWarning("failed to resolve address for split tunnel classification: %s", err)
 		return false
 	}
 	expiry := time.Now().Add(ttl)
@@ -206,13 +206,13 @@ func (classifier *SplitTunnelClassifier) setRoutes(tunnel *Tunnel) {
 
 	routesData, err := classifier.getRoutes(tunnel)
 	if err != nil {
-		NoticeAlert("failed to get split tunnel routes: %s", err)
+		NoticeWarning("failed to get split tunnel routes: %s", err)
 		return
 	}
 
 	err = classifier.installRoutes(routesData)
 	if err != nil {
-		NoticeAlert("failed to install split tunnel routes: %s", err)
+		NoticeWarning("failed to install split tunnel routes: %s", err)
 		return
 	}
 
@@ -272,7 +272,7 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 		err = fmt.Errorf("unexpected response status code: %d", response.StatusCode)
 	}
 	if err != nil {
-		NoticeAlert("failed to request split tunnel routes package: %s", errors.Trace(err))
+		NoticeWarning("failed to request split tunnel routes package: %s", errors.Trace(err))
 		useCachedRoutes = true
 	}
 
@@ -287,7 +287,7 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 	if !useCachedRoutes {
 		routesDataPackage, err = ioutil.ReadAll(response.Body)
 		if err != nil {
-			NoticeAlert("failed to download split tunnel routes package: %s", errors.Trace(err))
+			NoticeWarning("failed to download split tunnel routes package: %s", errors.Trace(err))
 			useCachedRoutes = true
 		}
 	}
@@ -297,7 +297,7 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 		encodedRoutesData, err = common.ReadAuthenticatedDataPackage(
 			routesDataPackage, false, routesSignaturePublicKey)
 		if err != nil {
-			NoticeAlert("failed to read split tunnel routes package: %s", errors.Trace(err))
+			NoticeWarning("failed to read split tunnel routes package: %s", errors.Trace(err))
 			useCachedRoutes = true
 		}
 	}
@@ -306,7 +306,7 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 	if !useCachedRoutes {
 		compressedRoutesData, err = base64.StdEncoding.DecodeString(encodedRoutesData)
 		if err != nil {
-			NoticeAlert("failed to decode split tunnel routes: %s", errors.Trace(err))
+			NoticeWarning("failed to decode split tunnel routes: %s", errors.Trace(err))
 			useCachedRoutes = true
 		}
 	}
@@ -318,7 +318,7 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 			zlibReader.Close()
 		}
 		if err != nil {
-			NoticeAlert("failed to decompress split tunnel routes: %s", errors.Trace(err))
+			NoticeWarning("failed to decompress split tunnel routes: %s", errors.Trace(err))
 			useCachedRoutes = true
 		}
 	}
@@ -328,7 +328,7 @@ func (classifier *SplitTunnelClassifier) getRoutes(tunnel *Tunnel) (routesData [
 		if etag != "" {
 			err := SetSplitTunnelRoutes(tunnel.serverContext.clientRegion, etag, routesData)
 			if err != nil {
-				NoticeAlert("failed to cache split tunnel routes: %s", errors.Trace(err))
+				NoticeWarning("failed to cache split tunnel routes: %s", errors.Trace(err))
 				// Proceed with fetched data, even when we can't cache it
 			}
 		}

+ 7 - 7
psiphon/tunnel.go

@@ -320,7 +320,7 @@ func (tunnel *Tunnel) Close(isDiscarded bool) {
 
 		err := tunnel.sshClient.Wait()
 		if err != nil {
-			NoticeAlert("close tunnel ssh error: %s", err)
+			NoticeWarning("close tunnel ssh error: %s", err)
 		}
 	}
 }
@@ -466,7 +466,7 @@ func (tunnel *Tunnel) wrapWithTransferStats(conn net.Conn) net.Conn {
 // SignalComponentFailure notifies the tunnel that an associated component has failed.
 // This will terminate the tunnel.
 func (tunnel *Tunnel) SignalComponentFailure() {
-	NoticeAlert("tunnel received component failure signal")
+	NoticeWarning("tunnel received component failure signal")
 	tunnel.Close(false)
 }
 
@@ -671,7 +671,7 @@ func dialTunnel(
 
 	// If dialConn is not a Closer, tunnel failure detection may be slower
 	if _, ok := dialConn.(common.Closer); !ok {
-		NoticeAlert("tunnel.dialTunnel: dialConn is not a Closer")
+		NoticeWarning("tunnel.dialTunnel: dialConn is not a Closer")
 	}
 
 	cleanupConn := dialConn
@@ -1213,7 +1213,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 				if err == nil {
 					serverRequest.Reply(true, nil)
 				} else {
-					NoticeAlert("HandleServerRequest for %s failed: %s", serverRequest.Type, err)
+					NoticeWarning("HandleServerRequest for %s failed: %s", serverRequest.Type, err)
 					serverRequest.Reply(false, nil)
 
 				}
@@ -1250,7 +1250,7 @@ func (tunnel *Tunnel) operateTunnel(tunnelOwner TunnelOwner) {
 		sendStats(tunnel)
 
 	} else {
-		NoticeAlert("operate tunnel error for %s: %s",
+		NoticeWarning("operate tunnel error for %s: %s",
 			tunnel.dialParams.ServerEntry.GetDiagnosticID(), err)
 		tunnelOwner.SignalTunnelFailure(tunnel)
 	}
@@ -1317,7 +1317,7 @@ func (tunnel *Tunnel) sendSshKeepAlive(isFirstPeriodicKeepAlive bool, timeout ti
 				request,
 				response)
 			if err != nil {
-				NoticeAlert("AddSpeedTestSample failed: %s", errors.Trace(err))
+				NoticeWarning("AddSpeedTestSample failed: %s", errors.Trace(err))
 			}
 		}
 	}()
@@ -1346,7 +1346,7 @@ func sendStats(tunnel *Tunnel) bool {
 
 	err := tunnel.serverContext.DoStatusRequest(tunnel)
 	if err != nil {
-		NoticeAlert("DoStatusRequest failed for %s: %s",
+		NoticeWarning("DoStatusRequest failed for %s: %s",
 			tunnel.dialParams.ServerEntry.GetDiagnosticID(), err)
 	}
 

+ 1 - 1
psiphon/upgradeDownload.go

@@ -136,7 +136,7 @@ func DownloadUpgrade(
 			// return an error so that we don't go into a rapid retry loop making
 			// ineffective HEAD requests (the client may still signal an upgrade
 			// download later in the session).
-			NoticeAlert(
+			NoticeWarning(
 				"failed to download upgrade: invalid %s header value %s: %s",
 				clientVersionHeader, availableClientVersion, err)
 			return nil