// SPDX-License-Identifier: Apache-2.0 // Copyright (c) 2024-2026 Quad4.io package cli import ( "context" "encoding/hex" "encoding/json" "flag" "fmt" "time" "quad4/reticulum-go/pkg/destination" "quad4/reticulum-go/pkg/node" "quad4/reticulum-go/pkg/rnsutil" ) func RunProbe(args []string, opt ...Options) int { stdout, stderr := cliIO(opt) fs := flag.NewFlagSet("rgoprobe", flag.ContinueOnError) fs.SetOutput(stderr) configDir := fs.String("config", "", "path to config directory") size := fs.Int("s", rnsutil.DefaultProbeSize, "probe payload size in bytes") timeoutSec := fs.Float64("t", 0, "per-probe timeout in seconds (0 = adaptive path wait, first-hop based probe)") waitSec := fs.Float64("w", 0, "delay between probes in seconds") count := fs.Int("n", 1, "number of probes") verbose := fs.Bool("v", false, "show next-hop details") jsonOut := fs.Bool("json", false, "emit JSON results") bindFlagUsage(fs, "rgoprobe - path probe tool", "Sends probe packets to a destination and reports loss and RTT.", []helpLine{ {Cmd: "rgoprobe [flags] "}, {Cmd: "reticulum-go probe [flags] "}, }, "rgoprobe example.app probe ", ) if err := fs.Parse(args); err != nil { return 2 } if fs.NArg() != 2 { usageErr(stderr, "rgoprobe [flags] ") return 2 } fullName := fs.Arg(0) if _, _, err := destination.ParseName(fullName); err != nil { diagErr(stderr, "name", err) return 2 } destHash, err := hex.DecodeString(fs.Arg(1)) if err != nil || len(destHash) != 16 { fmt.Fprintln(stderr, "destination hash must be 32 hex characters (16 bytes)") return 2 } cfg, err := rnsutil.LoadConfigDir(*configDir) if err != nil { diagErr(stderr, "config", err) return 1 } n, err := node.New(cfg) if err != nil { diagErr(stderr, "node", err) return 1 } if err := n.Start(); err != nil { diagErr(stderr, "start", err) return 1 } defer n.Stop() tr := n.Transport() var pathTimeout time.Duration if *timeoutSec > 0 { pathTimeout = time.Duration(*timeoutSec * float64(time.Second)) } else { pathTimeout = rnsutil.PathResponseWindow(tr, destHash) } pathCtx, cancel := context.WithTimeout(context.Background(), pathTimeout) defer cancel() fmt.Fprintln(stdout, infoMsg(stdout, fmt.Sprintf("Path to %s requested", rnsutil.PrettyHex(destHash)))) if err := rnsutil.WaitPath(pathCtx, tr, destHash); err != nil { fmt.Fprintf(stderr, "%s: %v\n", errMsg(stderr, "path request timed out"), err) report := rnsutil.DiagnoseReachability(tr, destHash) _ = rnsutil.WriteReachReportHuman(stderr, report) return 1 } wait := time.Duration(*waitSec * float64(time.Second)) sent, replies := 0, 0 type probeJSON struct { Index int `json:"index"` Delivered bool `json:"delivered"` RTTMS float64 `json:"rtt_ms,omitempty"` Hops uint8 `json:"hops,omitempty"` Error string `json:"error,omitempty"` } var jsonResults []probeJSON for i := 0; i < *count; i++ { if i > 0 && wait > 0 { time.Sleep(wait) } var probeTimeout time.Duration if *timeoutSec > 0 { probeTimeout = time.Duration(*timeoutSec * float64(time.Second)) } else { probeTimeout = rnsutil.DefaultProbeTimeout + rnsutil.FirstHopTimeout(tr, destHash) } ctx, cancelProbe := context.WithTimeout(context.Background(), probeTimeout) more := "" if *verbose { if nh := tr.NextHop(destHash); len(nh) > 0 { more += " via " + rnsutil.PrettyHex(nh) } if ifn := tr.NextHopInterface(destHash); ifn != "" && ifn != "None" { more += " on " + ifn } } if !*jsonOut { fmt.Fprintf(stdout, "Sent probe %d (%d bytes) to %s%s\n", i+1, *size, rnsutil.PrettyHex(destHash), more) } sent++ res, err := rnsutil.SendProbe(ctx, tr, destHash, *size) cancelProbe() if err != nil { if *jsonOut { jsonResults = append(jsonResults, probeJSON{Index: i + 1, Error: err.Error()}) } else { fmt.Fprintf(stderr, "%s: %v\n", errMsg(stderr, "probe failed"), err) } continue } if !res.Delivered { if *jsonOut { jsonResults = append(jsonResults, probeJSON{Index: i + 1, Delivered: false}) } else { fmt.Fprintln(stdout, warnMsg(stdout, "Probe timed out")) } continue } replies++ rtt := res.RTT if *jsonOut { jsonResults = append(jsonResults, probeJSON{ Index: i + 1, Delivered: true, RTTMS: float64(rtt.Microseconds()) / 1000, Hops: res.Hops, }) continue } rttStr := fmt.Sprintf("%.3f milliseconds", float64(rtt.Microseconds())/1000) if rtt >= time.Second { rttStr = fmt.Sprintf("%.3f seconds", rtt.Seconds()) } hopWord := "hops" if res.Hops == 1 { hopWord = "hop" } fmt.Fprintf(stdout, "%s %s\nRound-trip time is %s over %d %s\n", okMsg(stdout, "Valid reply from"), rnsutil.PrettyHex(destHash), rttStr, res.Hops, hopWord) } if *jsonOut { enc := json.NewEncoder(stdout) enc.SetIndent("", " ") _ = enc.Encode(map[string]any{ "sent": sent, "replies": replies, "loss_pct": func() float64 { if sent == 0 { return 0 } return (1 - float64(replies)/float64(sent)) * 100 }(), "probes": jsonResults, }) } else { loss := 0.0 if sent > 0 { loss = (1 - float64(replies)/float64(sent)) * 100 } summary := fmt.Sprintf("Sent %d, received %d, packet loss %.2f%%", sent, replies, loss) switch { case replies == 0: summary = errMsg(stdout, summary) case loss > 0: summary = warnMsg(stdout, summary) default: summary = okMsg(stdout, summary) } fmt.Fprintln(stdout, summary) } if replies == 0 { return 1 } loss := 0.0 if sent > 0 { loss = (1 - float64(replies)/float64(sent)) * 100 } if loss > 0 { return 2 } return 0 }