diff --git a/.gitignore b/.gitignore index b10dbf87..4454e05f 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ fabric.properties # End of https://www.toptal.com/developers/gitignore/api/goland+all .gstack/ + +# Nested workspace (separate keyless_tls project; not part of the portal module) +keyless_tls/ diff --git a/Makefile b/Makefile index dee00031..2f5e15ff 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install fmt vet lint lint-auto test vuln tidy all run build build-frontend build-docs build-tunnel build-server clean +.PHONY: help install fmt vet lint lint-auto test vuln tidy all run build build-frontend build-docs build-tunnel build-server clean load-test .DEFAULT_GOAL := help @@ -98,3 +98,13 @@ clean: rm -rf bin rm -rf cmd/relay-server/dist/app rm -rf cmd/relay-server/dist/tunnel + +# Run the uniformity probe. Extra flags are passed through after the target name: +# make load-test -- -clients 1000 -relays 5 +# GNU make consumes '--' and forwards remaining goals; the catch-all '%:' rule +# below silently absorbs them so make does not error with "no rule to make target." +load-test: + go run ./cmd/portal-loadtest $(filter-out $@,$(MAKECMDGOALS)) + +%: + @: diff --git a/cmd/portal-loadtest/main.go b/cmd/portal-loadtest/main.go new file mode 100644 index 00000000..d71f09b4 --- /dev/null +++ b/cmd/portal-loadtest/main.go @@ -0,0 +1,237 @@ +// Command portal-loadtest is a Phase 1 uniformity probe that measures +// how evenly the MOLS relay-selection policy distributes N synthetic clients +// across K synthetic relays. It runs entirely in-process — no running +// portal-tunnel server is required. +// +// Flags (Phase 1 only — -capacities and -selector are Phase 2): +// +// -clients N number of synthetic clients (default 100) +// -relays K number of synthetic relays (default 5) +// -multi-hop D multi-hop depth (0 = priority/single-hop; ≥2 = multi-hop) +// +// Output: per-relay top-pick histogram, chi-square statistic against the +// uniform expected distribution N/K, and a p-value. +// +// P-value method: regularized upper incomplete gamma function Q(k/2, x/2), +// implemented via the series expansion (|x| < s+1) and continued-fraction +// expansion (x ≥ s+1) from Numerical Recipes §6.2. This gives accurate +// results even at small df values (e.g. df=4 for K=5). +package main + +import ( + "flag" + "fmt" + "math" + "os" + "sort" + "time" + + "github.com/gosuda/portal-tunnel/v2/portal/discovery" + "github.com/gosuda/portal-tunnel/v2/types" +) + +func main() { + clients := flag.Int("clients", 100, "number of synthetic clients") + relays := flag.Int("relays", 5, "number of synthetic relays") + multiHop := flag.Int("multi-hop", 0, "multi-hop depth (0 = priority; ≥2 = multi-hop)") + flag.Parse() + + if *clients <= 0 { + fmt.Fprintln(os.Stderr, "portal-loadtest: -clients must be > 0") + os.Exit(1) + } + if *relays <= 0 { + fmt.Fprintln(os.Stderr, "portal-loadtest: -relays must be > 0") + os.Exit(1) + } + // MultiHopDepth ≤ 1 causes SelectMultiHop to return nil (see mols.go). + // Reject 1 explicitly; 0 means priority mode. + if *multiHop == 1 { + fmt.Fprintln(os.Stderr, "portal-loadtest: -multi-hop=1 is not valid; use 0 for priority or ≥2 for multi-hop") + os.Exit(1) + } + + mode := "priority" + if *multiHop >= 2 { + mode = "multihop" + } + + // Build K synthetic relay states. We construct discovery.RelayState values + // directly (not via RelaySet.InsertAnnounced) because the public announce + // path requires real EVM-signed descriptors. MOLSRelayPolicy is called + // directly so that no signature gate runs. + // + // For priority mode: states without an observed descriptor (LastSeenAt zero) + // are accepted into the auto pool by SelectPriorityWithTrace — the + // expiry/protocol gates only fire when hasObservedDescriptor() is true. + // + // For multi-hop mode: SelectMultiHopWithTrace requires hasObservedDescriptor, + // a non-expired ExpiresAt, and HasOverlayPeer()==true. We populate those + // fields with dummy-but-valid values using a far-future ExpiresAt and a + // syntactically valid WireGuard public key placeholder. + now := time.Now().UTC() + relayStates := make([]discovery.RelayState, *relays) + for i := range relayStates { + relayURL := fmt.Sprintf("https://test-relay-%d.example", i+1) + rs := discovery.RelayState{ + Descriptor: types.RelayDescriptor{ + APIHTTPSAddr: relayURL, + }, + } + if mode == "multihop" { + // Populate the fields required by SelectMultiHopWithTrace's eligibility + // gates: hasObservedDescriptor (LastSeenAt non-zero), valid ExpiresAt, + // and HasOverlayPeer() = SupportsOverlay && WireGuardPublicKey != "" && + // WireGuardPort in [1, 65535]. + rs.LastSeenAt = now + rs.Descriptor.IssuedAt = now + rs.Descriptor.ExpiresAt = now.Add(24 * time.Hour) + rs.Descriptor.SupportsOverlay = true + rs.Descriptor.WireGuardPublicKey = fmt.Sprintf("synthetic-wg-key-%d", i+1) + rs.Descriptor.WireGuardPort = 51820 + } + relayStates[i] = rs + } + + // 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. + policy := discovery.MOLSRelayPolicy{} + picks := make(map[string]int, *relays) // relay URL → count of clients that picked it first + for i := 0; i < *clients; i++ { + cs := discovery.ClientState{ + LocalAddress: fmt.Sprintf("synthetic-client-%d", i), + MultiHopDepth: *multiHop, + } + var outputURLs []string + if mode == "multihop" { + outputURLs, _ = policy.SelectMultiHopWithTrace(relayStates, cs) + } else { + outputURLs, _ = policy.SelectPriorityWithTrace(relayStates, cs) + } + if len(outputURLs) == 0 { + // All relays were filtered; skip this client. + continue + } + picks[outputURLs[0]]++ + } + + // Collect and sort relay URLs for deterministic output. + relayURLs := make([]string, 0, *relays) + for i := range relayStates { + relayURLs = append(relayURLs, relayStates[i].Descriptor.APIHTTPSAddr) + } + sort.Strings(relayURLs) + + expected := float64(*clients) / float64(*relays) + + // Chi-square statistic: Σ (observed - expected)^2 / expected + var chi2 float64 + for _, url := range relayURLs { + obs := float64(picks[url]) + diff := obs - expected + chi2 += diff * diff / expected + } + + df := *relays - 1 + + // P-value: P(χ² > chi2 | df) = Q(df/2, chi2/2) = igamc(df/2, chi2/2) + // using the regularized upper incomplete gamma function. + pval := igamc(float64(df)/2.0, chi2/2.0) + + // Print results. + header := fmt.Sprintf("portal-loadtest: N=%d clients, K=%d relays, mode=%s", *clients, *relays, mode) + fmt.Println(header) + 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], expected) + } + fmt.Printf("\nchi-square: %.4f\n", chi2) + fmt.Printf("df: %d\n", df) + fmt.Printf("p-value: %.4f\n", pval) +} + +// igamc returns the regularized upper incomplete gamma function Q(s, x), +// also written Γ(s, x) / Γ(s). This equals 1 - P(s, x) where P(s, x) is +// the regularized lower incomplete gamma. +// +// For s < x+1 the continued-fraction expansion converges faster; otherwise +// the series expansion is used. Algorithm from Numerical Recipes §6.2 +// (Press et al.). Accurate to ~1e-7 for the parameter ranges used here +// (s = df/2 ≥ 0.5, x = chi2/2 ≥ 0). +func igamc(s, x float64) float64 { + if x < 0 || s <= 0 { + return 1.0 + } + if x == 0 { + return 1.0 + } + + if x < s+1 { + // Series expansion for the lower incomplete gamma P(s, x); + // return Q = 1 - P. + return 1.0 - gamSer(s, x) + } + // Continued-fraction expansion for Q(s, x) directly. + return gamCF(s, x) +} + +// gamSer computes P(s, x) via a series expansion. P(s, x) = e^(-x) * x^s * +// Σ_{n=0}^∞ x^n / Γ(s+n+1). +func gamSer(s, x float64) float64 { + const maxIter = 200 + const eps = 3e-7 + + ap := s + del := 1.0 / s + sum := del + for n := 0; n < maxIter; n++ { + ap++ + del *= x / ap + sum += del + if math.Abs(del) < math.Abs(sum)*eps { + return sum * math.Exp(-x+s*math.Log(x)-lgamma(s)) + } + } + // Did not converge; return best estimate. + return sum * math.Exp(-x+s*math.Log(x)-lgamma(s)) +} + +// gamCF computes Q(s, x) via a modified Lentz continued-fraction expansion. +func gamCF(s, x float64) float64 { + const maxIter = 200 + const eps = 3e-7 + const fpMin = 1e-300 + + b := x + 1.0 - s + c := 1.0 / fpMin + d := 1.0 / b + h := d + for i := 1; i <= maxIter; i++ { + an := -float64(i) * (float64(i) - s) + b += 2.0 + d = an*d + b + if math.Abs(d) < fpMin { + d = fpMin + } + c = b + an/c + if math.Abs(c) < fpMin { + c = fpMin + } + d = 1.0 / d + del := d * c + h *= del + if math.Abs(del-1.0) < eps { + break + } + } + return math.Exp(-x+s*math.Log(x)-lgamma(s)) * h +} + +// lgamma returns the natural log of the Gamma function using the standard +// library, which is accurate for all positive real inputs. +func lgamma(x float64) float64 { + lg, _ := math.Lgamma(x) + return lg +} diff --git a/cmd/portal-tunnel/main.go b/cmd/portal-tunnel/main.go index ad00de7f..521c1d5e 100644 --- a/cmd/portal-tunnel/main.go +++ b/cmd/portal-tunnel/main.go @@ -6,12 +6,14 @@ import ( "flag" "fmt" "io" + "net/http" "os" "strings" "sync" "text/tabwriter" "time" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -62,6 +64,7 @@ type exposeFlags struct { tcp bool maxActiveRelays int multiHopDepth int + metricsAddr string } func runExposeCommand(args []string) error { @@ -88,6 +91,7 @@ func runExposeCommand(args []string) error { utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED") utils.IntFlagEnv(fs, &flags.maxActiveRelays, "max-active-relays", 3, nil, "Maximum number of auto-selected relays to keep connected; explicit --relays are always included", "MAX_ACTIVE_RELAYS") utils.IntFlagEnv(fs, &flags.multiHopDepth, "multi-hop-depth", 0, nil, "Automatically select one multi-hop route with this hop count; 0 or 1 disables multi-hop", "MULTI_HOP_DEPTH") + utils.StringFlag(fs, &flags.metricsAddr, "metrics-addr", "", "Optional address (host:port) to serve Prometheus /metrics. Empty = disabled.") if err := utils.ParseFlagSet(fs, args, printExposeUsage); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -117,6 +121,22 @@ func runExposeCommand(args []string) error { ctx, stop := utils.SignalContext() defer stop() + if flags.metricsAddr != "" { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + srv := &http.Server{ + Addr: flags.metricsAddr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + log.Info().Str("metrics_addr", flags.metricsAddr).Msg("metrics server listening") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error().Err(err).Msg("metrics server error") + } + }() + } + exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{ RelayURLs: utils.SplitCSV(flags.relayCSV), Discovery: flags.discovery, diff --git a/cmd/relay-server/admin.go b/cmd/relay-server/admin.go index 0dcd583e..56085216 100644 --- a/cmd/relay-server/admin.go +++ b/cmd/relay-server/admin.go @@ -12,6 +12,7 @@ import ( "github.com/gosuda/portal-tunnel/v2/portal/policy" "github.com/gosuda/portal-tunnel/v2/types" "github.com/gosuda/portal-tunnel/v2/utils" + "github.com/prometheus/client_golang/prometheus/promhttp" ) const ( @@ -165,6 +166,9 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) { invalidRequestBody := utils.InvalidRequestError(errors.New("invalid request body")) switch path { + case "/admin/metrics": + promhttp.Handler().ServeHTTP(w, r) + return case types.PathAdminSnapshot: if !utils.RequireMethod(w, r, http.MethodGet) { return diff --git a/go.mod b/go.mod index bd7bef2d..7b16391d 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,8 @@ require ( github.com/go-rod/rod v0.116.2 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc github.com/hashicorp/yamux v0.1.2 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/quic-go/quic-go v0.59.0 github.com/rs/zerolog v1.34.0 github.com/spruceid/siwe-go v0.2.1 @@ -41,6 +43,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dchest/uniuri v1.2.0 // indirect @@ -57,6 +60,9 @@ require ( 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 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 // indirect github.com/ysmood/fetchup v0.2.3 // indirect github.com/ysmood/goob v0.4.0 // indirect @@ -68,6 +74,7 @@ require ( go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect diff --git a/go.sum b/go.sum index 73a50642..c84270d8 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -87,6 +89,10 @@ 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/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/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= @@ -95,13 +101,25 @@ github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLG github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 h1:mLbKGKe5gDGHE8uJLYMmA/fkp/htaXEMl2Hj0k4xfYE= github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -141,8 +159,12 @@ go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9 go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= @@ -182,6 +204,9 @@ google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= diff --git a/portal/discovery/mols.go b/portal/discovery/mols.go index 1e9c5854..9f4c6883 100644 --- a/portal/discovery/mols.go +++ b/portal/discovery/mols.go @@ -44,6 +44,8 @@ import ( "slices" "sort" "time" + + "github.com/gosuda/portal-tunnel/v2/portal/telemetry" ) const ( @@ -297,23 +299,58 @@ func (p MOLSRelayPolicy) rankRelayPool(autoPool []RelayState, localAddress strin return autoURLs } -func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string { +// SelectPriorityWithTrace is the telemetry-instrumented sibling of +// SelectPriority. It returns the same ordered relay list plus a SelectionTrace +// that captures pool statistics, eligibility classification, and the scoring +// parameters used for this specific call. The returned OutputURLs slice is +// byte-identical to what SelectPriority returns for the same inputs. +// +// Banned relays are recorded in SelectionTrace.Suppressed / Reasons with +// reason "banned" even though SelectAggregate removes them before further +// processing. Explicit relays are not included in Ranked (they bypass MOLS +// scoring entirely). PoolFallback reflects the fallback count before the +// minimum-active-node promotion step. +func (p MOLSRelayPolicy) SelectPriorityWithTrace(states []RelayState, cs ClientState) ([]string, telemetry.SelectionTrace) { + start := time.Now() + now := start.UTC() + + trace := telemetry.SelectionTrace{ + Timestamp: start, + ClientHash: hashToGF64(cs.LocalAddress), + Mode: "priority", + PoolTotal: len(states), + Reasons: make(map[string]string), + } + + // Record banned relays before SelectAggregate strips them. + for _, state := range states { + if state.Banned { + url := state.Descriptor.APIHTTPSAddr + trace.Suppressed = append(trace.Suppressed, url) + trace.Reasons[url] = "banned" + } + } + selected := p.SelectAggregate(states) if len(selected) == 0 { - return nil + trace.SelectionTook = time.Since(start) + return nil, trace } - now := time.Now().UTC() explicit := make([]string, 0) autoPool := make([]RelayState, 0, len(selected)) for _, state := range selected { relayURL := state.Descriptor.APIHTTPSAddr - if slices.Contains(clientState.ExplicitRelayURLs, relayURL) { + if slices.Contains(cs.ExplicitRelayURLs, relayURL) { if state.hasObservedDescriptor() && state.Descriptor.ExpiresAt.After(now) { - if clientState.RequireUDP && !state.Descriptor.SupportsUDP { + if cs.RequireUDP && !state.Descriptor.SupportsUDP { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "require_udp" continue } - if clientState.RequireTCP && !state.Descriptor.SupportsTCP { + if cs.RequireTCP && !state.Descriptor.SupportsTCP { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "require_tcp" continue } } @@ -323,63 +360,261 @@ func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientS if state.hasObservedDescriptor() { if !state.Descriptor.ExpiresAt.After(now) { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "expired" continue } - if clientState.RequireUDP && !state.Descriptor.SupportsUDP { + if cs.RequireUDP && !state.Descriptor.SupportsUDP { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "require_udp" continue } - if clientState.RequireTCP && !state.Descriptor.SupportsTCP { + if cs.RequireTCP && !state.Descriptor.SupportsTCP { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "require_tcp" continue } } if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "suppressed" continue } autoPool = append(autoPool, state) } - autoURLs := p.rankRelayPool(autoPool, clientState.LocalAddress) - maxActiveRelays := clientState.MaxActiveRelays + // Compute pool statistics before promotion. + avgRTT, cv := molsRTTStats(autoPool) + trace.AvgRTT = avgRTT + trace.CV = cv + congested := avgRTT > molsCongestionRTTThreshold + nonLinear := cv > molsCVThreshold + trace.Congested = congested + trace.NonLinear = nonLinear + + m1, m2 := molsBaseM1, molsBaseM2 + if nonLinear { + m1, m2 = molsVariantM1, molsVariantM2 + } + trace.M1, trace.M2 = m1, m2 + + // Replicate the partition+promotion logic from rankRelayPool to determine + // which relays remain as fallbacks after the minimum-active-node promotion + // step. Demoted=true only for relays that stay in the fallback section after + // promotion (i.e., were not promoted to meet molsMinActiveNodes). + trActive := make([]RelayState, 0, len(autoPool)) + trFallbacks := make([]RelayState, 0) + for _, state := range autoPool { + if isRelayFallback(state) { + trFallbacks = append(trFallbacks, state) + } else { + trActive = append(trActive, state) + } + } + // PoolFallback is counted before promotion (reflects raw slow-relay count). + trace.PoolEligible = len(autoPool) + trace.PoolFallback = len(trFallbacks) + if len(trActive) < molsMinActiveNodes && len(trFallbacks) > 0 { + promote := min(molsMinActiveNodes-len(trActive), len(trFallbacks)) + trFallbacks = trFallbacks[promote:] + } + + // Build a set of relay URLs that remain demoted (survive as fallbacks after promotion). + demotedURLs := make(map[string]bool, len(trFallbacks)) + for _, s := range trFallbacks { + demotedURLs[s.Descriptor.APIHTTPSAddr] = true + } + + // Build Ranked entries for all candidates in the auto pool. + ingressIdx := hashToGF64(cs.LocalAddress) + for _, state := range autoPool { + candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr) + var score int + if congested { + score = molsCongestionScore(ingressIdx, candidateIdx, m1, m2) + } else { + score = molsScore(ingressIdx, candidateIdx, m1, m2) + } + trace.Ranked = append(trace.Ranked, telemetry.TraceEntry{ + URL: state.Descriptor.APIHTTPSAddr, + Score: score, + Confirmed: state.Confirmed, + RTT: state.DiscoveryRTT, + Demoted: demotedURLs[state.Descriptor.APIHTTPSAddr], + }) + } + + autoURLs := p.rankRelayPool(autoPool, cs.LocalAddress) + maxActiveRelays := cs.MaxActiveRelays if maxActiveRelays <= 0 { maxActiveRelays = defaultMaxActiveRelays } if len(autoURLs) > maxActiveRelays { autoURLs = autoURLs[:maxActiveRelays] } - return append(explicit, autoURLs...) + result := append(explicit, autoURLs...) + trace.OutputURLs = result + trace.SelectionTook = time.Since(start) + return result, trace } -func (p MOLSRelayPolicy) SelectMultiHop(states []RelayState, clientState ClientState) []string { - if clientState.MultiHopDepth <= 1 { - return nil +// SelectPriority returns the ordered list of relay URLs for a client using the +// MOLS policy. It delegates to SelectPriorityWithTrace and discards the trace. +func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string { + out, _ := p.SelectPriorityWithTrace(states, clientState) + return out +} + +// SelectMultiHopWithTrace is the telemetry-instrumented sibling of +// SelectMultiHop. It returns the same ordered relay list plus a SelectionTrace. +// The returned OutputURLs slice is byte-identical to what SelectMultiHop +// returns for the same inputs. +// +// Relays excluded by eligibility gates (no descriptor, expired, no overlay +// peer, UDP/TCP mismatch, suppressed, banned) are recorded in +// SelectionTrace.Suppressed / Reasons. PoolFallback reflects the fallback count +// before the minimum-active-node promotion step. +func (p MOLSRelayPolicy) SelectMultiHopWithTrace(states []RelayState, cs ClientState) ([]string, telemetry.SelectionTrace) { + start := time.Now() + now := start.UTC() + + trace := telemetry.SelectionTrace{ + Timestamp: start, + ClientHash: hashToGF64(cs.LocalAddress), + Mode: "multihop", + PoolTotal: len(states), + Reasons: make(map[string]string), + } + + if cs.MultiHopDepth <= 1 { + trace.SelectionTook = time.Since(start) + return nil, trace + } + + // Record banned relays before SelectAggregate strips them. + for _, state := range states { + if state.Banned { + url := state.Descriptor.APIHTTPSAddr + trace.Suppressed = append(trace.Suppressed, url) + trace.Reasons[url] = "banned" + } } selected := p.SelectAggregate(states) if len(selected) == 0 { - return nil + trace.SelectionTook = time.Since(start) + return nil, trace } - now := time.Now().UTC() autoPool := make([]RelayState, 0, len(selected)) for _, state := range selected { - if clientState.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP { + relayURL := state.Descriptor.APIHTTPSAddr + if cs.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "require_udp" + continue + } + if cs.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "require_tcp" + continue + } + if !state.hasObservedDescriptor() { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "no_descriptor" continue } - if clientState.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP { + if !state.Descriptor.ExpiresAt.After(now) { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "expired" continue } - if !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.HasOverlayPeer() { + if !state.Descriptor.HasOverlayPeer() { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "no_overlay_peer" continue } if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) { + trace.Suppressed = append(trace.Suppressed, relayURL) + trace.Reasons[relayURL] = "suppressed" continue } autoPool = append(autoPool, state) } - multiHop := p.rankRelayPool(autoPool, clientState.LocalAddress) - if len(multiHop) > clientState.MultiHopDepth { - multiHop = multiHop[:clientState.MultiHopDepth] + // Compute pool statistics before promotion. + avgRTT, cv := molsRTTStats(autoPool) + trace.AvgRTT = avgRTT + trace.CV = cv + congested := avgRTT > molsCongestionRTTThreshold + nonLinear := cv > molsCVThreshold + trace.Congested = congested + trace.NonLinear = nonLinear + + m1, m2 := molsBaseM1, molsBaseM2 + if nonLinear { + m1, m2 = molsVariantM1, molsVariantM2 + } + trace.M1, trace.M2 = m1, m2 + + // Replicate the partition+promotion logic from rankRelayPool to determine + // which relays remain as fallbacks after the minimum-active-node promotion + // step. Demoted=true only for relays that stay in the fallback section after + // promotion (i.e., were not promoted to meet molsMinActiveNodes). + mhActive := make([]RelayState, 0, len(autoPool)) + mhFallbacks := make([]RelayState, 0) + for _, state := range autoPool { + if isRelayFallback(state) { + mhFallbacks = append(mhFallbacks, state) + } else { + mhActive = append(mhActive, state) + } + } + // PoolFallback is counted before promotion (reflects raw slow-relay count). + trace.PoolEligible = len(autoPool) + trace.PoolFallback = len(mhFallbacks) + if len(mhActive) < molsMinActiveNodes && len(mhFallbacks) > 0 { + promote := min(molsMinActiveNodes-len(mhActive), len(mhFallbacks)) + mhFallbacks = mhFallbacks[promote:] + } + + // Build a set of relay URLs that remain demoted (survive as fallbacks after promotion). + mhDemotedURLs := make(map[string]bool, len(mhFallbacks)) + for _, s := range mhFallbacks { + mhDemotedURLs[s.Descriptor.APIHTTPSAddr] = true + } + + // Build Ranked entries for all candidates in the auto pool. + ingressIdx := hashToGF64(cs.LocalAddress) + for _, state := range autoPool { + candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr) + var score int + if congested { + score = molsCongestionScore(ingressIdx, candidateIdx, m1, m2) + } else { + score = molsScore(ingressIdx, candidateIdx, m1, m2) + } + trace.Ranked = append(trace.Ranked, telemetry.TraceEntry{ + URL: state.Descriptor.APIHTTPSAddr, + Score: score, + Confirmed: state.Confirmed, + RTT: state.DiscoveryRTT, + Demoted: mhDemotedURLs[state.Descriptor.APIHTTPSAddr], + }) + } + + multiHop := p.rankRelayPool(autoPool, cs.LocalAddress) + if len(multiHop) > cs.MultiHopDepth { + multiHop = multiHop[:cs.MultiHopDepth] } - return multiHop + trace.OutputURLs = multiHop + trace.SelectionTook = time.Since(start) + return multiHop, trace +} + +// SelectMultiHop returns the ordered list of relay URLs for multi-hop routing. +// It delegates to SelectMultiHopWithTrace and discards the trace. +func (p MOLSRelayPolicy) SelectMultiHop(states []RelayState, clientState ClientState) []string { + out, _ := p.SelectMultiHopWithTrace(states, clientState) + return out } diff --git a/portal/discovery/mols_test.go b/portal/discovery/mols_test.go index baf026a1..1a11be15 100644 --- a/portal/discovery/mols_test.go +++ b/portal/discovery/mols_test.go @@ -4,6 +4,8 @@ import ( "fmt" "testing" "time" + + "github.com/gosuda/portal-tunnel/v2/portal/telemetry" ) // TestGF64MulIdentity checks that multiplying any element by 1 is the identity. @@ -603,3 +605,241 @@ func TestMOLSRTTStatsEmpty(t *testing.T) { t.Fatalf("molsRTTStats(nil) = (%v, %v), want (0, 0)", mean, cv) } } + +// overlayPolicyRelayState returns a confirmed relay state whose descriptor +// satisfies HasOverlayPeer() — required for SelectMultiHop eligibility. +func overlayPolicyRelayState(t *testing.T, relayURL string) RelayState { + t.Helper() + state := confirmedPolicyRelayState(t, relayURL) + state.Descriptor.SupportsOverlay = true + state.Descriptor.WireGuardPublicKey = "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleTA=" // non-empty placeholder + state.Descriptor.WireGuardPort = 51820 + return state +} + +// selectionCase is a shared table row for TestMOLSWithTraceByteEqualToLegacy. +type selectionCase struct { + name string + states []RelayState + cs ClientState +} + +// assertByteEqual verifies that legacy and withTrace slices are identical and +// that trace.OutputURLs matches legacy. It also checks mode and PoolTotal. +func assertByteEqual(t *testing.T, mode string, states []RelayState, legacy []string, withTrace []string, trace telemetry.SelectionTrace) { + t.Helper() + if len(legacy) != len(withTrace) { + t.Fatalf("return-value length mismatch: legacy=%d withTrace=%d", len(legacy), len(withTrace)) + } + for i := range legacy { + if legacy[i] != withTrace[i] { + t.Fatalf("return-value[%d]: legacy=%q withTrace=%q", i, legacy[i], withTrace[i]) + } + } + if len(legacy) != len(trace.OutputURLs) { + t.Fatalf("OutputURLs length mismatch: legacy=%d trace=%d", len(legacy), len(trace.OutputURLs)) + } + for i := range legacy { + if legacy[i] != trace.OutputURLs[i] { + t.Fatalf("OutputURLs[%d]: legacy=%q trace=%q", i, legacy[i], trace.OutputURLs[i]) + } + } + if trace.Mode != mode { + t.Fatalf("Mode = %q, want %q", trace.Mode, mode) + } + if trace.PoolTotal != len(states) { + t.Fatalf("PoolTotal = %d, want %d", trace.PoolTotal, len(states)) + } +} + +// TestMOLSWithTraceByteEqualToLegacy asserts that for every test scenario the +// WithTrace variants produce OutputURLs that are byte-identical to the +// corresponding legacy methods. This is Phase 1 acceptance criterion #1 +// ("Golden no-behavior-change"). +// +// Priority scenarios mirror the existing TestMOLSSelectPriority* inputs. +// MultiHop scenarios are fresh (no pre-existing TestMOLSSelectMultiHop* exist) +// and cover the main eligibility branches. +func TestMOLSWithTraceByteEqualToLegacy(t *testing.T) { + policy := MOLSRelayPolicy{} + + t.Run("priority", func(t *testing.T) { + explicitURL := "https://relay-explicit.example" + relayA := "https://relay-a.example" + relayB := "https://relay-b.example" + + tenRelays := make([]RelayState, 10) + for i := range tenRelays { + tenRelays[i] = confirmedPolicyRelayState(t, fmt.Sprintf("https://relay-%d.example", i)) + } + + healthy1 := confirmedPolicyRelayState(t, "https://relay-healthy-1.example") + healthy1.DiscoveryRTT = 100 * time.Millisecond + healthy1.DiscoveryRTTAt = time.Now() + + healthy2 := confirmedPolicyRelayState(t, "https://relay-healthy-2.example") + healthy2.DiscoveryRTT = 150 * time.Millisecond + healthy2.DiscoveryRTTAt = time.Now() + + fallback := confirmedPolicyRelayState(t, "https://relay-fallback.example") + fallback.DiscoveryRTT = molsFallbackRTTThreshold + time.Millisecond + fallback.DiscoveryRTTAt = time.Now() + + fallback1 := confirmedPolicyRelayState(t, "https://relay-fallback-1.example") + fallback1.DiscoveryRTT = molsFallbackRTTThreshold + time.Millisecond + fallback1.DiscoveryRTTAt = time.Now() + fallback2 := confirmedPolicyRelayState(t, "https://relay-fallback-2.example") + fallback2.DiscoveryRTT = molsFallbackRTTThreshold + time.Millisecond + fallback2.DiscoveryRTTAt = time.Now() + + r1 := confirmedPolicyRelayState(t, "https://relay-one.example") + r2 := confirmedPolicyRelayState(t, "https://relay-two.example") + rttHigh := molsCongestionRTTThreshold + 100*time.Millisecond + r1c := r1 + r1c.DiscoveryRTT = rttHigh + r1c.DiscoveryRTTAt = time.Now() + r2c := r2 + r2c.DiscoveryRTT = rttHigh + r2c.DiscoveryRTTAt = time.Now() + + r1v := confirmedPolicyRelayState(t, "https://relay-one.example") + r1v.DiscoveryRTT = 100 * time.Millisecond + r1v.DiscoveryRTTAt = time.Now() + r2v := confirmedPolicyRelayState(t, "https://relay-two.example") + r2v.DiscoveryRTT = 400 * time.Millisecond + r2v.DiscoveryRTTAt = time.Now() + + rAlpha := confirmedPolicyRelayState(t, "https://relay-alpha.example") + rBeta := confirmedPolicyRelayState(t, "https://relay-beta.example") + rGamma := confirmedPolicyRelayState(t, "https://relay-gamma.example") + + expired := confirmedPolicyRelayState(t, "https://relay-expired.example") + expired.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Minute) + + expExplicit := confirmedPolicyRelayState(t, "https://relay-explicit-expired.example") + expExplicit.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Minute) + + backoff := confirmedPolicyRelayState(t, "https://relay-backoff.example") + backoff.suppressActiveUntil = time.Now().UTC().Add(time.Minute) + + discBackoff := confirmedPolicyRelayState(t, "https://relay-discovery-backoff.example") + discBackoff.nextDiscoveryRefreshAt = time.Now().UTC().Add(time.Minute) + + cases := []selectionCase{ + {name: "nil_pool", states: nil, cs: ClientState{}}, + { + name: "explicit_outside_auto_limit", + states: []RelayState{ + bootstrapPolicyRelayState(explicitURL), + confirmedPolicyRelayState(t, relayA), + confirmedPolicyRelayState(t, relayB), + }, + cs: ClientState{ExplicitRelayURLs: []string{explicitURL}, MaxActiveRelays: 1}, + }, + { + name: "deterministic_fixed_address", + states: []RelayState{ + confirmedPolicyRelayState(t, "https://relay-a.example"), + confirmedPolicyRelayState(t, "https://relay-b.example"), + confirmedPolicyRelayState(t, "https://relay-c.example"), + }, + cs: ClientState{LocalAddress: "0x1234abcd"}, + }, + {name: "fallback_relays_demoted", states: []RelayState{fallback, healthy1, healthy2}, cs: ClientState{}}, + {name: "min_active_nodes_promotes_fallback", states: []RelayState{fallback1, fallback2}, cs: ClientState{}}, + {name: "congestion_switch", states: []RelayState{r1c, r2c}, cs: ClientState{LocalAddress: "ingress-test"}}, + {name: "variant_grid_high_cv", states: []RelayState{r1v, r2v}, cs: ClientState{LocalAddress: "ingress-cv"}}, + {name: "different_ingress_addresses", states: []RelayState{rAlpha, rBeta, rGamma}, cs: ClientState{LocalAddress: "0xabc"}}, + {name: "max_active_relays_cap", states: tenRelays, cs: ClientState{MaxActiveRelays: 3}}, + {name: "zero_max_active_uses_default", states: tenRelays, cs: ClientState{MaxActiveRelays: 0}}, + {name: "skip_expired_auto_relay", states: []RelayState{expired}, cs: ClientState{}}, + { + name: "keep_expired_explicit_relay", + states: []RelayState{expExplicit}, + cs: ClientState{ExplicitRelayURLs: []string{expExplicit.Descriptor.APIHTTPSAddr}}, + }, + {name: "skip_auto_relay_in_backoff", states: []RelayState{backoff}, cs: ClientState{}}, + {name: "keep_discovery_backoff_relay", states: []RelayState{discBackoff}, cs: ClientState{}}, + {name: "keep_unobserved_seed", states: []RelayState{bootstrapPolicyRelayState("https://relay-seed.example")}, cs: ClientState{}}, + {name: "normal_mode_no_rtt", states: []RelayState{r1, r2}, cs: ClientState{LocalAddress: "ingress-test"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + legacy := policy.SelectPriority(tc.states, tc.cs) + withTrace, trace := policy.SelectPriorityWithTrace(tc.states, tc.cs) + assertByteEqual(t, "priority", tc.states, legacy, withTrace, trace) + // min_active_nodes_promotes_fallback: both fallbacks are promoted + // into the active section, so no Ranked entry should be Demoted. + if tc.name == "min_active_nodes_promotes_fallback" { + for i, entry := range trace.Ranked { + if entry.Demoted { + t.Errorf("Ranked[%d] (%q): Demoted=true but relay was promoted to active; want false", i, entry.URL) + } + } + } + // fallback_relays_demoted: the fallback relay (healthy1/healthy2 present, + // so no promotion occurs) must appear as Demoted=true in Ranked. + if tc.name == "fallback_relays_demoted" { + const fallbackURL = "https://relay-fallback.example" + found := false + for i, entry := range trace.Ranked { + if entry.URL == fallbackURL { + found = true + if !entry.Demoted { + t.Errorf("Ranked[%d] (%q): Demoted=false but relay stays in fallback section; want true", i, entry.URL) + } + } + } + if !found { + t.Errorf("fallback relay %q not found in trace.Ranked", fallbackURL) + } + } + }) + } + }) + + t.Run("multihop", func(t *testing.T) { + ovA := overlayPolicyRelayState(t, "https://mh-relay-a.example") + ovB := overlayPolicyRelayState(t, "https://mh-relay-b.example") + ovC := overlayPolicyRelayState(t, "https://mh-relay-c.example") + + // noDescRelay: hasObservedDescriptor()==false (LastSeenAt zero). + noDescRelay := newRelayState("https://mh-nodesc.example") + + bannedRelay := confirmedPolicyRelayState(t, "https://mh-banned.example") + bannedRelay.Banned = true + + suppressedRelay := overlayPolicyRelayState(t, "https://mh-suppressed.example") + suppressedRelay.suppressActiveUntil = time.Now().UTC().Add(time.Minute) + + // noOverlayRelay: hasObservedDescriptor()==true but HasOverlayPeer()==false. + noOverlayRelay := confirmedPolicyRelayState(t, "https://mh-no-overlay.example") + + expiredRelay := overlayPolicyRelayState(t, "https://mh-expired.example") + expiredRelay.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Minute) + + cases := []selectionCase{ + {name: "depth_zero_returns_nil", states: []RelayState{ovA, ovB}, cs: ClientState{MultiHopDepth: 0}}, + {name: "depth_one_returns_nil", states: []RelayState{ovA, ovB}, cs: ClientState{MultiHopDepth: 1}}, + {name: "nil_pool", states: nil, cs: ClientState{MultiHopDepth: 2}}, + {name: "empty_pool_after_aggregate", states: []RelayState{bannedRelay}, cs: ClientState{MultiHopDepth: 2}}, + {name: "eligible_pool_depth_2", states: []RelayState{ovA, ovB, ovC}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-1"}}, + {name: "eligible_pool_depth_3", states: []RelayState{ovA, ovB, ovC}, cs: ClientState{MultiHopDepth: 3, LocalAddress: "client-2"}}, + {name: "depth_exceeds_pool_size", states: []RelayState{ovA, ovB}, cs: ClientState{MultiHopDepth: 5, LocalAddress: "client-3"}}, + {name: "skip_no_descriptor", states: []RelayState{noDescRelay, ovA}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-4"}}, + {name: "skip_expired", states: []RelayState{expiredRelay, ovB}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-5"}}, + {name: "skip_no_overlay_peer", states: []RelayState{noOverlayRelay, ovC}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-6"}}, + {name: "skip_suppressed", states: []RelayState{suppressedRelay, ovA}, cs: ClientState{MultiHopDepth: 2, LocalAddress: "client-7"}}, + {name: "all_ineligible_returns_nil", states: []RelayState{expiredRelay, noDescRelay, noOverlayRelay}, cs: ClientState{MultiHopDepth: 2}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + legacy := policy.SelectMultiHop(tc.states, tc.cs) + withTrace, trace := policy.SelectMultiHopWithTrace(tc.states, tc.cs) + assertByteEqual(t, "multihop", tc.states, legacy, withTrace, trace) + }) + } + }) +} diff --git a/portal/discovery/relayset.go b/portal/discovery/relayset.go index 98737b64..dc068523 100644 --- a/portal/discovery/relayset.go +++ b/portal/discovery/relayset.go @@ -9,7 +9,10 @@ import ( "sync" "time" + "github.com/rs/zerolog/log" + "github.com/gosuda/portal-tunnel/v2/portal/auth" + "github.com/gosuda/portal-tunnel/v2/portal/telemetry" "github.com/gosuda/portal-tunnel/v2/types" ) @@ -220,7 +223,12 @@ func (s *RelaySet) ConfirmedRelays() []RelayState { return policy.SelectConfirmed(states) } -func (s *RelaySet) PriorityRelays(clientState ClientState) []string { +// PriorityRelaysWithTrace returns the same ordered relay-URL list as +// PriorityRelays, plus a SelectionTrace populated with pool statistics, +// eligibility classification, and the scoring parameters used. Prometheus +// metrics are emitted from the trace before returning, and a sampled zerolog +// debug entry is written. +func (s *RelaySet) PriorityRelaysWithTrace(clientState ClientState) ([]string, telemetry.SelectionTrace) { s.mu.RLock() states := make([]RelayState, 0, len(s.relays)) for _, state := range s.relays { @@ -229,10 +237,33 @@ func (s *RelaySet) PriorityRelays(clientState ClientState) []string { policy := s.policy s.mu.RUnlock() - return policy.SelectPriority(states, clientState) + result, trace := policy.SelectPriorityWithTrace(states, clientState) + telemetry.EmitFromTrace(trace) + log.Debug(). + Uint8("client_hash", trace.ClientHash). + Int("pool_size", trace.PoolTotal). + Int("output_count", len(trace.OutputURLs)). + Str("mode", trace.Mode). + Bool("congested", trace.Congested). + Strs("top3", first3(trace.OutputURLs)). + Msg("relay selection") + return result, trace } -func (s *RelaySet) PriorityMultiHop(clientState ClientState) []string { +// PriorityRelays returns the ordered list of relay URLs for a client. It +// delegates to PriorityRelaysWithTrace and discards the trace. The public +// signature is unchanged through all phases. +func (s *RelaySet) PriorityRelays(clientState ClientState) []string { + out, _ := s.PriorityRelaysWithTrace(clientState) + return out +} + +// PriorityMultiHopWithTrace returns the same ordered relay-URL list as +// PriorityMultiHop, plus a SelectionTrace populated with pool statistics, +// eligibility classification, and the scoring parameters used. Prometheus +// metrics are emitted from the trace before returning, and a sampled zerolog +// debug entry is written. +func (s *RelaySet) PriorityMultiHopWithTrace(clientState ClientState) ([]string, telemetry.SelectionTrace) { s.mu.RLock() states := make([]RelayState, 0, len(s.relays)) for _, state := range s.relays { @@ -241,7 +272,34 @@ func (s *RelaySet) PriorityMultiHop(clientState ClientState) []string { policy := s.policy s.mu.RUnlock() - return policy.SelectMultiHop(states, clientState) + result, trace := policy.SelectMultiHopWithTrace(states, clientState) + telemetry.EmitFromTrace(trace) + log.Debug(). + Uint8("client_hash", trace.ClientHash). + Int("pool_size", trace.PoolTotal). + Int("output_count", len(trace.OutputURLs)). + Str("mode", trace.Mode). + Bool("congested", trace.Congested). + Strs("top3", first3(trace.OutputURLs)). + Msg("relay selection") + return result, trace +} + +// PriorityMultiHop returns the ordered list of relay URLs for multi-hop +// routing. It delegates to PriorityMultiHopWithTrace and discards the trace. +// The public signature is unchanged through all phases. +func (s *RelaySet) PriorityMultiHop(clientState ClientState) []string { + out, _ := s.PriorityMultiHopWithTrace(clientState) + return out +} + +// first3 returns a slice containing the first three elements of s, or all +// elements if s has fewer than three. It never modifies the input slice. +func first3(s []string) []string { + if len(s) <= 3 { + return s + } + return s[:3] } func (s *RelaySet) OverlayPeerStates() []RelayState { diff --git a/portal/discovery/trace.go b/portal/discovery/trace.go new file mode 100644 index 00000000..034e63cf --- /dev/null +++ b/portal/discovery/trace.go @@ -0,0 +1,18 @@ +package discovery + +// SelectionTrace records observability data for a single relay-selection +// invocation. The struct is populated by SelectPriorityWithTrace / +// SelectMultiHopWithTrace on MOLSRelayPolicy and by the matching siblings on +// RelaySet, and consumed by: +// +// - portal/telemetry/metrics.go — emits low-cardinality fields to Prometheus +// (no per-client labels: ClientHash never becomes a metric label). +// - sampled debug logs (zerolog) — ClientHash carried; LocalAddress +// intentionally NOT carried in the trace (PII-leak surface; ClientHash is +// sufficient for log correlation). +// +// The trace is not part of the public RelaySet API; it is consumed in-process +// only. +// +// See /home/alpha/.claude/plans/sophisticate-and-rationalize-discovery-rosy-parnas.md +// (Phase 1 — Telemetry only) for the rationale. diff --git a/portal/telemetry/emit.go b/portal/telemetry/emit.go new file mode 100644 index 00000000..2bbe5a89 --- /dev/null +++ b/portal/telemetry/emit.go @@ -0,0 +1,76 @@ +package telemetry + +// EmitFromTrace updates relevant Prometheus metrics from a completed +// SelectionTrace. It is safe to call concurrently. +// +// Metrics updated: +// - relay_selected_total{relay, reason} — one increment per OutputURL. +// - selection_duration_seconds — one observation for the whole invocation. +// - congestion_mode — set according to Congested + NonLinear. +// - selection_skipped_total{reason} — one increment per suppressed URL that +// has a reason entry. +// - rtt_seconds{relay} — one observation per Ranked entry with non-zero RTT. +// +// Metrics NOT updated here (wired by later phases / other code paths): +// - relay_pool_size — set by RelaySet pool management. +// - active_tunnels_per_relay — incremented/decremented at tunnel accept/close. +// - failures_total — incremented on discovery/active failure events. +func EmitFromTrace(t SelectionTrace) { + // --- relay_selected_total --- + reason := selectionReason(t) + for _, url := range t.OutputURLs { + RelaySelectedTotal.WithLabelValues(BoundedRelay(url), reason).Inc() + } + + // --- selection_duration_seconds --- + SelectionDurationSeconds.Observe(t.SelectionTook.Seconds()) + + // --- congestion_mode --- + CongestionMode.Set(congestionModeValue(t.Congested, t.NonLinear)) + + // --- selection_skipped_total --- + // Build suppressed set for O(1) lookup. + suppressedSet := make(map[string]struct{}, len(t.Suppressed)) + for _, url := range t.Suppressed { + suppressedSet[url] = struct{}{} + } + for url, reason := range t.Reasons { + if _, ok := suppressedSet[url]; ok { + SelectionSkippedTotal.WithLabelValues(reason).Inc() + } + } + + // --- rtt_seconds --- + for _, entry := range t.Ranked { + if entry.RTT != 0 { + RTTSeconds.WithLabelValues(BoundedRelay(entry.URL)).Observe(entry.RTT.Seconds()) + } + } +} + +// selectionReason derives the reason label for relay_selected_total from the +// trace flags. Explicit/fallback semantics are wired by later phases; this +// function defaults to "auto" for uninstrumented call sites. +func selectionReason(t SelectionTrace) string { + switch { + case t.NonLinear: + return "variant-grid" + case t.Congested: + return "congestion-promoted" + default: + return "auto" + } +} + +// congestionModeValue maps the Congested + NonLinear pair to the metric value. +// 0 = normal, 1 = congested without variant-grid, 2 = variant-grid active. +func congestionModeValue(congested, nonLinear bool) float64 { + switch { + case nonLinear: + return 2 + case congested: + return 1 + default: + return 0 + } +} diff --git a/portal/telemetry/metrics.go b/portal/telemetry/metrics.go new file mode 100644 index 00000000..30f71d08 --- /dev/null +++ b/portal/telemetry/metrics.go @@ -0,0 +1,138 @@ +package telemetry + +// metrics.go — Phase 1 Prometheus telemetry surface for portal/discovery. +// +// Registers 8 low-cardinality metrics on prometheus.DefaultRegisterer via +// promauto. Provides EmitFromTrace(SelectionTrace) to update counter/histogram/ +// gauge metrics from a completed selection invocation. +// +// Cardinality discipline: +// - NO per-client labels (no client_hash, no local_address). +// - Relay-label cardinality capped at maxRelayLabelCardinality unique URLs; +// additional URLs are bucketed under relay="other". +// +// See /home/alpha/.claude/plans/sophisticate-and-rationalize-discovery-rosy-parnas.md +// (Phase 1 — Telemetry only) for rationale. + +import ( + "sync" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// MaxRelayLabelCardinality is the hard cap on distinct relay-URL values used as +// Prometheus labels. URLs beyond the first 64 distinct values are bucketed as +// relay="other" to prevent unbounded cardinality. +const MaxRelayLabelCardinality = 64 + +// relayBudget guards relay-URL cardinality with a single mutex so that the +// membership set and the count are always updated atomically. This prevents +// the race where two goroutines each see "URL not present" and both increment +// the counter, prematurely exhausting the 64-label budget. +var relayBudget = struct { + mu sync.Mutex + seen map[string]struct{} +}{ + seen: make(map[string]struct{}), +} + +// BoundedRelay returns url unchanged when the URL is already known or when +// the distinct-URL count is below MaxRelayLabelCardinality. +// Any URL that would exceed the cap is returned as "other". +func BoundedRelay(url string) string { + relayBudget.mu.Lock() + defer relayBudget.mu.Unlock() + if _, ok := relayBudget.seen[url]; ok { + return url + } + if len(relayBudget.seen) >= MaxRelayLabelCardinality { + return "other" + } + relayBudget.seen[url] = struct{}{} + return url +} + +// -------------------------------------------------------------------------- +// Metric registrations +// -------------------------------------------------------------------------- + +// RelaySelectedTotal counts relay-selection events by (relay, reason). +// reason ∈ {explicit, auto, fallback, congestion-promoted, variant-grid}. +var RelaySelectedTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "portal_discovery_relay_selected_total", + Help: "Total relays selected by reason.", + }, + []string{"relay", "reason"}, +) + +// RelayPoolSize is a gauge of auto-pool size partitioned by state. +// state ∈ {total, active, banned, expired, suppressed, fallback}. +var RelayPoolSize = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "portal_discovery_relay_pool_size", + Help: "Auto-pool size by state.", + }, + []string{"state"}, +) + +// RTTSeconds is a histogram of per-relay discovery RTT observations. +// label: relay. Buckets: 10 ms … 5 s. +var RTTSeconds = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "portal_discovery_rtt_seconds", + Help: "Discovery RTT per relay (seconds).", + Buckets: []float64{0.010, 0.050, 0.100, 0.250, 0.500, 1.0, 2.0, 5.0}, + }, + []string{"relay"}, +) + +// ActiveTunnelsPerRelay is a gauge of tunnel count for each relay. +// SDK-local measurement: tracks this process's tunnel distribution only. +var ActiveTunnelsPerRelay = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "portal_discovery_active_tunnels_per_relay", + Help: "SDK-local; measures this exposure's tunnel distribution, not relay-wide load.", + }, + []string{"relay"}, +) + +// SelectionDurationSeconds is a histogram of wall time per selection call. +// No labels; uses prometheus default buckets. +var SelectionDurationSeconds = promauto.NewHistogram( + prometheus.HistogramOpts{ + Name: "portal_discovery_selection_duration_seconds", + Help: "Wall time of a single relay-selection invocation.", + // Default prometheus buckets (.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10). + }, +) + +// SelectionSkippedTotal counts relays excluded from selection by reason. +// reason ∈ {expired, require_udp, require_tcp, suppressed, banned, no_descriptor, no_overlay_peer}. +var SelectionSkippedTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "portal_discovery_selection_skipped_total", + Help: "Relays skipped during selection by reason.", + }, + []string{"reason"}, +) + +// FailuresTotal counts discovery and active-path failures per relay. +// labels: relay, kind ∈ {discovery, active}. +var FailuresTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "portal_discovery_failures_total", + Help: "Discovery and active failures per relay.", + }, + []string{"relay", "kind"}, +) + +// CongestionMode is a gauge encoding the current congestion state. +// 0 = normal, 1 = congested (no variant-grid), 2 = variant-grid active. +var CongestionMode = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "portal_discovery_congestion_mode", + Help: "Active congestion mode (0=normal, 1=congested, 2=variant-grid).", + }, +) diff --git a/portal/telemetry/metrics_test.go b/portal/telemetry/metrics_test.go new file mode 100644 index 00000000..c2183a5f --- /dev/null +++ b/portal/telemetry/metrics_test.go @@ -0,0 +1,304 @@ +package telemetry_test + +import ( + "errors" + "fmt" + "testing" + "time" + + dto "github.com/prometheus/client_model/go" + + "github.com/gosuda/portal-tunnel/v2/portal/telemetry" + "github.com/prometheus/client_golang/prometheus" +) + +// metricFamilyByName gathers all metric families and returns the one with the +// given name, or nil if not found. +func metricFamilyByName(t *testing.T, name string) *dto.MetricFamily { + t.Helper() + mfs, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("gather: %v", err) + } + for _, mf := range mfs { + if mf.GetName() == name { + return mf + } + } + return nil +} + +// assertRegisteredWithName verifies that collector c is already registered on +// prometheus.DefaultRegisterer by attempting to re-register it and expecting +// AlreadyRegisteredError. It then confirms that the previously-registered +// collector's first described metric name equals wantName. +// +// This is the only approach that simultaneously proves (a) the collector is on +// the default registry and (b) the registered metric has the expected name, for +// both Vec and non-Vec collectors with no prior observations. +func assertRegisteredWithName(t *testing.T, c prometheus.Collector, wantName string) { + t.Helper() + err := prometheus.DefaultRegisterer.Register(c) + if err == nil { + // Re-registration succeeded — the collector was NOT on the default registry. + // Undo the registration so the rest of the test suite is not affected. + prometheus.DefaultRegisterer.Unregister(c) + t.Fatalf("metric %q: collector was not registered on DefaultRegisterer before the test", wantName) + } + var are prometheus.AlreadyRegisteredError + if !errors.As(err, &are) { + t.Fatalf("metric %q: unexpected registration error: %v", wantName, err) + } + // are.ExistingCollector is the collector already on the registry. + // Drain its Describe channel to confirm the expected metric name is present. + ch := make(chan *prometheus.Desc, 32) + go func() { + are.ExistingCollector.Describe(ch) + close(ch) + }() + found := false + for d := range ch { + // Desc.String() format: Desc{fqName: "the_name", help: "...", ...} + s := d.String() + const marker = `fqName: "` + idx := 0 + for idx+len(marker) <= len(s) { + if s[idx:idx+len(marker)] == marker { + start := idx + len(marker) + end := start + for end < len(s) && s[end] != '"' { + end++ + } + if s[start:end] == wantName { + found = true + } + break + } + idx++ + } + } + if !found { + t.Errorf("metric %q: name not found in described metrics of existing collector", wantName) + } +} + +// TestMetricsRegistryPresence asserts that all 8 Phase-1 metrics are registered +// on prometheus.DefaultRegisterer. It uses Register→AlreadyRegisteredError so +// Vec metrics with no prior observations are still detected (they are invisible +// to DefaultGatherer.Gather until the first label combination is used). +func TestMetricsRegistryPresence(t *testing.T) { + want := []struct { + name string + collector prometheus.Collector + typ dto.MetricType + }{ + {"portal_discovery_relay_selected_total", telemetry.RelaySelectedTotal, dto.MetricType_COUNTER}, + {"portal_discovery_relay_pool_size", telemetry.RelayPoolSize, dto.MetricType_GAUGE}, + {"portal_discovery_rtt_seconds", telemetry.RTTSeconds, dto.MetricType_HISTOGRAM}, + {"portal_discovery_active_tunnels_per_relay", telemetry.ActiveTunnelsPerRelay, dto.MetricType_GAUGE}, + {"portal_discovery_selection_duration_seconds", telemetry.SelectionDurationSeconds, dto.MetricType_HISTOGRAM}, + {"portal_discovery_selection_skipped_total", telemetry.SelectionSkippedTotal, dto.MetricType_COUNTER}, + {"portal_discovery_failures_total", telemetry.FailuresTotal, dto.MetricType_COUNTER}, + {"portal_discovery_congestion_mode", telemetry.CongestionMode, dto.MetricType_GAUGE}, + } + + for _, tc := range want { + t.Run(tc.name, func(t *testing.T) { + // Primary check: collector is on DefaultRegisterer. + assertRegisteredWithName(t, tc.collector, tc.name) + // Secondary check: if the metric has observations, verify HELP and type. + mf := metricFamilyByName(t, tc.name) + if mf != nil { + if mf.GetHelp() == "" { + t.Errorf("metric %q has empty HELP string", tc.name) + } + if mf.GetType() != tc.typ { + t.Errorf("metric %q: got type %v, want %v", tc.name, mf.GetType(), tc.typ) + } + } + }) + } +} + +// TestEmitFromTrace_CounterIncrement verifies that EmitFromTrace increments +// relay_selected_total for each output URL and records a selection duration. +// URL names are test-namespaced to avoid coupling to other tests that share the +// process-global relay-cardinality map. +func TestEmitFromTrace_CounterIncrement(t *testing.T) { + r1 := "t-counter-r1" + r2 := "t-counter-r2" + + // Capture baseline before the call. + baseline := func(relay string) float64 { + mf := metricFamilyByName(t, "portal_discovery_relay_selected_total") + if mf == nil { + return 0 + } + for _, m := range mf.GetMetric() { + var gotRelay, gotReason string + for _, lp := range m.GetLabel() { + switch lp.GetName() { + case "relay": + gotRelay = lp.GetValue() + case "reason": + gotReason = lp.GetValue() + } + } + if gotRelay == relay && gotReason == "auto" { + return m.GetCounter().GetValue() + } + } + return 0 + } + + baseR1 := baseline(r1) + baseR2 := baseline(r2) + + // Capture duration baseline before the single EmitFromTrace call. + durationSampleCount := func() uint64 { + mf := metricFamilyByName(t, "portal_discovery_selection_duration_seconds") + if mf == nil || len(mf.GetMetric()) == 0 { + return 0 + } + return mf.GetMetric()[0].GetHistogram().GetSampleCount() + } + baseDur := durationSampleCount() + + telemetry.EmitFromTrace(telemetry.SelectionTrace{ + OutputURLs: []string{r1, r2}, + SelectionTook: 50 * time.Millisecond, + Congested: false, + NonLinear: false, + }) + + // relay_selected_total delta must be 1 for each relay. + afterR1 := baseline(r1) + afterR2 := baseline(r2) + if afterR1-baseR1 != 1 { + t.Errorf("relay_selected_total{relay=%q,reason=auto}: delta want 1, got %v", r1, afterR1-baseR1) + } + if afterR2-baseR2 != 1 { + t.Errorf("relay_selected_total{relay=%q,reason=auto}: delta want 1, got %v", r2, afterR2-baseR2) + } + + // selection_duration_seconds delta must be exactly 1 for this invocation. + afterDur := durationSampleCount() + if afterDur-baseDur != 1 { + t.Errorf("selection_duration_seconds sample delta want 1, got %d", afterDur-baseDur) + } +} + +// TestEmitFromTrace_CardinalityCap verifies the relay-label cardinality cap. +// +// We emit maxRelayLabelCardinality+1 distinct relay URLs and then assert: +// 1. relay="other" appears in relay_selected_total (overflow was bucketed). +// 2. Every emitted URL either appears as its own relay label OR caused "other" +// to be incremented — i.e., no URL is silently dropped. +// +// Because relayBudget is process-global and prior tests may have consumed some +// slots, we emit enough URLs (maxRelayLabelCardinality+1 = 65) to guarantee at +// least one overflow regardless of prior state, then verify the above. +// +// URLs are namespaced as "t-cap-NNN" to isolate them from other tests. +func TestEmitFromTrace_CardinalityCap(t *testing.T) { + const total = telemetry.MaxRelayLabelCardinality + 1 // 65 + + // Build the set of our namespace URLs. + ourURLs := make(map[string]struct{}, total) + for i := 0; i < total; i++ { + ourURLs[fmt.Sprintf("t-cap-%03d", i)] = struct{}{} + } + + // relayReasonCounter returns the counter value for the given (relay, reason) pair. + relayReasonCounter := func(relay, reason string) float64 { + mf := metricFamilyByName(t, "portal_discovery_relay_selected_total") + if mf == nil { + return 0 + } + for _, m := range mf.GetMetric() { + var r, rs string + for _, lp := range m.GetLabel() { + switch lp.GetName() { + case "relay": + r = lp.GetValue() + case "reason": + rs = lp.GetValue() + } + } + if r == relay && rs == reason { + return m.GetCounter().GetValue() + } + } + return 0 + } + + // Our traces are all non-congested non-nonlinear → reason="auto". + baseOther := relayReasonCounter("other", "auto") + + for i := 0; i < total; i++ { + url := fmt.Sprintf("t-cap-%03d", i) + telemetry.EmitFromTrace(telemetry.SelectionTrace{ + OutputURLs: []string{url}, + SelectionTook: time.Millisecond, + }) + } + + mf := metricFamilyByName(t, "portal_discovery_relay_selected_total") + if mf == nil { + t.Fatal("portal_discovery_relay_selected_total not found") + } + + // Collect the our-namespace relay labels that were admitted (got own slot). + admittedOurs := make(map[string]struct{}) + for _, m := range mf.GetMetric() { + var r string + for _, lp := range m.GetLabel() { + if lp.GetName() == "relay" { + r = lp.GetValue() + } + } + if _, ok := ourURLs[r]; ok { + admittedOurs[r] = struct{}{} + } + } + + afterOther := relayReasonCounter("other", "auto") + overflowed := total - len(admittedOurs) // how many of our URLs were bucketed + + // Assert: at least one URL overflowed to "other". + if overflowed <= 0 { + t.Errorf("expected at least 1 URL to overflow to \"other\"; admitted=%d out of %d", len(admittedOurs), total) + } + + // Assert: the counter delta for {relay="other",reason="auto"} matches the + // number of our-namespace URLs that were not admitted (not merely inferred). + delta := afterOther - baseOther + if delta < float64(overflowed) { + t.Errorf("relay_selected_total{relay=\"other\",reason=\"auto\"} delta want >=%d, got %.0f", overflowed, delta) + } + + // Assert: admitted URL count never exceeds the cap. + if len(admittedOurs) > telemetry.MaxRelayLabelCardinality { + t.Errorf("admitted our-namespace relays: want <=%d, got %d", telemetry.MaxRelayLabelCardinality, len(admittedOurs)) + } +} + +// TestMetrics_NoPIILabels iterates every gathered metric family and every label +// pair within and asserts that no label *name* equals "client_hash" or +// "local_address". This is the Phase 1 regression defense for acceptance #4. +func TestMetrics_NoPIILabels(t *testing.T) { + mfs, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("gather: %v", err) + } + for _, mf := range mfs { + for _, m := range mf.GetMetric() { + for _, lp := range m.GetLabel() { + name := lp.GetName() + if name == "client_hash" || name == "local_address" { + t.Errorf("PII label %q found in metric family %q", name, mf.GetName()) + } + } + } + } +} diff --git a/portal/telemetry/trace.go b/portal/telemetry/trace.go new file mode 100644 index 00000000..2cc37791 --- /dev/null +++ b/portal/telemetry/trace.go @@ -0,0 +1,63 @@ +package telemetry + +import "time" + +// SelectionTrace records observability data for a single relay-selection +// invocation. +type SelectionTrace struct { + Timestamp time.Time + + // ClientHash is hashToGF64(LocalAddress) — a single byte derived from the + // client identity. Used only for sampled debug-log correlation. Not a + // Prometheus label (would unbounded cardinality) and not a public field on + // any external API. + ClientHash uint8 + + // Mode is "priority" or "multihop", matching the calling method. + Mode string + + // Pool snapshot at selection time. + PoolTotal int + PoolEligible int + PoolFallback int + + // Suppressed lists URLs excluded from selection along with the reason map. + Suppressed []string + Reasons map[string]string + + // Congested is true when the existing congestion-grid switch is active + // (RTT mean > molsCongestionRTTThreshold). + Congested bool + + // NonLinear is true when the variant-grid multiplier flip is active + // (CV > molsCVThreshold). + NonLinear bool + + // M1, M2 are the MOLS multipliers used for this selection. + M1, M2 uint8 + + // AvgRTT is the mean discovery RTT across the auto pool sample. + AvgRTT time.Duration + + // CV is the coefficient of variation of per-relay discovery RTTs. + CV float64 + + // Ranked carries per-relay scoring detail for every candidate considered + // (excluded URLs appear in Suppressed/Reasons instead). + Ranked []TraceEntry + + // OutputURLs is the final ordered list returned by the selection method. + OutputURLs []string + + // SelectionTook is wall time spent in the selection method. + SelectionTook time.Duration +} + +// TraceEntry captures per-relay scoring detail within a SelectionTrace. +type TraceEntry struct { + URL string + Score int + Confirmed bool + RTT time.Duration + Demoted bool +} diff --git a/sdk/expose.go b/sdk/expose.go index 7cb9632d..e67d5486 100644 --- a/sdk/expose.go +++ b/sdk/expose.go @@ -15,6 +15,7 @@ import ( "github.com/rs/zerolog/log" "github.com/gosuda/portal-tunnel/v2/portal/discovery" + "github.com/gosuda/portal-tunnel/v2/portal/telemetry" "github.com/gosuda/portal-tunnel/v2/types" "github.com/gosuda/portal-tunnel/v2/utils" ) @@ -357,6 +358,21 @@ func (c *exposureConn) Close() error { return closeErr } +// tunnelCounterConn wraps a net.Conn and calls decr exactly once on the first +// Close invocation to decrement the active_tunnels_per_relay gauge. Subsequent +// Close calls are forwarded to the underlying conn but do not double-decrement. +// Concurrency is guaranteed by sync.Once. +type tunnelCounterConn struct { + net.Conn + once sync.Once + decr func() +} + +func (c *tunnelCounterConn) Close() error { + c.once.Do(c.decr) + return c.Conn.Close() +} + func (e *Exposure) Accept() (net.Conn, error) { select { case <-e.done: @@ -609,11 +625,19 @@ func (e *Exposure) runListenerAcceptLoop(listener *listener) { return } + telemetry.ActiveTunnelsPerRelay.WithLabelValues(relayURL).Inc() + wrappedConn := &tunnelCounterConn{ + Conn: conn, + decr: func() { + telemetry.ActiveTunnelsPerRelay.WithLabelValues(relayURL).Dec() + }, + } + select { case <-e.done: - _ = conn.Close() + _ = wrappedConn.Close() return - case e.accepted <- conn: + case e.accepted <- wrappedConn: } } }