mirror of
https://github.com/Quad4-Software/Reticulum-Go
synced 2026-08-29 23:48:44 -04:00
406 lines
12 KiB
Go
406 lines
12 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright (c) 2024-2026 Quad4.io
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"quad4/reticulum-go/pkg/debug"
|
|
"quad4/reticulum-go/pkg/destination"
|
|
"quad4/reticulum-go/pkg/identity"
|
|
rlink "quad4/reticulum-go/pkg/link"
|
|
"quad4/reticulum-go/pkg/node"
|
|
"quad4/reticulum-go/pkg/rnsutil"
|
|
"quad4/reticulum-go/pkg/transport"
|
|
)
|
|
|
|
func RunSpeedtest(args []string, opt ...Options) int {
|
|
stdout, stderr := cliIO(opt)
|
|
fs := flag.NewFlagSet("rgospeed", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
|
|
configDir := fs.String("config", "", "path to config directory")
|
|
identityPath := fs.String("identity", "", "path to identity file (listen mode)")
|
|
listenMode := fs.Bool("l", false, "listen as speedtest server")
|
|
daemon := fs.Bool("daemon", false, "listen forever (implies -l -m, default announce every 120s)")
|
|
loopback := fs.Bool("loopback", false, "in-process pipe speedtest (CI / local smoke)")
|
|
printID := fs.Bool("p", false, "print identity and destination hash then exit")
|
|
multi := fs.Bool("m", false, "listen: serve multiple clients (default: one then exit)")
|
|
ifaceSel := fs.String("iface", "all", "interfaces to use: all, or comma-separated config names")
|
|
dataCap := fs.Int64("bytes", rlink.DefaultSpeedtestDataCap, "plaintext bytes to transfer")
|
|
minBps := fs.Float64("min-bps", 0, "fail if sustained rate is below this (0 disables; loopback default 1e6)")
|
|
timeoutSec := fs.Float64("timeout", 60, "overall timeout in seconds")
|
|
announceSec := fs.Float64("announce", 0, "listen: announce interval seconds (0 = once, <0 = never)")
|
|
jsonOut := fs.Bool("json", false, "emit JSON result lines on stdout")
|
|
quiet := fs.Bool("q", false, "suppress progress logs")
|
|
|
|
fs.Usage = func() {
|
|
fmt.Fprintf(stderr, `rgospeed - link throughput test (RNS Speedtest-style)
|
|
|
|
Modes:
|
|
-loopback in-process pipe (CI liveness)
|
|
-l listen as speedtest.server (one client then exit)
|
|
-daemon persistent server for docker / VPS (implies -l -m)
|
|
<destination_hash> connect and blast to a listening server
|
|
|
|
Usage:
|
|
reticulum-go speedtest -loopback [flags]
|
|
reticulum-go speedtest -l [flags]
|
|
reticulum-go speedtest -daemon [flags]
|
|
reticulum-go speedtest [flags] <destination_hash>
|
|
rgospeed ...
|
|
|
|
Flags:
|
|
`)
|
|
fs.PrintDefaults()
|
|
fmt.Fprintf(stderr, `
|
|
Examples:
|
|
reticulum-go speedtest -loopback
|
|
reticulum-go speedtest -daemon -iface tcp -json
|
|
reticulum-go speedtest -l -iface tcp,udp -bytes 1048576
|
|
reticulum-go speedtest -iface tcp -bytes 1048576 <server_dest_hash>
|
|
`)
|
|
}
|
|
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
|
|
if !*quiet {
|
|
debug.SetDebugLevel(debug.DebugCritical)
|
|
}
|
|
|
|
if *daemon {
|
|
*listenMode = true
|
|
*multi = true
|
|
if *announceSec == 0 {
|
|
*announceSec = 120
|
|
}
|
|
}
|
|
|
|
timeout := time.Duration(*timeoutSec * float64(time.Second))
|
|
if timeout <= 0 {
|
|
timeout = 60 * time.Second
|
|
}
|
|
|
|
spOpt := rlink.SpeedtestOptions{
|
|
DataCap: *dataCap,
|
|
Timeout: timeout,
|
|
}
|
|
useLoopback := *loopback || (fs.NArg() == 0 && !*listenMode && !*printID)
|
|
if useLoopback && *minBps == 0 {
|
|
spOpt.EnforceFloor = true
|
|
spOpt.MinBytesPerSec = rlink.DefaultSpeedtestMinBytesPerSec
|
|
} else if *minBps > 0 {
|
|
spOpt.EnforceFloor = true
|
|
spOpt.MinBytesPerSec = *minBps
|
|
}
|
|
|
|
if useLoopback {
|
|
if *listenMode {
|
|
fmt.Fprintln(stderr, "usage: speedtest -loopback [flags]")
|
|
return 2
|
|
}
|
|
return emitSpeedResult(stdout, stderr, *jsonOut, "loopback", "", spOpt, func() (rlink.SpeedtestResult, error) {
|
|
return rlink.RunLoopbackSpeedtest(spOpt)
|
|
})
|
|
}
|
|
|
|
cfg, err := rnsutil.LoadConfigDir(*configDir)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "config: %v\n", err)
|
|
return 1
|
|
}
|
|
// Speedtest must own its interfaces. Attaching to an ambient shared
|
|
// instance would skip configured UDP/TCP links.
|
|
cfg.ShareInstance = false
|
|
|
|
activeIfaces, err := rnsutil.SelectInterfaces(cfg, *ifaceSel)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "iface: %v\n", err)
|
|
return 2
|
|
}
|
|
|
|
idPath := *identityPath
|
|
if idPath == "" {
|
|
idPath = rnsutil.SpeedtestIdentityPath(rnsutil.StorageDir(cfg))
|
|
}
|
|
id, err := rnsutil.PrepareSpeedtestIdentity(idPath)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "identity: %v\n", err)
|
|
return 2
|
|
}
|
|
|
|
if *printID {
|
|
destHash := destination.Hash(id, rnsutil.SpeedtestAppName, rnsutil.SpeedtestAspect)
|
|
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Identity"), hex.EncodeToString(id.Hash()))
|
|
fmt.Fprintf(stdout, "%s : %s\n", infoMsg(stdout, "Listening on"), hex.EncodeToString(destHash))
|
|
return 0
|
|
}
|
|
|
|
n, err := node.New(cfg)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "node: %v\n", err)
|
|
return 1
|
|
}
|
|
if err := n.Start(); err != nil {
|
|
fmt.Fprintf(stderr, "start: %v\n", err)
|
|
return 1
|
|
}
|
|
defer n.Stop()
|
|
tr := n.Transport()
|
|
|
|
fmt.Fprintf(stdout, "%s interfaces=%v\n", infoMsg(stdout, "speedtest using"), activeIfaces)
|
|
|
|
switch {
|
|
case *listenMode:
|
|
return runSpeedtestListen(tr, id, spOpt, !*multi, *announceSec, *jsonOut, stdout, stderr)
|
|
default:
|
|
if fs.NArg() != 1 {
|
|
fmt.Fprintln(stderr, "usage: speedtest [flags] <destination_hash>")
|
|
fmt.Fprintln(stderr, " speedtest -l|-daemon [flags]")
|
|
fmt.Fprintln(stderr, " speedtest -loopback [flags]")
|
|
return 2
|
|
}
|
|
destHash, err := rnsutil.ParseDestHash(fs.Arg(0))
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%v\n", err)
|
|
return 1
|
|
}
|
|
return runSpeedtestClient(tr, destHash, spOpt, timeout, *jsonOut, stdout, stderr)
|
|
}
|
|
}
|
|
|
|
func runSpeedtestListen(
|
|
tr *transport.Transport,
|
|
id *identity.Identity,
|
|
spOpt rlink.SpeedtestOptions,
|
|
once bool,
|
|
announceSec float64,
|
|
jsonOut bool,
|
|
stdout, stderr io.Writer,
|
|
) int {
|
|
dest, err := destination.New(id, destination.In, destination.Single, rnsutil.SpeedtestAppName, tr, rnsutil.SpeedtestAspect)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "destination: %v\n", err)
|
|
return 1
|
|
}
|
|
dest.AcceptsLinks(true)
|
|
|
|
fmt.Fprintf(stdout, "%s %s\n", infoMsg(stdout, "Listening on"), hex.EncodeToString(dest.GetHash()))
|
|
if once {
|
|
fmt.Fprintln(stdout, infoMsg(stdout, "mode=oneshot (exit after one client)"))
|
|
} else {
|
|
fmt.Fprintln(stdout, infoMsg(stdout, "mode=daemon (serve until signal)"))
|
|
}
|
|
|
|
incoming := make(chan *rlink.Link, 1)
|
|
dest.SetLinkEstablishedCallback(func(v any) {
|
|
lnk, ok := v.(*rlink.Link)
|
|
if !ok || lnk == nil {
|
|
return
|
|
}
|
|
lnk.Start()
|
|
select {
|
|
case incoming <- lnk:
|
|
default:
|
|
fmt.Fprintln(stderr, warnMsg(stderr, "busy, rejecting extra link"))
|
|
lnk.Teardown()
|
|
}
|
|
})
|
|
|
|
if announceSec >= 0 {
|
|
if err := dest.Announce(false, nil, nil); err != nil {
|
|
fmt.Fprintf(stderr, "announce: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintln(stdout, infoMsg(stdout, "announce sent"))
|
|
}
|
|
var announceStop chan struct{}
|
|
if announceSec > 0 {
|
|
announceStop = make(chan struct{})
|
|
go func() {
|
|
t := time.NewTicker(time.Duration(announceSec * float64(time.Second)))
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-announceStop:
|
|
return
|
|
case <-t.C:
|
|
if err := dest.Announce(false, nil, nil); err != nil {
|
|
fmt.Fprintf(stderr, "announce: %v\n", err)
|
|
continue
|
|
}
|
|
fmt.Fprintln(stdout, infoMsg(stdout, "announce refreshed"))
|
|
}
|
|
}
|
|
}()
|
|
defer close(announceStop)
|
|
}
|
|
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
defer signal.Stop(sig)
|
|
|
|
for {
|
|
fmt.Fprintln(stdout, infoMsg(stdout, "Waiting for client link..."))
|
|
var lnk *rlink.Link
|
|
select {
|
|
case lnk = <-incoming:
|
|
case <-sig:
|
|
fmt.Fprintln(stdout, "interrupted")
|
|
return 130
|
|
}
|
|
|
|
ifaceName := linkIfaceName(lnk)
|
|
fmt.Fprintf(stdout, "%s iface=%s link=%s\n",
|
|
infoMsg(stdout, "Client linked, receiving..."),
|
|
ifaceName,
|
|
hex.EncodeToString(lnk.GetLinkID()),
|
|
)
|
|
recvOpt := spOpt
|
|
recvOpt.EnforceFloor = false
|
|
res, err := rlink.ReceiveOnLink(lnk, recvOpt)
|
|
if err == nil {
|
|
if ackErr := rlink.SendSpeedAck(lnk, res.BytesRecv); ackErr != nil {
|
|
fmt.Fprintf(stderr, "ack: %v\n", ackErr)
|
|
}
|
|
}
|
|
code := emitSpeedResultValues(stdout, stderr, jsonOut, "server", ifaceName, res, err)
|
|
lnk.Teardown()
|
|
if once {
|
|
return code
|
|
}
|
|
}
|
|
}
|
|
|
|
func runSpeedtestClient(
|
|
tr *transport.Transport,
|
|
destHash []byte,
|
|
spOpt rlink.SpeedtestOptions,
|
|
pathTimeout time.Duration,
|
|
jsonOut bool,
|
|
stdout, stderr io.Writer,
|
|
) int {
|
|
ctx, cancel := context.WithTimeout(context.Background(), pathTimeout)
|
|
defer cancel()
|
|
|
|
fmt.Fprintln(stdout, infoMsg(stdout, fmt.Sprintf("Path to %s...", rnsutil.PrettyHex(destHash))))
|
|
lnk, err := rnsutil.EstablishSpeedtestLink(ctx, tr, destHash)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%s: %v\n", errMsg(stderr, "link failed"), err)
|
|
return 1
|
|
}
|
|
defer lnk.Teardown()
|
|
lnk.Start()
|
|
|
|
ifaceName := linkIfaceName(lnk)
|
|
fmt.Fprintf(stdout, "%s iface=%s\n", infoMsg(stdout, "Link established, sending..."), ifaceName)
|
|
blastOpt := spOpt
|
|
blastOpt.EnforceFloor = false
|
|
if blastOpt.SendPace <= 0 {
|
|
// Pace UDP/TCP blasts so the peer socket is not overrun.
|
|
blastOpt.SendPace = 100 * time.Microsecond
|
|
}
|
|
res, err := rlink.BlastOnLink(lnk, blastOpt)
|
|
if err != nil {
|
|
return emitSpeedResultValues(stdout, stderr, jsonOut, "client", ifaceName, res, err)
|
|
}
|
|
|
|
ackTimeout := min(spOpt.Timeout, 30*time.Second)
|
|
confirmed, ackErr := rlink.WaitSpeedAck(lnk, ackTimeout)
|
|
if ackErr != nil {
|
|
fmt.Fprintf(stderr, "%s: %v (TX stats still valid)\n", warnMsg(stderr, "no server ack"), ackErr)
|
|
} else {
|
|
res.ConfirmedRecv = confirmed
|
|
res.BytesRecv = confirmed
|
|
}
|
|
|
|
if spOpt.EnforceFloor && res.BytesPerSec < spOpt.MinBytesPerSec {
|
|
err = fmt.Errorf("speedtest: %.0f B/s below floor %.0f B/s", res.BytesPerSec, spOpt.MinBytesPerSec)
|
|
}
|
|
return emitSpeedResultValues(stdout, stderr, jsonOut, "client", ifaceName, res, err)
|
|
}
|
|
|
|
func linkIfaceName(lnk *rlink.Link) string {
|
|
if lnk == nil {
|
|
return ""
|
|
}
|
|
ni := lnk.LinkedNetworkInterface()
|
|
if ni == nil {
|
|
return ""
|
|
}
|
|
return ni.GetName()
|
|
}
|
|
|
|
func emitSpeedResult(stdout, stderr io.Writer, jsonOut bool, mode, iface string, spOpt rlink.SpeedtestOptions, run func() (rlink.SpeedtestResult, error)) int {
|
|
res, err := run()
|
|
return emitSpeedResultValues(stdout, stderr, jsonOut, mode, iface, res, err)
|
|
}
|
|
|
|
func emitSpeedResultValues(stdout, stderr io.Writer, jsonOut bool, mode, iface string, res rlink.SpeedtestResult, err error) int {
|
|
out := map[string]any{
|
|
"mode": mode,
|
|
"bytes_sent": res.BytesSent,
|
|
"bytes_recv": res.BytesRecv,
|
|
"duration_sec": res.Duration.Seconds(),
|
|
"bytes_per_sec": res.BytesPerSec,
|
|
"mdu": res.MDU,
|
|
"ok": err == nil,
|
|
}
|
|
if iface != "" {
|
|
out["interface"] = iface
|
|
}
|
|
if res.ConfirmedRecv > 0 {
|
|
out["confirmed_recv"] = res.ConfirmedRecv
|
|
}
|
|
if err != nil {
|
|
out["error"] = err.Error()
|
|
}
|
|
|
|
// Always emit a one-line summary on stdout for docker logs / journald.
|
|
ok := err == nil
|
|
fmt.Fprintf(stdout, "speedtest_result mode=%s ok=%t iface=%q bps=%.0f sent=%d recv=%d duration=%s mdu=%d\n",
|
|
mode, ok, iface, res.BytesPerSec, res.BytesSent, res.BytesRecv, res.Duration.Round(time.Millisecond), res.MDU)
|
|
|
|
if jsonOut {
|
|
enc := json.NewEncoder(stdout)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(out)
|
|
} else {
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "%s: %v\n", errMsg(stderr, "speedtest failed"), err)
|
|
} else {
|
|
fmt.Fprintf(stdout, "%s (%s)\n", okMsg(stdout, "speedtest ok"), mode)
|
|
}
|
|
if iface != "" {
|
|
fmt.Fprintf(stdout, " interface : %s\n", iface)
|
|
}
|
|
if res.BytesSent > 0 {
|
|
fmt.Fprintf(stdout, " sent : %d bytes\n", res.BytesSent)
|
|
}
|
|
if res.BytesRecv > 0 {
|
|
fmt.Fprintf(stdout, " received : %d bytes\n", res.BytesRecv)
|
|
}
|
|
if res.ConfirmedRecv > 0 && mode == "client" {
|
|
fmt.Fprintf(stdout, " confirmed : %d bytes (server ack)\n", res.ConfirmedRecv)
|
|
}
|
|
fmt.Fprintf(stdout, " mdu : %d\n", res.MDU)
|
|
fmt.Fprintf(stdout, " duration : %s\n", res.Duration.Round(time.Millisecond))
|
|
fmt.Fprintf(stdout, " throughput : %.2f MB/s (%.0f B/s)\n", res.BytesPerSec/1e6, res.BytesPerSec)
|
|
}
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|