Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 137 additions & 6 deletions cmd/portal-loadtest/main.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -11,9 +11,15 @@
// -selector mols|weighted selector to test (default mols)
// -capacities w1,...,wK per-relay capacity weights (default: all 1.0)
// -lambda <float> 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
Expand All @@ -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"
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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),
Expand All @@ -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" {
Expand All @@ -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.
Expand Down Expand Up @@ -255,19 +329,76 @@ 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("---------------------------------------------------------------")
for _, url := range relayURLs {
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),
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
14 changes: 14 additions & 0 deletions portal/discovery/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// --------------------------------------------------------------------------
Expand Down
18 changes: 18 additions & 0 deletions portal/discovery/relaystate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading