diff --git a/.gitignore b/.gitignore index 9db059d..003c6f9 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,5 @@ fabric.properties # Dependency directories (remove the comment below to include it) # vendor/ + +dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..a0ee28a --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,87 @@ +version: 2 +project_name: "librespeed-cli" +#dist: ./out +before: + hooks: + - go mod download +builds: + - main: ./main.go + id: upx + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -w -s -X "librespeed-cli/defs.ProgName={{ .ProjectName }}" -X "librespeed-cli/defs.ProgVersion=v{{ .Version }}" -X "librespeed-cli/defs.BuildDate={{ .Date }}" + goos: + - linux + - darwin + - freebsd + goarch: + - "386" + - amd64 + - arm + - arm64 + goarm: + - "5" + - "6" + - "7" + ignore: + - goos: darwin + goarch: "386" + - goos: darwin + goarch: arm64 + hooks: + post: + - ./upx.sh -9 "{{ .Path }}" + - main: ./main.go + id: no-upx + env: + - CGO_ENABLED=0 + flags: + - -trimpath + ldflags: + - -w -s -X "librespeed-cli/defs.ProgName={{ .ProjectName }}" -X "librespeed-cli/defs.ProgVersion=v{{ .Version }}" -X "librespeed-cli/defs.BuildDate={{ .Date }}" + goos: + - linux + - windows + - darwin + goarch: + - "386" + - amd64 + - arm64 + - mips + - mipsle + - mips64 + - mips64le + - riscv64 + gomips: + - hardfloat + - softfloat + ignore: + - goos: linux + goarch: "386" + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: "386" + - goos: darwin + goarch: amd64 +archives: + - format_overrides: + - goos: windows + formats: ['zip'] + files: + - LICENSE +checksum: + name_template: "checksums.txt" +changelog: + disable: false + sort: asc +release: + github: + owner: librespeed + name: speedtest-cli + disable: false diff --git a/README.md b/README.md index a28a594..d3945cf 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![LibreSpeed Logo](https://github.com/librespeed/speedtest/blob/master/.logo/logo3.png?raw=true) # LibreSpeed command line tool -Don't have a GUI but wants to use LibreSpeed servers to test your Internet speed? 🚀 +Don't have a GUI but want to use LibreSpeed servers to test your Internet speed? 🚀 `librespeed-cli` comes to rescue! @@ -22,14 +22,19 @@ This is a command line interface for LibreSpeed speed test backends, written in [![asciicast](https://asciinema.org/a/J17bUAilWI3qR12JyhfGvPwu2.svg)](https://asciinema.org/a/J17bUAilWI3qR12JyhfGvPwu2) ## Requirements for compiling -- Go 1.14 +- Go 1.18+ ## Runtime requirements - Any [Go supported platforms](https://github.com/golang/go/wiki/MinimumRequirements) +## Use prebuilt binaries + +If you don't want to build `librespeed-cli` yourself, you can find different binaries compiled for various platforms in +the [releases page](https://github.com/librespeed/speedtest-cli/releases). + ## Building `librespeed-cli` -1. First, you'll have to install Go. For Windows users, [you can download an installer from golang.org](https://golang.org/dl/). +1. First, you'll have to install Go (at least version 1.11). For Windows users, [you can download an installer from golang.org](https://golang.org/dl/). For Linux users, you can use either the archive from golang.org, or install from your distribution's package manager. For example, Arch Linux: @@ -37,7 +42,7 @@ For Linux users, you can use either the archive from golang.org, or install from ```shell script # pacman -S go ``` - + 2. Then, clone the repository: ```shell script @@ -54,7 +59,10 @@ can now proceed to build `librespeed-cli` with the build script: If you want to build for another operating system or system architecture, use the `GOOS` and `GOARCH` environment variables. Run `go tool dist list` to get a list of possible combinations of `GOOS` and `GOARCH`. - + + Note: Technically, the CLI can be compiled with older Go versions that support Go modules, with `GO111MODULE=on` + set. If you're compiling with an older Go runtime, you might have to change the Go version in `go.mod`. + ```shell script # Let's say we're building for 64-bit Windows on Linux $ GOOS=windows GOARCH=amd64 ./build.sh @@ -66,18 +74,68 @@ can now proceed to build `librespeed-cli` with the build script: $ ls out librespeed-cli-windows-amd64.exe ``` - + 5. Now you can use the `librespeed-cli` and test your Internet speed! -## Install from AUR/Homebrew +## Install from AUR -TODO +To install `librespeed-cli` from AUR, use your favorite AUR helper and install package `librespeed-cli-bin` + +```shell script +$ yay librespeed-cli-bin +``` + +... or, clone it and build it yourself: + +```shell script +$ git clone https://aur.archlinux.org/librespeed-cli-bin.git +$ cd librespeed-cli-bin +$ makepkg -si +``` + +## Install from Homebrew + +See the [librespeed-cli Homebrew tap](https://github.com/librespeed/homebrew-tap#setup). + +## Install on Windows + +If you have either [Scoop](https://scoop.sh/) or [Chocolatey](https://chocolatey.org/) installed you can use one of the following commands: + +- Scoop (ensure you have the `extras` bucket added): + ``` + > scoop install librespeed-cli + ``` + +- Chocolatey: + ``` + > choco install librespeed-cli + ``` + +## Container Image + +You can run `librespeed-cli` in a container. + +1. Build the container image: + + ```shell script + docker build -t librespeed-cli:latest . + ``` + +2. Run the container: + + ```shell script + docker run --rm --name librespeed-cli librespeed-cli:latest + # With options + docker run --rm --name librespeed-cli librespeed-cli:latest --telemetry-level disabled --no-upload + # To avoid "Failed to ping target host: socket: permission denied" errors when using --verbose + docker run --rm --name librespeed-cli --sysctl net.ipv4.ping_group_range="0 2147483647" librespeed-cli:latest --verbose + ``` ## Usage You can see the full list of supported options with `librespeed-cli -h`: -```shell script +``` $ librespeed-cli -h NAME: librespeed-cli - Test your Internet speed with LibreSpeed 🚀 @@ -88,50 +146,67 @@ USAGE: GLOBAL OPTIONS: --help, -h show help (default: false) --version Show the version number and exit (default: false) + --ipv4, -4 Force IPv4 only (default: false) + --ipv6, -6 Force IPv6 only (default: false) --no-download Do not perform download test (default: false) --no-upload Do not perform upload test (default: false) + --no-icmp Do not use ICMP ping. ICMP doesn't work well under Linux + at this moment, so you might want to disable it (default: false) + --concurrent value Concurrent HTTP requests being made (default: 3) --bytes Display values in bytes instead of bits. Does not affect the image generated by --share, nor output from --json or --csv (default: false) + --mebibytes Use 1024 bytes as 1 kilobyte instead of 1000 (default: false) --distance value Change distance unit shown in ISP info, use 'mi' for miles, 'km' for kilometres, 'NM' for nautical miles (default: "km") --share Generate and provide a URL to the LibreSpeed.org share results image, not displayed with --csv (default: false) --simple Suppress verbose output, only show basic information - (default: false) + (default: false) --csv Suppress verbose output, only show basic information in CSV format. Speeds listed in bit/s and not affected by --bytes - (default: false) + (default: false) --csv-delimiter CSV_DELIMITER Single character delimiter (CSV_DELIMITER) to use in CSV output. (default: ",") --csv-header Print CSV headers (default: false) --json Suppress verbose output, only show basic information in JSON format. Speeds listed in bit/s and not - affected by --bytes (default: false) + affected by --bytes (default: false) --list Display a list of LibreSpeed.org servers (default: false) --server SERVER Specify a SERVER ID to test against. Can be supplied multiple times. Cannot be used with --exclude --exclude EXCLUDE EXCLUDE a server from selection. Can be supplied multiple times. Cannot be used with --server --server-json value Use an alternative server list from remote JSON file - --local-json value Use an alternative server list from local JSON file - --source SOURCE SOURCE IP address to bind to + --local-json value Use an alternative server list from local JSON file, + or read from stdin with "--local-json -". + --source SOURCE SOURCE IP address to bind to. Incompatible with --interface. + --interface INTERFACE The name of the network interface to bind to. Example: "enp0s3". + Not supported on Windows and incompatible with --source. + Implies --no-icmp. --timeout TIMEOUT HTTP TIMEOUT in seconds. (default: 15) + --duration value Upload and download test duration in seconds (default: 15) + --chunks value Chunks to download from server, chunk size depends on server configuration (default: 100) + --upload-size value Size of payload being uploaded in KiB (default: 1024) --secure Use HTTPS instead of HTTP when communicating with LibreSpeed.org operated servers (default: false) + --ca-cert value Use the specified CA certificate PEM bundle file instead + of the system certificate trust store + --skip-cert-verify Skip verifying SSL certificate for HTTPS connections (self-signed certs) (default: false) --no-pre-allocate Do not pre allocate upload data. Pre allocation is enabled by default to improve upload performance. To support systems with insufficient memory, use this option to avoid out of memory errors (default: false) --telemetry-json value Load telemetry server settings from a JSON file. This options overrides --telemetry-level, --telemetry-server, - --telemetry-path, and --telemetry-share + --telemetry-path, and --telemetry-share. Implies --share --telemetry-level value Set telemetry data verbosity, available values are: - disabled, basic, full, debug - --telemetry-server value Set the telemetry server base URL - --telemetry-path value Set the telemetry upload path - --telemetry-share value Set the telemetry share link path - --telemetry-extra value Send a custom message along with the telemetry results + disabled, basic, full, debug. Implies --share + --telemetry-server value Set the telemetry server base URL. Implies --share + --telemetry-path value Set the telemetry upload path. Implies --share + --telemetry-share value Set the telemetry share link path. Implies --share + --telemetry-extra value Send a custom message along with the telemetry results. + Implies --share ``` ## Use a custom backend server list @@ -141,6 +216,7 @@ locally via `--local-json`). The format is as below: ```json [ { + "id": 1, "name": "PHP Backend", "server": "https://example.com/", "dlURL": "garbage.php", @@ -149,6 +225,7 @@ locally via `--local-json`). The format is as below: "getIpURL": "getIP.php" }, { + "id": 2, "name": "Go Backend", "server": "http://example.com/speedtest/", "dlURL": "garbage", @@ -159,11 +236,15 @@ locally via `--local-json`). The format is as below: ] ``` +The `--local-json` option can also read from `stdin`: + +`echo '[{"id": 1,"name": "a","server": "https://speedtest.example.com/","dlURL": "garbage.php","ulURL": "empty.php","pingURL": "empty.php","getIpURL": "getIP.php"}]' | librespeed-cli --local-json - ` + As you can see in the example, all servers have their schemes defined. In case of undefined scheme (e.g. `//example.com`), `librespeed-cli` will use `http` by default, or `https` when the `--secure` option is enabled. ## Use a custom telemetry server -By default, the telemetry result will be sent to `librespeed.org` by default. You can also customize your telemetry settings +By default, the telemetry result will be sent to `librespeed.org`. You can also customize your telemetry settings via the `--telemetry` prefixed options. In order to load a custom telemetry endpoint configuration, you'll have to use the `--telemetry-json` option to specify a local JSON file containing the configuration bits. The format is as below: diff --git a/build.sh b/build.sh index ca51d85..b06797a 100755 --- a/build.sh +++ b/build.sh @@ -10,9 +10,10 @@ CURRENT_DIR=$(pwd) OUT_DIR=${CURRENT_DIR}/out PROGNAME="librespeed-cli" +DEFS_PATH="github.com/librespeed/speedtest-cli" BINARY=${PROGNAME}-$(go env GOOS)-$(go env GOARCH) BUILD_DATE=$(date -u "+%Y-%m-%d %H:%M:%S %Z") -LDFLAGS="-w -s -X \"librespeed-cli/defs.ProgName=${PROGNAME}\" -X \"librespeed-cli/defs.ProgVersion=${PROGVER}\" -X \"librespeed-cli/defs.BuildDate=${BUILD_DATE}\"" +LDFLAGS="-w -s -X \"${DEFS_PATH}/defs.ProgName=${PROGNAME}\" -X \"${DEFS_PATH}/defs.ProgVersion=${PROGVER}\" -X \"${DEFS_PATH}/defs.BuildDate=${BUILD_DATE}\"" if [[ -n "${GOARM}" ]] && [[ "${GOARM}" -gt 0 ]]; then BINARY=${BINARY}v${GOARM} diff --git a/defs/bytes_counter.go b/defs/bytes_counter.go index 9894d1b..64c155b 100644 --- a/defs/bytes_counter.go +++ b/defs/bytes_counter.go @@ -6,21 +6,35 @@ import ( "fmt" "io" "log" + "sync" "time" ) // BytesCounter implements io.Reader and io.Writer interface, for counting bytes being read/written in HTTP requests type BytesCounter struct { - start time.Time - total int - payload []byte - reader io.ReadSeeker + start time.Time + pos int + total uint64 + payload []byte + reader io.ReadSeeker + mebi bool + uploadSize int + + lock *sync.Mutex +} + +func NewCounter() *BytesCounter { + return &BytesCounter{ + lock: &sync.Mutex{}, + } } // Write implements io.Writer func (c *BytesCounter) Write(p []byte) (int, error) { n := len(p) - c.total += n + c.lock.Lock() + c.total += uint64(n) + c.lock.Unlock() return n, nil } @@ -28,43 +42,71 @@ func (c *BytesCounter) Write(p []byte) (int, error) { // Read implements io.Reader func (c *BytesCounter) Read(p []byte) (int, error) { n, err := c.reader.Read(p) - c.total += n + c.lock.Lock() + c.total += uint64(n) + c.pos += n + if c.pos == c.uploadSize { + c.resetReader() + } + c.lock.Unlock() return n, err } -// Average returns the average bytes/second -func (c *BytesCounter) Average() float64 { +// SetBase sets the base for dividing bytes into megabyte or mebibyte +func (c *BytesCounter) SetMebi(mebi bool) { + c.mebi = mebi +} + +// SetUploadSize sets the size of payload being uploaded +func (c *BytesCounter) SetUploadSize(uploadSize int) { + c.uploadSize = uploadSize * 1024 +} + +// AvgBytes returns the average bytes/second +func (c *BytesCounter) AvgBytes() float64 { return float64(c.total) / time.Now().Sub(c.start).Seconds() } -func (c *BytesCounter) AvgMbits() string { - return fmt.Sprintf("%.02f Mbps", c.Average()/131072) +// AvgMbps returns the average mbits/second +func (c *BytesCounter) AvgMbps() float64 { + var base float64 = 125000 + if c.mebi { + base = 131072 + } + return c.AvgBytes() / base } +// AvgHumanize returns the average bytes/kilobytes/megabytes/gigabytes (or bytes/kibibytes/mebibytes/gibibytes) per second func (c *BytesCounter) AvgHumanize() string { - val := c.Average() + val := c.AvgBytes() - if val < 1024 { + var base float64 = 1000 + if c.mebi { + base = 1024 + } + + if val < base { return fmt.Sprintf("%.2f bytes/s", val) - } else if val/1024 < 1024 { - return fmt.Sprintf("%.2f KB/s", val/1024) - } else if val/1024/1024 < 1024 { - return fmt.Sprintf("%.2f MB/s", val/1024/1024) + } else if val/base < base { + return fmt.Sprintf("%.2f KB/s", val/base) + } else if val/base/base < base { + return fmt.Sprintf("%.2f MB/s", val/base/base) } else { - return fmt.Sprintf("%.2f GB/s", val/1024/1024/1024) + return fmt.Sprintf("%.2f GB/s", val/base/base/base) } } // GenerateBlob generates a random byte array of `uploadSize` in the `payload` field, and sets the `reader` field to // read from it func (c *BytesCounter) GenerateBlob() { - c.payload = getRandomData(uploadSize) + c.payload = getRandomData(c.uploadSize) c.reader = bytes.NewReader(c.payload) } -// ResetReader resets the `reader` field to 0 position -func (c *BytesCounter) ResetReader() (int64, error) { +// resetReader resets the `reader` field to 0 position +func (c *BytesCounter) resetReader() (int64, error) { + c.pos = 0 return c.reader.Seek(0, 0) } @@ -74,7 +116,7 @@ func (c *BytesCounter) Start() { } // Total returns the total bytes read/written -func (c *BytesCounter) Total() int { +func (c *BytesCounter) Total() uint64 { return c.total } diff --git a/defs/defs.go b/defs/defs.go index 0197cfa..ad1cb46 100644 --- a/defs/defs.go +++ b/defs/defs.go @@ -1,12 +1,5 @@ package defs -const ( - // chunks to download in download test - downloadChunks = 100 - // payload size per upload request - uploadSize = 1024 * 1024 -) - var ( // values to be filled in by build script BuildDate string diff --git a/defs/options.go b/defs/options.go index f637490..84662b5 100644 --- a/defs/options.go +++ b/defs/options.go @@ -2,9 +2,16 @@ package defs const ( OptionHelp = "help" + OptionIPv4 = "ipv4" + OptionIPv4Alt = "4" + OptionIPv6 = "ipv6" + OptionIPv6Alt = "6" OptionNoDownload = "no-download" OptionNoUpload = "no-upload" + OptionNoICMP = "no-icmp" + OptionConcurrent = "concurrent" OptionBytes = "bytes" + OptionMebiBytes = "mebibytes" OptionDistance = "distance" OptionShare = "share" OptionSimple = "simple" @@ -17,8 +24,14 @@ const ( OptionExclude = "exclude" OptionServerJSON = "server-json" OptionSource = "source" + OptionInterface = "interface" OptionTimeout = "timeout" + OptionChunks = "chunks" + OptionUploadSize = "upload-size" + OptionDuration = "duration" OptionSecure = "secure" + OptionCACert = "ca-cert" + OptionSkipCertVerify = "skip-cert-verify" OptionNoPreAllocate = "no-pre-allocate" OptionVersion = "version" OptionLocalJSON = "local-json" @@ -29,4 +42,5 @@ const ( OptionTelemetryPath = "telemetry-path" OptionTelemetryShare = "telemetry-share" OptionTelemetryExtra = "telemetry-extra" + OptionFwmark = "fwmark" ) diff --git a/defs/server.go b/defs/server.go index 48f54d5..ed2ffad 100644 --- a/defs/server.go +++ b/defs/server.go @@ -16,20 +16,24 @@ import ( "time" "github.com/briandowns/spinner" + "github.com/go-ping/ping" log "github.com/sirupsen/logrus" - "github.com/sparrc/go-ping" ) // Server represents a speed test server type Server struct { - Name string `json:"name"` - Server string `json:"server"` - DownloadURL string `json:"dlURL"` - UploadURL string `json:"ulURL"` - PingURL string `json:"pingURL"` - GetIPURL string `json:"getIpURL"` - ICMPFail bool `json:"-"` - TLog TelemetryLog `json:"-"` + ID int `json:"id"` + Name string `json:"name"` + Server string `json:"server"` + DownloadURL string `json:"dlURL"` + UploadURL string `json:"ulURL"` + PingURL string `json:"pingURL"` + GetIPURL string `json:"getIpURL"` + SponsorName string `json:"sponsorName"` + SponsorURL string `json:"sponsorURL"` + + NoICMP bool `json:"-"` + TLog TelemetryLog `json:"-"` } // IsUp checks the speed test backend is up by accessing the ping URL @@ -41,25 +45,37 @@ func (s *Server) IsUp() bool { u, _ := s.GetURL() u.Path = path.Join(u.Path, s.PingURL) - resp, err := http.Get(u.String()) + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { + log.Debugf("Failed when creating HTTP request: %s", err) + return false + } + req.Header.Set("User-Agent", UserAgent) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Debugf("Error checking for server status: %s", err) return false } defer resp.Body.Close() b, _ := ioutil.ReadAll(resp.Body) + if len(b) > 0 { + log.Debugf("Failed when parsing get IP result: %s", b) + } // only return online if the ping URL returns nothing and 200 - return len(b) == 0 && resp.StatusCode == http.StatusOK + return resp.StatusCode == http.StatusOK } // ICMPPingAndJitter pings the server via ICMP echos and calculate the average ping and jitter -func (s *Server) ICMPPingAndJitter(count int, srcIp string) (float64, float64, error) { +func (s *Server) ICMPPingAndJitter(count int, srcIp, network string) (float64, float64, error) { t := time.Now() defer func() { s.TLog.Logf("ICMP ping took %s", time.Now().Sub(t).String()) }() - if s.ICMPFail { - log.Debug("ICMP ping failed already, using HTTP ping") + if s.NoICMP { + log.Debugf("Skipping ICMP for server %s, will use HTTP ping", s.Name) return s.PingAndJitter(count + 2) } @@ -68,12 +84,9 @@ func (s *Server) ICMPPingAndJitter(count int, srcIp string) (float64, float64, e log.Debugf("Failed to get server URL: %s", err) return 0, 0, err } - p, err := ping.NewPinger(u.Hostname()) - if err != nil { - log.Debugf("Failed to initialize pinger: %s", err) - return 0, 0, err - } + p := ping.New(u.Hostname()) + p.SetNetwork(network) p.Count = count p.Timeout = time.Duration(count) * time.Second if srcIp != "" { @@ -106,7 +119,7 @@ func (s *Server) ICMPPingAndJitter(count int, srcIp string) (float64, float64, e } if len(stats.Rtts) == 0 { - s.ICMPFail = true + s.NoICMP = true log.Debugf("No ICMP pings returned for server %s (%s), trying TCP ping", s.Name, u.Hostname()) return s.PingAndJitter(count + 2) } @@ -114,7 +127,7 @@ func (s *Server) ICMPPingAndJitter(count int, srcIp string) (float64, float64, e return float64(stats.AvgRtt.Milliseconds()), jitter, nil } -// ICMPPingAndJitter pings the server via accessing ping URL and calculate the average ping and jitter +// PingAndJitter pings the server via accessing ping URL and calculate the average ping and jitter func (s *Server) PingAndJitter(count int) (float64, float64, error) { t := time.Now() defer func() { @@ -157,9 +170,9 @@ func (s *Server) PingAndJitter(count int) (float64, float64, error) { } var lastPing, jitter float64 - for idx, ping := range pings { + for idx, p := range pings { if idx != 0 { - instJitter := math.Abs(lastPing - ping) + instJitter := math.Abs(lastPing - p) if idx > 1 { if jitter > instJitter { jitter = jitter*0.7 + instJitter*0.3 @@ -168,20 +181,21 @@ func (s *Server) PingAndJitter(count int) (float64, float64, error) { } } } - lastPing = ping + lastPing = p } return getAvg(pings), jitter, nil } // Download performs the actual download test -func (s *Server) Download(silent bool, useBytes bool) (float64, int, error) { +func (s *Server) Download(silent bool, useBytes, useMebi bool, requests int, chunks int, duration time.Duration) (float64, uint64, error) { t := time.Now() defer func() { s.TLog.Logf("Download took %s", time.Now().Sub(t).String()) }() - counter := &BytesCounter{} + counter := NewCounter() + counter.SetMebi(useMebi) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -199,12 +213,12 @@ func (s *Server) Download(silent bool, useBytes bool) (float64, int, error) { return 0, 0, err } q := req.URL.Query() - q.Set("ckSize", strconv.Itoa(downloadChunks)) + q.Set("ckSize", strconv.Itoa(chunks)) req.URL.RawQuery = q.Encode() req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept-Encoding", "identity") - downloadDone := make(chan struct{}) + downloadDone := make(chan struct{}, requests) doDownload := func() { resp, err := http.DefaultClient.Do(req) @@ -214,7 +228,7 @@ func (s *Server) Download(silent bool, useBytes bool) (float64, int, error) { defer resp.Body.Close() if _, err = io.Copy(ioutil.Discard, io.TeeReader(resp.Body, counter)); err != nil { - if !errors.Is(err, context.Canceled) { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { log.Debugf("Failed when reading HTTP response: %s", err) } } @@ -231,7 +245,7 @@ func (s *Server) Download(silent bool, useBytes bool) (float64, int, error) { if useBytes { s.Suffix = fmt.Sprintf(" %s", counter.AvgHumanize()) } else { - s.Suffix = fmt.Sprintf(" %s", counter.AvgMbits()) + s.Suffix = fmt.Sprintf(" %.2f Mbps", counter.AvgMbps()) } } @@ -240,14 +254,17 @@ func (s *Server) Download(silent bool, useBytes bool) (float64, int, error) { if useBytes { pb.FinalMSG = fmt.Sprintf("Download rate:\t%s\n", counter.AvgHumanize()) } else { - pb.FinalMSG = fmt.Sprintf("Download rate:\t%s\n", counter.AvgMbits()) + pb.FinalMSG = fmt.Sprintf("Download rate:\t%.2f Mbps\n", counter.AvgMbps()) } pb.Stop() }() } - go doDownload() - timeout := time.After(15 * time.Second) + for i := 0; i < requests; i++ { + go doDownload() + time.Sleep(200 * time.Millisecond) + } + timeout := time.After(duration) Loop: for { select { @@ -259,17 +276,19 @@ Loop: } } - return counter.Average() / 131072, counter.Total(), nil + return counter.AvgMbps(), counter.Total(), nil } // Upload performs the actual upload test -func (s *Server) Upload(noPrealloc, silent, useBytes bool) (float64, int, error) { +func (s *Server) Upload(noPrealloc, silent, useBytes, useMebi bool, requests int, uploadSize int, duration time.Duration) (float64, uint64, error) { t := time.Now() defer func() { s.TLog.Logf("Upload took %s", time.Now().Sub(t).String()) }() - counter := &BytesCounter{} + counter := NewCounter() + counter.SetMebi(useMebi) + counter.SetUploadSize(uploadSize) if noPrealloc { log.Info("Pre-allocation is disabled, performance might be lower!") @@ -295,11 +314,11 @@ func (s *Server) Upload(noPrealloc, silent, useBytes bool) (float64, int, error) req.Header.Set("User-Agent", UserAgent) req.Header.Set("Accept-Encoding", "identity") - uploadDone := make(chan struct{}) + uploadDone := make(chan struct{}, requests) doUpload := func() { resp, err := http.DefaultClient.Do(req) - if err != nil && !errors.Is(err, context.Canceled) { + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { log.Debugf("Failed when making HTTP request: %s", err) } else if err == nil { defer resp.Body.Close() @@ -307,7 +326,6 @@ func (s *Server) Upload(noPrealloc, silent, useBytes bool) (float64, int, error) log.Debugf("Failed when reading HTTP response: %s", err) } - counter.ResetReader() uploadDone <- struct{}{} } } @@ -320,7 +338,7 @@ func (s *Server) Upload(noPrealloc, silent, useBytes bool) (float64, int, error) if useBytes { s.Suffix = fmt.Sprintf(" %s", counter.AvgHumanize()) } else { - s.Suffix = fmt.Sprintf(" %s", counter.AvgMbits()) + s.Suffix = fmt.Sprintf(" %.2f Mbps", counter.AvgMbps()) } } @@ -329,14 +347,17 @@ func (s *Server) Upload(noPrealloc, silent, useBytes bool) (float64, int, error) if useBytes { pb.FinalMSG = fmt.Sprintf("Upload rate:\t%s\n", counter.AvgHumanize()) } else { - pb.FinalMSG = fmt.Sprintf("Upload rate:\t%s\n", counter.AvgMbits()) + pb.FinalMSG = fmt.Sprintf("Upload rate:\t%.2f Mbps\n", counter.AvgMbps()) } pb.Stop() }() } - go doUpload() - timeout := time.After(15 * time.Second) + for i := 0; i < requests; i++ { + go doUpload() + time.Sleep(200 * time.Millisecond) + } + timeout := time.After(duration) Loop: for { select { @@ -348,7 +369,7 @@ Loop: } } - return counter.Average() / 131072, counter.Total(), nil + return counter.AvgMbps(), counter.Total(), nil } // GetIPInfo accesses the backend's getIP.php endpoint and get current client's IP information @@ -375,6 +396,8 @@ func (s *Server) GetIPInfo(distanceUnit string) (*GetIPResult, error) { log.Debugf("Failed when creating HTTP request: %s", err) return nil, err } + req.Header.Set("User-Agent", UserAgent) + resp, err := http.DefaultClient.Do(req) if err != nil { log.Debugf("Failed when making HTTP request: %s", err) @@ -392,6 +415,7 @@ func (s *Server) GetIPInfo(distanceUnit string) (*GetIPResult, error) { if err := json.Unmarshal(b, &ipInfo); err != nil { log.Debugf("Failed when parsing get IP result: %s", err) log.Debugf("Received payload: %s", b) + ipInfo.ProcessedString = string(b[:]) } } @@ -412,3 +436,24 @@ func (s *Server) GetURL() (*url.URL, error) { } return u, nil } + +// Sponsor returns the sponsor's info +func (s *Server) Sponsor() string { + var sponsorMsg string + if s.SponsorName != "" { + sponsorMsg += s.SponsorName + + if s.SponsorURL != "" { + su, err := url.Parse(s.SponsorURL) + if err != nil { + log.Debugf("Sponsor URL is invalid: %s", s.SponsorURL) + } else { + if su.Scheme == "" { + su.Scheme = "https" + } + sponsorMsg += " @ " + su.String() + } + } + } + return sponsorMsg +} diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..944de29 --- /dev/null +++ b/dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.20.3-alpine as builder + +RUN apk add --no-cache bash upx + +# Set working directory +WORKDIR /usr/src/librespeed-cli + +# Copy librespeed-cli +COPY . . + +# Build librespeed-cli +RUN ./build.sh + +FROM alpine:3.17 + +# Copy librespeed-cli binary +COPY --from=builder /usr/src/librespeed-cli/out/librespeed-cli* /bin/librespeed-cli + +CMD ["/bin/librespeed-cli"] diff --git a/go.mod b/go.mod index 373c368..da5d4ff 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,27 @@ -module librespeed-cli +module github.com/librespeed/speedtest-cli -go 1.14 +go 1.23.0 -replace github.com/sparrc/go-ping v0.0.0-20190613174326-4e5b6552494c => github.com/maddie/go-ping v0.0.0-20200305135031-f8c069280206 +toolchain go1.24.2 require ( - github.com/briandowns/spinner v1.9.0 - github.com/gocarina/gocsv v0.0.0-20200302151839-87c60d755c58 - github.com/sirupsen/logrus v1.4.2 - github.com/sparrc/go-ping v0.0.0-20190613174326-4e5b6552494c - github.com/stretchr/testify v1.3.0 // indirect - github.com/urfave/cli/v2 v2.1.1 - golang.org/x/net v0.0.0-20200226121028-0de0cce0169b // indirect + github.com/briandowns/spinner v1.23.1 + github.com/go-ping/ping v1.2.0 + github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 + github.com/sirupsen/logrus v1.9.3 + github.com/urfave/cli/v2 v2.27.4 + golang.org/x/sys v0.33.0 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/fatih/color v1.17.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/term v0.32.0 // indirect ) diff --git a/go.sum b/go.sum index f9439a2..51dbae2 100644 --- a/go.sum +++ b/go.sum @@ -1,48 +1,56 @@ -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/briandowns/spinner v1.9.0 h1:+OMAisemaHar1hjuJ3Z2hIvNhQl9Y7GLPWUwwz2Pxo8= -github.com/briandowns/spinner v1.9.0/go.mod h1://Zf9tMcxfRUA36V23M6YGEAv+kECGfvpnLTnb8n4XQ= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= -github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/briandowns/spinner v1.23.1 h1:t5fDPmScwUjozhDj4FA46p5acZWIPXYE30qW2Ptu650= +github.com/briandowns/spinner v1.23.1/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/gocarina/gocsv v0.0.0-20200302151839-87c60d755c58 h1:rRQm5os6ffGTukb42ZgKWOFfdFrocrNkLcjPYRP1tm0= -github.com/gocarina/gocsv v0.0.0-20200302151839-87c60d755c58/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= -github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/maddie/go-ping v0.0.0-20200305135031-f8c069280206 h1:wcRUWd5aN1arYHnxsAXx56VEV9RKQxVay24fKBv0s8M= -github.com/maddie/go-ping v0.0.0-20200305135031-f8c069280206/go.mod h1:IYhVIzcOSIO1fLDdEz4JFidYhLPmmW3dchKuengmL9s= -github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/go-ping/ping v1.2.0 h1:vsJ8slZBZAXNCK4dPcI2PEE9eM9n9RbXbGouVQ/Y4yQ= +github.com/go-ping/ping v1.2.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ= +github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= -github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sparrc/go-ping v0.0.0-20190613174326-4e5b6552494c h1:gqEdF4VwBu3lTKGHS9rXE9x1/pEaSwCXRLOZRF6qtlw= -github.com/sparrc/go-ping v0.0.0-20190613174326-4e5b6552494c/go.mod h1:eMyUVp6f/5jnzM+3zahzl7q6UXLbgSc3MKg/+ow9QW0= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= -github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= +github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index cc93ab7..cc948cc 100644 --- a/main.go +++ b/main.go @@ -6,8 +6,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" - "librespeed-cli/defs" - "librespeed-cli/speedtest" + "github.com/librespeed/speedtest-cli/defs" + "github.com/librespeed/speedtest-cli/speedtest" ) // init sets up the essential bits on start up @@ -20,6 +20,7 @@ func init() { // warn level is for suppress modes // error level is for errors + log.SetOutput(os.Stderr) log.SetFormatter(formatter) log.SetLevel(log.InfoLevel) } @@ -28,7 +29,7 @@ func main() { // define cli options app := &cli.App{ Name: "librespeed-cli", - Usage: "Test your Internet speed with LibreSpeed 🚀", + Usage: "Test your Internet speed with LibreSpeed", Action: speedtest.SpeedTest, HideHelp: true, Flags: []cli.Flag{ @@ -37,6 +38,16 @@ func main() { Name: defs.OptionVersion, Usage: "Show the version number and exit", }, + &cli.BoolFlag{ + Name: defs.OptionIPv4, + Aliases: []string{defs.OptionIPv4Alt}, + Usage: "Force IPv4 only", + }, + &cli.BoolFlag{ + Name: defs.OptionIPv6, + Aliases: []string{defs.OptionIPv6Alt}, + Usage: "Force IPv6 only", + }, &cli.BoolFlag{ Name: defs.OptionNoDownload, Usage: "Do not perform download test", @@ -45,12 +56,26 @@ func main() { Name: defs.OptionNoUpload, Usage: "Do not perform upload test", }, + &cli.BoolFlag{ + Name: defs.OptionNoICMP, + Usage: "Do not use ICMP ping. ICMP doesn't work well under Linux\n" + + "\tat this moment, so you might want to disable it", + }, + &cli.IntFlag{ + Name: defs.OptionConcurrent, + Usage: "Concurrent HTTP requests being made", + Value: 3, + }, &cli.BoolFlag{ Name: defs.OptionBytes, Usage: "Display values in bytes instead of bits. Does not affect\n" + "\tthe image generated by --share, nor output from\n" + "\t--json or --csv", }, + &cli.BoolFlag{ + Name: defs.OptionMebiBytes, + Usage: "Use 1024 bytes as 1 kilobyte instead of 1000", + }, &cli.StringFlag{ Name: defs.OptionDistance, Usage: "Change distance unit shown in ISP info, use 'mi' for miles,\n" + @@ -85,7 +110,7 @@ func main() { Name: defs.OptionJSON, Usage: "Suppress verbose output, only show basic information\n" + "\tin JSON format. Speeds listed in bit/s and not\n" + - "\t affected by --bytes", + "\taffected by --bytes", }, &cli.BoolFlag{ Name: defs.OptionList, @@ -106,23 +131,52 @@ func main() { Usage: "Use an alternative server list from remote JSON file", }, &cli.StringFlag{ - Name: defs.OptionLocalJSON, - Usage: "Use an alternative server list from local JSON file", + Name: defs.OptionLocalJSON, + Usage: "Use an alternative server list from local JSON file,\n" + + "\tor read from stdin with \"--" + defs.OptionLocalJSON + " -\".", }, &cli.StringFlag{ Name: defs.OptionSource, Usage: "`SOURCE` IP address to bind to", }, + &cli.StringFlag{ + Name: defs.OptionInterface, + Usage: "network INTERFACE to bind to", + }, &cli.IntFlag{ Name: defs.OptionTimeout, Usage: "HTTP `TIMEOUT` in seconds.", Value: 15, }, + &cli.IntFlag{ + Name: defs.OptionDuration, + Usage: "Upload and download test duration in seconds", + Value: 15, + }, + &cli.IntFlag{ + Name: defs.OptionChunks, + Usage: "Chunks to download from server, chunk size depends on server configuration", + Value: 100, + }, + &cli.IntFlag{ + Name: defs.OptionUploadSize, + Usage: "Size of payload being uploaded in KiB", + Value: 1024, + }, &cli.BoolFlag{ Name: defs.OptionSecure, Usage: "Use HTTPS instead of HTTP when communicating with\n" + "\tLibreSpeed.org operated servers", }, + &cli.StringFlag{ + Name: defs.OptionCACert, + Usage: "Use the specified CA certificate PEM bundle file instead\n" + + "\tof the system certificate trust store", + }, + &cli.BoolFlag{ + Name: defs.OptionSkipCertVerify, + Usage: "Skip verifying SSL certificate for HTTPS connections (self-signed certs)", + }, &cli.BoolFlag{ Name: defs.OptionNoPreAllocate, Usage: "Do not pre allocate upload data. Pre allocation is\n" + @@ -140,28 +194,34 @@ func main() { Name: defs.OptionTelemetryJSON, Usage: "Load telemetry server settings from a JSON file. This\n" + "\toptions overrides --" + defs.OptionTelemetryLevel + ", --" + defs.OptionTelemetryServer + ",\n" + - "\t--" + defs.OptionTelemetryPath + ", and --" + defs.OptionTelemetryShare, + "\t--" + defs.OptionTelemetryPath + ", and --" + defs.OptionTelemetryShare + ". Implies --" + defs.OptionShare, }, &cli.StringFlag{ Name: defs.OptionTelemetryLevel, Usage: "Set telemetry data verbosity, available values are:\n" + - "\tdisabled, basic, full, debug", + "\tdisabled, basic, full, debug. Implies --" + defs.OptionShare, }, &cli.StringFlag{ Name: defs.OptionTelemetryServer, - Usage: "Set the telemetry server base URL", + Usage: "Set the telemetry server base URL. Implies --" + defs.OptionShare, }, &cli.StringFlag{ Name: defs.OptionTelemetryPath, - Usage: "Set the telemetry upload path", + Usage: "Set the telemetry upload path. Implies --" + defs.OptionShare, }, &cli.StringFlag{ Name: defs.OptionTelemetryShare, - Usage: "Set the telemetry share link path", + Usage: "Set the telemetry share link path. Implies --" + defs.OptionShare, }, &cli.StringFlag{ - Name: defs.OptionTelemetryExtra, - Usage: "Send a custom message along with the telemetry results", + Name: defs.OptionTelemetryExtra, + Usage: "Send a custom message along with the telemetry results.\n" + + "\tImplies --" + defs.OptionShare, + }, + &cli.IntFlag{ + Name: defs.OptionFwmark, + Usage: "firewall mark to set on socket.", + Value: 0, }, }, } diff --git a/report/json.go b/report/json.go index a8e7dd7..d399aed 100644 --- a/report/json.go +++ b/report/json.go @@ -3,7 +3,7 @@ package report import ( "time" - "librespeed-cli/defs" + "github.com/librespeed/speedtest-cli/defs" ) // JSONReport represents the output data fields in a JSON file @@ -11,8 +11,8 @@ type JSONReport struct { Timestamp time.Time `json:"timestamp"` Server Server `json:"server"` Client Client `json:"client"` - BytesSent int `json:"bytes_sent"` - BytesReceived int `json:"bytes_received"` + BytesSent uint64 `json:"bytes_sent"` + BytesReceived uint64 `json:"bytes_received"` Ping float64 `json:"ping"` Jitter float64 `json:"jitter"` Upload float64 `json:"upload"` diff --git a/speedtest/helper.go b/speedtest/helper.go index 951518b..1be6203 100644 --- a/speedtest/helper.go +++ b/speedtest/helper.go @@ -8,17 +8,17 @@ import ( "math" "mime/multipart" "net/http" + "os" "strconv" "strings" "time" "github.com/briandowns/spinner" "github.com/gocarina/gocsv" + "github.com/librespeed/speedtest-cli/defs" + "github.com/librespeed/speedtest-cli/report" log "github.com/sirupsen/logrus" - "github.com/urfave/cli/v2" - - "librespeed-cli/defs" - "librespeed-cli/report" +"github.com/urfave/cli/v2" ) const ( @@ -27,11 +27,14 @@ const ( ) // doSpeedTest is where the actual speed test happens -func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.TelemetryServer, silent bool) error { +func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.TelemetryServer, network string, silent bool, noICMP bool) error { if serverCount := len(servers); serverCount > 1 { log.Infof("Testing against %d servers", serverCount) } + var reps_json []report.JSONReport + var reps_csv []report.CSVReport + // fetch current user's IP info for _, currentServer := range servers { // get telemetry level @@ -45,6 +48,10 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel log.Infof("Selected server: %s [%s]", currentServer.Name, u.Hostname()) + if sponsorMsg := currentServer.Sponsor(); sponsorMsg != "" { + log.Infof("Sponsored by: %s", sponsorMsg) + } + if currentServer.IsUp() { ispInfo, err := currentServer.GetIPInfo(c.String(defs.OptionDistance)) if err != nil { @@ -61,59 +68,63 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel pb.Start() } - p, jitter, err := currentServer.ICMPPingAndJitter(pingCount, c.String(defs.OptionSource)) + // skip ICMP if option given + currentServer.NoICMP = noICMP + + p, jitter, err := currentServer.ICMPPingAndJitter(pingCount, c.String(defs.OptionSource), network) if err != nil { log.Errorf("Failed to get ping and jitter: %s", err) return err } if pb != nil { - pb.FinalMSG = fmt.Sprintf("Ping: %.0f ms\tJitter: %.0f ms\n", p, jitter) + pb.FinalMSG = fmt.Sprintf("Ping: %.2f ms\tJitter: %.2f ms\n", p, jitter) pb.Stop() } // get download value var downloadValue float64 - var bytesRead int + var bytesRead uint64 if c.Bool(defs.OptionNoDownload) { log.Info("Download test is disabled") } else { - download, br, err := currentServer.Download(silent, c.Bool(defs.OptionBytes)) + download, br, err := currentServer.Download(silent, c.Bool(defs.OptionBytes), c.Bool(defs.OptionMebiBytes), c.Int(defs.OptionConcurrent), c.Int(defs.OptionChunks), time.Duration(c.Int(defs.OptionDuration))*time.Second) if err != nil { log.Errorf("Failed to get download speed: %s", err) return err } downloadValue = download - bytesRead = br + bytesRead = uint64(br) } // get upload value var uploadValue float64 - var bytesWritten int + var bytesWritten uint64 if c.Bool(defs.OptionNoUpload) { log.Info("Upload test is disabled") } else { - upload, bw, err := currentServer.Upload(c.Bool(defs.OptionNoPreAllocate), silent, c.Bool(defs.OptionBytes)) + upload, bw, err := currentServer.Upload(c.Bool(defs.OptionNoPreAllocate), silent, c.Bool(defs.OptionBytes), c.Bool(defs.OptionMebiBytes), c.Int(defs.OptionConcurrent), c.Int(defs.OptionUploadSize), time.Duration(c.Int(defs.OptionDuration))*time.Second) if err != nil { log.Errorf("Failed to get upload speed: %s", err) return err } uploadValue = upload - bytesWritten = bw + bytesWritten = uint64(bw) } // print result if --simple is given if c.Bool(defs.OptionSimple) { if c.Bool(defs.OptionBytes) { - log.Warnf("Ping:\t%.0f ms\tJitter:\t%.0f ms\nDownload rate:\t%s\nUpload rate:\t%s", p, jitter, humanizeMbps(downloadValue), humanizeMbps(uploadValue)) + useMebi := c.Bool(defs.OptionMebiBytes) + log.Warnf("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%s\nUpload rate:\t%s", p, jitter, humanizeMbps(downloadValue, useMebi), humanizeMbps(uploadValue, useMebi)) } else { - log.Warnf("Ping:\t%.0f ms\tJitter:\t%.0f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps", p, jitter, downloadValue, uploadValue) + log.Warnf("Ping:\t%.2f ms\tJitter:\t%.2f ms\nDownload rate:\t%.2f Mbps\nUpload rate:\t%.2f Mbps", p, jitter, downloadValue, uploadValue) } } // print share link if --share is given var shareLink string - if c.Bool(defs.OptionShare) && !c.Bool(defs.OptionCSV) && telemetryServer.GetLevel() > 0 { + if telemetryServer.GetLevel() > 0 { var extra defs.TelemetryExtra extra.ServerName = currentServer.Name extra.Extra = c.String(defs.OptionTelemetryExtra) @@ -122,41 +133,35 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel log.Errorf("Error when sending telemetry data: %s", err) } else { shareLink = link - log.Warnf("Share your result: %s", link) + // only print to stdout when --json and --csv are not used + if !c.Bool(defs.OptionJSON) && !c.Bool(defs.OptionCSV) { + log.Warnf("Share your result: %s", link) + } } } // check for --csv or --json. the program prioritize the --csv before the --json. this is the same behavior as speedtest-cli if c.Bool(defs.OptionCSV) { // print csv if --csv is given - var reps []report.CSVReport - var rep report.CSVReport rep.Timestamp = time.Now() rep.Name = currentServer.Name rep.Address = u.String() - rep.Ping = p + rep.Ping = math.Round(p*100) / 100 rep.Jitter = math.Round(jitter*100) / 100 rep.Download = math.Round(downloadValue*100) / 100 rep.Upload = math.Round(uploadValue*100) / 100 rep.Share = shareLink rep.IP = ispInfo.RawISPInfo.IP - reps = append(reps, rep) - - var buf bytes.Buffer - if err := gocsv.MarshalWithoutHeaders(&reps, &buf); err != nil { - log.Errorf("Error generating CSV report: %s", err) - } else { - log.Warn(buf.String()) - } + reps_csv = append(reps_csv, rep) } else if c.Bool(defs.OptionJSON) { // print json if --json is given var rep report.JSONReport rep.Timestamp = time.Now() - rep.Ping = p + rep.Ping = math.Round(p*100) / 100 rep.Jitter = math.Round(jitter*100) / 100 rep.Download = math.Round(downloadValue*100) / 100 rep.Upload = math.Round(uploadValue*100) / 100 @@ -170,22 +175,34 @@ func doSpeedTest(c *cli.Context, servers []defs.Server, telemetryServer defs.Tel rep.Client = report.Client{ispInfo.RawISPInfo} rep.Client.Readme = "" - if b, err := json.Marshal(&rep); err != nil { - log.Errorf("Error generating JSON report: %s", err) - } else { - log.Warnf("%s", b) - } + reps_json = append(reps_json, rep) } } else { log.Infof("Selected server %s (%s) is not responding at the moment, try again later", currentServer.Name, u.Hostname()) } - // add a new line after each test if testing multiple servers - if len(servers) > 1 { + //add a new line after each test if testing multiple servers + if len(servers) > 1 && !silent { log.Warn() } } + // check for --csv or --json. the program prioritize the --csv before the --json. this is the same behavior as speedtest-cli + if c.Bool(defs.OptionCSV) { + var buf bytes.Buffer + if err := gocsv.MarshalWithoutHeaders(&reps_csv, &buf); err != nil { + log.Errorf("Error generating CSV report: %s", err) + } else { + os.Stdout.WriteString(buf.String()) + } + } else if c.Bool(defs.OptionJSON) { + if b, err := json.Marshal(&reps_json); err != nil { + log.Errorf("Error generating JSON report: %s", err) + } else { + os.Stdout.Write(b[:]) + } + } + return nil } @@ -222,7 +239,7 @@ func sendTelemetry(telemetryServer defs.TelemetryServer, ispInfo *defs.GetIPResu if fPing, err := wr.CreateFormField("ping"); err != nil { log.Debugf("Error creating form field: %s", err) return "", err - } else if _, err = fPing.Write([]byte(strconv.Itoa(int(pingVal)))); err != nil { + } else if _, err = fPing.Write([]byte(strconv.FormatFloat(pingVal, 'f', 2, 64))); err != nil { log.Debugf("Error writing form field: %s", err) return "", err } @@ -230,7 +247,7 @@ func sendTelemetry(telemetryServer defs.TelemetryServer, ispInfo *defs.GetIPResu if fJitter, err := wr.CreateFormField("jitter"); err != nil { log.Debugf("Error creating form field: %s", err) return "", err - } else if _, err = fJitter.Write([]byte(strconv.Itoa(int(jitter)))); err != nil { + } else if _, err = fJitter.Write([]byte(strconv.FormatFloat(jitter, 'f', 2, 64))); err != nil { log.Debugf("Error writing form field: %s", err) return "", err } @@ -299,16 +316,21 @@ func sendTelemetry(telemetryServer defs.TelemetryServer, ispInfo *defs.GetIPResu } } -func humanizeMbps(mbps float64) string { +func humanizeMbps(mbps float64, useMebi bool) string { val := mbps / 8 + var base float64 = 1000 + if useMebi { + base = 1024 + } + if val < 1 { - if kb := val * 1024; kb < 1 { - return fmt.Sprintf("%.2f bytes/s", kb*1024) + if kb := val * base; kb < 1 { + return fmt.Sprintf("%.2f bytes/s", kb*base) } else { return fmt.Sprintf("%.2f KB/s", kb) } - } else if val > 1024 { - return fmt.Sprintf("%.2f GB/s", val/1024) + } else if val > base { + return fmt.Sprintf("%.2f GB/s", val/base) } else { return fmt.Sprintf("%.2f MB/s", val) } diff --git a/speedtest/speedtest.go b/speedtest/speedtest.go index a9eba6e..20018ac 100644 --- a/speedtest/speedtest.go +++ b/speedtest/speedtest.go @@ -1,11 +1,18 @@ package speedtest import ( + "context" + "crypto/tls" + "crypto/x509" "encoding/json" "errors" + "fmt" + "io" "io/ioutil" "net" "net/http" + "os" + "strings" "sync" "time" @@ -13,18 +20,18 @@ import ( log "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" - "librespeed-cli/defs" - "librespeed-cli/report" + "github.com/librespeed/speedtest-cli/defs" + "github.com/librespeed/speedtest-cli/report" ) const ( // serverListUrl is the default remote server JSON URL - serverListUrl = `https://librespeed.org/backend-servers/servers.json` + serverListUrl = `https://librespeed.org/backend-servers/servers.php` defaultTelemetryLevel = "basic" defaultTelemetryServer = "https://librespeed.org" defaultTelemetryPath = "/results/telemetry.php" - defaultTeleemtryShare = "/results/" + defaultTelemetryShare = "/results/" ) type PingJob struct { @@ -58,6 +65,7 @@ func SpeedTest(c *cli.Context) error { // print version if c.Bool(defs.OptionVersion) { + log.SetOutput(os.Stdout) log.Warnf("%s %s (built on %s)", defs.ProgName, defs.ProgVersion, defs.BuildDate) log.Warn("https://github.com/librespeed/speedtest-cli") log.Warn("Licensed under GNU Lesser General Public License v3.0") @@ -67,6 +75,10 @@ func SpeedTest(c *cli.Context) error { return nil } + if c.String(defs.OptionSource) != "" && c.String(defs.OptionInterface) != "" { + return fmt.Errorf("incompatible options '%s' and '%s'", defs.OptionSource, defs.OptionInterface) + } + // set CSV delimiter gocsv.TagSeparator = c.String(defs.OptionCSVDelimiter) @@ -74,104 +86,177 @@ func SpeedTest(c *cli.Context) error { if c.Bool(defs.OptionCSVHeader) { var rep []report.CSVReport b, _ := gocsv.MarshalBytes(&rep) - log.Warnf("%s", b) + os.Stdout.WriteString(string(b)) return nil } - // read telemetry settings if --share is given + // read telemetry settings if --share or any --telemetry option is given var telemetryServer defs.TelemetryServer - if c.Bool(defs.OptionShare) { - if filename := c.String(defs.OptionTelemetryJSON); filename != "" { - b, err := ioutil.ReadFile(filename) + telemetryJSON := c.String(defs.OptionTelemetryJSON) + telemetryLevel := c.String(defs.OptionTelemetryLevel) + telemetryServerString := c.String(defs.OptionTelemetryServer) + telemetryPath := c.String(defs.OptionTelemetryPath) + telemetryShare := c.String(defs.OptionTelemetryShare) + if c.Bool(defs.OptionShare) || telemetryJSON != "" || telemetryLevel != "" || telemetryServerString != "" || telemetryPath != "" || telemetryShare != "" { + if telemetryJSON != "" { + b, err := ioutil.ReadFile(telemetryJSON) if err != nil { - log.Errorf("Cannot read %s: %s", filename, err) + log.Errorf("Cannot read %s: %s", telemetryJSON, err) return err } if err := json.Unmarshal(b, &telemetryServer); err != nil { - log.Errorf("Error parsing %s: %s", err) + log.Errorf("Error parsing %s: %s", telemetryJSON, err) return err } } - if str := c.String(defs.OptionTelemetryLevel); str != "" { - if str != "disabled" && str != "basic" && str != "full" && str != "debug" { - log.Fatalf("Unsupported telemetry level: %s", str) + if telemetryLevel != "" { + if telemetryLevel != "disabled" && telemetryLevel != "basic" && telemetryLevel != "full" && telemetryLevel != "debug" { + log.Fatalf("Unsupported telemetry level: %s", telemetryLevel) } - telemetryServer.Level = str + telemetryServer.Level = telemetryLevel } else if telemetryServer.Level == "" { telemetryServer.Level = defaultTelemetryLevel } - if str := c.String(defs.OptionTelemetryServer); str != "" { - telemetryServer.Server = str + if telemetryServerString != "" { + telemetryServer.Server = telemetryServerString } else if telemetryServer.Server == "" { telemetryServer.Server = defaultTelemetryServer } - if str := c.String(defs.OptionTelemetryPath); str != "" { - telemetryServer.Path = str + if telemetryPath != "" { + telemetryServer.Path = telemetryPath } else if telemetryServer.Path == "" { telemetryServer.Path = defaultTelemetryPath } - if str := c.String(defs.OptionTelemetryShare); str != "" { - telemetryServer.Share = str + if telemetryShare != "" { + telemetryServer.Share = telemetryShare } else if telemetryServer.Share == "" { - telemetryServer.Share = defaultTeleemtryShare + telemetryServer.Share = defaultTelemetryShare } } + if req := c.Int(defs.OptionConcurrent); req <= 0 { + log.Errorf("Concurrent requests cannot be lower than 1: %d is given", req) + return errors.New("invalid concurrent requests setting") + } + + noICMP := c.Bool(defs.OptionNoICMP) + // HTTP requests timeout http.DefaultClient.Timeout = time.Duration(c.Int(defs.OptionTimeout)) * time.Second + forceIPv4 := c.Bool(defs.OptionIPv4) + forceIPv6 := c.Bool(defs.OptionIPv6) + + var network string + switch { + case forceIPv4: + network = "ip4" + case forceIPv6: + network = "ip6" + default: + network = "ip" + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + + if caCertFileName := c.String(defs.OptionCACert); caCertFileName != "" { + caCert, err := ioutil.ReadFile(caCertFileName) + if err != nil { + log.Fatal(err) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: c.Bool(defs.OptionSkipCertVerify), + RootCAs: caCertPool, + } + } else { + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: c.Bool(defs.OptionSkipCertVerify), + } + } + + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } // bind to source IP address if given if src := c.String(defs.OptionSource); src != "" { - // first we parse the IP to see if it's valid - localAddr, err := net.ResolveIPAddr("ip", src) + var err error + dialer, err = newDialerAddressBound(src, network) if err != nil { - log.Errorf("Error parsing source IP: %s", err) return err } - - localTCPAddr := net.TCPAddr{IP: localAddr.IP} - - // set default HTTP client's Transport to the one that binds the source address - // this is modified from http.DefaultTransport - transport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - LocalAddr: &localTCPAddr, - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - // although this option is marked deprecated, but it's still used in http.DefaultTransport, keeping as-is - DualStack: true, - }).DialContext, - ForceAttemptHTTP2: true, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - } - - http.DefaultClient.Transport = transport } + // bind to interface if given + // bind to interface if given + iface := c.String(defs.OptionInterface) + fwmark := c.Int(defs.OptionFwmark) + + if iface != "" || fwmark > 0 { + var err error + dialer, err = newDialerInterfaceOrFwmarkBound(iface, fwmark) + if err != nil { + return err + } + // ICMP ping does not support interface binding. + noICMP = true + } + + // enforce if ipv4/ipv6 is forced + var dialContext func(context.Context, string, string) (net.Conn, error) + switch { + case forceIPv4: + dialContext = func(ctx context.Context, network, address string) (conn net.Conn, err error) { + return dialer.DialContext(ctx, "tcp4", address) + } + case forceIPv6: + dialContext = func(ctx context.Context, network, address string) (conn net.Conn, err error) { + return dialer.DialContext(ctx, "tcp6", address) + } + default: + dialContext = dialer.DialContext + } + + // set default HTTP client's Transport to the one that binds the source address + // this is modified from http.DefaultTransport + transport.DialContext = dialContext + http.DefaultClient.Transport = transport + // load server list var servers []defs.Server var err error if str := c.String(defs.OptionLocalJSON); str != "" { - // load server list from local JSON file - log.Infof("Using local JSON server list: %s", str) - servers, err = getLocalServers(c.Bool(defs.OptionSecure), str, c.IntSlice(defs.OptionExclude), c.IntSlice(defs.OptionServer), !c.Bool(defs.OptionList)) + switch str { + case "-": + // load server list from stdin + log.Info("Using local JSON server list from stdin") + servers, err = getLocalServersReader(c.Bool(defs.OptionSecure), os.Stdin, c.IntSlice(defs.OptionExclude), c.IntSlice(defs.OptionServer), !c.Bool(defs.OptionList)) + default: + // load server list from local JSON file + log.Infof("Using local JSON server list: %s", str) + servers, err = getLocalServers(c.Bool(defs.OptionSecure), str, c.IntSlice(defs.OptionExclude), c.IntSlice(defs.OptionServer), !c.Bool(defs.OptionList)) + } } else { // fetch the server list JSON and parse it into the `servers` array - log.Info("Retrieving LibreSpeed.org server list") serverUrl := serverListUrl if str := c.String(defs.OptionServerJSON); str != "" { serverUrl = str } + log.Infof("Retrieving server list from %s", serverUrl) servers, err = getServerList(c.Bool(defs.OptionSecure), serverUrl, c.IntSlice(defs.OptionExclude), c.IntSlice(defs.OptionServer), !c.Bool(defs.OptionList)) + + if err != nil { + log.Info("Retry with /.well-known/librespeed") + servers, err = getServerList(c.Bool(defs.OptionSecure), serverUrl+"/.well-known/librespeed", c.IntSlice(defs.OptionExclude), c.IntSlice(defs.OptionServer), !c.Bool(defs.OptionList)) + } } if err != nil { log.Errorf("Error when fetching server list: %s", err) @@ -180,29 +265,33 @@ func SpeedTest(c *cli.Context) error { // if --list is given, list all the servers fetched and exit if c.Bool(defs.OptionList) { - for idx, svr := range servers { - log.Warnf("%d: %s (%s)", idx, svr.Name, svr.Server) + for _, svr := range servers { + var sponsorMsg string + if svr.Sponsor() != "" { + sponsorMsg = fmt.Sprintf(" [Sponsor: %s]", svr.Sponsor()) + } + log.Warnf("%d: %s (%s) %s", svr.ID, svr.Name, svr.Server, sponsorMsg) } return nil } // if --server is given, do speed tests with all of them if len(c.IntSlice(defs.OptionServer)) > 0 { - return doSpeedTest(c, servers, telemetryServer, silent) + return doSpeedTest(c, servers, telemetryServer, network, silent, noICMP) } else { // else select the fastest server from the list log.Info("Selecting the fastest server based on ping") var wg sync.WaitGroup - jobs := make(chan PingJob, 10) - results := make(chan PingResult, 10) + jobs := make(chan PingJob, len(servers)) + results := make(chan PingResult, len(servers)) done := make(chan struct{}) pingList := make(map[int]float64) // spawn 10 concurrent pingers for i := 0; i < 10; i++ { - go pingWorker(jobs, results, &wg, c.String(defs.OptionSource)) + go pingWorker(jobs, results, &wg, c.String(defs.OptionSource), network, noICMP) } // send ping jobs to workers @@ -239,11 +328,11 @@ func SpeedTest(c *cli.Context) error { } // do speed test on the server - return doSpeedTest(c, []defs.Server{servers[serverIdx]}, telemetryServer, silent) + return doSpeedTest(c, []defs.Server{servers[serverIdx]}, telemetryServer, network, silent, noICMP) } } -func pingWorker(jobs <-chan PingJob, results chan<- PingResult, wg *sync.WaitGroup, srcIp string) { +func pingWorker(jobs <-chan PingJob, results chan<- PingResult, wg *sync.WaitGroup, srcIp, network string, noICMP bool) { for { job := <-jobs server := job.Server @@ -257,8 +346,11 @@ func pingWorker(jobs <-chan PingJob, results chan<- PingResult, wg *sync.WaitGro // check the server is up by accessing the ping URL and checking its returned value == empty and status code == 200 if server.IsUp() { + // skip ICMP if option given + server.NoICMP = noICMP + // if server is up, get ping - ping, _, err := server.ICMPPingAndJitter(1, srcIp) + ping, _, err := server.ICMPPingAndJitter(1, srcIp, network) if err != nil { log.Debugf("Can't ping server %s (%s), skipping", server.Name, u.Hostname()) wg.Done() @@ -283,7 +375,13 @@ func getServerList(forceHTTPS bool, serverList string, excludes, specific []int, // getting the server list from remote var servers []defs.Server - resp, err := http.DefaultClient.Get(serverList) + req, err := http.NewRequest(http.MethodGet, serverList, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", defs.UserAgent) + + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } @@ -301,10 +399,13 @@ func getServerList(forceHTTPS bool, serverList string, excludes, specific []int, return preprocessServers(servers, forceHTTPS, excludes, specific, filter) } -// getLocalServers loads the server JSON from a local file -func getLocalServers(forceHTTPS bool, jsonFile string, excludes, specific []int, filter bool) ([]defs.Server, error) { +// getLocalServersReader loads the server JSON from an io.Reader +func getLocalServersReader(forceHTTPS bool, reader io.ReadCloser, excludes, specific []int, filter bool) ([]defs.Server, error) { + defer reader.Close() + var servers []defs.Server - b, err := ioutil.ReadFile(jsonFile) + + b, err := ioutil.ReadAll(reader) if err != nil { return nil, err } @@ -316,6 +417,15 @@ func getLocalServers(forceHTTPS bool, jsonFile string, excludes, specific []int, return preprocessServers(servers, forceHTTPS, excludes, specific, filter) } +// getLocalServers loads the server JSON from a local file +func getLocalServers(forceHTTPS bool, jsonFile string, excludes, specific []int, filter bool) ([]defs.Server, error) { + f, err := os.OpenFile(jsonFile, os.O_RDONLY, 0644) + if err != nil { + return nil, err + } + return getLocalServersReader(forceHTTPS, f, excludes, specific, filter) +} + // preprocessServers makes some needed modifications to the servers fetched func preprocessServers(servers []defs.Server, forceHTTPS bool, excludes, specific []int, filter bool) ([]defs.Server, error) { for i := range servers { @@ -345,8 +455,8 @@ func preprocessServers(servers []defs.Server, forceHTTPS bool, excludes, specifi // exclude servers from --exclude if len(excludes) > 0 { var ret []defs.Server - for idx, server := range servers { - if contains(excludes, idx) { + for _, server := range servers { + if contains(excludes, server.ID) { continue } ret = append(ret, server) @@ -358,11 +468,15 @@ func preprocessServers(servers []defs.Server, forceHTTPS bool, excludes, specifi // special value -1 will test all servers if len(specific) > 0 && !contains(specific, -1) { var ret []defs.Server - for idx, server := range servers { - if contains(specific, idx) { + for _, server := range servers { + if contains(specific, server.ID) { ret = append(ret, server) } } + if len(ret) == 0 { + error_message := fmt.Sprintf("specified server(s) not found: %v", specific) + return nil, errors.New(error_message) + } return ret, nil } } @@ -379,3 +493,31 @@ func contains(arr []int, val int) bool { } return false } + +func newDialerAddressBound(src string, network string) (dialer *net.Dialer, err error) { + // first we parse the IP to see if it's valid + addr, err := net.ResolveIPAddr(network, src) + if err != nil { + if strings.Contains(err.Error(), "no suitable address") { + if network == "ip6" { + log.Errorf("Address %s is not a valid IPv6 address", src) + } else { + log.Errorf("Address %s is not a valid IPv4 address", src) + } + } else { + log.Errorf("Error parsing source IP: %s", err) + } + return nil, err + } + + log.Debugf("Using %s as source IP", src) + localTCPAddr := &net.TCPAddr{IP: addr.IP} + + defaultDialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + + defaultDialer.LocalAddr = localTCPAddr + return defaultDialer, nil +} diff --git a/speedtest/util.go b/speedtest/util.go new file mode 100644 index 0000000..0a35393 --- /dev/null +++ b/speedtest/util.go @@ -0,0 +1,13 @@ +//go:build !linux +// +build !linux + +package speedtest + +import ( + "fmt" + "net" +) + +func newDialerInterfaceOrFwmarkBound(iface string, fwmark int) (dialer *net.Dialer, err error) { + return nil, fmt.Errorf("cannot bound to interface on this platform") +} diff --git a/speedtest/util_linux.go b/speedtest/util_linux.go new file mode 100644 index 0000000..e153e87 --- /dev/null +++ b/speedtest/util_linux.go @@ -0,0 +1,38 @@ +package speedtest + +import ( + "net" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +func newDialerInterfaceOrFwmarkBound(iface string, fwmark int) (dialer *net.Dialer, err error) { + // In linux there is the socket option SO_BINDTODEVICE. + // Therefore we can really bind the socket to the device instead of binding to the address that + // would be affected by the default routes. + control := func(network, address string, c syscall.RawConn) error { + var errSock error + err := c.Control((func(fd uintptr) { + if iface != "" { + errSock = unix.BindToDevice(int(fd), iface) + } + + if fwmark > 0 { + errSock = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, fwmark) + } + })) + if err != nil { + return err + } + return errSock + } + + dialer = &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + Control: control, + } + return dialer, nil +} diff --git a/upx.sh b/upx.sh new file mode 100755 index 0000000..22b4320 --- /dev/null +++ b/upx.sh @@ -0,0 +1,2 @@ +#!/bin/sh +upx "$@" || true \ No newline at end of file