From 2b5fe5b05c794338a59267ed0a4961cd4a35186c Mon Sep 17 00:00:00 2001 From: Isusami Date: Tue, 12 May 2026 19:37:20 +0300 Subject: [PATCH 1/3] refactor(mtu): extract ProbeResolverMTU and ScanOnce primitives Splits the inline probe path inside RunInitialMTUTests into a single-resolver primitive ProbeResolverMTU(ctx, conn, opts) Result and a multi-resolver helper ScanOnce(ctx, opts) []Result that iterates over candidate resolvers. RunInitialMTUTests now drives ProbeResolverMTU through its existing worker pool (via runConnectionMTUTest) so per-completion logging and counter ordering are unchanged. ScanOnce is the side-effect-free multi-resolver entry point that the upcoming -scan-resolvers subcommand and any future re-discovery trigger will call. The single-resolver primitive is decoupled from *Client through a ProbeResolverMTUOptions struct that carries only what it needs: the per-domain max upload payload, the upload/download probe callables, and an optional transport-opener seam. This narrow seam lets the future -doctor per-resolver tunnel-readiness probe (D3) swap in fixed-MTU upload/download variants without dragging the whole client lifecycle along. The pre-existing per-result side effects (balancer mutation, success log line, accept/reject counter and log) stay inside runConnectionMTUTest; the optimizeMTUResolvers post-processing stays inside RunInitialMTUTests. ScanOnce intentionally does not touch any of them so it can be reused outside the normal client startup path. The refactor lands slightly above the 150 LOC estimate because preserving the partial-measurement semantics on rejection and keeping the worker-pool ordering inside RunInitialMTUTests required collapsing mtuConnectionProbeResult into ProbeResolverMTUResult and adding a Client.buildMTUProbeOptions helper plus a Client.buildScanOnceOptions helper for the upcoming subcommand caller. No behavior change. Tests assert ProbeResolverMTU produces the expected per-connection result for accepted, upload-rejected, download-rejected and transport-failure cases, and that ScanOnce matches an inline ProbeResolverMTU loop on a shared fixture across several worker counts. --- internal/client/mtu.go | 198 ++++++++++++++++++++++++--- internal/client/mtu_scan_test.go | 223 +++++++++++++++++++++++++++++++ 2 files changed, 404 insertions(+), 17 deletions(-) create mode 100644 internal/client/mtu_scan_test.go diff --git a/internal/client/mtu.go b/internal/client/mtu.go index 85b4298d..3653c933 100644 --- a/internal/client/mtu.go +++ b/internal/client/mtu.go @@ -60,13 +60,50 @@ type mtuProbeOptions struct { Quiet bool } -type mtuConnectionProbeResult struct { +// ProbeResolverMTUResult is the outcome of a single-resolver MTU probe. +// Reason is mtuRejectNone when the resolver passed both the upload and +// download probe; otherwise the partial measurements that were taken +// before rejection are preserved so callers can inspect them. +type ProbeResolverMTUResult struct { + Conn Connection + Reason mtuRejectReason UploadBytes int UploadChars int DownloadBytes int ResolveTime time.Duration } +// Accepted reports whether the resolver passed both the upload and +// download MTU probes. +func (r ProbeResolverMTUResult) Accepted() bool { + return r.Reason == mtuRejectNone +} + +// ProbeResolverMTUOptions packages the dependencies the per-resolver +// MTU probe needs from the rest of the client. Splitting these out +// keeps ProbeResolverMTU decoupled from *Client so it can be reused +// outside the normal client lifecycle (e.g. by the -scan-resolvers and +// future -doctor flows). Each option is intentionally narrow: only the +// per-domain payload cap and the upload/download probe callables are +// exposed, plus an optional transport-opener seam for tests. +type ProbeResolverMTUOptions struct { + MaxUploadPayload int + NewTransport func(resolverLabel string) (*udpQueryTransport, error) + UploadProbe func(ctx context.Context, conn Connection, transport *udpQueryTransport, maxPayload int) (bool, int, int, time.Duration, error) + DownloadProbe func(ctx context.Context, conn Connection, transport *udpQueryTransport, uploadMTU int) (bool, int, time.Duration, error) +} + +// ScanOnceOptions packages the multi-resolver inputs to ScanOnce. +// ProbeOptionsFor is invoked exactly once per connection and returns +// the per-connection ProbeResolverMTUOptions; typical callers build it +// from Client.buildMTUProbeOptions so each connection sees the right +// per-domain upload payload cap. +type ScanOnceOptions struct { + Connections []Connection + Workers int + ProbeOptionsFor func(conn Connection) ProbeResolverMTUOptions +} + type mtuScanCounters struct { completed atomic.Int32 valid atomic.Int32 @@ -823,15 +860,15 @@ func (c *Client) runConnectionMTUTest(ctx context.Context, conn Connection, serv ) } - result, reason := c.probeConnectionMTU(ctx, conn, maxUploadPayload) + result := ProbeResolverMTU(ctx, conn, c.buildMTUProbeOptions(maxUploadPayload)) if counters == nil { return } - decision := buildMTUDecision(result, reason) + decision := buildMTUDecision(result) c.applyMTUDecision(conn.Key, decision) - switch reason { + switch result.Reason { case mtuRejectUpload: completed := counters.completed.Add(1) rejectedNow := counters.rejectUpload.Add(1) + counters.rejectDownload.Load() @@ -887,46 +924,173 @@ func (c *Client) runConnectionMTUTest(ctx context.Context, conn Connection, serv } } -func (c *Client) probeConnectionMTU(ctx context.Context, conn Connection, maxUploadPayload int) (mtuConnectionProbeResult, mtuRejectReason) { - var result mtuConnectionProbeResult +// ProbeResolverMTU is the single-resolver MTU primitive. It opens a UDP +// transport via opts.NewTransport (defaulting to newUDPQueryTransport), +// probes the upload then download MTU for the connection using the +// supplied probe callables, and returns the per-connection probe +// outcome. Partial measurements are preserved on the result even on +// rejection so callers can surface them. +// +// The options struct keeps this primitive decoupled from *Client so it +// can be reused outside the normal client lifecycle: the multi-resolver +// helper ScanOnce iterates ProbeResolverMTU under -scan-resolvers, and +// future flows (e.g. -doctor's per-resolver tunnel-readiness probe in +// D3) can swap UploadProbe/DownloadProbe for fixed-MTU variants. +func ProbeResolverMTU(ctx context.Context, conn Connection, opts ProbeResolverMTUOptions) ProbeResolverMTUResult { + result := ProbeResolverMTUResult{Conn: conn} - probeTransport, err := newUDPQueryTransport(conn.ResolverLabel) + if opts.UploadProbe == nil || opts.DownloadProbe == nil { + result.Reason = mtuRejectUpload + return result + } + + newTransport := opts.NewTransport + if newTransport == nil { + newTransport = newUDPQueryTransport + } + + probeTransport, err := newTransport(conn.ResolverLabel) if err != nil { - return result, mtuRejectUpload + result.Reason = mtuRejectUpload + return result } defer probeTransport.conn.Close() - upOK, upBytes, upChars, upRTT, err := c.testUploadMTU(ctx, conn, probeTransport, maxUploadPayload) + upOK, upBytes, upChars, upRTT, err := opts.UploadProbe(ctx, conn, probeTransport, opts.MaxUploadPayload) if err != nil || !upOK { result.UploadBytes = upBytes result.UploadChars = upChars - return result, mtuRejectUpload + result.Reason = mtuRejectUpload + return result } result.UploadBytes = upBytes result.UploadChars = upChars - downOK, downBytes, downRTT, err := c.testDownloadMTU(ctx, conn, probeTransport, upBytes) + downOK, downBytes, downRTT, err := opts.DownloadProbe(ctx, conn, probeTransport, upBytes) if err != nil || !downOK { result.DownloadBytes = downBytes - return result, mtuRejectDownload + result.Reason = mtuRejectDownload + return result } result.DownloadBytes = downBytes result.ResolveTime = averageMTUProbeRTT(upRTT, downRTT) - return result, mtuRejectNone + return result +} + +// ScanOnce probes every connection in opts.Connections once via +// ProbeResolverMTU and returns one result per input, in input order. +// It runs probes through up to opts.Workers worker goroutines (clamped +// to [1, len(connections)]). ScanOnce is intentionally side-effect +// free: it does not mutate the balancer, write log lines, or render +// output, so callers (RunInitialMTUTests, the -scan-resolvers +// subcommand, and any future re-discovery trigger) can layer their own +// post-processing on top of the returned slice. +func ScanOnce(ctx context.Context, opts ScanOnceOptions) []ProbeResolverMTUResult { + if len(opts.Connections) == 0 { + return nil + } + + results := make([]ProbeResolverMTUResult, len(opts.Connections)) + if opts.ProbeOptionsFor == nil { + return results + } + + workers := opts.Workers + if workers < 1 { + workers = 1 + } + if workers > len(opts.Connections) { + workers = len(opts.Connections) + } + + if workers <= 1 { + for i, conn := range opts.Connections { + if err := ctx.Err(); err != nil { + return results + } + results[i] = ProbeResolverMTU(ctx, conn, opts.ProbeOptionsFor(conn)) + } + return results + } + + jobs := make(chan int, len(opts.Connections)) + var wg sync.WaitGroup + for range workers { + wg.Go(func() { + for i := range jobs { + if err := ctx.Err(); err != nil { + return + } + conn := opts.Connections[i] + results[i] = ProbeResolverMTU(ctx, conn, opts.ProbeOptionsFor(conn)) + } + }) + } + + for i := range opts.Connections { + select { + case <-ctx.Done(): + close(jobs) + wg.Wait() + return results + case jobs <- i: + } + } + close(jobs) + wg.Wait() + + return results +} + +// buildMTUProbeOptions constructs a ProbeResolverMTUOptions wired to +// this client's upload/download probe helpers and the supplied +// per-domain max upload payload cap (sourced from precomputeUploadCaps). +func (c *Client) buildMTUProbeOptions(maxUploadPayload int) ProbeResolverMTUOptions { + return ProbeResolverMTUOptions{ + MaxUploadPayload: maxUploadPayload, + NewTransport: newUDPQueryTransport, + UploadProbe: c.testUploadMTU, + DownloadProbe: c.testDownloadMTU, + } +} + +// buildScanOnceOptions assembles a ScanOnceOptions populated with the +// client's full connection catalog (via the balancer), the configured +// MTU-test parallelism, and a ProbeOptionsFor closure that reads the +// per-domain upload cap from precomputeUploadCaps. It is the entry +// point used by the -scan-resolvers subcommand (C2) to invoke ScanOnce +// without dragging the rest of the client lifecycle into the call. +func (c *Client) buildScanOnceOptions() ScanOnceOptions { + if c == nil || c.balancer == nil { + return ScanOnceOptions{} + } + connections := c.balancer.AllConnections() + if len(connections) == 0 { + return ScanOnceOptions{Connections: connections} + } + uploadCaps := c.precomputeUploadCaps() + workers := min(max(1, c.cfg.EffectiveMTUTestParallelism()), len(connections)) + return ScanOnceOptions{ + Connections: connections, + Workers: workers, + ProbeOptionsFor: func(conn Connection) ProbeResolverMTUOptions { + return c.buildMTUProbeOptions(uploadCaps[conn.Domain]) + }, + } } -func buildMTUDecision(result mtuConnectionProbeResult, reason mtuRejectReason) mtuDecision { +func buildMTUDecision(result ProbeResolverMTUResult) mtuDecision { decision := mtuDecision{ - active: reason == mtuRejectNone, - reason: reason, + active: result.Reason == mtuRejectNone, + reason: result.Reason, uploadBytes: result.UploadBytes, uploadChars: result.UploadChars, downloadBytes: result.DownloadBytes, resolveTime: result.ResolveTime, } - switch reason { + switch result.Reason { case mtuRejectUpload: decision.uploadBytes = 0 decision.uploadChars = 0 diff --git a/internal/client/mtu_scan_test.go b/internal/client/mtu_scan_test.go new file mode 100644 index 00000000..2eee3c88 --- /dev/null +++ b/internal/client/mtu_scan_test.go @@ -0,0 +1,223 @@ +package client + +import ( + "context" + "net" + "reflect" + "testing" + "time" +) + +// fakeUDPTransport returns a *udpQueryTransport that wraps a closed +// loopback packet conn. ProbeResolverMTU never reads from this +// transport in tests because the upload/download probe callables are +// stubbed; the conn just has to be safe to Close on the deferred path. +func fakeUDPTransport(t *testing.T) func(string) (*udpQueryTransport, error) { + t.Helper() + return func(string) (*udpQueryTransport, error) { + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + return nil, err + } + return &udpQueryTransport{conn: conn}, nil + } +} + +func TestProbeResolverMTUReturnsAcceptedResultWhenBothProbesPass(t *testing.T) { + conn := Connection{ + Key: "k", + Domain: "example.com", + Resolver: "127.0.0.1", + ResolverPort: 5353, + ResolverLabel: "127.0.0.1:5353", + } + + opts := ProbeResolverMTUOptions{ + MaxUploadPayload: 200, + NewTransport: fakeUDPTransport(t), + UploadProbe: func(ctx context.Context, c Connection, transport *udpQueryTransport, maxPayload int) (bool, int, int, time.Duration, error) { + if maxPayload != 200 { + t.Fatalf("expected MaxUploadPayload to be threaded through, got=%d", maxPayload) + } + return true, 180, 240, 30 * time.Millisecond, nil + }, + DownloadProbe: func(ctx context.Context, c Connection, transport *udpQueryTransport, uploadMTU int) (bool, int, time.Duration, error) { + if uploadMTU != 180 { + t.Fatalf("expected DownloadProbe to receive measured upload MTU, got=%d", uploadMTU) + } + return true, 360, 50 * time.Millisecond, nil + }, + } + + got := ProbeResolverMTU(context.Background(), conn, opts) + if !got.Accepted() { + t.Fatalf("expected accepted result, got reason=%d", got.Reason) + } + want := ProbeResolverMTUResult{ + Conn: conn, + Reason: mtuRejectNone, + UploadBytes: 180, + UploadChars: 240, + DownloadBytes: 360, + ResolveTime: averageMTUProbeRTT(30*time.Millisecond, 50*time.Millisecond), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected result\n got=%+v\nwant=%+v", got, want) + } +} + +func TestProbeResolverMTURejectsOnUploadFailure(t *testing.T) { + conn := Connection{Key: "k", Domain: "d", ResolverLabel: "r"} + downloadCalled := false + + opts := ProbeResolverMTUOptions{ + MaxUploadPayload: 100, + NewTransport: fakeUDPTransport(t), + UploadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, int, time.Duration, error) { + return false, 42, 60, 0, nil + }, + DownloadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, time.Duration, error) { + downloadCalled = true + return true, 0, 0, nil + }, + } + + got := ProbeResolverMTU(context.Background(), conn, opts) + if got.Reason != mtuRejectUpload { + t.Fatalf("expected upload rejection, got reason=%d", got.Reason) + } + if got.UploadBytes != 42 || got.UploadChars != 60 { + t.Fatalf("expected partial upload measurements to be preserved, got=%+v", got) + } + if downloadCalled { + t.Fatal("download probe must not run after upload rejection") + } +} + +func TestProbeResolverMTURejectsOnDownloadFailure(t *testing.T) { + conn := Connection{Key: "k", Domain: "d", ResolverLabel: "r"} + + opts := ProbeResolverMTUOptions{ + MaxUploadPayload: 100, + NewTransport: fakeUDPTransport(t), + UploadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, int, time.Duration, error) { + return true, 150, 200, 25 * time.Millisecond, nil + }, + DownloadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, time.Duration, error) { + return false, 11, 0, nil + }, + } + + got := ProbeResolverMTU(context.Background(), conn, opts) + if got.Reason != mtuRejectDownload { + t.Fatalf("expected download rejection, got reason=%d", got.Reason) + } + if got.UploadBytes != 150 || got.UploadChars != 200 { + t.Fatalf("expected upload measurements to be preserved on download failure, got=%+v", got) + } + if got.DownloadBytes != 11 { + t.Fatalf("expected partial download bytes to be preserved, got=%d", got.DownloadBytes) + } + if got.ResolveTime != 0 { + t.Fatalf("expected ResolveTime to remain zero on download failure, got=%s", got.ResolveTime) + } +} + +func TestProbeResolverMTURejectsOnTransportFailure(t *testing.T) { + conn := Connection{Key: "k", Domain: "d", ResolverLabel: "r"} + + opts := ProbeResolverMTUOptions{ + NewTransport: func(string) (*udpQueryTransport, error) { + return nil, net.ErrClosed + }, + UploadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, int, time.Duration, error) { + t.Fatal("upload probe must not run when transport fails to open") + return false, 0, 0, 0, nil + }, + DownloadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, time.Duration, error) { + return false, 0, 0, nil + }, + } + + got := ProbeResolverMTU(context.Background(), conn, opts) + if got.Reason != mtuRejectUpload { + t.Fatalf("expected upload rejection on transport failure, got reason=%d", got.Reason) + } +} + +// scanOnceFixture builds a deterministic fixture used by both +// TestScanOnce_MatchesInlineProbeLoopForFixture and the inline-loop +// reference. Each connection's ProbeOptionsFor returns a stub upload +// and download probe whose return values are derived from the +// connection's ResolverPort, so different inputs produce different +// outputs and we can compare aggregated outputs deterministically. +func scanOnceFixture(t *testing.T) ([]Connection, func(Connection) ProbeResolverMTUOptions) { + t.Helper() + conns := []Connection{ + {Key: "a", Domain: "d", Resolver: "10.0.0.1", ResolverPort: 100, ResolverLabel: "10.0.0.1:100"}, + {Key: "b", Domain: "d", Resolver: "10.0.0.2", ResolverPort: 200, ResolverLabel: "10.0.0.2:200"}, + {Key: "c", Domain: "d", Resolver: "10.0.0.3", ResolverPort: 300, ResolverLabel: "10.0.0.3:300"}, + {Key: "d", Domain: "d", Resolver: "10.0.0.4", ResolverPort: 400, ResolverLabel: "10.0.0.4:400"}, + {Key: "e", Domain: "d", Resolver: "10.0.0.5", ResolverPort: 500, ResolverLabel: "10.0.0.5:500"}, + } + transport := fakeUDPTransport(t) + probeFor := func(c Connection) ProbeResolverMTUOptions { + port := c.ResolverPort + return ProbeResolverMTUOptions{ + MaxUploadPayload: port, + NewTransport: transport, + UploadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, int, time.Duration, error) { + if port%200 == 0 { + return false, port / 2, 0, 0, nil + } + return true, port, port + 10, time.Duration(port) * time.Microsecond, nil + }, + DownloadProbe: func(context.Context, Connection, *udpQueryTransport, int) (bool, int, time.Duration, error) { + if port%300 == 0 { + return false, 0, 0, nil + } + return true, port * 2, time.Duration(port*2) * time.Microsecond, nil + }, + } + } + return conns, probeFor +} + +func TestScanOnceMatchesInlineProbeLoopOnFixture(t *testing.T) { + conns, probeFor := scanOnceFixture(t) + + inline := make([]ProbeResolverMTUResult, len(conns)) + for i, conn := range conns { + inline[i] = ProbeResolverMTU(context.Background(), conn, probeFor(conn)) + } + + for _, workers := range []int{1, 2, 4, 8} { + got := ScanOnce(context.Background(), ScanOnceOptions{ + Connections: conns, + Workers: workers, + ProbeOptionsFor: probeFor, + }) + if !reflect.DeepEqual(got, inline) { + t.Fatalf("ScanOnce(workers=%d) diverged from inline probe loop\n got=%+v\nwant=%+v", workers, got, inline) + } + } +} + +func TestScanOnceReturnsEmptyForEmptyInput(t *testing.T) { + got := ScanOnce(context.Background(), ScanOnceOptions{}) + if len(got) != 0 { + t.Fatalf("expected nil/empty results for empty input, got=%v", got) + } +} + +func TestScanOnceReturnsZeroResultsWhenProbeOptionsForIsNil(t *testing.T) { + conns := []Connection{{Key: "a", ResolverLabel: "r"}} + got := ScanOnce(context.Background(), ScanOnceOptions{Connections: conns}) + if len(got) != 1 { + t.Fatalf("expected one result slot for one input, got=%d", len(got)) + } + want := ProbeResolverMTUResult{} + if !reflect.DeepEqual(got[0], want) { + t.Fatalf("expected zero-value result when ProbeOptionsFor is nil, got=%+v", got[0]) + } +} From 2a16236d2f37d963e2a2c54d6c27126346195417 Mon Sep 17 00:00:00 2001 From: Isusami Date: Tue, 12 May 2026 19:40:25 +0300 Subject: [PATCH 2/3] feat(client): add -scan-resolvers subcommand Introduces a new -scan-resolvers flag in cmd/client/main.go that loads the configured resolver list via the existing Bootstrap path, calls ScanOnce (added in C1), and writes the surviving resolvers to stdout, one IP per line. Useful for sanity-checking a resolver list outside the normal client startup path. The dispatch fires after Bootstrap (so the resolver catalog is fully loaded into the balancer) but before PrintBanner and the runtime loop, so no tunnel traffic, ping manager, or async runtime ever starts in scan mode. Output format and exit codes are intentionally minimal here: every accepted result writes "{IP}" to stdout and the command exits 0 on success or 1 if the client cannot be initialized. Wiring of MTU_SERVERS_FILE_FORMAT and the empty-result / I/O-failure exit codes lands in the follow-up C3 commit. The renderer is split into ScanResolvers (high-level entry that takes a *Client) and an inner scanResolvers (takes pre-built ScanOnceOptions) so tests can drive the output path with stub upload/download probes without touching the network or building a fully-bootstrapped client. --- cmd/client/main.go | 9 ++++ cmd/client/main_test.go | 20 ++++++++ internal/client/scancmd.go | 53 ++++++++++++++++++++ internal/client/scancmd_test.go | 87 +++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 internal/client/scancmd.go create mode 100644 internal/client/scancmd_test.go diff --git a/cmd/client/main.go b/cmd/client/main.go index d949b2de..2325e334 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -93,6 +93,7 @@ type clientCLIOptions struct { resolversPath string showVersion bool showHelp bool + scanResolvers bool domainsShort string keyShort string } @@ -125,6 +126,8 @@ func newClientFlagSet(output io.Writer) (*flag.FlagSet, *clientCLIOptions, *conf fs.BoolVar(&opts.showHelp, "help", false, "Show help and exit") fs.BoolVar(&opts.showHelp, "h", false, "Alias for -help") + fs.BoolVar(&opts.scanResolvers, "scan-resolvers", false, "Probe the configured resolver list once and write surviving resolvers to stdout, then exit") + fs.StringVar(&opts.domainsShort, "d", "", "Alias for -domains (comma separated)") fs.StringVar(&opts.keyShort, "k", "", "Alias for -encryption-key") fs.StringVar(&opts.keyShort, "key", "", "Compatibility alias for -encryption-key") @@ -239,6 +242,12 @@ func main() { os.Exit(1) } + if opts.scanResolvers { + scanCtx, scanStop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer scanStop() + os.Exit(client.ScanResolvers(scanCtx, app, os.Stdout, os.Stderr)) + } + app.PrintBanner() log := app.Log() diff --git a/cmd/client/main_test.go b/cmd/client/main_test.go index 8717ed94..c1cd6883 100644 --- a/cmd/client/main_test.go +++ b/cmd/client/main_test.go @@ -79,6 +79,26 @@ func TestParseClientCLIArgsAcceptsJSONBase64Mode(t *testing.T) { } } +func TestParseClientCLIArgsAcceptsScanResolversFlag(t *testing.T) { + opts, _, err := parseClientCLIArgs([]string{"-scan-resolvers"}, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parseClientCLIArgs returned error: %v", err) + } + if !opts.scanResolvers { + t.Fatal("expected scanResolvers flag to be set") + } +} + +func TestParseClientCLIArgsScanResolversDefaultsToFalse(t *testing.T) { + opts, _, err := parseClientCLIArgs(nil, &bytes.Buffer{}) + if err != nil { + t.Fatalf("parseClientCLIArgs returned error: %v", err) + } + if opts.scanResolvers { + t.Fatal("expected scanResolvers default to be false") + } +} + func TestParseClientCLIArgsIgnoresExecutableInjectedAsPositionalConfig(t *testing.T) { withClientTestArgs(t, []string{"/data/data/com.termux/files/home/MasterDNS/MasterDnsVPN_Client_Termux_ARM64"}, func() { opts, overrides, err := parseClientCLIArgs([]string{"/data/data/com.termux/files/home/MasterDNS/MasterDnsVPN_Client_Termux_ARM64"}, &bytes.Buffer{}) diff --git a/internal/client/scancmd.go b/internal/client/scancmd.go new file mode 100644 index 00000000..978267cf --- /dev/null +++ b/internal/client/scancmd.go @@ -0,0 +1,53 @@ +// ============================================================================== +// MasterDnsVPN +// Author: MasterkinG32 +// Github: https://github.com/masterking32 +// Year: 2026 +// ============================================================================== +// Package client provides the core logic for the MasterDnsVPN client. +// This file (scancmd.go) implements the -scan-resolvers subcommand, +// which short-circuits the normal client startup to probe the loaded +// resolver catalog once and write the surviving entries to stdout. +// ============================================================================== +package client + +import ( + "context" + "fmt" + "io" +) + +// ScanResolvers is the entry point used by cmd/client when the +// -scan-resolvers flag is set. It builds a ScanOnceOptions from the +// supplied client (which must already have the resolver catalog loaded +// via Bootstrap) and dispatches to the inner scanResolvers helper. +// +// It returns the process exit code: 0 on a successful scan, 1 on a +// setup or write failure. C3 will introduce exit code 2 for the +// empty-result case and wire MTU_SERVERS_FILE_FORMAT into the output +// renderer. +func ScanResolvers(ctx context.Context, c *Client, stdout io.Writer, stderr io.Writer) int { + if c == nil { + fmt.Fprintln(stderr, "scan-resolvers: client must be initialized") + return 1 + } + return scanResolvers(ctx, c.buildScanOnceOptions(), stdout, stderr) +} + +// scanResolvers is the dependency-injected core of ScanResolvers. It +// runs ScanOnce against opts and writes one "{IP}" line per surviving +// resolver to stdout. The split lets tests drive the renderer without +// needing live network probes or a fully-bootstrapped *Client. +func scanResolvers(ctx context.Context, opts ScanOnceOptions, stdout io.Writer, stderr io.Writer) int { + results := ScanOnce(ctx, opts) + for _, result := range results { + if !result.Accepted() { + continue + } + if _, err := fmt.Fprintln(stdout, result.Conn.Resolver); err != nil { + fmt.Fprintf(stderr, "scan-resolvers: write failure: %v\n", err) + return 1 + } + } + return 0 +} diff --git a/internal/client/scancmd_test.go b/internal/client/scancmd_test.go new file mode 100644 index 00000000..dd88978b --- /dev/null +++ b/internal/client/scancmd_test.go @@ -0,0 +1,87 @@ +package client + +import ( + "bytes" + "context" + "strings" + "testing" + "time" +) + +// scanResolversTestOptions returns a deterministic ScanOnceOptions that +// accepts the first two of three resolvers and rejects the third on +// upload. The reject case exercises the result-filtering path in +// scanResolvers without touching the network. +func scanResolversTestOptions(t *testing.T) ScanOnceOptions { + t.Helper() + conns := []Connection{ + {Key: "a", Domain: "example.com", Resolver: "10.0.0.1", ResolverPort: 53, ResolverLabel: "10.0.0.1:53"}, + {Key: "b", Domain: "example.com", Resolver: "10.0.0.2", ResolverPort: 53, ResolverLabel: "10.0.0.2:53"}, + {Key: "c", Domain: "example.com", Resolver: "10.0.0.3", ResolverPort: 53, ResolverLabel: "10.0.0.3:53"}, + } + transport := fakeUDPTransport(t) + return ScanOnceOptions{ + Connections: conns, + Workers: 2, + ProbeOptionsFor: func(c Connection) ProbeResolverMTUOptions { + return ProbeResolverMTUOptions{ + MaxUploadPayload: 100, + NewTransport: transport, + UploadProbe: func(ctx context.Context, conn Connection, transport *udpQueryTransport, maxPayload int) (bool, int, int, time.Duration, error) { + if conn.Resolver == "10.0.0.3" { + return false, 0, 0, 0, nil + } + return true, 120, 160, 5 * time.Millisecond, nil + }, + DownloadProbe: func(ctx context.Context, conn Connection, transport *udpQueryTransport, uploadMTU int) (bool, int, time.Duration, error) { + return true, 240, 5 * time.Millisecond, nil + }, + } + }, + } +} + +func TestScanResolversWritesAcceptedResolversToStdoutAndExitsZero(t *testing.T) { + var stdout, stderr bytes.Buffer + + code := scanResolvers(context.Background(), scanResolversTestOptions(t), &stdout, &stderr) + if code != 0 { + t.Fatalf("expected exit code 0, got=%d (stderr=%q)", code, stderr.String()) + } + + gotLines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + wantLines := []string{"10.0.0.1", "10.0.0.2"} + if len(gotLines) != len(wantLines) { + t.Fatalf("unexpected stdout line count: got=%d want=%d (raw=%q)", len(gotLines), len(wantLines), stdout.String()) + } + for i, want := range wantLines { + if gotLines[i] != want { + t.Fatalf("line %d: got=%q want=%q (raw=%q)", i, gotLines[i], want, stdout.String()) + } + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr on successful scan, got=%q", stderr.String()) + } +} + +func TestScanResolversReturnsOneOnNilClient(t *testing.T) { + var stdout, stderr bytes.Buffer + code := ScanResolvers(context.Background(), nil, &stdout, &stderr) + if code != 1 { + t.Fatalf("expected exit code 1 for nil client, got=%d", code) + } + if stderr.Len() == 0 { + t.Fatal("expected stderr to describe the nil-client failure") + } +} + +func TestScanResolversReturnsZeroForEmptyResultsInC2(t *testing.T) { + var stdout, stderr bytes.Buffer + code := scanResolvers(context.Background(), ScanOnceOptions{}, &stdout, &stderr) + if code != 0 { + t.Fatalf("C2 contract: empty result must exit 0; got=%d (stderr=%q)", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout for empty result, got=%q", stdout.String()) + } +} From 9369284e4d971871ce05391cee376eca3a98a8e1 Mon Sep 17 00:00:00 2001 From: Isusami Date: Tue, 12 May 2026 19:43:04 +0300 Subject: [PATCH 3/3] feat(client): -scan-resolvers output format and exit codes Wires MTUServersFileFormat (TOML key MTU_SERVERS_FILE_FORMAT) into the -scan-resolvers output renderer; defaults to {IP} (the raw resolver IP) when unset or trimmed to empty. The renderer reuses formatMTULogLine so operators get the same placeholder vocabulary they already use for the MTU success log file ({IP}, {DOMAIN}, {UP_MTU}, {DOWN_MTU}, etc.). Per-result MTU measurements are spliced onto the Connection copy passed to the formatter so {UP_MTU} / {DOWN_MTU} resolve to the values just measured by ProbeResolverMTU rather than stale catalog defaults. Exit codes (now part of the public contract for scripting): 0 = wrote at least one surviving resolver 1 = setup / config-load / I/O failure (warning on stderr) 2 = empty result, i.e. no resolver survived the scan (warning on stderr) Lets operators script around the scan command and reuse the same resolver-file format they already use elsewhere. Tests cover: format roundtrip with a custom MTU_SERVERS_FILE_FORMAT template; blank-format fallback to {IP}; empty result and all-rejected paths returning exit code 2 with the warning on stderr; write-failure path returning exit code 1 with the underlying error surfaced on stderr; nil-client guard on ScanResolvers returning exit code 1. --- internal/client/scancmd.go | 108 ++++++++++++++++++++----- internal/client/scancmd_test.go | 137 ++++++++++++++++++++++++++++---- 2 files changed, 212 insertions(+), 33 deletions(-) diff --git a/internal/client/scancmd.go b/internal/client/scancmd.go index 978267cf..d373b46c 100644 --- a/internal/client/scancmd.go +++ b/internal/client/scancmd.go @@ -15,39 +15,111 @@ import ( "context" "fmt" "io" + "strings" ) +// Process exit codes returned by the -scan-resolvers subcommand. +// Operators script around these so they are part of the public +// contract; treat changes as breaking. +const ( + scanExitOK = 0 // wrote at least one surviving resolver + scanExitFailure = 1 // setup, config-load, or I/O failure + scanExitNoSurvivors = 2 // ScanOnce returned no surviving resolvers + scanNoSurvivorsWarning = "scan-resolvers: no resolvers survived the scan" + scanNilClientErrorLine = "scan-resolvers: client must be initialized" + scanWriteFailureFormat = "scan-resolvers: write failure: %v\n" +) + +// scanLineRenderer turns an accepted ProbeResolverMTUResult into the +// text line written to stdout. Returning the empty string causes the +// renderer to skip that result entirely (it does not count toward the +// "wrote at least one resolver" tally that drives the exit code). +type scanLineRenderer func(result ProbeResolverMTUResult) string + // ScanResolvers is the entry point used by cmd/client when the -// -scan-resolvers flag is set. It builds a ScanOnceOptions from the -// supplied client (which must already have the resolver catalog loaded -// via Bootstrap) and dispatches to the inner scanResolvers helper. +// -scan-resolvers flag is set. It builds a ScanOnceOptions and a line +// renderer from the supplied client (which must already have the +// resolver catalog loaded via Bootstrap) and dispatches to the inner +// scanResolvers helper. +// +// Returns the process exit code: +// +// 0 — wrote at least one surviving resolver to stdout. +// 1 — setup / config-load / I/O failure (a warning is written to stderr). +// 2 — ScanOnce returned no surviving resolvers (a warning is written to stderr). // -// It returns the process exit code: 0 on a successful scan, 1 on a -// setup or write failure. C3 will introduce exit code 2 for the -// empty-result case and wire MTU_SERVERS_FILE_FORMAT into the output -// renderer. +// The line format honors MTU_SERVERS_FILE_FORMAT (TOML field +// MTUServersFileFormat). When unset, the default is "{IP}", i.e. the +// raw resolver IP. func ScanResolvers(ctx context.Context, c *Client, stdout io.Writer, stderr io.Writer) int { if c == nil { - fmt.Fprintln(stderr, "scan-resolvers: client must be initialized") - return 1 + fmt.Fprintln(stderr, scanNilClientErrorLine) + return scanExitFailure + } + return scanResolvers(ctx, c.buildScanOnceOptions(), c.scanLineRenderer(), stdout, stderr) +} + +// scanLineRenderer returns the renderer used by -scan-resolvers when +// formatting accepted results. When MTUServersFileFormat is unset (or +// trimmed to empty) the renderer falls back to "{IP}", i.e. the raw +// resolver IP, matching the C2 default. Otherwise it reuses +// formatMTULogLine so operators get the same placeholder vocabulary +// (and trimming semantics) they already use for the MTU success log +// file. Per-result MTU measurements are spliced onto the Connection +// copy so placeholders like {UP_MTU} / {DOWN_MTU} resolve to the +// values measured by ProbeResolverMTU rather than the stale catalog +// defaults. +func (c *Client) scanLineRenderer() scanLineRenderer { + template := strings.TrimSpace(c.mtuServersFileFormat) + if template == "" { + return func(result ProbeResolverMTUResult) string { + return result.Conn.Resolver + } + } + return func(result ProbeResolverMTUResult) string { + conn := result.Conn + conn.UploadMTUBytes = result.UploadBytes + conn.UploadMTUChars = result.UploadChars + conn.DownloadMTUBytes = result.DownloadBytes + conn.MTUResolveTime = result.ResolveTime + return c.formatMTULogLine(template, &conn, "") } - return scanResolvers(ctx, c.buildScanOnceOptions(), stdout, stderr) } // scanResolvers is the dependency-injected core of ScanResolvers. It -// runs ScanOnce against opts and writes one "{IP}" line per surviving -// resolver to stdout. The split lets tests drive the renderer without -// needing live network probes or a fully-bootstrapped *Client. -func scanResolvers(ctx context.Context, opts ScanOnceOptions, stdout io.Writer, stderr io.Writer) int { +// runs ScanOnce against opts, renders each accepted result via render, +// writes one non-empty line per result to stdout, and reports an exit +// code per the contract documented on ScanResolvers. The split lets +// tests drive the renderer without needing a fully-bootstrapped +// *Client or live network probes. +func scanResolvers(ctx context.Context, opts ScanOnceOptions, render scanLineRenderer, stdout io.Writer, stderr io.Writer) int { + if render == nil { + render = func(result ProbeResolverMTUResult) string { + return result.Conn.Resolver + } + } + results := ScanOnce(ctx, opts) + + written := 0 for _, result := range results { if !result.Accepted() { continue } - if _, err := fmt.Fprintln(stdout, result.Conn.Resolver); err != nil { - fmt.Fprintf(stderr, "scan-resolvers: write failure: %v\n", err) - return 1 + line := render(result) + if line == "" { + continue + } + if _, err := fmt.Fprintln(stdout, line); err != nil { + fmt.Fprintf(stderr, scanWriteFailureFormat, err) + return scanExitFailure } + written++ + } + + if written == 0 { + fmt.Fprintln(stderr, scanNoSurvivorsWarning) + return scanExitNoSurvivors } - return 0 + return scanExitOK } diff --git a/internal/client/scancmd_test.go b/internal/client/scancmd_test.go index dd88978b..8bb09687 100644 --- a/internal/client/scancmd_test.go +++ b/internal/client/scancmd_test.go @@ -3,15 +3,18 @@ package client import ( "bytes" "context" + "errors" "strings" "testing" "time" + + "masterdnsvpn-go/internal/config" ) -// scanResolversTestOptions returns a deterministic ScanOnceOptions that -// accepts the first two of three resolvers and rejects the third on -// upload. The reject case exercises the result-filtering path in -// scanResolvers without touching the network. +// scanResolversTestOptions returns a deterministic ScanOnceOptions +// fixture that accepts the first two of three resolvers and rejects +// the third on upload. The reject case exercises the result-filtering +// path in scanResolvers without touching the network. func scanResolversTestOptions(t *testing.T) ScanOnceOptions { t.Helper() conns := []Connection{ @@ -41,12 +44,46 @@ func scanResolversTestOptions(t *testing.T) ScanOnceOptions { } } +// rejectAllOptions returns a ScanOnceOptions whose probes always +// reject so the renderer produces zero output lines (exercises the +// exit-code-2 path). +func rejectAllOptions(t *testing.T) ScanOnceOptions { + t.Helper() + conns := []Connection{ + {Key: "a", Domain: "example.com", Resolver: "10.0.0.1", ResolverPort: 53, ResolverLabel: "10.0.0.1:53"}, + {Key: "b", Domain: "example.com", Resolver: "10.0.0.2", ResolverPort: 53, ResolverLabel: "10.0.0.2:53"}, + } + transport := fakeUDPTransport(t) + return ScanOnceOptions{ + Connections: conns, + Workers: 1, + ProbeOptionsFor: func(c Connection) ProbeResolverMTUOptions { + return ProbeResolverMTUOptions{ + MaxUploadPayload: 100, + NewTransport: transport, + UploadProbe: func(ctx context.Context, conn Connection, transport *udpQueryTransport, maxPayload int) (bool, int, int, time.Duration, error) { + return false, 0, 0, 0, nil + }, + DownloadProbe: func(ctx context.Context, conn Connection, transport *udpQueryTransport, uploadMTU int) (bool, int, time.Duration, error) { + return true, 0, 0, nil + }, + } + }, + } +} + +// failingWriter is an io.Writer that always returns the supplied error +// on Write, used to exercise the I/O-failure path in scanResolvers. +type failingWriter struct{ err error } + +func (w failingWriter) Write([]byte) (int, error) { return 0, w.err } + func TestScanResolversWritesAcceptedResolversToStdoutAndExitsZero(t *testing.T) { var stdout, stderr bytes.Buffer - code := scanResolvers(context.Background(), scanResolversTestOptions(t), &stdout, &stderr) - if code != 0 { - t.Fatalf("expected exit code 0, got=%d (stderr=%q)", code, stderr.String()) + code := scanResolvers(context.Background(), scanResolversTestOptions(t), nil, &stdout, &stderr) + if code != scanExitOK { + t.Fatalf("expected exit code %d, got=%d (stderr=%q)", scanExitOK, code, stderr.String()) } gotLines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") @@ -67,21 +104,91 @@ func TestScanResolversWritesAcceptedResolversToStdoutAndExitsZero(t *testing.T) func TestScanResolversReturnsOneOnNilClient(t *testing.T) { var stdout, stderr bytes.Buffer code := ScanResolvers(context.Background(), nil, &stdout, &stderr) - if code != 1 { - t.Fatalf("expected exit code 1 for nil client, got=%d", code) + if code != scanExitFailure { + t.Fatalf("expected exit code %d for nil client, got=%d", scanExitFailure, code) } - if stderr.Len() == 0 { - t.Fatal("expected stderr to describe the nil-client failure") + if !strings.Contains(stderr.String(), "client must be initialized") { + t.Fatalf("expected stderr to describe the nil-client failure, got=%q", stderr.String()) } } -func TestScanResolversReturnsZeroForEmptyResultsInC2(t *testing.T) { +func TestScanResolversReturnsTwoOnEmptyResults(t *testing.T) { var stdout, stderr bytes.Buffer - code := scanResolvers(context.Background(), ScanOnceOptions{}, &stdout, &stderr) - if code != 0 { - t.Fatalf("C2 contract: empty result must exit 0; got=%d (stderr=%q)", code, stderr.String()) + code := scanResolvers(context.Background(), ScanOnceOptions{}, nil, &stdout, &stderr) + if code != scanExitNoSurvivors { + t.Fatalf("expected exit code %d for empty results, got=%d (stderr=%q)", scanExitNoSurvivors, code, stderr.String()) } if stdout.Len() != 0 { t.Fatalf("expected empty stdout for empty result, got=%q", stdout.String()) } + if !strings.Contains(stderr.String(), "no resolvers survived") { + t.Fatalf("expected stderr warning on empty results, got=%q", stderr.String()) + } +} + +func TestScanResolversReturnsTwoWhenAllResolversAreRejected(t *testing.T) { + var stdout, stderr bytes.Buffer + code := scanResolvers(context.Background(), rejectAllOptions(t), nil, &stdout, &stderr) + if code != scanExitNoSurvivors { + t.Fatalf("expected exit code %d when nothing survives, got=%d (stderr=%q)", scanExitNoSurvivors, code, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout when nothing survives, got=%q", stdout.String()) + } + if !strings.Contains(stderr.String(), "no resolvers survived") { + t.Fatalf("expected stderr warning when nothing survives, got=%q", stderr.String()) + } +} + +func TestScanResolversReturnsOneOnWriteFailure(t *testing.T) { + var stderr bytes.Buffer + wantErr := errors.New("disk full") + code := scanResolvers(context.Background(), scanResolversTestOptions(t), nil, failingWriter{err: wantErr}, &stderr) + if code != scanExitFailure { + t.Fatalf("expected exit code %d on write failure, got=%d (stderr=%q)", scanExitFailure, code, stderr.String()) + } + if !strings.Contains(stderr.String(), "write failure") { + t.Fatalf("expected stderr to describe the write failure, got=%q", stderr.String()) + } + if !strings.Contains(stderr.String(), wantErr.Error()) { + t.Fatalf("expected stderr to mention the underlying error %q, got=%q", wantErr.Error(), stderr.String()) + } +} + +func TestScanResolversHonorsConfiguredFormatTemplate(t *testing.T) { + c := buildTestClientWithResolvers(config.ClientConfig{}, "ignored") + c.mtuServersFileFormat = "{IP}:{UP_MTU}/{DOWN_MTU} via {DOMAIN}" + + var stdout, stderr bytes.Buffer + code := scanResolvers(context.Background(), scanResolversTestOptions(t), c.scanLineRenderer(), &stdout, &stderr) + if code != scanExitOK { + t.Fatalf("expected exit code %d, got=%d (stderr=%q)", scanExitOK, code, stderr.String()) + } + + got := strings.TrimRight(stdout.String(), "\n") + wantLines := []string{ + "10.0.0.1:53:120/240 via example.com", + "10.0.0.2:53:120/240 via example.com", + } + want := strings.Join(wantLines, "\n") + if got != want { + t.Fatalf("custom format roundtrip mismatch\n got=%q\nwant=%q", got, want) + } +} + +func TestScanResolversFallsBackToIPWhenFormatIsBlank(t *testing.T) { + c := buildTestClientWithResolvers(config.ClientConfig{}, "ignored") + c.mtuServersFileFormat = " " + + var stdout, stderr bytes.Buffer + code := scanResolvers(context.Background(), scanResolversTestOptions(t), c.scanLineRenderer(), &stdout, &stderr) + if code != scanExitOK { + t.Fatalf("expected exit code %d, got=%d (stderr=%q)", scanExitOK, code, stderr.String()) + } + + got := strings.TrimRight(stdout.String(), "\n") + want := "10.0.0.1\n10.0.0.2" + if got != want { + t.Fatalf("blank-format fallback mismatch\n got=%q\nwant=%q", got, want) + } }