TUN-8807: Add support_datagram_v3 to remote feature rollout

Support rolling out the `support_datagram_v3` feature via remote feature rollout (DNS TXT record) with `dv3` key.

Consolidated some of the feature evaluation code into the features module to simplify the lookup of available features at runtime.

Reduced complexity for management logs feature lookup since it's a default feature.

Closes TUN-8807
This commit is contained in:
Devin Carr 2025-01-06 09:15:18 -08:00
parent 5cfe9bef79
commit 3b522a27cf
7 changed files with 274 additions and 95 deletions

View File

@ -31,7 +31,6 @@ import (
"github.com/cloudflare/cloudflared/credentials" "github.com/cloudflare/cloudflared/credentials"
"github.com/cloudflare/cloudflared/diagnostic" "github.com/cloudflare/cloudflared/diagnostic"
"github.com/cloudflare/cloudflared/edgediscovery" "github.com/cloudflare/cloudflared/edgediscovery"
"github.com/cloudflare/cloudflared/features"
"github.com/cloudflare/cloudflared/ingress" "github.com/cloudflare/cloudflared/ingress"
"github.com/cloudflare/cloudflared/logger" "github.com/cloudflare/cloudflared/logger"
"github.com/cloudflare/cloudflared/management" "github.com/cloudflare/cloudflared/management"
@ -515,26 +514,23 @@ func StartServer(
tunnelConfig.ICMPRouterServer = nil tunnelConfig.ICMPRouterServer = nil
} }
internalRules := []ingress.Rule{} serviceIP := c.String("service-op-ip")
if features.Contains(features.FeatureManagementLogs) { if edgeAddrs, err := edgediscovery.ResolveEdge(log, tunnelConfig.Region, tunnelConfig.EdgeIPVersion); err == nil {
serviceIP := c.String("service-op-ip") if serviceAddr, err := edgeAddrs.GetAddrForRPC(); err == nil {
if edgeAddrs, err := edgediscovery.ResolveEdge(log, tunnelConfig.Region, tunnelConfig.EdgeIPVersion); err == nil { serviceIP = serviceAddr.TCP.String()
if serviceAddr, err := edgeAddrs.GetAddrForRPC(); err == nil {
serviceIP = serviceAddr.TCP.String()
}
} }
mgmt := management.New(
c.String("management-hostname"),
c.Bool("management-diagnostics"),
serviceIP,
clientID,
c.String(connectorLabelFlag),
logger.ManagementLogger.Log,
logger.ManagementLogger,
)
internalRules = []ingress.Rule{ingress.NewManagementRule(mgmt)}
} }
mgmt := management.New(
c.String("management-hostname"),
c.Bool("management-diagnostics"),
serviceIP,
clientID,
c.String(connectorLabelFlag),
logger.ManagementLogger.Log,
logger.ManagementLogger,
)
internalRules := []ingress.Rule{ingress.NewManagementRule(mgmt)}
orchestrator, err := orchestration.NewOrchestrator(ctx, orchestratorConfig, tunnelConfig.Tags, internalRules, tunnelConfig.Log) orchestrator, err := orchestration.NewOrchestrator(ctx, orchestratorConfig, tunnelConfig.Tags, internalRules, tunnelConfig.Log)
if err != nil { if err != nil {
return err return err

View File

@ -137,20 +137,15 @@ func prepareTunnelConfig(
transportProtocol := c.String("protocol") transportProtocol := c.String("protocol")
clientFeatures := features.Dedup(append(c.StringSlice("features"), features.DefaultFeatures...)) if c.Bool("post-quantum") && FipsEnabled {
return nil, nil, fmt.Errorf("post-quantum not supported in FIPS mode")
staticFeatures := features.StaticFeatures{}
if c.Bool("post-quantum") {
if FipsEnabled {
return nil, nil, fmt.Errorf("post-quantum not supported in FIPS mode")
}
pqMode := features.PostQuantumStrict
staticFeatures.PostQuantumMode = &pqMode
} }
featureSelector, err := features.NewFeatureSelector(ctx, namedTunnel.Credentials.AccountTag, staticFeatures, log)
featureSelector, err := features.NewFeatureSelector(ctx, namedTunnel.Credentials.AccountTag, c.StringSlice("features"), c.Bool("post-quantum"), log)
if err != nil { if err != nil {
return nil, nil, errors.Wrap(err, "Failed to create feature selector") return nil, nil, errors.Wrap(err, "Failed to create feature selector")
} }
clientFeatures := featureSelector.ClientFeatures()
pqMode := featureSelector.PostQuantumMode() pqMode := featureSelector.PostQuantumMode()
if pqMode == features.PostQuantumStrict { if pqMode == features.PostQuantumStrict {
// Error if the user tries to force a non-quic transport protocol // Error if the user tries to force a non-quic transport protocol
@ -158,7 +153,6 @@ func prepareTunnelConfig(
return nil, nil, fmt.Errorf("post-quantum is only supported with the quic transport") return nil, nil, fmt.Errorf("post-quantum is only supported with the quic transport")
} }
transportProtocol = connection.QUIC.String() transportProtocol = connection.QUIC.String()
clientFeatures = append(clientFeatures, features.FeaturePostQuantum)
log.Info().Msgf( log.Info().Msgf(
"Using hybrid post-quantum key agreement %s", "Using hybrid post-quantum key agreement %s",

View File

@ -12,7 +12,7 @@ const (
) )
var ( var (
DefaultFeatures = []string{ defaultFeatures = []string{
FeatureAllowRemoteConfig, FeatureAllowRemoteConfig,
FeatureSerializedHeaders, FeatureSerializedHeaders,
FeatureDatagramV2, FeatureDatagramV2,
@ -21,15 +21,30 @@ var (
} }
) )
func Contains(feature string) bool { // Features set by user provided flags
for _, f := range DefaultFeatures { type staticFeatures struct {
if f == feature { PostQuantumMode *PostQuantumMode
return true
}
}
return false
} }
type PostQuantumMode uint8
const (
// Prefer post quantum, but fallback if connection cannot be established
PostQuantumPrefer PostQuantumMode = iota
// If the user passes the --post-quantum flag, we override
// CurvePreferences to only support hybrid post-quantum key agreements.
PostQuantumStrict
)
type DatagramVersion string
const (
// DatagramV2 is the currently supported datagram protocol for UDP and ICMP packets
DatagramV2 DatagramVersion = FeatureDatagramV2
// DatagramV3 is a new datagram protocol for UDP and ICMP packets. It is not backwards compatible with datagram v2.
DatagramV3 DatagramVersion = FeatureDatagramV3
)
// Remove any duplicates from the slice // Remove any duplicates from the slice
func Dedup(slice []string) []string { func Dedup(slice []string) []string {

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"hash/fnv" "hash/fnv"
"net" "net"
"slices"
"sync" "sync"
"time" "time"
@ -18,61 +19,67 @@ const (
lookupTimeout = time.Second * 10 lookupTimeout = time.Second * 10
) )
type PostQuantumMode uint8
const (
// Prefer post quantum, but fallback if connection cannot be established
PostQuantumPrefer PostQuantumMode = iota
// If the user passes the --post-quantum flag, we override
// CurvePreferences to only support hybrid post-quantum key agreements.
PostQuantumStrict
)
// If the TXT record adds other fields, the umarshal logic will ignore those keys // If the TXT record adds other fields, the umarshal logic will ignore those keys
// If the TXT record is missing a key, the field will unmarshal to the default Go value // If the TXT record is missing a key, the field will unmarshal to the default Go value
// pq was removed in TUN-7970
type featuresRecord struct{}
func NewFeatureSelector(ctx context.Context, accountTag string, staticFeatures StaticFeatures, logger *zerolog.Logger) (*FeatureSelector, error) { type featuresRecord struct {
return newFeatureSelector(ctx, accountTag, logger, newDNSResolver(), staticFeatures, defaultRefreshFreq) // support_datagram_v3
DatagramV3Percentage int32 `json:"dv3"`
// PostQuantumPercentage int32 `json:"pq"` // Removed in TUN-7970
} }
// FeatureSelector determines if this account will try new features. It preiodically queries a DNS TXT record func NewFeatureSelector(ctx context.Context, accountTag string, cliFeatures []string, pq bool, logger *zerolog.Logger) (*FeatureSelector, error) {
// to see which features are turned on return newFeatureSelector(ctx, accountTag, logger, newDNSResolver(), cliFeatures, pq, defaultRefreshFreq)
}
// FeatureSelector determines if this account will try new features. It periodically queries a DNS TXT record
// to see which features are turned on.
type FeatureSelector struct { type FeatureSelector struct {
accountHash int32 accountHash int32
logger *zerolog.Logger logger *zerolog.Logger
resolver resolver resolver resolver
staticFeatures StaticFeatures staticFeatures staticFeatures
cliFeatures []string
// lock protects concurrent access to dynamic features // lock protects concurrent access to dynamic features
lock sync.RWMutex lock sync.RWMutex
features featuresRecord features featuresRecord
} }
// Features set by user provided flags func newFeatureSelector(ctx context.Context, accountTag string, logger *zerolog.Logger, resolver resolver, cliFeatures []string, pq bool, refreshFreq time.Duration) (*FeatureSelector, error) {
type StaticFeatures struct { // Combine default features and user-provided features
PostQuantumMode *PostQuantumMode var pqMode *PostQuantumMode
} if pq {
mode := PostQuantumStrict
func newFeatureSelector(ctx context.Context, accountTag string, logger *zerolog.Logger, resolver resolver, staticFeatures StaticFeatures, refreshFreq time.Duration) (*FeatureSelector, error) { pqMode = &mode
cliFeatures = append(cliFeatures, FeaturePostQuantum)
}
staticFeatures := staticFeatures{
PostQuantumMode: pqMode,
}
selector := &FeatureSelector{ selector := &FeatureSelector{
accountHash: switchThreshold(accountTag), accountHash: switchThreshold(accountTag),
logger: logger, logger: logger,
resolver: resolver, resolver: resolver,
staticFeatures: staticFeatures, staticFeatures: staticFeatures,
cliFeatures: Dedup(cliFeatures),
} }
if err := selector.refresh(ctx); err != nil { if err := selector.refresh(ctx); err != nil {
logger.Err(err).Msg("Failed to fetch features, default to disable") logger.Err(err).Msg("Failed to fetch features, default to disable")
} }
// Run refreshLoop next time we have a new feature to rollout go selector.refreshLoop(ctx, refreshFreq)
return selector, nil return selector, nil
} }
func (fs *FeatureSelector) accountEnabled(percentage int32) bool {
return percentage > fs.accountHash
}
func (fs *FeatureSelector) PostQuantumMode() PostQuantumMode { func (fs *FeatureSelector) PostQuantumMode() PostQuantumMode {
if fs.staticFeatures.PostQuantumMode != nil { if fs.staticFeatures.PostQuantumMode != nil {
return *fs.staticFeatures.PostQuantumMode return *fs.staticFeatures.PostQuantumMode
@ -81,6 +88,33 @@ func (fs *FeatureSelector) PostQuantumMode() PostQuantumMode {
return PostQuantumPrefer return PostQuantumPrefer
} }
func (fs *FeatureSelector) DatagramVersion() DatagramVersion {
fs.lock.RLock()
defer fs.lock.RUnlock()
// If user provides the feature via the cli, we take it as priority over remote feature evaluation
if slices.Contains(fs.cliFeatures, FeatureDatagramV3) {
return DatagramV3
}
// If the user specifies DatagramV2, we also take that over remote
if slices.Contains(fs.cliFeatures, FeatureDatagramV2) {
return DatagramV2
}
if fs.accountEnabled(fs.features.DatagramV3Percentage) {
return DatagramV3
}
return DatagramV2
}
// ClientFeatures will return the list of currently available features that cloudflared should provide to the edge.
//
// This list is dynamic and can change in-between returns.
func (fs *FeatureSelector) ClientFeatures() []string {
// Evaluate any remote features along with static feature list to construct the list of features
return Dedup(slices.Concat(defaultFeatures, fs.cliFeatures, []string{string(fs.DatagramVersion())}))
}
func (fs *FeatureSelector) refreshLoop(ctx context.Context, refreshFreq time.Duration) { func (fs *FeatureSelector) refreshLoop(ctx context.Context, refreshFreq time.Duration) {
ticker := time.NewTicker(refreshFreq) ticker := time.NewTicker(refreshFreq)
for { for {

View File

@ -13,16 +13,20 @@ import (
func TestUnmarshalFeaturesRecord(t *testing.T) { func TestUnmarshalFeaturesRecord(t *testing.T) {
tests := []struct { tests := []struct {
record []byte record []byte
expectedPercentage int32
}{ }{
{ {
record: []byte(`{"pq":0}`), record: []byte(`{"dv3":0}`),
expectedPercentage: 0,
}, },
{ {
record: []byte(`{"pq":39}`), record: []byte(`{"dv3":39}`),
expectedPercentage: 39,
}, },
{ {
record: []byte(`{"pq":100}`), record: []byte(`{"dv3":100}`),
expectedPercentage: 100,
}, },
{ {
record: []byte(`{}`), // Unmarshal to default struct if key is not present record: []byte(`{}`), // Unmarshal to default struct if key is not present
@ -36,37 +40,186 @@ func TestUnmarshalFeaturesRecord(t *testing.T) {
var features featuresRecord var features featuresRecord
err := json.Unmarshal(test.record, &features) err := json.Unmarshal(test.record, &features)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, featuresRecord{}, features) require.Equal(t, test.expectedPercentage, features.DatagramV3Percentage, test)
} }
} }
func TestFeaturePrecedenceEvaluationPostQuantum(t *testing.T) {
logger := zerolog.Nop()
tests := []struct {
name string
cli bool
expectedFeatures []string
expectedVersion PostQuantumMode
}{
{
name: "default",
cli: false,
expectedFeatures: defaultFeatures,
expectedVersion: PostQuantumPrefer,
},
{
name: "user_specified",
cli: true,
expectedFeatures: Dedup(append(defaultFeatures, FeaturePostQuantum)),
expectedVersion: PostQuantumStrict,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := &staticResolver{record: featuresRecord{}}
selector, err := newFeatureSelector(context.Background(), test.name, &logger, resolver, []string{}, test.cli, time.Second)
require.NoError(t, err)
require.ElementsMatch(t, test.expectedFeatures, selector.ClientFeatures())
require.Equal(t, test.expectedVersion, selector.PostQuantumMode())
})
}
}
func TestFeaturePrecedenceEvaluationDatagramVersion(t *testing.T) {
logger := zerolog.Nop()
tests := []struct {
name string
cli []string
remote featuresRecord
expectedFeatures []string
expectedVersion DatagramVersion
}{
{
name: "default",
cli: []string{},
remote: featuresRecord{},
expectedFeatures: defaultFeatures,
expectedVersion: DatagramV2,
},
{
name: "user_specified_v2",
cli: []string{FeatureDatagramV2},
remote: featuresRecord{},
expectedFeatures: defaultFeatures,
expectedVersion: DatagramV2,
},
{
name: "user_specified_v3",
cli: []string{FeatureDatagramV3},
remote: featuresRecord{},
expectedFeatures: Dedup(append(defaultFeatures, FeatureDatagramV3)),
expectedVersion: FeatureDatagramV3,
},
{
name: "remote_specified_v3",
cli: []string{},
remote: featuresRecord{
DatagramV3Percentage: 100,
},
expectedFeatures: Dedup(append(defaultFeatures, FeatureDatagramV3)),
expectedVersion: FeatureDatagramV3,
},
{
name: "remote_and_user_specified_v3",
cli: []string{FeatureDatagramV3},
remote: featuresRecord{
DatagramV3Percentage: 100,
},
expectedFeatures: Dedup(append(defaultFeatures, FeatureDatagramV3)),
expectedVersion: FeatureDatagramV3,
},
{
name: "remote_v3_and_user_specified_v2",
cli: []string{FeatureDatagramV2},
remote: featuresRecord{
DatagramV3Percentage: 100,
},
expectedFeatures: defaultFeatures,
expectedVersion: DatagramV2,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
resolver := &staticResolver{record: test.remote}
selector, err := newFeatureSelector(context.Background(), test.name, &logger, resolver, test.cli, false, time.Second)
require.NoError(t, err)
require.ElementsMatch(t, test.expectedFeatures, selector.ClientFeatures())
require.Equal(t, test.expectedVersion, selector.DatagramVersion())
})
}
}
func TestRefreshFeaturesRecord(t *testing.T) {
// The hash of the accountTag is 82
accountTag := t.Name()
threshold := switchThreshold(accountTag)
percentages := []int32{0, 10, 81, 82, 83, 100, 101, 1000}
refreshFreq := time.Millisecond * 10
selector := newTestSelector(t, percentages, false, refreshFreq)
// Starting out should default to DatagramV2
require.Equal(t, DatagramV2, selector.DatagramVersion())
for _, percentage := range percentages {
if percentage > threshold {
require.Equal(t, DatagramV3, selector.DatagramVersion())
} else {
require.Equal(t, DatagramV2, selector.DatagramVersion())
}
time.Sleep(refreshFreq + time.Millisecond)
}
// Make sure error doesn't override the last fetched features
require.Equal(t, DatagramV3, selector.DatagramVersion())
}
func TestStaticFeatures(t *testing.T) { func TestStaticFeatures(t *testing.T) {
pqMode := PostQuantumStrict percentages := []int32{0}
selector := newTestSelector(t, &pqMode, time.Millisecond*10) // PostQuantum Enabled from user flag
selector := newTestSelector(t, percentages, true, time.Millisecond*10)
require.Equal(t, PostQuantumStrict, selector.PostQuantumMode()) require.Equal(t, PostQuantumStrict, selector.PostQuantumMode())
// No StaticFeatures configured // PostQuantum Disabled (or not set)
selector = newTestSelector(t, nil, time.Millisecond*10) selector = newTestSelector(t, percentages, false, time.Millisecond*10)
require.Equal(t, PostQuantumPrefer, selector.PostQuantumMode()) require.Equal(t, PostQuantumPrefer, selector.PostQuantumMode())
} }
func newTestSelector(t *testing.T, pqMode *PostQuantumMode, refreshFreq time.Duration) *FeatureSelector { func newTestSelector(t *testing.T, percentages []int32, pq bool, refreshFreq time.Duration) *FeatureSelector {
accountTag := t.Name() accountTag := t.Name()
logger := zerolog.Nop() logger := zerolog.Nop()
resolver := &mockResolver{} resolver := &mockResolver{
percentages: percentages,
staticFeatures := StaticFeatures{
PostQuantumMode: pqMode,
} }
selector, err := newFeatureSelector(context.Background(), accountTag, &logger, resolver, staticFeatures, refreshFreq)
selector, err := newFeatureSelector(context.Background(), accountTag, &logger, resolver, []string{}, pq, refreshFreq)
require.NoError(t, err) require.NoError(t, err)
return selector return selector
} }
type mockResolver struct{} type mockResolver struct {
nextIndex int
percentages []int32
}
func (mr *mockResolver) lookupRecord(ctx context.Context) ([]byte, error) { func (mr *mockResolver) lookupRecord(ctx context.Context) ([]byte, error) {
return nil, fmt.Errorf("mockResolver hasn't implement lookupRecord") if mr.nextIndex >= len(mr.percentages) {
return nil, fmt.Errorf("no more record to lookup")
}
record, err := json.Marshal(featuresRecord{
DatagramV3Percentage: mr.percentages[mr.nextIndex],
})
mr.nextIndex++
return record, err
}
type staticResolver struct {
record featuresRecord
}
func (r *staticResolver) lookupRecord(ctx context.Context) ([]byte, error) {
return json.Marshal(r.record)
} }

View File

@ -16,7 +16,6 @@ import (
"golang.org/x/term" "golang.org/x/term"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
"github.com/cloudflare/cloudflared/features"
"github.com/cloudflare/cloudflared/management" "github.com/cloudflare/cloudflared/management"
) )
@ -46,11 +45,7 @@ func init() {
zerolog.TimeFieldFormat = time.RFC3339 zerolog.TimeFieldFormat = time.RFC3339
zerolog.TimestampFunc = utcNow zerolog.TimestampFunc = utcNow
if features.Contains(features.FeatureManagementLogs) { ManagementLogger = management.NewLogger()
// Management logger needs to be initialized before any of the other loggers as to not capture
// it's own logging events.
ManagementLogger = management.NewLogger()
}
} }
func utcNow() time.Time { func utcNow() time.Time {
@ -124,10 +119,7 @@ func newZerolog(loggerConfig *Config) *zerolog.Logger {
writers = append(writers, rollingLogger) writers = append(writers, rollingLogger)
} }
var managementWriter zerolog.LevelWriter managementWriter := ManagementLogger
if features.Contains(features.FeatureManagementLogs) {
managementWriter = ManagementLogger
}
level, levelErr := zerolog.ParseLevel(loggerConfig.MinLevel) level, levelErr := zerolog.ParseLevel(loggerConfig.MinLevel)
if levelErr != nil { if levelErr != nil {

View File

@ -7,7 +7,6 @@ import (
"net" "net"
"net/netip" "net/netip"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -554,10 +553,6 @@ func (e *EdgeTunnelServer) serveQUIC(
tlsConfig := e.config.EdgeTLSConfigs[connection.QUIC] tlsConfig := e.config.EdgeTLSConfigs[connection.QUIC]
pqMode := e.config.FeatureSelector.PostQuantumMode() pqMode := e.config.FeatureSelector.PostQuantumMode()
if pqMode == features.PostQuantumStrict || pqMode == features.PostQuantumPrefer {
connOptions.Client.Features = features.Dedup(append(connOptions.Client.Features, features.FeaturePostQuantum))
}
curvePref, err := curvePreference(pqMode, tlsConfig.CurvePreferences) curvePref, err := curvePreference(pqMode, tlsConfig.CurvePreferences)
if err != nil { if err != nil {
return err, true return err, true
@ -602,7 +597,7 @@ func (e *EdgeTunnelServer) serveQUIC(
} }
var datagramSessionManager connection.DatagramSessionHandler var datagramSessionManager connection.DatagramSessionHandler
if slices.Contains(connOptions.Client.Features, features.FeatureDatagramV3) { if e.config.FeatureSelector.DatagramVersion() == features.DatagramV3 {
datagramSessionManager = connection.NewDatagramV3Connection( datagramSessionManager = connection.NewDatagramV3Connection(
ctx, ctx,
conn, conn,