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

Add server-side tactics parameters lookup cache

Rod Hynes 5 лет назад
Родитель
Сommit
21124cf8ef

+ 1 - 0
README.md

@@ -176,6 +176,7 @@ Psiphon Tunnel Core uses:
 * [hashicorp/golang-lru](https://github.com/hashicorp/golang-lru)
 * [hashicorp/golang-lru](https://github.com/hashicorp/golang-lru)
 * [juju/ratelimit](https://github.com/juju/ratelimit)
 * [juju/ratelimit](https://github.com/juju/ratelimit)
 * [kardianos/osext](https://github.com/kardianos/osext)
 * [kardianos/osext](https://github.com/kardianos/osext)
+* [groupcache/lru]("github.com/golang/groupcache/lru")
 * [lucas-clemente/quic-go](https://github.com/lucas-clemente/quic-go)
 * [lucas-clemente/quic-go](https://github.com/lucas-clemente/quic-go)
 * [marusama/semaphore](https://github.com/marusama/semaphore)
 * [marusama/semaphore](https://github.com/marusama/semaphore)
 * [mdlayher/netlink)](https://github.com/mdlayher/netlink)
 * [mdlayher/netlink)](https://github.com/mdlayher/netlink)

+ 107 - 5
psiphon/common/tactics/tactics.go

@@ -250,11 +250,20 @@ type Server struct {
 	// condition (vs., say, checking for a zero-value Server).
 	// condition (vs., say, checking for a zero-value Server).
 	loaded bool
 	loaded bool
 
 
+	filterGeoIPScope   int
+	filterRegionScopes map[string]int
+
 	logger                common.Logger
 	logger                common.Logger
 	logFieldFormatter     common.APIParameterLogFieldFormatter
 	logFieldFormatter     common.APIParameterLogFieldFormatter
 	apiParameterValidator common.APIParameterValidator
 	apiParameterValidator common.APIParameterValidator
 }
 }
 
 
+const (
+	GeoIPScopeRegion = 1
+	GeoIPScopeISP    = 2
+	GeoIPScopeCity   = 4
+)
+
 // Filter defines a filter to match against client attributes.
 // Filter defines a filter to match against client attributes.
 // Each field within the filter is optional and may be omitted.
 // Each field within the filter is optional and may be omitted.
 type Filter struct {
 type Filter struct {
@@ -579,6 +588,9 @@ const stringLookupThreshold = 5
 // slice.
 // slice.
 func (server *Server) initLookups() {
 func (server *Server) initLookups() {
 
 
+	server.filterGeoIPScope = 0
+	server.filterRegionScopes = make(map[string]int)
+
 	for _, filteredTactics := range server.FilteredTactics {
 	for _, filteredTactics := range server.FilteredTactics {
 
 
 		if len(filteredTactics.Filter.Regions) >= stringLookupThreshold {
 		if len(filteredTactics.Filter.Regions) >= stringLookupThreshold {
@@ -602,11 +614,68 @@ func (server *Server) initLookups() {
 			}
 			}
 		}
 		}
 
 
+		// Initialize the filter GeoIP scope fields used by GetFilterGeoIPScope.
+		//
+		// The basic case is, for example, when only Regions appear in filters, then
+		// only GeoIPScopeRegion is set.
+		//
+		// As an optimization, a regional map is populated so that, for example,
+		// GeoIPScopeRegion&GeoIPScopeISP will be set only for regions for which
+		// there is a filter with region and ISP, while other regions will set only
+		// GeoIPScopeRegion.
+		//
+		// When any ISP or City appears in a filter without a Region, the regional
+		// map optimization is disabled.
+
+		if len(filteredTactics.Filter.Regions) == 0 {
+			if len(filteredTactics.Filter.ISPs) > 0 {
+				server.filterGeoIPScope |= GeoIPScopeISP
+				server.filterRegionScopes = nil
+			}
+			if len(filteredTactics.Filter.Cities) > 0 {
+				server.filterGeoIPScope |= GeoIPScopeCity
+				server.filterRegionScopes = nil
+			}
+		} else {
+			server.filterGeoIPScope |= GeoIPScopeRegion
+			if server.filterRegionScopes != nil {
+				regionScope := 0
+				if len(filteredTactics.Filter.ISPs) > 0 {
+					regionScope |= GeoIPScopeISP
+				}
+				if len(filteredTactics.Filter.Cities) > 0 {
+					regionScope |= GeoIPScopeCity
+				}
+				for _, region := range filteredTactics.Filter.Regions {
+					server.filterRegionScopes[region] |= regionScope
+				}
+			}
+		}
+
 		// TODO: add lookups for APIParameters?
 		// TODO: add lookups for APIParameters?
 		// Not expected to be long lists of values.
 		// Not expected to be long lists of values.
 	}
 	}
 }
 }
 
 
+// GetFilterGeoIPScope returns which GeoIP fields are relevent to tactics
+// filters. The return value is a bit array containing some combination of the
+// GeoIPScopeRegion, GeoIPScopeISP, and GeoIPScopeCity flags. For the given
+// geoIPData, all tactics filters reference only the flagged fields.
+func (server *Server) GetFilterGeoIPScope(geoIPData common.GeoIPData) int {
+
+	scope := server.filterGeoIPScope
+
+	if server.filterRegionScopes != nil {
+
+		regionScope, ok := server.filterRegionScopes[geoIPData.Country]
+		if ok {
+			scope |= regionScope
+		}
+	}
+
+	return scope
+}
+
 // GetTacticsPayload assembles and returns a tactics payload for a client with
 // GetTacticsPayload assembles and returns a tactics payload for a client with
 // the specified GeoIP, API parameter, and speed test attributes.
 // the specified GeoIP, API parameter, and speed test attributes.
 //
 //
@@ -635,15 +704,11 @@ func (server *Server) GetTacticsPayload(
 		return nil, nil
 		return nil, nil
 	}
 	}
 
 
-	marshaledTactics, err := json.Marshal(tactics)
+	marshaledTactics, tag, err := marshalTactics(tactics)
 	if err != nil {
 	if err != nil {
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
 
 
-	// MD5 hash is used solely as a data checksum and not for any security purpose.
-	digest := md5.Sum(marshaledTactics)
-	tag := hex.EncodeToString(digest[:])
-
 	payload := &Payload{
 	payload := &Payload{
 		Tag: tag,
 		Tag: tag,
 	}
 	}
@@ -676,6 +741,43 @@ func (server *Server) GetTacticsPayload(
 	return payload, nil
 	return payload, nil
 }
 }
 
 
+func marshalTactics(tactics *Tactics) ([]byte, string, error) {
+	marshaledTactics, err := json.Marshal(tactics)
+	if err != nil {
+		return nil, "", errors.Trace(err)
+	}
+
+	// MD5 hash is used solely as a data checksum and not for any security purpose.
+	digest := md5.Sum(marshaledTactics)
+	tag := hex.EncodeToString(digest[:])
+
+	return marshaledTactics, tag, nil
+}
+
+// GetTacticsWithTag returns a GetTactics value along with the associated tag value.
+func (server *Server) GetTacticsWithTag(
+	includeServerSideOnly bool,
+	geoIPData common.GeoIPData,
+	apiParams common.APIParameters) (*Tactics, string, error) {
+
+	tactics, err := server.GetTactics(
+		includeServerSideOnly, geoIPData, apiParams)
+	if err != nil {
+		return nil, "", errors.Trace(err)
+	}
+
+	if tactics == nil {
+		return nil, "", nil
+	}
+
+	_, tag, err := marshalTactics(tactics)
+	if err != nil {
+		return nil, "", errors.Trace(err)
+	}
+
+	return tactics, tag, nil
+}
+
 // GetTactics assembles and returns tactics data for a client with the
 // GetTactics assembles and returns tactics data for a client with the
 // specified GeoIP, API parameter, and speed test attributes.
 // specified GeoIP, API parameter, and speed test attributes.
 //
 //

+ 232 - 0
psiphon/common/tactics/tactics_test.go

@@ -698,6 +698,238 @@ func TestTactics(t *testing.T) {
 	// TODO: test Server.Validate with invalid tactics configurations
 	// TODO: test Server.Validate with invalid tactics configurations
 }
 }
 
 
+func TestTacticsFilterGeoIPScope(t *testing.T) {
+
+	encodedRequestPublicKey, encodedRequestPrivateKey, encodedObfuscatedKey, err := GenerateKeys()
+	if err != nil {
+		t.Fatalf("GenerateKeys failed: %s", err)
+	}
+
+	tacticsConfigTemplate := fmt.Sprintf(`
+    {
+      "RequestPublicKey" : "%s",
+      "RequestPrivateKey" : "%s",
+      "RequestObfuscatedKey" : "%s",
+      "DefaultTactics" : {
+        "TTL" : "60s",
+        "Probability" : 1.0
+      },
+      %%s
+      ]
+    }
+    `, encodedRequestPublicKey, encodedRequestPrivateKey, encodedObfuscatedKey)
+
+	// Test: region-only scope
+
+	filteredTactics := `
+      "FilteredTactics" : [
+        {
+          "Filter" : {
+            "Regions": ["R1", "R2", "R3"]
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R4", "R5", "R6"]
+          }
+        }
+	`
+
+	tacticsConfig := fmt.Sprintf(tacticsConfigTemplate, filteredTactics)
+
+	file, err := ioutil.TempFile("", "tactics.config")
+	if err != nil {
+		t.Fatalf("TempFile create failed: %s", err)
+	}
+	_, err = file.Write([]byte(tacticsConfig))
+	if err != nil {
+		t.Fatalf("TempFile write failed: %s", err)
+	}
+	file.Close()
+
+	configFileName := file.Name()
+	defer os.Remove(configFileName)
+
+	server, err := NewServer(
+		nil,
+		nil,
+		nil,
+		configFileName)
+	if err != nil {
+		t.Fatalf("NewServer failed: %s", err)
+	}
+
+	geoIPData := common.GeoIPData{
+		Country: "R0",
+		ISP:     "I0",
+		City:    "C0",
+	}
+
+	scope := server.GetFilterGeoIPScope(geoIPData)
+
+	if scope != GeoIPScopeRegion {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+
+	// Test: ISP-only scope
+
+	filteredTactics = `
+      "FilteredTactics" : [
+        {
+          "Filter" : {
+            "ISPs": ["I1", "I2", "I3"]
+          }
+        },
+        {
+          "Filter" : {
+            "ISPs": ["I4", "I5", "I6"]
+          }
+        }
+	`
+
+	reload := func() {
+		tacticsConfig = fmt.Sprintf(tacticsConfigTemplate, filteredTactics)
+
+		err = ioutil.WriteFile(configFileName, []byte(tacticsConfig), 0600)
+		if err != nil {
+			t.Fatalf("WriteFile failed: %s", err)
+		}
+
+		reloaded, err := server.Reload()
+		if err != nil {
+			t.Fatalf("Reload failed: %s", err)
+		}
+
+		if !reloaded {
+			t.Fatalf("Server config failed to reload")
+		}
+	}
+
+	reload()
+
+	scope = server.GetFilterGeoIPScope(geoIPData)
+
+	if scope != GeoIPScopeISP {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+
+	// Test: City-only scope
+
+	filteredTactics = `
+      "FilteredTactics" : [
+        {
+          "Filter" : {
+            "Cities": ["C1", "C2", "C3"]
+          }
+        },
+        {
+          "Filter" : {
+            "Cities": ["C4", "C5", "C6"]
+          }
+        }
+	`
+
+	reload()
+
+	scope = server.GetFilterGeoIPScope(geoIPData)
+
+	if scope != GeoIPScopeCity {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+
+	// Test: full scope
+
+	filteredTactics = `
+      "FilteredTactics" : [
+        {
+          "Filter" : {
+            "Regions": ["R1", "R2", "R3"]
+          }
+        },
+        {
+          "Filter" : {
+            "ISPs": ["I1", "I2", "I3"]
+          }
+        },
+        {
+          "Filter" : {
+            "Cities": ["C4", "C5", "C6"]
+          }
+        }
+	`
+
+	reload()
+
+	scope = server.GetFilterGeoIPScope(geoIPData)
+
+	if scope != GeoIPScopeRegion|GeoIPScopeISP|GeoIPScopeCity {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+
+	// Test: conditional scopes
+
+	filteredTactics = `
+      "FilteredTactics" : [
+        {
+          "Filter" : {
+            "Regions": ["R1"]
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R2"],
+            "ISPs": ["I2a"]
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R2"],
+            "ISPs": ["I2b"]
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R3"],
+            "ISPs": ["I3a"],
+            "Cities": ["C3a"]
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R3"],
+            "ISPs": ["I3b"],
+            "Cities": ["C3b"]
+          }
+        }
+	`
+
+	reload()
+
+	scope = server.GetFilterGeoIPScope(common.GeoIPData{Country: "R0"})
+
+	if scope != GeoIPScopeRegion {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+
+	scope = server.GetFilterGeoIPScope(common.GeoIPData{Country: "R1"})
+
+	if scope != GeoIPScopeRegion {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+
+	scope = server.GetFilterGeoIPScope(common.GeoIPData{Country: "R2"})
+
+	if scope != GeoIPScopeRegion|GeoIPScopeISP {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+
+	scope = server.GetFilterGeoIPScope(common.GeoIPData{Country: "R3"})
+
+	if scope != GeoIPScopeRegion|GeoIPScopeISP|GeoIPScopeCity {
+		t.Fatalf("unexpected scope: %d", scope)
+	}
+}
+
 type testStorer struct {
 type testStorer struct {
 	tacticsRecords         map[string][]byte
 	tacticsRecords         map[string][]byte
 	speedTestSampleRecords map[string][]byte
 	speedTestSampleRecords map[string][]byte

+ 1 - 2
psiphon/server/bpf.go

@@ -27,7 +27,6 @@ import (
 	"syscall"
 	"syscall"
 	"unsafe"
 	"unsafe"
 
 
-	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/prng"
@@ -92,7 +91,7 @@ func newTCPListenerWithBPF(
 
 
 func getBPFProgram(support *SupportServices) (bool, string, []bpf.RawInstruction, error) {
 func getBPFProgram(support *SupportServices) (bool, string, []bpf.RawInstruction, error) {
 
 
-	p, err := GetServerTacticsParameters(r.support, NewGeoIPData())
+	p, err := support.ServerTacticsParametersCache.Get(NewGeoIPData())
 	if err != nil {
 	if err != nil {
 		return false, "", nil, errors.Trace(err)
 		return false, "", nil, errors.Trace(err)
 	}
 	}

+ 1 - 2
psiphon/server/listener.go

@@ -74,8 +74,7 @@ func (listener *TacticsListener) Accept() (net.Conn, error) {
 	geoIPData := listener.geoIPLookup(
 	geoIPData := listener.geoIPLookup(
 		common.IPAddressFromAddr(conn.RemoteAddr()))
 		common.IPAddressFromAddr(conn.RemoteAddr()))
 
 
-	p, err := GetServerTacticsParameters(
-		listener.support, geoIPData)
+	p, err := listener.support.ServerTacticsParametersCache.Get(geoIPData)
 	if err != nil {
 	if err != nil {
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}

+ 2 - 0
psiphon/server/listener_test.go

@@ -148,6 +148,8 @@ func TestListener(t *testing.T) {
 				TacticsServer: tacticsServer,
 				TacticsServer: tacticsServer,
 			}
 			}
 			support.ReplayCache = NewReplayCache(support)
 			support.ReplayCache = NewReplayCache(support)
+			support.ServerTacticsParametersCache =
+				NewServerTacticsParametersCache(support)
 
 
 			tacticsListener := NewTacticsListener(
 			tacticsListener := NewTacticsListener(
 				support,
 				support,

+ 3 - 3
psiphon/server/packetman.go

@@ -75,10 +75,10 @@ func makePacketManipulatorConfig(
 func getPacketManipulationSpecs(support *SupportServices) ([]*packetman.Spec, error) {
 func getPacketManipulationSpecs(support *SupportServices) ([]*packetman.Spec, error) {
 
 
 	// By convention, parameters.ServerPacketManipulationSpecs should be in
 	// By convention, parameters.ServerPacketManipulationSpecs should be in
-	// DefaultTactics, not FilteredTactics; and GetServerTacticsParameters
+	// DefaultTactics, not FilteredTactics; and ServerTacticsParametersCache
 	// ignores Tactics.Probability.
 	// ignores Tactics.Probability.
 
 
-	p, err := GetServerTacticsParameters(support, NewGeoIPData())
+	p, err := support.ServerTacticsParametersCache.Get(NewGeoIPData())
 	if err != nil {
 	if err != nil {
 		return nil, errors.Trace(err)
 		return nil, errors.Trace(err)
 	}
 	}
@@ -158,7 +158,7 @@ func selectPacketManipulationSpec(
 	// entry are allowed, enabling weighted selection. If a spec appears in both
 	// entry are allowed, enabling weighted selection. If a spec appears in both
 	// "All" and a specific protocol, the duplicate(s) are retained.
 	// "All" and a specific protocol, the duplicate(s) are retained.
 
 
-	p, err := GetServerTacticsParameters(support, geoIPData)
+	p, err := support.ServerTacticsParametersCache.Get(geoIPData)
 	if err != nil {
 	if err != nil {
 		return "", nil, errors.Trace(err)
 		return "", nil, errors.Trace(err)
 	}
 	}

+ 6 - 6
psiphon/server/replay.go

@@ -115,10 +115,10 @@ func (r *ReplayCache) GetMetrics() LogFields {
 func (r *ReplayCache) GetReplayTargetDuration(
 func (r *ReplayCache) GetReplayTargetDuration(
 	geoIPData GeoIPData) (bool, time.Duration, time.Duration) {
 	geoIPData GeoIPData) (bool, time.Duration, time.Duration) {
 
 
-	p, err := GetServerTacticsParameters(r.support, geoIPData)
+	p, err := r.support.ServerTacticsParametersCache.Get(geoIPData)
 	if err != nil {
 	if err != nil {
 		log.WithTraceFields(LogFields{"error": errors.Trace(err)}).Warning(
 		log.WithTraceFields(LogFields{"error": errors.Trace(err)}).Warning(
-			"GetServerTacticsParameters failed")
+			"ServerTacticsParametersCache.Get failed")
 		return false, 0, 0
 		return false, 0, 0
 	}
 	}
 
 
@@ -164,10 +164,10 @@ func (r *ReplayCache) SetReplayParameters(
 	tunneledBytesUp int64,
 	tunneledBytesUp int64,
 	tunneledBytesDown int64) {
 	tunneledBytesDown int64) {
 
 
-	p, err := GetServerTacticsParameters(r.support, geoIPData)
+	p, err := r.support.ServerTacticsParametersCache.Get(geoIPData)
 	if err != nil {
 	if err != nil {
 		log.WithTraceFields(LogFields{"error": errors.Trace(err)}).Warning(
 		log.WithTraceFields(LogFields{"error": errors.Trace(err)}).Warning(
-			"GetServerTacticsParameters failed")
+			"ServerTacticsParametersCache.Get failed")
 		return
 		return
 	}
 	}
 
 
@@ -300,10 +300,10 @@ func (r *ReplayCache) FailedReplayParameters(
 	packetManipulationSpecName string,
 	packetManipulationSpecName string,
 	fragmentorSeed *prng.Seed) {
 	fragmentorSeed *prng.Seed) {
 
 
-	p, err := GetServerTacticsParameters(r.support, geoIPData)
+	p, err := r.support.ServerTacticsParametersCache.Get(geoIPData)
 	if err != nil {
 	if err != nil {
 		log.WithTraceFields(LogFields{"error": errors.Trace(err)}).Warning(
 		log.WithTraceFields(LogFields{"error": errors.Trace(err)}).Warning(
-			"GetServerTacticsParameters failed")
+			"ServerTacticsParametersCache.Get failed")
 		return
 		return
 	}
 	}
 
 

+ 20 - 13
psiphon/server/services.go

@@ -378,6 +378,8 @@ func logServerLoad(support *SupportServices) {
 
 
 	serverLoad.Add(support.ReplayCache.GetMetrics())
 	serverLoad.Add(support.ReplayCache.GetMetrics())
 
 
+	serverLoad.Add(support.ServerTacticsParametersCache.GetMetrics())
+
 	protocolStats, regionStats :=
 	protocolStats, regionStats :=
 		support.TunnelServer.GetLoadStats()
 		support.TunnelServer.GetLoadStats()
 
 
@@ -428,18 +430,19 @@ func logIrregularTunnel(
 // components, which allows these data components to be refreshed
 // components, which allows these data components to be refreshed
 // without restarting the server process.
 // without restarting the server process.
 type SupportServices struct {
 type SupportServices struct {
-	Config             *Config
-	TrafficRulesSet    *TrafficRulesSet
-	OSLConfig          *osl.Config
-	PsinetDatabase     *psinet.Database
-	GeoIPService       *GeoIPService
-	DNSResolver        *DNSResolver
-	TunnelServer       *TunnelServer
-	PacketTunnelServer *tun.Server
-	TacticsServer      *tactics.Server
-	Blocklist          *Blocklist
-	PacketManipulator  *packetman.Manipulator
-	ReplayCache        *ReplayCache
+	Config                       *Config
+	TrafficRulesSet              *TrafficRulesSet
+	OSLConfig                    *osl.Config
+	PsinetDatabase               *psinet.Database
+	GeoIPService                 *GeoIPService
+	DNSResolver                  *DNSResolver
+	TunnelServer                 *TunnelServer
+	PacketTunnelServer           *tun.Server
+	TacticsServer                *tactics.Server
+	Blocklist                    *Blocklist
+	PacketManipulator            *packetman.Manipulator
+	ReplayCache                  *ReplayCache
+	ServerTacticsParametersCache *ServerTacticsParametersCache
 }
 }
 
 
 // NewSupportServices initializes a new SupportServices.
 // NewSupportServices initializes a new SupportServices.
@@ -498,6 +501,9 @@ func NewSupportServices(config *Config) (*SupportServices, error) {
 
 
 	support.ReplayCache = NewReplayCache(support)
 	support.ReplayCache = NewReplayCache(support)
 
 
+	support.ServerTacticsParametersCache =
+		NewServerTacticsParametersCache(support)
+
 	return support, nil
 	return support, nil
 }
 }
 
 
@@ -521,8 +527,9 @@ func (support *SupportServices) Reload() {
 
 
 	reloadTactics := func() {
 	reloadTactics := func() {
 
 
-		// Don't replay using stale tactics.
+		// Don't use stale tactics.
 		support.ReplayCache.Flush()
 		support.ReplayCache.Flush()
+		support.ServerTacticsParametersCache.Flush()
 
 
 		if support.Config.RunPacketManipulator {
 		if support.Config.RunPacketManipulator {
 			err := reloadPacketManipulationSpecs(support)
 			err := reloadPacketManipulationSpecs(support)

+ 194 - 7
psiphon/server/tactics.go

@@ -20,22 +20,122 @@
 package server
 package server
 
 
 import (
 import (
+	"fmt"
+	"sync"
+
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/errors"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
 	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
+	"github.com/golang/groupcache/lru"
+)
+
+const (
+	TACTICS_CACHE_MAX_ENTRIES = 10000
 )
 )
 
 
-// GetServerTacticsParameters returns server-side tactics parameters for the
-// specified GeoIP scope. GetServerTacticsParameters is designed to be called
-// before the API handshake and does not filter by API parameters. IsNil
-// guards must be used when accessing the returned ParametersAccessor.
-func GetServerTacticsParameters(
-	support *SupportServices,
+// ServerTacticsParametersCache is a cache of filtered server-side tactics,
+// intended to speed-up frequent tactics lookups.
+//
+// Presently, the cache is targeted at pre-handshake lookups which are both
+// the most time critical and have a low tactic cardinality, as only GeoIP
+// filter inputs are available.
+//
+// There is no TTL for cache entries as the cached filtered tactics remain
+// valid until the tactics config changes; Flush must be called on tactics
+// config hot reloads.
+type ServerTacticsParametersCache struct {
+	support             *SupportServices
+	mutex               sync.Mutex
+	tacticsCache        *lru.Cache
+	parameterReferences map[string]*parameterReference
+	metrics             *serverTacticsParametersCacheMetrics
+}
+
+type parameterReference struct {
+	params         *parameters.Parameters
+	referenceCount int
+}
+
+type serverTacticsParametersCacheMetrics struct {
+	MaxCacheEntries        int64
+	MaxParameterReferences int64
+	CacheHitCount          int64
+	CacheMissCount         int64
+}
+
+// NewServerTacticsParametersCache creates a new ServerTacticsParametersCache.
+func NewServerTacticsParametersCache(
+	support *SupportServices) *ServerTacticsParametersCache {
+
+	cache := &ServerTacticsParametersCache{
+		support:             support,
+		tacticsCache:        lru.New(TACTICS_CACHE_MAX_ENTRIES),
+		parameterReferences: make(map[string]*parameterReference),
+		metrics:             &serverTacticsParametersCacheMetrics{},
+	}
+
+	cache.tacticsCache.OnEvicted = cache.onEvicted
+
+	return cache
+}
+
+// GetMetrics returns a snapshop of current ServerTacticsParametersCache event
+// counters and resets all counters to zero.
+func (c *ServerTacticsParametersCache) GetMetrics() LogFields {
+
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	logFields := LogFields{
+		"server_tactics_max_cache_entries":        c.metrics.MaxCacheEntries,
+		"server_tactics_max_parameter_references": c.metrics.MaxParameterReferences,
+		"server_tactics_cache_hit_count":          c.metrics.CacheHitCount,
+		"server_tactics_cache_miss_count":         c.metrics.CacheMissCount,
+	}
+
+	c.metrics = &serverTacticsParametersCacheMetrics{}
+
+	return logFields
+}
+
+// Get returns server-side tactics parameters for the specified GeoIP scope.
+// Get is designed to be called before the API handshake and does not filter
+// by API parameters. IsNil guards must be used when accessing the returned
+// ParametersAccessor.
+func (c *ServerTacticsParametersCache) Get(
 	geoIPData GeoIPData) (parameters.ParametersAccessor, error) {
 	geoIPData GeoIPData) (parameters.ParametersAccessor, error) {
 
 
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
 	nilAccessor := parameters.MakeNilParametersAccessor()
 	nilAccessor := parameters.MakeNilParametersAccessor()
 
 
-	tactics, err := support.TacticsServer.GetTactics(
+	key := c.makeKey(geoIPData)
+
+	// Check for cached result.
+
+	if tag, ok := c.tacticsCache.Get(key); ok {
+		paramRef, ok := c.parameterReferences[tag.(string)]
+		if !ok {
+			return nilAccessor, errors.TraceNew("missing parameters")
+		}
+
+		c.metrics.CacheHitCount += 1
+
+		// The returned accessor is read-only, and paramRef.params is never
+		// modified, so the return value is safe of concurrent use and may be
+		// references both while the entry remains in the cache or after it is
+		// evicted.
+
+		return paramRef.params.Get(), nil
+	}
+
+	c.metrics.CacheMissCount += 1
+
+	// Construct parameters from tactics.
+
+	tactics, tag, err := c.support.TacticsServer.GetTacticsWithTag(
 		true, common.GeoIPData(geoIPData), make(common.APIParameters))
 		true, common.GeoIPData(geoIPData), make(common.APIParameters))
 	if err != nil {
 	if err != nil {
 		return nilAccessor, errors.Trace(err)
 		return nilAccessor, errors.Trace(err)
@@ -57,5 +157,92 @@ func GetServerTacticsParameters(
 		return nilAccessor, errors.Trace(err)
 		return nilAccessor, errors.Trace(err)
 	}
 	}
 
 
+	// Update the cache.
+	//
+	// Two optimizations are used to limit the memory size of the cache:
+	//
+	// 1. The scope of the GeoIP data cache key is limited to the fields --
+	// Country/ISP/City -- that are present in tactics filters. E.g., if only
+	// Country appears in filters, then the key will omit ISP and City.
+	//
+	// 2. Two maps are maintained: GeoIP-key -> tactics-tag; and tactics-tag ->
+	// parameters. For N keys with the same filtered parameters, the mapped value
+	// overhead is N tags and 1 larger parameters data structure.
+	//
+	// If the cache is full, the LRU entry will be ejected.
+
+	// Update the parameterRefence _before_ calling Add: if Add happens to evict
+	// the last other entry referencing the same parameters, this order avoids an
+	// unnecessary delete/re-add.
+
+	paramRef, ok := c.parameterReferences[tag]
+	if !ok {
+		c.parameterReferences[tag] = &parameterReference{
+			params:         params,
+			referenceCount: 1,
+		}
+	} else {
+		paramRef.referenceCount += 1
+	}
+	c.tacticsCache.Add(key, tag)
+
+	cacheSize := int64(c.tacticsCache.Len())
+	if cacheSize > c.metrics.MaxCacheEntries {
+		c.metrics.MaxCacheEntries = cacheSize
+	}
+
+	paramRefsSize := int64(len(c.parameterReferences))
+	if paramRefsSize > c.metrics.MaxParameterReferences {
+		c.metrics.MaxParameterReferences = paramRefsSize
+	}
+
 	return params.Get(), nil
 	return params.Get(), nil
 }
 }
+
+func (c *ServerTacticsParametersCache) Flush() {
+	c.mutex.Lock()
+	defer c.mutex.Unlock()
+
+	// onEvicted will clear c.parameterReferences.
+
+	c.tacticsCache.Clear()
+}
+
+func (c *ServerTacticsParametersCache) onEvicted(
+	key lru.Key, value interface{}) {
+
+	// Cleanup unreferenced parameterReferences. Assumes mutex is held by Get,
+	// which calls Add, which may call onEvicted.
+
+	tag := value.(string)
+
+	paramRef, ok := c.parameterReferences[tag]
+	if !ok {
+		return
+	}
+
+	paramRef.referenceCount -= 1
+	if paramRef.referenceCount == 0 {
+		delete(c.parameterReferences, tag)
+	}
+}
+
+func (c *ServerTacticsParametersCache) makeKey(geoIPData GeoIPData) string {
+
+	scope := c.support.TacticsServer.GetFilterGeoIPScope(
+		common.GeoIPData(geoIPData))
+
+	var region, ISP, city string
+
+	if scope&tactics.GeoIPScopeRegion != 0 {
+		region = geoIPData.Country
+	}
+	if scope&tactics.GeoIPScopeISP != 0 {
+		ISP = geoIPData.ISP
+	}
+	if scope&tactics.GeoIPScopeCity != 0 {
+		city = geoIPData.City
+	}
+
+	return fmt.Sprintf("%s-%s-%s", region, ISP, city)
+}

+ 278 - 0
psiphon/server/tactics_test.go

@@ -0,0 +1,278 @@
+/*
+ * Copyright (c) 2020, Psiphon Inc.
+ * All rights reserved.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+package server
+
+import (
+	"fmt"
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/parameters"
+	"github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon/common/tactics"
+)
+
+func TestServerTacticsParametersCache(t *testing.T) {
+
+	tacticsConfigJSONFormat := `
+    {
+      "RequestPublicKey" : "%s",
+      "RequestPrivateKey" : "%s",
+      "RequestObfuscatedKey" : "%s",
+      "DefaultTactics" : {
+        "TTL" : "60s",
+        "Probability" : 1.0,
+        "Parameters" : {
+          "ConnectionWorkerPoolSize" : 1
+        }
+      },
+      "FilteredTactics" : [
+        {
+          "Filter" : {
+            "Regions": ["R1"]
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "ConnectionWorkerPoolSize" : 2
+            }
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R2"],
+            "ISPs": ["I2a"]
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "ConnectionWorkerPoolSize" : 3
+            }
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R2"],
+            "ISPs": ["I2b"]
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "ConnectionWorkerPoolSize" : 4
+            }
+          }
+        },
+        {
+          "Filter" : {
+            "Regions": ["R2"],
+            "ISPs": ["I2c"]
+          },
+          "Tactics" : {
+            "Parameters" : {
+              "ConnectionWorkerPoolSize" : 4
+            }
+          }
+        }
+      ]
+    }
+    `
+
+	tacticsRequestPublicKey, tacticsRequestPrivateKey, tacticsRequestObfuscatedKey, err :=
+		tactics.GenerateKeys()
+	if err != nil {
+		t.Fatalf("error generating tactics keys: %s", err)
+	}
+
+	tacticsConfigJSON := fmt.Sprintf(
+		tacticsConfigJSONFormat,
+		tacticsRequestPublicKey, tacticsRequestPrivateKey, tacticsRequestObfuscatedKey)
+
+	tacticsConfigFilename := filepath.Join(testDataDirName, "tactics_config.json")
+
+	err = ioutil.WriteFile(tacticsConfigFilename, []byte(tacticsConfigJSON), 0600)
+	if err != nil {
+		t.Fatalf("error paving tactics config file: %s", err)
+	}
+
+	tacticsServer, err := tactics.NewServer(
+		nil,
+		nil,
+		nil,
+		tacticsConfigFilename)
+	if err != nil {
+		t.Fatalf("NewServer failed: %s", err)
+	}
+
+	support := &SupportServices{
+		TacticsServer: tacticsServer,
+	}
+	support.ReplayCache = NewReplayCache(support)
+	support.ServerTacticsParametersCache =
+		NewServerTacticsParametersCache(support)
+
+	keySplitTestCases := []struct {
+		description                          string
+		geoIPData                            GeoIPData
+		expectedConnectionWorkerPoolSize     int
+		expectedCacheSizeBefore              int
+		expectedCacheSizeAfter               int
+		expectedParameterReferencesSizeAfter int
+	}{
+		{
+			"add new cache entry, default parameter",
+			GeoIPData{Country: "R0", ISP: "I0", City: "C0"},
+			1,
+			0, 1, 1,
+		},
+		{
+			"region already cached, region-only key",
+			GeoIPData{Country: "R0", ISP: "I1", City: "C1"},
+			1,
+			1, 1, 1,
+		},
+		{
+			"add new cache entry, filtered parameter",
+			GeoIPData{Country: "R1", ISP: "I1a", City: "C1a"},
+			2,
+			1, 2, 2,
+		},
+		{
+			"region already cached, region-only key",
+			GeoIPData{Country: "R1", ISP: "I1a", City: "C1a"},
+			2,
+			2, 2, 2,
+		},
+		{
+			"region already cached, region-only key",
+			GeoIPData{Country: "R1", ISP: "I1b", City: "C1b"},
+			2,
+			2, 2, 2,
+		},
+		{
+			"region already cached, region-only key",
+			GeoIPData{Country: "R1", ISP: "I1b", City: "C1c"},
+			2,
+			2, 2, 2,
+		},
+		{
+			"add new cache entry, filtered parameter, region/ISP key",
+			GeoIPData{Country: "R2", ISP: "I2a", City: "C2a"},
+			3,
+			2, 3, 3,
+		},
+		{
+			"region/ISP already cached",
+			GeoIPData{Country: "R2", ISP: "I2a", City: "C2a"},
+			3,
+			3, 3, 3,
+		},
+		{
+			"region/ISP already cached, city is ignored",
+			GeoIPData{Country: "R2", ISP: "I2a", City: "C2b"},
+			3,
+			3, 3, 3,
+		},
+		{
+			"add new cache entry, filtered parameter, region/ISP key",
+			GeoIPData{Country: "R2", ISP: "I2b", City: "C2a"},
+			4,
+			3, 4, 4,
+		},
+		{
+			"region/ISP already cached, city is ignored",
+			GeoIPData{Country: "R2", ISP: "I2b", City: "C2b"},
+			4,
+			4, 4, 4,
+		},
+		{
+			"add new cache entry, filtered parameter, region/ISP key, duplicate parameters",
+			GeoIPData{Country: "R2", ISP: "I2c", City: "C2a"},
+			4,
+			4, 5, 4,
+		},
+	}
+
+	for _, testCase := range keySplitTestCases {
+		t.Run(testCase.description, func(t *testing.T) {
+
+			support.ServerTacticsParametersCache.mutex.Lock()
+			cacheSize := support.ServerTacticsParametersCache.tacticsCache.Len()
+			support.ServerTacticsParametersCache.mutex.Unlock()
+			if cacheSize != testCase.expectedCacheSizeBefore {
+				t.Fatalf("unexpected tacticsCache size before lookup: %d", cacheSize)
+			}
+
+			p, err := support.ServerTacticsParametersCache.Get(testCase.geoIPData)
+			if err != nil {
+				t.Fatalf("ServerTacticsParametersCache.Get failed: %d", err)
+			}
+
+			connectionWorkerPoolSize := p.Int(parameters.ConnectionWorkerPoolSize)
+			if connectionWorkerPoolSize != testCase.expectedConnectionWorkerPoolSize {
+				t.Fatalf("unexpected ConnectionWorkerPoolSize value: %d", connectionWorkerPoolSize)
+			}
+
+			support.ServerTacticsParametersCache.mutex.Lock()
+			cacheSize = support.ServerTacticsParametersCache.tacticsCache.Len()
+			support.ServerTacticsParametersCache.mutex.Unlock()
+			if cacheSize != testCase.expectedCacheSizeAfter {
+				t.Fatalf("unexpected cache size after lookup: %d", cacheSize)
+			}
+
+			support.ServerTacticsParametersCache.mutex.Lock()
+			paramRefsSize := len(support.ServerTacticsParametersCache.parameterReferences)
+			support.ServerTacticsParametersCache.mutex.Unlock()
+			if paramRefsSize != testCase.expectedParameterReferencesSizeAfter {
+				t.Fatalf("unexpected parameterReferences size after lookup: %d", paramRefsSize)
+			}
+
+		})
+	}
+
+	metrics := support.ServerTacticsParametersCache.GetMetrics()
+	if metrics["server_tactics_max_cache_entries"].(int64) != 5 ||
+		metrics["server_tactics_max_parameter_references"].(int64) != 4 ||
+		metrics["server_tactics_cache_hit_count"].(int64) != 7 ||
+		metrics["server_tactics_cache_miss_count"].(int64) != 5 {
+
+		t.Fatalf("unexpected metrics: %v", metrics)
+	}
+
+	// Test: force eviction and check parameterReferences cleanup.
+
+	for i := 0; i < TACTICS_CACHE_MAX_ENTRIES*2; i++ {
+		_, err := support.ServerTacticsParametersCache.Get(
+			GeoIPData{Country: "R2", ISP: fmt.Sprintf("I-%d", i), City: "C2a"})
+		if err != nil {
+			t.Fatalf("ServerTacticsParametersCache.Get failed: %d", err)
+		}
+	}
+
+	support.ServerTacticsParametersCache.mutex.Lock()
+	cacheSize := support.ServerTacticsParametersCache.tacticsCache.Len()
+	paramRefsSize := len(support.ServerTacticsParametersCache.parameterReferences)
+	support.ServerTacticsParametersCache.mutex.Unlock()
+
+	if cacheSize != TACTICS_CACHE_MAX_ENTRIES {
+		t.Fatalf("unexpected tacticsCache size before lookup: %d", cacheSize)
+
+	}
+
+	if paramRefsSize != 1 {
+		t.Fatalf("unexpected parameterReferences size after lookup: %d", paramRefsSize)
+	}
+}

+ 191 - 0
vendor/github.com/golang/groupcache/LICENSE

@@ -0,0 +1,191 @@
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, "control" means (i) the power, direct or
+indirect, to cause the direction or management of such entity, whether by
+contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
+outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+"Object" form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object code,
+generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made
+available under the License, as indicated by a copyright notice that is included
+in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative Works
+shall not include works that remain separable from, or merely link (or bind by
+name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative Works
+thereof, that is intentionally submitted to Licensor for inclusion in the Work
+by the copyright owner or by an individual or Legal Entity authorized to submit
+on behalf of the copyright owner. For the purposes of this definition,
+"submitted" means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor for
+the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+2. Grant of Copyright License.
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the Work and such
+Derivative Works in Source or Object form.
+
+3. Grant of Patent License.
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable (except as stated in this section) patent license to make, have
+made, use, offer to sell, sell, import, and otherwise transfer the Work, where
+such license applies only to those patent claims licensable by such Contributor
+that are necessarily infringed by their Contribution(s) alone or by combination
+of their Contribution(s) with the Work to which such Contribution(s) was
+submitted. If You institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work or a
+Contribution incorporated within the Work constitutes direct or contributory
+patent infringement, then any patent licenses granted to You under this License
+for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution.
+
+You may reproduce and distribute copies of the Work or Derivative Works thereof
+in any medium, with or without modifications, and in Source or Object form,
+provided that You meet the following conditions:
+
+You must give any other recipients of the Work or Derivative Works a copy of
+this License; and
+You must cause any modified files to carry prominent notices stating that You
+changed the files; and
+You must retain, in the Source form of any Derivative Works that You distribute,
+all copyright, patent, trademark, and attribution notices from the Source form
+of the Work, excluding those notices that do not pertain to any part of the
+Derivative Works; and
+If the Work includes a "NOTICE" text file as part of its distribution, then any
+Derivative Works that You distribute must include a readable copy of the
+attribution notices contained within such NOTICE file, excluding those notices
+that do not pertain to any part of the Derivative Works, in at least one of the
+following places: within a NOTICE text file distributed as part of the
+Derivative Works; within the Source form or documentation, if provided along
+with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents of
+the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works that
+You distribute, alongside or as an addendum to the NOTICE text from the Work,
+provided that such additional attribution notices cannot be construed as
+modifying the License.
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a whole,
+provided Your use, reproduction, and distribution of the Work otherwise complies
+with the conditions stated in this License.
+
+5. Submission of Contributions.
+
+Unless You explicitly state otherwise, any Contribution intentionally submitted
+for inclusion in the Work by You to the Licensor shall be under the terms and
+conditions of this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify the terms of
+any separate license agreement you may have executed with Licensor regarding
+such Contributions.
+
+6. Trademarks.
+
+This License does not grant permission to use the trade names, trademarks,
+service marks, or product names of the Licensor, except as required for
+reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty.
+
+Unless required by applicable law or agreed to in writing, Licensor provides the
+Work (and each Contributor provides its Contributions) on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
+including, without limitation, any warranties or conditions of TITLE,
+NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
+solely responsible for determining the appropriateness of using or
+redistributing the Work and assume any risks associated with Your exercise of
+permissions under this License.
+
+8. Limitation of Liability.
+
+In no event and under no legal theory, whether in tort (including negligence),
+contract, or otherwise, unless required by applicable law (such as deliberate
+and grossly negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special, incidental,
+or consequential damages of any character arising as a result of this License or
+out of the use or inability to use the Work (including but not limited to
+damages for loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses), even if such Contributor has
+been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability.
+
+While redistributing the Work or Derivative Works thereof, You may choose to
+offer, and charge a fee for, acceptance of support, warranty, indemnity, or
+other liability obligations and/or rights consistent with this License. However,
+in accepting such obligations, You may act only on Your own behalf and on Your
+sole responsibility, not on behalf of any other Contributor, and only if You
+agree to indemnify, defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason of your
+accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work
+
+To apply the Apache License to your work, attach the following boilerplate
+notice, with the fields enclosed by brackets "[]" replaced with your own
+identifying information. (Don't include the brackets!) The text should be
+enclosed in the appropriate comment syntax for the file format. We also
+recommend that a file or class name and description of purpose be included on
+the same "printed page" as the copyright notice for easier identification within
+third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 133 - 0
vendor/github.com/golang/groupcache/lru/lru.go

@@ -0,0 +1,133 @@
+/*
+Copyright 2013 Google Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package lru implements an LRU cache.
+package lru
+
+import "container/list"
+
+// Cache is an LRU cache. It is not safe for concurrent access.
+type Cache struct {
+	// MaxEntries is the maximum number of cache entries before
+	// an item is evicted. Zero means no limit.
+	MaxEntries int
+
+	// OnEvicted optionally specifies a callback function to be
+	// executed when an entry is purged from the cache.
+	OnEvicted func(key Key, value interface{})
+
+	ll    *list.List
+	cache map[interface{}]*list.Element
+}
+
+// A Key may be any value that is comparable. See http://golang.org/ref/spec#Comparison_operators
+type Key interface{}
+
+type entry struct {
+	key   Key
+	value interface{}
+}
+
+// New creates a new Cache.
+// If maxEntries is zero, the cache has no limit and it's assumed
+// that eviction is done by the caller.
+func New(maxEntries int) *Cache {
+	return &Cache{
+		MaxEntries: maxEntries,
+		ll:         list.New(),
+		cache:      make(map[interface{}]*list.Element),
+	}
+}
+
+// Add adds a value to the cache.
+func (c *Cache) Add(key Key, value interface{}) {
+	if c.cache == nil {
+		c.cache = make(map[interface{}]*list.Element)
+		c.ll = list.New()
+	}
+	if ee, ok := c.cache[key]; ok {
+		c.ll.MoveToFront(ee)
+		ee.Value.(*entry).value = value
+		return
+	}
+	ele := c.ll.PushFront(&entry{key, value})
+	c.cache[key] = ele
+	if c.MaxEntries != 0 && c.ll.Len() > c.MaxEntries {
+		c.RemoveOldest()
+	}
+}
+
+// Get looks up a key's value from the cache.
+func (c *Cache) Get(key Key) (value interface{}, ok bool) {
+	if c.cache == nil {
+		return
+	}
+	if ele, hit := c.cache[key]; hit {
+		c.ll.MoveToFront(ele)
+		return ele.Value.(*entry).value, true
+	}
+	return
+}
+
+// Remove removes the provided key from the cache.
+func (c *Cache) Remove(key Key) {
+	if c.cache == nil {
+		return
+	}
+	if ele, hit := c.cache[key]; hit {
+		c.removeElement(ele)
+	}
+}
+
+// RemoveOldest removes the oldest item from the cache.
+func (c *Cache) RemoveOldest() {
+	if c.cache == nil {
+		return
+	}
+	ele := c.ll.Back()
+	if ele != nil {
+		c.removeElement(ele)
+	}
+}
+
+func (c *Cache) removeElement(e *list.Element) {
+	c.ll.Remove(e)
+	kv := e.Value.(*entry)
+	delete(c.cache, kv.key)
+	if c.OnEvicted != nil {
+		c.OnEvicted(kv.key, kv.value)
+	}
+}
+
+// Len returns the number of items in the cache.
+func (c *Cache) Len() int {
+	if c.cache == nil {
+		return 0
+	}
+	return c.ll.Len()
+}
+
+// Clear purges all stored items from the cache.
+func (c *Cache) Clear() {
+	if c.OnEvicted != nil {
+		for _, e := range c.cache {
+			kv := e.Value.(*entry)
+			c.OnEvicted(kv.key, kv.value)
+		}
+	}
+	c.ll = nil
+	c.cache = nil
+}

+ 6 - 0
vendor/vendor.json

@@ -328,6 +328,12 @@
 			"revision": "f00a7392b43971b2fdb562418faab1f18da2067a",
 			"revision": "f00a7392b43971b2fdb562418faab1f18da2067a",
 			"revisionTime": "2018-04-02T14:15:43Z"
 			"revisionTime": "2018-04-02T14:15:43Z"
 		},
 		},
+		{
+			"checksumSHA1": "LHNzQwau1zPeFPPG5zbNf8AgUOQ=",
+			"path": "github.com/golang/groupcache/lru",
+			"revision": "8c9f03a8e57eb486e42badaed3fb287da51807ba",
+			"revisionTime": "2020-01-21T04:51:36Z"
+		},
 		{
 		{
 			"checksumSHA1": "Y2MOwzNZfl4NRNDbLCZa6sgx7O0=",
 			"checksumSHA1": "Y2MOwzNZfl4NRNDbLCZa6sgx7O0=",
 			"path": "github.com/golang/protobuf/proto",
 			"path": "github.com/golang/protobuf/proto",