Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ type clientCLIOptions struct {
resolversPath string
showVersion bool
showHelp bool
scanResolvers bool
domainsShort string
keyShort string
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions cmd/client/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
198 changes: 181 additions & 17 deletions internal/client/mtu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading