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

Add client-side server entry pruning

Rod Hynes 7 лет назад
Родитель
Сommit
6976dad23e

+ 3 - 0
psiphon/common/parameters/clientParameters.go

@@ -207,6 +207,7 @@ const (
 	PersistentStatsMaxSendBytes                      = "PersistentStatsMaxSendBytes"
 	RecordRemoteServerListPersistentStatsProbability = "RecordRemoteServerListPersistentStatsProbability"
 	RecordFailedTunnelPersistentStatsProbability     = "RecordFailedTunnelPersistentStatsProbability"
+	ServerEntryMinimumAgeForPruning                  = "ServerEntryMinimumAgeForPruning"
 )
 
 const (
@@ -422,6 +423,8 @@ var defaultClientParameters = map[string]struct {
 	PersistentStatsMaxSendBytes:                      {value: 65536, minimum: 1},
 	RecordRemoteServerListPersistentStatsProbability: {value: 1.0, minimum: 0.0},
 	RecordFailedTunnelPersistentStatsProbability:     {value: 0.0, minimum: 0.0},
+
+	ServerEntryMinimumAgeForPruning: {value: 7 * 24 * time.Hour, minimum: 24 * time.Hour},
 }
 
 // IsServerSideOnly indicates if the parameter specified by name is used

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

@@ -312,6 +312,11 @@ type ConnectedResponse struct {
 	Padding            string `json:"padding"`
 }
 
+type StatusResponse struct {
+	InvalidServerEntryTags []string `json:"invalid_server_entry_tags"`
+	Padding                string   `json:"padding"`
+}
+
 type OSLRequest struct {
 	ClearLocalSLOKs bool             `json:"clear_local_sloks"`
 	SeedPayload     *osl.SeedPayload `json:"seed_payload"`

+ 46 - 0
psiphon/common/protocol/serverEntry.go

@@ -22,6 +22,9 @@ package protocol
 import (
 	"bufio"
 	"bytes"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/base64"
 	"encoding/hex"
 	"encoding/json"
 	"errors"
@@ -38,6 +41,7 @@ import (
 // several protocols. Server entries are JSON records downloaded from
 // various sources.
 type ServerEntry struct {
+	Tag                           string   `json:"tag"`
 	IpAddress                     string   `json:"ipAddress"`
 	WebServerPort                 string   `json:"webServerPort"` // not an int
 	WebServerSecret               string   `json:"webServerSecret"`
@@ -85,6 +89,22 @@ type ServerEntry struct {
 // ServerEntry type.
 type ServerEntryFields map[string]interface{}
 
+func (fields ServerEntryFields) GetTag() string {
+	tag, ok := fields["tag"]
+	if !ok {
+		return ""
+	}
+	tagStr, ok := tag.(string)
+	if !ok {
+		return ""
+	}
+	return tagStr
+}
+
+func (fields ServerEntryFields) SetTag(tag string) {
+	fields["tag"] = tag
+}
+
 func (fields ServerEntryFields) GetIPAddress() string {
 	ipAddress, ok := fields["ipAddress"]
 	if !ok {
@@ -97,6 +117,18 @@ func (fields ServerEntryFields) GetIPAddress() string {
 	return ipAddressStr
 }
 
+func (fields ServerEntryFields) GetWebServerSecret() string {
+	webServerSecret, ok := fields["webServerSecret"]
+	if !ok {
+		return ""
+	}
+	webServerSecretStr, ok := webServerSecret.(string)
+	if !ok {
+		return ""
+	}
+	return webServerSecretStr
+}
+
 func (fields ServerEntryFields) GetConfigurationVersion() int {
 	configurationVersion, ok := fields["configurationVersion"]
 	if !ok {
@@ -219,6 +251,20 @@ func (serverEntry *ServerEntry) GetUntunneledWebRequestPorts() []string {
 	return ports
 }
 
+// GenerateServerEntryTag creates a server entry tag value that is
+// cryptographically derived from the IP address and web server secret in a
+// way that is difficult to reverse the IP address value from the tag or
+// compute the tag without having the web server secret, a 256-bit random
+// value which is unique per server, in addition to the IP address. A database
+// consisting only of server entry tags should be resistent to an attack that
+// attempts to reverse all the server IPs, even given a small IP space (IPv4),
+// or some subset of the web server secrets.
+func GenerateServerEntryTag(ipAddress, webServerSecret string) string {
+	h := hmac.New(sha256.New, []byte(webServerSecret))
+	h.Write([]byte(ipAddress))
+	return base64.StdEncoding.EncodeToString(h.Sum(nil))
+}
+
 // EncodeServerEntry returns a string containing the encoding of
 // a ServerEntry following Psiphon conventions.
 func EncodeServerEntry(serverEntry *ServerEntry) (string, error) {

+ 218 - 32
psiphon/dataStore.go

@@ -25,6 +25,7 @@ import (
 	"errors"
 	"fmt"
 	"sync"
+	"time"
 
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
@@ -34,6 +35,8 @@ import (
 
 var (
 	datastoreServerEntriesBucket                = []byte("serverEntries")
+	datastoreServerEntryTagsBucket              = []byte("serverEntryTags")
+	datastoreServerEntryTombstoneTagsBucket     = []byte("serverEntryTombstoneTags")
 	datastoreSplitTunnelRouteETagsBucket        = []byte("splitTunnelRouteETags")
 	datastoreSplitTunnelRouteDataBucket         = []byte("splitTunnelRouteData")
 	datastoreUrlETagsBucket                     = []byte("urlETags")
@@ -161,13 +164,15 @@ func StoreServerEntry(serverEntryFields protocol.ServerEntryFields, replaceIfExi
 	err = datastoreUpdate(func(tx *datastoreTx) error {
 
 		serverEntries := tx.bucket(datastoreServerEntriesBucket)
+		serverEntryTags := tx.bucket(datastoreServerEntryTagsBucket)
+		serverEntryTombstoneTags := tx.bucket(datastoreServerEntryTombstoneTagsBucket)
 
-		ipAddress := serverEntryFields.GetIPAddress()
+		serverEntryID := []byte(serverEntryFields.GetIPAddress())
 
 		// Check not only that the entry exists, but is valid. This
 		// will replace in the rare case where the data is corrupt.
 		existingConfigurationVersion := -1
-		existingData := serverEntries.get([]byte(ipAddress))
+		existingData := serverEntries.get(serverEntryID)
 		if existingData != nil {
 			var existingServerEntry *protocol.ServerEntry
 			err := json.Unmarshal(existingData, &existingServerEntry)
@@ -188,16 +193,41 @@ func StoreServerEntry(serverEntryFields protocol.ServerEntryFields, replaceIfExi
 			return nil
 		}
 
+		serverEntryTag := serverEntryFields.GetTag()
+
+		// Generate a derived tag when the server entry has no tag.
+		if serverEntryTag == "" {
+
+			serverEntryTag = protocol.GenerateServerEntryTag(
+				serverEntryFields.GetIPAddress(),
+				serverEntryFields.GetWebServerSecret())
+
+			serverEntryFields.SetTag(serverEntryTag)
+		}
+
+		serverEntryTagBytes := []byte(serverEntryTag)
+
+		// Ignore the server entry if it was previously pruned.
+		if serverEntryTombstoneTags.get(serverEntryTagBytes) != nil {
+			return nil
+		}
+
 		data, err := json.Marshal(serverEntryFields)
 		if err != nil {
 			return common.ContextError(err)
 		}
-		err = serverEntries.put([]byte(ipAddress), data)
+
+		err = serverEntries.put(serverEntryID, data)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		err = serverEntryTags.put(serverEntryTagBytes, serverEntryID)
 		if err != nil {
 			return common.ContextError(err)
 		}
 
-		NoticeInfo("updated server %s", ipAddress)
+		NoticeInfo("updated server %s", serverEntryFields.GetIPAddress())
 
 		return nil
 	})
@@ -285,7 +315,7 @@ func PromoteServerEntry(config *Config, ipAddress string) error {
 		bucket = tx.bucket(datastoreKeyValueBucket)
 		err := bucket.put(datastoreAffinityServerEntryIDKey, serverEntryID)
 		if err != nil {
-			return err
+			return common.ContextError(err)
 		}
 
 		// Store the current server entry filter (e.g, region, etc.) that
@@ -295,7 +325,7 @@ func PromoteServerEntry(config *Config, ipAddress string) error {
 
 		currentFilter, err := makeServerEntryFilterValue(config)
 		if err != nil {
-			return err
+			return common.ContextError(err)
 		}
 
 		return bucket.put(datastoreLastServerEntryFilterKey, currentFilter)
@@ -625,34 +655,39 @@ func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
 		serverEntryID := iterator.serverEntryIDs[iterator.serverEntryIndex]
 		iterator.serverEntryIndex += 1
 
-		var data []byte
+		serverEntry = nil
 
 		err = datastoreView(func(tx *datastoreTx) error {
-			bucket := tx.bucket(datastoreServerEntriesBucket)
-			value := bucket.get(serverEntryID)
-			if value != nil {
-				// Must make a copy as slice is only valid within transaction.
-				data = make([]byte, len(value))
-				copy(data, value)
+			serverEntries := tx.bucket(datastoreServerEntriesBucket)
+			value := serverEntries.get(serverEntryID)
+			if value == nil {
+				return nil
 			}
+
+			// Must unmarshal here as slice is only valid within transaction.
+			err = json.Unmarshal(value, &serverEntry)
+
+			if err != nil {
+				// In case of data corruption or a bug causing this condition,
+				// do not stop iterating.
+				serverEntry = nil
+				NoticeAlert(
+					"ServerEntryIterator.Next: json.Unmarshal failed: %s",
+					common.ContextError(err))
+			}
+
 			return nil
 		})
 		if err != nil {
 			return nil, common.ContextError(err)
 		}
 
-		if data == nil {
-			// In case of data corruption or a bug causing this condition,
-			// do not stop iterating.
-			NoticeAlert("ServerEntryIterator.Next: unexpected missing server entry: %s", string(serverEntryID))
-			continue
-		}
-
-		err = json.Unmarshal(data, &serverEntry)
-		if err != nil {
+		if serverEntry == nil {
 			// In case of data corruption or a bug causing this condition,
 			// do not stop iterating.
-			NoticeAlert("ServerEntryIterator.Next: %s", common.ContextError(err))
+			NoticeAlert(
+				"ServerEntryIterator.Next: unexpected missing server entry: %s",
+				string(serverEntryID))
 			continue
 		}
 
@@ -660,6 +695,45 @@ func (iterator *ServerEntryIterator) Next() (*protocol.ServerEntry, error) {
 			DoGarbageCollection()
 		}
 
+		// Generate a derived server entry tag for server entries with no tag. Store
+		// back the updated server entry so that (a) the tag doesn't need to be
+		// regenerated; (b) the server entry can be looked up by tag (currently used
+		// in the status request prune case).
+		if serverEntry.Tag == "" {
+
+			serverEntry.Tag = protocol.GenerateServerEntryTag(
+				serverEntry.IpAddress, serverEntry.WebServerSecret)
+
+			jsonServerEntry, err := json.Marshal(serverEntry)
+
+			if err == nil {
+				err = datastoreUpdate(func(tx *datastoreTx) error {
+
+					serverEntries := tx.bucket(datastoreServerEntriesBucket)
+					serverEntryTags := tx.bucket(datastoreServerEntryTagsBucket)
+
+					serverEntries.put(serverEntryID, jsonServerEntry)
+					if err != nil {
+						return common.ContextError(err)
+					}
+
+					serverEntryTags.put([]byte(serverEntry.Tag), serverEntryID)
+					if err != nil {
+						return common.ContextError(err)
+					}
+
+					return nil
+				})
+			}
+
+			if err != nil {
+				// Do not stop.
+				NoticeAlert(
+					"ServerEntryIterator.Next: update server entry failed: %s",
+					common.ContextError(err))
+			}
+		}
+
 		// Check filter requirements
 
 		if iterator.isTacticsServerEntryIterator {
@@ -694,6 +768,94 @@ func MakeCompatibleServerEntry(serverEntry *protocol.ServerEntry) *protocol.Serv
 	return serverEntry
 }
 
+// PruneServerEntry deletes the server entry, along with associated data,
+// corresponding to the specified server entry tag. Pruning is subject to an
+// age check. In the case of an error, a notice is emitted.
+func PruneServerEntry(config *Config, serverEntryTag string) {
+	err := pruneServerEntry(config, serverEntryTag)
+	if err != nil {
+		NoticeAlert(
+			"PruneServerEntry failed: %s: %s",
+			serverEntryTag, common.ContextError(err))
+		return
+	}
+	NoticePruneServerEntry(serverEntryTag)
+}
+
+func pruneServerEntry(config *Config, serverEntryTag string) error {
+
+	minimumAgeForPruning := config.GetClientParameters().Duration(
+		parameters.ServerEntryMinimumAgeForPruning)
+
+	return datastoreUpdate(func(tx *datastoreTx) error {
+
+		serverEntries := tx.bucket(datastoreServerEntriesBucket)
+		serverEntryTags := tx.bucket(datastoreServerEntryTagsBucket)
+		serverEntryTombstoneTags := tx.bucket(datastoreServerEntryTombstoneTagsBucket)
+		keyValues := tx.bucket(datastoreKeyValueBucket)
+
+		serverEntryTagBytes := []byte(serverEntryTag)
+
+		serverEntryID := serverEntryTags.get(serverEntryTagBytes)
+		if serverEntryID == nil {
+			return common.ContextError(errors.New("server entry tag not found"))
+		}
+
+		serverEntryJson := serverEntries.get(serverEntryID)
+		if serverEntryJson == nil {
+			return common.ContextError(errors.New("server entry not found"))
+		}
+
+		var serverEntry *protocol.ServerEntry
+		err := json.Unmarshal(serverEntryJson, &serverEntry)
+		if err != nil {
+			common.ContextError(err)
+		}
+
+		// Only prune sufficiently old server entries. This mitigates the case where
+		// stale data in psiphond will incorrectly identify brand new servers as
+		// being invalid/deleted.
+		serverEntryLocalTimestamp, err := time.Parse(time.RFC3339, serverEntry.LocalTimestamp)
+		if err != nil {
+			common.ContextError(err)
+		}
+		if serverEntryLocalTimestamp.Add(minimumAgeForPruning).After(time.Now()) {
+			return nil
+		}
+
+		err = serverEntries.delete(serverEntryID)
+		if err != nil {
+			common.ContextError(err)
+		}
+
+		err = serverEntryTags.delete(serverEntryTagBytes)
+		if err != nil {
+			common.ContextError(err)
+		}
+
+		affinityServerEntryID := keyValues.get(datastoreAffinityServerEntryIDKey)
+		if 0 == bytes.Compare(affinityServerEntryID, serverEntryID) {
+			err = keyValues.delete(datastoreAffinityServerEntryIDKey)
+			if err != nil {
+				return common.ContextError(err)
+			}
+		}
+
+		// TODO: also prune dial parameters?
+
+		// Tombstones prevent reimporting pruned server entries. Tombstone
+		// identifiers are tags, which are derived from the web server secret in
+		// addition to the server IP, so tombstones will not clobber recycled server
+		// IPs as long as new web server secrets are generated in the recycle case.
+		err = serverEntryTombstoneTags.put(serverEntryID, serverEntryTagBytes)
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		return nil
+	})
+}
+
 func scanServerEntries(scanner func(*protocol.ServerEntry)) error {
 	err := datastoreView(func(tx *datastoreTx) error {
 		bucket := tx.bucket(datastoreServerEntriesBucket)
@@ -827,10 +989,17 @@ func SetSplitTunnelRoutes(region, etag string, data []byte) error {
 	err := datastoreUpdate(func(tx *datastoreTx) error {
 		bucket := tx.bucket(datastoreSplitTunnelRouteETagsBucket)
 		err := bucket.put([]byte(region), []byte(etag))
+		if err != nil {
+			return common.ContextError(err)
+		}
 
 		bucket = tx.bucket(datastoreSplitTunnelRouteDataBucket)
 		err = bucket.put([]byte(region), data)
-		return err
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		return nil
 	})
 
 	if err != nil {
@@ -888,7 +1057,10 @@ func SetUrlETag(url, etag string) error {
 	err := datastoreUpdate(func(tx *datastoreTx) error {
 		bucket := tx.bucket(datastoreUrlETagsBucket)
 		err := bucket.put([]byte(url), []byte(etag))
-		return err
+		if err != nil {
+			return common.ContextError(err)
+		}
+		return nil
 	})
 
 	if err != nil {
@@ -921,7 +1093,10 @@ func SetKeyValue(key, value string) error {
 	err := datastoreUpdate(func(tx *datastoreTx) error {
 		bucket := tx.bucket(datastoreKeyValueBucket)
 		err := bucket.put([]byte(key), []byte(value))
-		return err
+		if err != nil {
+			return common.ContextError(err)
+		}
+		return nil
 	})
 
 	if err != nil {
@@ -1004,7 +1179,11 @@ func StorePersistentStat(config *Config, statType string, stat []byte) error {
 		}
 
 		err := bucket.put(stat, persistentStatStateUnreported)
-		return err
+		if err != nil {
+			return common.ContextError(err)
+		}
+
+		return nil
 	})
 
 	if err != nil {
@@ -1101,7 +1280,7 @@ func TakeOutUnreportedPersistentStats(config *Config) (map[string][][]byte, erro
 			for _, key := range stats[statType] {
 				err := bucket.put(key, persistentStatStateReporting)
 				if err != nil {
-					return err
+					return common.ContextError(err)
 				}
 			}
 
@@ -1128,7 +1307,7 @@ func PutBackUnreportedPersistentStats(stats map[string][][]byte) error {
 			for _, key := range stats[statType] {
 				err := bucket.put(key, persistentStatStateUnreported)
 				if err != nil {
-					return err
+					return common.ContextError(err)
 				}
 			}
 		}
@@ -1194,7 +1373,7 @@ func resetAllPersistentStatsToUnreported() error {
 			for _, key := range resetKeys {
 				err := bucket.put(key, persistentStatStateUnreported)
 				if err != nil {
-					return err
+					return common.ContextError(err)
 				}
 			}
 		}
@@ -1256,7 +1435,10 @@ func SetSLOK(id, key []byte) (bool, error) {
 		bucket := tx.bucket(datastoreSLOKsBucket)
 		duplicate = bucket.get(id) != nil
 		err := bucket.put([]byte(id), []byte(key))
-		return err
+		if err != nil {
+			return common.ContextError(err)
+		}
+		return nil
 	})
 
 	if err != nil {
@@ -1366,7 +1548,11 @@ func setBucketValue(bucket, key, value []byte) error {
 
 	err := datastoreUpdate(func(tx *datastoreTx) error {
 		bucket := tx.bucket(bucket)
-		return bucket.put(key, value)
+		err := bucket.put(key, value)
+		if err != nil {
+			return common.ContextError(err)
+		}
+		return nil
 	})
 
 	if err != nil {

+ 2 - 0
psiphon/dataStore_bolt.go

@@ -92,6 +92,8 @@ func datastoreOpenDB(rootDataDirectory string) (*datastoreDB, error) {
 	err = newDB.Update(func(tx *bolt.Tx) error {
 		requiredBuckets := [][]byte{
 			datastoreServerEntriesBucket,
+			datastoreServerEntryTagsBucket,
+			datastoreServerEntryTombstoneTagsBucket,
 			datastoreSplitTunnelRouteETagsBucket,
 			datastoreSplitTunnelRouteDataBucket,
 			datastoreUrlETagsBucket,

+ 6 - 0
psiphon/notice.go

@@ -765,6 +765,12 @@ func NoticeLivenessTest(ipAddress string, metrics *livenessTestMetrics, success
 		"success", success)
 }
 
+func NoticePruneServerEntry(serverEntryTag string) {
+	singletonNoticeLogger.outputNotice(
+		"PruneServerEntry", noticeIsDiagnostic,
+		"serverEntryTag", serverEntryTag)
+}
+
 // NoticeEstablishTunnelTimeout reports that the configured EstablishTunnelTimeout
 // duration was exceeded.
 func NoticeEstablishTunnelTimeout(timeout time.Duration) {

+ 62 - 3
psiphon/server/api.go

@@ -405,7 +405,7 @@ var remoteServerListStatParams = []requestParamSpec{
 
 var failedTunnelStatParams = append(
 	[]requestParamSpec{
-		{"server_entry_ip_address", isIPAddress, requestParamNotLogged},
+		{"server_entry_tag", isAnyString, requestParamOptional},
 		{"session_id", isHexDigits, 0},
 		{"last_connected", isLastConnected, 0},
 		{"client_failed_timestamp", isISO8601Date, 0},
@@ -515,8 +515,15 @@ func statusAPIRequestHandler(
 	// Failed tunnel persistent stats.
 	// Older clients may not submit this data.
 
+	var invalidServerEntryTags map[string]bool
+
 	if statusData["failed_tunnel_stats"] != nil {
 
+		// Note: no guarantee that PsinetDatabase won't reload between database calls
+		db := support.PsinetDatabase
+
+		invalidServerEntryTags = make(map[string]bool)
+
 		failedTunnelStats, err := getJSONObjectArrayRequestParam(statusData, "failed_tunnel_stats")
 		if err != nil {
 			return nil, common.ContextError(err)
@@ -524,7 +531,7 @@ func statusAPIRequestHandler(
 		for _, failedTunnelStat := range failedTunnelStats {
 
 			// failed_tunnel supplies a full set of common params, but the
-			// server secret must use the corect value from the outer
+			// server secret must use the correct value from the outer
 			// statusRequestParams
 			failedTunnelStat["server_secret"] = params["server_secret"]
 
@@ -540,6 +547,40 @@ func statusAPIRequestHandler(
 				failedTunnelStat,
 				failedTunnelStatParams)
 
+			// Return a list of servers, identified by server entry tag, that are
+			// invalid and presumed to be deleted. This information is used by clients
+			// to prune deleted servers from their local datastores and stop attempting
+			// connections to servers that no longer exist.
+			//
+			// This mechanism uses tags instead of server IPs: (a) to prevent an
+			// enumeration attack, where a malicious client can query the entire IPv4
+			// range and build a map of the Psiphon network; (b) to deal with recyling
+			// cases where a server deleted and its IP is reused for a new server with
+			// a distinct server entry.
+			//
+			// IsValidServerEntryTag ensures that the local copy of psinet is not stale
+			// before returning a negative result, to mitigate accidental pruning.
+
+			var serverEntryTagStr string
+
+			serverEntryTag, ok := failedTunnelStat["server_entry_tag"]
+			if ok {
+				serverEntryTagStr, ok = serverEntryTag.(string)
+			}
+
+			if ok {
+				serverEntryValid := db.IsValidServerEntryTag(serverEntryTagStr)
+				if !serverEntryValid {
+					invalidServerEntryTags[serverEntryTagStr] = true
+				}
+
+				// Add a field to the failed_tunnel log indicating if the server entry is
+				// valid.
+				failedTunnelFields["server_entry_valid"] = serverEntryValid
+			}
+
+			// Log failed_tunnel.
+
 			logQueue = append(logQueue, failedTunnelFields)
 		}
 	}
@@ -550,7 +591,25 @@ func statusAPIRequestHandler(
 
 	pad_response, _ := getPaddingSizeRequestParam(params, "pad_response")
 
-	return make([]byte, pad_response), nil
+	statusResponse := protocol.StatusResponse{
+		Padding: strings.Repeat(" ", pad_response),
+	}
+
+	if len(invalidServerEntryTags) > 0 {
+		statusResponse.InvalidServerEntryTags = make([]string, len(invalidServerEntryTags))
+		i := 0
+		for tag, _ := range invalidServerEntryTags {
+			statusResponse.InvalidServerEntryTags[i] = tag
+			i++
+		}
+	}
+
+	responsePayload, err := json.Marshal(statusResponse)
+	if err != nil {
+		return nil, common.ContextError(err)
+	}
+
+	return responsePayload, nil
 }
 
 // clientVerificationAPIRequestHandler is just a compliance stub

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

@@ -36,17 +36,23 @@ import (
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 )
 
+const (
+	MAX_DATABASE_AGE_FOR_SERVER_ENTRY_VALIDITY = 48 * time.Hour
+)
+
 // Database serves Psiphon API data requests. It's safe for
 // concurrent usage. The Reload function supports hot reloading
 // of Psiphon network data while the server is running.
 type Database struct {
 	common.ReloadableFile
 
-	Hosts            map[string]Host            `json:"hosts"`
-	Servers          []Server                   `json:"servers"`
-	Sponsors         map[string]Sponsor         `json:"sponsors"`
-	Versions         map[string][]ClientVersion `json:"client_versions"`
-	DefaultSponsorID string                     `json:"default_sponsor_id"`
+	Timestamp            time.Time                  `json:"timestamp"`
+	Hosts                map[string]Host            `json:"hosts"`
+	Servers              []Server                   `json:"servers"`
+	Sponsors             map[string]Sponsor         `json:"sponsors"`
+	Versions             map[string][]ClientVersion `json:"client_versions"`
+	DefaultSponsorID     string                     `json:"default_sponsor_id"`
+	ValidServerEntryTags map[string]bool            `json:"valid_server_entry_tags"`
 }
 
 type Host struct {
@@ -141,11 +147,13 @@ func NewDatabase(filename string) (*Database, error) {
 			}
 			// Note: an unmarshal directly into &database would fail
 			// to reset to zero value fields not present in the JSON.
+			database.Timestamp = newDatabase.Timestamp
 			database.Hosts = newDatabase.Hosts
 			database.Servers = newDatabase.Servers
 			database.Sponsors = newDatabase.Sponsors
 			database.Versions = newDatabase.Versions
 			database.DefaultSponsorID = newDatabase.DefaultSponsorID
+			database.ValidServerEntryTags = newDatabase.ValidServerEntryTags
 
 			return nil
 		})
@@ -548,3 +556,21 @@ func parseSshKeyString(sshKeyString string) (keyType string, key string) {
 
 	return sshKeyArr[0], sshKeyArr[1]
 }
+
+// IsValidServerEntryTag checks if the specified server entry tag is valid.
+func (db *Database) IsValidServerEntryTag(serverEntryTag string) bool {
+	db.ReloadableFile.RLock()
+	defer db.ReloadableFile.RUnlock()
+
+	// Default to "valid" if the valid list is unexpectedly empty or stale. This
+	// helps prevent premature client-side server-entry pruning when there is an
+	// issue with updating the database.
+
+	if len(db.ValidServerEntryTags) == 0 ||
+		db.Timestamp.Add(MAX_DATABASE_AGE_FOR_SERVER_ENTRY_VALIDITY).Before(time.Now()) {
+		return true
+	}
+
+	// The tag must be in the map and have the value "true".
+	return db.ValidServerEntryTags[serverEntryTag]
+}

+ 358 - 9
psiphon/server/server_test.go

@@ -115,8 +115,8 @@ func runMockWebServer() (string, string) {
 	return fmt.Sprintf("http://%s/", webServerAddress), responseBody
 }
 
-// Note: not testing fronting meek protocols, which client is
-// hard-wired to except running on privileged ports 80 and 443.
+// Note: not testing fronted meek protocols, which client is
+// hard-wired to expect running on privileged ports 80 and 443.
 
 func TestSSH(t *testing.T) {
 	runServer(t,
@@ -132,6 +132,7 @@ func TestSSH(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -149,6 +150,7 @@ func TestOSSH(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -166,6 +168,7 @@ func TestFragmentedOSSH(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     true,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -183,6 +186,7 @@ func TestUnfrontedMeek(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -201,6 +205,7 @@ func TestUnfrontedMeekHTTPS(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -219,6 +224,7 @@ func TestUnfrontedMeekHTTPSTLS13(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -237,6 +243,7 @@ func TestUnfrontedMeekSessionTicket(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -255,6 +262,7 @@ func TestUnfrontedMeekSessionTicketTLS13(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -272,6 +280,7 @@ func TestQUICOSSH(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -292,6 +301,7 @@ func TestMarionetteOSSH(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -309,6 +319,7 @@ func TestWebTransportAPIRequests(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -326,6 +337,7 @@ func TestHotReload(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -343,6 +355,7 @@ func TestDefaultSponsorID(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -360,6 +373,7 @@ func TestDenyTrafficRules(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -377,6 +391,7 @@ func TestOmitAuthorization(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -394,6 +409,7 @@ func TestNoAuthorization(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -411,6 +427,7 @@ func TestUnusedAuthorization(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -428,6 +445,7 @@ func TestTCPOnlySLOK(t *testing.T) {
 			doTunneledNTPRequest: false,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -445,6 +463,7 @@ func TestUDPOnlySLOK(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    false,
+			doPruneServerEntries: false,
 		})
 }
 
@@ -462,6 +481,25 @@ func TestLivenessTest(t *testing.T) {
 			doTunneledNTPRequest: true,
 			forceFragmenting:     false,
 			forceLivenessTest:    true,
+			doPruneServerEntries: false,
+		})
+}
+
+func TestPruneServerEntries(t *testing.T) {
+	runServer(t,
+		&runServerConfig{
+			tunnelProtocol:       "OSSH",
+			enableSSHAPIRequests: true,
+			doHotReload:          false,
+			doDefaultSponsorID:   false,
+			denyTrafficRules:     false,
+			requireAuthorization: true,
+			omitAuthorization:    false,
+			doTunneledWebRequest: true,
+			doTunneledNTPRequest: true,
+			forceFragmenting:     false,
+			forceLivenessTest:    true,
+			doPruneServerEntries: true,
 		})
 }
 
@@ -478,6 +516,7 @@ type runServerConfig struct {
 	doTunneledNTPRequest bool
 	forceFragmenting     bool
 	forceLivenessTest    bool
+	doPruneServerEntries bool
 }
 
 var (
@@ -564,10 +603,14 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	// customize server config
 
+	// Initialize prune server entry test cases and associated data to pave into psinet.
+	pruneServerEntryTestCases, psinetValidServerEntryTags, expectedNumPruneNotices :=
+		initializePruneServerEntriesTest(t, runConfig)
+
 	// Pave psinet with random values to test handshake homepages.
 	psinetFilename := filepath.Join(testDataDirName, "psinet.json")
 	sponsorID, expectedHomepageURL := pavePsinetDatabaseFile(
-		t, runConfig.doDefaultSponsorID, psinetFilename)
+		t, runConfig.doDefaultSponsorID, psinetFilename, psinetValidServerEntryTags)
 
 	// Pave OSL config for SLOK testing
 	oslConfigFilename := filepath.Join(testDataDirName, "osl_config.json")
@@ -706,7 +749,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 		// Pave new config files with different random values.
 		sponsorID, expectedHomepageURL = pavePsinetDatabaseFile(
-			t, runConfig.doDefaultSponsorID, psinetFilename)
+			t, runConfig.doDefaultSponsorID, psinetFilename, psinetValidServerEntryTags)
 
 		propagationChannelID = paveOSLConfigFile(t, oslConfigFilename)
 
@@ -834,6 +877,11 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			applyParameters[parameters.LivenessTestMaxDownstreamBytes] = livenessTestSize
 		}
 
+		if runConfig.doPruneServerEntries {
+			applyParameters[parameters.PsiphonAPIStatusRequestShortPeriodMin] = 1 * time.Millisecond
+			applyParameters[parameters.PsiphonAPIStatusRequestShortPeriodMax] = 1 * time.Millisecond
+		}
+
 		err = clientConfig.SetClientParameters("", true, applyParameters)
 		if err != nil {
 			t.Fatalf("SetClientParameters failed: %s", err)
@@ -848,8 +896,12 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	}
 	defer psiphon.CloseDataStore()
 
+	// Clear SLOKs from previous test runs.
 	psiphon.DeleteSLOKs()
 
+	// Store prune server entry test server entries and failed tunnel records.
+	storePruneServerEntriesTest(t, runConfig, pruneServerEntryTestCases)
+
 	controller, err := psiphon.NewController(clientConfig)
 	if err != nil {
 		t.Fatalf("error creating client controller: %s", err)
@@ -860,6 +912,9 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	slokSeeded := make(chan struct{}, 1)
 	clientConnectedNotice := make(chan map[string]interface{}, 1)
 
+	numPruneNotices := 0
+	pruneServerEntriesNoticesEmitted := make(chan struct{}, 1)
+
 	psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(
 		func(notice []byte) {
 
@@ -889,6 +944,12 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			case "SLOKSeeded":
 				sendNotificationReceived(slokSeeded)
 
+			case "PruneServerEntry":
+				numPruneNotices += 1
+				if numPruneNotices == expectedNumPruneNotices {
+					sendNotificationReceived(pruneServerEntriesNoticesEmitted)
+				}
+
 			case "ConnectedServer":
 				select {
 				case clientConnectedNotice <- payload:
@@ -997,6 +1058,19 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		}
 	}
 
+	// Test: await expected prune server entry notices
+	//
+	// Note: will take up to PsiphonAPIStatusRequestShortPeriodMax to emit.
+
+	if expectedNumPruneNotices > 0 {
+
+		waitOnNotification(
+			t,
+			pruneServerEntriesNoticesEmitted,
+			timeoutSignal,
+			"prune server entries timeout exceeded")
+	}
+
 	// Shutdown to ensure logs/notices are flushed
 
 	stopClient()
@@ -1036,6 +1110,9 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	default:
 		t.Fatalf("missing server tunnel log")
 	}
+
+	// Check that datastore had retained/pruned server entries as expected.
+	checkPruneServerEntriesTest(t, runConfig, pruneServerEntryTestCases)
 }
 
 func checkExpectedLogFields(runConfig *runServerConfig, fields map[string]interface{}) error {
@@ -1433,16 +1510,25 @@ func makeTunneledNTPRequestAttempt(
 }
 
 func pavePsinetDatabaseFile(
-	t *testing.T, useDefaultSponsorID bool, psinetFilename string) (string, string) {
+	t *testing.T,
+	useDefaultSponsorID bool,
+	psinetFilename string,
+	validServerEntryTags []string) (string, string) {
 
 	sponsorID := prng.HexString(8)
 
+	defaultSponsorID := ""
+	if useDefaultSponsorID {
+		defaultSponsorID = sponsorID
+	}
+
 	fakeDomain := prng.HexString(4)
 	fakePath := prng.HexString(4)
 	expectedHomepageURL := fmt.Sprintf("https://%s.com/%s", fakeDomain, fakePath)
 
 	psinetJSONFormat := `
     {
+        "timestamp" : "%s",
         "default_sponsor_id" : "%s",
         "sponsors": {
             "%s": {
@@ -1455,17 +1541,28 @@ func pavePsinetDatabaseFile(
                     ]
                 }
             }
+        },
+        "valid_server_entry_tags" : {
+            %s
         }
     }
 	`
 
-	defaultSponsorID := ""
-	if useDefaultSponsorID {
-		defaultSponsorID = sponsorID
+	validServerEntryTagsJSON := ""
+	for _, serverEntryTag := range validServerEntryTags {
+		if len(validServerEntryTagsJSON) > 0 {
+			validServerEntryTagsJSON += ", "
+		}
+		validServerEntryTagsJSON += fmt.Sprintf("\"%s\" : true", serverEntryTag)
 	}
 
 	psinetJSON := fmt.Sprintf(
-		psinetJSONFormat, defaultSponsorID, sponsorID, expectedHomepageURL)
+		psinetJSONFormat,
+		common.GetCurrentTimestamp(),
+		defaultSponsorID,
+		sponsorID,
+		expectedHomepageURL,
+		validServerEntryTagsJSON)
 
 	err := ioutil.WriteFile(psinetFilename, []byte(psinetJSON), 0600)
 	if err != nil {
@@ -1741,3 +1838,255 @@ func waitOnNotification(t *testing.T, c, timeoutSignal <-chan struct{}, timeoutM
 		t.Fatalf(timeoutMessage)
 	}
 }
+
+type pruneServerEntryTestCase struct {
+	IPAddress         string
+	ExplicitTag       bool
+	ExpectedTag       string
+	LocalTimestamp    string
+	PsinetValid       bool
+	ExpectPrune       bool
+	ServerEntryFields protocol.ServerEntryFields
+}
+
+func initializePruneServerEntriesTest(
+	t *testing.T,
+	runConfig *runServerConfig) ([]*pruneServerEntryTestCase, []string, int) {
+
+	if !runConfig.doPruneServerEntries {
+		return nil, nil, 0
+	}
+
+	newTimeStamp := time.Now().UTC().Format(time.RFC3339)
+	oldTimeStamp := time.Now().Add(-30 * 24 * time.Hour).UTC().Format(time.RFC3339)
+
+	// Test Cases:
+	// - ExplicitTag: server entry includes a tag; vs. generate a derive tag
+	// - LocalTimestamp: server entry is sufficiently old to be pruned; vs. not
+	// - PsinetValid: server entry is reported valid by psinet; vs. deleted
+	// - ExpectPrune: prune outcome based on flags above
+
+	pruneServerEntryTestCases := []*pruneServerEntryTestCase{
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.1", ExplicitTag: true, LocalTimestamp: newTimeStamp, PsinetValid: true, ExpectPrune: false},
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.2", ExplicitTag: false, LocalTimestamp: newTimeStamp, PsinetValid: true, ExpectPrune: false},
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.3", ExplicitTag: true, LocalTimestamp: oldTimeStamp, PsinetValid: true, ExpectPrune: false},
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.4", ExplicitTag: false, LocalTimestamp: oldTimeStamp, PsinetValid: true, ExpectPrune: false},
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.5", ExplicitTag: true, LocalTimestamp: newTimeStamp, PsinetValid: false, ExpectPrune: false},
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.6", ExplicitTag: false, LocalTimestamp: newTimeStamp, PsinetValid: false, ExpectPrune: false},
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.7", ExplicitTag: true, LocalTimestamp: oldTimeStamp, PsinetValid: false, ExpectPrune: true},
+		&pruneServerEntryTestCase{IPAddress: "192.0.2.8", ExplicitTag: false, LocalTimestamp: oldTimeStamp, PsinetValid: false, ExpectPrune: true},
+	}
+
+	for _, testCase := range pruneServerEntryTestCases {
+
+		_, _, _, _, encodedServerEntry, err := GenerateConfig(
+			&GenerateConfigParams{
+				ServerIPAddress:     testCase.IPAddress,
+				WebServerPort:       8000,
+				TunnelProtocolPorts: map[string]int{runConfig.tunnelProtocol: 4000},
+			})
+		if err != nil {
+			t.Fatalf("GenerateConfig failed: %s", err)
+		}
+
+		serverEntrySources := []string{
+			protocol.SERVER_ENTRY_SOURCE_EMBEDDED,
+			protocol.SERVER_ENTRY_SOURCE_REMOTE,
+			protocol.SERVER_ENTRY_SOURCE_DISCOVERY,
+			protocol.SERVER_ENTRY_SOURCE_OBFUSCATED,
+		}
+		serverEntryFields, err := protocol.DecodeServerEntryFields(
+			string(encodedServerEntry),
+			testCase.LocalTimestamp,
+			serverEntrySources[prng.Intn(len(serverEntrySources))])
+		if err != nil {
+			t.Fatalf("DecodeServerEntryFields failed: %s", err)
+		}
+
+		if testCase.ExplicitTag {
+			testCase.ExpectedTag = prng.Base64String(32)
+			serverEntryFields.SetTag(testCase.ExpectedTag)
+		} else {
+			testCase.ExpectedTag = protocol.GenerateServerEntryTag(
+				serverEntryFields.GetIPAddress(),
+				serverEntryFields.GetWebServerSecret())
+		}
+
+		testCase.ServerEntryFields = serverEntryFields
+	}
+
+	psinetValidServerEntryTags := make([]string, 0)
+	expectedNumPruneNotices := 0
+
+	for _, testCase := range pruneServerEntryTestCases {
+
+		if testCase.PsinetValid {
+			psinetValidServerEntryTags = append(
+				psinetValidServerEntryTags, testCase.ExpectedTag)
+		}
+
+		if testCase.ExpectPrune {
+			expectedNumPruneNotices += 1
+		}
+	}
+
+	return pruneServerEntryTestCases,
+		psinetValidServerEntryTags,
+		expectedNumPruneNotices
+}
+
+func storePruneServerEntriesTest(
+	t *testing.T,
+	runConfig *runServerConfig,
+	pruneServerEntryTestCases []*pruneServerEntryTestCase) {
+
+	if !runConfig.doPruneServerEntries {
+		return
+	}
+
+	for _, testCase := range pruneServerEntryTestCases {
+
+		err := psiphon.StoreServerEntry(testCase.ServerEntryFields, true)
+		if err != nil {
+			t.Fatalf("StoreServerEntry failed: %s", err)
+		}
+	}
+
+	verifyTestCasesStored := make(map[string]bool)
+	for _, testCase := range pruneServerEntryTestCases {
+		verifyTestCasesStored[testCase.IPAddress] = true
+	}
+
+	clientConfig := &psiphon.Config{SponsorId: "0", PropagationChannelId: "0"}
+	err := clientConfig.Commit()
+	if err != nil {
+		t.Fatalf("Commit failed: %s", err)
+	}
+
+	applyParameters := make(map[string]interface{})
+	applyParameters[parameters.RecordFailedTunnelPersistentStatsProbability] = 1.0
+
+	err = clientConfig.SetClientParameters("", true, applyParameters)
+	if err != nil {
+		t.Fatalf("SetClientParameters failed: %s", err)
+	}
+
+	_, iterator, err := psiphon.NewServerEntryIterator(clientConfig)
+	if err != nil {
+		t.Fatalf("NewServerEntryIterator failed: %s", err)
+	}
+	defer iterator.Close()
+
+	for {
+
+		serverEntry, err := iterator.Next()
+		if err != nil {
+			t.Fatalf("ServerIterator.Next failed: %s", err)
+		}
+		if serverEntry == nil {
+			break
+		}
+
+		var testCase *pruneServerEntryTestCase
+		for i, _ := range pruneServerEntryTestCases {
+			if pruneServerEntryTestCases[i].IPAddress == serverEntry.IpAddress {
+				testCase = pruneServerEntryTestCases[i]
+				break
+			}
+		}
+		if testCase == nil {
+			break
+		}
+
+		delete(verifyTestCasesStored, testCase.IPAddress)
+
+		if serverEntry.Tag != testCase.ExpectedTag {
+			t.Fatalf("unexpected tag for %s got %s expected %s",
+				testCase.IPAddress, serverEntry.Tag, testCase.ExpectedTag)
+		}
+
+		dialParams, err := psiphon.MakeDialParameters(
+			clientConfig,
+			func(_ *protocol.ServerEntry, _ string) bool { return true },
+			func(serverEntry *protocol.ServerEntry) (string, bool) {
+				return runConfig.tunnelProtocol, true
+			},
+			serverEntry,
+			false,
+			0)
+		if err != nil {
+			t.Fatalf("MakeDialParameters failed: %s", err)
+		}
+
+		err = psiphon.RecordFailedTunnelStat(
+			clientConfig, dialParams, errors.New("test error"))
+		if err != nil {
+			t.Fatalf("RecordFailedTunnelStat failed: %s", err)
+		}
+	}
+
+	if len(verifyTestCasesStored) > 0 {
+		t.Fatalf("missing prune test case server entries: %+v", verifyTestCasesStored)
+	}
+}
+
+func checkPruneServerEntriesTest(
+	t *testing.T,
+	runConfig *runServerConfig,
+	pruneServerEntryTestCases []*pruneServerEntryTestCase) {
+
+	if !runConfig.doPruneServerEntries {
+		return
+	}
+
+	clientConfig := &psiphon.Config{SponsorId: "0", PropagationChannelId: "0"}
+	err := clientConfig.Commit()
+	if err != nil {
+		t.Fatalf("Commit failed: %s", err)
+	}
+
+	_, iterator, err := psiphon.NewServerEntryIterator(clientConfig)
+	if err != nil {
+		t.Fatalf("NewServerEntryIterator failed: %s", err)
+	}
+	defer iterator.Close()
+
+	verifyTestCasesStored := make(map[string]bool)
+	for _, testCase := range pruneServerEntryTestCases {
+		if !testCase.ExpectPrune {
+			verifyTestCasesStored[testCase.IPAddress] = true
+		}
+	}
+
+	for {
+
+		serverEntry, err := iterator.Next()
+		if err != nil {
+			t.Fatalf("ServerIterator.Next failed: %s", err)
+		}
+		if serverEntry == nil {
+			break
+		}
+
+		var testCase *pruneServerEntryTestCase
+		for i, _ := range pruneServerEntryTestCases {
+			if pruneServerEntryTestCases[i].IPAddress == serverEntry.IpAddress {
+				testCase = pruneServerEntryTestCases[i]
+				break
+			}
+		}
+		if testCase == nil {
+			break
+		}
+
+		if testCase.ExpectPrune {
+			t.Fatalf("expected prune for %s", testCase.IPAddress)
+		} else {
+			delete(verifyTestCasesStored, testCase.IPAddress)
+		}
+	}
+
+	if len(verifyTestCasesStored) > 0 {
+		t.Fatalf("missing prune test case server entries: %+v", verifyTestCasesStored)
+	}
+}

+ 17 - 3
psiphon/serverApi.go

@@ -392,6 +392,8 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 		return nil
 	}
 
+	var response []byte
+
 	if serverContext.psiphonHttpsClient == nil {
 
 		rawMessage := json.RawMessage(statusPayload)
@@ -401,14 +403,14 @@ func (serverContext *ServerContext) DoStatusRequest(tunnel *Tunnel) error {
 		request, err = serverContext.makeSSHAPIRequestPayload(params)
 
 		if err == nil {
-			_, err = serverContext.tunnel.SendAPIRequest(
+			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))
@@ -426,6 +428,16 @@ 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 _, serverEntryTag := range statusResponse.InvalidServerEntryTags {
+		PruneServerEntry(serverContext.tunnel.config, serverEntryTag)
+	}
+
 	return nil
 }
 
@@ -617,7 +629,9 @@ func RecordFailedTunnelStat(
 	}
 
 	params := getBaseAPIParameters(config, dialParams)
-	params["server_entry_ip_address"] = dialParams.ServerEntry.IpAddress
+
+	delete(params, "server_secret")
+	params["server_entry_tag"] = dialParams.ServerEntry.Tag
 	params["last_connected"] = lastConnected
 	params["client_failed_timestamp"] = common.TruncateTimestampToHour(common.GetCurrentTimestamp())
 	params["tunnel_error"] = failedTunnelErrStripAddressRegex.ReplaceAllString(tunnelErr.Error(), "<address>")