ソースを参照

Add more options to write notices to files

- Homepage notices can be written to a specified file.
  The file is automatically truncated when a new set
  of homepages is stored.

- All notices can be written to a rotating file with a
  maximum size.

- Removed "LogFilename" config param  and replaces with
  a ConsoleClient command line option.
Rod Hynes 8 年 前
コミット
a569523b13

+ 37 - 19
ConsoleClient/main.go

@@ -84,6 +84,21 @@ func main() {
 		flag.StringVar(&tunSecondaryDNS, "tunSecondaryDNS", "8.8.4.4", "secondary DNS resolver for bypass")
 	}
 
+	var noticeFilename string
+	flag.StringVar(&noticeFilename, "notices", "", "notices output file (defaults to stderr)")
+
+	var homepageFilename string
+	flag.StringVar(&homepageFilename, "homepages", "", "homepages notices output file")
+
+	var rotatingFilename string
+	flag.StringVar(&rotatingFilename, "rotating", "", "rotating notices output file")
+
+	var rotatingFileSize int
+	flag.IntVar(&rotatingFileSize, "rotatingFileSize", 1<<20, "rotating notices file size")
+
+	var rotatingSyncFrequency int
+	flag.IntVar(&rotatingSyncFrequency, "rotatingSyncFrequency", 100, "rotating notices file sync frequency")
+
 	flag.Parse()
 
 	if versionDetails {
@@ -117,14 +132,34 @@ func main() {
 		os.Exit(0)
 	}
 
-	// Initialize default Notice output (stderr)
+	// Initialize notice output
 
 	var noticeWriter io.Writer
 	noticeWriter = os.Stderr
+
+	if noticeFilename != "" {
+		noticeFile, err := os.OpenFile(noticeFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+		if err != nil {
+			fmt.Printf("error opening notice file: %s\n", err)
+			os.Exit(1)
+		}
+		defer noticeFile.Close()
+		noticeWriter = noticeFile
+	}
+
 	if formatNotices {
 		noticeWriter = psiphon.NewNoticeConsoleRewriter(noticeWriter)
 	}
-	psiphon.SetNoticeOutput(noticeWriter)
+	err := psiphon.SetNoticeOutput(
+		noticeWriter,
+		homepageFilename,
+		rotatingFilename,
+		rotatingFileSize,
+		rotatingSyncFrequency)
+	if err != nil {
+		fmt.Printf("error initializing notice output: %s\n", err)
+		os.Exit(1)
+	}
 
 	psiphon.NoticeBuildInfo()
 
@@ -148,23 +183,6 @@ func main() {
 		os.Exit(1)
 	}
 
-	// When a logfile is configured, reinitialize Notice output
-
-	if config.LogFilename != "" {
-		logFile, err := os.OpenFile(config.LogFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
-		if err != nil {
-			psiphon.NoticeError("error opening log file: %s", err)
-			os.Exit(1)
-		}
-		defer logFile.Close()
-		var noticeWriter io.Writer
-		noticeWriter = logFile
-		if formatNotices {
-			noticeWriter = psiphon.NewNoticeConsoleRewriter(noticeWriter)
-		}
-		psiphon.SetNoticeOutput(noticeWriter)
-	}
-
 	// Handle optional profiling parameter
 
 	if profileFilename != "" {

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

@@ -401,10 +401,16 @@ public class PsiphonTunnel extends Psi.PsiphonProvider.Stub {
             Psi.Start(
                     loadPsiphonConfig(mHostService.getContext(), fd),
                     embeddedServerEntries,
-                    "",
+
+                    "",          // Not using embedded server entry file
+                    "",          // ... or homepage notice file
+                    "",          // ... or rotating notice file
+                    0,
+                    0,
+
                     this,
                     isVpnMode(),
-                    false // Do not use IPv6 synthesizer for android
+                    false        // Do not use IPv6 synthesizer for android
                     );
         } catch (java.lang.Exception e) {
             throw new Exception("failed to start Psiphon library", e);

+ 13 - 2
MobileLibrary/iOS/PsiphonTunnel/PsiphonTunnel/PsiphonTunnel.h

@@ -124,7 +124,6 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
  */
 - (NSString * _Nullable)getEmbeddedServerEntries;
 
-
 //
 // Optional delegate methods. Note that some of these are probably necessary for
 // for a functioning app to implement, for example `onConnected`.
@@ -134,10 +133,22 @@ typedef NS_ENUM(NSInteger, PsiphonConnectionState)
 /*!
   Called when the tunnel is starting to get the initial server entries (typically embedded in the app) that will be used to bootstrap the Psiphon tunnel connection. This value is in a particular format and will be supplied by Psiphon Inc.
   If this method is implemented, it takes precedence over getEmbeddedServerEntries, and getEmbeddedServerEntries will not be called unless this method returns NULL or an empty string.
-  @return Optional path where embedded server entries file is located. This file should be accessible by the Network Extension.
+  @return Optional path where embedded server entries file is located. This file should be readable by the library.
  */
 - (NSString * _Nullable)getEmbeddedServerEntriesPath;
 
+/*!
+  Called when the tunnel is starting. If this method is implemented, it should return the path where a homepage
+  notices file is to be written. This path should be writable by the library.
+ */
+- (NSString * _Nullable)getHomepageNoticesPath;
+
+/*!
+  Called when the tunnel is starting. If this method is implemented, it should return the path where a rotating
+  notice file set is to be written. path file should be writable by the library.
+ */
+- (NSString * _Nullable)getRotatingNoticesPath;
+
 /*!
  Gets runtime errors info that may be useful for debugging.
  @param message  The diagnostic message string.

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

@@ -196,6 +196,26 @@
             }
         }
 
+        __block NSString *homepageNoticesPath = @"";
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(getHomepageNoticesPath)]) {
+            dispatch_sync(self->callbackQueue, ^{
+                homepageNoticesPath = [self.tunneledAppDelegate getHomepageNoticesPath];
+                if (homepageNoticesPath == nil) {
+                    homepageNoticesPath = @"";
+                }
+            });
+        }
+
+        __block NSString *rotatingNoticesPath = @"";
+        if ([self.tunneledAppDelegate respondsToSelector:@selector(getRotatingNoticesPath)]) {
+            dispatch_sync(self->callbackQueue, ^{
+                rotatingNoticesPath = [self.tunneledAppDelegate getRotatingNoticesPath];
+                if (rotatingNoticesPath == nil) {
+                    rotatingNoticesPath = @"";
+                }
+            });
+        }
+
         [self changeConnectionStateTo:PsiphonConnectionStateConnecting evenIfSameState:NO];
 
         @try {
@@ -205,6 +225,10 @@
                            configStr,
                            embeddedServerEntries,
                            embeddedServerEntriesPath,
+                           homepageNoticesPath,
+                           rotatingNoticesPath,
+                           0, // Use default rotating settings
+                           0, // ...
                            self,
                            self->tunnelWholeDevice, // useDeviceBinder
                            useIPv6Synthesizer,

+ 31 - 19
MobileLibrary/psi/psi.go

@@ -51,10 +51,16 @@ var shutdownBroadcast chan struct{}
 var controllerWaitGroup *sync.WaitGroup
 
 func Start(
-	configJson, embeddedServerEntryList,
-	embeddedServerEntryListPath string,
+	configJson,
+	embeddedServerEntryList,
+	embeddedServerEntryListFilename,
+	homepageFilename,
+	rotatingFilename string,
+	rotatingFileSize int,
+	rotatingSyncFrequency int,
 	provider PsiphonProvider,
-	useDeviceBinder bool, useIPv6Synthesizer bool) error {
+	useDeviceBinder,
+	useIPv6Synthesizer bool) error {
 
 	controllerMutex.Lock()
 	defer controllerMutex.Unlock()
@@ -86,10 +92,17 @@ func Start(
 		config.IPv6Synthesizer = provider
 	}
 
-	psiphon.SetNoticeOutput(psiphon.NewNoticeReceiver(
+	err = psiphon.SetNoticeOutput(psiphon.NewNoticeReceiver(
 		func(notice []byte) {
 			provider.Notice(string(notice))
-		}))
+		}),
+		homepageFilename,
+		rotatingFilename,
+		rotatingFileSize,
+		rotatingSyncFrequency)
+	if err != nil {
+		return fmt.Errorf("error initializing notice output: %s\n", err)
+	}
 
 	psiphon.NoticeBuildInfo()
 
@@ -101,7 +114,7 @@ func Start(
 	}
 
 	// Stores list of server entries.
-	err = storeServerEntries(embeddedServerEntryListPath, embeddedServerEntryList)
+	err = storeServerEntries(embeddedServerEntryListFilename, embeddedServerEntryList)
 	if err != nil {
 		return err
 	}
@@ -154,9 +167,9 @@ func SetClientVerificationPayload(clientVerificationPayload string) {
 func SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer, uploadPath, uploadServerHeaders string) {
 	err := psiphon.SendFeedback(configJson, diagnosticsJson, b64EncodedPublicKey, uploadServer, uploadPath, uploadServerHeaders)
 	if err != nil {
-		psiphon.NoticeAlert("Failed to upload feedback: %s", err)
+		psiphon.NoticeAlert("error uploading feedback: %s", err)
 	} else {
-		psiphon.NoticeInfo("Feedback uploaded successfully")
+		psiphon.NoticeInfo("feedback uploaded successfully")
 	}
 }
 
@@ -182,26 +195,25 @@ func GetPacketTunnelDNSResolverIPv6Address() string {
 }
 
 // Helper function to store a list of server entries.
-// if embeddedServerEntryListPath is not empty, embeddedServerEntryList will be ignored.
-func storeServerEntries(embeddedServerEntryListPath, embeddedServerEntryList string) error {
+// if embeddedServerEntryListFilename is not empty, embeddedServerEntryList will be ignored.
+func storeServerEntries(embeddedServerEntryListFilename, embeddedServerEntryList string) error {
 
-	// if embeddedServerEntryListPath is not empty, ignore embeddedServerEntryList.
-	if embeddedServerEntryListPath != "" {
+	if embeddedServerEntryListFilename != "" {
 
-		serverEntriesFile, err := os.Open(embeddedServerEntryListPath)
+		file, err := os.Open(embeddedServerEntryListFilename)
 		if err != nil {
-			return fmt.Errorf("failed to read remote server list: %s", common.ContextError(err))
+			return fmt.Errorf("error reading embedded server list file: %s", common.ContextError(err))
 		}
-		defer serverEntriesFile.Close()
+		defer file.Close()
 
 		err = psiphon.StreamingStoreServerEntries(
 			protocol.NewStreamingServerEntryDecoder(
-				serverEntriesFile,
+				file,
 				common.GetCurrentTimestamp(),
 				protocol.SERVER_ENTRY_SOURCE_EMBEDDED),
 			false)
 		if err != nil {
-			return fmt.Errorf("failed to store common remote server list: %s", common.ContextError(err))
+			return fmt.Errorf("error storing embedded server list: %s", common.ContextError(err))
 		}
 
 	} else {
@@ -211,11 +223,11 @@ func storeServerEntries(embeddedServerEntryListPath, embeddedServerEntryList str
 			common.GetCurrentTimestamp(),
 			protocol.SERVER_ENTRY_SOURCE_EMBEDDED)
 		if err != nil {
-			return fmt.Errorf("error decoding embedded server entry list: %s", err)
+			return fmt.Errorf("error decoding embedded server list: %s", err)
 		}
 		err = psiphon.StoreServerEntries(serverEntries, false)
 		if err != nil {
-			return fmt.Errorf("error storing embedded server entry list: %s", err)
+			return fmt.Errorf("error storing embedded server list: %s", err)
 		}
 	}
 

+ 0 - 3
psiphon/config.go

@@ -91,9 +91,6 @@ const (
 // Config is the Psiphon configuration specified by the application. This
 // configuration controls the behavior of the core tunnel functionality.
 type Config struct {
-	// LogFilename specifies a file to receive event notices (JSON format)
-	// By default, notices are emitted to stdout.
-	LogFilename string
 
 	// DataStoreDirectory is the directory in which to store the persistent
 	// database, which contains information such as server entries.

+ 373 - 87
psiphon/notice.go

@@ -22,10 +22,8 @@ package psiphon
 import (
 	"bytes"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
-	"log"
 	"os"
 	"sort"
 	"strings"
@@ -36,9 +34,24 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
-var noticeLoggerMutex sync.Mutex
-var noticeLogger = log.New(os.Stderr, "", 0)
-var noticeLogDiagnostics = int32(0)
+type noticeLogger struct {
+	logDiagnostics             int32
+	mutex                      sync.Mutex
+	writer                     io.Writer
+	homepageFilename           string
+	homepageFile               *os.File
+	rotatingFilename           string
+	rotatingOlderFilename      string
+	rotatingFile               *os.File
+	rotatingFileSize           int64
+	rotatingCurrentFileSize    int64
+	rotatingSyncFrequency      int
+	rotatingCurrentNoticeCount int
+}
+
+var singletonNoticeLogger = noticeLogger{
+	writer: os.Stderr,
+}
 
 // SetEmitDiagnosticNotices toggles whether diagnostic notices
 // are emitted. Diagnostic notices contain potentially sensitive
@@ -47,20 +60,38 @@ var noticeLogDiagnostics = int32(0)
 // notices in log files which users could post to public forums).
 func SetEmitDiagnosticNotices(enable bool) {
 	if enable {
-		atomic.StoreInt32(&noticeLogDiagnostics, 1)
+		atomic.StoreInt32(&singletonNoticeLogger.logDiagnostics, 1)
 	} else {
-		atomic.StoreInt32(&noticeLogDiagnostics, 0)
+		atomic.StoreInt32(&singletonNoticeLogger.logDiagnostics, 0)
 	}
 }
 
 // GetEmitDiagnoticNotices returns the current state
 // of emitting diagnostic notices.
 func GetEmitDiagnoticNotices() bool {
-	return atomic.LoadInt32(&noticeLogDiagnostics) == 1
+	return atomic.LoadInt32(&singletonNoticeLogger.logDiagnostics) == 1
 }
 
 // SetNoticeOutput sets a target writer to receive notices. By default,
-// notices are written to stderr.
+// notices are written to stderr. Notices are newline delimited.
+//
+// - writer specifies an alternate io.Writer where notices are to be written.
+//
+// - When homepageFilename is not "", homepages are written to the specified file
+//   and omitted from the writer. The file may be read after the Tunnels notice
+//   with count of 1. The file should be opened read-only for reading.
+//
+// - When rotatingFilename is not "", all notices are are written to the specified
+//   file. Diagnostic notices are omitted from the writer. The file is rotated
+//   when its size exceeds rotatingFileSize. One rotated older file,
+//   <rotatingFilename>.1, is retained. The files may be read at any time; and
+//   should be opened read-only for reading. rotatingSyncFrequency specifies how
+//   many notices are written before syncing the file.
+//   If either rotatingFileSize or rotatingSyncFrequency are <= 0, default values
+//   are used.
+//
+// - If an error occurs when writing to a file, an Alert notice is emitted to
+//   the writer.
 //
 // Notices are encoded in JSON. Here's an example:
 //
@@ -73,25 +104,75 @@ func GetEmitDiagnoticNotices() bool {
 // - "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, RFC3339Nano format timestamp for notice event
+// - "timestamp": UTC timezone, RFC3339Milli format timestamp for notice event
 //
 // See the Notice* functions for details on each notice meaning and payload.
 //
-func SetNoticeOutput(output io.Writer) {
-	noticeLoggerMutex.Lock()
-	defer noticeLoggerMutex.Unlock()
-	noticeLogger = log.New(output, "", 0)
+func SetNoticeOutput(
+	writer io.Writer,
+	homepageFilename string,
+	rotatingFilename string,
+	rotatingFileSize int,
+	rotatingSyncFrequency int) error {
+
+	singletonNoticeLogger.mutex.Lock()
+	defer singletonNoticeLogger.mutex.Unlock()
+
+	singletonNoticeLogger.writer = writer
+
+	if homepageFilename != "" {
+		var err error
+		singletonNoticeLogger.homepageFile, err = os.OpenFile(
+			homepageFilename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	if rotatingFilename != "" {
+		var err error
+		singletonNoticeLogger.rotatingFile, err = os.OpenFile(
+			rotatingFilename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		fileInfo, err := singletonNoticeLogger.rotatingFile.Stat()
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		if rotatingFileSize <= 0 {
+			rotatingFileSize = 1 << 20
+		}
+
+		if rotatingSyncFrequency <= 0 {
+			rotatingSyncFrequency = 100
+		}
+
+		singletonNoticeLogger.rotatingFilename = rotatingFilename
+		singletonNoticeLogger.rotatingOlderFilename = rotatingFilename + ".1"
+		singletonNoticeLogger.rotatingFileSize = int64(rotatingFileSize)
+		singletonNoticeLogger.rotatingCurrentFileSize = fileInfo.Size()
+		singletonNoticeLogger.rotatingSyncFrequency = rotatingSyncFrequency
+		singletonNoticeLogger.rotatingCurrentNoticeCount = 0
+	}
+
+	return nil
 }
 
 const (
-	noticeIsDiagnostic = 1
-	noticeShowUser     = 2
+	noticeShowUser       = 1
+	noticeIsDiagnostic   = 2
+	noticeIsHomepage     = 4
+	noticeClearHomepages = 8
+	noticeSyncHomepages  = 16
 )
 
 // outputNotice encodes a notice in JSON and writes it to the output writer.
-func outputNotice(noticeType string, noticeFlags uint32, args ...interface{}) {
+func (nl *noticeLogger) outputNotice(noticeType string, noticeFlags uint32, args ...interface{}) {
 
-	if (noticeFlags&noticeIsDiagnostic != 0) && !GetEmitDiagnoticNotices() {
+	if (noticeFlags&noticeIsDiagnostic != 0) && atomic.LoadInt32(&nl.logDiagnostics) != 1 {
 		return
 	}
 
@@ -109,52 +190,171 @@ func outputNotice(noticeType string, noticeFlags uint32, args ...interface{}) {
 		}
 	}
 	encodedJson, err := json.Marshal(obj)
-	var output string
+	var output []byte
 	if err == nil {
-		output = string(encodedJson)
+		output = append(encodedJson, byte('\n'))
+
 	} else {
 		// Try to emit a properly formatted Alert notice that the outer client can
 		// report. One scenario where this is useful is if the preceding Marshal
 		// fails due to bad data in the args. This has happened for a json.RawMessage
 		// field.
-		obj := make(map[string]interface{})
-		obj["noticeType"] = "Alert"
-		obj["showUser"] = false
-		obj["data"] = map[string]interface{}{
-			"message": fmt.Sprintf("Marshal notice failed: %s", common.ContextError(err)),
+		output = makeOutputNoticeError(
+			fmt.Sprintf("marshal notice failed: %s", common.ContextError(err)))
+	}
+
+	nl.mutex.Lock()
+	defer nl.mutex.Unlock()
+
+	skipWriter := false
+
+	if nl.homepageFile != nil &&
+		(noticeFlags&noticeIsHomepage != 0) {
+
+		skipWriter = true
+
+		err := nl.outputNoticeToHomepageFile(noticeFlags, output)
+
+		if err != nil {
+			output := makeOutputNoticeError(
+				fmt.Sprintf("write homepage file failed: %s", err))
+			nl.writer.Write(output)
 		}
+	}
 
-		obj["timestamp"] = time.Now().UTC().Format(common.RFC3339Milli)
-		encodedJson, err := json.Marshal(obj)
-		if err == nil {
-			output = string(encodedJson)
-		} else {
-			output = common.ContextError(errors.New("failed to marshal notice")).Error()
+	if nl.rotatingFile != nil {
+
+		if !skipWriter {
+			skipWriter = (noticeFlags&noticeIsDiagnostic != 0)
 		}
+
+		err := nl.outputNoticeToRotatingFile(output)
+
+		if err != nil {
+			output := makeOutputNoticeError(
+				fmt.Sprintf("write rotating file failed: %s", err))
+			nl.writer.Write(output)
+		}
+	}
+
+	if !skipWriter {
+		_, _ = nl.writer.Write(output)
 	}
-	noticeLoggerMutex.Lock()
-	defer noticeLoggerMutex.Unlock()
-	noticeLogger.Print(output)
+}
+
+func makeOutputNoticeError(errorMessage string) []byte {
+	// Format an Alert Notice (_without_ using json.Marshal, since that can fail)
+	alertNoticeFormat := "{\"noticeType\":\"Alert\",\"showUser\":false,\"timestamp\":\"%s\",\"data\":{\"message\":\"%s\"}}\n"
+	return []byte(fmt.Sprintf(alertNoticeFormat, time.Now().UTC().Format(common.RFC3339Milli), errorMessage))
+
+}
+
+func (nl *noticeLogger) outputNoticeToHomepageFile(noticeFlags uint32, output []byte) error {
+
+	if (noticeFlags & noticeClearHomepages) != 0 {
+		err := nl.homepageFile.Truncate(0)
+		if err != nil {
+			return common.ContextError(err)
+		}
+		_, err = nl.homepageFile.Seek(0, 0)
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	_, err := nl.homepageFile.Write(output)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	if (noticeFlags & noticeSyncHomepages) != 0 {
+		err = nl.homepageFile.Sync()
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	return nil
+}
+
+func (nl *noticeLogger) outputNoticeToRotatingFile(output []byte) error {
+
+	nl.rotatingCurrentFileSize += int64(len(output) + 1)
+	if nl.rotatingCurrentFileSize >= nl.rotatingFileSize {
+
+		// Note: all errors are fatal in order to preserve the
+		// rotatingFileSize limit; e.g., no attempt is made to
+		// continue writing to the file if it can't be rotated.
+
+		err := nl.rotatingFile.Sync()
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		err = nl.rotatingFile.Close()
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		err = os.Rename(nl.rotatingFilename, nl.rotatingOlderFilename)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		nl.rotatingFile, err = os.OpenFile(
+			nl.rotatingFilename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		nl.rotatingCurrentFileSize = 0
+	}
+
+	_, err := nl.rotatingFile.Write(output)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	nl.rotatingCurrentNoticeCount += 1
+	if nl.rotatingCurrentNoticeCount >= nl.rotatingSyncFrequency {
+		nl.rotatingCurrentNoticeCount = 0
+		err = nl.rotatingFile.Sync()
+		if err != nil {
+			return common.ContextError(err)
+		}
+	}
+
+	return nil
 }
 
 // NoticeInfo is an informational message
 func NoticeInfo(format string, args ...interface{}) {
-	outputNotice("Info", noticeIsDiagnostic, "message", fmt.Sprintf(format, args...))
+	singletonNoticeLogger.outputNotice(
+		"Info", noticeIsDiagnostic,
+		"message", fmt.Sprintf(format, args...))
 }
 
 // NoticeAlert is an alert message; typically a recoverable error condition
 func NoticeAlert(format string, args ...interface{}) {
-	outputNotice("Alert", noticeIsDiagnostic, "message", fmt.Sprintf(format, args...))
+	singletonNoticeLogger.outputNotice(
+		"Alert", noticeIsDiagnostic,
+		"message", fmt.Sprintf(format, args...))
 }
 
 // NoticeError is an error message; typically an unrecoverable error condition
 func NoticeError(format string, args ...interface{}) {
-	outputNotice("Error", noticeIsDiagnostic, "message", fmt.Sprintf(format, args...))
+	singletonNoticeLogger.outputNotice(
+		"Error", noticeIsDiagnostic,
+		"message", fmt.Sprintf(format, args...))
 }
 
 // NoticeCandidateServers is how many possible servers are available for the selected region and protocol
 func NoticeCandidateServers(region, protocol string, count int) {
-	outputNotice("CandidateServers", 0, "region", region, "protocol", protocol, "count", count)
+	singletonNoticeLogger.outputNotice(
+		"CandidateServers", noticeIsDiagnostic,
+		"region", region,
+		"protocol", protocol,
+		"count", count)
 }
 
 // NoticeAvailableEgressRegions is what regions are available for egress from.
@@ -217,64 +417,92 @@ func noticeServerDialStats(noticeType, ipAddress, region, protocol string, tunne
 		args = append(args, "TLSProfile", tunnelDialStats.TLSProfile)
 	}
 
-	outputNotice(
-		noticeType,
-		noticeIsDiagnostic,
+	singletonNoticeLogger.outputNotice(
+		noticeType, noticeIsDiagnostic,
 		args...)
 }
 
 // NoticeConnectingServer reports parameters and details for a single connection attempt
 func NoticeConnectingServer(ipAddress, region, protocol string, tunnelDialStats *TunnelDialStats) {
-	noticeServerDialStats("ConnectingServer", ipAddress, region, protocol, tunnelDialStats)
+	noticeServerDialStats(
+		"ConnectingServer", ipAddress, region, protocol, tunnelDialStats)
 }
 
 // NoticeConnectedServer reports parameters and details for a single successful connection
 func NoticeConnectedServer(ipAddress, region, protocol string, tunnelDialStats *TunnelDialStats) {
-	noticeServerDialStats("ConnectedServer", ipAddress, region, protocol, tunnelDialStats)
+	noticeServerDialStats(
+		"ConnectedServer", ipAddress, region, protocol, tunnelDialStats)
 }
 
 // NoticeActiveTunnel is a successful connection that is used as an active tunnel for port forwarding
 func NoticeActiveTunnel(ipAddress, protocol string, isTCS bool) {
-	outputNotice("ActiveTunnel", noticeIsDiagnostic, "ipAddress", ipAddress, "protocol", protocol, "isTCS", isTCS)
+	singletonNoticeLogger.outputNotice(
+		"ActiveTunnel", noticeIsDiagnostic,
+		"ipAddress", ipAddress,
+		"protocol", protocol,
+		"isTCS", isTCS)
 }
 
 // NoticeSocksProxyPortInUse is a failure to use the configured LocalSocksProxyPort
 func NoticeSocksProxyPortInUse(port int) {
-	outputNotice("SocksProxyPortInUse", noticeShowUser, "port", port)
+	singletonNoticeLogger.outputNotice(
+		"SocksProxyPortInUse",
+		noticeShowUser, "port", port)
 }
 
 // NoticeListeningSocksProxyPort is the selected port for the listening local SOCKS proxy
 func NoticeListeningSocksProxyPort(port int) {
-	outputNotice("ListeningSocksProxyPort", 0, "port", port)
+	singletonNoticeLogger.outputNotice(
+		"ListeningSocksProxyPort", 0,
+		"port", port)
 }
 
 // NoticeHttpProxyPortInUse is a failure to use the configured LocalHttpProxyPort
 func NoticeHttpProxyPortInUse(port int) {
-	outputNotice("HttpProxyPortInUse", noticeShowUser, "port", port)
+	singletonNoticeLogger.outputNotice(
+		"HttpProxyPortInUse", noticeShowUser,
+		"port", port)
 }
 
 // NoticeListeningHttpProxyPort is the selected port for the listening local HTTP proxy
 func NoticeListeningHttpProxyPort(port int) {
-	outputNotice("ListeningHttpProxyPort", 0, "port", port)
+	singletonNoticeLogger.outputNotice(
+		"ListeningHttpProxyPort", 0,
+		"port", port)
 }
 
 // NoticeClientUpgradeAvailable is an available client upgrade, as per the handshake. The
 // client should download and install an upgrade.
 func NoticeClientUpgradeAvailable(version string) {
-	outputNotice("ClientUpgradeAvailable", 0, "version", version)
+	singletonNoticeLogger.outputNotice(
+		"ClientUpgradeAvailable", 0,
+		"version", version)
 }
 
 // NoticeClientIsLatestVersion reports that an upgrade check was made and the client
 // is already the latest version. availableVersion is the version available for download,
 // if known.
 func NoticeClientIsLatestVersion(availableVersion string) {
-	outputNotice("ClientIsLatestVersion", 0, "availableVersion", availableVersion)
-}
-
-// NoticeHomepage is a sponsor homepage, as per the handshake. The client
-// should display the sponsor's homepage.
-func NoticeHomepage(url string) {
-	outputNotice("Homepage", 0, "url", url)
+	singletonNoticeLogger.outputNotice(
+		"ClientIsLatestVersion", 0,
+		"availableVersion", availableVersion)
+}
+
+// NoticeHomepages emits a series of NoticeHomepage, the sponsor homepages. The client
+// should display the sponsor's homepages.
+func NoticeHomepages(urls []string) {
+	for i, url := range urls {
+		noticeFlags := uint32(noticeIsHomepage)
+		if i == 0 {
+			noticeFlags |= noticeClearHomepages
+		}
+		if i == len(urls)-1 {
+			noticeFlags |= noticeSyncHomepages
+		}
+		singletonNoticeLogger.outputNotice(
+			"Homepage", noticeFlags,
+			"url", url)
+	}
 }
 
 // NoticeClientVerificationRequired indicates that client verification is required, as
@@ -283,29 +511,40 @@ func NoticeHomepage(url string) {
 // payload to the server. If resetCache is set the client must always perform a new
 // verification and update its cache
 func NoticeClientVerificationRequired(nonce string, ttlSeconds int, resetCache bool) {
-	outputNotice("ClientVerificationRequired", 0, "nonce", nonce, "ttlSeconds", ttlSeconds, "resetCache", resetCache)
+	singletonNoticeLogger.outputNotice(
+		"ClientVerificationRequired", 0,
+		"nonce", nonce,
+		"ttlSeconds", ttlSeconds,
+		"resetCache", resetCache)
 }
 
 // NoticeClientRegion is the client's region, as determined by the server and
 // reported to the client in the handshake.
 func NoticeClientRegion(region string) {
-	outputNotice("ClientRegion", 0, "region", region)
+	singletonNoticeLogger.outputNotice(
+		"ClientRegion", 0,
+		"region", region)
 }
 
 // NoticeTunnels is how many active tunnels are available. The client should use this to
 // determine connecting/unexpected disconnect state transitions. When count is 0, the core is
 // disconnected; when count > 1, the core is connected.
 func NoticeTunnels(count int) {
-	outputNotice("Tunnels", 0, "count", count)
+	singletonNoticeLogger.outputNotice(
+		"Tunnels", 0,
+		"count", count)
 }
 
 // NoticeSessionId is the session ID used across all tunnels established by the controller.
 func NoticeSessionId(sessionId string) {
-	outputNotice("SessionId", noticeIsDiagnostic, "sessionId", sessionId)
+	singletonNoticeLogger.outputNotice(
+		"SessionId", noticeIsDiagnostic,
+		"sessionId", sessionId)
 }
 
 func NoticeImpairedProtocolClassification(impairedProtocolClassification map[string]int) {
-	outputNotice("ImpairedProtocolClassification", noticeIsDiagnostic,
+	singletonNoticeLogger.outputNotice(
+		"ImpairedProtocolClassification", noticeIsDiagnostic,
 		"classification", impairedProtocolClassification)
 }
 
@@ -316,29 +555,39 @@ func NoticeImpairedProtocolClassification(impairedProtocolClassification map[str
 // users, not for diagnostics logs.
 //
 func NoticeUntunneled(address string) {
-	outputNotice("Untunneled", noticeShowUser, "address", address)
+	singletonNoticeLogger.outputNotice(
+		"Untunneled", noticeShowUser,
+		"address", address)
 }
 
 // NoticeSplitTunnelRegion reports that split tunnel is on for the given region.
 func NoticeSplitTunnelRegion(region string) {
-	outputNotice("SplitTunnelRegion", noticeShowUser, "region", region)
+	singletonNoticeLogger.outputNotice(
+		"SplitTunnelRegion", noticeShowUser,
+		"region", region)
 }
 
 // NoticeUpstreamProxyError reports an error when connecting to an upstream proxy. The
 // user may have input, for example, an incorrect address or incorrect credentials.
 func NoticeUpstreamProxyError(err error) {
-	outputNotice("UpstreamProxyError", noticeShowUser, "message", err.Error())
+	singletonNoticeLogger.outputNotice(
+		"UpstreamProxyError", noticeShowUser,
+		"message", err.Error())
 }
 
 // NoticeClientUpgradeDownloadedBytes reports client upgrade download progress.
 func NoticeClientUpgradeDownloadedBytes(bytes int64) {
-	outputNotice("ClientUpgradeDownloadedBytes", noticeIsDiagnostic, "bytes", bytes)
+	singletonNoticeLogger.outputNotice(
+		"ClientUpgradeDownloadedBytes", noticeIsDiagnostic,
+		"bytes", bytes)
 }
 
 // NoticeClientUpgradeDownloaded indicates that a client upgrade download
 // is complete and available at the destination specified.
 func NoticeClientUpgradeDownloaded(filename string) {
-	outputNotice("ClientUpgradeDownloaded", 0, "filename", filename)
+	singletonNoticeLogger.outputNotice(
+		"ClientUpgradeDownloaded", 0,
+		"filename", filename)
 }
 
 // NoticeBytesTransferred reports how many tunneled bytes have been
@@ -346,10 +595,17 @@ func NoticeClientUpgradeDownloaded(filename string) {
 // to the server at ipAddress.
 func NoticeBytesTransferred(ipAddress string, sent, received int64) {
 	if GetEmitDiagnoticNotices() {
-		outputNotice("BytesTransferred", noticeIsDiagnostic, "ipAddress", ipAddress, "sent", sent, "received", received)
+		singletonNoticeLogger.outputNotice(
+			"BytesTransferred", noticeIsDiagnostic,
+			"ipAddress", ipAddress,
+			"sent", sent,
+			"received", received)
 	} else {
 		// This case keeps the EmitBytesTransferred and EmitDiagnosticNotices config options independent
-		outputNotice("BytesTransferred", 0, "sent", sent, "received", received)
+		singletonNoticeLogger.outputNotice(
+			"BytesTransferred", 0,
+			"sent", sent,
+			"received", received)
 	}
 }
 
@@ -358,10 +614,17 @@ func NoticeBytesTransferred(ipAddress string, sent, received int64) {
 // at ipAddress.
 func NoticeTotalBytesTransferred(ipAddress string, sent, received int64) {
 	if GetEmitDiagnoticNotices() {
-		outputNotice("TotalBytesTransferred", noticeIsDiagnostic, "ipAddress", ipAddress, "sent", sent, "received", received)
+		singletonNoticeLogger.outputNotice(
+			"TotalBytesTransferred", noticeIsDiagnostic,
+			"ipAddress", ipAddress,
+			"sent", sent,
+			"received", received)
 	} else {
 		// This case keeps the EmitBytesTransferred and EmitDiagnosticNotices config options independent
-		outputNotice("TotalBytesTransferred", 0, "sent", sent, "received", received)
+		singletonNoticeLogger.outputNotice(
+			"TotalBytesTransferred", 0,
+			"sent", sent,
+			"received", received)
 	}
 }
 
@@ -382,44 +645,60 @@ func NoticeLocalProxyError(proxyType string, err error) {
 
 	outputRepetitiveNotice(
 		"LocalProxyError"+proxyType, repetitionMessage, 1,
-		"LocalProxyError", noticeIsDiagnostic, "message", err.Error())
+		"LocalProxyError", noticeIsDiagnostic,
+		"message", err.Error())
 }
 
 // NoticeBuildInfo reports build version info.
 func NoticeBuildInfo() {
-	outputNotice("BuildInfo", 0, "buildInfo", common.GetBuildInfo())
+	singletonNoticeLogger.outputNotice(
+		"BuildInfo", noticeIsDiagnostic,
+		"buildInfo", common.GetBuildInfo())
 }
 
 // NoticeExiting indicates that tunnel-core is exiting imminently.
 func NoticeExiting() {
-	outputNotice("Exiting", 0)
+	singletonNoticeLogger.outputNotice(
+		"Exiting", 0)
 }
 
 // NoticeRemoteServerListResourceDownloadedBytes reports remote server list download progress.
 func NoticeRemoteServerListResourceDownloadedBytes(url string, bytes int64) {
-	outputNotice("RemoteServerListResourceDownloadedBytes", noticeIsDiagnostic, "url", url, "bytes", bytes)
+	singletonNoticeLogger.outputNotice(
+		"RemoteServerListResourceDownloadedBytes", noticeIsDiagnostic,
+		"url", url,
+		"bytes", bytes)
 }
 
 // NoticeRemoteServerListResourceDownloaded indicates that a remote server list download
 // completed successfully.
 func NoticeRemoteServerListResourceDownloaded(url string) {
-	outputNotice("RemoteServerListResourceDownloaded", noticeIsDiagnostic, "url", url)
+	singletonNoticeLogger.outputNotice(
+		"RemoteServerListResourceDownloaded", noticeIsDiagnostic,
+		"url", url)
 }
 
 func NoticeClientVerificationRequestCompleted(ipAddress string) {
 	// TODO: remove "Notice" prefix
-	outputNotice("NoticeClientVerificationRequestCompleted", noticeIsDiagnostic, "ipAddress", ipAddress)
+	singletonNoticeLogger.outputNotice(
+		"NoticeClientVerificationRequestCompleted", noticeIsDiagnostic,
+		"ipAddress", ipAddress)
 }
 
 // NoticeSLOKSeeded indicates that the SLOK with the specified ID was received from
 // the Psiphon server. The "duplicate" flags indicates whether the SLOK was previously known.
 func NoticeSLOKSeeded(slokID string, duplicate bool) {
-	outputNotice("SLOKSeeded", noticeIsDiagnostic, "slokID", slokID, "duplicate", duplicate)
+	singletonNoticeLogger.outputNotice(
+		"SLOKSeeded", noticeIsDiagnostic,
+		"slokID", slokID,
+		"duplicate", duplicate)
 }
 
 // NoticeServerTimestamp reports server side timestamp as seen in the handshake
 func NoticeServerTimestamp(timestamp string) {
-	outputNotice("ServerTimestamp", 0, "timestamp", timestamp)
+	singletonNoticeLogger.outputNotice(
+		"ServerTimestamp", 0,
+		"timestamp", timestamp)
 }
 
 type repetitiveNoticeState struct {
@@ -462,7 +741,9 @@ func outputRepetitiveNotice(
 		if state.repeats > 0 {
 			args = append(args, "repeats", state.repeats)
 		}
-		outputNotice(noticeType, noticeFlags, args...)
+		singletonNoticeLogger.outputNotice(
+			noticeType, noticeFlags,
+			args...)
 	}
 }
 
@@ -516,10 +797,15 @@ func (receiver *NoticeReceiver) Write(p []byte) (n int, err error) {
 	}
 
 	notice := receiver.buffer[:index]
-	receiver.buffer = receiver.buffer[index+1:]
 
 	receiver.callback(notice)
 
+	if index == len(receiver.buffer)-1 {
+		receiver.buffer = receiver.buffer[0:0]
+	} else {
+		receiver.buffer = receiver.buffer[index+1:]
+	}
+
 	return len(p), nil
 }
 
@@ -553,7 +839,9 @@ func NewNoticeWriter(noticeType string) *NoticeWriter {
 
 // Write implements io.Writer.
 func (writer *NoticeWriter) Write(p []byte) (n int, err error) {
-	outputNotice(writer.noticeType, noticeIsDiagnostic, "message", string(p))
+	singletonNoticeLogger.outputNotice(
+		writer.noticeType, noticeIsDiagnostic,
+		"message", string(p))
 	return len(p), nil
 }
 
@@ -581,9 +869,8 @@ func (logger *commonLogger) WithContextFields(fields common.LogFields) common.Lo
 }
 
 func (logger *commonLogger) LogMetric(metric string, fields common.LogFields) {
-	outputNotice(
-		metric,
-		noticeIsDiagnostic,
+	singletonNoticeLogger.outputNotice(
+		metric, noticeIsDiagnostic,
 		listCommonFields(fields)...)
 }
 
@@ -609,9 +896,8 @@ type commonLogContext struct {
 func (context *commonLogContext) outputNotice(
 	noticeType string, args ...interface{}) {
 
-	outputNotice(
-		noticeType,
-		noticeIsDiagnostic,
+	singletonNoticeLogger.outputNotice(
+		noticeType, noticeIsDiagnostic,
 		append(
 			[]interface{}{
 				"message", fmt.Sprint(args...),

+ 3 - 5
psiphon/serverApi.go

@@ -176,6 +176,8 @@ func (serverContext *ServerContext) doHandshakeRequest(
 	// - 'preemptive_reconnect_lifetime_milliseconds' is unused and ignored
 	// - 'ssh_session_id' is ignored; client session ID is used instead
 
+	NoticeInfo("response body len: %d", len(response))
+
 	var handshakeResponse protocol.HandshakeResponse
 	err := json.Unmarshal(response, &handshakeResponse)
 	if err != nil {
@@ -218,11 +220,7 @@ func (serverContext *ServerContext) doHandshakeRequest(
 		return common.ContextError(err)
 	}
 
-	// TODO: formally communicate the sponsor and upgrade info to an
-	// outer client via some control interface.
-	for _, homepage := range handshakeResponse.Homepages {
-		NoticeHomepage(homepage)
-	}
+	NoticeHomepages(handshakeResponse.Homepages)
 
 	serverContext.clientUpgradeVersion = handshakeResponse.UpgradeClientVersion
 	if handshakeResponse.UpgradeClientVersion != "" {