Browse Source

Avoid logging destination and domain bytes from previous configurations

When the destination or domain bytes configuration changes, we should stop
logging bytes for the previous configuration. Previously, tunnels would
continue using the configuration as it was at initial connection time. This
was not an issue for most, typically short-lived tunnels. But very long-lived
tunnels produced confusing metrics.
Rod Hynes 3 years ago
parent
commit
228198e2a1

+ 5 - 4
psiphon/server/api.go

@@ -245,7 +245,7 @@ func handshakeAPIRequestHandler(
 	// Note: no guarantee that PsinetDatabase won't reload between database calls
 	db := support.PsinetDatabase
 
-	httpsRequestRegexes := db.GetHttpsRequestRegexes(sponsorID)
+	httpsRequestRegexes, domainBytesChecksum := db.GetHttpsRequestRegexes(sponsorID)
 
 	// Flag the SSH client as having completed its handshake. This
 	// may reselect traffic rules and starts allowing port forwards.
@@ -259,7 +259,7 @@ func handshakeAPIRequestHandler(
 			completed:               true,
 			apiProtocol:             apiProtocol,
 			apiParams:               copyBaseSessionAndDialParams(params),
-			expectDomainBytes:       len(httpsRequestRegexes) > 0,
+			domainBytesChecksum:     domainBytesChecksum,
 			establishedTunnelsCount: establishedTunnelsCount,
 			splitTunnelLookup:       splitTunnelLookup,
 		},
@@ -596,12 +596,13 @@ func statusAPIRequestHandler(
 	// Clients are expected to send host_bytes/domain_bytes stats only when
 	// configured to do so in the handshake reponse. Legacy clients may still
 	// report "(OTHER)" host_bytes when no regexes are set. Drop those stats.
-	domainBytesExpected, err := support.TunnelServer.ExpectClientDomainBytes(sessionID)
+
+	acceptDomainBytes, err := support.TunnelServer.AcceptClientDomainBytes(sessionID)
 	if err != nil {
 		return nil, errors.Trace(err)
 	}
 
-	if domainBytesExpected && statusData["host_bytes"] != nil {
+	if acceptDomainBytes && statusData["host_bytes"] != nil {
 
 		hostBytes, err := getMapStringInt64RequestParam(statusData, "host_bytes")
 		if err != nil {

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

@@ -24,6 +24,7 @@
 package psinet
 
 import (
+	"crypto/md5"
 	"encoding/json"
 	"math"
 	"math/rand"
@@ -66,6 +67,8 @@ type Sponsor struct {
 	MobileHomePages     map[string][]HomePage `json:"mobile_home_pages"`
 	AlertActionURLs     map[string][]string   `json:"alert_action_urls"`
 	HttpsRequestRegexes []HttpsRequestRegex   `json:"https_request_regexes"`
+
+	domainBytesChecksum []byte `json:"-"`
 }
 
 type ClientVersion struct {
@@ -107,6 +110,19 @@ func NewDatabase(filename string) (*Database, error) {
 			database.DiscoveryServers = newDatabase.DiscoveryServers
 			database.fileModTime = fileModTime
 
+			for _, sponsor := range database.Sponsors {
+
+				value, err := json.Marshal(sponsor.HttpsRequestRegexes)
+				if err != nil {
+					return errors.Trace(err)
+				}
+
+				// MD5 hash is used solely as a data checksum and not for any
+				// security purpose.
+				checksum := md5.Sum(value)
+				sponsor.domainBytesChecksum = checksum[:]
+			}
+
 			return nil
 		})
 
@@ -267,9 +283,9 @@ func (db *Database) GetUpgradeClientVersion(clientVersion, clientPlatform string
 	return ""
 }
 
-// GetHttpsRequestRegexes returns bytes transferred stats regexes for the
-// specified sponsor.
-func (db *Database) GetHttpsRequestRegexes(sponsorID string) []map[string]string {
+// GetHttpsRequestRegexes returns bytes transferred stats regexes and the
+// associated checksum for the specified sponsor. The checksum may be nil.
+func (db *Database) GetHttpsRequestRegexes(sponsorID string) ([]map[string]string, []byte) {
 	db.ReloadableFile.RLock()
 	defer db.ReloadableFile.RUnlock()
 
@@ -281,7 +297,7 @@ func (db *Database) GetHttpsRequestRegexes(sponsorID string) []map[string]string
 	}
 
 	if sponsor == nil {
-		return regexes
+		return regexes, nil
 	}
 
 	// If neither sponsorID or DefaultSponsorID were found, sponsor will be the
@@ -293,7 +309,25 @@ func (db *Database) GetHttpsRequestRegexes(sponsorID string) []map[string]string
 		regexes = append(regexes, regex)
 	}
 
-	return regexes
+	return regexes, sponsor.domainBytesChecksum
+}
+
+// GetDomainBytesChecksum returns the bytes transferred stats regexes
+// checksum for the specified sponsor. The checksum may be nil.
+func (db *Database) GetDomainBytesChecksum(sponsorID string) []byte {
+	db.ReloadableFile.RLock()
+	defer db.ReloadableFile.RUnlock()
+
+	sponsor, ok := db.Sponsors[sponsorID]
+	if !ok {
+		sponsor = db.Sponsors[db.DefaultSponsorID]
+	}
+
+	if sponsor == nil {
+		return nil
+	}
+
+	return sponsor.domainBytesChecksum
 }
 
 // DiscoverServers selects new encoded server entries to be "discovered" by

+ 5 - 1
psiphon/server/psinet/psinet_test.go

@@ -20,6 +20,7 @@
 package psinet
 
 import (
+	"bytes"
 	"fmt"
 	"io/ioutil"
 	"os"
@@ -194,7 +195,10 @@ func TestDatabase(t *testing.T) {
 
 	for _, testCase := range httpsRegexTestCases {
 		t.Run(fmt.Sprintf("%+v", testCase), func(t *testing.T) {
-			regexes := db.GetHttpsRequestRegexes(testCase.sponsorID)
+			regexes, checksum := db.GetHttpsRequestRegexes(testCase.sponsorID)
+			if !bytes.Equal(checksum, db.GetDomainBytesChecksum(testCase.sponsorID)) {
+				t.Fatalf("unexpected checksum: %+v", checksum)
+			}
 			var regexValue, replaceValue string
 			ok := false
 			if len(regexes) == 1 && len(regexes[0]) == 2 {

+ 172 - 13
psiphon/server/server_test.go

@@ -137,6 +137,7 @@ func TestSSH(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -161,6 +162,7 @@ func TestOSSH(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -185,6 +187,7 @@ func TestFragmentedOSSH(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -209,6 +212,7 @@ func TestUnfrontedMeek(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -234,6 +238,7 @@ func TestUnfrontedMeekHTTPS(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -259,6 +264,7 @@ func TestUnfrontedMeekHTTPSTLS13(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -284,6 +290,7 @@ func TestUnfrontedMeekSessionTicket(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -309,6 +316,7 @@ func TestUnfrontedMeekSessionTicketTLS13(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -336,6 +344,7 @@ func TestQUICOSSH(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -363,6 +372,7 @@ func TestLimitedQUICOSSH(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    true,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -387,6 +397,7 @@ func TestWebTransportAPIRequests(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -411,6 +422,7 @@ func TestHotReload(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -435,6 +447,7 @@ func TestDefaultSponsorID(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -459,6 +472,7 @@ func TestDenyTrafficRules(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -483,6 +497,7 @@ func TestOmitAuthorization(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -507,6 +522,7 @@ func TestNoAuthorization(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -531,6 +547,7 @@ func TestUnusedAuthorization(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -555,6 +572,7 @@ func TestTCPOnlySLOK(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -579,6 +597,7 @@ func TestUDPOnlySLOK(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -603,6 +622,7 @@ func TestLivenessTest(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -627,6 +647,7 @@ func TestPruneServerEntries(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -651,6 +672,32 @@ func TestBurstMonitorAndDestinationBytes(t *testing.T) {
 			doSplitTunnel:        false,
 			limitQUICVersions:    false,
 			doDestinationBytes:   true,
+			doChangeBytesConfig:  false,
+		})
+}
+
+func TestChangeBytesConfig(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:    false,
+			doPruneServerEntries: false,
+			doDanglingTCPConn:    true,
+			doPacketManipulation: false,
+			doBurstMonitor:       false,
+			doSplitTunnel:        false,
+			limitQUICVersions:    false,
+			doDestinationBytes:   true,
+			doChangeBytesConfig:  true,
 		})
 }
 
@@ -675,6 +722,7 @@ func TestSplitTunnel(t *testing.T) {
 			doSplitTunnel:        true,
 			limitQUICVersions:    false,
 			doDestinationBytes:   false,
+			doChangeBytesConfig:  false,
 		})
 }
 
@@ -698,6 +746,7 @@ type runServerConfig struct {
 	doSplitTunnel        bool
 	limitQUICVersions    bool
 	doDestinationBytes   bool
+	doChangeBytesConfig  bool
 }
 
 var (
@@ -814,7 +863,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	// Pave psinet with random values to test handshake homepages.
 	psinetFilename := filepath.Join(testDataDirName, "psinet.json")
 	sponsorID, expectedHomepageURL := pavePsinetDatabaseFile(
-		t, runConfig.doDefaultSponsorID, psinetFilename, psinetValidServerEntryTags)
+		t, psinetFilename, "", runConfig.doDefaultSponsorID, true, psinetValidServerEntryTags)
 
 	// Pave OSL config for SLOK testing
 	oslConfigFilename := filepath.Join(testDataDirName, "osl_config.json")
@@ -908,8 +957,9 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 	serverConfigJSON, _ = json.Marshal(serverConfig)
 
-	serverTunnelLog := make(chan map[string]interface{}, 1)
 	uniqueUserLog := make(chan map[string]interface{}, 1)
+	domainBytesLog := make(chan map[string]interface{}, 1)
+	serverTunnelLog := make(chan map[string]interface{}, 1)
 
 	setLogCallback(func(log []byte) {
 
@@ -930,6 +980,11 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 			case uniqueUserLog <- logFields:
 			default:
 			}
+		case "domain_bytes":
+			select {
+			case domainBytesLog <- logFields:
+			default:
+			}
 		case "server_tunnel":
 			select {
 			case serverTunnelLog <- logFields:
@@ -991,7 +1046,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 
 		// Pave new config files with different random values.
 		sponsorID, expectedHomepageURL = pavePsinetDatabaseFile(
-			t, runConfig.doDefaultSponsorID, psinetFilename, psinetValidServerEntryTags)
+			t, psinetFilename, "", runConfig.doDefaultSponsorID, true, psinetValidServerEntryTags)
 
 		propagationChannelID = paveOSLConfigFile(t, oslConfigFilename)
 
@@ -1296,8 +1351,47 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	waitOnNotification(t, tunnelsEstablished, timeoutSignal, "tunnel established timeout exceeded")
 	waitOnNotification(t, homepageReceived, timeoutSignal, "homepage received timeout exceeded")
 
+	if runConfig.doChangeBytesConfig {
+
+		if !runConfig.doDestinationBytes {
+			t.Fatalf("invalid test configuration")
+		}
+
+		// Test: now that the client is connected, change the domain bytes and
+		// destination bytes configurations. No stats should be logged, even
+		// with an already connected client.
+
+		// Pave psinet without domain bytes; retain the same sponsor ID. The
+		// random homepage URLs will change, but this has no effect on the
+		// already connected client.
+		_, _ = pavePsinetDatabaseFile(
+			t, psinetFilename, sponsorID, runConfig.doDefaultSponsorID, false, psinetValidServerEntryTags)
+
+		// Pave tactics without destination bytes.
+		paveTacticsConfigFile(
+			t,
+			tacticsConfigFilename,
+			tacticsRequestPublicKey,
+			tacticsRequestPrivateKey,
+			tacticsRequestObfuscatedKey,
+			runConfig.tunnelProtocol,
+			propagationChannelID,
+			livenessTestSize,
+			runConfig.doBurstMonitor,
+			false)
+
+		p, _ := os.FindProcess(os.Getpid())
+		p.Signal(syscall.SIGUSR1)
+
+		// TODO: monitor logs for more robust wait-until-reloaded
+		time.Sleep(1 * time.Second)
+	}
+
 	expectTrafficFailure := runConfig.denyTrafficRules || (runConfig.omitAuthorization && runConfig.requireAuthorization)
 
+	// The client still reports zero domain_bytes when no port forwards are allowed (expectTrafficFailure)
+	expectDomainBytes := !runConfig.doChangeBytesConfig
+
 	if runConfig.doTunneledWebRequest {
 
 		// Test: tunneled web site fetch
@@ -1429,7 +1523,7 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 	if runConfig.limitQUICVersions {
 		expectQUICVersion = limitQUICVersions[0]
 	}
-	expectDestinationBytesFields := runConfig.doDestinationBytes
+	expectDestinationBytesFields := runConfig.doDestinationBytes && !runConfig.doChangeBytesConfig
 
 	select {
 	case logFields := <-serverTunnelLog:
@@ -1472,6 +1566,26 @@ func runServer(t *testing.T, runConfig *runServerConfig) {
 		}
 	}
 
+	if expectDomainBytes {
+		select {
+		case logFields := <-domainBytesLog:
+			err := checkExpectedDomainBytesLogFields(
+				runConfig,
+				logFields)
+			if err != nil {
+				t.Fatalf("invalid domain bytes log fields: %s", err)
+			}
+		default:
+			t.Fatalf("missing domain bytes log")
+		}
+	} else {
+		select {
+		case <-domainBytesLog:
+			t.Fatalf("unexpected domain bytes log")
+		default:
+		}
+	}
+
 	// Check that datastore had retained/pruned server entries as expected.
 	checkPruneServerEntriesTest(t, runConfig, testDataDirName, pruneServerEntryTestCases)
 }
@@ -1971,6 +2085,33 @@ func checkExpectedUniqueUserLogFields(
 	return nil
 }
 
+func checkExpectedDomainBytesLogFields(
+	runConfig *runServerConfig,
+	fields map[string]interface{}) error {
+
+	for _, name := range []string{
+		"session_id",
+		"propagation_channel_id",
+		"sponsor_id",
+		"client_platform",
+		"device_region",
+		"domain",
+		"bytes",
+	} {
+		if fields[name] == nil || fmt.Sprintf("%s", fields[name]) == "" {
+			return fmt.Errorf("missing expected field '%s'", name)
+		}
+
+		if name == "domain" {
+			if fields[name].(string) != "ALL" && fields[name].(string) != "(OTHER)" {
+				return fmt.Errorf("unexpected field value %s: '%v'", name, fields[name])
+			}
+		}
+	}
+
+	return nil
+}
+
 func makeTunneledWebRequest(
 	t *testing.T,
 	localHTTPProxyPort int,
@@ -2215,11 +2356,15 @@ func makeTunneledNTPRequestAttempt(
 
 func pavePsinetDatabaseFile(
 	t *testing.T,
-	useDefaultSponsorID bool,
 	psinetFilename string,
+	sponsorID string,
+	useDefaultSponsorID bool,
+	doDomainBytes bool,
 	validServerEntryTags []string) (string, string) {
 
-	sponsorID := prng.HexString(8)
+	if sponsorID == "" {
+		sponsorID = prng.HexString(8)
+	}
 
 	defaultSponsorID := ""
 	if useDefaultSponsorID {
@@ -2233,20 +2378,21 @@ func pavePsinetDatabaseFile(
 	psinetJSONFormat := `
     {
         "default_sponsor_id" : "%s",
-        "sponsors": {
-            "%s": {
-                "home_pages": {
-                    "None": [
+        "sponsors" : {
+            "%s" : {
+                %s
+                "home_pages" : {
+                    "None" : [
                         {
-                            "region": null,
-                            "url": "%s"
+                            "region" : null,
+                            "url" : "%s"
                         }
                     ]
                 }
             }
         },
         "default_alert_action_urls" : {
-            "%s": %s
+            "%s" : %s
         },
         "valid_server_entry_tags" : {
             %s
@@ -2254,6 +2400,18 @@ func pavePsinetDatabaseFile(
     }
 	`
 
+	domainBytes := ""
+	if doDomainBytes {
+		domainBytes = `
+                "https_request_regexes" : [
+                    {
+                        "regex" : ".*",
+                        "replace" : "ALL"
+                    }
+                ],
+	`
+	}
+
 	actionURLsJSON, _ := json.Marshal(testDisallowedTrafficAlertActionURLs)
 
 	validServerEntryTagsJSON := ""
@@ -2268,6 +2426,7 @@ func pavePsinetDatabaseFile(
 		psinetJSONFormat,
 		defaultSponsorID,
 		sponsorID,
+		domainBytes,
 		expectedHomepageURL,
 		protocol.PSIPHON_API_ALERT_DISALLOWED_TRAFFIC,
 		actionURLsJSON,

+ 70 - 19
psiphon/server/tunnelServer.go

@@ -20,6 +20,7 @@
 package server
 
 import (
+	"bytes"
 	"context"
 	"crypto/rand"
 	"crypto/subtle"
@@ -336,12 +337,12 @@ func (server *TunnelServer) UpdateClientAPIParameters(
 	return server.sshServer.updateClientAPIParameters(sessionID, apiParams)
 }
 
-// ExpectClientDomainBytes indicates whether the client was configured to report
-// domain bytes in its handshake response.
-func (server *TunnelServer) ExpectClientDomainBytes(
+// AcceptClientDomainBytes indicates whether to accept domain bytes reported
+// by the client.
+func (server *TunnelServer) AcceptClientDomainBytes(
 	sessionID string) (bool, error) {
 
-	return server.sshServer.expectClientDomainBytes(sessionID)
+	return server.sshServer.acceptClientDomainBytes(sessionID)
 }
 
 // SetEstablishTunnels sets whether new tunnels may be established or not.
@@ -1194,7 +1195,7 @@ func (sshServer *sshServer) revokeClientAuthorizations(sessionID string) {
 	client.setTrafficRules()
 }
 
-func (sshServer *sshServer) expectClientDomainBytes(
+func (sshServer *sshServer) acceptClientDomainBytes(
 	sessionID string) (bool, error) {
 
 	sshServer.clientsMutex.Lock()
@@ -1205,7 +1206,7 @@ func (sshServer *sshServer) expectClientDomainBytes(
 		return false, errors.TraceNew("unknown session ID")
 	}
 
-	return client.expectDomainBytes(), nil
+	return client.acceptDomainBytes(), nil
 }
 
 func (sshServer *sshServer) stopClients() {
@@ -1556,7 +1557,7 @@ type handshakeState struct {
 	activeAuthorizationIDs  []string
 	authorizedAccessTypes   []string
 	authorizationsRevoked   bool
-	expectDomainBytes       bool
+	domainBytesChecksum     []byte
 	establishedTunnelsCount int
 	splitTunnelLookup       *splitTunnelLookup
 }
@@ -2056,6 +2057,11 @@ func (sshClient *sshClient) run(
 	replayMetrics["server_replay_packet_manipulation"] = sshClient.replayedServerPacketManipulation
 	additionalMetrics = append(additionalMetrics, replayMetrics)
 
+	// Limitation: there's only one log per tunnel with bytes transferred
+	// metrics, so the byte count can't be attributed to a certain day for
+	// tunnels that remain connected for well over 24h. In practise, most
+	// tunnels are short-lived, especially on mobile devices.
+
 	sshClient.logTunnel(additionalMetrics)
 
 	// Transfer OSL seed state -- the OSL progress -- from the closing
@@ -2907,17 +2913,39 @@ func (sshClient *sshClient) logTunnel(additionalMetrics []LogFields) {
 
 	if sshClient.destinationBytesMetricsASN != "" {
 
-		bytesUpTCP := sshClient.tcpDestinationBytesMetrics.getBytesUp()
-		bytesDownTCP := sshClient.tcpDestinationBytesMetrics.getBytesDown()
-		bytesUpUDP := sshClient.udpDestinationBytesMetrics.getBytesUp()
-		bytesDownUDP := sshClient.udpDestinationBytesMetrics.getBytesDown()
+		// Check if the configured DestinationBytesMetricsASN has changed
+		// (or been cleared). If so, don't log and discard the accumulated
+		// bytes to ensure we don't continue to record stats as previously
+		// configured.
+		//
+		// Any counts accumulated before the DestinationBytesMetricsASN change
+		// are lost. At this time we can't change
+		// sshClient.destinationBytesMetricsASN dynamically, after a tactics
+		// hot reload, as there may be destination bytes port forwards that
+		// were in place before the change, which will continue to count.
+
+		logDestBytes := true
+		if sshClient.sshServer.support.ServerTacticsParametersCache != nil {
+			p, err := sshClient.sshServer.support.ServerTacticsParametersCache.Get(sshClient.geoIPData)
+			if err != nil || p.IsNil() ||
+				sshClient.destinationBytesMetricsASN != p.String(parameters.DestinationBytesMetricsASN) {
+				logDestBytes = false
+			}
+		}
 
-		logFields["dest_bytes_asn"] = sshClient.destinationBytesMetricsASN
-		logFields["dest_bytes_up_tcp"] = bytesUpTCP
-		logFields["dest_bytes_down_tcp"] = bytesDownTCP
-		logFields["dest_bytes_up_udp"] = bytesUpUDP
-		logFields["dest_bytes_down_udp"] = bytesDownUDP
-		logFields["dest_bytes"] = bytesUpTCP + bytesDownTCP + bytesUpUDP + bytesDownUDP
+		if logDestBytes {
+			bytesUpTCP := sshClient.tcpDestinationBytesMetrics.getBytesUp()
+			bytesDownTCP := sshClient.tcpDestinationBytesMetrics.getBytesDown()
+			bytesUpUDP := sshClient.udpDestinationBytesMetrics.getBytesUp()
+			bytesDownUDP := sshClient.udpDestinationBytesMetrics.getBytesDown()
+
+			logFields["dest_bytes_asn"] = sshClient.destinationBytesMetricsASN
+			logFields["dest_bytes_up_tcp"] = bytesUpTCP
+			logFields["dest_bytes_down_tcp"] = bytesDownTCP
+			logFields["dest_bytes_up_udp"] = bytesUpUDP
+			logFields["dest_bytes_down_udp"] = bytesDownUDP
+			logFields["dest_bytes"] = bytesUpTCP + bytesDownTCP + bytesUpUDP + bytesDownUDP
+		}
 	}
 
 	// Only log fields for peakMetrics when there is data recorded, otherwise
@@ -3422,11 +3450,34 @@ func (sshClient *sshClient) updateAPIParameters(
 	}
 }
 
-func (sshClient *sshClient) expectDomainBytes() bool {
+func (sshClient *sshClient) acceptDomainBytes() bool {
 	sshClient.Lock()
 	defer sshClient.Unlock()
 
-	return sshClient.handshakeState.expectDomainBytes
+	// When the domain bytes checksum differs from the checksum sent to the
+	// client in the handshake response, the psinet regex configuration has
+	// changed. In this case, drop the stats so we don't continue to record
+	// stats as previously configured.
+	//
+	// Limitations:
+	// - The checksum comparison may result in dropping some stats for a
+	//   domain that remains in the new configuration.
+	// - We don't push new regexs to the clients, so clients that remain
+	//   connected will continue to send stats that will be dropped; and
+	//   those clients will not send stats as newly configured until after
+	//   reconnecting.
+	// - Due to the design of
+	//   transferstats.ReportRecentBytesTransferredForServer in the client,
+	//   the client may accumulate stats, reconnect before its next status
+	//   request, get a new regex configuration, and then send the previously
+	//   accumulated stats in its next status request. The checksum scheme
+	//   won't prevent the reporting of those stats.
+
+	sponsorID, _ := getStringRequestParam(sshClient.handshakeState.apiParams, "sponsor_id")
+
+	domainBytesChecksum := sshClient.sshServer.support.PsinetDatabase.GetDomainBytesChecksum(sponsorID)
+
+	return bytes.Equal(sshClient.handshakeState.domainBytesChecksum, domainBytesChecksum)
 }
 
 // setOSLConfig resets the client's OSL seed state based on the latest OSL config