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/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]) + } +} diff --git a/internal/client/scancmd.go b/internal/client/scancmd.go new file mode 100644 index 00000000..d373b46c --- /dev/null +++ b/internal/client/scancmd.go @@ -0,0 +1,125 @@ +// ============================================================================== +// 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" + "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 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). +// +// 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, 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, "") + } +} + +// scanResolvers is the dependency-injected core of ScanResolvers. It +// 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 + } + 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 scanExitOK +} diff --git a/internal/client/scancmd_test.go b/internal/client/scancmd_test.go new file mode 100644 index 00000000..8bb09687 --- /dev/null +++ b/internal/client/scancmd_test.go @@ -0,0 +1,194 @@ +package client + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "masterdnsvpn-go/internal/config" +) + +// 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{ + {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 + }, + } + }, + } +} + +// 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), 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") + 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 != scanExitFailure { + t.Fatalf("expected exit code %d for nil client, got=%d", scanExitFailure, code) + } + if !strings.Contains(stderr.String(), "client must be initialized") { + t.Fatalf("expected stderr to describe the nil-client failure, got=%q", stderr.String()) + } +} + +func TestScanResolversReturnsTwoOnEmptyResults(t *testing.T) { + var stdout, stderr bytes.Buffer + 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) + } +}