diff --git a/cmd/portal-loadtest/main.go b/cmd/portal-loadtest/main.go index 5acb9b4d..915fb598 100644 --- a/cmd/portal-loadtest/main.go +++ b/cmd/portal-loadtest/main.go @@ -1,4 +1,4 @@ -// Command portal-loadtest is a Phase 1/2 uniformity probe that measures +// Command portal-loadtest is a Phase 1/2/3 uniformity probe that measures // how evenly a relay-selection policy distributes N synthetic clients // across K synthetic relays. It runs entirely in-process — no running // portal-tunnel server is required. @@ -11,9 +11,15 @@ // -selector mols|weighted selector to test (default mols) // -capacities w1,...,wK per-relay capacity weights (default: all 1.0) // -lambda lambda for weighted selector (default 1.0) +// -anonymity enable AnonymityGrade on synthetic clients (opt-in /16+family diversity) +// -anonymity-collide put all relays in the same /16 (forces anonymity_grade relaxation) +// +// When -multi-hop > 0, the selector is automatically wrapped in +// diversity.New(selector) so hop-path diversity constraints apply. // // Output: per-relay top-pick histogram, chi-square statistic against the -// capacity-weighted expected distribution, and a p-value. +// capacity-weighted expected distribution, p-value, and (when -multi-hop > 0) +// diversity acceptance lines. // // P-value method: regularized upper incomplete gamma function Q(k/2, x/2), // implemented via the series expansion (|x| < s+1) and continued-fraction @@ -32,7 +38,10 @@ import ( "strings" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/gosuda/portal-tunnel/v2/portal/discovery" + "github.com/gosuda/portal-tunnel/v2/portal/discovery/selectors/diversity" "github.com/gosuda/portal-tunnel/v2/portal/discovery/selectors/mols" "github.com/gosuda/portal-tunnel/v2/portal/discovery/selectors/weighted" "github.com/gosuda/portal-tunnel/v2/types" @@ -52,6 +61,8 @@ func main() { selectorName := flag.String("selector", "mols", "selector to test: mols or weighted") capacitiesStr := flag.String("capacities", "", "comma-separated per-relay capacity weights (default: all 1.0)") lambdaVal := flag.Float64("lambda", 1.0, "lambda weight for weighted selector (ignored for mols)") + anonymity := flag.Bool("anonymity", false, "enable AnonymityGrade on synthetic clients (opt-in /16+family diversity)") + anonymityCollide := flag.Bool("anonymity-collide", false, "put all relays in the same /16 (forces anonymity_grade relaxation; implies -anonymity)") flag.Parse() if *clients <= 0 { @@ -68,6 +79,13 @@ func main() { fmt.Fprintln(os.Stderr, "portal-loadtest: -multi-hop=1 is not valid; use 0 for priority or ≥2 for multi-hop") os.Exit(1) } + // -anonymity (without -anonymity-collide) assigns each relay a unique /16 + // in 10.0/16 … 10.255/16, giving 256 distinct buckets. Reject counts above + // that to avoid silent collisions in the Subnet16 assignment. + if *anonymity && !*anonymityCollide && *relays > 256 { + fmt.Fprintln(os.Stderr, "portal-loadtest: -anonymity without -anonymity-collide supports at most 256 relays (unique /16 budget)") + os.Exit(1) + } // Parse and validate capacities. // capacitiesProvided tracks whether the user explicitly supplied -capacities. @@ -143,6 +161,16 @@ func main() { rs.Descriptor.WireGuardPublicKey = fmt.Sprintf("synthetic-wg-key-%d", i+1) rs.Descriptor.WireGuardPort = 51820 } + // Assign Subnet16 for anonymity diversity testing. + // -anonymity-collide: all relays in the same /16 (forces relaxation). + // -anonymity: each relay in its own /16 (enables clean diversity). + // Valid second octets are 0–255, so at most 256 unique /16 slots in 10.x. + // The relay-count guard above already rejects -relays > 256 for this mode. + if *anonymityCollide { + rs.Descriptor.Subnet16 = "10.0" + } else if *anonymity { + rs.Descriptor.Subnet16 = "10." + strconv.Itoa(i) + } relayStates[i] = rs } @@ -185,12 +213,29 @@ func main() { default: // "mols" policy = mols.New() } + // Wrap in diversity selector for multi-hop mode so hop-path diversity + // constraints are applied. Priority mode is passed through unchanged. + if mode == "multihop" { + policy = diversity.New(policy) + } + + // -anonymity-collide implies -anonymity (sets AnonymityGrade on clients). + effectiveAnonymity := *anonymity || *anonymityCollide // Generate N synthetic client states with UNIQUE LocalAddress values. // MOLS is deterministic on (LocalAddress, relayURL): duplicate addresses // would make all clients pick identically, falsely appearing as 100% imbalance. ctx := context.Background() picks := make(map[string]int, *relays) // relay URL → count of clients that picked it first + + // Per-client path tracking for diversity acceptance checks (multi-hop only). + dupPathClients := 0 // clients whose path contained a duplicate URL + subnet16CollidePaths := 0 // clients whose path contained a Subnet16 collision + stateByURL := make(map[string]discovery.RelayState, *relays) + for _, rs := range relayStates { + stateByURL[rs.Descriptor.APIHTTPSAddr] = rs + } + for i := 0; i < *clients; i++ { cs := discovery.ClientState{ LocalAddress: fmt.Sprintf("synthetic-client-%d", i), @@ -200,6 +245,7 @@ func main() { // pool — without it MOLS caps output at 3, hiding low-position relays // from the weighted penalty step. MaxActiveRelays: *relays, + AnonymityGrade: effectiveAnonymity, } var outputURLs []string if mode == "multihop" { @@ -212,6 +258,34 @@ func main() { continue } picks[outputURLs[0]]++ + + // Diversity acceptance checks (multi-hop only). + if mode == "multihop" { + seenURLs := make(map[string]struct{}, len(outputURLs)) + seenSubnets := make(map[string]struct{}, len(outputURLs)) + hasDup := false + hasSubnetCollide := false + for _, u := range outputURLs { + if _, dup := seenURLs[u]; dup { + hasDup = true + } + seenURLs[u] = struct{}{} + if rs, ok := stateByURL[u]; ok { + if s := rs.Descriptor.Subnet16; s != "" { + if _, dup := seenSubnets[s]; dup { + hasSubnetCollide = true + } + seenSubnets[s] = struct{}{} + } + } + } + if hasDup { + dupPathClients++ + } + if hasSubnetCollide { + subnet16CollidePaths++ + } + } } // Collect and sort relay URLs for deterministic output. @@ -255,12 +329,28 @@ func main() { pval := igamc(float64(df)/2.0, chi2/2.0) // Print results. - if *selectorName == "weighted" { - fmt.Printf("selector: weighted (lambda=%.1f, epsilon=0.1, beta=1.0)\n", *lambdaVal) + if mode == "multihop" { + if *selectorName == "weighted" { + fmt.Printf("selector: weighted+diversity (lambda=%.1f, epsilon=0.1, beta=1.0)\n", *lambdaVal) + } else { + fmt.Printf("selector: %s+diversity\n", *selectorName) + } } else { - fmt.Printf("selector: %s\n", *selectorName) + if *selectorName == "weighted" { + fmt.Printf("selector: weighted (lambda=%.1f, epsilon=0.1, beta=1.0)\n", *lambdaVal) + } else { + fmt.Printf("selector: %s\n", *selectorName) + } + } + fmt.Printf("clients: %d relays: %d mode: %s\n", *clients, *relays, mode) + if effectiveAnonymity { + collideNote := "" + if *anonymityCollide { + collideNote = " (collide mode: all same /16)" + } + fmt.Printf("anonymity-grade: enabled%s\n", collideNote) } - fmt.Printf("clients: %d relays: %d mode: %s\n\n", *clients, *relays, mode) + fmt.Println() fmt.Printf("%-45s %6s %8s\n", "relay", "picks", "expected") fmt.Println("---------------------------------------------------------------") @@ -268,6 +358,47 @@ func main() { fmt.Printf("%-45s %6d %8.1f\n", url, picks[url], expectedByURL[url]) } fmt.Printf("\nchi-square: %.4f df: %d p-value: %.4f\n", chi2, df, pval) + + // Diversity acceptance lines (multi-hop only). + if mode == "multihop" { + fmt.Println() + if dupPathClients == 0 { + fmt.Println("zero duplicate-relay paths: PASS") + } else { + fmt.Printf("zero duplicate-relay paths: FAIL (%d/%d clients had duplicate hops)\n", dupPathClients, *clients) + } + if effectiveAnonymity { + if subnet16CollidePaths == 0 { + fmt.Println("zero /16 collisions: PASS") + } else { + fmt.Printf("zero /16 collisions: FAIL (%d/%d clients had /16 collisions)\n", subnet16CollidePaths, *clients) + } + } + // Print final relaxation event counter values from the Prometheus registry. + var relaxedAnonymity, relaxedRoleSep float64 + if mfs, err := prometheus.DefaultGatherer.Gather(); err == nil { + for _, mf := range mfs { + if mf.GetName() != "portal_discovery_diversity_relaxed_total" { + continue + } + for _, m := range mf.GetMetric() { + for _, lp := range m.GetLabel() { + if lp.GetName() != "reason" { + continue + } + switch lp.GetValue() { + case "anonymity_grade": + relaxedAnonymity = m.GetCounter().GetValue() + case "role_separation": + relaxedRoleSep = m.GetCounter().GetValue() + } + } + } + } + } + fmt.Printf("relaxation event metric: anonymity_grade=%d role_separation=%d\n", + int(relaxedAnonymity), int(relaxedRoleSep)) + } } // igamc returns the regularized upper incomplete gamma function Q(s, x), diff --git a/go.mod b/go.mod index 7b16391d..e805e81d 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/holiman/uint256 v1.3.2 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/miekg/dns v1.1.72 // indirect diff --git a/go.sum b/go.sum index c84270d8..2651c9eb 100644 --- a/go.sum +++ b/go.sum @@ -89,10 +89,14 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8 github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= diff --git a/portal/discovery/metrics.go b/portal/discovery/metrics.go index 82554981..3fd257b5 100644 --- a/portal/discovery/metrics.go +++ b/portal/discovery/metrics.go @@ -137,6 +137,20 @@ var CongestionMode = promauto.NewGauge( }, ) +// DiversityRelaxedTotal counts diversity-constraint relaxations during +// multi-hop selection. reason has exactly 2 values: +// - "anonymity_grade": AnonymityGrade constraint (Subnet16 + Family dedup) +// was relaxed due to pool shortfall. +// - "role_separation": URL-uniqueness (role-separation) constraint was +// relaxed due to pool shortfall; inner result returned unchanged. +var DiversityRelaxedTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "portal_discovery_diversity_relaxed_total", + Help: "Diversity-constraint relaxations during multi-hop selection.", + }, + []string{"reason"}, +) + // -------------------------------------------------------------------------- // EmitFromTrace // -------------------------------------------------------------------------- diff --git a/portal/discovery/relaystate.go b/portal/discovery/relaystate.go index b2dd2466..0e8f4eb8 100644 --- a/portal/discovery/relaystate.go +++ b/portal/discovery/relaystate.go @@ -83,4 +83,22 @@ type ClientState struct { // LocalAddress is the ingress identity address used by the relay selector to // derive a deterministic row index into the GF(64) MOLS grid. LocalAddress string + + // DisableDiversityRoles opts out of role separation in multi-hop paths. + // Default false (i.e. role separation is ENABLED by default). When false, + // the diversity selector enforces URL-uniqueness across hops so that entry, + // transit, and exit relays are always distinct. Set to true only when you + // explicitly want to allow duplicate relays in a path (e.g. load tests that + // intentionally exhaust the relay pool below MultiHopDepth). This inverted + // field name is used to make the zero value of ClientState the safe default + // (role separation on). + DisableDiversityRoles bool + + // AnonymityGrade opts in to /16-prefix + operator-family diversity on + // multi-hop paths. Default false (disabled). When true, the diversity + // selector additionally enforces that no two selected relays share the same + // Subnet16 or Family value (ignoring empty values, which contribute no + // constraint). A shortfall caused by this constraint triggers relaxation + // and increments portal_discovery_diversity_relaxed_total{reason="anonymity_grade"}. + AnonymityGrade bool } diff --git a/portal/discovery/selectors/diversity/diversity.go b/portal/discovery/selectors/diversity/diversity.go new file mode 100644 index 00000000..8fd7acf4 --- /dev/null +++ b/portal/discovery/selectors/diversity/diversity.go @@ -0,0 +1,238 @@ +// Package diversity provides a discovery.Selector wrapper that enforces +// multi-hop path diversity on top of any inner selector. It does NOT modify +// SelectPriority — single-hop priority selection is passed through unchanged. +// +// # Role Separation (default-on) +// +// When client.DisableDiversityRoles is false (the default), SelectMultiHop +// enforces URL-uniqueness across hops: entry, transit, and exit relays must +// all have distinct URLs. This surfaces the user-reported symptom that +// unguarded multi-hop selection has "no control basis". +// +// # Anonymity Grade (opt-in) +// +// When client.AnonymityGrade is true, the selector additionally enforces that +// no two selected relays share the same Subnet16 or Family value. Empty +// Subnet16 / Family values contribute no constraint (relax-by-omission: a +// relay without metadata doesn't block others, it simply doesn't consume a +// diversity slot). +// +// # Candidate Pool +// +// The walk function first consumes innerURLs (the inner selector's preference +// ordering) and then falls through to remaining pool entries (sorted by URL +// for determinism) if innerURLs is exhausted before MultiHopDepth is reached. +// This ensures that valid diverse relays available in the pool are not ignored +// just because the inner selector returned a trimmed top-N list. +// +// # Relaxation +// +// When the combined candidate set (innerURLs + remaining pool) is still too +// small to satisfy a constraint, the selector relaxes gracefully: +// +// 1. AnonymityGrade shortfall: retry the walk without AnonymityGrade. +// Increments portal_discovery_diversity_relaxed_total{reason="anonymity_grade"}. +// +// 2. Role-separation shortfall: the pool has fewer distinct URLs than +// MultiHopDepth. Fall back to the inner result directly (which may contain +// duplicates). Increments portal_discovery_diversity_relaxed_total{reason="role_separation"}. +// +// The relaxation order is (AnonymityGrade, then role-separation) so the +// strongest constraint is dropped first. +package diversity + +import ( + "context" + "sort" + + "github.com/gosuda/portal-tunnel/v2/portal/discovery" +) + +// Diversity wraps an inner Selector and adds hop-path diversity enforcement +// for multi-hop selections. +type Diversity struct { + inner discovery.Selector +} + +// Option is a functional option for Diversity (reserved for future extension). +type Option func(*Diversity) + +// New returns a new Diversity selector wrapping inner. Any Options are applied +// in order after the Diversity is initialised. +func New(inner discovery.Selector, opts ...Option) *Diversity { + d := &Diversity{inner: inner} + for _, o := range opts { + o(d) + } + return d +} + +// Compile-time assertion: *Diversity must satisfy discovery.Selector. +var _ discovery.Selector = (*Diversity)(nil) + +// Name returns the inner selector's name with "+diversity" appended. +func (d *Diversity) Name() string { return d.inner.Name() + "+diversity" } + +// SelectPriority passes through to the inner selector unchanged. Single-hop +// priority selection does not benefit from diversity wrapping. +func (d *Diversity) SelectPriority(ctx context.Context, pool []discovery.RelayState, client discovery.ClientState) ([]string, discovery.SelectionTrace) { + return d.inner.SelectPriority(ctx, pool, client) +} + +// SelectMultiHop delegates to the inner selector and then applies diversity +// filtering according to client.DisableDiversityRoles and client.AnonymityGrade. +// +// The candidate set passed to the walk is: innerURLs first (respecting the +// inner selector's preference ordering), then any pool entries not already +// in innerURLs (sorted by URL for determinism). This ensures pool-wide diverse +// candidates are not missed when the inner returns only a trimmed top-N list. +// +// The returned trace is the inner trace with OutputURLs updated to the +// post-diversity result, plus any relaxation notes appended to Reasons. +func (d *Diversity) SelectMultiHop(ctx context.Context, pool []discovery.RelayState, client discovery.ClientState) ([]string, discovery.SelectionTrace) { + innerURLs, trace := d.inner.SelectMultiHop(ctx, pool, client) + + // Early exit: no depth, no inner result. + if client.MultiHopDepth < 2 || len(innerURLs) == 0 { + return innerURLs, trace + } + + // Build a URL → RelayState lookup for O(1) access. + stateByURL := make(map[string]discovery.RelayState, len(pool)) + for _, rs := range pool { + stateByURL[rs.Descriptor.APIHTTPSAddr] = rs + } + + // Build the full candidate ordering: + // 1. innerURLs in the order the inner selector returned them. + // 2. Remaining trace.Ranked entries (sorted by URL for determinism) not in + // innerURLs. Ranked contains only the eligible relays that the inner + // selector actually evaluated (banned/expired/suppressed entries are + // excluded before ranking), so this avoids leaking ineligible relays into + // the diversity candidate set. + candidates := buildCandidates(innerURLs, trace.Ranked) + + // Attempt 1: full constraints (role-separation + AnonymityGrade if set). + out, ok := walk(candidates, stateByURL, client) + if ok { + trace.OutputURLs = out + return out, trace + } + + // Relaxation step (a): if AnonymityGrade was on, retry without it. + if client.AnonymityGrade { + discovery.DiversityRelaxedTotal.WithLabelValues("anonymity_grade").Inc() + relaxedClient := client + relaxedClient.AnonymityGrade = false + out, ok = walk(candidates, stateByURL, relaxedClient) + if ok { + if trace.Reasons == nil { + trace.Reasons = make(map[string]string) + } + trace.Reasons["diversity_relaxed"] = "anonymity_grade" + trace.OutputURLs = out + return out, trace + } + } + + // Relaxation step (b): role-separation shortfall — fall back to inner result. + if !client.DisableDiversityRoles { + discovery.DiversityRelaxedTotal.WithLabelValues("role_separation").Inc() + if trace.Reasons == nil { + trace.Reasons = make(map[string]string) + } + trace.Reasons["diversity_relaxed"] = "role_separation" + } + trace.OutputURLs = innerURLs + return innerURLs, trace +} + +// buildCandidates returns an ordered candidate slice: innerURLs first, then +// any entries from ranked not in innerURLs sorted by URL for determinism. +// ranked must contain only eligible relays (the inner selector's evaluated +// candidates), so that banned/expired/suppressed relays are never added. +func buildCandidates(innerURLs []string, ranked []discovery.TraceEntry) []string { + inInner := make(map[string]struct{}, len(innerURLs)) + for _, u := range innerURLs { + inInner[u] = struct{}{} + } + + // Collect extras in sorted order for determinism. + extras := make([]string, 0, len(ranked)) + for _, entry := range ranked { + u := entry.URL + if _, seen := inInner[u]; !seen { + extras = append(extras, u) + } + } + sort.Strings(extras) + + result := make([]string, 0, len(innerURLs)+len(extras)) + result = append(result, innerURLs...) + result = append(result, extras...) + return result +} + +// walk applies diversity filtering to candidates and returns the first +// client.MultiHopDepth entries that satisfy role-separation and (if enabled) +// AnonymityGrade constraints. Returns (out, true) when len(out)==depth, or +// (partial, false) on shortfall. +func walk( + candidates []string, + stateByURL map[string]discovery.RelayState, + client discovery.ClientState, +) ([]string, bool) { + depth := client.MultiHopDepth + roleCheck := !client.DisableDiversityRoles + anonCheck := client.AnonymityGrade + + out := make([]string, 0, depth) + usedURLs := make(map[string]struct{}, depth) + usedSubnets := make(map[string]struct{}, depth) + usedFamilies := make(map[string]struct{}, depth) + + for _, url := range candidates { + if len(out) >= depth { + break + } + + // Role-separation: reject duplicate URLs. + if roleCheck { + if _, dup := usedURLs[url]; dup { + continue + } + } + + // AnonymityGrade: reject if Subnet16 or Family already used. + if anonCheck { + if rs, ok := stateByURL[url]; ok { + if s := rs.Descriptor.Subnet16; s != "" { + if _, seen := usedSubnets[s]; seen { + continue + } + } + if f := rs.Descriptor.Family; f != "" { + if _, seen := usedFamilies[f]; seen { + continue + } + } + } + } + + // Accept this relay. + out = append(out, url) + usedURLs[url] = struct{}{} + if anonCheck { + if rs, ok := stateByURL[url]; ok { + if s := rs.Descriptor.Subnet16; s != "" { + usedSubnets[s] = struct{}{} + } + if f := rs.Descriptor.Family; f != "" { + usedFamilies[f] = struct{}{} + } + } + } + } + + return out, len(out) >= depth +} diff --git a/portal/discovery/selectors/diversity/diversity_test.go b/portal/discovery/selectors/diversity/diversity_test.go new file mode 100644 index 00000000..842cbf60 --- /dev/null +++ b/portal/discovery/selectors/diversity/diversity_test.go @@ -0,0 +1,372 @@ +package diversity_test + +import ( + "context" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" + + "github.com/gosuda/portal-tunnel/v2/portal/discovery" + "github.com/gosuda/portal-tunnel/v2/portal/discovery/selectors/diversity" + "github.com/gosuda/portal-tunnel/v2/portal/discovery/selectors/mols" + "github.com/gosuda/portal-tunnel/v2/portal/discovery/selectortest" + "github.com/gosuda/portal-tunnel/v2/types" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// overlayState returns a RelayState that passes all SelectMultiHop eligibility +// gates (HasObservedDescriptor, non-expired ExpiresAt, HasOverlayPeer). +func overlayState(url string) discovery.RelayState { + now := time.Now().UTC() + return discovery.RelayState{ + Descriptor: types.RelayDescriptor{ + APIHTTPSAddr: url, + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + SupportsOverlay: true, + WireGuardPublicKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleTA=", + WireGuardPort: 51820, + }, + Confirmed: true, + LastSeenAt: now, + } +} + +// overlayStateWith returns an overlayState with Family and Subnet16 set. +func overlayStateWith(url, family, subnet16 string) discovery.RelayState { + rs := overlayState(url) + rs.Descriptor.Family = family + rs.Descriptor.Subnet16 = subnet16 + return rs +} + +// diversityMOLS returns a fresh diversity.Diversity wrapping a new mols.MOLS. +// Used as the factory for the contract harness. +func diversityMOLS() discovery.Selector { + return diversity.New(mols.New()) +} + +// --------------------------------------------------------------------------- +// Contract harness +// --------------------------------------------------------------------------- + +// TestDiversityContract runs the full Selector invariant suite (~13 sub-tests) +// against diversity.New(mols.New()). +func TestDiversityContract(t *testing.T) { + selectortest.Contract(t, "diversity_mols", diversityMOLS) +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +// TestDiversityName verifies that Name() returns "+diversity". +func TestDiversityName(t *testing.T) { + d := diversity.New(mols.New()) + got := d.Name() + want := "mols+diversity" + if got != want { + t.Fatalf("Name() = %q, want %q", got, want) + } +} + +// TestDiversityPassthroughSelectPriority confirms SelectPriority delegates +// unchanged to the inner selector. +func TestDiversityPassthroughSelectPriority(t *testing.T) { + inner := mols.New() + d := diversity.New(inner) + ctx := context.Background() + + now := time.Now().UTC() + pool := []discovery.RelayState{ + { + Descriptor: types.RelayDescriptor{ + APIHTTPSAddr: "https://relay-a.example", + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + }, + Bootstrap: true, + }, + { + Descriptor: types.RelayDescriptor{ + APIHTTPSAddr: "https://relay-b.example", + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + }, + Bootstrap: true, + }, + } + client := discovery.ClientState{LocalAddress: "passthrough-test"} + + innerURLs, _ := inner.SelectPriority(ctx, pool, client) + dURLs, _ := d.SelectPriority(ctx, pool, client) + + if len(innerURLs) != len(dURLs) { + t.Fatalf("SelectPriority length mismatch: inner=%d diversity=%d", len(innerURLs), len(dURLs)) + } + for i := range innerURLs { + if innerURLs[i] != dURLs[i] { + t.Fatalf("SelectPriority[%d]: inner=%q diversity=%q; want identical output", i, innerURLs[i], dURLs[i]) + } + } +} + +// TestDiversityNoOpWhenAllDisabled verifies that when DisableDiversityRoles=true +// and AnonymityGrade=false, SelectMultiHop output equals inner.SelectMultiHop. +func TestDiversityNoOpWhenAllDisabled(t *testing.T) { + inner := mols.New() + d := diversity.New(inner) + ctx := context.Background() + + pool := []discovery.RelayState{ + overlayState("https://relay-1.example"), + overlayState("https://relay-2.example"), + overlayState("https://relay-3.example"), + } + client := discovery.ClientState{ + LocalAddress: "noop-test", + MultiHopDepth: 3, + DisableDiversityRoles: true, + AnonymityGrade: false, + } + + innerURLs, _ := inner.SelectMultiHop(ctx, pool, client) + dURLs, _ := d.SelectMultiHop(ctx, pool, client) + + if len(innerURLs) != len(dURLs) { + t.Fatalf("NoOp: length mismatch: inner=%d diversity=%d", len(innerURLs), len(dURLs)) + } + for i := range innerURLs { + if innerURLs[i] != dURLs[i] { + t.Fatalf("NoOp: output[%d]: inner=%q diversity=%q; want identical output when all constraints disabled", i, innerURLs[i], dURLs[i]) + } + } +} + +// TestDiversityZeroDuplicateRelays verifies that with 3 distinct overlay relays +// and MultiHopDepth=3, the output has 3 distinct URLs. +func TestDiversityZeroDuplicateRelays(t *testing.T) { + d := diversity.New(mols.New()) + ctx := context.Background() + + pool := []discovery.RelayState{ + overlayState("https://relay-a.example"), + overlayState("https://relay-b.example"), + overlayState("https://relay-c.example"), + } + client := discovery.ClientState{ + LocalAddress: "dedup-test", + MultiHopDepth: 3, + } + + urls, _ := d.SelectMultiHop(ctx, pool, client) + if len(urls) != 3 { + t.Fatalf("want 3 URLs, got %d: %v", len(urls), urls) + } + seen := make(map[string]struct{}, 3) + for _, u := range urls { + if _, dup := seen[u]; dup { + t.Fatalf("duplicate URL in output: %q (full output: %v)", u, urls) + } + seen[u] = struct{}{} + } +} + +// TestDiversityZeroSubnet16Collisions verifies that with AnonymityGrade=true +// and MultiHopDepth=2, the two selected relays have distinct Subnet16 values. +// Pool has 3 relays with distinct Subnet16 and 3 with the same Subnet16. +func TestDiversityZeroSubnet16Collisions(t *testing.T) { + d := diversity.New(mols.New()) + ctx := context.Background() + + pool := []discovery.RelayState{ + overlayStateWith("https://diverse-a.example", "op-a", "10.1"), + overlayStateWith("https://diverse-b.example", "op-b", "10.2"), + overlayStateWith("https://diverse-c.example", "op-c", "10.3"), + overlayStateWith("https://same-1.example", "op-d", "10.0"), + overlayStateWith("https://same-2.example", "op-e", "10.0"), + overlayStateWith("https://same-3.example", "op-f", "10.0"), + } + client := discovery.ClientState{ + LocalAddress: "subnet16-test", + MultiHopDepth: 2, + AnonymityGrade: true, + } + + urls, _ := d.SelectMultiHop(ctx, pool, client) + if len(urls) != 2 { + t.Fatalf("want 2 URLs, got %d: %v", len(urls), urls) + } + + // Collect Subnet16 values; assert uniqueness. + stateByURL := make(map[string]discovery.RelayState, len(pool)) + for _, rs := range pool { + stateByURL[rs.Descriptor.APIHTTPSAddr] = rs + } + usedSubnets := make(map[string]struct{}, 2) + for _, u := range urls { + rs, ok := stateByURL[u] + if !ok { + t.Fatalf("output URL %q not found in pool; selector returned an unknown relay", u) + } + s := rs.Descriptor.Subnet16 + if s == "" { + continue + } + if _, dup := usedSubnets[s]; dup { + t.Fatalf("Subnet16 collision: %q appears more than once in output %v", s, urls) + } + usedSubnets[s] = struct{}{} + } +} + +// TestDiversitySubnet16ForcesRelaxation verifies that when ALL relays share the +// same Subnet16, AnonymityGrade triggers relaxation for MultiHopDepth=2. +// The counter portal_discovery_diversity_relaxed_total{reason="anonymity_grade"} +// must increment by exactly 1. +func TestDiversitySubnet16ForcesRelaxation(t *testing.T) { + d := diversity.New(mols.New()) + ctx := context.Background() + + // All 3 relays share the same Subnet16 — impossible to satisfy anonymity_grade. + pool := []discovery.RelayState{ + overlayStateWith("https://same-subnet-a.example", "op-a", "10.99"), + overlayStateWith("https://same-subnet-b.example", "op-b", "10.99"), + overlayStateWith("https://same-subnet-c.example", "op-c", "10.99"), + } + client := discovery.ClientState{ + LocalAddress: "relaxation-test", + MultiHopDepth: 2, + AnonymityGrade: true, + } + + // Capture baseline counter value before the call. + counterBefore := testutil.ToFloat64( + discovery.DiversityRelaxedTotal.WithLabelValues("anonymity_grade"), + ) + + urls, _ := d.SelectMultiHop(ctx, pool, client) + + // Must still return a non-empty result after relaxation. + if len(urls) == 0 { + t.Fatal("want non-empty result after AnonymityGrade relaxation, got empty") + } + + // Counter must have incremented by exactly 1. + counterAfter := testutil.ToFloat64( + discovery.DiversityRelaxedTotal.WithLabelValues("anonymity_grade"), + ) + if delta := counterAfter - counterBefore; delta != 1.0 { + t.Fatalf("portal_discovery_diversity_relaxed_total{reason=anonymity_grade} delta = %.0f, want 1", delta) + } +} + +// TestDiversityBannedRelayExcludedFromExtras verifies that a banned relay with +// a distinct Subnet16 is never included as a diversity "extra" to satisfy +// AnonymityGrade depth. The bug this catches: if buildCandidates pulled from +// the raw pool instead of trace.Ranked, a banned relay with a unique /16 +// would satisfy the third hop even though it must not be selected. +// +// Pool: relay-a and relay-b share Subnet16 "10.1"; relay-banned has Subnet16 +// "10.2" but Banned=true. With AnonymityGrade=true and MultiHopDepth=3, there +// are only 2 distinct eligible subnets → depth cannot be satisfied; the +// selector must relax (AnonymityGrade counter increments) and return 2 URLs +// that do NOT include relay-banned. +func TestDiversityBannedRelayExcludedFromExtras(t *testing.T) { + d := diversity.New(mols.New()) + ctx := context.Background() + + bannedState := overlayStateWith("https://relay-banned.example", "op-banned", "10.2") + bannedState.Banned = true + + pool := []discovery.RelayState{ + overlayStateWith("https://relay-a.example", "op-a", "10.1"), + overlayStateWith("https://relay-b.example", "op-b", "10.1"), + bannedState, + } + client := discovery.ClientState{ + LocalAddress: "banned-extras-test", + MultiHopDepth: 3, + AnonymityGrade: true, + } + + counterBefore := testutil.ToFloat64( + discovery.DiversityRelaxedTotal.WithLabelValues("anonymity_grade"), + ) + + urls, _ := d.SelectMultiHop(ctx, pool, client) + + // Must not include the banned relay's URL. + for _, u := range urls { + if u == "https://relay-banned.example" { + t.Fatalf("banned relay appeared in output %v; eligibility leak in buildCandidates", urls) + } + } + + // After relaxation the selector must return 2 eligible URLs (relay-a and + // relay-b are the only non-banned relays in the pool). + if len(urls) != 2 { + t.Fatalf("want 2 eligible URLs after AnonymityGrade relaxation, got %d: %v", len(urls), urls) + } + + // AnonymityGrade relaxation counter must have incremented (depth unsatisfiable + // without the banned relay, so the selector must relax). + counterAfter := testutil.ToFloat64( + discovery.DiversityRelaxedTotal.WithLabelValues("anonymity_grade"), + ) + if delta := counterAfter - counterBefore; delta != 1.0 { + t.Fatalf("portal_discovery_diversity_relaxed_total{reason=anonymity_grade} delta = %.0f, want 1 (banned relay must not satisfy depth)", delta) + } +} + +// TestDiversityFamilyDedup verifies that with AnonymityGrade=true and a pool +// containing relays with various Family values, no two selected relays share +// the same Family. +func TestDiversityFamilyDedup(t *testing.T) { + d := diversity.New(mols.New()) + ctx := context.Background() + + // 3 relays with distinct families, 2 extras sharing one of those families. + pool := []discovery.RelayState{ + overlayStateWith("https://family-a1.example", "op-a", "10.1"), + overlayStateWith("https://family-b1.example", "op-b", "10.2"), + overlayStateWith("https://family-c1.example", "op-c", "10.3"), + overlayStateWith("https://family-a2.example", "op-a", "10.4"), // same family as a1 + overlayStateWith("https://family-a3.example", "op-a", "10.5"), // same family as a1 + } + client := discovery.ClientState{ + LocalAddress: "family-test", + MultiHopDepth: 3, + AnonymityGrade: true, + } + + urls, _ := d.SelectMultiHop(ctx, pool, client) + if len(urls) != 3 { + t.Fatalf("want 3 URLs, got %d: %v", len(urls), urls) + } + + stateByURL := make(map[string]discovery.RelayState, len(pool)) + for _, rs := range pool { + stateByURL[rs.Descriptor.APIHTTPSAddr] = rs + } + + usedFamilies := make(map[string]struct{}, 3) + for _, u := range urls { + rs, ok := stateByURL[u] + if !ok { + t.Fatalf("output URL %q not found in pool; selector returned an unknown relay", u) + } + f := rs.Descriptor.Family + if f == "" { + continue + } + if _, dup := usedFamilies[f]; dup { + t.Fatalf("Family collision: %q appears more than once in output %v", f, urls) + } + usedFamilies[f] = struct{}{} + } +} diff --git a/types/identity.go b/types/identity.go index 0f7065dc..a4c2829c 100644 --- a/types/identity.go +++ b/types/identity.go @@ -141,6 +141,25 @@ type RelayDescriptor struct { ActiveConnections int64 `json:"active_connections,omitempty"` TCPBPS float64 `json:"tcp_bps,omitempty"` Signature string `json:"signature,omitempty"` + + // Family is an operator-controlled identifier grouping relays under a + // single administrative domain (e.g. "operator-a"). Used by the diversity + // selector to avoid selecting multiple relays from the same operator family + // on a single multi-hop path when AnonymityGrade is enabled. + // Empty = no family-diversity constraint. Set by the relay at registration + // time; advisory only — NOT included in CanonicalBytes and therefore not + // covered by the descriptor signature. Adversarial relays can forge these + // values; the only consequence is self-degradation of their own diversity + // contribution. + Family string `json:"family,omitempty"` + + // Subnet16 is the /16 bucket key for this relay (e.g. "10.0", "192.168"). + // Computed by the relay when registering from its primary IP address. + // Used by the diversity selector to avoid selecting multiple relays from + // the same /16 prefix on a single multi-hop path when AnonymityGrade is + // enabled. Empty = no subnet-diversity constraint. Same advisory caveat as + // Family: not in CanonicalBytes, not signed. + Subnet16 string `json:"subnet16,omitempty"` } func (desc RelayDescriptor) HasOverlayPeer() bool {