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

Next phase of obfuscated server list implementation
- psiphond integration: hot reloadable OSL config file,
set seed state for clients, track seed progress for
each port forward, issue seeded SLOKs in status requests
- server_test end-to-end test case for SLOK seeding
- client integration (in progress): read seeded SLOKs
in status responses, store in local datastore
- support all-region OSL schemes
- modify ReloadableFile to check file content before
triggering reload action
- move protocol.go to its own package to avoid import
cycle
- to avoid double reporting stats, changed status request
handler to only log stats after fully validating input
- moved bulk of ssh client logic from sshServer.handleClient()
to sshClient.run()
- separate DNS resolve and TCP connect by calling LookupIP
before Dial; use IP to apply traffic rules and OSL config

Rod Hynes 9 лет назад
Родитель
Сommit
0d29640924

+ 2 - 1
ConsoleClient/main.go

@@ -30,6 +30,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 func main() {
@@ -141,7 +142,7 @@ func main() {
 			serverEntries, err := psiphon.DecodeAndValidateServerEntryList(
 				string(serverEntryList),
 				common.GetCurrentTimestamp(),
-				common.SERVER_ENTRY_SOURCE_EMBEDDED)
+				protocol.SERVER_ENTRY_SOURCE_EMBEDDED)
 			if err != nil {
 				psiphon.NoticeError("error decoding embedded server entry list file: %s", err)
 				return

+ 2 - 1
MobileLibrary/psi/psi.go

@@ -30,6 +30,7 @@ import (
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 type PsiphonProvider interface {
@@ -85,7 +86,7 @@ func Start(
 	serverEntries, err := psiphon.DecodeAndValidateServerEntryList(
 		embeddedServerEntryList,
 		common.GetCurrentTimestamp(),
-		common.SERVER_ENTRY_SOURCE_EMBEDDED)
+		protocol.SERVER_ENTRY_SOURCE_EMBEDDED)
 	if err != nil {
 		return fmt.Errorf("error decoding embedded server entry list: %s", err)
 	}

+ 29 - 1
psiphon/common/net.go

@@ -185,6 +185,11 @@ func (entry *LRUConnsEntry) Touch() {
 // When a LRUConnsEntry is specified, then the LRU entry is promoted on
 // either a successful read or write.
 //
+// When an ActivityUpdater is set, then its UpdateActivity method is
+// called on each read and write with the number of bytes transferred.
+// The durationNanoseconds, which is the time since the last read, is
+// reported only on reads.
+//
 type ActivityMonitoredConn struct {
 	// Note: 64-bit ints used with atomic operations are at placed
 	// at the start of struct to ensure 64-bit alignment.
@@ -195,13 +200,23 @@ type ActivityMonitoredConn struct {
 	net.Conn
 	inactivityTimeout time.Duration
 	activeOnWrite     bool
+	activityUpdater   ActivityUpdater
 	lruEntry          *LRUConnsEntry
 }
 
+// ActivityUpdater defines an interface for receiving updates for
+// ActivityMonitoredConn activity. Values passed to UpdateProgress are
+// bytes transferred and conn duration since the previous UpdateProgress.
+type ActivityUpdater interface {
+	UpdateProgress(bytesRead, bytesWritten int64, durationNanoseconds int64)
+}
+
+// NewActivityMonitoredConn creates a new ActivityMonitoredConn.
 func NewActivityMonitoredConn(
 	conn net.Conn,
 	inactivityTimeout time.Duration,
 	activeOnWrite bool,
+	activityUpdater ActivityUpdater,
 	lruEntry *LRUConnsEntry) (*ActivityMonitoredConn, error) {
 
 	if inactivityTimeout > 0 {
@@ -220,6 +235,7 @@ func NewActivityMonitoredConn(
 		realStartTime:        time.Now(),
 		monotonicStartTime:   now,
 		lastReadActivityTime: now,
+		activityUpdater:      activityUpdater,
 		lruEntry:             lruEntry,
 	}, nil
 }
@@ -252,11 +268,19 @@ func (conn *ActivityMonitoredConn) Read(buffer []byte) (int, error) {
 				return n, ContextError(err)
 			}
 		}
+
+		readActivityTime := int64(monotime.Now())
+
+		if conn.activityUpdater != nil {
+			conn.activityUpdater.UpdateProgress(
+				int64(n), 0, readActivityTime-atomic.LoadInt64(&conn.lastReadActivityTime))
+		}
+
 		if conn.lruEntry != nil {
 			conn.lruEntry.Touch()
 		}
 
-		atomic.StoreInt64(&conn.lastReadActivityTime, int64(monotime.Now()))
+		atomic.StoreInt64(&conn.lastReadActivityTime, readActivityTime)
 
 	}
 	// Note: no context error to preserve error type
@@ -274,6 +298,10 @@ func (conn *ActivityMonitoredConn) Write(buffer []byte) (int, error) {
 			}
 		}
 
+		if conn.activityUpdater != nil {
+			conn.activityUpdater.UpdateProgress(0, int64(n), 0)
+		}
+
 		if conn.lruEntry != nil {
 			conn.lruEntry.Touch()
 		}

+ 4 - 3
psiphon/common/net_test.go

@@ -106,6 +106,7 @@ func TestActivityMonitoredConn(t *testing.T) {
 		&dummyConn{},
 		200*time.Millisecond,
 		true,
+		nil,
 		nil)
 	if err != nil {
 		t.Fatalf("NewActivityMonitoredConn failed")
@@ -182,19 +183,19 @@ func TestActivityMonitoredLRUConns(t *testing.T) {
 	lruConns := NewLRUConns()
 
 	dummy1 := &dummyConn{}
-	conn1, err := NewActivityMonitoredConn(dummy1, 0, true, lruConns.Add(dummy1))
+	conn1, err := NewActivityMonitoredConn(dummy1, 0, true, nil, lruConns.Add(dummy1))
 	if err != nil {
 		t.Fatalf("NewActivityMonitoredConn failed")
 	}
 
 	dummy2 := &dummyConn{}
-	conn2, err := NewActivityMonitoredConn(dummy2, 0, true, lruConns.Add(dummy2))
+	conn2, err := NewActivityMonitoredConn(dummy2, 0, true, nil, lruConns.Add(dummy2))
 	if err != nil {
 		t.Fatalf("NewActivityMonitoredConn failed")
 	}
 
 	dummy3 := &dummyConn{}
-	conn3, err := NewActivityMonitoredConn(dummy3, 0, true, lruConns.Add(dummy3))
+	conn3, err := NewActivityMonitoredConn(dummy3, 0, true, nil, lruConns.Add(dummy3))
 	if err != nil {
 		t.Fatalf("NewActivityMonitoredConn failed")
 	}

+ 53 - 16
psiphon/common/osl/osl.go

@@ -57,7 +57,11 @@ const (
 )
 
 // Config is an OSL configuration, which consists of a list of schemes.
+// The Reload function supports hot reloading of rules data while the
+// process is running.
 type Config struct {
+	common.ReloadableFile
+
 	Schemes []*Scheme
 }
 
@@ -160,14 +164,14 @@ type SeedSpec struct {
 // TrafficValues defines a client traffic level that seeds a SLOK.
 // BytesRead and BytesWritten are the minimum bytes transferred counts to
 // seed a SLOK. Both UDP and TCP data will be counted towards these totals.
-// PortForwardDurationMilliseconds is the duration that a TCP or UDP port
+// PortForwardDurationNanoseconds is the duration that a TCP or UDP port
 // forward is active (not connected, in the UDP case). All threshold
 // settings must be met to seed a SLOK; any threshold may be set to 0 to
 // be trivially satisfied.
 type TrafficValues struct {
-	BytesRead                       int64
-	BytesWritten                    int64
-	PortForwardDurationMilliseconds int64
+	BytesRead                      int64
+	BytesWritten                   int64
+	PortForwardDurationNanoseconds int64
 }
 
 // KeySplit defines a secret key splitting scheme where the secret is split
@@ -222,9 +226,35 @@ type SeedPayload struct {
 	SLOKs []*SLOK
 }
 
-// LoadConfig loads, vaildates, and initializes a JSON encoded OSL
+// NewConfig initializes a Config with the settings in the specified
+// file.
+func NewConfig(filename string) (*Config, error) {
+
+	config := &Config{}
+
+	config.ReloadableFile = common.NewReloadableFile(
+		filename,
+		func(fileContent []byte) error {
+			newConfig, err := loadConfig(fileContent)
+			if err != nil {
+				return common.ContextError(err)
+			}
+			// Modify actual traffic rules only after validation
+			config.Schemes = newConfig.Schemes
+			return nil
+		})
+
+	_, err := config.Reload()
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return config, nil
+}
+
+// loadConfig loads, vaildates, and initializes a JSON encoded OSL
 // configuration.
-func LoadConfig(configJSON []byte) (*Config, error) {
+func loadConfig(configJSON []byte) (*Config, error) {
 
 	var config Config
 	err := json.Unmarshal(configJSON, &config)
@@ -298,8 +328,11 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 // NewClientSeedState creates a new client seed state to track
 // client progress towards seeding SLOKs. psiphond maintains one
 // ClientSeedState for each connected client.
-func NewClientSeedState(
-	config *Config, clientRegion, propagationChannelID string) *ClientSeedState {
+func (config *Config) NewClientSeedState(
+	clientRegion, propagationChannelID string) *ClientSeedState {
+
+	config.ReloadableFile.RLock()
+	defer config.ReloadableFile.RUnlock()
 
 	for _, scheme := range config.Schemes {
 		// Only the first matching scheme is selected.
@@ -308,7 +341,7 @@ func NewClientSeedState(
 		// maps for more efficient lookup.
 		if scheme.epoch.Before(time.Now().UTC()) &&
 			common.Contains(scheme.PropagationChannelIDs, propagationChannelID) &&
-			common.Contains(scheme.Regions, clientRegion) {
+			(len(scheme.Regions) == 0 || common.Contains(scheme.Regions, clientRegion)) {
 
 			// Empty progress is initialized up front for all seed specs. Once
 			// created, the progress map structure is read-only (the map, not the
@@ -383,7 +416,8 @@ func (state *ClientSeedState) NewClientSeedPortForward(
 // not blocking port forward relaying; a consequence of this lock-free
 // design is that progress reported at the exact time of SLOK time period
 // rollover may be dropped.
-func (portForward *ClientSeedPortForward) UpdateProgress(progressDelta *TrafficValues) {
+func (portForward *ClientSeedPortForward) UpdateProgress(
+	bytesRead, bytesWritten int64, durationNanoseconds int64) {
 
 	// Concurrency: access to ClientSeedState is unsynchronized to read-only
 	// fields or atomic, except in the case of a time period rollover, in which
@@ -411,9 +445,9 @@ func (portForward *ClientSeedPortForward) UpdateProgress(progressDelta *TrafficV
 	// As a consequence, progress may be dropped at the exact time of
 	// time period rollover.
 	for _, progress := range portForward.progress {
-		atomic.AddInt64(&progress.BytesRead, progressDelta.BytesRead)
-		atomic.AddInt64(&progress.BytesWritten, progressDelta.BytesWritten)
-		atomic.AddInt64(&progress.PortForwardDurationMilliseconds, progressDelta.PortForwardDurationMilliseconds)
+		atomic.AddInt64(&progress.BytesRead, bytesRead)
+		atomic.AddInt64(&progress.BytesWritten, bytesWritten)
+		atomic.AddInt64(&progress.PortForwardDurationNanoseconds, durationNanoseconds)
 	}
 }
 
@@ -441,8 +475,8 @@ func (state *ClientSeedState) issueSLOKs() {
 
 		if atomic.LoadInt64(&progress.BytesRead) >= seedSpec.Targets.BytesRead &&
 			atomic.LoadInt64(&progress.BytesWritten) >= seedSpec.Targets.BytesWritten &&
-			atomic.LoadInt64(&progress.PortForwardDurationMilliseconds) >=
-				seedSpec.Targets.PortForwardDurationMilliseconds {
+			atomic.LoadInt64(&progress.PortForwardDurationNanoseconds) >=
+				seedSpec.Targets.PortForwardDurationNanoseconds {
 
 			ref := &slokReference{
 				PropagationChannelID: state.propagationChannelID,
@@ -474,7 +508,7 @@ func (state *ClientSeedState) issueSLOKs() {
 		for _, progress := range state.progress {
 			atomic.StoreInt64(&progress.BytesRead, 0)
 			atomic.StoreInt64(&progress.BytesWritten, 0)
-			atomic.StoreInt64(&progress.PortForwardDurationMilliseconds, 0)
+			atomic.StoreInt64(&progress.PortForwardDurationNanoseconds, 0)
 		}
 	}
 }
@@ -598,6 +632,9 @@ func (config *Config) Pave(
 	signingPrivateKey string,
 	paveServerEntries []map[time.Time][]byte) ([]*PaveFile, error) {
 
+	config.ReloadableFile.RLock()
+	defer config.ReloadableFile.RUnlock()
+
 	var paveFiles []*PaveFile
 
 	Directory := &Directory{}

+ 18 - 58
psiphon/common/osl/osl_test.go

@@ -52,7 +52,7 @@ func TestOSL(t *testing.T) {
           {
               "BytesRead" : 1,
               "BytesWritten" : 1,
-              "PortForwardDurationMilliseconds" : 1
+              "PortForwardDurationNanoseconds" : 1
           }
         },
         {
@@ -63,7 +63,7 @@ func TestOSL(t *testing.T) {
           {
               "BytesRead" : 10,
               "BytesWritten" : 10,
-              "PortForwardDurationMilliseconds" : 10
+              "PortForwardDurationNanoseconds" : 10
           }
         },
         {
@@ -74,7 +74,7 @@ func TestOSL(t *testing.T) {
           {
               "BytesRead" : 100,
               "BytesWritten" : 100,
-              "PortForwardDurationMilliseconds" : 100
+              "PortForwardDurationNanoseconds" : 100
           }
         }
       ],
@@ -112,7 +112,7 @@ func TestOSL(t *testing.T) {
           {
               "BytesRead" : 1,
               "BytesWritten" : 1,
-              "PortForwardDurationMilliseconds" : 1
+              "PortForwardDurationNanoseconds" : 1
           }
         },
         {
@@ -123,7 +123,7 @@ func TestOSL(t *testing.T) {
           {
               "BytesRead" : 10,
               "BytesWritten" : 10,
-              "PortForwardDurationMilliseconds" : 10
+              "PortForwardDurationNanoseconds" : 10
           }
         },
         {
@@ -134,7 +134,7 @@ func TestOSL(t *testing.T) {
           {
               "BytesRead" : 0,
               "BytesWritten" : 0,
-              "PortForwardDurationMilliseconds" : 0
+              "PortForwardDurationNanoseconds" : 0
           }
         }
       ],
@@ -162,14 +162,14 @@ func TestOSL(t *testing.T) {
 	// periods and 5/10 10 millisecond longer periods. The second scheme requires
 	// sufficient activity within 25/100 1 millisecond periods.
 
-	config, err := LoadConfig([]byte(configJSON))
+	config, err := loadConfig([]byte(configJSON))
 	if err != nil {
 		t.Fatalf("LoadConfig failed: %s", err)
 	}
 
 	t.Run("ineligible client, sufficient transfer", func(t *testing.T) {
 
-		clientSeedState := NewClientSeedState(config, "US", "C5E8D2EDFD093B50D8D65CF59D0263CA")
+		clientSeedState := config.NewClientSeedState("US", "C5E8D2EDFD093B50D8D65CF59D0263CA")
 
 		seedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1"))
 
@@ -179,7 +179,7 @@ func TestOSL(t *testing.T) {
 	})
 
 	// This clientSeedState is used across multiple tests.
-	clientSeedState := NewClientSeedState(config, "US", "2995DB0C968C59C4F23E87988D9C0D41")
+	clientSeedState := config.NewClientSeedState("US", "2995DB0C968C59C4F23E87988D9C0D41")
 
 	t.Run("eligible client, no transfer", func(t *testing.T) {
 
@@ -190,12 +190,7 @@ func TestOSL(t *testing.T) {
 
 	t.Run("eligible client, insufficient transfer", func(t *testing.T) {
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
 
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 0 {
 			t.Fatalf("expected 0 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
@@ -212,12 +207,7 @@ func TestOSL(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
 
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 0 {
 			t.Fatalf("expected 0 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
@@ -230,19 +220,9 @@ func TestOSL(t *testing.T) {
 
 		clientSeedPortForward := clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1"))
 
-		clientSeedPortForward.UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedPortForward.UpdateProgress(5, 5, 5)
 
-		clientSeedPortForward.UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedPortForward.UpdateProgress(5, 5, 5)
 
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 1 {
 			t.Fatalf("expected 1 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))
@@ -253,19 +233,9 @@ func TestOSL(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
 
 		// Expect 2 SLOKS: 1 new, and 1 remaining in payload.
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 2 {
@@ -277,19 +247,9 @@ func TestOSL(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1")).UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("192.168.0.1")).UpdateProgress(5, 5, 5)
 
-		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(
-			&TrafficValues{
-				BytesRead:                       5,
-				BytesWritten:                    5,
-				PortForwardDurationMilliseconds: 5,
-			})
+		clientSeedState.NewClientSeedPortForward(net.ParseIP("10.0.0.1")).UpdateProgress(5, 5, 5)
 
 		// Expect 4 SLOKS: 2 new, and 2 remaining in payload.
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 4 {
@@ -301,7 +261,7 @@ func TestOSL(t *testing.T) {
 
 		rolloverToNextSLOKTime()
 
-		clientSeedState := NewClientSeedState(config, "US", "36F1CF2DF1250BF0C7BA0629CE3DC657")
+		clientSeedState := config.NewClientSeedState("US", "36F1CF2DF1250BF0C7BA0629CE3DC657")
 
 		if len(clientSeedState.GetSeedPayload().SLOKs) != 1 {
 			t.Fatalf("expected 1 SLOKs, got %d", len(clientSeedState.GetSeedPayload().SLOKs))

+ 9 - 1
psiphon/common/protocol.go → psiphon/common/protocol/protocol.go

@@ -17,7 +17,11 @@
  *
  */
 
-package common
+package protocol
+
+import (
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
+)
 
 const (
 	TUNNEL_PROTOCOL_SSH                  = "SSH"
@@ -93,3 +97,7 @@ type HandshakeResponse struct {
 type ConnectedResponse struct {
 	ConnectedTimestamp string `json:"connected_timestamp"`
 }
+
+type StatusResponse struct {
+	SeedPayload *osl.SeedPayload `json:"seed_payload"`
+}

+ 35 - 38
psiphon/common/reloader.go

@@ -20,33 +20,11 @@
 package common
 
 import (
-	"os"
+	"hash/crc64"
+	"io/ioutil"
 	"sync"
 )
 
-// IsFileChanged uses os.Stat to check if the name, size, or last mod time of the
-// file has changed (which is a heuristic, but sufficiently robust for users of this
-// function). Returns nil if file has not changed; otherwise, returns a changed
-// os.FileInfo which may be used to check for subsequent changes.
-func IsFileChanged(path string, previousFileInfo os.FileInfo) (os.FileInfo, error) {
-
-	fileInfo, err := os.Stat(path)
-	if err != nil {
-		return nil, ContextError(err)
-	}
-
-	changed := previousFileInfo == nil ||
-		fileInfo.Name() != previousFileInfo.Name() ||
-		fileInfo.Size() != previousFileInfo.Size() ||
-		fileInfo.ModTime() != previousFileInfo.ModTime()
-
-	if !changed {
-		return nil, nil
-	}
-
-	return fileInfo, nil
-}
-
 // Reloader represents a read-only, in-memory reloadable data object. For example,
 // a JSON data file that is loaded into memory and accessed for read-only lookups;
 // and from time to time may be reloaded from the same file, updating the memory
@@ -71,10 +49,11 @@ type Reloader interface {
 // in other types that add the actual reloadable data structures.
 //
 // ReloadableFile has a multi-reader mutex for synchronization. Its Reload() function
-// will obtain a write lock before reloading the data structures. Actually reloading
-// action is to be provided via the reloadAction callback (for example, read the contents
-// of the file and unmarshall the contents into data structures). All read access to
-// the data structures should be guarded by RLocks on the ReloadableFile mutex.
+// will obtain a write lock before reloading the data structures. The actual reloading
+// action is to be provided via the reloadAction callback, which receives the content
+// of reloaded files and must process the new data (for example, unmarshall the contents
+// into data structures). All read access to the data structures should be guarded by
+// RLocks on the ReloadableFile mutex.
 //
 // reloadAction must ensure that data structures revert to their previous state when
 // a reload fails.
@@ -82,14 +61,14 @@ type Reloader interface {
 type ReloadableFile struct {
 	sync.RWMutex
 	fileName     string
-	fileInfo     os.FileInfo
-	reloadAction func(string) error
+	checksum     uint64
+	reloadAction func([]byte) error
 }
 
 // NewReloadableFile initializes a new ReloadableFile
 func NewReloadableFile(
 	fileName string,
-	reloadAction func(string) error) ReloadableFile {
+	reloadAction func([]byte) error) ReloadableFile {
 
 	return ReloadableFile{
 		fileName:     fileName,
@@ -103,10 +82,23 @@ func (reloadable *ReloadableFile) WillReload() bool {
 	return reloadable.fileName != ""
 }
 
-// Reload checks if the underlying file has changed (using IsFileChanged semantics, which
-// are heuristics) and, when changed, invokes the reloadAction callback which should
-// reload, from the file, the in-memory data structures.
+var crc64table = crc64.MakeTable(crc64.ISO)
+
+// Reload checks if the underlying file has changed and, when changed, invokes
+// the reloadAction callback which should reload the in-memory data structures.
+//
+// In some case (e.g., traffic rules and OSL), there are penalties associated
+// with proceeding with reload, so care is taken to not invoke the reload action
+// unless the contents have changed.
+//
+// The file content is loaded and a checksum is taken to determine whether it
+// has changed. Neither file size (may not change when content changes) nor
+// modified date (may change when identical file is repaved) is a sufficient
+// indicator.
+//
 // All data structure readers should be blocked by the ReloadableFile mutex.
+//
+// Reload must not be called from multiple concurrent goroutines.
 func (reloadable *ReloadableFile) Reload() (bool, error) {
 
 	if !reloadable.WillReload() {
@@ -116,13 +108,18 @@ func (reloadable *ReloadableFile) Reload() (bool, error) {
 	// Check whether the file has changed _before_ blocking readers
 
 	reloadable.RLock()
-	changedFileInfo, err := IsFileChanged(reloadable.fileName, reloadable.fileInfo)
+	fileName := reloadable.fileName
+	previousChecksum := reloadable.checksum
 	reloadable.RUnlock()
+
+	content, err := ioutil.ReadFile(fileName)
 	if err != nil {
 		return false, ContextError(err)
 	}
 
-	if changedFileInfo == nil {
+	checksum := crc64.Checksum(content, crc64table)
+
+	if checksum == previousChecksum {
 		return false, nil
 	}
 
@@ -131,12 +128,12 @@ func (reloadable *ReloadableFile) Reload() (bool, error) {
 	reloadable.Lock()
 	defer reloadable.Unlock()
 
-	err = reloadable.reloadAction(reloadable.fileName)
+	err = reloadable.reloadAction(content)
 	if err != nil {
 		return false, ContextError(err)
 	}
 
-	reloadable.fileInfo = changedFileInfo
+	reloadable.checksum = checksum
 
 	return true, nil
 }

+ 2 - 25
psiphon/common/reloader_test.go

@@ -22,9 +22,7 @@ package common
 import (
 	"bytes"
 	"io/ioutil"
-	"os"
 	"testing"
-	"time"
 )
 
 func TestReloader(t *testing.T) {
@@ -40,12 +38,8 @@ func TestReloader(t *testing.T) {
 
 	file.ReloadableFile = NewReloadableFile(
 		fileName,
-		func(filename string) error {
-			contents, err := ioutil.ReadFile(filename)
-			if err != nil {
-				return err
-			}
-			file.contents = contents
+		func(fileContent []byte) error {
+			file.contents = fileContent
 			return nil
 		})
 
@@ -56,13 +50,6 @@ func TestReloader(t *testing.T) {
 		t.Fatalf("WriteFile failed: %s", err)
 	}
 
-	time.Sleep(2 * time.Second)
-	fileInfo, err := os.Stat(fileName)
-	if err != nil {
-		t.Fatalf("Stat failed: %s", err)
-	}
-	t.Logf("ModTime: %s", fileInfo.ModTime())
-
 	reloaded, err := file.Reload()
 	if err != nil {
 		t.Fatalf("Reload failed: %s", err)
@@ -98,16 +85,6 @@ func TestReloader(t *testing.T) {
 		t.Fatalf("WriteFile failed: %s", err)
 	}
 
-	// TODO: without the sleeps, the os.Stat ModTime doesn't
-	// change and IsFileChanged fails to detect the modification.
-
-	time.Sleep(2 * time.Second)
-	fileInfo, err = os.Stat(fileName)
-	if err != nil {
-		t.Fatalf("Stat failed: %s", err)
-	}
-	t.Logf("ModTime: %s", fileInfo.ModTime())
-
 	reloaded, err = file.Reload()
 	if err != nil {
 		t.Fatalf("Reload failed: %s", err)

+ 8 - 2
psiphon/config.go

@@ -29,6 +29,7 @@ import (
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 // TODO: allow all params to be configured
@@ -393,6 +394,11 @@ type Config struct {
 
 	// RateLimits specify throttling configuration for the tunnel.
 	RateLimits common.RateLimits
+
+	// ReportSLOKs indicates whether to emit notices for each seeded SLOK. As this
+	// could reveal user browsing activity, it's intended for debugging and testing
+	// only.
+	ReportSLOKs bool
 }
 
 // LoadConfig parses and validates a JSON format Psiphon config JSON
@@ -437,7 +443,7 @@ func LoadConfig(configJson []byte) (*Config, error) {
 	}
 
 	if config.TunnelProtocol != "" {
-		if !common.Contains(common.SupportedTunnelProtocols, config.TunnelProtocol) {
+		if !common.Contains(protocol.SupportedTunnelProtocols, config.TunnelProtocol) {
 			return nil, common.ContextError(
 				errors.New("invalid tunnel protocol"))
 		}
@@ -477,7 +483,7 @@ func LoadConfig(configJson []byte) (*Config, error) {
 	}
 
 	if !common.Contains(
-		[]string{"", common.PSIPHON_SSH_API_PROTOCOL, common.PSIPHON_WEB_API_PROTOCOL},
+		[]string{"", protocol.PSIPHON_SSH_API_PROTOCOL, protocol.PSIPHON_WEB_API_PROTOCOL},
 		config.TargetApiProtocol) {
 
 		return nil, common.ContextError(

+ 4 - 2
psiphon/controller.go

@@ -32,6 +32,7 @@ import (
 
 	"github.com/Psiphon-Inc/goarista/monotime"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 // Controller is a tunnel lifecycle coordinator. It manages lists of servers to
@@ -155,6 +156,7 @@ func NewController(config *Config) (controller *Controller, err error) {
 // - a local SOCKS proxy that port forwards through the pool of tunnels
 // - a local HTTP proxy that port forwards through the pool of tunnels
 func (controller *Controller) Run(shutdownBroadcast <-chan struct{}) {
+
 	ReportAvailableRegions()
 
 	// Start components
@@ -677,7 +679,7 @@ func (controller *Controller) classifyImpairedProtocol(failedTunnel *Tunnel) {
 	} else {
 		controller.impairedProtocolClassification[failedTunnel.protocol] = 0
 	}
-	if len(controller.getImpairedProtocols()) == len(common.SupportedTunnelProtocols) {
+	if len(controller.getImpairedProtocols()) == len(protocol.SupportedTunnelProtocols) {
 		// Reset classification if all protocols are classified as impaired as
 		// the network situation (or attack) may not be protocol-specific.
 		// TODO: compare against count of distinct supported protocols for
@@ -1045,7 +1047,7 @@ loop:
 				break
 			}
 
-			if controller.config.TargetApiProtocol == common.PSIPHON_SSH_API_PROTOCOL &&
+			if controller.config.TargetApiProtocol == protocol.PSIPHON_SSH_API_PROTOCOL &&
 				!serverEntry.SupportsSSHAPIRequests() {
 				continue
 			}

+ 13 - 12
psiphon/controller_test.go

@@ -37,6 +37,7 @@ import (
 	"github.com/Psiphon-Inc/goarista/monotime"
 	socks "github.com/Psiphon-Inc/goptlib"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/elazarl/goproxy"
 )
 
@@ -187,7 +188,7 @@ func TestSSH(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_SSH,
+			protocol:                 protocol.TUNNEL_PROTOCOL_SSH,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -204,7 +205,7 @@ func TestObfuscatedSSH(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_OBFUSCATED_SSH,
+			protocol:                 protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -221,7 +222,7 @@ func TestUnfrontedMeek(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_UNFRONTED_MEEK,
+			protocol:                 protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -238,7 +239,7 @@ func TestUnfrontedMeekWithTransformer(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_UNFRONTED_MEEK,
+			protocol:                 protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK,
 			clientIsLatestVersion:    true,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -255,7 +256,7 @@ func TestFrontedMeek(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_FRONTED_MEEK,
+			protocol:                 protocol.TUNNEL_PROTOCOL_FRONTED_MEEK,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -272,7 +273,7 @@ func TestFrontedMeekWithTransformer(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_FRONTED_MEEK,
+			protocol:                 protocol.TUNNEL_PROTOCOL_FRONTED_MEEK,
 			clientIsLatestVersion:    true,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -289,7 +290,7 @@ func TestFrontedMeekHTTP(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP,
+			protocol:                 protocol.TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP,
 			clientIsLatestVersion:    true,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -306,7 +307,7 @@ func TestUnfrontedMeekHTTPS(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
+			protocol:                 protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -323,7 +324,7 @@ func TestUnfrontedMeekHTTPSWithTransformer(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
+			protocol:                 protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
 			clientIsLatestVersion:    true,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -357,7 +358,7 @@ func TestObfuscatedSSHWithUpstreamProxy(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_OBFUSCATED_SSH,
+			protocol:                 protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -374,7 +375,7 @@ func TestUnfrontedMeekWithUpstreamProxy(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_UNFRONTED_MEEK,
+			protocol:                 protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,
@@ -391,7 +392,7 @@ func TestUnfrontedMeekHTTPSWithUpstreamProxy(t *testing.T) {
 	controllerRun(t,
 		&controllerRunConfig{
 			expectNoServerEntries:    false,
-			protocol:                 common.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
+			protocol:                 protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS,
 			clientIsLatestVersion:    false,
 			disableUntunneledUpgrade: true,
 			disableEstablishing:      false,

+ 43 - 1
psiphon/dataStore.go

@@ -33,6 +33,7 @@ import (
 
 	"github.com/Psiphon-Inc/bolt"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 // The BoltDB dataStore implementation is an alternative to the sqlite3-based
@@ -57,6 +58,7 @@ const (
 	urlETagsBucket              = "urlETags"
 	keyValueBucket              = "keyValues"
 	tunnelStatsBucket           = "tunnelStats"
+	slokBucket                  = "SLOKs"
 	rankedServerEntryCount      = 100
 )
 
@@ -103,6 +105,7 @@ func InitDataStore(config *Config) (err error) {
 				urlETagsBucket,
 				keyValueBucket,
 				tunnelStatsBucket,
+				slokBucket,
 			}
 			for _, bucket := range requiredBuckets {
 				_, err := tx.CreateBucketIfNotExists([]byte(bucket))
@@ -398,7 +401,7 @@ func NewServerEntryIterator(config *Config) (iterator *ServerEntryIterator, err
 // newTargetServerEntryIterator is a helper for initializing the TargetServerEntry case
 func newTargetServerEntryIterator(config *Config) (iterator *ServerEntryIterator, err error) {
 	serverEntry, err := DecodeServerEntry(
-		config.TargetServerEntry, common.GetCurrentTimestamp(), common.SERVER_ENTRY_SOURCE_TARGET)
+		config.TargetServerEntry, common.GetCurrentTimestamp(), protocol.SERVER_ENTRY_SOURCE_TARGET)
 	if err != nil {
 		return nil, err
 	}
@@ -995,3 +998,42 @@ func resetAllTunnelStatsToUnreported() error {
 	}
 	return nil
 }
+
+// SetSLOK stores a SLOK key, referenced by its ID. The bool
+// return value indicates whether the SLOK was already stored.
+func SetSLOK(id, key []byte) (bool, error) {
+	checkInitDataStore()
+
+	var duplicate bool
+
+	err := singleton.db.Update(func(tx *bolt.Tx) error {
+		bucket := tx.Bucket([]byte(slokBucket))
+		duplicate = bucket.Get(id) != nil
+		err := bucket.Put([]byte(id), []byte(key))
+		return err
+	})
+
+	if err != nil {
+		return false, common.ContextError(err)
+	}
+
+	return duplicate, nil
+}
+
+// GetSLOK returns a SLOK key for the specified ID. The return
+// value is nil if the SLOK is not found.
+func GetSLOK(id []byte) (key []byte, err error) {
+	checkInitDataStore()
+
+	err = singleton.db.View(func(tx *bolt.Tx) error {
+		bucket := tx.Bucket([]byte(slokBucket))
+		key = bucket.Get(id)
+		return nil
+	})
+
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return key, nil
+}

+ 7 - 0
psiphon/notice.go

@@ -376,9 +376,16 @@ func NoticeRemoteServerListDownloaded(filename string) {
 }
 
 func NoticeClientVerificationRequestCompleted(ipAddress string) {
+	// TODO: remove "Notice" prefix
 	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)
+}
+
 type repetitiveNoticeState struct {
 	message string
 	repeats int

+ 2 - 1
psiphon/remoteServerList.go

@@ -27,6 +27,7 @@ import (
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 // FetchRemoteServerList downloads a remote server list JSON record from
@@ -110,7 +111,7 @@ func FetchRemoteServerList(
 	serverEntries, err := DecodeAndValidateServerEntryList(
 		remoteServerList,
 		common.GetCurrentTimestamp(),
-		common.SERVER_ENTRY_SOURCE_REMOTE)
+		protocol.SERVER_ENTRY_SOURCE_REMOTE)
 	if err != nil {
 		return common.ContextError(err)
 	}

+ 44 - 13
psiphon/server/api.go

@@ -31,6 +31,7 @@ import (
 	"unicode"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 const (
@@ -74,7 +75,7 @@ func sshAPIRequestHandler(
 
 	return dispatchAPIRequestHandler(
 		support,
-		common.PSIPHON_SSH_API_PROTOCOL,
+		protocol.PSIPHON_SSH_API_PROTOCOL,
 		geoIPData,
 		name,
 		params)
@@ -102,13 +103,13 @@ func dispatchAPIRequestHandler(
 	}()
 
 	switch name {
-	case common.PSIPHON_API_HANDSHAKE_REQUEST_NAME:
+	case protocol.PSIPHON_API_HANDSHAKE_REQUEST_NAME:
 		return handshakeAPIRequestHandler(support, apiProtocol, geoIPData, params)
-	case common.PSIPHON_API_CONNECTED_REQUEST_NAME:
+	case protocol.PSIPHON_API_CONNECTED_REQUEST_NAME:
 		return connectedAPIRequestHandler(support, geoIPData, params)
-	case common.PSIPHON_API_STATUS_REQUEST_NAME:
+	case protocol.PSIPHON_API_STATUS_REQUEST_NAME:
 		return statusAPIRequestHandler(support, geoIPData, params)
-	case common.PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME:
+	case protocol.PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME:
 		return clientVerificationAPIRequestHandler(support, geoIPData, params)
 	}
 
@@ -168,7 +169,7 @@ func handshakeAPIRequestHandler(
 
 	// Note: no guarantee that PsinetDatabase won't reload between database calls
 	db := support.PsinetDatabase
-	handshakeResponse := common.HandshakeResponse{
+	handshakeResponse := protocol.HandshakeResponse{
 		Homepages:            db.GetHomepages(sponsorID, geoIPData.Country, isMobile),
 		UpgradeClientVersion: db.GetUpgradeClientVersion(clientVersion, normalizedPlatform),
 		HttpsRequestRegexes:  db.GetHttpsRequestRegexes(sponsorID),
@@ -214,7 +215,7 @@ func connectedAPIRequestHandler(
 			params,
 			connectedRequestParams))
 
-	connectedResponse := common.ConnectedResponse{
+	connectedResponse := protocol.ConnectedResponse{
 		ConnectedTimestamp: common.TruncateTimestampToHour(common.GetCurrentTimestamp()),
 	}
 
@@ -253,6 +254,13 @@ func statusAPIRequestHandler(
 		return nil, common.ContextError(err)
 	}
 
+	// Logs are queued until the input is fully validated. Otherwise, stats
+	// could be double counted if the client has a bug in its request
+	// formatting: partial stats would be logged (counted), the request would
+	// fail, and clients would then resend all the same stats again.
+
+	logQueue := make([]LogFields, 0)
+
 	// Overall bytes transferred stats
 
 	bytesTransferred, err := getInt64RequestParam(statusData, "bytes_transferred")
@@ -262,7 +270,7 @@ func statusAPIRequestHandler(
 	bytesTransferredFields := getRequestLogFields(
 		support, "bytes_transferred", geoIPData, params, statusRequestParams)
 	bytesTransferredFields["bytes"] = bytesTransferred
-	log.LogRawFieldsWithTimestamp(bytesTransferredFields)
+	logQueue = append(logQueue, bytesTransferredFields)
 
 	// Domain bytes transferred stats
 	// Older clients may not submit this data
@@ -278,7 +286,7 @@ func statusAPIRequestHandler(
 		for domain, bytes := range hostBytes {
 			domainBytesFields["domain"] = domain
 			domainBytesFields["bytes"] = bytes
-			log.LogRawFieldsWithTimestamp(domainBytesFields)
+			logQueue = append(logQueue, domainBytesFields)
 		}
 	}
 
@@ -357,11 +365,34 @@ func statusAPIRequestHandler(
 			}
 			sessionFields["total_bytes_received"] = totalBytesReceived
 
-			log.LogRawFieldsWithTimestamp(sessionFields)
+			logQueue = append(logQueue, sessionFields)
 		}
 	}
 
-	return make([]byte, 0), nil
+	// Note: ignoring param format errors as params have been validated
+	sessionID, _ := getStringRequestParam(params, "client_session_id")
+
+	// TODO: in the case of SSH API requests, the actual sshClient could
+	// be passed in and used directly.
+	seedPayload, err := support.TunnelServer.GetClientSeedPayload(sessionID)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	statusResponse := protocol.StatusResponse{
+		SeedPayload: seedPayload,
+	}
+
+	responsePayload, err := json.Marshal(statusResponse)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	for _, logItem := range logQueue {
+		log.LogRawFieldsWithTimestamp(logItem)
+	}
+
+	return responsePayload, nil
 }
 
 // clientVerificationAPIRequestHandler implements the
@@ -773,7 +804,7 @@ func isClientPlatform(_ *SupportServices, value string) bool {
 }
 
 func isRelayProtocol(_ *SupportServices, value string) bool {
-	return common.Contains(common.SupportedTunnelProtocols, value)
+	return common.Contains(protocol.SupportedTunnelProtocols, value)
 }
 
 func isBooleanFlag(_ *SupportServices, value string) bool {
@@ -855,7 +886,7 @@ func isHostHeader(support *SupportServices, value string) bool {
 }
 
 func isServerEntrySource(_ *SupportServices, value string) bool {
-	return common.Contains(common.SupportedServerEntrySources, value)
+	return common.Contains(protocol.SupportedServerEntrySources, value)
 }
 
 var isISO8601DateRegex = regexp.MustCompile(

+ 21 - 17
psiphon/server/config.go

@@ -36,6 +36,7 @@ import (
 	"github.com/Psiphon-Inc/crypto/ssh"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 const (
@@ -67,7 +68,7 @@ type Config struct {
 	// used to determine a unique discovery strategy.
 	DiscoveryValueHMACKey string
 
-	// GeoIPDatabaseFilenames ares paths of GeoIP2/GeoLite2
+	// GeoIPDatabaseFilenames are paths of GeoIP2/GeoLite2
 	// MaxMind database files. When empty, no GeoIP lookups are
 	// performed. Each file is queried, in order, for the
 	// logged fields: country code, city, and ISP. Multiple
@@ -221,10 +222,13 @@ type Config struct {
 	// CPU profiling. For the default, 0, no CPU profile is taken.
 	ProcessCPUProfileDurationSeconds int
 
-	// TrafficRulesFilename is the path of a file containing a
-	// JSON-encoded TrafficRulesSet, the traffic rules to apply to
-	// Psiphon client tunnels.
+	// TrafficRulesFilename is the path of a file containing a JSON-encoded
+	// TrafficRulesSet, the traffic rules to apply to Psiphon client tunnels.
 	TrafficRulesFilename string
+
+	// OSLConfigFilename is the path of a file containing a JSON-encoded
+	// OSL Config, the OSL schemes to apply to Psiphon client tunnels.
+	OSLConfigFilename string
 }
 
 // RunWebServer indicates whether to run a web server component.
@@ -276,11 +280,11 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 	}
 
 	for tunnelProtocol, _ := range config.TunnelProtocolPorts {
-		if !common.Contains(common.SupportedTunnelProtocols, tunnelProtocol) {
+		if !common.Contains(protocol.SupportedTunnelProtocols, tunnelProtocol) {
 			return nil, fmt.Errorf("Unsupported tunnel protocol: %s", tunnelProtocol)
 		}
-		if common.TunnelProtocolUsesSSH(tunnelProtocol) ||
-			common.TunnelProtocolUsesObfuscatedSSH(tunnelProtocol) {
+		if protocol.TunnelProtocolUsesSSH(tunnelProtocol) ||
+			protocol.TunnelProtocolUsesObfuscatedSSH(tunnelProtocol) {
 			if config.SSHPrivateKey == "" || config.SSHServerVersion == "" ||
 				config.SSHUserName == "" || config.SSHPassword == "" {
 				return nil, fmt.Errorf(
@@ -288,22 +292,22 @@ func LoadConfig(configJSON []byte) (*Config, error) {
 					tunnelProtocol)
 			}
 		}
-		if common.TunnelProtocolUsesObfuscatedSSH(tunnelProtocol) {
+		if protocol.TunnelProtocolUsesObfuscatedSSH(tunnelProtocol) {
 			if config.ObfuscatedSSHKey == "" {
 				return nil, fmt.Errorf(
 					"Tunnel protocol %s requires ObfuscatedSSHKey",
 					tunnelProtocol)
 			}
 		}
-		if common.TunnelProtocolUsesMeekHTTP(tunnelProtocol) ||
-			common.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
+		if protocol.TunnelProtocolUsesMeekHTTP(tunnelProtocol) ||
+			protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
 			if config.MeekCookieEncryptionPrivateKey == "" || config.MeekObfuscatedKey == "" {
 				return nil, fmt.Errorf(
 					"Tunnel protocol %s requires MeekCookieEncryptionPrivateKey, MeekObfuscatedKey",
 					tunnelProtocol)
 			}
 		}
-		if common.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
+		if protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
 			if config.MeekCertificateCommonName == "" {
 				return nil, fmt.Errorf(
 					"Tunnel protocol %s requires MeekCertificateCommonName",
@@ -382,9 +386,9 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, error
 
 	usingMeek := false
 
-	for protocol, port := range params.TunnelProtocolPorts {
+	for tunnelProtocol, port := range params.TunnelProtocolPorts {
 
-		if !common.Contains(common.SupportedTunnelProtocols, protocol) {
+		if !common.Contains(protocol.SupportedTunnelProtocols, tunnelProtocol) {
 			return nil, nil, nil, common.ContextError(errors.New("invalid tunnel protocol"))
 		}
 
@@ -393,8 +397,8 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, error
 		}
 		usedPort[port] = true
 
-		if common.TunnelProtocolUsesMeekHTTP(protocol) ||
-			common.TunnelProtocolUsesMeekHTTPS(protocol) {
+		if protocol.TunnelProtocolUsesMeekHTTP(tunnelProtocol) ||
+			protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
 			usingMeek = true
 		}
 	}
@@ -559,11 +563,11 @@ func GenerateConfig(params *GenerateConfigParams) ([]byte, []byte, []byte, error
 	capabilities := []string{}
 
 	if params.EnableSSHAPIRequests {
-		capabilities = append(capabilities, common.CAPABILITY_SSH_API_REQUESTS)
+		capabilities = append(capabilities, protocol.CAPABILITY_SSH_API_REQUESTS)
 	}
 
 	if params.WebServerPort != 0 {
-		capabilities = append(capabilities, common.CAPABILITY_UNTUNNELED_WEB_API_REQUESTS)
+		capabilities = append(capabilities, protocol.CAPABILITY_UNTUNNELED_WEB_API_REQUESTS)
 	}
 
 	for protocol, _ := range params.TunnelProtocolPorts {

+ 8 - 10
psiphon/server/dns.go

@@ -21,9 +21,9 @@ package server
 
 import (
 	"bufio"
+	"bytes"
 	"errors"
 	"net"
-	"os"
 	"strings"
 	"sync/atomic"
 	"time"
@@ -77,9 +77,9 @@ func NewDNSResolver(defaultResolver string) (*DNSResolver, error) {
 
 	dns.ReloadableFile = common.NewReloadableFile(
 		DNS_SYSTEM_CONFIG_FILENAME,
-		func(filename string) error {
+		func(fileContent []byte) error {
 
-			resolver, err := parseResolveConf(filename)
+			resolver, err := parseResolveConf(fileContent)
 			if err != nil {
 				// On error, state remains the same
 				return common.ContextError(err)
@@ -161,14 +161,10 @@ func (dns *DNSResolver) Get() net.IP {
 	return dns.resolver
 }
 
-func parseResolveConf(filename string) (net.IP, error) {
-	file, err := os.Open(filename)
-	if err != nil {
-		return nil, common.ContextError(err)
-	}
-	defer file.Close()
+func parseResolveConf(fileContent []byte) (net.IP, error) {
+
+	scanner := bufio.NewScanner(bytes.NewReader(fileContent))
 
-	scanner := bufio.NewScanner(file)
 	for scanner.Scan() {
 		line := scanner.Text()
 		if strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
@@ -182,9 +178,11 @@ func parseResolveConf(filename string) (net.IP, error) {
 			return parseResolver(fields[1])
 		}
 	}
+
 	if err := scanner.Err(); err != nil {
 		return nil, common.ContextError(err)
 	}
+
 	return nil, common.ContextError(errors.New("nameserver not found"))
 }
 

+ 2 - 2
psiphon/server/geoip.go

@@ -89,8 +89,8 @@ func NewGeoIPService(
 		database := &geoIPDatabase{}
 		database.ReloadableFile = common.NewReloadableFile(
 			filename,
-			func(filename string) error {
-				maxMindReader, err := maxminddb.Open(filename)
+			func(fileContent []byte) error {
+				maxMindReader, err := maxminddb.FromBytes(fileContent)
 				if err != nil {
 					// On error, database state remains the same
 					return common.ContextError(err)

+ 2 - 8
psiphon/server/psinet/psinet.go

@@ -27,7 +27,6 @@ import (
 	"encoding/hex"
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"math"
 	"strconv"
 	"strings"
@@ -126,13 +125,8 @@ func NewDatabase(filename string) (*Database, error) {
 
 	database.ReloadableFile = common.NewReloadableFile(
 		filename,
-		func(filename string) error {
-			psinetJSON, err := ioutil.ReadFile(filename)
-			if err != nil {
-				// On error, state remains the same
-				return common.ContextError(err)
-			}
-			err = json.Unmarshal(psinetJSON, &database)
+		func(fileContent []byte) error {
+			err := json.Unmarshal(fileContent, &database)
 			if err != nil {
 				// On error, state remains the same
 				// (Unmarshal first validates the provided

+ 74 - 0
psiphon/server/server_test.go

@@ -187,11 +187,15 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	trafficRulesFilename := "traffic_rules.json"
 	paveTrafficRulesFile(t, trafficRulesFilename, sponsorID, runConfig.denyTrafficRules)
 
+	oslConfigFilename := "osl_config.json"
+	propagationChannelID := paveOSLConfigFile(t, oslConfigFilename)
+
 	var serverConfig interface{}
 	json.Unmarshal(serverConfigJSON, &serverConfig)
 	serverConfig.(map[string]interface{})["GeoIPDatabaseFilename"] = ""
 	serverConfig.(map[string]interface{})["PsinetDatabaseFilename"] = psinetFilename
 	serverConfig.(map[string]interface{})["TrafficRulesFilename"] = trafficRulesFilename
+	serverConfig.(map[string]interface{})["OSLConfigFilename"] = oslConfigFilename
 	serverConfig.(map[string]interface{})["LogLevel"] = "debug"
 
 	// 1 second is the minimum period; should be small enough to emit a log during the
@@ -274,6 +278,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	clientConfig, _ := psiphon.LoadConfig([]byte(clientConfigJSON))
 
 	clientConfig.SponsorId = sponsorID
+	clientConfig.PropagationChannelId = propagationChannelID
 	clientConfig.ConnectionWorkerPoolSize = numTunnels
 	clientConfig.TunnelPoolSize = numTunnels
 	clientConfig.DisableRemoteServerListFetcher = true
@@ -295,6 +300,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	tunnelsEstablished := make(chan struct{}, 1)
 	homepageReceived := make(chan struct{}, 1)
+	slokSeeded := make(chan struct{}, 1)
 	verificationRequired := make(chan struct{}, 1)
 	verificationCompleted := make(chan struct{}, 1)
 
@@ -324,6 +330,8 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 					t.Fatalf("unexpected homepage: %s", homepageURL)
 				}
 				sendNotificationReceived(homepageReceived)
+			case "SLOKSeeded":
+				sendNotificationReceived(slokSeeded)
 			case "ClientVerificationRequired":
 				sendNotificationReceived(verificationRequired)
 				controller.SetClientVerificationPayloadForActiveTunnels(dummyClientVerificationPayload)
@@ -401,6 +409,10 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			t.Fatalf("tunneled NTP request failed: %s", err)
 		}
 	}
+
+	if !runConfig.denyTrafficRules {
+		waitOnNotification(t, slokSeeded, timeoutSignal, "SLOK seeded timeout exceeded")
+	}
 }
 
 func makeTunneledWebRequest(t *testing.T, localHTTPProxyPort int) error {
@@ -693,3 +705,65 @@ func paveTrafficRulesFile(t *testing.T, trafficRulesFilename, sponsorID string,
 		t.Fatalf("error paving traffic rules file: %s", err)
 	}
 }
+
+func paveOSLConfigFile(t *testing.T, oslConfigFilename string) string {
+
+	oslConfigJSONFormat := `
+    {
+      "Schemes" : [
+        {
+          "Epoch" : "%s",
+          "Regions" : [],
+          "PropagationChannelIDs" : ["%s"],
+          "MasterKey" : "wFuSbqU/pJ/35vRmoM8T9ys1PgDa8uzJps1Y+FNKa5U=",
+          "SeedSpecs" : [
+            {
+              "ID" : "IXHWfVgWFkEKvgqsjmnJuN3FpaGuCzQMETya+DSQvsk=",
+              "UpstreamSubnets" : ["0.0.0.0/32"],
+              "Targets" :
+              {
+                  "BytesRead" : 1,
+                  "BytesWritten" : 1,
+                  "PortForwardDurationNanoseconds" : 1
+              }
+            },
+            {
+              "ID" : "qvpIcORLE2Pi5TZmqRtVkEp+OKov0MhfsYPLNV7FYtI=",
+              "UpstreamSubnets" : ["0.0.0.0/32"],
+              "Targets" :
+              {
+                  "BytesRead" : 1,
+                  "BytesWritten" : 1,
+                  "PortForwardDurationNanoseconds" : 1
+              }
+            }
+          ],
+          "SeedSpecThreshold" : 2,
+          "SeedPeriodNanoseconds" : 1000000,
+          "SeedPeriodKeySplits": [
+            {
+              "Total": 2,
+              "Threshold": 2
+            }
+          ]
+        }
+      ]
+    }
+    `
+
+	propagationChannelID, _ := common.MakeRandomStringHex(8)
+
+	now := time.Now().UTC()
+	epoch := now.Truncate(1 * time.Millisecond)
+	epochStr := epoch.Format(time.RFC3339Nano)
+
+	oslConfigJSON := fmt.Sprintf(
+		oslConfigJSONFormat, epochStr, propagationChannelID)
+
+	err := ioutil.WriteFile(oslConfigFilename, []byte(oslConfigJSON), 0600)
+	if err != nil {
+		t.Fatalf("error paving osl config file: %s", err)
+	}
+
+	return propagationChannelID
+}

+ 28 - 7
psiphon/server/services.go

@@ -34,6 +34,7 @@ import (
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/server/psinet"
 )
 
@@ -163,9 +164,6 @@ loop:
 
 		case <-reloadSupportServicesSignal:
 			supportServices.Reload()
-			// Reset traffic rules for established clients to reflect reloaded config
-			// TODO: only update when traffic rules config has changed
-			tunnelServer.ResetAllClientTrafficRules()
 
 		case <-logServerLoadSignal:
 			// Signal profiles writes first to ensure some diagnostics are
@@ -315,6 +313,7 @@ func logServerLoad(server *TunnelServer) {
 type SupportServices struct {
 	Config          *Config
 	TrafficRulesSet *TrafficRulesSet
+	OSLConfig       *osl.Config
 	PsinetDatabase  *psinet.Database
 	GeoIPService    *GeoIPService
 	DNSResolver     *DNSResolver
@@ -323,11 +322,17 @@ type SupportServices struct {
 
 // NewSupportServices initializes a new SupportServices.
 func NewSupportServices(config *Config) (*SupportServices, error) {
+
 	trafficRulesSet, err := NewTrafficRulesSet(config.TrafficRulesFilename)
 	if err != nil {
 		return nil, common.ContextError(err)
 	}
 
+	oslConfig, err := osl.NewConfig(config.OSLConfigFilename)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
 	psinetDatabase, err := psinet.NewDatabase(config.PsinetDatabaseFilename)
 	if err != nil {
 		return nil, common.ContextError(err)
@@ -347,6 +352,7 @@ func NewSupportServices(config *Config) (*SupportServices, error) {
 	return &SupportServices{
 		Config:          config,
 		TrafficRulesSet: trafficRulesSet,
+		OSLConfig:       oslConfig,
 		PsinetDatabase:  psinetDatabase,
 		GeoIPService:    geoIPService,
 		DNSResolver:     dnsResolver,
@@ -356,15 +362,23 @@ func NewSupportServices(config *Config) (*SupportServices, error) {
 // Reload reinitializes traffic rules, psinet database, and geo IP database
 // components. If any component fails to reload, an error is logged and
 // Reload proceeds, using the previous state of the component.
-//
-// Limitation: reload of traffic rules currently doesn't apply to existing,
-// established clients.
 func (support *SupportServices) Reload() {
 
 	reloaders := append(
-		[]common.Reloader{support.TrafficRulesSet, support.PsinetDatabase},
+		[]common.Reloader{
+			support.TrafficRulesSet,
+			support.OSLConfig,
+			support.PsinetDatabase},
 		support.GeoIPService.Reloaders()...)
 
+	// Take these actions only after the corresponding Reloader has reloaded.
+	// In both the traffic rules and OSL cases, there is some impact from state
+	// reset, so the reset should be avoided where possible.
+	reloadPostActions := map[common.Reloader]func(){
+		support.TrafficRulesSet: func() { support.TunnelServer.ResetAllClientTrafficRules() },
+		support.OSLConfig:       func() { support.TunnelServer.ResetAllClientOSLConfigs() },
+	}
+
 	for _, reloader := range reloaders {
 
 		if !reloader.WillReload() {
@@ -374,6 +388,13 @@ func (support *SupportServices) Reload() {
 
 		// "reloaded" flag indicates if file was actually reloaded or ignored
 		reloaded, err := reloader.Reload()
+
+		if reloaded {
+			if action, ok := reloadPostActions[reloader]; ok {
+				action()
+			}
+		}
+
 		if err != nil {
 			log.WithContextFields(
 				LogFields{

+ 2 - 8
psiphon/server/trafficRules.go

@@ -22,7 +22,6 @@ package server
 import (
 	"encoding/json"
 	"fmt"
-	"io/ioutil"
 	"net"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
@@ -171,14 +170,9 @@ func NewTrafficRulesSet(filename string) (*TrafficRulesSet, error) {
 
 	set.ReloadableFile = common.NewReloadableFile(
 		filename,
-		func(filename string) error {
-			configJSON, err := ioutil.ReadFile(filename)
-			if err != nil {
-				// On error, state remains the same
-				return common.ContextError(err)
-			}
+		func(fileContent []byte) error {
 			var newSet TrafficRulesSet
-			err = json.Unmarshal(configJSON, &newSet)
+			err := json.Unmarshal(fileContent, &newSet)
 			if err != nil {
 				return common.ContextError(err)
 			}

+ 274 - 119
psiphon/server/tunnelServer.go

@@ -35,20 +35,18 @@ import (
 	"github.com/Psiphon-Inc/goarista/monotime"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/osl"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 const (
-	SSH_HANDSHAKE_TIMEOUT                 = 30 * time.Second
-	SSH_CONNECTION_READ_DEADLINE          = 5 * time.Minute
-	SSH_TCP_PORT_FORWARD_DIAL_TIMEOUT     = 30 * time.Second
-	SSH_TCP_PORT_FORWARD_COPY_BUFFER_SIZE = 8192
+	SSH_HANDSHAKE_TIMEOUT                  = 30 * time.Second
+	SSH_CONNECTION_READ_DEADLINE           = 5 * time.Minute
+	SSH_TCP_PORT_FORWARD_IP_LOOKUP_TIMEOUT = 30 * time.Second
+	SSH_TCP_PORT_FORWARD_DIAL_TIMEOUT      = 30 * time.Second
+	SSH_TCP_PORT_FORWARD_COPY_BUFFER_SIZE  = 8192
 )
 
-// Disallowed port forward hosts is a failsafe. The server should
-// be run on a host with correctly configured firewall rules, or
-// containerization, or both.
-var SSH_DISALLOWED_PORT_FORWARD_HOSTS = []string{"localhost", "127.0.0.1"}
-
 // TunnelServer is the main server that accepts Psiphon client
 // connections, via various obfuscation protocols, and provides
 // port forwarding (TCP and UDP) services to the Psiphon client.
@@ -194,11 +192,19 @@ func (server *TunnelServer) GetLoadStats() map[string]map[string]int64 {
 }
 
 // ResetAllClientTrafficRules resets all established client traffic rules
-// to use the latest server config and client state.
+// to use the latest config and client properties. Any existing traffic
+// rule state is lost, including throttling state.
 func (server *TunnelServer) ResetAllClientTrafficRules() {
 	server.sshServer.resetAllClientTrafficRules()
 }
 
+// ResetAllClientOSLConfigs resets all established client OSL state to use
+// the latest OSL config. Any existing OSL state is lost, including partial
+// progress towards SLOKs.
+func (server *TunnelServer) ResetAllClientOSLConfigs() {
+	server.sshServer.resetAllClientOSLConfigs()
+}
+
 // SetClientHandshakeState sets the handshake state -- that it completed and
 // what paramaters were passed -- in sshClient. This state is used for allowing
 // port forwards and for future traffic rule selection. SetClientHandshakeState
@@ -211,6 +217,14 @@ func (server *TunnelServer) SetClientHandshakeState(
 	return server.sshServer.setClientHandshakeState(sessionID, state)
 }
 
+// GetClientSeedPayload gets the current OSL seed payload for the specified
+// client session. Any seeded SLOKs are issued and included in the payload.
+func (server *TunnelServer) GetClientSeedPayload(
+	sessionID string) (*osl.SeedPayload, error) {
+
+	return server.sshServer.getClientSeedPayload(sessionID)
+}
+
 // SetEstablishTunnels sets whether new tunnels may be established or not.
 // When not establishing, incoming connections are immediately closed.
 func (server *TunnelServer) SetEstablishTunnels(establish bool) {
@@ -310,13 +324,13 @@ func (sshServer *sshServer) runListener(
 	// TunnelServer.Run will properly shut down instead of remaining
 	// running.
 
-	if common.TunnelProtocolUsesMeekHTTP(tunnelProtocol) ||
-		common.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
+	if protocol.TunnelProtocolUsesMeekHTTP(tunnelProtocol) ||
+		protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol) {
 
 		meekServer, err := NewMeekServer(
 			sshServer.support,
 			listener,
-			common.TunnelProtocolUsesMeekHTTPS(tunnelProtocol),
+			protocol.TunnelProtocolUsesMeekHTTPS(tunnelProtocol),
 			handleClient,
 			sshServer.shutdownBroadcast)
 		if err != nil {
@@ -511,6 +525,20 @@ func (sshServer *sshServer) resetAllClientTrafficRules() {
 	}
 }
 
+func (sshServer *sshServer) resetAllClientOSLConfigs() {
+
+	sshServer.clientsMutex.Lock()
+	clients := make(map[string]*sshClient)
+	for sessionID, client := range sshServer.clients {
+		clients[sessionID] = client
+	}
+	sshServer.clientsMutex.Unlock()
+
+	for _, client := range clients {
+		client.setOSLConfig()
+	}
+}
+
 func (sshServer *sshServer) setClientHandshakeState(
 	sessionID string, state handshakeState) error {
 
@@ -527,11 +555,23 @@ func (sshServer *sshServer) setClientHandshakeState(
 		return common.ContextError(err)
 	}
 
-	client.setTrafficRules()
-
 	return nil
 }
 
+func (sshServer *sshServer) getClientSeedPayload(
+	sessionID string) (*osl.SeedPayload, error) {
+
+	sshServer.clientsMutex.Lock()
+	client := sshServer.clients[sessionID]
+	sshServer.clientsMutex.Unlock()
+
+	if client == nil {
+		return nil, common.ContextError(errors.New("unknown session ID"))
+	}
+
+	return client.getClientSeedPayload(), nil
+}
+
 func (sshServer *sshServer) stopClients() {
 
 	sshServer.clientsMutex.Lock()
@@ -555,6 +595,70 @@ func (sshServer *sshServer) handleClient(tunnelProtocol string, clientConn net.C
 
 	sshClient := newSshClient(sshServer, tunnelProtocol, geoIPData)
 
+	sshClient.run(clientConn)
+}
+
+type sshClient struct {
+	sync.Mutex
+	sshServer               *sshServer
+	tunnelProtocol          string
+	sshConn                 ssh.Conn
+	activityConn            *common.ActivityMonitoredConn
+	throttledConn           *common.ThrottledConn
+	geoIPData               GeoIPData
+	sessionID               string
+	handshakeState          handshakeState
+	udpChannel              ssh.Channel
+	trafficRules            TrafficRules
+	tcpTrafficState         trafficState
+	udpTrafficState         trafficState
+	qualityMetrics          qualityMetrics
+	channelHandlerWaitGroup *sync.WaitGroup
+	tcpPortForwardLRU       *common.LRUConns
+	oslClientSeedState      *osl.ClientSeedState
+	stopBroadcast           chan struct{}
+}
+
+type trafficState struct {
+	bytesUp                        int64
+	bytesDown                      int64
+	concurrentPortForwardCount     int64
+	peakConcurrentPortForwardCount int64
+	totalPortForwardCount          int64
+}
+
+// qualityMetrics records upstream TCP dial attempts and
+// elapsed time. Elapsed time includes the full TCP handshake
+// and, in aggregate, is a measure of the quality of the
+// upstream link. These stats are recorded by each sshClient
+// and then reported and reset in sshServer.getLoadStats().
+type qualityMetrics struct {
+	tcpPortForwardDialedCount    int64
+	tcpPortForwardDialedDuration time.Duration
+	tcpPortForwardFailedCount    int64
+	tcpPortForwardFailedDuration time.Duration
+}
+
+type handshakeState struct {
+	completed   bool
+	apiProtocol string
+	apiParams   requestJSONObject
+}
+
+func newSshClient(
+	sshServer *sshServer, tunnelProtocol string, geoIPData GeoIPData) *sshClient {
+	return &sshClient{
+		sshServer:               sshServer,
+		tunnelProtocol:          tunnelProtocol,
+		geoIPData:               geoIPData,
+		channelHandlerWaitGroup: new(sync.WaitGroup),
+		tcpPortForwardLRU:       common.NewLRUConns(),
+		stopBroadcast:           make(chan struct{}),
+	}
+}
+
+func (sshClient *sshClient) run(clientConn net.Conn) {
+
 	// Set initial traffic rules, pre-handshake, based on currently known info.
 	sshClient.setTrafficRules()
 
@@ -569,6 +673,7 @@ func (sshServer *sshServer) handleClient(tunnelProtocol string, clientConn net.C
 		clientConn,
 		SSH_CONNECTION_READ_DEADLINE,
 		false,
+		nil,
 		nil)
 	if err != nil {
 		clientConn.Close()
@@ -607,21 +712,21 @@ func (sshServer *sshServer) handleClient(tunnelProtocol string, clientConn net.C
 		sshServerConfig := &ssh.ServerConfig{
 			PasswordCallback: sshClient.passwordCallback,
 			AuthLogCallback:  sshClient.authLogCallback,
-			ServerVersion:    sshServer.support.Config.SSHServerVersion,
+			ServerVersion:    sshClient.sshServer.support.Config.SSHServerVersion,
 		}
-		sshServerConfig.AddHostKey(sshServer.sshHostKey)
+		sshServerConfig.AddHostKey(sshClient.sshServer.sshHostKey)
 
 		result := &sshNewServerConnResult{}
 
 		// Wrap the connection in an SSH deobfuscator when required.
 
-		if common.TunnelProtocolUsesObfuscatedSSH(tunnelProtocol) {
+		if protocol.TunnelProtocolUsesObfuscatedSSH(sshClient.tunnelProtocol) {
 			// Note: NewObfuscatedSshConn blocks on network I/O
 			// TODO: ensure this won't block shutdown
 			conn, result.err = psiphon.NewObfuscatedSshConn(
 				psiphon.OBFUSCATION_CONN_MODE_SERVER,
 				conn,
-				sshServer.support.Config.ObfuscatedSSHKey)
+				sshClient.sshServer.support.Config.ObfuscatedSSHKey)
 			if result.err != nil {
 				result.err = common.ContextError(result.err)
 			}
@@ -639,7 +744,7 @@ func (sshServer *sshServer) handleClient(tunnelProtocol string, clientConn net.C
 	var result *sshNewServerConnResult
 	select {
 	case result = <-resultChannel:
-	case <-sshServer.shutdownBroadcast:
+	case <-sshClient.sshServer.shutdownBroadcast:
 		// Close() will interrupt an ongoing handshake
 		// TODO: wait for goroutine to exit before returning?
 		clientConn.Close()
@@ -661,80 +766,22 @@ func (sshServer *sshServer) handleClient(tunnelProtocol string, clientConn net.C
 	sshClient.throttledConn = throttledConn
 	sshClient.Unlock()
 
-	if !sshServer.registerEstablishedClient(sshClient) {
+	if !sshClient.sshServer.registerEstablishedClient(sshClient) {
 		clientConn.Close()
 		log.WithContext().Warning("register failed")
 		return
 	}
-	defer sshServer.unregisterEstablishedClient(sshClient.sessionID)
+	defer sshClient.sshServer.unregisterEstablishedClient(sshClient.sessionID)
 
-	sshClient.runClient(result.channels, result.requests)
+	sshClient.runTunnel(result.channels, result.requests)
 
-	// Note: sshServer.unregisterClient calls sshClient.Close(),
+	// Note: sshServer.unregisterEstablishedClient calls sshClient.Close(),
 	// which also closes underlying transport Conn.
 }
 
-type sshClient struct {
-	sync.Mutex
-	sshServer               *sshServer
-	tunnelProtocol          string
-	sshConn                 ssh.Conn
-	activityConn            *common.ActivityMonitoredConn
-	throttledConn           *common.ThrottledConn
-	geoIPData               GeoIPData
-	sessionID               string
-	handshakeState          handshakeState
-	udpChannel              ssh.Channel
-	trafficRules            TrafficRules
-	tcpTrafficState         trafficState
-	udpTrafficState         trafficState
-	qualityMetrics          qualityMetrics
-	channelHandlerWaitGroup *sync.WaitGroup
-	tcpPortForwardLRU       *common.LRUConns
-	stopBroadcast           chan struct{}
-}
-
-type trafficState struct {
-	bytesUp                        int64
-	bytesDown                      int64
-	concurrentPortForwardCount     int64
-	peakConcurrentPortForwardCount int64
-	totalPortForwardCount          int64
-}
-
-// qualityMetrics records upstream TCP dial attempts and
-// elapsed time. Elapsed time includes the full TCP handshake
-// and, in aggregate, is a measure of the quality of the
-// upstream link. These stats are recorded by each sshClient
-// and then reported and reset in sshServer.getLoadStats().
-type qualityMetrics struct {
-	tcpPortForwardDialedCount    int64
-	tcpPortForwardDialedDuration time.Duration
-	tcpPortForwardFailedCount    int64
-	tcpPortForwardFailedDuration time.Duration
-}
-
-type handshakeState struct {
-	completed   bool
-	apiProtocol string
-	apiParams   requestJSONObject
-}
-
-func newSshClient(
-	sshServer *sshServer, tunnelProtocol string, geoIPData GeoIPData) *sshClient {
-	return &sshClient{
-		sshServer:               sshServer,
-		tunnelProtocol:          tunnelProtocol,
-		geoIPData:               geoIPData,
-		channelHandlerWaitGroup: new(sync.WaitGroup),
-		tcpPortForwardLRU:       common.NewLRUConns(),
-		stopBroadcast:           make(chan struct{}),
-	}
-}
-
 func (sshClient *sshClient) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
 
-	expectedSessionIDLength := 2 * common.PSIPHON_API_CLIENT_SESSION_ID_LENGTH
+	expectedSessionIDLength := 2 * protocol.PSIPHON_API_CLIENT_SESSION_ID_LENGTH
 	expectedSSHPasswordLength := 2 * SSH_PASSWORD_BYTE_LENGTH
 
 	var sshPasswordPayload struct {
@@ -867,10 +914,10 @@ func (sshClient *sshClient) stop() {
 	log.LogRawFieldsWithTimestamp(logFields)
 }
 
-// runClient handles/dispatches new channel and new requests from the client.
+// runTunnel handles/dispatches new channel and new requests from the client.
 // When the SSH client connection closes, both the channels and requests channels
 // will close and runClient will exit.
-func (sshClient *sshClient) runClient(
+func (sshClient *sshClient) runTunnel(
 	channels <-chan ssh.NewChannel, requests <-chan *ssh.Request) {
 
 	requestsWaitGroup := new(sync.WaitGroup)
@@ -975,22 +1022,28 @@ func (sshClient *sshClient) handleNewPortForwardChannel(newChannel ssh.NewChanne
 // handshake parameters are included in the session summary log recorded in
 // sshClient.stop().
 func (sshClient *sshClient) setHandshakeState(state handshakeState) error {
+
 	sshClient.Lock()
-	defer sshClient.Unlock()
+	completed := sshClient.handshakeState.completed
+	if !completed {
+		sshClient.handshakeState = state
+	}
+	sshClient.Unlock()
 
 	// Client must only perform one handshake
-	if sshClient.handshakeState.completed {
+	if completed {
 		return common.ContextError(errors.New("handshake already completed"))
 	}
 
-	sshClient.handshakeState = state
+	sshClient.setTrafficRules()
+	sshClient.setOSLConfig()
 
 	return nil
 }
 
 // setTrafficRules resets the client's traffic rules based on the latest server config
-// and client state. As sshClient.trafficRules may be reset by a concurrent goroutine,
-// trafficRules must only be accessed within the sshClient mutex.
+// and client properties. As sshClient.trafficRules may be reset by a concurrent
+// goroutine, trafficRules must only be accessed within the sshClient mutex.
 func (sshClient *sshClient) setTrafficRules() {
 	sshClient.Lock()
 	defer sshClient.Unlock()
@@ -999,11 +1052,68 @@ func (sshClient *sshClient) setTrafficRules() {
 		sshClient.tunnelProtocol, sshClient.geoIPData, sshClient.handshakeState)
 
 	if sshClient.throttledConn != nil {
+		// Any existing throttling state is reset.
 		sshClient.throttledConn.SetLimits(
 			sshClient.trafficRules.RateLimits.CommonRateLimits())
 	}
 }
 
+// setOSLConfig resets the client's OSL seed state based on the latest OSL config
+// As sshClient.oslClientSeedState may be reset by a concurrent goroutine,
+// oslClientSeedState must only be accessed within the sshClient mutex.
+func (sshClient *sshClient) setOSLConfig() {
+	sshClient.Lock()
+	defer sshClient.Unlock()
+
+	propagationChannelID, err := getStringRequestParam(
+		sshClient.handshakeState.apiParams, "propagation_channel_id")
+	if err != nil {
+		// This should not fail as long as client has sent valid handshake
+		return
+	}
+
+	// Two limitations when setOSLConfig() is invoked due to an
+	// OSL config hot reload:
+	//
+	// 1. any partial progress towards SLOKs is lost.
+	//
+	// 2. all existing osl.ClientSeedPortForwards for existing
+	//    port forwards will not send progress to the new client
+	//    seed state.
+
+	sshClient.oslClientSeedState = sshClient.sshServer.support.OSLConfig.NewClientSeedState(
+		sshClient.geoIPData.Country,
+		propagationChannelID)
+}
+
+// newClientSeedPortForward will return nil when no seeding is
+// associated with the specified ipAddress.
+func (sshClient *sshClient) newClientSeedPortForward(ipAddress net.IP) *osl.ClientSeedPortForward {
+	sshClient.Lock()
+	defer sshClient.Unlock()
+
+	// Will not be initialized before handshake.
+	if sshClient.oslClientSeedState == nil {
+		return nil
+	}
+
+	return sshClient.oslClientSeedState.NewClientSeedPortForward(ipAddress)
+}
+
+// getClientSeedPayload returns a payload containing all seeded SLOKs for
+// this client's session.
+func (sshClient *sshClient) getClientSeedPayload() *osl.SeedPayload {
+	sshClient.Lock()
+	defer sshClient.Unlock()
+
+	// Will not be initialized before handshake.
+	if sshClient.oslClientSeedState == nil {
+		return &osl.SeedPayload{SLOKs: make([]*osl.SLOK, 0)}
+	}
+
+	return sshClient.oslClientSeedState.GetSeedPayload()
+}
+
 func (sshClient *sshClient) rateLimits() common.RateLimits {
 	sshClient.Lock()
 	defer sshClient.Unlock()
@@ -1032,7 +1142,7 @@ const (
 )
 
 func (sshClient *sshClient) isPortForwardPermitted(
-	portForwardType int, host string, port int) bool {
+	portForwardType int, remoteIP net.IP, port int) bool {
 
 	sshClient.Lock()
 	defer sshClient.Unlock()
@@ -1041,7 +1151,9 @@ func (sshClient *sshClient) isPortForwardPermitted(
 		return false
 	}
 
-	if common.Contains(SSH_DISALLOWED_PORT_FORWARD_HOSTS, host) {
+	// Disallow connection to loopback. This is a failsafe. The server
+	// should be run on a host with correctly configured firewall rules.
+	if remoteIP.IsLoopback() {
 		return false
 	}
 
@@ -1065,17 +1177,11 @@ func (sshClient *sshClient) isPortForwardPermitted(
 		}
 	}
 
-	// TODO: AllowSubnets won't match when host is a domain.
-	// Callers should resolve domain host before checking
-	// isPortForwardPermitted.
-
-	if ip := net.ParseIP(host); ip != nil {
-		for _, subnet := range sshClient.trafficRules.AllowSubnets {
-			// Note: ignoring error as config has been validated
-			_, network, _ := net.ParseCIDR(subnet)
-			if network.Contains(ip) {
-				return true
-			}
+	for _, subnet := range sshClient.trafficRules.AllowSubnets {
+		// Note: ignoring error as config has been validated
+		_, network, _ := net.ParseCIDR(subnet)
+		if network.Contains(remoteIP) {
+			return true
 		}
 	}
 
@@ -1179,8 +1285,48 @@ func (sshClient *sshClient) handleTCPChannel(
 		}
 	}
 
-	if !isWebServerPortForward && !sshClient.isPortForwardPermitted(
-		portForwardTypeTCP, hostToConnect, portToConnect) {
+	type lookupIPResult struct {
+		IP  net.IP
+		err error
+	}
+	lookupResultChannel := make(chan *lookupIPResult, 1)
+
+	go func() {
+		// TODO: explicit timeout for DNS resolution?
+		IPs, err := net.LookupIP(hostToConnect)
+		// TODO: shuffle list to try other IPs
+		// TODO: IPv6 support
+		var IP net.IP
+		for _, ip := range IPs {
+			if ip.To4() != nil {
+				IP = ip
+			}
+		}
+		if err == nil && IP == nil {
+			err = errors.New("no IP address")
+		}
+		lookupResultChannel <- &lookupIPResult{IP, err}
+	}()
+
+	var lookupResult *lookupIPResult
+	select {
+	case lookupResult = <-lookupResultChannel:
+	case <-sshClient.stopBroadcast:
+		// Note: may leave LookupIP in progress
+		return
+	}
+
+	if lookupResult.err != nil {
+		sshClient.rejectNewChannel(
+			newChannel, ssh.ConnectionFailed, fmt.Sprintf("LookupIP failed: %s", lookupResult.err))
+		return
+	}
+
+	if !isWebServerPortForward &&
+		!sshClient.isPortForwardPermitted(
+			portForwardTypeTCP,
+			lookupResult.IP,
+			portToConnect) {
 
 		sshClient.rejectNewChannel(
 			newChannel, ssh.Prohibited, "port forward not permitted")
@@ -1239,46 +1385,47 @@ func (sshClient *sshClient) handleTCPChannel(
 	// Dial the target remote address. This is done in a goroutine to
 	// ensure the shutdown signal is handled immediately.
 
-	remoteAddr := fmt.Sprintf("%s:%d", hostToConnect, portToConnect)
+	remoteAddr := net.JoinHostPort(lookupResult.IP.String(), strconv.Itoa(portToConnect))
 
 	log.WithContextFields(LogFields{"remoteAddr": remoteAddr}).Debug("dialing")
 
-	type dialTcpResult struct {
+	type dialTCPResult struct {
 		conn net.Conn
 		err  error
 	}
+	dialResultChannel := make(chan *dialTCPResult, 1)
 
-	resultChannel := make(chan *dialTcpResult, 1)
 	dialStartTime := monotime.Now()
 
 	go func() {
 		// TODO: on EADDRNOTAVAIL, temporarily suspend new clients
-		// TODO: IPv6 support
 		conn, err := net.DialTimeout(
-			"tcp4", remoteAddr, SSH_TCP_PORT_FORWARD_DIAL_TIMEOUT)
-		resultChannel <- &dialTcpResult{conn, err}
+			"tcp", remoteAddr, SSH_TCP_PORT_FORWARD_DIAL_TIMEOUT)
+		dialResultChannel <- &dialTCPResult{conn, err}
 	}()
 
-	var result *dialTcpResult
+	var dialResult *dialTCPResult
 	select {
-	case result = <-resultChannel:
+	case dialResult = <-dialResultChannel:
 	case <-sshClient.stopBroadcast:
-		// Note: may leave dial in progress (TODO: use DialContext to cancel)
+		// Note: may leave Dial in progress
+		// TODO: use net.Dialer.DialContext to be able to cancel
 		return
 	}
 
 	sshClient.updateQualityMetrics(
-		result.err == nil, monotime.Since(dialStartTime))
+		dialResult.err == nil, monotime.Since(dialStartTime))
 
-	if result.err != nil {
-		sshClient.rejectNewChannel(newChannel, ssh.ConnectionFailed, result.err.Error())
+	if dialResult.err != nil {
+		sshClient.rejectNewChannel(
+			newChannel, ssh.ConnectionFailed, fmt.Sprintf("DialTimeout failed: %s", dialResult.err))
 		return
 	}
 
 	// The upstream TCP port forward connection has been established. Schedule
 	// some cleanup and notify the SSH client that the channel is accepted.
 
-	fwdConn := result.conn
+	fwdConn := dialResult.conn
 	defer fwdConn.Close()
 
 	fwdChannel, requests, err := newChannel.Accept()
@@ -1297,12 +1444,20 @@ func (sshClient *sshClient) handleTCPChannel(
 	lruEntry := sshClient.tcpPortForwardLRU.Add(fwdConn)
 	defer lruEntry.Remove()
 
+	// Ensure nil interface if newClientSeedPortForward returns nil
+	var updater common.ActivityUpdater
+	seedUpdater := sshClient.newClientSeedPortForward(lookupResult.IP)
+	if seedUpdater != nil {
+		updater = seedUpdater
+	}
+
 	fwdConn, err = common.NewActivityMonitoredConn(
 		fwdConn,
 		sshClient.idleTCPPortForwardTimeout(),
 		true,
+		updater,
 		lruEntry)
-	if result.err != nil {
+	if err != nil {
 		log.WithContextFields(LogFields{"error": err}).Error("NewActivityMonitoredConn failed")
 		return
 	}

+ 9 - 1
psiphon/server/udp.go

@@ -163,7 +163,7 @@ func (mux *udpPortForwardMultiplexer) run() {
 			}
 
 			if !mux.sshClient.isPortForwardPermitted(
-				portForwardTypeUDP, dialIP.String(), int(message.remotePort)) {
+				portForwardTypeUDP, dialIP, int(message.remotePort)) {
 				// The udpgw protocol has no error response, so
 				// we just discard the message and read another.
 				continue
@@ -211,10 +211,18 @@ func (mux *udpPortForwardMultiplexer) run() {
 
 			lruEntry := mux.portForwardLRU.Add(udpConn)
 
+			// Ensure nil interface if newClientSeedPortForward returns nil
+			var updater common.ActivityUpdater
+			seedUpdater := mux.sshClient.newClientSeedPortForward(dialIP)
+			if seedUpdater != nil {
+				updater = seedUpdater
+			}
+
 			conn, err := common.NewActivityMonitoredConn(
 				udpConn,
 				mux.sshClient.idleUDPPortForwardTimeout(),
 				true,
+				updater,
 				lruEntry)
 			if err != nil {
 				lruEntry.Remove()

+ 9 - 8
psiphon/server/webServer.go

@@ -31,6 +31,7 @@ import (
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 const WEB_SERVER_IO_TIMEOUT = 10 * time.Second
@@ -234,9 +235,9 @@ func (webServer *webServer) handshakeHandler(w http.ResponseWriter, r *http.Requ
 	if err == nil {
 		responsePayload, err = dispatchAPIRequestHandler(
 			webServer.support,
-			common.PSIPHON_WEB_API_PROTOCOL,
+			protocol.PSIPHON_WEB_API_PROTOCOL,
 			webServer.lookupGeoIPData(params),
-			common.PSIPHON_API_HANDSHAKE_REQUEST_NAME,
+			protocol.PSIPHON_API_HANDSHAKE_REQUEST_NAME,
 			params)
 	}
 
@@ -264,9 +265,9 @@ func (webServer *webServer) connectedHandler(w http.ResponseWriter, r *http.Requ
 	if err == nil {
 		responsePayload, err = dispatchAPIRequestHandler(
 			webServer.support,
-			common.PSIPHON_WEB_API_PROTOCOL,
+			protocol.PSIPHON_WEB_API_PROTOCOL,
 			webServer.lookupGeoIPData(params),
-			common.PSIPHON_API_CONNECTED_REQUEST_NAME,
+			protocol.PSIPHON_API_CONNECTED_REQUEST_NAME,
 			params)
 	}
 
@@ -287,9 +288,9 @@ func (webServer *webServer) statusHandler(w http.ResponseWriter, r *http.Request
 	if err == nil {
 		_, err = dispatchAPIRequestHandler(
 			webServer.support,
-			common.PSIPHON_WEB_API_PROTOCOL,
+			protocol.PSIPHON_WEB_API_PROTOCOL,
 			webServer.lookupGeoIPData(params),
-			common.PSIPHON_API_STATUS_REQUEST_NAME,
+			protocol.PSIPHON_API_STATUS_REQUEST_NAME,
 			params)
 	}
 
@@ -310,9 +311,9 @@ func (webServer *webServer) clientVerificationHandler(w http.ResponseWriter, r *
 	if err == nil {
 		responsePayload, err = dispatchAPIRequestHandler(
 			webServer.support,
-			common.PSIPHON_WEB_API_PROTOCOL,
+			protocol.PSIPHON_WEB_API_PROTOCOL,
 			webServer.lookupGeoIPData(params),
-			common.PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME,
+			protocol.PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME,
 			params)
 	}
 

+ 38 - 12
psiphon/serverApi.go

@@ -36,6 +36,7 @@ import (
 	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/transferstats"
 )
 
@@ -72,7 +73,7 @@ var nextTunnelNumber int64
 // Controller (e.g., the user's commanded start and stop) and we measure this
 // duration as well as the duration of each tunnel within the session.
 func MakeSessionId() (sessionId string, err error) {
-	randomId, err := common.MakeSecureRandomBytes(common.PSIPHON_API_CLIENT_SESSION_ID_LENGTH)
+	randomId, err := common.MakeSecureRandomBytes(protocol.PSIPHON_API_CLIENT_SESSION_ID_LENGTH)
 	if err != nil {
 		return "", common.ContextError(err)
 	}
@@ -88,7 +89,7 @@ func NewServerContext(tunnel *Tunnel, sessionId string) (*ServerContext, error)
 	// accessing the Psiphon API via the web service.
 	var psiphonHttpsClient *http.Client
 	if !tunnel.serverEntry.SupportsSSHAPIRequests() ||
-		tunnel.config.TargetApiProtocol == common.PSIPHON_WEB_API_PROTOCOL {
+		tunnel.config.TargetApiProtocol == protocol.PSIPHON_WEB_API_PROTOCOL {
 
 		var err error
 		psiphonHttpsClient, err = makePsiphonHttpsClient(tunnel)
@@ -142,7 +143,7 @@ func (serverContext *ServerContext) doHandshakeRequest() error {
 		}
 
 		response, err = serverContext.tunnel.SendAPIRequest(
-			common.PSIPHON_API_HANDSHAKE_REQUEST_NAME, request)
+			protocol.PSIPHON_API_HANDSHAKE_REQUEST_NAME, request)
 		if err != nil {
 			return common.ContextError(err)
 		}
@@ -173,7 +174,7 @@ func (serverContext *ServerContext) doHandshakeRequest() error {
 	// - 'preemptive_reconnect_lifetime_milliseconds' is unused and ignored
 	// - 'ssh_session_id' is ignored; client session ID is used instead
 
-	var handshakeResponse common.HandshakeResponse
+	var handshakeResponse protocol.HandshakeResponse
 	err := json.Unmarshal(response, &handshakeResponse)
 	if err != nil {
 		return common.ContextError(err)
@@ -192,7 +193,7 @@ func (serverContext *ServerContext) doHandshakeRequest() error {
 		serverEntry, err := DecodeServerEntry(
 			encodedServerEntry,
 			common.TruncateTimestampToHour(handshakeResponse.ServerTimestamp),
-			common.SERVER_ENTRY_SOURCE_DISCOVERY)
+			protocol.SERVER_ENTRY_SOURCE_DISCOVERY)
 		if err != nil {
 			return common.ContextError(err)
 		}
@@ -271,7 +272,7 @@ func (serverContext *ServerContext) DoConnectedRequest() error {
 		}
 
 		response, err = serverContext.tunnel.SendAPIRequest(
-			common.PSIPHON_API_CONNECTED_REQUEST_NAME, request)
+			protocol.PSIPHON_API_CONNECTED_REQUEST_NAME, request)
 		if err != nil {
 			return common.ContextError(err)
 		}
@@ -287,7 +288,7 @@ func (serverContext *ServerContext) DoConnectedRequest() error {
 		}
 	}
 
-	var connectedResponse common.ConnectedResponse
+	var connectedResponse protocol.ConnectedResponse
 	err = json.Unmarshal(response, &connectedResponse)
 	if err != nil {
 		return common.ContextError(err)
@@ -298,6 +299,7 @@ func (serverContext *ServerContext) DoConnectedRequest() error {
 	if err != nil {
 		return common.ContextError(err)
 	}
+
 	return nil
 }
 
@@ -320,6 +322,7 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 		return common.ContextError(err)
 	}
 
+	var response []byte
 	if serverContext.psiphonHttpsClient == nil {
 
 		rawMessage := json.RawMessage(statusPayload)
@@ -329,14 +332,14 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 		request, err = makeSSHAPIRequestPayload(params)
 
 		if err == nil {
-			_, err = serverContext.tunnel.SendAPIRequest(
-				common.PSIPHON_API_STATUS_REQUEST_NAME, request)
+			response, err = serverContext.tunnel.SendAPIRequest(
+				protocol.PSIPHON_API_STATUS_REQUEST_NAME, request)
 		}
 
 	} else {
 
 		// Legacy web service API request
-		_, err = serverContext.doPostRequest(
+		response, err = serverContext.doPostRequest(
 			makeRequestUrl(serverContext.tunnel, "", "status", params),
 			"application/json",
 			bytes.NewReader(statusPayload))
@@ -354,6 +357,29 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 
 	confirmStatusRequestPayload(statusPayloadInfo)
 
+	var statusResponse protocol.StatusResponse
+	err = json.Unmarshal(response, &statusResponse)
+	if err != nil {
+		return common.ContextError(err)
+	}
+
+	for _, slok := range statusResponse.SeedPayload.SLOKs {
+		duplicate, err := SetSLOK(slok.ID, slok.Key)
+		if err != nil {
+
+			NoticeAlert("SetSLOK failed: %s", common.ContextError(err))
+
+			// Proceed with next SLOK. Also, no immediate retry.
+			// For an ongoing session, another status request will occur within
+			// PSIPHON_API_STATUS_REQUEST_PERIOD_MIN/MAX and the server will
+			// resend the same SLOKs, giving another opportunity to store.
+		}
+
+		if tunnel.config.ReportSLOKs {
+			NoticeSLOKSeeded(base64.StdEncoding.EncodeToString(slok.ID), duplicate)
+		}
+	}
+
 	return nil
 }
 
@@ -368,7 +394,7 @@ func (serverContext *ServerContext) getStatusParams(isTunneled bool) requestJSON
 
 	randomPadding, err := common.MakeSecureRandomPadding(0, PSIPHON_API_STATUS_REQUEST_PADDING_MAX_BYTES)
 	if err != nil {
-		NoticeAlert("MakeSecureRandomPadding failed: %s", err)
+		NoticeAlert("MakeSecureRandomPadding failed: %s", common.ContextError(err))
 		// Proceed without random padding
 		randomPadding = make([]byte, 0)
 	}
@@ -660,7 +686,7 @@ func (serverContext *ServerContext) DoClientVerificationRequest(
 		}
 
 		response, err = serverContext.tunnel.SendAPIRequest(
-			common.PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME, request)
+			protocol.PSIPHON_API_CLIENT_VERIFICATION_REQUEST_NAME, request)
 		if err != nil {
 			return common.ContextError(err)
 		}

+ 5 - 4
psiphon/serverEntry.go

@@ -29,6 +29,7 @@ import (
 	"strings"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 // ServerEntry represents a Psiphon server. It contains information
@@ -82,7 +83,7 @@ func (serverEntry *ServerEntry) SupportsProtocol(protocol string) bool {
 // by the ServerEntry's capabilities.
 func (serverEntry *ServerEntry) GetSupportedProtocols() []string {
 	supportedProtocols := make([]string, 0)
-	for _, protocol := range common.SupportedTunnelProtocols {
+	for _, protocol := range protocol.SupportedTunnelProtocols {
 		if serverEntry.SupportsProtocol(protocol) {
 			supportedProtocols = append(supportedProtocols, protocol)
 		}
@@ -114,16 +115,16 @@ func (serverEntry *ServerEntry) DisableImpairedProtocols(impairedProtocols []str
 // SupportsSSHAPIRequests returns true when the server supports
 // SSH API requests.
 func (serverEntry *ServerEntry) SupportsSSHAPIRequests() bool {
-	return common.Contains(serverEntry.Capabilities, common.CAPABILITY_SSH_API_REQUESTS)
+	return common.Contains(serverEntry.Capabilities, protocol.CAPABILITY_SSH_API_REQUESTS)
 }
 
 func (serverEntry *ServerEntry) GetUntunneledWebRequestPorts() []string {
 	ports := make([]string, 0)
-	if common.Contains(serverEntry.Capabilities, common.CAPABILITY_UNTUNNELED_WEB_API_REQUESTS) {
+	if common.Contains(serverEntry.Capabilities, protocol.CAPABILITY_UNTUNNELED_WEB_API_REQUESTS) {
 		// Server-side configuration quirk: there's a port forward from
 		// port 443 to the web server, which we can try, except on servers
 		// running FRONTED_MEEK, which listens on port 443.
-		if !serverEntry.SupportsProtocol(common.TUNNEL_PROTOCOL_FRONTED_MEEK) {
+		if !serverEntry.SupportsProtocol(protocol.TUNNEL_PROTOCOL_FRONTED_MEEK) {
 			ports = append(ports, "443")
 		}
 		ports = append(ports, serverEntry.WebServerPort)

+ 3 - 2
psiphon/serverEntry_test.go

@@ -24,6 +24,7 @@ import (
 	"testing"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 )
 
 const (
@@ -43,7 +44,7 @@ func TestDecodeAndValidateServerEntryList(t *testing.T) {
 		hex.EncodeToString([]byte(_INVALID_MALFORMED_IP_ADDRESS_SERVER_ENTRY))
 
 	serverEntries, err := DecodeAndValidateServerEntryList(
-		testEncodedServerEntryList, common.GetCurrentTimestamp(), common.SERVER_ENTRY_SOURCE_EMBEDDED)
+		testEncodedServerEntryList, common.GetCurrentTimestamp(), protocol.SERVER_ENTRY_SOURCE_EMBEDDED)
 	if err != nil {
 		t.Error(err.Error())
 		t.FailNow()
@@ -66,7 +67,7 @@ func TestInvalidServerEntries(t *testing.T) {
 	for _, testCase := range testCases {
 		encodedServerEntry := hex.EncodeToString([]byte(testCase))
 		serverEntry, err := DecodeServerEntry(
-			encodedServerEntry, common.GetCurrentTimestamp(), common.SERVER_ENTRY_SOURCE_EMBEDDED)
+			encodedServerEntry, common.GetCurrentTimestamp(), protocol.SERVER_ENTRY_SOURCE_EMBEDDED)
 		if err != nil {
 			t.Error(err.Error())
 		}

+ 8 - 7
psiphon/tunnel.go

@@ -36,6 +36,7 @@ import (
 	"github.com/Psiphon-Inc/goarista/monotime"
 	regen "github.com/Psiphon-Inc/goregen"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/protocol"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/transferstats"
 )
 
@@ -471,7 +472,7 @@ func initMeekConfig(
 	transformedHostName := false
 
 	switch selectedProtocol {
-	case common.TUNNEL_PROTOCOL_FRONTED_MEEK:
+	case protocol.TUNNEL_PROTOCOL_FRONTED_MEEK:
 		frontingAddress, frontingHost, err := selectFrontingParameters(serverEntry)
 		if err != nil {
 			return nil, common.ContextError(err)
@@ -484,7 +485,7 @@ func initMeekConfig(
 		}
 		hostHeader = frontingHost
 
-	case common.TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP:
+	case protocol.TUNNEL_PROTOCOL_FRONTED_MEEK_HTTP:
 		frontingAddress, frontingHost, err := selectFrontingParameters(serverEntry)
 		if err != nil {
 			return nil, common.ContextError(err)
@@ -492,7 +493,7 @@ func initMeekConfig(
 		dialAddress = fmt.Sprintf("%s:80", frontingAddress)
 		hostHeader = frontingHost
 
-	case common.TUNNEL_PROTOCOL_UNFRONTED_MEEK:
+	case protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK:
 		dialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.MeekServerPort)
 		hostname := serverEntry.IpAddress
 		hostname, transformedHostName = config.HostNameTransformer.TransformHostName(hostname)
@@ -502,7 +503,7 @@ func initMeekConfig(
 			hostHeader = fmt.Sprintf("%s:%d", hostname, serverEntry.MeekServerPort)
 		}
 
-	case common.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS:
+	case protocol.TUNNEL_PROTOCOL_UNFRONTED_MEEK_HTTPS:
 		dialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.MeekServerPort)
 		useHTTPS = true
 		SNIServerName, transformedHostName =
@@ -559,11 +560,11 @@ func dialSsh(
 	var err error
 
 	switch selectedProtocol {
-	case common.TUNNEL_PROTOCOL_OBFUSCATED_SSH:
+	case protocol.TUNNEL_PROTOCOL_OBFUSCATED_SSH:
 		useObfuscatedSsh = true
 		directTCPDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshObfuscatedPort)
 
-	case common.TUNNEL_PROTOCOL_SSH:
+	case protocol.TUNNEL_PROTOCOL_SSH:
 		directTCPDialAddress = fmt.Sprintf("%s:%d", serverEntry.IpAddress, serverEntry.SshPort)
 
 	default:
@@ -629,7 +630,7 @@ func dialSsh(
 	}()
 
 	// Activity monitoring is used to measure tunnel duration
-	monitoredConn, err := common.NewActivityMonitoredConn(dialConn, 0, false, nil)
+	monitoredConn, err := common.NewActivityMonitoredConn(dialConn, 0, false, nil, nil)
 	if err != nil {
 		return nil, nil, nil, nil, common.ContextError(err)
 	}