Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3fea6de
feat(discovery): add SelectionTrace + TraceEntry types (Phase 1 trace…
metaphorics Apr 30, 2026
8c4c941
build: add prometheus/client_golang as direct dep
metaphorics Apr 30, 2026
404b6cf
feat(discovery): add Phase 1 Prometheus metrics surface (8 metrics + …
metaphorics Apr 30, 2026
9befcd9
fix(discovery): strengthen metrics_test presence check + errorlint fix
metaphorics Apr 30, 2026
b5ce160
feat(discovery): add SelectPriorityWithTrace + SelectMultiHopWithTrac…
metaphorics Apr 30, 2026
e20e9f9
feat(relay-server): mount /metrics on admin endpoint (auth-gated)
metaphorics Apr 30, 2026
6eb0144
feat(portal-tunnel): add optional --metrics-addr flag
metaphorics Apr 30, 2026
ef4b980
feat(sdk): track active_tunnels_per_relay gauge in accept loop
metaphorics Apr 30, 2026
2b5b6fb
feat(discovery): add RelaySet.PriorityRelaysWithTrace + PriorityMulti…
metaphorics Apr 30, 2026
a3e3646
feat(loadtest): add portal-loadtest uniformity probe (chi-square vs u…
metaphorics Apr 30, 2026
8502e23
build: add Makefile load-test target
metaphorics Apr 30, 2026
b9015c6
fix: remove accidental keyless_tls submodule pointer
metaphorics Apr 30, 2026
5c37195
chore: gitignore keyless_tls/ (separate nested workspace)
metaphorics Apr 30, 2026
5704079
Merge branch 'main' into discovery-phase-1-telemetry
metaphorics Apr 30, 2026
8c40440
Merge branch 'main' into discovery-phase-1-telemetry
gg582 May 1, 2026
020e055
refact: extract telemetry from discovery into new package
gg582 May 2, 2026
24e6b38
lint: fix lint
gg582 May 2, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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))

%:
@:
Comment on lines +109 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove global catch-all rule that masks unknown make targets

The new %: fallback makes every unknown target succeed as a no-op, so typos in local/CI commands silently pass instead of failing fast (e.g., make definitely-not-a-target now exits 0). That can skip intended build/test/vet steps without any signal, which is a reliability regression beyond the load-test passthrough use case this rule was added for.

Useful? React with 👍 / 👎.

237 changes: 237 additions & 0 deletions cmd/portal-loadtest/main.go
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +43 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject relay count below two for chi-square calculation

The CLI currently accepts -relays=1, but the chi-square test then uses df := relays - 1 (so df=0) and calls igamc(0, ...), which immediately returns 1.0 via the s <= 0 guard; this prints a valid-looking p-value for a statistically undefined case. This can mislead users running the probe with small K values, so input validation should require at least 2 relays (or explicitly special-case the output).

Useful? React with 👍 / 👎.

}
// 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
}
20 changes: 20 additions & 0 deletions cmd/portal-tunnel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -62,6 +64,7 @@ type exposeFlags struct {
tcp bool
maxActiveRelays int
multiHopDepth int
metricsAddr string
}

func runExposeCommand(args []string) error {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions cmd/relay-server/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading