diff --git a/socks/applyfileover_test.go b/socks/applyfileover_test.go new file mode 100644 index 0000000..c3b0833 --- /dev/null +++ b/socks/applyfileover_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "testing" + "time" +) + +// TestApplyFileOverAllFields walks every field branch of applyFileOver by +// overlaying a file with every field set onto an empty base, then verifying +// each landed. This is the branch-coverage companion to the partial-merge +// semantics test. +func TestApplyFileOverAllFields(t *testing.T) { + overlay := &reliabilityFile{ + UdpTeardownSignal: boolPtr(true), + QuicRebindOnExitLoss: boolPtr(true), + TcpCollapseMaxHoldMs: int64Ptr(1500), + SendStallTimeoutMs: int64Ptr(3000), + ClusterAffinityFallback: boolPtr(true), + ServerNameAffinityBridge: boolPtr(true), + SequenceIdleTimeoutMs: int64Ptr(120000), + TcpSequenceIdleTimeoutMs: int64Ptr(600000), + BlackholeReceiveTimeoutMs: int64Ptr(20000), + MaxFlowsPerExit: intPtr(16), + AffinityStickyPastCap: boolPtr(true), + QuarantineGroupFollow: boolPtr(true), + GroupFollowWindowMs: int64Ptr(45000), + DialFailureRerace: boolPtr(true), + UplinkStalenessGateMs: int64Ptr(5000), + SoftVerdictDemote: boolPtr(true), + RemovalBudgetCount: intPtr(2), + RemovalBudgetWindowMs: int64Ptr(30000), + StandingReserve: boolPtr(true), + EffectiveTierSelection: boolPtr(true), + MinBlackholeDestinations: intPtr(2), + BlackholeLoadCorroboration: intPtr(8), + ProviderProbe: boolPtr(true), + ProbeTimeoutMs: int64Ptr(4000), + ProbeSampleHostCount: intPtr(4), + ProbeSilenceWarnStreak: intPtr(2), + EvaluationPoolMultiple: intPtr(2), + FormationPollTimeoutMs: int64Ptr(200), + BusyProbe: boolPtr(true), + BusyProbeBudgetMs: int64Ptr(1500), + SchedulerPauseToleranceMs: int64Ptr(2000), + SchedulerPauseRecoveryTimeoutMs: int64Ptr(5000), + BlackholeConnectComparativeTimeoutMs: int64Ptr(10000), + HeartbeatIntervalMs: int64Ptr(60000), + } + + dst := &reliabilityFile{} + if err := applyFileOver(dst, overlay); err != nil { + t.Fatal(err) + } + + s, err := fileToSettings(dst) + if err != nil { + t.Fatal(err) + } + + checks := []struct { + name string + got any + want any + }{ + {"UdpTeardownSignal", s.UdpTeardownSignal, true}, + {"QuicRebindOnExitLoss", s.QuicRebindOnExitLoss, true}, + {"TcpCollapseMaxHold", s.TcpCollapseMaxHold, 1500 * time.Millisecond}, + {"SendStallTimeout", s.SendStallTimeout, 3 * time.Second}, + {"ClusterAffinityFallback", s.ClusterAffinityFallback, true}, + {"ServerNameAffinityBridge", s.ServerNameAffinityBridge, true}, + {"SequenceIdleTimeout", s.SequenceIdleTimeout, 120 * time.Second}, + {"TcpSequenceIdleTimeout", s.TcpSequenceIdleTimeout, 600 * time.Second}, + {"BlackholeReceiveTimeout", s.BlackholeReceiveTimeout, 20 * time.Second}, + {"MaxFlowsPerExit", s.MaxFlowsPerExit, 16}, + {"AffinityStickyPastCap", s.AffinityStickyPastCap, true}, + {"QuarantineGroupFollow", s.QuarantineGroupFollow, true}, + {"GroupFollowWindow", s.GroupFollowWindow, 45 * time.Second}, + {"DialFailureRerace", s.DialFailureRerace, true}, + {"UplinkStalenessGate", s.UplinkStalenessGate, 5 * time.Second}, + {"SoftVerdictDemote", s.SoftVerdictDemote, true}, + {"RemovalBudgetCount", s.RemovalBudgetCount, 2}, + {"RemovalBudgetWindow", s.RemovalBudgetWindow, 30 * time.Second}, + {"StandingReserve", s.StandingReserve, true}, + {"EffectiveTierSelection", s.EffectiveTierSelection, true}, + {"MinBlackholeDestinations", s.MinBlackholeDestinations, 2}, + {"BlackholeLoadCorroboration", s.BlackholeLoadCorroboration, 8}, + {"ProviderProbe", s.ProviderProbe, true}, + {"ProbeTimeout", s.ProbeTimeout, 4 * time.Second}, + {"ProbeSampleHostCount", s.ProbeSampleHostCount, 4}, + {"ProbeSilenceWarnStreak", s.ProbeSilenceWarnStreak, 2}, + {"EvaluationPoolMultiple", s.EvaluationPoolMultiple, 2}, + {"FormationPollTimeout", s.FormationPollTimeout, 200 * time.Millisecond}, + {"BusyProbe", s.BusyProbe, true}, + {"BusyProbeBudget", s.BusyProbeBudget, 1500 * time.Millisecond}, + {"SchedulerPauseTolerance", s.SchedulerPauseTolerance, 2 * time.Second}, + {"SchedulerPauseRecoveryTimeout", s.SchedulerPauseRecoveryTimeout, 5 * time.Second}, + {"BlackholeConnectComparativeTimeout", s.BlackholeConnectComparativeTimeout, 10 * time.Second}, + {"HeartbeatInterval", s.HeartbeatInterval, 60 * time.Second}, + } + for _, c := range checks { + if c.got != c.want { + t.Errorf("%s = %v, want %v", c.name, c.got, c.want) + } + } +} + +// TestLoadReliabilitySettingsFile covers the thin file-loading wrapper and +// its error paths. +func TestLoadReliabilitySettingsFile(t *testing.T) { + path := writeRelFile(t, `{"maxFlowsPerExit": 32}`) + s, err := loadReliabilitySettingsFile(path) + if err != nil { + t.Fatal(err) + } + if s.MaxFlowsPerExit != 32 { + t.Errorf("MaxFlowsPerExit = %d, want 32", s.MaxFlowsPerExit) + } + + if _, err := loadReliabilitySettingsFile("/nonexistent.json"); err == nil { + t.Error("expected error for missing file") + } + bad := writeRelFile(t, `{bad`) + if _, err := loadReliabilitySettingsFile(bad); err == nil { + t.Error("expected error for invalid json") + } +} diff --git a/socks/control_panel.html b/socks/control_panel.html new file mode 100644 index 0000000..9ee3f93 --- /dev/null +++ b/socks/control_panel.html @@ -0,0 +1,429 @@ + + + + + + +socksproxy dev panel + + + +
+ URnetwork +
+

socksproxy dev panel

+
connecting...
+
+
--
+
+ +

Measurements

+
loading...
+ +

Actions

+
+ + + + +
+ +
+ +

Exits

+
loading...
+ +
+ + + + diff --git a/socks/control_server.go b/socks/control_server.go new file mode 100644 index 0000000..617ec2c --- /dev/null +++ b/socks/control_server.go @@ -0,0 +1,337 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "time" + + _ "embed" + + "github.com/urnetwork/connect" +) + +//go:embed control_panel.html +var controlPanelHTML []byte + +//go:embed ur_logo.svg +var urLogoSVG []byte + +// mcControl is the slice of the multi client the control plane uses. The real +// *connect.RemoteUserNatMultiClient satisfies it; tests substitute a fake. +type mcControl interface { + ReliabilitySettings() *connect.ReliabilitySettings + SetReliabilitySettings(settings *connect.ReliabilitySettings) + ReliabilityMetrics() *connect.ReliabilityMetricsSnapshot + ResetReliabilityMetrics() + Shuffle() + ProbeAllExits() int + Exits() []*connect.ExitInfo + DropExit(clientId connect.Id) bool + StallExit(clientId connect.Id, stalled bool) bool + MigrateExit(clientId connect.Id) int + PacketStats() *connect.PacketStats +} + +// controlServer exposes the live connection to a local http client so the +// dev knobs can be changed mid-run without a reconnect, mirroring the android +// developer screen. Bind it to loopback -- it is an unauthenticated control +// plane. Even on loopback, mutating requests are rejected unless they carry +// no Origin header or a loopback Origin, so a web page open in a browser +// cannot drive the control plane (same class as the webpack-dev-server +// drive-by-localhost CVEs). +type controlServer struct { + server *http.Server + mc mcControl + mu sync.Mutex // serializes the read-merge-write of settings + started time.Time // when the control server came up; session elapsed + cancel context.CancelFunc // optional: quit action cancels the session ctx +} + +// SetCancel wires the session cancel function so the "quit" action can stop +// the process gracefully (same path as Ctrl+C). +func (c *controlServer) SetCancel(cancel context.CancelFunc) { + c.cancel = cancel +} + +// newControlServer builds the local http control plane bound to addr. It does +// not bind until serve is called; addr must be a loopback address (enforced +// by the caller in main.go run). +func newControlServer(addr string, mc mcControl) (*controlServer, error) { + c := &controlServer{mc: mc, started: time.Now()} + mux := http.NewServeMux() + mux.HandleFunc("/", c.handleRoot) + mux.HandleFunc("/favicon.svg", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Write(urLogoSVG) + }) + mux.HandleFunc("/settings", c.handleSettings) + mux.HandleFunc("/actions", c.handleActions) + mux.HandleFunc("/metrics", c.handleMetrics) + mux.HandleFunc("/exits", c.handleExits) + mux.HandleFunc("/stats", c.handleStats) + c.server = &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + return c, nil +} + +func (c *controlServer) serve(ctx context.Context) { + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + c.server.Shutdown(shutdownCtx) + }() + if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fmt.Printf("control server error: %v\n", err) + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +// handleRoot serves the embedded dev panel (the GUI mirror of the android +// developer screen). Everything else on / is a 404; the api routes are +// registered explicitly. +func (c *controlServer) handleRoot(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(controlPanelHTML) +} + +// loopbackOrigin reports whether the request's Origin header (if any) is a +// loopback origin. A request with no Origin header (curl, local scripts) is +// allowed; a browser cross-origin request always sends Origin, and only +// loopback origins may drive the unauthenticated control plane. +func loopbackOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil || u.Hostname() == "" { + return false + } + host := u.Hostname() + ip := net.ParseIP(host) + if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return false + } + return true +} + +// GET /settings -> current ReliabilitySettings as ms/json +// PUT /settings -> partial reliabilityFile; only named fields change +func (c *controlServer) handleSettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + c.mu.Lock() + s := c.mc.ReliabilitySettings() + c.mu.Unlock() + writeJSON(w, http.StatusOK, settingsToFile(s)) + case http.MethodPut, http.MethodPost: + if !loopbackOrigin(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "non-loopback origin rejected"}) + return + } + var f reliabilityFile + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) // 64 KiB is plenty for a settings overlay + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + // start from current so unset fields are preserved + c.mu.Lock() + merged, err := mergeReliabilitySettings(c.mc.ReliabilitySettings(), f) + if err != nil { + c.mu.Unlock() + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + c.mc.SetReliabilitySettings(merged) + out := settingsToFile(c.mc.ReliabilitySettings()) + c.mu.Unlock() + writeJSON(w, http.StatusOK, out) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// POST /actions {"action":"drop|stall|migrate|shuffle|probe-all|reset-metrics","clientId":"...","stalled":true} +func (c *controlServer) handleActions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if !loopbackOrigin(r) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "non-loopback origin rejected"}) + return + } + var req struct { + Action string `json:"action"` + ClientId string `json:"clientId"` + Stalled bool `json:"stalled"` + } + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + switch req.Action { + case "quit": + if c.cancel != nil { + c.cancel() + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "quitting": true}) + } else { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no cancel wired"}) + } + case "reset-metrics": + c.mc.ResetReliabilityMetrics() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case "shuffle": + c.mc.Shuffle() + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + case "probe-all": + n := c.mc.ProbeAllExits() + writeJSON(w, http.StatusOK, map[string]any{"scheduled": n}) + case "drop", "stall", "migrate": + if req.ClientId == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "clientId required"}) + return + } + id, err := connect.ParseId(req.ClientId) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("bad clientId: %v", err)}) + return + } + switch req.Action { + case "drop": + ok := c.mc.DropExit(id) + writeJSON(w, http.StatusOK, map[string]any{"ok": ok}) + case "stall": + ok := c.mc.StallExit(id, req.Stalled) + writeJSON(w, http.StatusOK, map[string]any{"ok": ok}) + case "migrate": + n := c.mc.MigrateExit(id) + writeJSON(w, http.StatusOK, map[string]any{"moved": n}) + } + default: + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown action"}) + } +} + +// GET /metrics -> reliability snapshot +func (c *controlServer) handleMetrics(w http.ResponseWriter, r *http.Request) { + m := c.mc.ReliabilityMetrics() + writeJSON(w, http.StatusOK, m) +} + +// GET /exits -> current exit table +func (c *controlServer) handleExits(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, c.mc.Exits()) +} + +// statsResponse is the session-level readout the panel's header row shows: +// elapsed time and bytes/packets up and down since the control server +// started. Egress = traffic sent toward the network (up), Ingress = traffic +// received back (down). +type statsResponse struct { + ElapsedSeconds int64 `json:"elapsedSeconds"` + BytesUp int64 `json:"bytesUp"` + BytesDown int64 `json:"bytesDown"` + PacketsUp int64 `json:"packetsUp"` + PacketsDown int64 `json:"packetsDown"` +} + +// GET /stats -> session elapsed + traffic counters +func (c *controlServer) handleStats(w http.ResponseWriter, r *http.Request) { + ps := c.mc.PacketStats() + elapsed := time.Since(c.started) / time.Second + writeJSON(w, http.StatusOK, &statsResponse{ + ElapsedSeconds: int64(elapsed), + BytesUp: int64(ps.RemoteEgressByteCount), + BytesDown: int64(ps.RemoteIngressByteCount), + PacketsUp: int64(ps.RemoteEgressPacketCount), + PacketsDown: int64(ps.RemoteIngressPacketCount), + }) +} + +// settingsToFile renders current settings as the json-friendly shape. +func settingsToFile(s *connect.ReliabilitySettings) *reliabilityFile { + if s == nil { + return &reliabilityFile{} + } + ms := func(d time.Duration) *int64 { + v := int64(d / time.Millisecond) + return &v + } + b := func(v bool) *bool { return &v } + i := func(v int) *int { return &v } + return &reliabilityFile{ + UdpTeardownSignal: b(s.UdpTeardownSignal), + QuicRebindOnExitLoss: b(s.QuicRebindOnExitLoss), + TcpCollapseMaxHoldMs: ms(s.TcpCollapseMaxHold), + SendStallTimeoutMs: ms(s.SendStallTimeout), + ClusterAffinityFallback: b(s.ClusterAffinityFallback), + ServerNameAffinityBridge: b(s.ServerNameAffinityBridge), + SequenceIdleTimeoutMs: ms(s.SequenceIdleTimeout), + TcpSequenceIdleTimeoutMs: ms(s.TcpSequenceIdleTimeout), + BlackholeReceiveTimeoutMs: ms(s.BlackholeReceiveTimeout), + MaxFlowsPerExit: i(s.MaxFlowsPerExit), + AffinityStickyPastCap: b(s.AffinityStickyPastCap), + QuarantineGroupFollow: b(s.QuarantineGroupFollow), + GroupFollowWindowMs: ms(s.GroupFollowWindow), + DialFailureRerace: b(s.DialFailureRerace), + UplinkStalenessGateMs: ms(s.UplinkStalenessGate), + SoftVerdictDemote: b(s.SoftVerdictDemote), + RemovalBudgetCount: i(s.RemovalBudgetCount), + RemovalBudgetWindowMs: ms(s.RemovalBudgetWindow), + StandingReserve: b(s.StandingReserve), + EffectiveTierSelection: b(s.EffectiveTierSelection), + MinBlackholeDestinations: i(s.MinBlackholeDestinations), + BlackholeLoadCorroboration: i(s.BlackholeLoadCorroboration), + ProviderProbe: b(s.ProviderProbe), + ProbeTimeoutMs: ms(s.ProbeTimeout), + ProbeSampleHostCount: i(s.ProbeSampleHostCount), + ProbeSilenceWarnStreak: i(s.ProbeSilenceWarnStreak), + EvaluationPoolMultiple: i(s.EvaluationPoolMultiple), + FormationPollTimeoutMs: ms(s.FormationPollTimeout), + BusyProbe: b(s.BusyProbe), + BusyProbeBudgetMs: ms(s.BusyProbeBudget), + SchedulerPauseToleranceMs: ms(s.SchedulerPauseTolerance), + SchedulerPauseRecoveryTimeoutMs: ms(s.SchedulerPauseRecoveryTimeout), + BlackholeConnectComparativeTimeoutMs: ms(s.BlackholeConnectComparativeTimeout), + HeartbeatIntervalMs: ms(s.HeartbeatInterval), + } +} + +// mergeReliabilitySettings applies a partial file over the current settings. +func mergeReliabilitySettings(cur *connect.ReliabilitySettings, f reliabilityFile) (*connect.ReliabilitySettings, error) { + if cur == nil { + cur = &connect.ReliabilitySettings{} + } + // reuse the loader by round-tripping through the file it would produce + merged := settingsToFile(cur) + // overwrite named fields (json decode already validated types) + if err := applyFileOver(merged, &f); err != nil { + return nil, err + } + // build back into a struct + return fileToSettings(merged) +} diff --git a/socks/coverage_test.go b/socks/coverage_test.go new file mode 100644 index 0000000..47aff26 --- /dev/null +++ b/socks/coverage_test.go @@ -0,0 +1,415 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/urnetwork/connect" +) + +// --- pure helpers in main.go --- + +func TestFmtDurationMillis(t *testing.T) { + cases := []struct { + ms int64 + want string + }{ + {0, "0ms"}, + {500, "500ms"}, + {1000, "1.0s"}, + {1500, "1.5s"}, + {65000, "65.0s"}, + {-1, "-"}, + } + for _, c := range cases { + if got := fmtDurationMillis(c.ms); got != c.want { + t.Errorf("fmtDurationMillis(%d) = %q, want %q", c.ms, got, c.want) + } + } +} + +func TestExitLabel(t *testing.T) { + id, err := connect.ParseId("019fde39-e690-add0-ac88-e354a0d76d6a") + if err != nil { + t.Fatal(err) + } + if got := exitLabel(id); got != "a0d76d6a" { + t.Errorf("exitLabel = %q, want last-8 chars", got) + } + // a short id must be returned whole (the len<=8 branch). connect.ParseId + // only accepts full-length ids, so test the string logic directly. + if got := exitLabelString("abcd"); got != "abcd" { + t.Errorf("exitLabelString(short) = %q, want whole id", got) + } + if got := exitLabelString("abcdefgh"); got != "abcdefgh" { + t.Errorf("exitLabelString(8) = %q, want whole id", got) + } +} + +func TestFilterMapSlice(t *testing.T) { + nums := []int{1, 2, 3, 4} + evens := filter(nums, func(v int) bool { return v%2 == 0 }) + if len(evens) != 2 || evens[0] != 2 || evens[1] != 4 { + t.Errorf("filter evens = %v, want [2 4]", evens) + } + doubled := mapSlice(nums, func(v int) int { return v * 2 }) + if len(doubled) != 4 || doubled[3] != 8 { + t.Errorf("mapSlice doubled = %v, want [2 4 6 8]", doubled) + } +} + +// parseByJwtClientId extracts the client_id claim from an (unverified) JWT. +func TestParseByJwtClientId(t *testing.T) { + cid := "019fde39-e690-add0-ac88-e354a0d76d6a" + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "client_id": cid, + }) + signed, err := tok.SignedString([]byte("test-secret")) + if err != nil { + t.Fatal(err) + } + got, err := parseByJwtClientId(signed) + if err != nil { + t.Fatalf("parseByJwtClientId: %v", err) + } + if got.String() != cid { + t.Errorf("client id = %s, want %s", got.String(), cid) + } + + // missing client_id claim -> error + bad := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"sub": "x"}) + badSigned, err := bad.SignedString([]byte("test-secret")) + if err != nil { + t.Fatal(err) + } + if _, err := parseByJwtClientId(badSigned); err == nil { + t.Error("expected error for JWT without client_id claim") + } +} + +// --- getProviderSpec using the real sample fixture --- + +func loadSampleLocations(t *testing.T) *FindLocationsResult { + t.Helper() + data, err := os.ReadFile("findLocations.sample.json") + if err != nil { + t.Skipf("sample fixture not present: %v", err) + } + var res FindLocationsResult + if err := json.Unmarshal(data, &res); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + return &res +} + +func TestGetProviderSpecByCountry(t *testing.T) { + locs := loadSampleLocations(t) + specs, err := getProviderSpec(locs, "", "United States", "", "") + if err != nil { + t.Fatal(err) + } + if len(specs) != 1 || specs[0].LocationId == nil { + t.Fatalf("country spec = %+v, want 1 spec with LocationId", specs) + } +} + +func TestGetProviderSpecByCityCaseInsensitive(t *testing.T) { + locs := loadSampleLocations(t) + specs, err := getProviderSpec(locs, "los angeles", "", "", "") + if err != nil { + t.Fatal(err) + } + if len(specs) != 1 || specs[0].LocationId == nil { + t.Fatalf("city spec = %+v, want 1 spec", specs) + } +} + +func TestGetProviderSpecByProviderID(t *testing.T) { + locs := loadSampleLocations(t) + cid := "019fde39-e690-add0-ac88-e354a0d76d6a" + specs, err := getProviderSpec(locs, "", "", "", cid) + if err != nil { + t.Fatal(err) + } + if len(specs) != 1 || specs[0].ClientId == nil || specs[0].ClientId.String() != cid { + t.Fatalf("provider-id spec = %+v, want client %s", specs, cid) + } +} + +func TestGetProviderSpecBadProviderID(t *testing.T) { + locs := loadSampleLocations(t) + if _, err := getProviderSpec(locs, "", "", "", "not-an-id"); err == nil { + t.Fatal("expected error for malformed provider id") + } +} + +func TestGetProviderSpecNoMatch(t *testing.T) { + locs := loadSampleLocations(t) + if _, err := getProviderSpec(locs, "", "Atlantis", "", ""); err == nil { + t.Fatal("expected error for unmatched country") + } +} + +// --- loginWithAuthCode against a stubbed /auth/code-login --- + +func TestLoginWithAuthCodeSuccess(t *testing.T) { + expectedJwt := "eyJhbGciOiJIUzI1NiJ9.eyJjbGllbnRfaWQiOiIwMTlmZGUzOS1lNjkwLWFkZDAtYWM4OC1lMzU0YTBkNzZkNmEifQ.fake" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/code-login" { + t.Errorf("path = %s, want /auth/code-login", r.URL.Path) + } + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + if body["auth_code"] != "the-code" { + t.Errorf("auth_code = %v, want the-code", body["auth_code"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"by_jwt": expectedJwt}) + })) + defer srv.Close() + + got, err := loginWithAuthCode(context.Background(), srv.URL, "the-code") + if err != nil { + t.Fatalf("loginWithAuthCode: %v", err) + } + if got != expectedJwt { + t.Errorf("jwt = %q, want %q", got, expectedJwt) + } +} + +func TestLoginWithAuthCodeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"message": "invalid auth code"}, + }) + })) + defer srv.Close() + + if _, err := loginWithAuthCode(context.Background(), srv.URL, "bad"); err == nil { + t.Fatal("expected error for rejected auth code") + } else if !strings.Contains(err.Error(), "invalid auth code") { + t.Errorf("error = %v, want it to surface the backend message", err) + } +} + +// --- control server error branches --- + +func TestControlSettingsBadJSON(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + req, _ := http.NewRequest("PUT", srv.URL+"/settings", strings.NewReader("{not json")) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("PUT bad json status = %d, want 400", resp.StatusCode) + } + resp.Body.Close() +} + +func TestControlActionsStallMigrate(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + cid := "019fde39-e690-add0-ac88-e354a0d76d6a" + + resp, err := http.Post(srv.URL+"/actions", "application/json", + strings.NewReader(`{"action":"stall","clientId":"`+cid+`","stalled":true}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(mc.stalled) != 1 || mc.stalled[0] != cid { + t.Errorf("stalled = %v, want [%s]", mc.stalled, cid) + } + + resp, err = http.Post(srv.URL+"/actions", "application/json", + strings.NewReader(`{"action":"migrate","clientId":"`+cid+`"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(mc.migrated) != 1 || mc.migrated[0] != cid { + t.Errorf("migrated = %v, want [%s]", mc.migrated, cid) + } + + // malformed client id -> 400 + resp, err = http.Post(srv.URL+"/actions", "application/json", + strings.NewReader(`{"action":"drop","clientId":"nope"}`)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("drop bad clientId status = %d, want 400", resp.StatusCode) + } + resp.Body.Close() +} + +// TestControlQuitAction verifies the quit action invokes the wired cancel. +func TestControlQuitAction(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + cancelled := false + c.SetCancel(func() { cancelled = true }) + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/actions", "application/json", strings.NewReader(`{"action":"quit"}`)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST quit status = %d, want 200", resp.StatusCode) + } + resp.Body.Close() + if !cancelled { + t.Error("quit action did not invoke the session cancel") + } +} + +// --- /stats session readout --- + +func TestControlStatsEndpoint(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/stats") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /stats status = %d, want 200", resp.StatusCode) + } + var st statsResponse + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { + t.Fatal(err) + } + if st.BytesUp != 1024 || st.BytesDown != 2048 { + t.Errorf("stats bytes = %d up / %d down, want 1024/2048", st.BytesUp, st.BytesDown) + } + if st.PacketsUp != 10 || st.PacketsDown != 20 { + t.Errorf("stats packets = %d up / %d down, want 10/20", st.PacketsUp, st.PacketsDown) + } + if st.ElapsedSeconds < 0 { + t.Errorf("elapsedSeconds = %d, want >= 0", st.ElapsedSeconds) + } +} + +// --- serve() shutdown on context cancel --- + +func TestControlServerServeShutdown(t *testing.T) { + mc := &fakeMC{} + // port 0 = ephemeral; we only verify the goroutine exits on ctx cancel + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + c.serve(ctx) + close(done) + }() + time.Sleep(50 * time.Millisecond) + cancel() + select { + case <-done: + // clean exit + case <-time.After(3 * time.Second): + t.Fatal("serve did not return within 3s of ctx cancel") + } +} + +// --- printReliabilityMetrics output format --- + +func TestPrintReliabilityMetrics(t *testing.T) { + mc := &fakeMC{ + settings: &connect.ReliabilitySettings{}, + metrics: &connect.ReliabilityMetricsSnapshot{ + FlowsOpened: 12, + DialFailuresIntercepted: 3, + FlowsReraced: 2, + ExitLossEvents: 1, + FlowsLostToExit: 4, + MaxFlowsLostInOneEvent: 4, + MeanFlowsLostPerExitLoss: 4.0, + RecoveryCount: 1, + RecoveryMeanNanos: 500 * 1e6, + RecoveryMaxNanos: 1500 * 1e6, + ProbesSent: 10, + ProbesAnswered: 8, + ProvidersQualified: 3, + VerdictsHeldUplinkStale: 2, + VerdictsHeldTransportDown: 1, + }, + exits: []*connect.ExitInfo{ + {ClientId: mustParseId(t, "019fde39-e690-add0-ac88-e354a0d76d6a"), FlowCount: 5, Tier: 1, EffectiveTier: 1, Proven: true}, + }, + } + + // capture stdout + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + printReliabilityMetrics(mc) + w.Close() + os.Stdout = old + buf, _ := io.ReadAll(r) + r.Close() + out := string(buf) + + for _, want := range []string{ + "reliability metrics", + "flows_opened=12", + "dial_failures_intercepted=3", + "exit_loss_events=1", + "mean_flows_lost_per_exit_loss=4.00", + "recovery_mean=500ms", + "probes_sent=10", + "verdicts_held_uplink_stale=2", + "exits:", + } { + if !strings.Contains(out, want) { + t.Errorf("metrics output missing %q; got:\n%s", want, out) + } + } +} + +func mustParseId(t *testing.T, s string) connect.Id { + t.Helper() + id, err := connect.ParseId(s) + if err != nil { + t.Fatal(err) + } + return id +} diff --git a/socks/docs/control-panel.png b/socks/docs/control-panel.png new file mode 100644 index 0000000..f2cac21 Binary files /dev/null and b/socks/docs/control-panel.png differ diff --git a/socks/main.go b/socks/main.go index 513dba7..90992d3 100644 --- a/socks/main.go +++ b/socks/main.go @@ -3,13 +3,14 @@ package main import ( "context" "errors" + "flag" "fmt" "net" // "net/netip" - "flag" "os" "os/signal" "slices" + "strconv" "strings" "syscall" "time" @@ -40,15 +41,22 @@ func initGlog() { func main() { cfg := struct { - addr string - apiURL string - platformURL string - userAuth string - password string - providerID string - city string - country string - region string + addr string + network string + apiURL string + platformURL string + userAuth string + password string + authCode string + jwt string + providerID string + city string + country string + region string + metricsEveryMs int + resetMetrics bool + reliability string + control string }{} usage := `socksproxy - dev socks5 proxy over urnetwork. @@ -57,14 +65,21 @@ Usage: Options: --addr= socks5 server address (env ADDR, default 127.0.0.1:9999) - --api-url= api url (env API_URL, default https://api.bringyour.com) - --platform-url= platform url (env PLATFORM_URL, default wss://connect.bringyour.com) - --user-auth= user auth, required (env USER_AUTH) - --password= password, required (env PASSWORD) + --network= preset endpoints: main or beta (env NETWORK, default main) + --api-url= api url (env API_URL, default from --network) + --platform-url= platform url (env PLATFORM_URL, default from --network) + --user-auth= user auth (env USER_AUTH) + --password= password (env PASSWORD) + --auth-code= auth code login (env AUTH_CODE) — preferred for beta/test networks + --jwt= existing network jwt (env JWT), skips login entirely --provider-id= provider id (env PROVIDER_ID) --city= city (env CITY) --country= country (env COUNTRY) --region= region (env REGION) + --metrics-every= print reliability metrics + exit table every N ms (env METRICS_EVERY, 0 = off) + --reset-metrics zero the reliability counters at startup (env RESET_METRICS=1) + --reliability= json file of reliability settings applied at startup (env RELIABILITY) + --control= local control http server (env CONTROL, e.g. 127.0.0.1:9998, empty = off) -h --help show this help.` opts, err := docopt.ParseArgs(usage, os.Args[1:], Version) @@ -90,16 +105,51 @@ Options: return def } cfg.addr = pick("--addr", "ADDR", "127.0.0.1:9999") - cfg.apiURL = pick("--api-url", "API_URL", "https://api.bringyour.com") - cfg.platformURL = pick("--platform-url", "PLATFORM_URL", "wss://connect.bringyour.com") + cfg.network = pick("--network", "NETWORK", "main") + switch cfg.network { + case "main": + cfg.apiURL = pick("--api-url", "API_URL", "https://api.bringyour.com") + cfg.platformURL = pick("--platform-url", "PLATFORM_URL", "wss://connect.bringyour.com") + case "beta": + cfg.apiURL = pick("--api-url", "API_URL", "https://api.beta-test.net") + cfg.platformURL = pick("--platform-url", "PLATFORM_URL", "wss://connect.beta-test.net") + default: + fmt.Fprintf(os.Stderr, "unknown --network %q: expected main or beta\n", cfg.network) + os.Exit(1) + } + // explicit --api-url / --platform-url still override the preset, and the + // env vars follow -- pick() above already gave flags priority over env cfg.userAuth = pick("--user-auth", "USER_AUTH", "") cfg.password = pick("--password", "PASSWORD", "") + cfg.authCode = pick("--auth-code", "AUTH_CODE", "") + cfg.jwt = pick("--jwt", "JWT", "") cfg.providerID = pick("--provider-id", "PROVIDER_ID", "") cfg.city = pick("--city", "CITY", "") cfg.country = pick("--country", "COUNTRY", "") cfg.region = pick("--region", "REGION", "") - if cfg.userAuth == "" || cfg.password == "" { - fmt.Fprintln(os.Stderr, "--user-auth and --password are required (or set USER_AUTH / PASSWORD)") + + if v := optStr("--metrics-every"); v != "" { + ms, err := strconv.Atoi(v) + if err != nil || ms <= 0 { + fmt.Fprintln(os.Stderr, "--metrics-every must be a positive millisecond value") + os.Exit(1) + } + cfg.metricsEveryMs = ms + } else if v := os.Getenv("METRICS_EVERY"); v != "" { + ms, err := strconv.Atoi(v) + if err != nil || ms <= 0 { + fmt.Fprintln(os.Stderr, "--metrics-every must be a positive millisecond value") + os.Exit(1) + } + cfg.metricsEveryMs = ms + } + resetMetrics, _ := opts.Bool("--reset-metrics") + cfg.resetMetrics = resetMetrics || os.Getenv("RESET_METRICS") == "1" + cfg.reliability = pick("--reliability", "RELIABILITY", "") + cfg.control = pick("--control", "CONTROL", "") + + if cfg.jwt == "" && cfg.authCode == "" && (cfg.userAuth == "" || cfg.password == "") { + fmt.Fprintln(os.Stderr, "provide --jwt, --auth-code, or --user-auth + --password (or set JWT / AUTH_CODE / USER_AUTH + PASSWORD)") os.Exit(1) } @@ -108,7 +158,16 @@ Options: run := func() error { - jwt, err := login(ctx, cfg.apiURL, cfg.userAuth, cfg.password) + var jwt string + var err error + switch { + case cfg.jwt != "": + jwt = cfg.jwt + case cfg.authCode != "": + jwt, err = loginWithAuthCode(ctx, cfg.apiURL, cfg.authCode) + default: + jwt, err = login(ctx, cfg.apiURL, cfg.userAuth, cfg.password) + } if err != nil { return fmt.Errorf("login failed: %w", err) } @@ -191,6 +250,65 @@ Options: protocol.ProvideMode_Network, ) + if cfg.reliability != "" { + // merge over the current (default) settings so unspecified fields + // keep their defaults instead of being zeroed + f, err := parseReliabilityFile(cfg.reliability) + if err != nil { + return fmt.Errorf("reliability settings: %w", err) + } + merged := settingsToFile(mc.ReliabilitySettings()) + if err := applyFileOver(merged, f); err != nil { + return fmt.Errorf("reliability settings: %w", err) + } + settings, err := fileToSettings(merged) + if err != nil { + return fmt.Errorf("reliability settings: %w", err) + } + mc.SetReliabilitySettings(settings) + fmt.Printf("reliability settings loaded from %s\n", cfg.reliability) + } + + if cfg.control != "" { + // the control server is unauthenticated and can change settings, + // drop exits, and migrate flows -- refuse non-loopback binds + ok, err := controlAddrIsLoopback(cfg.control) + if err != nil { + return fmt.Errorf("control address: %w", err) + } + if !ok { + return fmt.Errorf("control address %q must be loopback; the control server is unauthenticated", cfg.control) + } + server, err := newControlServer(cfg.control, mc) + if err != nil { + return fmt.Errorf("control server: %w", err) + } + server.SetCancel(stop) + go server.serve(ctx) + fmt.Printf("control server listening on %s\n", cfg.control) + } + + if cfg.resetMetrics { + mc.ResetReliabilityMetrics() + fmt.Println("reliability metrics reset") + } + + if cfg.metricsEveryMs > 0 { + go func() { + ticker := time.NewTicker(time.Duration(cfg.metricsEveryMs) * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + printReliabilityMetrics(mc) + } + } + }() + fmt.Printf("reliability metrics every %dms\n", cfg.metricsEveryMs) + } + source := connect.SourceId(clientID) go func() { @@ -237,6 +355,104 @@ Options: } } +// printReliabilityMetrics writes the reliability counters and the current +// exit table to stdout. It is the console equivalent of the android +// developer screen's measurements block: flows opened, dial failures +// intercepted/reraced, blast radius (mean + worst exit loss), recovery +// mean/max/pending, probes sent/answered/qualified, busy probes, verdicts +// held, removals deferred, rebinds, and one row per exit (warning / +// quarantine flags, flow count, dial failures, tier vs effective tier, +// proven status). Called on the --metrics-every ticker. +func printReliabilityMetrics(mc mcControl) { + m := mc.ReliabilityMetrics() + if m == nil { + return + } + fmt.Printf("\n--- reliability metrics %s ---\n", time.Now().Format("15:04:05")) + fmt.Printf("flows_opened=%d dial_failures_intercepted=%d flows_reraced=%d\n", m.FlowsOpened, m.DialFailuresIntercepted, m.FlowsReraced) + fmt.Printf("exit_loss_events=%d flows_lost_to_exit=%d max_flows_lost_one_event=%d mean_flows_lost_per_exit_loss=%.2f\n", + m.ExitLossEvents, m.FlowsLostToExit, m.MaxFlowsLostInOneEvent, m.MeanFlowsLostPerExitLoss) + fmt.Printf("recovery_count=%d recovery_missed=%d recovery_mean=%s recovery_max=%s recovery_pending=%d\n", + m.RecoveryCount, m.RecoveryMissed, fmtDurationMillis(m.RecoveryMeanNanos/1e6), fmtDurationMillis(m.RecoveryMaxNanos/1e6), m.RecoveryPending) + fmt.Printf("probes_sent=%d probes_answered=%d providers_qualified=%d busy_probes_sent=%d busy_probes_acquitted=%d scheduler_pauses=%d\n", + m.ProbesSent, m.ProbesAnswered, m.ProvidersQualified, m.BusyProbesSent, m.BusyProbesAcquitted, m.SchedulerPausesDetected) + fmt.Printf("verdicts_held_uplink_stale=%d verdicts_held_transport_down=%d removals_deferred=%d\n", + m.VerdictsHeldUplinkStale, m.VerdictsHeldTransportDown, m.RemovalsDeferred) + fmt.Printf("flows_rebound=%d rebinds_accepted=%d rebinds_redialed=%d groups_followed=%d groups_scattered=%d\n", + m.FlowsRebound, m.RebindsAccepted, m.RebindsRedialed, m.GroupsFollowed, m.GroupsScattered) + + exits := mc.Exits() + if len(exits) == 0 { + fmt.Println("exits: none") + return + } + fmt.Println("exits:") + for _, e := range exits { + flags := "" + if e.Warning { + flags += "W" + } + if e.Quarantined { + flags += "Q" + } + if e.Done { + flags += "D" + } + if flags == "" { + flags = "-" + } + fmt.Printf(" %s %s flows=%d dial_failures=%d tier=%d effective_tier=%d proven=%v\n", + flags, exitLabel(e.ClientId), e.FlowCount, e.DialFailureCount, e.Tier, e.EffectiveTier, e.Proven) + } +} + +// fmtDurationMillis renders a millisecond duration compactly: raw ms below a +// second, one-decimal seconds above. Negative values (an unset clock) render +// as "-" so "never measured" reads differently from "0ms". +func fmtDurationMillis(ms int64) string { + if ms < 0 { + return "-" + } + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + return fmt.Sprintf("%.1fs", float64(ms)/1000.0) +} + +// exitLabel renders a short suffix of the client id so rows are +// distinguishable. Client ids are ULIDs -- the leading characters encode +// creation time, so channels opened milliseconds apart look identical; the +// random component is in the tail. +func exitLabel(id connect.Id) string { + return exitLabelString(id.String()) +} + +func exitLabelString(s string) string { + if len(s) <= 8 { + return s + } + return s[len(s)-8:] +} + +// controlAddrIsLoopback reports whether addr is a loopback bind address +// (localhost, 127.0.0.1, or ::1) with a port. The control server is +// unauthenticated, so anything else must be refused. +func controlAddrIsLoopback(addr string) (bool, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false, err + } + ip := net.ParseIP(host) + if host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return false, nil + } + return true, nil +} + +// getProviderSpec resolves the provider-locations result to a ProviderSpec +// by, in order: exact provider id, city, country, region. The matching is +// case-insensitive. An unmatched location yields a help listing of every +// country/region/city available. func getProviderSpec( locations *FindLocationsResult, city string, @@ -400,6 +616,29 @@ func login(ctx context.Context, apiURL, userAuth, password string) (string, erro } +// loginWithAuthCode redeems an auth code via POST /auth/code-login and +// returns the network by_jwt. This is the auth path for beta/test networks +// that have no password accounts -- the same flow the provider CLI uses. +func loginWithAuthCode(ctx context.Context, apiURL, authCode string) (string, error) { + api := connect.NewBringYourApi( + ctx, + connect.NewClientStrategyWithDefaults(ctx), + apiURL, + ) + + res, err := api.AuthCodeLoginSync(&connect.AuthCodeLoginArgs{ + AuthCode: authCode, + }) + if err != nil { + return "", err + } + if res.Error != nil { + return "", errors.New(res.Error.Message) + } + + return res.ByJwt, nil +} + func getProviderLocations(ctx context.Context, apiURL string, jwt string) (*FindLocationsResult, error) { strategy := connect.NewClientStrategyWithDefaults(ctx) @@ -467,6 +706,8 @@ func authNetworkClient(ctx context.Context, apiURL, jwt string, req *connect.Aut return res.ByClientJwt, nil } +// parseByJwtClientId extracts the client_id claim from a by_jwt token without +// verifying its signature (the network already authenticated the token). func parseByJwtClientId(byJwt string) (connect.Id, error) { claims := gojwt.MapClaims{} gojwt.NewParser().ParseUnverified(byJwt, claims) diff --git a/socks/main_test.go b/socks/main_test.go index 7332337..c219674 100644 --- a/socks/main_test.go +++ b/socks/main_test.go @@ -22,7 +22,7 @@ import ( // exercise the real, current text/literals rather than a copy that could // drift from it. -// usageFromSource extracts the literal `usage := \`...\`` doc string from +// usageFromSource extracts the literal `usage := \`...\` doc string from // main.go. func usageFromSource(t *testing.T) string { t.Helper() @@ -134,4 +134,4 @@ func TestDevSocksProxyHasNoValidUserOverride(t *testing.T) { "ConnectDialWithRequest; the dev \"any credentials accepted\" override should "+ "have been removed in favor of the library's nil-ValidUser no-auth default:\n%s", between) } -} \ No newline at end of file +} diff --git a/socks/reliability_file.go b/socks/reliability_file.go new file mode 100644 index 0000000..f24ebee --- /dev/null +++ b/socks/reliability_file.go @@ -0,0 +1,302 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/urnetwork/connect" +) + +// reliabilityFile mirrors connect.ReliabilitySettings with json-friendly +// fields. Durations are milliseconds (same convention as the android dev +// screen's presets); a nil field is untouched (keeps the current/default +// value), while an explicit zero value is applied. +type reliabilityFile struct { + UdpTeardownSignal *bool `json:"udpTeardownSignal"` + QuicRebindOnExitLoss *bool `json:"quicRebindOnExitLoss"` + TcpCollapseMaxHoldMs *int64 `json:"tcpCollapseMaxHoldMs"` + SendStallTimeoutMs *int64 `json:"sendStallTimeoutMs"` + ClusterAffinityFallback *bool `json:"clusterAffinityFallback"` + ServerNameAffinityBridge *bool `json:"serverNameAffinityBridge"` + SequenceIdleTimeoutMs *int64 `json:"sequenceIdleTimeoutMs"` + TcpSequenceIdleTimeoutMs *int64 `json:"tcpSequenceIdleTimeoutMs"` + BlackholeReceiveTimeoutMs *int64 `json:"blackholeReceiveTimeoutMs"` + MaxFlowsPerExit *int `json:"maxFlowsPerExit"` + AffinityStickyPastCap *bool `json:"affinityStickyPastCap"` + QuarantineGroupFollow *bool `json:"quarantineGroupFollow"` + GroupFollowWindowMs *int64 `json:"groupFollowWindowMs"` + DialFailureRerace *bool `json:"dialFailureRerace"` + UplinkStalenessGateMs *int64 `json:"uplinkStalenessGateMs"` + SoftVerdictDemote *bool `json:"softVerdictDemote"` + RemovalBudgetCount *int `json:"removalBudgetCount"` + RemovalBudgetWindowMs *int64 `json:"removalBudgetWindowMs"` + StandingReserve *bool `json:"standingReserve"` + EffectiveTierSelection *bool `json:"effectiveTierSelection"` + MinBlackholeDestinations *int `json:"minBlackholeDestinations"` + BlackholeLoadCorroboration *int `json:"blackholeLoadCorroboration"` + ProviderProbe *bool `json:"providerProbe"` + ProbeTimeoutMs *int64 `json:"probeTimeoutMs"` + ProbeSampleHostCount *int `json:"probeSampleHostCount"` + ProbeSilenceWarnStreak *int `json:"probeSilenceWarnStreak"` + EvaluationPoolMultiple *int `json:"evaluationPoolMultiple"` + FormationPollTimeoutMs *int64 `json:"formationPollTimeoutMs"` + BusyProbe *bool `json:"busyProbe"` + BusyProbeBudgetMs *int64 `json:"busyProbeBudgetMs"` + SchedulerPauseToleranceMs *int64 `json:"schedulerPauseToleranceMs"` + SchedulerPauseRecoveryTimeoutMs *int64 `json:"schedulerPauseRecoveryTimeoutMs"` + BlackholeConnectComparativeTimeoutMs *int64 `json:"blackholeConnectComparativeTimeoutMs"` + HeartbeatIntervalMs *int64 `json:"heartbeatIntervalMs"` +} + +// parseReliabilityFile reads and unmarshals a reliability settings file into +// its json shape. Missing fields stay nil (untouched); an explicit zero value +// is applied. See reliabilityFile. +func parseReliabilityFile(path string) (*reliabilityFile, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var f reliabilityFile + if err := json.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &f, nil +} + +// loadReliabilitySettingsFile reads a settings file and converts it to a +// connect.ReliabilitySettings. Fields absent from the file are left at their +// zero value here; callers that need to preserve engine defaults must merge +// over the current settings first (see applyFileOver + settingsToFile). +func loadReliabilitySettingsFile(path string) (*connect.ReliabilitySettings, error) { + f, err := parseReliabilityFile(path) + if err != nil { + return nil, err + } + return fileToSettings(f) +} + +// fileToSettings converts the json shape to a connect.ReliabilitySettings. +// Durations are milliseconds in the file, time.Duration in the struct; nil +// fields map to zero values in the struct. +func fileToSettings(f *reliabilityFile) (*connect.ReliabilitySettings, error) { + if f == nil { + return &connect.ReliabilitySettings{}, nil + } + settings := &connect.ReliabilitySettings{} + if f.UdpTeardownSignal != nil { + settings.UdpTeardownSignal = *f.UdpTeardownSignal + } + if f.QuicRebindOnExitLoss != nil { + settings.QuicRebindOnExitLoss = *f.QuicRebindOnExitLoss + } + if f.TcpCollapseMaxHoldMs != nil { + settings.TcpCollapseMaxHold = time.Duration(*f.TcpCollapseMaxHoldMs) * time.Millisecond + } + if f.SendStallTimeoutMs != nil { + settings.SendStallTimeout = time.Duration(*f.SendStallTimeoutMs) * time.Millisecond + } + if f.ClusterAffinityFallback != nil { + settings.ClusterAffinityFallback = *f.ClusterAffinityFallback + } + if f.ServerNameAffinityBridge != nil { + settings.ServerNameAffinityBridge = *f.ServerNameAffinityBridge + } + if f.SequenceIdleTimeoutMs != nil { + settings.SequenceIdleTimeout = time.Duration(*f.SequenceIdleTimeoutMs) * time.Millisecond + } + if f.TcpSequenceIdleTimeoutMs != nil { + settings.TcpSequenceIdleTimeout = time.Duration(*f.TcpSequenceIdleTimeoutMs) * time.Millisecond + } + if f.BlackholeReceiveTimeoutMs != nil { + settings.BlackholeReceiveTimeout = time.Duration(*f.BlackholeReceiveTimeoutMs) * time.Millisecond + } + if f.MaxFlowsPerExit != nil { + settings.MaxFlowsPerExit = *f.MaxFlowsPerExit + } + if f.AffinityStickyPastCap != nil { + settings.AffinityStickyPastCap = *f.AffinityStickyPastCap + } + if f.QuarantineGroupFollow != nil { + settings.QuarantineGroupFollow = *f.QuarantineGroupFollow + } + if f.GroupFollowWindowMs != nil { + settings.GroupFollowWindow = time.Duration(*f.GroupFollowWindowMs) * time.Millisecond + } + if f.DialFailureRerace != nil { + settings.DialFailureRerace = *f.DialFailureRerace + } + if f.UplinkStalenessGateMs != nil { + settings.UplinkStalenessGate = time.Duration(*f.UplinkStalenessGateMs) * time.Millisecond + } + if f.SoftVerdictDemote != nil { + settings.SoftVerdictDemote = *f.SoftVerdictDemote + } + if f.RemovalBudgetCount != nil { + settings.RemovalBudgetCount = *f.RemovalBudgetCount + } + if f.RemovalBudgetWindowMs != nil { + settings.RemovalBudgetWindow = time.Duration(*f.RemovalBudgetWindowMs) * time.Millisecond + } + if f.StandingReserve != nil { + settings.StandingReserve = *f.StandingReserve + } + if f.EffectiveTierSelection != nil { + settings.EffectiveTierSelection = *f.EffectiveTierSelection + } + if f.MinBlackholeDestinations != nil { + settings.MinBlackholeDestinations = *f.MinBlackholeDestinations + } + if f.BlackholeLoadCorroboration != nil { + settings.BlackholeLoadCorroboration = *f.BlackholeLoadCorroboration + } + if f.ProviderProbe != nil { + settings.ProviderProbe = *f.ProviderProbe + } + if f.ProbeTimeoutMs != nil { + settings.ProbeTimeout = time.Duration(*f.ProbeTimeoutMs) * time.Millisecond + } + if f.ProbeSampleHostCount != nil { + settings.ProbeSampleHostCount = *f.ProbeSampleHostCount + } + if f.ProbeSilenceWarnStreak != nil { + settings.ProbeSilenceWarnStreak = *f.ProbeSilenceWarnStreak + } + if f.EvaluationPoolMultiple != nil { + settings.EvaluationPoolMultiple = *f.EvaluationPoolMultiple + } + if f.FormationPollTimeoutMs != nil { + settings.FormationPollTimeout = time.Duration(*f.FormationPollTimeoutMs) * time.Millisecond + } + if f.BusyProbe != nil { + settings.BusyProbe = *f.BusyProbe + } + if f.BusyProbeBudgetMs != nil { + settings.BusyProbeBudget = time.Duration(*f.BusyProbeBudgetMs) * time.Millisecond + } + if f.SchedulerPauseToleranceMs != nil { + settings.SchedulerPauseTolerance = time.Duration(*f.SchedulerPauseToleranceMs) * time.Millisecond + } + if f.SchedulerPauseRecoveryTimeoutMs != nil { + settings.SchedulerPauseRecoveryTimeout = time.Duration(*f.SchedulerPauseRecoveryTimeoutMs) * time.Millisecond + } + if f.BlackholeConnectComparativeTimeoutMs != nil { + settings.BlackholeConnectComparativeTimeout = time.Duration(*f.BlackholeConnectComparativeTimeoutMs) * time.Millisecond + } + if f.HeartbeatIntervalMs != nil { + settings.HeartbeatInterval = time.Duration(*f.HeartbeatIntervalMs) * time.Millisecond + } + + return settings, nil +} + +// applyFileOver copies every non-nil field of src onto dst (both json shapes). +func applyFileOver(dst, src *reliabilityFile) error { + if src == nil { + return nil + } + if src.UdpTeardownSignal != nil { + dst.UdpTeardownSignal = src.UdpTeardownSignal + } + if src.QuicRebindOnExitLoss != nil { + dst.QuicRebindOnExitLoss = src.QuicRebindOnExitLoss + } + if src.TcpCollapseMaxHoldMs != nil { + dst.TcpCollapseMaxHoldMs = src.TcpCollapseMaxHoldMs + } + if src.SendStallTimeoutMs != nil { + dst.SendStallTimeoutMs = src.SendStallTimeoutMs + } + if src.ClusterAffinityFallback != nil { + dst.ClusterAffinityFallback = src.ClusterAffinityFallback + } + if src.ServerNameAffinityBridge != nil { + dst.ServerNameAffinityBridge = src.ServerNameAffinityBridge + } + if src.SequenceIdleTimeoutMs != nil { + dst.SequenceIdleTimeoutMs = src.SequenceIdleTimeoutMs + } + if src.TcpSequenceIdleTimeoutMs != nil { + dst.TcpSequenceIdleTimeoutMs = src.TcpSequenceIdleTimeoutMs + } + if src.BlackholeReceiveTimeoutMs != nil { + dst.BlackholeReceiveTimeoutMs = src.BlackholeReceiveTimeoutMs + } + if src.MaxFlowsPerExit != nil { + dst.MaxFlowsPerExit = src.MaxFlowsPerExit + } + if src.AffinityStickyPastCap != nil { + dst.AffinityStickyPastCap = src.AffinityStickyPastCap + } + if src.QuarantineGroupFollow != nil { + dst.QuarantineGroupFollow = src.QuarantineGroupFollow + } + if src.GroupFollowWindowMs != nil { + dst.GroupFollowWindowMs = src.GroupFollowWindowMs + } + if src.DialFailureRerace != nil { + dst.DialFailureRerace = src.DialFailureRerace + } + if src.UplinkStalenessGateMs != nil { + dst.UplinkStalenessGateMs = src.UplinkStalenessGateMs + } + if src.SoftVerdictDemote != nil { + dst.SoftVerdictDemote = src.SoftVerdictDemote + } + if src.RemovalBudgetCount != nil { + dst.RemovalBudgetCount = src.RemovalBudgetCount + } + if src.RemovalBudgetWindowMs != nil { + dst.RemovalBudgetWindowMs = src.RemovalBudgetWindowMs + } + if src.StandingReserve != nil { + dst.StandingReserve = src.StandingReserve + } + if src.EffectiveTierSelection != nil { + dst.EffectiveTierSelection = src.EffectiveTierSelection + } + if src.MinBlackholeDestinations != nil { + dst.MinBlackholeDestinations = src.MinBlackholeDestinations + } + if src.BlackholeLoadCorroboration != nil { + dst.BlackholeLoadCorroboration = src.BlackholeLoadCorroboration + } + if src.ProviderProbe != nil { + dst.ProviderProbe = src.ProviderProbe + } + if src.ProbeTimeoutMs != nil { + dst.ProbeTimeoutMs = src.ProbeTimeoutMs + } + if src.ProbeSampleHostCount != nil { + dst.ProbeSampleHostCount = src.ProbeSampleHostCount + } + if src.ProbeSilenceWarnStreak != nil { + dst.ProbeSilenceWarnStreak = src.ProbeSilenceWarnStreak + } + if src.EvaluationPoolMultiple != nil { + dst.EvaluationPoolMultiple = src.EvaluationPoolMultiple + } + if src.FormationPollTimeoutMs != nil { + dst.FormationPollTimeoutMs = src.FormationPollTimeoutMs + } + if src.BusyProbe != nil { + dst.BusyProbe = src.BusyProbe + } + if src.BusyProbeBudgetMs != nil { + dst.BusyProbeBudgetMs = src.BusyProbeBudgetMs + } + if src.SchedulerPauseToleranceMs != nil { + dst.SchedulerPauseToleranceMs = src.SchedulerPauseToleranceMs + } + if src.SchedulerPauseRecoveryTimeoutMs != nil { + dst.SchedulerPauseRecoveryTimeoutMs = src.SchedulerPauseRecoveryTimeoutMs + } + if src.BlackholeConnectComparativeTimeoutMs != nil { + dst.BlackholeConnectComparativeTimeoutMs = src.BlackholeConnectComparativeTimeoutMs + } + if src.HeartbeatIntervalMs != nil { + dst.HeartbeatIntervalMs = src.HeartbeatIntervalMs + } + return nil +} diff --git a/socks/reliability_test.go b/socks/reliability_test.go new file mode 100644 index 0000000..7d74fc0 --- /dev/null +++ b/socks/reliability_test.go @@ -0,0 +1,505 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/urnetwork/connect" +) + +// --- reliability file --- + +func writeRelFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "reliability.json") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func TestParseReliabilityFilePartial(t *testing.T) { + path := writeRelFile(t, `{ + "maxFlowsPerExit": 32, + "sendStallTimeoutMs": 5000, + "busyProbe": true + }`) + f, err := parseReliabilityFile(path) + if err != nil { + t.Fatalf("parse: %v", err) + } + if f.MaxFlowsPerExit == nil || *f.MaxFlowsPerExit != 32 { + t.Errorf("MaxFlowsPerExit = %v, want 32", f.MaxFlowsPerExit) + } + if f.SendStallTimeoutMs == nil || *f.SendStallTimeoutMs != 5000 { + t.Errorf("SendStallTimeoutMs = %v, want 5000", f.SendStallTimeoutMs) + } + if f.BusyProbe == nil || !*f.BusyProbe { + t.Errorf("BusyProbe = %v, want true", f.BusyProbe) + } + // unspecified fields stay nil (untouched), not zero + if f.UdpTeardownSignal != nil { + t.Errorf("UdpTeardownSignal = %v, want nil (untouched)", f.UdpTeardownSignal) + } + if f.BlackholeReceiveTimeoutMs != nil { + t.Errorf("BlackholeReceiveTimeoutMs = %v, want nil (untouched)", f.BlackholeReceiveTimeoutMs) + } +} + +func TestParseReliabilityFileBadJSON(t *testing.T) { + path := writeRelFile(t, `{not json`) + if _, err := parseReliabilityFile(path); err == nil { + t.Fatal("expected error for invalid json") + } +} + +func TestParseReliabilityFileMissing(t *testing.T) { + if _, err := parseReliabilityFile("/nonexistent/reliability.json"); err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestFileToSettingsExplicitZeroApplied(t *testing.T) { + // an explicit 0 in the file must be applied (0 = off / pre-fix behavior), + // distinct from nil (untouched) + f := &reliabilityFile{ + MaxFlowsPerExit: intPtr(0), + SendStallTimeoutMs: int64Ptr(0), + BusyProbe: boolPtr(false), + BlackholeReceiveTimeoutMs: int64Ptr(0), + } + s, err := fileToSettings(f) + if err != nil { + t.Fatal(err) + } + if s.MaxFlowsPerExit != 0 { + t.Errorf("MaxFlowsPerExit = %d, want 0 (explicit zero applied)", s.MaxFlowsPerExit) + } + if s.SendStallTimeout != 0 { + t.Errorf("SendStallTimeout = %v, want 0", s.SendStallTimeout) + } + if s.BusyProbe { + t.Error("BusyProbe = true, want false") + } + if s.BlackholeReceiveTimeout != 0 { + t.Errorf("BlackholeReceiveTimeout = %v, want 0", s.BlackholeReceiveTimeout) + } +} + +func TestApplyFileOverPreservesBase(t *testing.T) { + base := &connect.ReliabilitySettings{ + UdpTeardownSignal: true, + SendStallTimeout: 3000 * time.Millisecond, + MaxFlowsPerExit: 16, + BlackholeReceiveTimeout: 20 * time.Second, + } + overlay := &reliabilityFile{MaxFlowsPerExit: intPtr(64)} + + merged := settingsToFile(base) + if err := applyFileOver(merged, overlay); err != nil { + t.Fatal(err) + } + s, err := fileToSettings(merged) + if err != nil { + t.Fatal(err) + } + if s.MaxFlowsPerExit != 64 { + t.Errorf("MaxFlowsPerExit = %d, want 64", s.MaxFlowsPerExit) + } + if !s.UdpTeardownSignal { + t.Error("UdpTeardownSignal lost, want true (preserved from base)") + } + if s.SendStallTimeout != 3*time.Second { + t.Errorf("SendStallTimeout = %v, want 3s (preserved)", s.SendStallTimeout) + } + if s.BlackholeReceiveTimeout != 20*time.Second { + t.Errorf("BlackholeReceiveTimeout = %v, want 20s (preserved)", s.BlackholeReceiveTimeout) + } +} + +func TestSettingsToFileRoundTrip(t *testing.T) { + s := &connect.ReliabilitySettings{ + UdpTeardownSignal: true, + QuicRebindOnExitLoss: true, + TcpCollapseMaxHold: 1500 * time.Millisecond, + SendStallTimeout: 3 * time.Second, + ClusterAffinityFallback: true, + ServerNameAffinityBridge: true, + SequenceIdleTimeout: 120 * time.Second, + TcpSequenceIdleTimeout: 600 * time.Second, + BlackholeReceiveTimeout: 20 * time.Second, + MaxFlowsPerExit: 16, + AffinityStickyPastCap: true, + QuarantineGroupFollow: true, + GroupFollowWindow: 45 * time.Second, + DialFailureRerace: true, + UplinkStalenessGate: 5 * time.Second, + SoftVerdictDemote: true, + RemovalBudgetCount: 2, + RemovalBudgetWindow: 30 * time.Second, + StandingReserve: true, + EffectiveTierSelection: true, + MinBlackholeDestinations: 2, + BlackholeLoadCorroboration: 8, + ProviderProbe: true, + ProbeTimeout: 4 * time.Second, + ProbeSampleHostCount: 0, + ProbeSilenceWarnStreak: 2, + EvaluationPoolMultiple: 2, + FormationPollTimeout: 200 * time.Millisecond, + BusyProbe: true, + BusyProbeBudget: 0, + SchedulerPauseTolerance: 2 * time.Second, + SchedulerPauseRecoveryTimeout: 5 * time.Second, + BlackholeConnectComparativeTimeout: 10 * time.Second, + HeartbeatInterval: 60 * time.Second, + } + + back, err := fileToSettings(settingsToFile(s)) + if err != nil { + t.Fatal(err) + } + // spot-check the ones with interesting conversions + if back.MaxFlowsPerExit != 16 { + t.Errorf("MaxFlowsPerExit = %d, want 16", back.MaxFlowsPerExit) + } + if back.SendStallTimeout != 3*time.Second { + t.Errorf("SendStallTimeout = %v, want 3s", back.SendStallTimeout) + } + if back.ProbeSampleHostCount != 0 { + t.Errorf("ProbeSampleHostCount = %d, want 0 (explicit zero survives)", back.ProbeSampleHostCount) + } + if !back.BusyProbe { + t.Error("BusyProbe lost") + } + if back.BusyProbeBudget != 0 { + t.Errorf("BusyProbeBudget = %v, want 0", back.BusyProbeBudget) + } + if back.HeartbeatInterval != 60*time.Second { + t.Errorf("HeartbeatInterval = %v, want 60s", back.HeartbeatInterval) + } +} + +// --- control server loopback guard --- + +func TestControlAddrLoopbackGuard(t *testing.T) { + ok, err := controlAddrIsLoopback("127.0.0.1:9998") + if err != nil || !ok { + t.Errorf("127.0.0.1:9998 should be allowed (ok=%v err=%v)", ok, err) + } + ok, err = controlAddrIsLoopback("localhost:9998") + if err != nil || !ok { + t.Errorf("localhost:9998 should be allowed (ok=%v err=%v)", ok, err) + } + ok, err = controlAddrIsLoopback("[::1]:9998") + if err != nil || !ok { + t.Errorf("[::1]:9998 should be allowed (ok=%v err=%v)", ok, err) + } + ok, err = controlAddrIsLoopback("0.0.0.0:9998") + if err != nil || ok { + t.Errorf("0.0.0.0:9998 must be rejected (ok=%v err=%v)", ok, err) + } + ok, err = controlAddrIsLoopback("192.168.1.5:9998") + if err != nil || ok { + t.Errorf("192.168.1.5:9998 must be rejected (ok=%v err=%v)", ok, err) + } + if _, err := controlAddrIsLoopback("not-an-addr"); err == nil { + t.Error("expected SplitHostPort error for malformed address") + } +} + +// --- control server endpoints (with a real multi client is impossible here, +// so use the real server with a stub mc via a minimal fake) --- + +// fakeMC implements just enough of the mc surface for the control handlers. +type fakeMC struct { + settings *connect.ReliabilitySettings + metrics *connect.ReliabilityMetricsSnapshot + exits []*connect.ExitInfo + dropped []string + stalled []string + migrated []string + shuffled bool + probed int + resets int +} + +func (f *fakeMC) ReliabilitySettings() *connect.ReliabilitySettings { + if f.settings == nil { + return &connect.ReliabilitySettings{} + } + return f.settings +} +func (f *fakeMC) SetReliabilitySettings(s *connect.ReliabilitySettings) { f.settings = s } +func (f *fakeMC) ReliabilityMetrics() *connect.ReliabilityMetricsSnapshot { + if f.metrics == nil { + return &connect.ReliabilityMetricsSnapshot{} + } + return f.metrics +} +func (f *fakeMC) ResetReliabilityMetrics() { f.resets++ } +func (f *fakeMC) Shuffle() { f.shuffled = true } +func (f *fakeMC) ProbeAllExits() int { f.probed++; return 3 } +func (f *fakeMC) Exits() []*connect.ExitInfo { return f.exits } +func (f *fakeMC) DropExit(id connect.Id) bool { + f.dropped = append(f.dropped, id.String()) + return true +} +func (f *fakeMC) StallExit(id connect.Id, stalled bool) bool { + f.stalled = append(f.stalled, id.String()) + return true +} +func (f *fakeMC) MigrateExit(id connect.Id) int { + f.migrated = append(f.migrated, id.String()) + return 5 +} +func (f *fakeMC) PacketStats() *connect.PacketStats { + return &connect.PacketStats{ + RemoteEgressByteCount: 1024, + RemoteIngressByteCount: 2048, + RemoteEgressPacketCount: 10, + RemoteIngressPacketCount: 20, + } +} + +func TestControlEndpoints(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + // GET /settings returns json with current values + resp, err := http.Get(srv.URL + "/settings") + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + resp.Body.Close() + if got["maxFlowsPerExit"] != nil && got["maxFlowsPerExit"].(float64) != 0 { + t.Errorf("unexpected maxFlowsPerExit: %v", got["maxFlowsPerExit"]) + } + + // PUT /settings partial merge: set maxFlowsPerExit, keep the rest + req, _ := http.NewRequest("PUT", srv.URL+"/settings", strings.NewReader(`{"maxFlowsPerExit": 64}`)) + req.Header.Set("Content-Type", "application/json") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("PUT /settings status = %d, want 200", resp.StatusCode) + } + resp.Body.Close() + if mc.settings == nil || mc.settings.MaxFlowsPerExit != 64 { + t.Fatalf("settings after PUT = %+v, want MaxFlowsPerExit=64", mc.settings) + } + + // POST /actions reset-metrics + resp, err = http.Post(srv.URL+"/actions", "application/json", strings.NewReader(`{"action":"reset-metrics"}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if mc.resets != 1 { + t.Errorf("resets = %d, want 1", mc.resets) + } + + // POST /actions shuffle + resp, _ = http.Post(srv.URL+"/actions", "application/json", strings.NewReader(`{"action":"shuffle"}`)) + resp.Body.Close() + if !mc.shuffled { + t.Error("shuffle not called") + } + + // POST /actions probe-all + resp, _ = http.Post(srv.URL+"/actions", "application/json", strings.NewReader(`{"action":"probe-all"}`)) + resp.Body.Close() + if mc.probed != 1 { + t.Errorf("probed = %d, want 1", mc.probed) + } + + // POST /actions drop with a valid client id + cid := "019fde39-e690-add0-ac88-e354a0d76d6a" + resp, _ = http.Post(srv.URL+"/actions", "application/json", strings.NewReader(`{"action":"drop","clientId":"`+cid+`"}`)) + resp.Body.Close() + if len(mc.dropped) != 1 || mc.dropped[0] != cid { + t.Errorf("dropped = %v, want [%s]", mc.dropped, cid) + } + + // POST /actions drop with missing clientId -> 400 + resp, _ = http.Post(srv.URL+"/actions", "application/json", strings.NewReader(`{"action":"drop"}`)) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("drop w/o clientId status = %d, want 400", resp.StatusCode) + } + resp.Body.Close() + + // POST /actions unknown action -> 400 + resp, _ = http.Post(srv.URL+"/actions", "application/json", strings.NewReader(`{"action":"bogus"}`)) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("bogus action status = %d, want 400", resp.StatusCode) + } + resp.Body.Close() + + // GET /metrics returns the snapshot + resp, err = http.Get(srv.URL + "/metrics") + if err != nil { + t.Fatal(err) + } + var m connect.ReliabilityMetricsSnapshot + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + t.Fatal(err) + } + resp.Body.Close() + + // GET /exits returns the table + resp, err = http.Get(srv.URL + "/exits") + if err != nil { + t.Fatal(err) + } + var xs []*connect.ExitInfo + if err := json.NewDecoder(resp.Body).Decode(&xs); err != nil { + t.Fatal(err) + } + resp.Body.Close() +} + +func TestControlServerRejectsBadMethod(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + req, _ := http.NewRequest("DELETE", srv.URL+"/settings", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("DELETE /settings status = %d, want 405", resp.StatusCode) + } + resp.Body.Close() +} + +// TestControlRejectsNonLoopbackOrigin pins the CSRF guard: a browser +// cross-origin request (which always sends Origin) must be refused on the +// mutating endpoints, while requests without Origin (curl) and with a +// loopback Origin pass. +func TestControlRejectsNonLoopbackOrigin(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + // no Origin -> allowed (curl) + req, _ := http.NewRequest("PUT", srv.URL+"/settings", strings.NewReader(`{"maxFlowsPerExit": 16}`)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("PUT without Origin = %d, want 200", resp.StatusCode) + } + resp.Body.Close() + + // loopback Origin -> allowed + req, _ = http.NewRequest("POST", srv.URL+"/actions", strings.NewReader(`{"action":"shuffle"}`)) + req.Header.Set("Origin", "http://127.0.0.1:8080") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Errorf("POST with loopback Origin = %d, want 200", resp.StatusCode) + } + resp.Body.Close() + + // evil cross-origin -> 403 on both mutating endpoints + for _, tc := range []struct { + method, path, body string + }{ + {"PUT", "/settings", `{"maxFlowsPerExit": 32}`}, + {"POST", "/actions", `{"action":"drop","clientId":"019fde39-e690-add0-ac88-e354a0d76d6a"}`}, + } { + req, _ := http.NewRequest(tc.method, srv.URL+tc.path, strings.NewReader(tc.body)) + req.Header.Set("Origin", "https://evil.example") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusForbidden { + t.Errorf("%s with evil Origin = %d, want 403", tc.method, resp.StatusCode) + } + resp.Body.Close() + } + + // the evil-origin drop must not have executed + if len(mc.dropped) != 0 { + t.Errorf("drop executed despite non-loopback origin: %v", mc.dropped) + } +} + +// TestControlConcurrentSettingsPut exercises the lost-update race fix: two +// PUTs that set different fields concurrently must both survive. +func TestControlConcurrentSettingsPut(t *testing.T) { + mc := &fakeMC{} + c, err := newControlServer("127.0.0.1:0", mc) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(c.server.Handler) + defer srv.Close() + + done := make(chan struct{}, 2) + go func() { + defer func() { done <- struct{}{} }() + req, _ := http.NewRequest("PUT", srv.URL+"/settings", strings.NewReader(`{"maxFlowsPerExit": 64}`)) + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + } + }() + go func() { + defer func() { done <- struct{}{} }() + req, _ := http.NewRequest("PUT", srv.URL+"/settings", strings.NewReader(`{"sendStallTimeoutMs": 5000}`)) + resp, err := http.DefaultClient.Do(req) + if err == nil { + resp.Body.Close() + } + }() + <-done + <-done + + if mc.settings == nil { + t.Fatal("no settings applied") + } + if mc.settings.MaxFlowsPerExit != 64 { + t.Errorf("MaxFlowsPerExit = %d, want 64 (lost update)", mc.settings.MaxFlowsPerExit) + } + if mc.settings.SendStallTimeout != 5*time.Second { + t.Errorf("SendStallTimeout = %v, want 5s (lost update)", mc.settings.SendStallTimeout) + } +} + +// --- helpers --- + +func intPtr(v int) *int { return &v } +func int64Ptr(v int64) *int64 { return &v } +func boolPtr(v bool) *bool { return &v } diff --git a/socks/ur_logo.svg b/socks/ur_logo.svg new file mode 100644 index 0000000..921e5c2 --- /dev/null +++ b/socks/ur_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file