4 |
5 | Example of embedded widget
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/ndt5/web100/web100_stub.go:
--------------------------------------------------------------------------------
1 | // +build !linux
2 |
3 | package web100
4 |
5 | import (
6 | "context"
7 |
8 | "github.com/m-lab/ndt-server/netx"
9 | )
10 |
11 | // MeasureViaPolling collects all required data by polling. It is required for
12 | // non-BBR connections because MinRTT is one of our critical metrics.
13 | func MeasureViaPolling(ctx context.Context, ci netx.ConnInfo) <-chan *Metrics {
14 | // Just a stub.
15 | return nil
16 | }
17 |
--------------------------------------------------------------------------------
/testdata/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ndt-integration-test-client",
3 | "version": "1.0.0",
4 | "description": "A node.js client for NDT.",
5 | "main": "unittest_client.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "github.com/m-lab/ndt-server/"
9 | },
10 | "keywords": [
11 | "ndt"
12 | ],
13 | "author": "",
14 | "license": "Apache-2.0",
15 | "dependencies": {
16 | "minimist": "latest",
17 | "ws": "latest"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.20-alpine3.18 as ndt-server-build
2 | RUN apk add --no-cache git gcc linux-headers musl-dev
3 | ADD . /go/src/github.com/m-lab/ndt-server
4 | RUN /go/src/github.com/m-lab/ndt-server/build.sh
5 |
6 | # Now copy the built image into the minimal base image
7 | FROM alpine:3.18
8 | COPY --from=ndt-server-build /go/bin/ndt-server /
9 | COPY --from=ndt-server-build /go/bin/generate-schemas /
10 | ADD ./html /html
11 | WORKDIR /
12 | ENTRYPOINT ["/ndt-server"]
13 |
--------------------------------------------------------------------------------
/tcpinfox/tcpinfox.go:
--------------------------------------------------------------------------------
1 | // Package tcpinfox helps to gather TCP_INFO statistics.
2 | package tcpinfox
3 |
4 | import (
5 | "errors"
6 | "os"
7 |
8 | "github.com/m-lab/tcp-info/tcp"
9 | )
10 |
11 | // ErrNoSupport is returned on systems that do not support TCP_INFO.
12 | var ErrNoSupport = errors.New("TCP_INFO not supported")
13 |
14 | // GetTCPInfo measures TCP_INFO metrics using |fp| and returns them. In
15 | // case of error, instead, an error is returned.
16 | func GetTCPInfo(fp *os.File) (*tcp.LinuxTCPInfo, error) {
17 | return getTCPInfo(fp)
18 | }
19 |
--------------------------------------------------------------------------------
/ndt5/control/data.go:
--------------------------------------------------------------------------------
1 | package control
2 |
3 | import (
4 | "github.com/m-lab/ndt-server/metadata"
5 | "github.com/m-lab/ndt-server/ndt5/ndt"
6 | )
7 |
8 | type ArchivalData struct {
9 | // These data members should all be self-describing. In the event of confusion,
10 | // rename them to add clarity rather than adding a comment.
11 | UUID string
12 | Protocol ndt.ConnectionType
13 | MessageProtocol string
14 | ClientMetadata []metadata.NameValue `json:",omitempty"`
15 | ServerMetadata []metadata.NameValue `json:",omitempty"`
16 | }
17 |
--------------------------------------------------------------------------------
/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Script to test ndt-server with the correct `go get` flags. This script
3 | # is designed to run inside a container.
4 | set -ex
5 |
6 | # Test the NDT binary
7 | PATH=${PATH}:${GOPATH}/bin
8 |
9 | # If we aren't running on Travis, then there's no need to produce a coverage
10 | # file and submit it to coveralls.io
11 | if [[ -z ${TRAVIS_PULL_REQUEST} ]]; then
12 | go test -v -cover=1 -coverpkg=./... -tags netgo ./...
13 | else
14 | go test -v -coverprofile=ndt.cov -coverpkg=./... -tags netgo ./...
15 | /go/bin/goveralls -coverprofile=ndt.cov -service=travis-ci
16 | fi
17 |
--------------------------------------------------------------------------------
/ndt5/ws/ws.go:
--------------------------------------------------------------------------------
1 | package ws
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/gorilla/websocket"
8 | )
9 |
10 | // Upgrader returns a struct that can hijack an HTTP(S) connection into a WS(S)
11 | // connection.
12 | func Upgrader(protocol string) *websocket.Upgrader {
13 | return &websocket.Upgrader{
14 | ReadBufferSize: 81920,
15 | WriteBufferSize: 81920,
16 | Subprotocols: []string{protocol},
17 | EnableCompression: false,
18 | HandshakeTimeout: 10 * time.Second,
19 | CheckOrigin: func(r *http.Request) bool {
20 | // TODO: make this check more appropriate -- added to get initial html5 widget to work.
21 | return true
22 | },
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/ndt7/closer/closer.go:
--------------------------------------------------------------------------------
1 | // Package closer implements the WebSocket closer.
2 | package closer
3 |
4 | import (
5 | "time"
6 |
7 | "github.com/gorilla/websocket"
8 | "github.com/m-lab/ndt-server/logging"
9 | )
10 |
11 | // StartClosing will start closing the websocket connection.
12 | func StartClosing(conn *websocket.Conn) {
13 | msg := websocket.FormatCloseMessage(
14 | websocket.CloseNormalClosure, "Done sending")
15 | d := time.Now().Add(time.Second) // Liveness!
16 | err := conn.WriteControl(websocket.CloseMessage, msg, d)
17 | if err != nil {
18 | logging.Logger.WithError(err).Warn("sender: conn.WriteControl failed")
19 | return
20 | }
21 | logging.Logger.Debug("sender: sending Close message")
22 | }
23 |
--------------------------------------------------------------------------------
/bbr/bbr.go:
--------------------------------------------------------------------------------
1 | // Package bbr contains code required to read BBR variables of a net.Conn
2 | // on which we're serving a WebSocket client. This code currently only
3 | // works on Linux systems, as BBR is only available there.
4 | package bbr
5 |
6 | import (
7 | "errors"
8 | "os"
9 |
10 | "github.com/m-lab/tcp-info/inetdiag"
11 | )
12 |
13 | // ErrNoSupport indicates that this system does not support BBR.
14 | var ErrNoSupport = errors.New("TCP_CC_INFO not supported")
15 |
16 | // Enable enables BBR on |fp|.
17 | func Enable(fp *os.File) error {
18 | return enableBBR(fp)
19 | }
20 |
21 | // GetBBRInfo obtains BBR info from |fp|.
22 | func GetBBRInfo(fp *os.File) (inetdiag.BBRInfo, error) {
23 | return getMaxBandwidthAndMinRTT(fp)
24 | }
25 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | # We don't care about the language because we build in a docker. Not doing
2 | # that and instead requiring go leads to failures caused by missing headers
3 | # on the build machine. So we use minimal, which is basically "no lang".
4 | #
5 | # See
6 | # See
7 | language: minimal
8 | dist: xenial
9 |
10 | services:
11 | - docker
12 |
13 | script:
14 | - docker build -f TestDockerfile -t ndt-server-test .
15 | - docker run -t
16 | -e TRAVIS_PULL_REQUEST=$TRAVIS_PULL_REQUEST
17 | -e TRAVIS_JOB_ID=$TRAVIS_JOB_ID
18 | -e TRAVIS=true
19 | -e COVERALLS_SERVICE_NAME=travis-ci
20 | ndt-server-test
21 | - docker build . -f Dockerfile -t m-lab/ndt-server
22 |
23 | # Need a deployment section which calls "docker push"
24 |
--------------------------------------------------------------------------------
/tcpinfox/tcpinfox_linux.go:
--------------------------------------------------------------------------------
1 | package tcpinfox
2 |
3 | import (
4 | "os"
5 | "syscall"
6 | "unsafe"
7 |
8 | "github.com/m-lab/tcp-info/tcp"
9 | )
10 |
11 | func getTCPInfo(fp *os.File) (*tcp.LinuxTCPInfo, error) {
12 | tcpInfo := tcp.LinuxTCPInfo{}
13 | tcpInfoLen := uint32(unsafe.Sizeof(tcpInfo))
14 | rawConn, err := fp.SyscallConn()
15 | if err != nil {
16 | return &tcpInfo, err
17 | }
18 | var syscallErr syscall.Errno
19 | err = rawConn.Control(func(fd uintptr) {
20 | _, _, syscallErr = syscall.Syscall6(
21 | uintptr(syscall.SYS_GETSOCKOPT),
22 | fd,
23 | uintptr(syscall.SOL_TCP),
24 | uintptr(syscall.TCP_INFO),
25 | uintptr(unsafe.Pointer(&tcpInfo)),
26 | uintptr(unsafe.Pointer(&tcpInfoLen)),
27 | uintptr(0))
28 | })
29 | if err != nil {
30 | return &tcpInfo, err
31 | }
32 | if syscallErr != 0 {
33 | return &tcpInfo, syscallErr
34 | }
35 | return &tcpInfo, nil
36 | }
37 |
--------------------------------------------------------------------------------
/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | An NDT server
4 |
5 |
This is an NDT server.
6 |
7 |
You can run an NDT7 test (recommended) by going to ndt7.html
9 |
10 |
You can run an NDT5 test (legacy, less recommended) by going to embed.html or by connecting to the configured ports
12 | (the defaults are 3001 and 3010).
13 |
14 |
NDT7 is recommended for all new clients. NDT5 is for existing clients
15 | (including all versions before 5) that have not yet been ported to NDT7. The
16 | version "NDT6" was skipped entirely as IPv6 has been supported by NDT for
17 | many years, and the name NDT6 risked confusion with the naming scheme used by
18 | ping6 and the like.
22 |
23 |
24 |
--------------------------------------------------------------------------------
/logging/logging_test.go:
--------------------------------------------------------------------------------
1 | package logging
2 |
3 | import (
4 | "bytes"
5 | "log"
6 | "net/http"
7 | "testing"
8 |
9 | "github.com/m-lab/go/httpx"
10 | "github.com/m-lab/go/rtx"
11 | )
12 |
13 | type fakeHandler struct{}
14 |
15 | func (s *fakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
16 | w.WriteHeader(200)
17 | }
18 |
19 | func TestMakeAccessLogHandler(t *testing.T) {
20 | buff := &bytes.Buffer{}
21 | old := log.Writer()
22 | defer func() {
23 | log.SetOutput(old)
24 | }()
25 | log.SetOutput(buff)
26 | f := MakeAccessLogHandler(&fakeHandler{})
27 | log.SetOutput(old)
28 | srv := http.Server{
29 | Addr: ":0",
30 | Handler: f,
31 | }
32 | rtx.Must(httpx.ListenAndServeAsync(&srv), "Could not start server")
33 | defer srv.Close()
34 | _, err := http.Get("http://" + srv.Addr + "/")
35 | rtx.Must(err, "Could not get")
36 | s, err := buff.ReadString('\n')
37 | if s == "" {
38 | t.Error("We should not have had an empty string")
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/ndt7/ping/ping.go:
--------------------------------------------------------------------------------
1 | // Package ping implements WebSocket PING messages.
2 | package ping
3 |
4 | import (
5 | "encoding/json"
6 | "time"
7 |
8 | "github.com/gorilla/websocket"
9 | )
10 |
11 | // SendTicks sends the current ticks as a ping message.
12 | func SendTicks(conn *websocket.Conn, deadline time.Time) error {
13 | // TODO(bassosimone): when we'll have a unique base time.Time reference for
14 | // the whole test, we should use that, since UnixNano() is not monotonic.
15 | ticks := int64(time.Now().UnixNano())
16 | data, err := json.Marshal(ticks)
17 | if err == nil {
18 | err = conn.WriteControl(websocket.PingMessage, data, deadline)
19 | }
20 | return err
21 | }
22 |
23 | func ParseTicks(s string) (d int64, err error) {
24 | // TODO(bassosimone): when we'll have a unique base time.Time reference for
25 | // the whole test, we should use that, since UnixNano() is not monotonic.
26 | var prev int64
27 | err = json.Unmarshal([]byte(s), &prev)
28 | if err == nil {
29 | d = (int64(time.Now().UnixNano()) - prev)
30 | }
31 | return
32 | }
33 |
--------------------------------------------------------------------------------
/logging/logging.go:
--------------------------------------------------------------------------------
1 | // Package logging contains data structures useful to implement logging
2 | // across ndt-server in a Docker friendly way.
3 | package logging
4 |
5 | import (
6 | golog "log"
7 | "net/http"
8 | "os"
9 |
10 | "github.com/apex/log"
11 | "github.com/apex/log/handlers/json"
12 | "github.com/gorilla/handlers"
13 | )
14 |
15 | // Logger is a logger that logs messages on the standard error
16 | // in a structured JSON format, to simplify processing. Emitting logs
17 | // on the standard error is consistent with the standard practices
18 | // when dockerising an Apache or Nginx instance.
19 | var Logger = log.Logger{
20 | Handler: json.New(os.Stderr),
21 | Level: log.InfoLevel,
22 | }
23 |
24 | // MakeAccessLogHandler wraps |handler| with another handler that logs
25 | // access to each resource on the standard output. This is consistent with
26 | // the way in which Apache and Nginx are dockerised. We do not emit JSON
27 | // access logs, because access logs are a fairly standard format that
28 | // has been around for a long time now, so better to follow such standard.
29 | func MakeAccessLogHandler(handler http.Handler) http.Handler {
30 | return handlers.LoggingHandler(golog.Writer(), handler)
31 | }
32 |
--------------------------------------------------------------------------------
/ndt5/web100/web100.go:
--------------------------------------------------------------------------------
1 | // Package web100 provides web100 variables (or a simulation thereof) to
2 | // interested systems. When run on not-BBR it is polling-based, when run on BBR
3 | // it only needs to measure once.
4 | package web100
5 |
6 | import "github.com/m-lab/tcp-info/tcp"
7 |
8 | // Metrics holds web100 data. According to the NDT5 protocol, each of these
9 | // metrics is required. That does not mean each is required to be non-zero, but
10 | // it does mean that the field should be present in any response.
11 | type Metrics struct {
12 | // Milliseconds
13 | MaxRTT, MinRTT, SumRTT, CurRTO, SndLimTimeCwnd, SndLimTimeRwin, SndLimTimeSender uint32
14 |
15 | // Counters
16 | DataBytesOut uint64
17 | DupAcksIn, PktsOut, PktsRetrans, Timeouts, CountRTT, CongestionSignals uint32
18 | AckPktsIn uint32 // Called SegsIn in tcp-kis.txt
19 |
20 | // Octets
21 | MaxCwnd, MaxRwinRcvd, CurMSS, Sndbuf uint32
22 |
23 | // Scaling factors
24 | RcvWinScale, SndWinScale int
25 |
26 | // Useful metrics that are not part of the required set.
27 | BytesPerSecond float64
28 | TCPInfo tcp.LinuxTCPInfo
29 | }
30 |
--------------------------------------------------------------------------------
/cmd/generate-schemas/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "io/ioutil"
6 |
7 | "github.com/m-lab/go/cloud/bqx"
8 | "github.com/m-lab/go/rtx"
9 | "github.com/m-lab/ndt-server/data"
10 |
11 | "cloud.google.com/go/bigquery"
12 | )
13 |
14 | var (
15 | ndt7schema string
16 | ndt5schema string
17 | )
18 |
19 | func init() {
20 | flag.StringVar(&ndt7schema, "ndt7", "/var/spool/datatypes/ndt7.json", "filename to write ndt7 schema")
21 | flag.StringVar(&ndt5schema, "ndt5", "/var/spool/datatypes/ndt5.json", "filename to write ndt5 schema")
22 | }
23 |
24 | func main() {
25 | flag.Parse()
26 | // Generate and save ndt7 schema for autoloading.
27 | row7 := data.NDT7Result{}
28 | sch, err := bigquery.InferSchema(row7)
29 | rtx.Must(err, "failed to generate ndt7 schema")
30 | sch = bqx.RemoveRequired(sch)
31 | b, err := sch.ToJSONFields()
32 | rtx.Must(err, "failed to marshal schema")
33 | ioutil.WriteFile(ndt7schema, b, 0o644)
34 |
35 | // Generate and save ndt5 schema for autoloading.
36 | row5 := data.NDT7Result{}
37 | sch, err = bigquery.InferSchema(row5)
38 | rtx.Must(err, "failed to generate ndt5 schema")
39 | sch = bqx.RemoveRequired(sch)
40 | b, err = sch.ToJSONFields()
41 | rtx.Must(err, "failed to marshal schema")
42 | ioutil.WriteFile(ndt5schema, b, 0o644)
43 | }
44 |
--------------------------------------------------------------------------------
/ndt7/upload/upload.go:
--------------------------------------------------------------------------------
1 | // Package upload implements the ndt7 upload
2 | package upload
3 |
4 | import (
5 | "context"
6 |
7 | "github.com/gorilla/websocket"
8 | "github.com/m-lab/ndt-server/ndt7/model"
9 | "github.com/m-lab/ndt-server/ndt7/receiver"
10 | "github.com/m-lab/ndt-server/ndt7/upload/sender"
11 | )
12 |
13 | // Do implements the upload subtest. The ctx argument is the parent context for
14 | // the subtest. The conn argument is the open WebSocket connection. The data
15 | // argument is the archival data where results are saved. All arguments are
16 | // owned by the caller of this function.
17 | func Do(ctx context.Context, conn *websocket.Conn, data *model.ArchivalData) error {
18 | // Implementation note: use child contexts so the sender is strictly time
19 | // bounded. After timeout, the sender closes the conn, which results in the
20 | // receiver completing.
21 |
22 | // Receive and save client-provided measurements in data.
23 | recv := receiver.StartUploadReceiverAsync(ctx, conn, data)
24 |
25 | // Perform upload and save server-measurements in data.
26 | // TODO: move sender.Start logic to this file.
27 | err := sender.Start(ctx, conn, data)
28 |
29 | // Block on the receiver completing to guarantee that access to data is synchronous.
30 | <-recv.Done()
31 | return err
32 | }
33 |
--------------------------------------------------------------------------------
/metrics/metrics.go:
--------------------------------------------------------------------------------
1 | package metrics
2 |
3 | import (
4 | "github.com/prometheus/client_golang/prometheus"
5 | "github.com/prometheus/client_golang/prometheus/promauto"
6 | )
7 |
8 | // Metrics for general use, in both NDT5 and in NDT7.
9 | var (
10 | ActiveTests = promauto.NewGaugeVec(
11 | prometheus.GaugeOpts{
12 | Name: "ndt_active_tests",
13 | Help: "A gauge of requests currently being served by the NDT server.",
14 | },
15 | []string{"protocol"})
16 | TestRate = promauto.NewHistogramVec(
17 | prometheus.HistogramOpts{
18 | Name: "ndt_test_rate_mbps",
19 | Help: "A histogram of request rates.",
20 | Buckets: []float64{
21 | .1, .15, .25, .4, .6,
22 | 1, 1.5, 2.5, 4, 6,
23 | 10, 15, 25, 40, 60,
24 | 100, 150, 250, 400, 600,
25 | 1000},
26 | },
27 | []string{"protocol", "direction", "monitoring"},
28 | )
29 | )
30 |
31 | // GetResultLabel returns one of four strings based on the combination of
32 | // whether the error ("okay" or "error") and the rate ("with-rate" (non-zero) or
33 | // "without-rate" (zero)).
34 | func GetResultLabel(err error, rate float64) string {
35 | withErr := "okay"
36 | if err != nil {
37 | withErr = "error"
38 | }
39 | withResult := "-with-rate"
40 | if rate == 0 {
41 | withResult = "-without-rate"
42 | }
43 | return withErr + withResult
44 | }
45 |
--------------------------------------------------------------------------------
/html/mlab/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | NDT (Network Diagnostic Tool) Server from Measurement Lab
5 |
6 |
7 |
8 |
NDT (Network Diagnostic Tool) Server from Measurement
10 | Lab
11 |
12 |
13 |
This is an
15 | NDT server, provided by Measurement Lab (M-Lab).
16 |
17 |
More information about NDT, other M-Lab hosted tests, the M-Lab platform, and
20 | the open data we publish can be found on our website.
22 |
23 |
If you need assistance, please email support@measurementlab.net, or
24 | visit our website for complete
25 | information about the M-Lab platform, tests, and data.
29 |
30 |
31 |
--------------------------------------------------------------------------------
/ndt7/download/download.go:
--------------------------------------------------------------------------------
1 | // Package download implements the ndt7/server downloader.
2 | package download
3 |
4 | import (
5 | "context"
6 |
7 | "github.com/gorilla/websocket"
8 | "github.com/m-lab/ndt-server/ndt7/download/sender"
9 | "github.com/m-lab/ndt-server/ndt7/model"
10 | "github.com/m-lab/ndt-server/ndt7/receiver"
11 | )
12 |
13 | // Do implements the download subtest. The ctx argument is the parent context
14 | // for the subtest. The conn argument is the open WebSocket connection. The data
15 | // argument is the archival data where results are saved. All arguments are
16 | // owned by the caller of this function.
17 | func Do(ctx context.Context, conn *websocket.Conn, data *model.ArchivalData, params *sender.Params) error {
18 | // Implementation note: use child contexts so the sender is strictly time
19 | // bounded. After timeout, the sender closes the conn, which results in the
20 | // receiver completing.
21 |
22 | // Receive and save client-provided measurements in data.
23 | recv := receiver.StartDownloadReceiverAsync(ctx, conn, data)
24 |
25 | // Perform download and save server-measurements in data.
26 | // TODO: move sender.Start logic to this file.
27 | err := sender.Start(ctx, conn, data, params)
28 |
29 | // Block on the receiver completing to guarantee that access to data is synchronous.
30 | <-recv.Done()
31 | return err
32 | }
33 |
--------------------------------------------------------------------------------
/ndt7/ndt7test/ndt7test.go:
--------------------------------------------------------------------------------
1 | package ndt7test
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "net/http"
7 | "net/http/httptest"
8 | "testing"
9 |
10 | "github.com/m-lab/go/testingx"
11 | "github.com/m-lab/ndt-server/ndt7/handler"
12 | "github.com/m-lab/ndt-server/ndt7/spec"
13 | "github.com/m-lab/ndt-server/netx"
14 | "github.com/m-lab/tcp-info/eventsocket"
15 | )
16 |
17 | // NewNDT7Server creates a local httptest server capable of running an ndt7
18 | // measurement in unittests.
19 | func NewNDT7Server(t *testing.T) (*handler.Handler, *httptest.Server) {
20 | dir := t.TempDir()
21 | // TODO: add support for token verifiers.
22 | // TODO: add support for TLS server.
23 | ndt7Handler := &handler.Handler{DataDir: dir, Events: eventsocket.NullServer()}
24 | ndt7Mux := http.NewServeMux()
25 | ndt7Mux.Handle(spec.DownloadURLPath, http.HandlerFunc(ndt7Handler.Download))
26 | ndt7Mux.Handle(spec.UploadURLPath, http.HandlerFunc(ndt7Handler.Upload))
27 |
28 | // Create unstarted so we can setup a custom netx.Listener.
29 | ts := httptest.NewUnstartedServer(ndt7Mux)
30 | listener, err := net.Listen("tcp", ":0")
31 | testingx.Must(t, err, "failed to allocate a listening tcp socket")
32 | addr := (listener.(*net.TCPListener)).Addr().(*net.TCPAddr)
33 | // Populate insecure port value with dynamic port.
34 | ndt7Handler.InsecurePort = fmt.Sprintf(":%d", addr.Port)
35 | ts.Listener = netx.NewListener(listener.(*net.TCPListener))
36 | // Now that the test server has our custom listener, start it.
37 | ts.Start()
38 | return ndt7Handler, ts
39 | }
40 |
--------------------------------------------------------------------------------
/ndt7/handler/handler_test.go:
--------------------------------------------------------------------------------
1 | // Package handler implements the WebSocket handler for ndt7.
2 | package handler
3 |
4 | import (
5 | "net/url"
6 | "reflect"
7 | "testing"
8 |
9 | "github.com/m-lab/ndt-server/ndt7/download/sender"
10 | "github.com/m-lab/ndt-server/ndt7/spec"
11 | )
12 |
13 | func Test_validateEarlyExit(t *testing.T) {
14 | type args struct {
15 | values url.Values
16 | }
17 | tests := []struct {
18 | name string
19 | values url.Values
20 | want *sender.Params
21 | wantErr bool
22 | }{
23 | {
24 | name: "valid-param",
25 | values: url.Values{"early_exit": {spec.ValidEarlyExitValues[0]}},
26 | want: &sender.Params{
27 | IsEarlyExit: true,
28 | MaxBytes: 250000000,
29 | },
30 | wantErr: false,
31 | },
32 | {
33 | name: "invalid-param",
34 | values: url.Values{"early_exit": {"123"}},
35 | want: nil,
36 | wantErr: true,
37 | },
38 | {
39 | name: "missing-value",
40 | values: url.Values{"early_exit": {""}},
41 | want: nil,
42 | wantErr: true,
43 | },
44 | {
45 | name: "absent-param",
46 | values: url.Values{"foo": {"bar"}},
47 | want: &sender.Params{
48 | IsEarlyExit: false,
49 | MaxBytes: 0,
50 | },
51 | wantErr: false,
52 | },
53 | }
54 | for _, tt := range tests {
55 | t.Run(tt.name, func(t *testing.T) {
56 | got, err := validateEarlyExit(tt.values)
57 | if (err != nil) != tt.wantErr {
58 | t.Errorf("validateEarlyExit() error = %v, wantErr %v", err, tt.wantErr)
59 | return
60 | }
61 | if !reflect.DeepEqual(got, tt.want) {
62 | t.Errorf("validateEarlyExit() = %v, want %v", got, tt.want)
63 | }
64 | })
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/m-lab/ndt-server
2 |
3 | go 1.20
4 |
5 | require (
6 | github.com/apex/log v1.9.0
7 | github.com/google/uuid v1.3.0
8 | github.com/gorilla/handlers v1.5.1
9 | github.com/gorilla/websocket v1.5.0
10 | github.com/m-lab/access v0.0.11
11 | github.com/m-lab/go v0.1.75
12 | github.com/m-lab/tcp-info v1.5.3
13 | github.com/m-lab/uuid v1.0.1
14 | github.com/prometheus/client_golang v1.13.0
15 | go.uber.org/goleak v1.1.12
16 | gopkg.in/m-lab/pipe.v3 v3.0.0-20180108231244-604e84f43ee0
17 | )
18 |
19 | require (
20 | github.com/google/go-cmp v0.5.9 // indirect
21 | github.com/rogpeppe/go-internal v1.9.0 // indirect
22 | github.com/stretchr/testify v1.8.1 // indirect
23 | golang.org/x/net v0.8.0 // indirect
24 | golang.org/x/text v0.8.0 // indirect
25 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
26 | )
27 |
28 | require (
29 | github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 // indirect
30 | github.com/beorn7/perks v1.0.1 // indirect
31 | github.com/cespare/xxhash/v2 v2.2.0 // indirect
32 | github.com/felixge/httpsnoop v1.0.1 // indirect
33 | github.com/gocarina/gocsv v0.0.0-20210408192840-02d7211d929d // indirect
34 | github.com/golang/protobuf v1.5.3 // indirect
35 | github.com/justinas/alice v1.2.0 // indirect
36 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
37 | github.com/pkg/errors v0.9.1 // indirect
38 | github.com/prometheus/client_model v0.2.0 // indirect
39 | github.com/prometheus/common v0.37.0 // indirect
40 | github.com/prometheus/procfs v0.8.0 // indirect
41 | golang.org/x/crypto v0.7.0
42 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
43 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect
44 | golang.org/x/sys v0.7.0 // indirect
45 | google.golang.org/protobuf v1.30.0 // indirect
46 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect
47 | )
48 |
--------------------------------------------------------------------------------
/fullstack/Dockerfile:
--------------------------------------------------------------------------------
1 | # Traceroute-caller is the most brittle of our tools, as it requires
2 | # scamper which is not statically linked. So we work within that image.
3 | FROM measurementlab/traceroute-caller
4 |
5 | # UUIDs require a little setup to use appropriately. In particular, they need a
6 | # unique string written to a well-known location to serve as a prefix.
7 | COPY --from=measurementlab/uuid /create-uuid-prefix-file /
8 |
9 | # tcp-info needs its binary and also needs zstd
10 | COPY --from=measurementlab/tcp-info /bin/tcp-info /tcp-info
11 | COPY --from=measurementlab/tcp-info /bin/zstd /bin/zstd
12 | COPY --from=measurementlab/tcp-info /licences/zstd/ /licences/zstd/
13 |
14 | # packet-headers needs its binary and libpcap. There's no good way to get both
15 | # easily from the image, due to C-linking issues and the differences between
16 | # alpine and ubuntu, so just rebuild it here.
17 | ENV DEBIAN_FRONTEND=noninteractive
18 | RUN apt-get update && apt-get install -y libpcap-dev golang-go git socat
19 | RUN go get github.com/m-lab/packet-headers
20 | RUN mv /root/go/bin/packet-headers /packet-headers
21 |
22 | # The NDT server needs the server binary and its HTML files
23 | COPY --from=measurementlab/ndt-server /ndt-server /
24 | COPY --from=measurementlab/ndt-server /html /html
25 |
26 | COPY fullstack/start.sh /start.sh
27 | RUN chmod +x /start.sh
28 |
29 | WORKDIR /
30 |
31 | # You can add further arguments to ndt-server, all the other commands are
32 | # fixed. Prometheus metrics for tcp-info and traceroute-caller can be
33 | # found on ports 9991 and 9992 (set in the start script), while the ndt
34 | # server metrics can be found on port 9990 by default, but can be set by
35 | # passing --prometheusx.listen-address to that start script.
36 | #
37 | # If you would like to run any SSL/TLS-based tests, you'll need to pass in
38 | # the --cert= and --key= arguments.
39 | ENTRYPOINT ["/start.sh"]
40 |
--------------------------------------------------------------------------------
/ndt7/metrics/metrics.go:
--------------------------------------------------------------------------------
1 | package metrics
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/gorilla/websocket"
7 | "github.com/prometheus/client_golang/prometheus"
8 | "github.com/prometheus/client_golang/prometheus/promauto"
9 | )
10 |
11 | // Metrics for exporting to prometheus to aid in server monitoring.
12 | var (
13 | ClientConnections = promauto.NewCounterVec(
14 | prometheus.CounterOpts{
15 | Name: "ndt7_client_connections_total",
16 | Help: "Count of clients that connect and setup an ndt7 measurement.",
17 | },
18 | []string{"direction", "status"},
19 | )
20 | ClientTestResults = promauto.NewCounterVec(
21 | prometheus.CounterOpts{
22 | Name: "ndt7_client_test_results_total",
23 | Help: "Number of client-connections for NDT tests run by this server.",
24 | },
25 | []string{"protocol", "direction", "result"},
26 | )
27 | ClientSenderErrors = promauto.NewCounterVec(
28 | prometheus.CounterOpts{
29 | Name: "ndt7_client_sender_errors_total",
30 | Help: "Number of sender errors on all return paths.",
31 | },
32 | []string{"protocol", "direction", "error"},
33 | )
34 | ClientReceiverErrors = promauto.NewCounterVec(
35 | prometheus.CounterOpts{
36 | Name: "ndt7_client_receiver_errors_total",
37 | Help: "Number of receiver errors on all return paths.",
38 | },
39 | []string{"protocol", "direction", "error"},
40 | )
41 | )
42 |
43 | // ConnLabel infers an appropriate label for the websocket protocol.
44 | func ConnLabel(conn *websocket.Conn) string {
45 | // NOTE: this isn't perfect, but it is simple and a) works for production deployments,
46 | // and 2) will work for custom deployments with ports having the same suffix, e.g. 4433, 8080.
47 | if strings.HasSuffix(conn.LocalAddr().String(), "443") {
48 | return "ndt7+wss"
49 | }
50 | if strings.HasSuffix(conn.LocalAddr().String(), "80") {
51 | return "ndt7+ws"
52 | }
53 | return "ndt7+unknown"
54 | }
55 |
--------------------------------------------------------------------------------
/ndt5/ndt/server.go:
--------------------------------------------------------------------------------
1 | package ndt
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/m-lab/ndt-server/metadata"
7 | "github.com/m-lab/ndt-server/ndt5/protocol"
8 | )
9 |
10 | // ConnectionType records whether this test is performed over plain TCP,
11 | // websockets, or secure websockets.
12 | type ConnectionType string
13 |
14 | // String returns the connection type named used in archival data.
15 | func (c ConnectionType) String() string {
16 | return string(c)
17 | }
18 |
19 | // Label returns the connection type name used in monitoring metrics.
20 | func (c ConnectionType) Label() string {
21 | switch c {
22 | case WSS:
23 | return "ndt5+wss"
24 | case WS:
25 | return "ndt5+ws"
26 | case Plain:
27 | return "ndt5+plain"
28 | default:
29 | return "ndt5+unknown"
30 | }
31 | }
32 |
33 | // The types of connections we support.
34 | var (
35 | WS = ConnectionType("WS")
36 | WSS = ConnectionType("WSS")
37 | Plain = ConnectionType("PLAIN")
38 | )
39 |
40 | // Server describes the methods implemented by every server of every connection
41 | // type.
42 | type Server interface {
43 | SingleMeasurementServerFactory
44 | ConnectionType() ConnectionType
45 | DataDir() string
46 | Metadata() []metadata.NameValue
47 | LoginCeremony(protocol.Connection) (int, error)
48 | }
49 |
50 | // SingleMeasurementServerFactory is the method by which we abstract away what
51 | // kind of server is being created at any given time. Using this abstraction
52 | // allows us to use almost the same code for WS and WSS.
53 | type SingleMeasurementServerFactory interface {
54 | SingleServingServer(direction string) (SingleMeasurementServer, error)
55 | }
56 |
57 | // SingleMeasurementServer is the interface implemented by every single-serving
58 | // server. No matter whether they use WSS, WS, TCP with JSON, or TCP without
59 | // JSON.
60 | type SingleMeasurementServer interface {
61 | Port() int
62 | ServeOnce(context.Context) (protocol.MeasuredConnection, error)
63 | Close()
64 | }
65 |
--------------------------------------------------------------------------------
/html/ndt-wrapper-ww.js:
--------------------------------------------------------------------------------
1 | /* This is an NDT client, written in javascript. It speaks the websocket
2 | * version of the NDT protocol. The NDT protocol is documented at:
3 | * https://code.google.com/p/ndt/wiki/NDTProtocol
4 | */
5 |
6 | /*jslint bitwise: true, browser: true, nomen: true, vars: true */
7 | /*global Uint8Array, WebSocket */
8 |
9 | 'use strict';
10 |
11 | importScripts('ndt-browser-client.js');
12 |
13 | self.addEventListener('message', function (e) {
14 | var msg = e.data;
15 | switch (msg.cmd) {
16 | case 'start':
17 | startNDT(msg.hostname, msg.port, msg.protocol, msg.path,
18 | msg.update_interval);
19 | break;
20 | case 'stop':
21 | self.close();
22 | break;
23 | default:
24 | // self.postMessage('Unknown command: ' + data.msg);
25 | break;
26 | };
27 | }, false);
28 |
29 | function startNDT(hostname, port, protocol, path, update_interval) {
30 | var callbacks = {
31 | 'onstart': function(server) {
32 | self.postMessage({
33 | 'cmd': 'onstart',
34 | 'server': server
35 | });
36 | },
37 | 'onstatechange': function(state, results) {
38 | self.postMessage({
39 | 'cmd': 'onstatechange',
40 | 'state': state,
41 | 'results': results,
42 | });
43 | },
44 | 'onprogress': function(state, results) {
45 | self.postMessage({
46 | 'cmd': 'onprogress',
47 | 'state': state,
48 | 'results': results,
49 | });
50 | },
51 | 'onfinish': function(results) {
52 | self.postMessage({
53 | 'cmd': 'onfinish',
54 | 'results': results
55 | });
56 | },
57 | 'onerror': function(error_message) {
58 | self.postMessage({
59 | 'cmd': 'onerror',
60 | 'error_message': error_message
61 | });
62 | }
63 | };
64 |
65 | var client = new NDTjs(hostname, port, protocol, path, callbacks,
66 | update_interval);
67 | client.startTest();
68 | }
69 |
--------------------------------------------------------------------------------
/ndt7/metrics/README.md:
--------------------------------------------------------------------------------
1 | # NDT7 Server Metrics
2 |
3 | Summary of ndt7 metrics useful for monitoring client requests, measurement
4 | successes, and error rates for the sender and receiver.
5 |
6 | * `ndt7_client_connections_total{direction, status}` counts every client
7 | connection that reaches `handler.Upload` or `handler.Download`.
8 |
9 | * The "direction=" label indicates an "upload" or "download" measurement.
10 | * The "status=" label is either "result" or a specific error that
11 | prevented setup before the connection was aborted.
12 | * All status="result" clients are counted in `ndt7_client_test_results_total`.
13 | * All status="result" clients should also equal the number of files written.
14 |
15 | * `ndt7_client_test_results_total{protocol, direction, result}` counts the
16 | test results of clients that successfully setup the websocket connection.
17 |
18 | * The "protocol=" label indicates the "ndt7+wss" or "ndt7+ws" protocol.
19 | * The "direction=" labels are as above.
20 | * The "result=" label is either "okay-with-rate", "error-with-rate" or
21 | "error-without-rate".
22 | * All result=~"*-with-rate" measurements are also recorded in the shared
23 | test rate histogram.
24 | * All results are also counted in `ndt7_client_sender_errors_total` and
25 | `ndt7_client_receiver_errors_total`
26 |
27 | * `ndt7_client_sender_errors_total{protocol, direction, error}`
28 | * The "protocol=" and "direction=" labels are as above.
29 | * The "error=" label contains unique values mapping to specific error or return
30 | paths in the sender.
31 |
32 | * `ndt7_client_receiver_errors_total{protocol, direction, error}`
33 | * Just like the `ndt7_client_sender_errors_total` metric, but for the receiver.
34 |
35 | Expected invariants:
36 |
37 | * `ndt7_client_connections_total{status="result"} == sum(ndt7_client_test_results_total)`
38 | * `sum(ndt7_client_test_results_total) == sum(ndt7_client_sender_errors_total)`
39 | * `sum(ndt7_client_test_results_total) == sum(ndt7_client_receiver_errors_total)`
40 |
--------------------------------------------------------------------------------
/cloud-config.yaml:
--------------------------------------------------------------------------------
1 | #cloud-config
2 |
3 | write_files:
4 | - path: /var/lib/cloud/scripts/prom_vdlimit_metrics.sh
5 | permissions: 0700
6 | owner: root:root
7 | content: |
8 | #!/bin/bash
9 | VDLIMIT_TEXT=""
10 | VDLIMIT_FILE="/var/spool/node_exporter/vdlimit.prom"
11 | USED=$(df --output="used" /mnt/stateful_partition | tail -n1 )
12 | FREE=$(df --output="avail" /mnt/stateful_partition | tail -n1 )
13 | SIZE=$(df --output="size" /mnt/stateful_partition | tail -n1 )
14 | VDLIMIT_TEXT="vdlimit_used{experiment=\"ndt.iupui\"} ${USED}\n"
15 | VDLIMIT_TEXT+="vdlimit_free{experiment=\"ndt.iupui\"} ${FREE}\n"
16 | VDLIMIT_TEXT+="vdlimit_total{experiment=\"ndt.iupui\"} ${SIZE}\n"
17 | echo -e $VDLIMIT_TEXT > $VDLIMIT_FILE
18 | - path: /etc/systemd/system/prom-vdlimit-metrics.service
19 | permissions: 0644
20 | owner: root:root
21 | content: |
22 | [Unit]
23 | Description=Writes vdlimit Prom metrics to /var/spool/node_exporter/vdlimit.prom
24 | [Service]
25 | Type=oneshot
26 | ExecStart=/var/lib/cloud/scripts/prom_vdlimit_metrics.sh
27 | - path: /etc/systemd/system/prom-vdlimit-metrics.timer
28 | permissions: 0644
29 | owner: root:root
30 | content: |
31 | [Unit]
32 | Description=Run /usr/bin/prom_vdlimit_metrics.sh every 5 minutes.
33 | [Timer]
34 | OnBootSec=1min
35 | OnUnitActiveSec=5min
36 | [Install]
37 | WantedBy=multi-user.target
38 |
39 | # The iptables command below is necessary due to the way that the Google
40 | # Container-Optimized OS works. For COS, it is necessary to both open holes in
41 | # the VPC firewall as well as opening the firewall on the VM itself.
42 | # https://cloud.google.com/container-optimized-os/docs/how-to/firewall
43 | runcmd:
44 | - systemctl daemon-reload
45 | - systemctl enable prom-vdlimit-metrics.service
46 | - systemctl enable prom-vdlimit-metrics.timer
47 | - systemctl start prom-vdlimit-metrics.service
48 | - systemctl start prom-vdlimit-metrics.timer
49 | - iptables -A INPUT -p tcp -j ACCEPT
50 |
--------------------------------------------------------------------------------
/ndt7/model/archivaldata.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/m-lab/ndt-server/metadata"
7 | "github.com/m-lab/tcp-info/inetdiag"
8 | "github.com/m-lab/tcp-info/tcp"
9 | )
10 |
11 | // ArchivalData saves all instantaneous measurements over the lifetime of a test.
12 | type ArchivalData struct {
13 | UUID string
14 | StartTime time.Time
15 | EndTime time.Time
16 | ServerMeasurements []Measurement
17 | ClientMeasurements []Measurement
18 | ClientMetadata []metadata.NameValue `json:",omitempty"`
19 | ServerMetadata []metadata.NameValue `json:",omitempty"`
20 | }
21 |
22 | // The Measurement struct contains measurement results. This structure is
23 | // meant to be serialised as JSON as sent as a textual message. This
24 | // structure is specified in the ndt7 specification.
25 | type Measurement struct {
26 | AppInfo *AppInfo `json:",omitempty"`
27 | ConnectionInfo *ConnectionInfo `json:",omitempty"`
28 | BBRInfo *BBRInfo `json:",omitempty"`
29 | TCPInfo *TCPInfo `json:",omitempty"`
30 | }
31 |
32 | // AppInfo contains an application level measurement. This structure is
33 | // described in the ndt7 specification.
34 | type AppInfo struct {
35 | NumBytes int64
36 | ElapsedTime int64
37 | }
38 |
39 | // ConnectionInfo contains connection info. This structure is described
40 | // in the ndt7 specification.
41 | type ConnectionInfo struct {
42 | Client string
43 | Server string
44 | UUID string `json:",omitempty"`
45 | StartTime time.Time `json:",omitempty"`
46 | }
47 |
48 | // The BBRInfo struct contains information measured using BBR. This structure is
49 | // an extension to the ndt7 specification. Variables here have the same
50 | // measurement unit that is used by the Linux kernel.
51 | type BBRInfo struct {
52 | inetdiag.BBRInfo
53 | ElapsedTime int64
54 | }
55 |
56 | // The TCPInfo struct contains information measured using TCP_INFO. This
57 | // structure is described in the ndt7 specification.
58 | type TCPInfo struct {
59 | tcp.LinuxTCPInfo
60 | ElapsedTime int64
61 | }
62 |
--------------------------------------------------------------------------------
/fullstack/README.md:
--------------------------------------------------------------------------------
1 | This is a docker image that allows people to easily run their own ndt-server
2 | binary, along with some recommended "sidecar" measurement services to grab
3 | richer data. Advanced users will likely want to configure each service
4 | independently, but this docker image allows interested parties to try running
5 | the server with a simple command:
6 | ```
7 | $ docker run --net=host measurementlab/ndt
8 | ```
9 | After that, point your web browser to
10 | [http://localhost:3001/static/widget.html](http://localhost:3001/static/widget.html)
11 | and run a speed test to verify that things work. The default NDT5 JS client
12 | needs some Javascript client-side optimization (pull requests gratefully
13 | accepted), but you should still be able to run a speed test immediately.
14 |
15 | # How to use this image.
16 |
17 | If you just want to run a server that speaks the unencrypted NDT5 (legacy)
18 | protocol, then you can run:
19 | ```
20 | $ docker run --net=host measurementlab/ndt
21 | ```
22 | and you will get an NDT server running on port 3001, with data being saved to
23 | the in-container directory `/var/spool/ndt/`
24 |
25 | If you would like to run NDT7 tests (which you should, it is a simpler protocol
26 | and a more robust measurement) or NDT5 tests over TLS, then you will need a
27 | private key and a TLS certificate (let's assume they are called
28 | `/etc/certs/key.pem` and `/etc/certs/cert.pem`). To run an NDT7 server on port
29 | 443, you must mount the directory with those certificates inside the container,
30 | and then tell the NDT server about those files. For example:
31 | ```
32 | $ docker run -v /etc/certs:/certs --net=host measurementlab/ndt \
33 | --key=/certs/key.pem --cert=/certs/cert.pem
34 | ```
35 |
36 | The NDT server produces data on disk. If you would like this data saved to a
37 | directory outside of the docker container, then you need to mount the external
38 | directory inside the container at `/var/spool/ndt` using the `-v` argument to
39 | `docker run`.
40 |
41 | All arguments to `docker run` after the name of the image are passed directly
42 | through to the NDT server.
43 |
--------------------------------------------------------------------------------
/ndt7/ndt7test/ndt7test_test.go:
--------------------------------------------------------------------------------
1 | package ndt7test
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "net/url"
7 | "os"
8 | "path/filepath"
9 | "testing"
10 | "time"
11 |
12 | "github.com/gorilla/websocket"
13 | "github.com/m-lab/go/testingx"
14 | "github.com/m-lab/ndt-server/ndt7/spec"
15 | )
16 |
17 | func TestNewNDT7Server(t *testing.T) {
18 | // Create the ndt7test server.
19 | h, srv := NewNDT7Server(t)
20 | defer os.RemoveAll(h.DataDir)
21 |
22 | // Prepare to run a simplified download with ndt7test server.
23 | URL, _ := url.Parse(srv.URL)
24 | URL.Scheme = "ws"
25 | URL.Path = spec.DownloadURLPath
26 | headers := http.Header{}
27 | headers.Add("Sec-WebSocket-Protocol", spec.SecWebSocketProtocol)
28 | headers.Add("User-Agent", "fake-user-agent")
29 | ctx := context.Background()
30 | dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
31 | conn, _, err := dialer.DialContext(ctx, URL.String(), headers)
32 | testingx.Must(t, err, "failed to dial websocket ndt7 test")
33 | err = simpleDownload(ctx, t, conn)
34 | if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure) {
35 | testingx.Must(t, err, "failed to download")
36 | }
37 |
38 | // Allow the server time to save the file, the client may stop before the server does.
39 | time.Sleep(1 * time.Second)
40 | // Verify a file was saved.
41 | m, err := filepath.Glob(h.DataDir + "/ndt7/*/*/*/*")
42 | testingx.Must(t, err, "failed to glob datadir: %s", h.DataDir)
43 | if len(m) == 0 {
44 | t.Errorf("no files found")
45 | }
46 | }
47 |
48 | func simpleDownload(ctx context.Context, t *testing.T, conn *websocket.Conn) error {
49 | defer conn.Close()
50 | wholectx, cancel := context.WithTimeout(ctx, spec.MaxRuntime)
51 | defer cancel()
52 | conn.SetReadLimit(spec.MaxMessageSize)
53 | err := conn.SetReadDeadline(time.Now().Add(spec.MaxRuntime))
54 | testingx.Must(t, err, "failed to set read deadline")
55 |
56 | var total int64
57 | // WARNING: this is not a reference client.
58 | for wholectx.Err() == nil {
59 | _, mdata, err := conn.ReadMessage()
60 | if err != nil {
61 | return err
62 | }
63 | total += int64(len(mdata))
64 | }
65 | return nil
66 | }
67 |
--------------------------------------------------------------------------------
/ndt5/protocol/messager_test.go:
--------------------------------------------------------------------------------
1 | package protocol
2 |
3 | import (
4 | "errors"
5 | "testing"
6 |
7 | "github.com/m-lab/ndt-server/ndt5/web100"
8 | )
9 |
10 | func assertJSONMessagerIsMessager(jm *jsonMessager) {
11 | func(m Messager) {}(jm)
12 | }
13 |
14 | func assertTLVMessagerIsMessager(tm *tlvMessager) {
15 | func(m Messager) {}(tm)
16 | }
17 |
18 | type fakeMessager struct {
19 | sentMessages []string
20 | errorAfter int
21 | }
22 |
23 | func (fm *fakeMessager) SendMessage(_ MessageType, msg []byte) error {
24 | fm.sentMessages = append(fm.sentMessages, string(msg))
25 | if fm.errorAfter > 0 {
26 | defer func() { fm.errorAfter-- }()
27 | if fm.errorAfter == 1 {
28 | return errors.New("Error for testing")
29 | }
30 | }
31 | return nil
32 | }
33 |
34 | func (fm *fakeMessager) SendS2CResults(throughputKbps, unsentBytes, totalSentBytes int64) error {
35 | return nil
36 | }
37 |
38 | func (fm *fakeMessager) ReceiveMessage(MessageType) ([]byte, error) { return []byte{}, nil }
39 |
40 | func (fm *fakeMessager) Encoding() Encoding {
41 | return Unknown
42 | }
43 |
44 | func TestSendMetrics(t *testing.T) {
45 | data := &web100.Metrics{}
46 | fm := &fakeMessager{}
47 | err := SendMetrics(data, fm, "")
48 | if err != nil {
49 | t.Error("Error should be nil", err)
50 | }
51 | // 73 was chosen because we needed a number that was greater than zero and not
52 | // greater than the number of fields in the Metrics struct. This is a moving
53 | // target, so we don't want to be too specific and require equality with the
54 | // current count. There were a total of 73 fields as of 2019-08-23, so that's a
55 | // good lower bound.
56 | if len(fm.sentMessages) < 73 {
57 | t.Error("Bad messages:", len(fm.sentMessages), fm)
58 | }
59 | }
60 |
61 | func TestSendMetricsWithErrors(t *testing.T) {
62 | data := &web100.Metrics{}
63 | // Erroring after 25 fields means that the error occurs inside the tcpinfo
64 | // struct, which exercises both error cases in the recursive function.
65 | fm := &fakeMessager{
66 | errorAfter: 25,
67 | }
68 | err := SendMetrics(data, fm, "")
69 | if err == nil {
70 | t.Error("Error should not be nil", err)
71 | }
72 | if len(fm.sentMessages) > 25 {
73 | t.Error("Too many messages sent:", fm)
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/netx/iface/fdinfo.go:
--------------------------------------------------------------------------------
1 | // Package iface provides access to network connection operations via file
2 | // descriptor. The implementation MUST be correct by inspection.
3 | package iface
4 |
5 | import (
6 | "net"
7 | "os"
8 |
9 | "github.com/m-lab/ndt-server/bbr"
10 | "github.com/m-lab/ndt-server/tcpinfox"
11 | "github.com/m-lab/tcp-info/inetdiag"
12 | "github.com/m-lab/tcp-info/tcp"
13 | "github.com/m-lab/uuid"
14 | )
15 |
16 | // ConnFile provides access to underlying network file.
17 | type ConnFile interface {
18 | DupFile(tc *net.TCPConn) (*os.File, error)
19 | }
20 |
21 | // NetInfo provides access to network connection metadata.
22 | type NetInfo interface {
23 | GetUUID(fp *os.File) (string, error)
24 | GetBBRInfo(fp *os.File) (inetdiag.BBRInfo, error)
25 | GetTCPInfo(fp *os.File) (*tcp.LinuxTCPInfo, error)
26 | }
27 |
28 | // RealConnInfo implements both the ConnFile and NetInfo interfaces.
29 | type RealConnInfo struct{}
30 |
31 | // DupFile returns the corresponding *os.File. Note that the
32 | // returned *os.File is a dup() of the original, hence you now have ownership
33 | // of two objects that you need to remember to defer Close() of.
34 | func (f *RealConnInfo) DupFile(tc *net.TCPConn) (*os.File, error) {
35 | // Implementation note: according to a 2013 message on golang-nuts [1], the
36 | // code that follows is broken on Unix because calling File() makes the socket
37 | // blocking so causing Go to use more threads and, additionally, "timer wheel
38 | // inside net package never fires". However, an April, 19 2018 commit
39 | // on src/net/tcpsock.go apparently has removed such restriction and so now
40 | // (i.e. since go1.11beta1) it's safe to use the code below [2, 3].
41 | //
42 | // [1] https://grokbase.com/t/gg/golang-nuts/1349whs82r
43 | //
44 | // [2] https://github.com/golang/go/commit/60e3ebb9cba
45 | //
46 | // [3] https://github.com/golang/go/issues/24942
47 | //
48 | // For this reason, this code only works correctly with go >= 1.11.
49 | return tc.File()
50 | }
51 |
52 | // GetUUID returns a UUID for the given file pointer.
53 | func (f *RealConnInfo) GetUUID(fp *os.File) (string, error) {
54 | return uuid.FromFile(fp)
55 | }
56 |
57 | // GetBBRInfo returns BBRInfo for the given file pointer.
58 | func (f *RealConnInfo) GetBBRInfo(fp *os.File) (inetdiag.BBRInfo, error) {
59 | return bbr.GetBBRInfo(fp)
60 | }
61 |
62 | // GetTCPInfo returns TCPInfo for the given file pointer.
63 | func (f *RealConnInfo) GetTCPInfo(fp *os.File) (*tcp.LinuxTCPInfo, error) {
64 | return tcpinfox.GetTCPInfo(fp)
65 | }
66 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 | services:
3 | ndt-server:
4 | image: ndt-server
5 | volumes:
6 | - ./certs:/certs
7 | - ./html:/html
8 | - ./schemas:/schemas
9 | - ./resultsdir:/resultsdir
10 | - ./localgcs:/localgcs
11 | cap_drop:
12 | - ALL
13 | depends_on:
14 | generate-schemas:
15 | condition: service_completed_successfully
16 |
17 | # NOTE: All service containers will use the same network and IP. All ports
18 | # must be configured on the first.
19 | ports:
20 | # ndt-server prometheus, alt TLS and alt non-tls ports.
21 | - target: 9990
22 | published: 9990
23 | protocol: tcp
24 | mode: bridge
25 | - target: 4443
26 | published: 4443
27 | protocol: tcp
28 | mode: bridge
29 | - target: 8080
30 | published: 8080
31 | protocol: tcp
32 | mode: bridge
33 | # jostler prometheus.
34 | - target: 9991
35 | published: 9991
36 | protocol: tcp
37 | mode: bridge
38 | command:
39 | - /ndt-server
40 | - -cert=/certs/cert.pem
41 | - -key=/certs/key.pem
42 | - -datadir=/resultsdir/ndt
43 | - -ndt7_addr=:4443
44 | - -ndt7_addr_cleartext=:8080
45 | - -compress-results=false
46 | - -prometheusx.listen-address=:9990
47 |
48 | jostler:
49 | image: measurementlab/jostler:v1.0.7
50 | volumes:
51 | - ./resultsdir:/resultsdir
52 | - ./schemas:/schemas
53 | - ./localgcs:/localgcs
54 | network_mode: "service:ndt-server"
55 | depends_on:
56 | generate-schemas:
57 | condition: service_completed_successfully
58 | command:
59 | - -gcs-local-disk
60 | - -mlab-node-name=ndt-mlab1-lga01.mlab-sandbox.measurement-lab.org
61 | - -gcs-bucket=newclient,download,upload
62 | - -gcs-data-dir=/localgcs/autoload/v1
63 | - -local-data-dir=/resultsdir
64 | - -experiment=ndt
65 | - -datatype=ndt7
66 | - -datatype-schema-file=ndt7:/schemas/ndt7.json
67 | - -bundle-size-max=81920
68 | - -bundle-age-max=10s
69 | - -missed-age=20s
70 | - -missed-interval=15s
71 | - -verbose
72 | - -prometheusx.listen-address=:9991
73 |
74 | generate-schemas:
75 | image: ndt-server
76 | build:
77 | context: .
78 | dockerfile: Dockerfile.local
79 | volumes:
80 | - ./schemas:/schemas
81 | entrypoint:
82 | - /go/bin/generate-schemas
83 | - -ndt7=/schemas/ndt7.json
84 | - -ndt5=/schemas/ndt5.json
85 |
--------------------------------------------------------------------------------
/data/result.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/m-lab/ndt-server/ndt5/c2s"
7 | "github.com/m-lab/ndt-server/ndt5/control"
8 | "github.com/m-lab/ndt-server/ndt5/s2c"
9 |
10 | "github.com/m-lab/ndt-server/ndt7/model"
11 | )
12 |
13 | // NDTResult is preserved for legacy compatibility with an older unified version
14 | // of the NDT5 and NDT7 result structures below.
15 | // TODO(github.com/m-lab/ndt-server/issues/260) remove this alias once no one uses it.
16 | type NDTResult = NDT5Result
17 |
18 | // NDT5Result is the struct that is serialized as JSON to disk as the archival
19 | // record of an NDT test.
20 | //
21 | // This struct is dual-purpose. It contains the necessary data to allow joining
22 | // with tcp-info data and traceroute-caller data as well as any other UUID-based
23 | // data. It also contains enough data for interested parties to perform
24 | // lightweight data analysis without needing to join with other tools.
25 | //
26 | // WARNING: The BigQuery schema is inferred directly from this structure. To
27 | // preserve compatibility with historical data, never remove fields.
28 | // For more information see: https://github.com/m-lab/etl/issues/719
29 | type NDT5Result struct {
30 | // GitShortCommit is the Git commit (short form) of the running server code.
31 | GitShortCommit string
32 | // Version is the symbolic version (if any) of the running server code.
33 | Version string
34 |
35 | // All data members should all be self-describing. In the event of confusion,
36 | // rename them to add clarity rather than adding a comment.
37 | ServerIP string
38 | ServerPort int
39 | ClientIP string
40 | ClientPort int
41 |
42 | StartTime time.Time
43 | EndTime time.Time
44 |
45 | // ndt5
46 | Control *control.ArchivalData `json:",omitempty"`
47 | C2S *c2s.ArchivalData `json:",omitempty"`
48 | S2C *s2c.ArchivalData `json:",omitempty"`
49 | }
50 |
51 | // NDT7Result is the struct that is serialized as JSON to disk as the archival
52 | // record of an NDT7 test. This is similar to, but independent from, the NDT5Result.
53 | type NDT7Result struct {
54 | // GitShortCommit is the Git commit (short form) of the running server code.
55 | GitShortCommit string
56 | // Version is the symbolic version (if any) of the running server code.
57 | Version string
58 |
59 | // All data members should all be self-describing. In the event of confusion,
60 | // rename them to add clarity rather than adding a comment.
61 | ServerIP string
62 | ServerPort int
63 | ClientIP string
64 | ClientPort int
65 |
66 | StartTime time.Time
67 | EndTime time.Time
68 |
69 | // ndt7
70 | Upload *model.ArchivalData `json:",omitempty"`
71 | Download *model.ArchivalData `json:",omitempty"`
72 | }
73 |
--------------------------------------------------------------------------------
/ndt7/upload/sender/sender.go:
--------------------------------------------------------------------------------
1 | // Package sender implements the upload sender.
2 | package sender
3 |
4 | import (
5 | "context"
6 | "time"
7 |
8 | "github.com/gorilla/websocket"
9 | "github.com/m-lab/ndt-server/logging"
10 | "github.com/m-lab/ndt-server/ndt7/closer"
11 | "github.com/m-lab/ndt-server/ndt7/measurer"
12 | ndt7metrics "github.com/m-lab/ndt-server/ndt7/metrics"
13 | "github.com/m-lab/ndt-server/ndt7/model"
14 | "github.com/m-lab/ndt-server/ndt7/ping"
15 | "github.com/m-lab/ndt-server/ndt7/spec"
16 | )
17 |
18 | // Start sends measurement messages (status messages) to the client conn. Each
19 | // measurement message will also be saved to data.
20 | //
21 | // Liveness guarantee: the sender will not be stuck sending for more than the
22 | // MaxRuntime of the subtest. This is enforced by setting the write deadline to
23 | // Time.Now() + MaxRuntime.
24 | func Start(ctx context.Context, conn *websocket.Conn, data *model.ArchivalData) error {
25 | logging.Logger.Debug("sender: start")
26 | proto := ndt7metrics.ConnLabel(conn)
27 |
28 | // Start collecting connection measurements. Measurements will be sent to
29 | // src until DefaultRuntime, when the src channel is closed.
30 | mr := measurer.New(conn, data.UUID)
31 | src := mr.Start(ctx, spec.DefaultRuntime)
32 | defer logging.Logger.Debug("sender: stop")
33 | defer mr.Stop(src)
34 |
35 | deadline := time.Now().Add(spec.MaxRuntime)
36 | err := conn.SetWriteDeadline(deadline) // Liveness!
37 | if err != nil {
38 | logging.Logger.WithError(err).Warn("sender: conn.SetWriteDeadline failed")
39 | ndt7metrics.ClientSenderErrors.WithLabelValues(
40 | proto, string(spec.SubtestUpload), "set-write-deadline").Inc()
41 | return err
42 | }
43 |
44 | // Record measurement start time, and prepare recording of the endtime on return.
45 | data.StartTime = time.Now().UTC()
46 | defer func() {
47 | data.EndTime = time.Now().UTC()
48 | }()
49 | for {
50 | m, ok := <-src
51 | if !ok { // This means that the previous step has terminated
52 | closer.StartClosing(conn)
53 | ndt7metrics.ClientSenderErrors.WithLabelValues(
54 | proto, string(spec.SubtestUpload), "measurer-closed").Inc()
55 | return nil
56 | }
57 | if err := conn.WriteJSON(m); err != nil {
58 | logging.Logger.WithError(err).Warn("sender: conn.WriteJSON failed")
59 | ndt7metrics.ClientSenderErrors.WithLabelValues(
60 | proto, string(spec.SubtestUpload), "write-json").Inc()
61 | return err
62 | }
63 | // Only save measurements sent to the client.
64 | data.ServerMeasurements = append(data.ServerMeasurements, m)
65 | if err := ping.SendTicks(conn, deadline); err != nil {
66 | logging.Logger.WithError(err).Warn("sender: ping.SendTicks failed")
67 | ndt7metrics.ClientSenderErrors.WithLabelValues(
68 | proto, string(spec.SubtestUpload), "ping-send-ticks").Inc()
69 | return err
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/fullstack/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Starts the ndt-server binary running with all its associated supporting
4 | # services set up just right. If you just want to run a server that speaks the
5 | # unencrypted NDT5 (legacy) protocol, then you can run:
6 | # $ docker run --net=host measurementlab/ndt
7 | # and you will get an NDT server running on port 3001, with data being saved to
8 | # the in-container directory /var/spool/ndt/
9 | #
10 | # If you would like to run NDT7 tests (which you should, it is a simpler
11 | # protocol and a more robust measurement), then you will need a private key and
12 | # a TLS certificate (let's assume they are called "/etc/certs/key.pem" and
13 | # "/etc/certs/cert.pem"). To run an NDT7 server on port 443, you can do:
14 | # $ docker run -v /etc/certs:/certs --net=host measurementlab/ndt \
15 | # --key=/certs/key.pem --cert=/certs/cert.pem
16 | #
17 | # The NDT server produces data on disk. If you would like this data saved to a
18 | # directory outside of the docker container, then you need to mount the external
19 | # directory inside the container at /var/spool/ndt using the -v argument to
20 | # "docker run".
21 | #
22 | # All arguments to this script are passed directly through to ndt-server.
23 |
24 | set -euxo pipefail
25 |
26 |
27 | # Set up the filesystem.
28 |
29 | # Set up UUIDs to have a common race-free prefix.
30 | UUID_FILE=$(mktemp /tmp/uuidprefix.XXXXXX)
31 | /create-uuid-prefix-file --filename="${UUID_FILE}"
32 |
33 | # Set up the data directory.
34 | DATA_DIR=/var/spool/ndt
35 | mkdir -p "${DATA_DIR}"
36 |
37 |
38 | # Start all services.
39 |
40 | # Start the tcp-info logging service.
41 | mkdir -p "${DATA_DIR}"/tcpinfo
42 | /tcp-info \
43 | --prometheusx.listen-address=:9991 \
44 | --uuid-prefix-file="${UUID_FILE}" \
45 | --output="${DATA_DIR}"/tcpinfo \
46 | --tcpinfo.eventsocket=/var/local/tcpeventsocket.sock \
47 | &
48 |
49 | echo "Waiting for the tcpinfo eventsocket to become available..."
50 | while ! socat -u OPEN:/dev/null UNIX-CONNECT:/var/local/tcpeventsocket.sock; do
51 | sleep 1
52 | done
53 |
54 | # Start the traceroute service.
55 | mkdir -p "${DATA_DIR}"/traceroute
56 | /traceroute-caller \
57 | --prometheusx.listen-address=:9992 \
58 | --uuid-prefix-file="${UUID_FILE}" \
59 | --hopannotation-output="${DATA_DIR}"/hopannotation1 \
60 | --traceroute-output="${DATA_DIR}"/scamper1 \
61 | --tcpinfo.eventsocket=/var/local/tcpeventsocket.sock \
62 | &
63 |
64 | # Start packet header capture.
65 | mkdir -p "${DATA_DIR}"/pcap
66 | /packet-headers \
67 | --prometheusx.listen-address=:9993 \
68 | --datadir="${DATA_DIR}"/pcap \
69 | --tcpinfo.eventsocket=/var/local/tcpeventsocket.sock \
70 | &
71 |
72 |
73 | # Start the NDT server.
74 | /ndt-server \
75 | --uuid-prefix-file="${UUID_FILE}" \
76 | --datadir="${DATA_DIR}" \
77 | $*
78 | &
79 |
80 | # Any of the backgrounded processes dying should kill the whole thing.
81 | wait -n
82 |
--------------------------------------------------------------------------------
/ndt5/README.md:
--------------------------------------------------------------------------------
1 | # NDT5 ndt-server code
2 |
3 | All code in this directory tree is related to the support of the legacy NDT5
4 | protocol. We have many extant clients that use this protocol, and we don't
5 | want to leave them high and dry, but new clients are encouraged to use the
6 | services provided by ndt7. The test is streamlined, the client is easier to
7 | write, and basically everything about it is better.
8 |
9 | In this subtree, we support existing clients, but we will be adding no new
10 | functionality. If you are reading this and trying to decide how to implement
11 | a speed test, use ndt7 and not the legacy, ndt5 protocol. The legacy protocol is
12 | deprecated. It will be supported until usage drops to very low levels, but it
13 | is also not recommended for new integrations or code.
14 |
15 | ## NDT5 Metrics
16 |
17 | Summary of metrics useful for monitoring client request, success, and error rates.
18 |
19 | * `ndt5_control_total{protocol, result}` counts every client connection
20 | that reaches `HandleControlChannel`.
21 |
22 | * The "protocol=" label matches the client protocol, e.g., "WS", "WSS", or
23 | "PLAIN".
24 | * The "result=" label is either "okay" or "panic".
25 | * All result="panic" results also count specific causes in
26 | `ndt5_control_panic_total`.
27 | * All result="okay" results come from "protocol complete" clients.
28 |
29 | * `ndt5_client_test_requested_total{protocol, direction}` counts
30 | client-requested tests.
31 |
32 | * The "protocol=" label is the same as above.
33 | * The "direction=" label will have values like "c2s" and "s2c".
34 | * If the client continues the test, then the result will be counted in
35 | `ndt5_client_test_results_total`.
36 |
37 | * `ndt5_client_test_results_total{protocol, direction, result}` counts the
38 | results of client-requested tests.
39 |
40 | * The "protocol=" and "direction=" labels are as above.
41 | * The "result=" label is either "okay-with-rate", "error-with-rate" or
42 | "error-without-rate".
43 | * All result="okay-with-rate" count all "protocol complete" clients up to that
44 | point.
45 | * All result=~"error-.*" results also count specific causes in
46 | `ndt5_client_test_errors_total`.
47 |
48 | * `ndt5_client_test_errors_total{protocol, direction, error}`
49 |
50 | * The "protocol=" and "direction=" labels are as above.
51 | * The "error=" label contains unique values mapping to specific error paths in
52 | the ndt-server.
53 |
54 | Expected invariants:
55 |
56 | * `sum(ndt5_control_channel_duration_count) == sum(ndt5_control_total)`
57 | * `sum(ndt5_control_total{result="panic"}) == sum(ndt5_control_panic_total)`
58 | * `sum(ndt5_client_test_results_total{result=~"error.*"}) == sum(ndt5_client_test_errors_total)`
59 |
60 | NOTE:
61 |
62 | * `ndt5_client_test_results_total` may be less than `ndt5_client_test_requested_total`
63 | if the client hangs up before the test can run.
64 |
--------------------------------------------------------------------------------
/cmd/generate-schemas/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/m-lab/ndt-server/cmd/generate-schemas
2 |
3 | go 1.20
4 |
5 | require (
6 | cloud.google.com/go/bigquery v1.49.0
7 | github.com/m-lab/go v0.1.66
8 | github.com/m-lab/ndt-server v0.20.17
9 | )
10 |
11 | require (
12 | cloud.google.com/go v0.110.0 // indirect
13 | cloud.google.com/go/compute v1.18.0 // indirect
14 | cloud.google.com/go/compute/metadata v0.2.3 // indirect
15 | cloud.google.com/go/iam v0.12.0 // indirect
16 | github.com/andybalholm/brotli v1.0.4 // indirect
17 | github.com/apache/arrow/go/v11 v11.0.0 // indirect
18 | github.com/apache/thrift v0.16.0 // indirect
19 | github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 // indirect
20 | github.com/beorn7/perks v1.0.1 // indirect
21 | github.com/cespare/xxhash/v2 v2.2.0 // indirect
22 | github.com/goccy/go-json v0.9.11 // indirect
23 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
24 | github.com/golang/protobuf v1.5.2 // indirect
25 | github.com/golang/snappy v0.0.4 // indirect
26 | github.com/google/flatbuffers v2.0.8+incompatible // indirect
27 | github.com/google/go-cmp v0.5.9 // indirect
28 | github.com/google/uuid v1.3.0 // indirect
29 | github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
30 | github.com/googleapis/gax-go/v2 v2.7.0 // indirect
31 | github.com/gorilla/websocket v1.5.0 // indirect
32 | github.com/klauspost/asmfmt v1.3.2 // indirect
33 | github.com/klauspost/compress v1.15.9 // indirect
34 | github.com/klauspost/cpuid/v2 v2.0.9 // indirect
35 | github.com/m-lab/tcp-info v1.5.3 // indirect
36 | github.com/m-lab/uuid v1.0.1 // indirect
37 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
38 | github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
39 | github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
40 | github.com/pierrec/lz4/v4 v4.1.15 // indirect
41 | github.com/prometheus/client_golang v1.13.0 // indirect
42 | github.com/prometheus/client_model v0.2.0 // indirect
43 | github.com/prometheus/common v0.37.0 // indirect
44 | github.com/prometheus/procfs v0.8.0 // indirect
45 | github.com/zeebo/xxh3 v1.0.2 // indirect
46 | go.opencensus.io v0.24.0 // indirect
47 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
48 | golang.org/x/net v0.7.0 // indirect
49 | golang.org/x/oauth2 v0.5.0 // indirect
50 | golang.org/x/sync v0.1.0 // indirect
51 | golang.org/x/sys v0.5.0 // indirect
52 | golang.org/x/text v0.7.0 // indirect
53 | golang.org/x/tools v0.1.12 // indirect
54 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
55 | google.golang.org/api v0.111.0 // indirect
56 | google.golang.org/appengine v1.6.7 // indirect
57 | google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4 // indirect
58 | google.golang.org/grpc v1.53.0 // indirect
59 | google.golang.org/protobuf v1.28.1 // indirect
60 | gopkg.in/yaml.v2 v2.4.0 // indirect
61 | )
62 |
--------------------------------------------------------------------------------
/bbr/bbr_linux.go:
--------------------------------------------------------------------------------
1 | package bbr
2 |
3 | // #include
4 | // #include
5 | // #include
6 | import "C"
7 |
8 | import (
9 | "math"
10 | "os"
11 | "syscall"
12 | "unsafe"
13 |
14 | "github.com/m-lab/tcp-info/inetdiag"
15 | )
16 |
17 | func enableBBR(fp *os.File) error {
18 | rawconn, err := fp.SyscallConn()
19 | if err != nil {
20 | return err
21 | }
22 | var syscallErr error
23 | err = rawconn.Control(func(fd uintptr) {
24 | // Note: Fd() returns uintptr but on Unix we can safely use int for sockets.
25 | syscallErr = syscall.SetsockoptString(int(fd), syscall.IPPROTO_TCP, syscall.TCP_CONGESTION, "bbr")
26 | })
27 | if err != nil {
28 | return err
29 | }
30 | return syscallErr
31 | }
32 |
33 | func getMaxBandwidthAndMinRTT(fp *os.File) (inetdiag.BBRInfo, error) {
34 | cci := C.union_tcp_cc_info{}
35 | size := uint32(C.sizeof_union_tcp_cc_info)
36 | metrics := inetdiag.BBRInfo{}
37 | rawconn, rawConnErr := fp.SyscallConn()
38 | if rawConnErr != nil {
39 | return metrics, rawConnErr
40 | }
41 | var syscallErr syscall.Errno
42 | err := rawconn.Control(func(fd uintptr) {
43 | _, _, syscallErr = syscall.Syscall6(
44 | uintptr(syscall.SYS_GETSOCKOPT),
45 | fd,
46 | uintptr(C.IPPROTO_TCP),
47 | uintptr(C.TCP_CC_INFO),
48 | uintptr(unsafe.Pointer(&cci)),
49 | uintptr(unsafe.Pointer(&size)),
50 | uintptr(0))
51 | })
52 | if err != nil {
53 | return metrics, err
54 | }
55 | if syscallErr != 0 {
56 | // C.get_bbr_info returns ENOSYS when the system does not support BBR. In
57 | // such case let us map the error to ErrNoSupport, such that this Linux
58 | // system looks like any other system where BBR is not available. This way
59 | // the code for dealing with this error is not platform dependent.
60 | if syscallErr == syscall.ENOSYS {
61 | return metrics, ErrNoSupport
62 | }
63 | return metrics, syscallErr
64 | }
65 | // Apparently, tcp_bbr_info is the only congestion control data structure
66 | // to occupy five 32 bit words. Currently, in September 2018, the other two
67 | // data structures (i.e. Vegas and DCTCP) both occupy four 32 bit words.
68 | //
69 | // See include/uapi/linux/inet_diag.h in torvalds/linux@bbb6189d.
70 | if size != C.sizeof_struct_tcp_bbr_info {
71 | return metrics, syscall.EINVAL
72 | }
73 | bbrip := (*C.struct_tcp_bbr_info)(unsafe.Pointer(&cci[0]))
74 | // Convert the values from the kernel provided units to the units that
75 | // we're going to use in ndt7. The units we use are the most common ones
76 | // in which people typically expects these variables.
77 | maxbw := uint64(bbrip.bbr_bw_hi)<<32 | uint64(bbrip.bbr_bw_lo)
78 | if maxbw > math.MaxInt64 {
79 | return metrics, syscall.EOVERFLOW
80 | }
81 | metrics.BW = int64(maxbw) // Java has no uint64
82 | metrics.MinRTT = uint32(bbrip.bbr_min_rtt)
83 | metrics.PacingGain = uint32(bbrip.bbr_pacing_gain)
84 | metrics.CwndGain = uint32(bbrip.bbr_cwnd_gain)
85 | return metrics, nil
86 | }
87 |
--------------------------------------------------------------------------------
/ndt5/meta/meta.go:
--------------------------------------------------------------------------------
1 | package meta
2 |
3 | import (
4 | "context"
5 | "log"
6 | "strings"
7 | "time"
8 |
9 | "github.com/m-lab/ndt-server/metadata"
10 | "github.com/m-lab/ndt-server/ndt5/metrics"
11 | "github.com/m-lab/ndt-server/ndt5/ndt"
12 | "github.com/m-lab/ndt-server/ndt5/protocol"
13 | )
14 |
15 | // maxClientMessages is the maximum allowed messages we will accept from a client.
16 | var maxClientMessages = 20
17 |
18 | // ManageTest runs the meta tests. If the given ctx is canceled or the meta test
19 | // takes longer than 15sec, then ManageTest will return after the next ReceiveMessage.
20 | // The given protocolMessager should have its own connection timeout to prevent
21 | // "slow drip" clients holding the connection open indefinitely.
22 | func ManageTest(ctx context.Context, m protocol.Messager, s ndt.Server) ([]metadata.NameValue, error) {
23 | localCtx, localCancel := context.WithTimeout(ctx, 15*time.Second)
24 | defer localCancel()
25 |
26 | var err error
27 | var message []byte
28 | results := []metadata.NameValue{}
29 | connType := s.ConnectionType().Label()
30 |
31 | err = m.SendMessage(protocol.TestPrepare, []byte{})
32 | if err != nil {
33 | log.Println("META TestPrepare:", err)
34 | metrics.ClientTestErrors.WithLabelValues(connType, "meta", "TestPrepare").Inc()
35 | return nil, err
36 | }
37 | err = m.SendMessage(protocol.TestStart, []byte{})
38 | if err != nil {
39 | log.Println("META TestStart:", err)
40 | metrics.ClientTestErrors.WithLabelValues(connType, "meta", "TestStart").Inc()
41 | return nil, err
42 | }
43 | count := 0
44 | for count < maxClientMessages && localCtx.Err() == nil {
45 | message, err = m.ReceiveMessage(protocol.TestMsg)
46 | if string(message) == "" || err != nil {
47 | break
48 | }
49 | count++
50 |
51 | s := strings.SplitN(string(message), ":", 2)
52 | if len(s) != 2 {
53 | continue
54 | }
55 | name := strings.TrimSpace(s[0])
56 | if len(name) > 63 {
57 | name = name[:63]
58 | }
59 | value := strings.TrimSpace(s[1])
60 | if len(value) > 255 {
61 | value = value[:255]
62 | }
63 | results = append(results, metadata.NameValue{Name: name, Value: value})
64 | }
65 | if localCtx.Err() != nil {
66 | log.Println("META context error:", localCtx.Err())
67 | metrics.ClientTestErrors.WithLabelValues(connType, "meta", "context").Inc()
68 | return nil, localCtx.Err()
69 | }
70 | if err != nil {
71 | log.Println("Error reading JSON message:", err)
72 | metrics.ClientTestErrors.WithLabelValues(connType, "meta", "ReceiveMessage").Inc()
73 | return nil, err
74 | }
75 | // Count the number meta values sent by the client (when there are no errors).
76 | metrics.SubmittedMetaValues.Observe(float64(count))
77 | err = m.SendMessage(protocol.TestFinalize, []byte{})
78 | if err != nil {
79 | log.Println("META TestFinalize:", err)
80 | metrics.ClientTestErrors.WithLabelValues(connType, "meta", "TestFinalize").Inc()
81 | return nil, err
82 | }
83 | return results, nil
84 | }
85 |
--------------------------------------------------------------------------------
/ndt7/results/file.go:
--------------------------------------------------------------------------------
1 | package results
2 |
3 | import (
4 | "compress/gzip"
5 | "encoding/json"
6 | "io"
7 | "os"
8 | "path"
9 | "time"
10 |
11 | "github.com/m-lab/ndt-server/logging"
12 | "github.com/m-lab/ndt-server/ndt7/spec"
13 | )
14 |
15 | // File is the file where we save measurements.
16 | type File struct {
17 | // Writer is the writer for results.
18 | Writer io.Writer
19 |
20 | // UUID is the UUID of this subtest.
21 | UUID string
22 |
23 | // fp is the underlying writer file.
24 | fp *os.File
25 |
26 | // gzip is an optional writer for compressed results.
27 | gzip *gzip.Writer
28 | }
29 |
30 | // newFile opens a measurements file in the current working
31 | // directory on success and returns an error on failure.
32 | func newFile(datadir, what, uuid string, compress bool) (*File, error) {
33 | timestamp := time.Now().UTC()
34 | dir := path.Join(datadir, "ndt7", timestamp.Format("2006/01/02"))
35 | err := os.MkdirAll(dir, 0755)
36 | if err != nil {
37 | return nil, err
38 | }
39 | name := dir + "/ndt7-" + what + "-" + timestamp.Format("20060102T150405.000000000Z") + "." + uuid + ".json"
40 | if compress {
41 | name += ".gz"
42 | }
43 | // My assumption here is that we have nanosecond precision and hence it's
44 | // unlikely to have conflicts. If I'm wrong, O_EXCL will let us know.
45 | fp, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
46 | if err != nil {
47 | return nil, err
48 | }
49 | if !compress {
50 | return &File{
51 | Writer: fp,
52 | fp: fp,
53 | }, nil
54 | }
55 | writer, err := gzip.NewWriterLevel(fp, gzip.BestSpeed)
56 | if err != nil {
57 | fp.Close()
58 | return nil, err
59 | }
60 | return &File{
61 | Writer: writer,
62 | fp: fp,
63 | gzip: writer,
64 | }, nil
65 | }
66 |
67 | // NewFile creates a file for saving results in datadir named after the uuid and
68 | // kind. Returns the results file on success. Returns an error in case of
69 | // failure. The "datadir" argument specifies the directory on disk to write the
70 | // data into and the what argument should indicate whether this is a
71 | // spec.SubtestDownload or a spec.SubtestUpload ndt7 measurement.
72 | func NewFile(uuid string, datadir string, what spec.SubtestKind, compress bool) (*File, error) {
73 | fp, err := newFile(datadir, string(what), uuid, compress)
74 | if err != nil {
75 | logging.Logger.WithError(err).Warn("newFile failed")
76 | return nil, err
77 | }
78 | return fp, nil
79 | }
80 |
81 | // Close closes the measurement file.
82 | func (fp *File) Close() error {
83 | if fp.gzip != nil {
84 | err := fp.gzip.Close()
85 | if err != nil {
86 | fp.fp.Close()
87 | return err
88 | }
89 | }
90 | return fp.fp.Close()
91 | }
92 |
93 | // WriteResult serializes |result| as JSON.
94 | func (fp *File) WriteResult(result interface{}) error {
95 | data, err := json.Marshal(result)
96 | if err != nil {
97 | return err
98 | }
99 | _, err = fp.Writer.Write(data)
100 | return err
101 | }
102 |
--------------------------------------------------------------------------------
/deploy_ndt_to_gce.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -u
4 | set -x
5 |
6 | # These variables should not change much
7 | USAGE="Usage: $0 "
8 | PROJECT=${1:?Please provide project name: $USAGE}
9 | GCE_NAME=${2:?Please provide a GCE VM name: $USAGE}
10 | GCE_ZONE=${3:?Please provide GCE zone: $USAGE}
11 | SCP_FILES="certs collectd.prom lame_duck.prom"
12 | IMAGE_TAG="ndt-cloud"
13 | GCE_IMG_PROJECT="cos-cloud"
14 | GCE_IMG_FAMILY="cos-stable"
15 | NDT_DOCKER_IMAGE="pboothe/ndt-cloud:6122c37b15d80b6f84aebd47760fa39e4c570862"
16 |
17 | # Set the project and zone for all future gcloud commands.
18 | gcloud config set project $PROJECT
19 | gcloud config set compute/zone $GCE_ZONE
20 |
21 | # Make sure that the files we want to copy actually exist.
22 | for scp_file in ${SCP_FILES}; do
23 | if [[ ! -e "${scp_file}" ]]; then
24 | echo "Missing required file/dir: ${scp_file}!"
25 | exit 1
26 | fi
27 | done
28 |
29 | EXISTING_INSTANCE=$(gcloud compute instances list --filter "name=${GCE_NAME}")
30 | if [[ -z "${EXISTING_INSTANCE}" ]]; then
31 | gcloud compute instances create $GCE_NAME \
32 | --image-project $GCE_IMG_PROJECT \
33 | --image-family $GCE_IMG_FAMILY \
34 | --tags ${IMAGE_TAG} \
35 | --machine-type n1-standard-4 \
36 | --boot-disk-size 50GB \
37 | --metadata-from-file user-data=cloud-config.yaml
38 | fi
39 |
40 | # Copy required files to the GCE instance.
41 | gcloud compute scp --recurse $SCP_FILES $GCE_NAME:~
42 |
43 | # Build and start a new NDT container, deleting any existing one first.
44 | gcloud compute ssh $GCE_NAME --command \
45 | '[[ -n "$(docker ps --quiet --filter name=iupui_ndt)" ]] &&
46 | docker rm --force iupui_ndt'
47 |
48 | gcloud compute ssh $GCE_NAME --command "\
49 | docker run --detach --network host \
50 | --volume ~/certs:/certs --volume ~/logs:/var/spool/iupui_ndt \
51 | --restart always --name iupui_ndt ${NDT_DOCKER_IMAGE}"
52 |
53 | # Create the directory /var/spool/node_exporter and copy our textfile module
54 | # files into it.
55 | gcloud compute ssh $GCE_NAME --command "sudo mkdir -p /var/spool/node_exporter"
56 | gcloud compute ssh $GCE_NAME --command "\
57 | sudo cp ~/*.prom /var/spool/node_exporter/"
58 |
59 | # Build and start a new Prometheus node_exporter container, deleting any
60 | # existing one first.
61 | gcloud compute ssh $GCE_NAME --command \
62 | '[[ -n "$(docker ps --quiet --filter name=node_exporter)" ]] &&
63 | docker rm --force node_exporter'
64 |
65 | gcloud compute ssh $GCE_NAME --command "\
66 | docker run --detach --network host \
67 | --volume /proc:/host/proc --volume /sys:/host/sys \
68 | --volume /var/spool/node_exporter:/var/spool/node_exporter \
69 | --name node_exporter --restart always prom/node-exporter \
70 | --path.procfs /host/proc --path.sysfs /host/sys \
71 | --collector.textfile.directory /var/spool/node_exporter \
72 | --no-collector.arp --no-collector.bcache --no-collector.conntrack \
73 | --no-collector.edac --no-collector.entropy --no-collector.filefd \
74 | --no-collector.hwmon --no-collector.infiniband --no-collector.ipvs \
75 | --no-collector.mdadm --no-collector.netstat --no-collector.sockstat \
76 | --no-collector.time --no-collector.timex --no-collector.uname \
77 | --no-collector.vmstat --no-collector.wifi --no-collector.xfs \
78 | --no-collector.zfs"
--------------------------------------------------------------------------------
/html/ndt7-download-worker.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser, node, worker */
2 |
3 | // workerMain is the WebWorker function that runs the ndt7 download test.
4 | const workerMain = function(ev) {
5 | 'use strict';
6 | const url = ev.data['///ndt/v7/download'];
7 | const sock = new WebSocket(url, 'net.measurementlab.ndt.v7');
8 | let now;
9 | if (typeof performance !== 'undefined' &&
10 | typeof performance.now === 'function') {
11 | now = () => performance.now();
12 | } else {
13 | now = () => Date.now();
14 | }
15 | downloadTest(sock, postMessage, now);
16 | };
17 |
18 | /**
19 | * downloadTest is a function that runs an ndt7 download test using the
20 | * passed-in websocket instance and the passed-in callback function. The
21 | * socket and callback are passed in to enable testing and mocking.
22 | *
23 | * @param {WebSocket} sock - The WebSocket being read.
24 | * @param {function} postMessage - A function for messages to the main thread.
25 | * @param {function} now - A function returning a time in milliseconds.
26 | */
27 | const downloadTest = function(sock, postMessage, now) {
28 | sock.onclose = function() {
29 | postMessage({
30 | MsgType: 'complete',
31 | });
32 | };
33 |
34 | sock.onerror = function(ev) {
35 | postMessage({
36 | MsgType: 'error',
37 | Error: ev.type,
38 | });
39 | };
40 |
41 | let start = now();
42 | let previous = start;
43 | let total = 0;
44 |
45 | sock.onopen = function() {
46 | start = now();
47 | previous = start;
48 | total = 0;
49 | postMessage({
50 | MsgType: 'start',
51 | Data: {
52 | ClientStartTime: start,
53 | },
54 | });
55 | };
56 |
57 | sock.onmessage = function(ev) {
58 | total +=
59 | (typeof ev.data.size !== 'undefined') ? ev.data.size : ev.data.length;
60 | // Perform a client-side measurement 4 times per second.
61 | const t = now();
62 | const every = 250; // ms
63 | if (t - previous > every) {
64 | postMessage({
65 | MsgType: 'measurement',
66 | ClientData: {
67 | ElapsedTime: (t - start) / 1000, // seconds
68 | NumBytes: total,
69 | // MeanClientMbps is calculated via the logic:
70 | // (bytes) * (bits / byte) * (megabits / bit) = Megabits
71 | // (Megabits) * (1/milliseconds) * (milliseconds / second) = Mbps
72 | // Collect the conversion constants, we find it is 8*1000/1000000
73 | // When we simplify we get: 8*1000/1000000 = .008
74 | MeanClientMbps: (total / (t - start)) * 0.008,
75 | },
76 | Source: 'client',
77 | });
78 | previous = t;
79 | }
80 |
81 | // Pass along every server-side measurement.
82 | if (typeof ev.data === 'string') {
83 | postMessage({
84 | MsgType: 'measurement',
85 | ServerMessage: ev.data,
86 | Source: 'server',
87 | });
88 | }
89 | };
90 | };
91 |
92 | // Node and browsers get onmessage defined differently.
93 | if (typeof self !== 'undefined') {
94 | self.onmessage = workerMain;
95 | } else if (typeof this !== 'undefined') {
96 | this.onmessage = workerMain;
97 | } else if (typeof onmessage !== 'undefined') {
98 | onmessage = workerMain;
99 | }
100 |
--------------------------------------------------------------------------------
/ndt7/handler/handler_integration_test.go:
--------------------------------------------------------------------------------
1 | // Package handler implements the WebSocket handler for ndt7.
2 | package handler_test
3 |
4 | import (
5 | "context"
6 | "net/http"
7 | "net/url"
8 | "testing"
9 | "time"
10 |
11 | "github.com/gorilla/websocket"
12 | "github.com/m-lab/go/testingx"
13 | "github.com/m-lab/ndt-server/ndt7/ndt7test"
14 | "github.com/m-lab/ndt-server/ndt7/spec"
15 | "github.com/m-lab/tcp-info/inetdiag"
16 | )
17 |
18 | // fakeServer implements the eventsocket.Server interface for testing the ndt7 handler.
19 | type fakeServer struct {
20 | created int
21 | deleted chan bool
22 | }
23 |
24 | func (f *fakeServer) Listen() error { return nil }
25 | func (f *fakeServer) Serve(context.Context) error { return nil }
26 | func (f *fakeServer) FlowCreated(timestamp time.Time, uuid string, sockid inetdiag.SockID) {
27 | f.created++
28 | }
29 | func (f *fakeServer) FlowDeleted(timestamp time.Time, uuid string) {
30 | close(f.deleted)
31 | }
32 |
33 | func TestHandler_Download(t *testing.T) {
34 | t.Run("download flow events", func(t *testing.T) {
35 | fs := &fakeServer{deleted: make(chan bool)}
36 | ndt7h, srv := ndt7test.NewNDT7Server(t)
37 | // Override the handler Events server with our fake server.
38 | ndt7h.Events = fs
39 |
40 | // Run a pseudo test to generate connection events.
41 | conn, err := simpleConnect(srv.URL)
42 | testingx.Must(t, err, "failed to dial websocket ndt7 test")
43 | err = downloadHelper(context.Background(), t, conn)
44 | if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure) {
45 | testingx.Must(t, err, "failed to download")
46 | }
47 | srv.Close()
48 |
49 | // Verify that both events have occurred once.
50 | if fs.created == 0 {
51 | t.Errorf("flow events created not detected; got %d, want 1", fs.created)
52 | }
53 | // Since the connection handler goroutine shutdown is independent of the
54 | // server and client connection shutdowns, wait for the fakeServer to
55 | // receive the delete flow message up to 15 seconds.
56 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
57 | defer cancel()
58 | select {
59 | case <-ctx.Done():
60 | t.Errorf("flow events not deleted before timeout")
61 | case <-fs.deleted:
62 | // Success.
63 | }
64 | })
65 | }
66 |
67 | func simpleConnect(srv string) (*websocket.Conn, error) {
68 | // Prepare to run a simplified download with ndt7test server.
69 | URL, _ := url.Parse(srv)
70 | URL.Scheme = "ws"
71 | URL.Path = spec.DownloadURLPath
72 | headers := http.Header{}
73 | headers.Add("Sec-WebSocket-Protocol", spec.SecWebSocketProtocol)
74 | headers.Add("User-Agent", "fake-user-agent")
75 | ctx := context.Background()
76 | dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
77 | conn, _, err := dialer.DialContext(ctx, URL.String(), headers)
78 | return conn, err
79 | }
80 |
81 | // WARNING: this is not a reference client.
82 | func downloadHelper(ctx context.Context, t *testing.T, conn *websocket.Conn) error {
83 | defer conn.Close()
84 | conn.SetReadLimit(spec.MaxMessageSize)
85 | err := conn.SetReadDeadline(time.Now().Add(spec.MaxRuntime))
86 | testingx.Must(t, err, "failed to set read deadline")
87 | _, _, err = conn.ReadMessage()
88 | if err != nil {
89 | return err
90 | }
91 | // We only read one message, so this is an early close.
92 | return conn.Close()
93 | }
94 |
--------------------------------------------------------------------------------
/html/ndt7.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
22 |
23 | ndt7 Speed Test
24 |
25 |
26 |
27 |
28 |
[Download]
29 |
[Upload]
30 |
31 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/spec/data-format.md:
--------------------------------------------------------------------------------
1 | # Data Format
2 |
3 | This specification describes how ndt-server serializes ndt7 data
4 | on disk. Other implementations of the ndt7 protocol MAY use other
5 | data serialization formats.
6 |
7 | This is version v0.3.0 of the data-format specification.
8 |
9 | For each subtest, ndt7 writes to the current working directory a Gzip
10 | compressed JSON file. The file name MUST match the following pattern:
11 |
12 | ```
13 | ndt7--T.Z..json.gz
14 | ```
15 |
16 | The only JSON value contains all metadata and measurements.
17 |
18 | ## Result JSON
19 |
20 | The result JSON value is complete record of the test. It consists of an
21 | object with fields for client and server IP and port, start and end time, and
22 | for ndt7 either an upload or download summary data.
23 |
24 | Both upload and download data use the same schema. Only "Upload" is shown below.
25 |
26 | ```JSON
27 | {
28 | "GitShortCommit": "773d318",
29 | "Version": "v0.9.1-20-g773d318",
30 | "ClientIP": "::1",
31 | "ClientPort": 40910,
32 | "ServerIP": "::1",
33 | "ServerPort": 443,
34 | "StartTime": "2019-07-16T15:26:05.987748459-04:00",
35 | "EndTime": "2019-07-16T15:26:16.008714743-04:00",
36 | "Upload": {
37 | "StartTime": "2019-07-16T15:26:05.987853779-04:00",
38 | "EndTime": "2019-07-16T15:26:16.008677965-04:00",
39 | "UUID": "soltesz99.nyc.corp.google.com_1563200740_unsafe_00000000000157C6",
40 | "ClientMeasurements": [
41 | ],
42 | "ClientMetadata": [
43 | ],
44 | "ServerMetadata": [
45 | ],
46 | "ServerMeasurements": [
47 | ]
48 | }
49 | }
50 | ```
51 |
52 | ## Client Metadata
53 |
54 | The keys contained in the ClientMetadata JSON are the ones provided by the client
55 | in the query string as specified in the "Query string parameters" section of
56 | [ndt7-protocol.md](ndt7-protocol.md).
57 |
58 | Valid JSON metadata object in ClientMetadata could look like this:
59 |
60 | ```JSON
61 | "ClientMetadata":[
62 | {
63 | "Name":"ClientLibraryName",
64 | "Value":"libndt7.js"
65 | },
66 | {
67 | "Name":"ClientLibraryVersion",
68 | "Value":"0.4"
69 | }
70 | ]
71 | ```
72 |
73 | ## Server Metadata
74 |
75 | The data contained in the ServerMetadata JSON is the one contained
76 | in the "-label" flag specified in the deployment configuration.
77 |
78 | Valid JSON metadata object in ServerMetadata could look like this:
79 |
80 | ```JSON
81 | "ServerMetadata":[
82 | {
83 | "Name":"deployment",
84 | "Value":"stable"
85 | },
86 | {
87 | "Name":"machine-type",
88 | "Value":"virtual"
89 | }
90 | ]
91 | ```
92 |
93 | ## Client and Server Measurements
94 |
95 | The elements of the ClientMeasurements and ServerMeasurements arrays
96 | represent individual measurements recorded by the client or server.
97 |
98 | A measurement is a JSON object containing the fields specified by
99 | [ndt7-protocol.md](ndt7-protocol.md) in the "Measurements message" section,
100 | except that a server MAY choose to remove the "ConnectionInfo" optional
101 | object to avoid storing duplicate information.
102 |
103 | A valid measurement JSON could be:
104 |
105 | ```JSON
106 | {
107 | "AppInfo": {
108 | "ElapsedTime": 1234,
109 | "NumBytes": 1234
110 | }
111 | }
112 | ```
113 |
--------------------------------------------------------------------------------
/ndt7/spec/spec.go:
--------------------------------------------------------------------------------
1 | // Package spec contains constants defined in the ndt7 specification.
2 | package spec
3 |
4 | import "time"
5 |
6 | // DownloadURLPath selects the download subtest.
7 | const DownloadURLPath = "/ndt/v7/download"
8 |
9 | // UploadURLPath selects the upload subtest.
10 | const UploadURLPath = "/ndt/v7/upload"
11 |
12 | // SecWebSocketProtocol is the WebSocket subprotocol used by ndt7.
13 | const SecWebSocketProtocol = "net.measurementlab.ndt.v7"
14 |
15 | // MaxMessageSize is the minimum value of the maximum message size
16 | // that an implementation MAY want to configure. Messages smaller than this
17 | // threshold MUST always be accepted by an implementation.
18 | const MaxMessageSize = 1 << 24
19 |
20 | // MaxScaledMessageSize is the maximum value of a scaled binary WebSocket
21 | // message size. This should be <= of MaxMessageSize. The 1<<20 value is
22 | // a good compromise between Go and JavaScript as seen in cloud based tests.
23 | const MaxScaledMessageSize = 1 << 20
24 |
25 | // ValidEarlyExitValues contains the set of accepted MB transfer amounts after which
26 | // ndt7 download tests can be prematurely terminated.
27 | // Client requests with values outside of this set will result in a 400 error.
28 | var ValidEarlyExitValues = []string{"250"}
29 |
30 | // EarlyExitParameterName is the name of the parameter that clients can use to terminate
31 | // ndt7 download tests once the test has transferred as many MB as the parameter's value.
32 | const EarlyExitParameterName = "early_exit"
33 |
34 | // DefaultWebsocketBufferSize is the read and write buffer sizes used when
35 | // creating a websocket connection. This size is independent of the websocket
36 | // message sizes defined above (which may be larger) and used to optimize read
37 | // and write operations. However, larger buffers will practically limit the
38 | // total number of concurrent connections possible. We use 1MB as a balance.
39 | const DefaultWebsocketBufferSize = 1 << 20
40 |
41 | // ScalingFraction sets the threshold for scaling binary messages. When
42 | // the current binary message size is <= than 1/scalingFactor of the
43 | // amount of bytes sent so far, we scale the message. This is documented
44 | // in the appendix of the ndt7 specification.
45 | const ScalingFraction = 16
46 |
47 | // AveragePoissonSamplingInterval is the average of a lambda distribution
48 | // used to decide when to perform next measurement.
49 | const AveragePoissonSamplingInterval = 250 * time.Millisecond
50 |
51 | // MinPoissonSamplingInterval is the min acceptable time that we want
52 | // the lambda distribution to return. Smaller values will be clamped
53 | // to be this value instead.
54 | const MinPoissonSamplingInterval = 25 * time.Millisecond
55 |
56 | // MaxPoissonSamplingInterval is the max acceptable time that we want
57 | // the lambda distribution to return. Bigger values will be clamped
58 | // to be this value instead.
59 | const MaxPoissonSamplingInterval = 625 * time.Millisecond
60 |
61 | // DefaultRuntime is the default runtime of a subtest
62 | const DefaultRuntime = 10 * time.Second
63 |
64 | // MaxRuntime is the maximum runtime of a subtest
65 | const MaxRuntime = 15 * time.Second
66 |
67 | // SubtestKind indicates the subtest kind
68 | type SubtestKind string
69 |
70 | const (
71 | // SubtestDownload is a download subtest
72 | SubtestDownload = SubtestKind("download")
73 |
74 | // SubtestUpload is a upload subtest
75 | SubtestUpload = SubtestKind("upload")
76 | )
77 |
78 | // Params defines the client parameters for ndt7 requests.
79 | type Params struct {
80 | IsEarlyExit bool
81 | MaxBytes int64
82 | }
83 |
--------------------------------------------------------------------------------
/ndt5/web100/web100_linux.go:
--------------------------------------------------------------------------------
1 | package web100
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log"
7 | "time"
8 |
9 | "github.com/m-lab/ndt-server/netx"
10 | "github.com/m-lab/tcp-info/tcp"
11 | )
12 |
13 | func summarize(snaps []tcp.LinuxTCPInfo) (*Metrics, error) {
14 | if len(snaps) == 0 {
15 | return nil, errors.New("zero-length list of data collected")
16 | }
17 | sumrtt := uint32(0)
18 | countrtt := uint32(0)
19 | maxrtt := uint32(0)
20 | minrtt := uint32(0)
21 | for _, snap := range snaps {
22 | countrtt++
23 | sumrtt += snap.RTT
24 | if snap.RTT < minrtt || minrtt == 0 {
25 | minrtt = snap.RTT
26 | }
27 | if snap.RTT > maxrtt {
28 | maxrtt = snap.RTT
29 | }
30 | }
31 | lastSnap := snaps[len(snaps)-1]
32 | info := &Metrics{
33 | TCPInfo: snaps[len(snaps)-1], // Save the last snapshot of TCPInfo data into the metric struct.
34 |
35 | MinRTT: minrtt / 1000, // tcpinfo is microsecond data, web100 needs milliseconds
36 | MaxRTT: maxrtt / 1000, // tcpinfo is microsecond data, web100 needs milliseconds
37 | SumRTT: sumrtt / 1000, // tcpinfo is microsecond data, web100 needs milliseconds
38 |
39 | CountRTT: countrtt, // This counts how many samples went into SumRTT
40 |
41 | CurMSS: lastSnap.SndMSS,
42 |
43 | // If this cast bites us, it's because of a 10 second test pushing more than
44 | // 2**31 packets * 1500 bytes/packet * 8 bits/byte / 10 seconds = 2,576,980,377,600 bits/second = 2.5Tbps
45 | // If we are using web100 variables to measure terabit connections then
46 | // something has gone horribly wrong. Please switch to NDT7+tcpinfo or
47 | // whatever their successor is.
48 | PktsOut: uint32(lastSnap.SegsOut),
49 | }
50 | return info, nil
51 | }
52 |
53 | func measureUntilContextCancellation(ctx context.Context, ci netx.ConnInfo) (*Metrics, error) {
54 | ticker := time.NewTicker(100 * time.Millisecond)
55 | // We need to make sure fp is closed when the polling loop ends to ensure legacy
56 | // clients work. See https://github.com/m-lab/ndt-server/issues/160.
57 | defer ticker.Stop()
58 |
59 | snaps := make([]tcp.LinuxTCPInfo, 0, 200) // Enough space for 20 seconds of data.
60 |
61 | // Poll until the context is canceled, but never more than once per ticker-firing.
62 | //
63 | // This slightly-funny way of writing the loop ensures that one last
64 | // measurement occurs after the context is canceled (unless the most recent
65 | // measurement and the context cancellation happened simultaneously, in which
66 | // case the most recent measurement should count as the last measurement).
67 | for ; ctx.Err() == nil; <-ticker.C {
68 | // Get the tcp_cc metrics
69 | _, snapshot, err := ci.ReadInfo()
70 | if err == nil {
71 | snaps = append(snaps, snapshot)
72 | } else {
73 | log.Println("Getsockopt error:", err)
74 | }
75 | }
76 | return summarize(snaps)
77 | }
78 |
79 | // MeasureViaPolling collects all required data by polling and returns a channel
80 | // for the results. This function may or may not send socket information along
81 | // the channel, depending on whether or not an error occurred. The value is sent
82 | // along the channel sometime after the context is canceled.
83 | func MeasureViaPolling(ctx context.Context, ci netx.ConnInfo) <-chan *Metrics {
84 | // Give a capacity of 1 because we will only ever send one message and the
85 | // buffer allows the component goroutine to exit when done, no matter what the
86 | // client does.
87 | c := make(chan *Metrics, 1)
88 | go func() {
89 | summary, err := measureUntilContextCancellation(ctx, ci)
90 | if err == nil {
91 | c <- summary
92 | }
93 | close(c)
94 | }()
95 | return c
96 | }
97 |
--------------------------------------------------------------------------------
/ndt5/handler/wshandler.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 | "strconv"
8 |
9 | "github.com/m-lab/access/controller"
10 | "github.com/m-lab/go/warnonerror"
11 | "github.com/m-lab/ndt-server/metadata"
12 | "github.com/m-lab/ndt-server/ndt5"
13 | "github.com/m-lab/ndt-server/ndt5/ndt"
14 | "github.com/m-lab/ndt-server/ndt5/protocol"
15 | "github.com/m-lab/ndt-server/ndt5/singleserving"
16 | "github.com/m-lab/ndt-server/ndt5/ws"
17 | )
18 |
19 | // WSHandler is both an ndt.Server and an http.Handler to allow websocket-based
20 | // NDT tests to be run by Go's http libraries.
21 | type WSHandler interface {
22 | ndt.Server
23 | http.Handler
24 | }
25 |
26 | type httpFactory struct{}
27 |
28 | func (hf *httpFactory) SingleServingServer(dir string) (ndt.SingleMeasurementServer, error) {
29 | return singleserving.ListenWS(dir)
30 | }
31 |
32 | // httpHandler handles requests that come in over HTTP or HTTPS. It should be
33 | // created with MakeHTTPHandler() or MakeHTTPSHandler().
34 | type httpHandler struct {
35 | serverFactory ndt.SingleMeasurementServerFactory
36 | connectionType ndt.ConnectionType
37 | datadir string
38 | metadata []metadata.NameValue
39 | }
40 |
41 | func (s *httpHandler) DataDir() string { return s.datadir }
42 | func (s *httpHandler) ConnectionType() ndt.ConnectionType { return s.connectionType }
43 | func (s *httpHandler) Metadata() []metadata.NameValue { return s.metadata }
44 |
45 | func (s *httpHandler) LoginCeremony(conn protocol.Connection) (int, error) {
46 | // WS and WSS both only support JSON clients and not TLV clients.
47 | msg, err := protocol.ReceiveJSONMessage(conn, protocol.MsgExtendedLogin)
48 | if err != nil {
49 | return 0, err
50 | }
51 | return strconv.Atoi(msg.Tests)
52 | }
53 |
54 | func (s *httpHandler) SingleServingServer(dir string) (ndt.SingleMeasurementServer, error) {
55 | return s.serverFactory.SingleServingServer(dir)
56 | }
57 |
58 | // ServeHTTP is the command channel for the NDT-WS or NDT-WSS test. All
59 | // subsequent client communication is synchronized with this method. Returning
60 | // closes the websocket connection, so only occurs after all tests complete or
61 | // an unrecoverable error. It is called ServeHTTP to make sure that the Server
62 | // implements the http.Handler interface.
63 | func (s *httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
64 | upgrader := ws.Upgrader("ndt")
65 | wsc, err := upgrader.Upgrade(w, r, nil)
66 | if err != nil {
67 | log.Println("ERROR SERVER:", err)
68 | return
69 | }
70 | ws := protocol.AdaptWsConn(wsc)
71 | defer warnonerror.Close(ws, "Could not close connection")
72 | isMon := fmt.Sprintf("%t", controller.IsMonitoring(controller.GetClaim(r.Context())))
73 | ndt5.HandleControlChannel(ws, s, isMon)
74 | }
75 |
76 | // NewWS returns a handler suitable for http-based connections.
77 | func NewWS(datadir string, metadata []metadata.NameValue) WSHandler {
78 | return &httpHandler{
79 | serverFactory: &httpFactory{},
80 | connectionType: ndt.WS,
81 | datadir: datadir,
82 | metadata: metadata,
83 | }
84 | }
85 |
86 | type httpsFactory struct {
87 | certFile string
88 | keyFile string
89 | }
90 |
91 | func (hf *httpsFactory) SingleServingServer(dir string) (ndt.SingleMeasurementServer, error) {
92 | return singleserving.ListenWSS(dir, hf.certFile, hf.keyFile)
93 | }
94 |
95 | // NewWSS returns a handler suitable for https-based connections.
96 | func NewWSS(datadir, certFile, keyFile string, metadata []metadata.NameValue) WSHandler {
97 | return &httpHandler{
98 | serverFactory: &httpsFactory{
99 | certFile: certFile,
100 | keyFile: keyFile,
101 | },
102 | connectionType: ndt.WSS,
103 | datadir: datadir,
104 | metadata: metadata,
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/ndt5/metrics/metrics.go:
--------------------------------------------------------------------------------
1 | package metrics
2 |
3 | import (
4 | "github.com/prometheus/client_golang/prometheus"
5 | "github.com/prometheus/client_golang/prometheus/promauto"
6 | )
7 |
8 | // Metrics for exporting to prometheus to aid in server monitoring.
9 | //
10 | // TODO: Decide what monitoring we want and transition to that.
11 | var (
12 | ControlChannelDuration = promauto.NewHistogramVec(
13 | prometheus.HistogramOpts{
14 | Name: "ndt5_control_channel_duration",
15 | Help: "How long do tests last.",
16 | Buckets: []float64{
17 | .1, .15, .25, .4, .6,
18 | 1, 1.5, 2.5, 4, 6,
19 | 10, 15, 25, 40, 60,
20 | 100, 150},
21 | },
22 | []string{"protocol"},
23 | )
24 | ControlPanicCount = promauto.NewCounterVec(
25 | prometheus.CounterOpts{
26 | Name: "ndt5_control_panic_total",
27 | Help: "Number of recovered panics in the control channel.",
28 | },
29 | []string{"protocol", "error"},
30 | )
31 | ControlCount = promauto.NewCounterVec(
32 | prometheus.CounterOpts{
33 | Name: "ndt5_control_total",
34 | Help: "Number of control channel requests that results for each protocol and test type.",
35 | },
36 | []string{"protocol", "result"},
37 | )
38 | MeasurementServerStart = promauto.NewCounterVec(
39 | prometheus.CounterOpts{
40 | Name: "ndt5_measurementserver_start_total",
41 | Help: "The number of times a single-serving server was started.",
42 | },
43 | []string{"protocol"},
44 | )
45 | MeasurementServerAccept = promauto.NewCounterVec(
46 | prometheus.CounterOpts{
47 | Name: "ndt5_measurementserver_accept_total",
48 | Help: "The number of times a single-serving server received a successful client connections.",
49 | },
50 | []string{"protocol", "direction"},
51 | )
52 | MeasurementServerStop = promauto.NewCounterVec(
53 | prometheus.CounterOpts{
54 | Name: "ndt5_measurementserver_stop_total",
55 | Help: "The number of times a single-serving server was stopped.",
56 | },
57 | []string{"protocol"},
58 | )
59 | SniffedReverseProxyCount = promauto.NewCounter(
60 | prometheus.CounterOpts{
61 | Name: "ndt5_sniffed_ws_total",
62 | Help: "The number of times we sniffed-then-proxied a websocket connection on the plain ndt5 channel.",
63 | },
64 | )
65 | ClientRequestedTestSuites = promauto.NewCounterVec(
66 | prometheus.CounterOpts{
67 | Name: "ndt5_client_requested_suites_total",
68 | Help: "The number of client request test suites (the combination of all test types as an integer 0-255).",
69 | },
70 | []string{"protocol", "suite"},
71 | )
72 | ClientRequestedTests = promauto.NewCounterVec(
73 | prometheus.CounterOpts{
74 | Name: "ndt5_client_test_requested_total",
75 | Help: "The number of client requests for each ndt5 test type.",
76 | },
77 | []string{"protocol", "direction"},
78 | )
79 | ClientForwardingTimeouts = promauto.NewCounter(
80 | prometheus.CounterOpts{
81 | Name: "ndt5_forwarding_timeouts_total",
82 | Help: "The number of times forwarded client connections have timed out on the server instead of being closed by the client",
83 | },
84 | )
85 | ClientTestResults = promauto.NewCounterVec(
86 | prometheus.CounterOpts{
87 | Name: "ndt5_client_test_results_total",
88 | Help: "Number of client-connections for NDT tests run by this server.",
89 | },
90 | []string{"protocol", "direction", "result"},
91 | )
92 | ClientTestErrors = promauto.NewCounterVec(
93 | prometheus.CounterOpts{
94 | Name: "ndt5_client_test_errors_total",
95 | Help: "Number of test errors of each type for each test.",
96 | },
97 | []string{"protocol", "direction", "error"},
98 | )
99 | SubmittedMetaValues = promauto.NewHistogram(
100 | prometheus.HistogramOpts{
101 | Name: "ndt5_submitted_meta_values",
102 | Help: "The number of meta values submitted by clients.",
103 | Buckets: []float64{
104 | 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
105 | 11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
106 | },
107 | )
108 | )
109 |
--------------------------------------------------------------------------------
/ndt5/meta/meta_test.go:
--------------------------------------------------------------------------------
1 | package meta
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "reflect"
7 | "testing"
8 |
9 | "github.com/m-lab/ndt-server/metadata"
10 | "github.com/m-lab/ndt-server/ndt5/ndt"
11 | "github.com/m-lab/ndt-server/ndt5/protocol"
12 | )
13 |
14 | type sendMessage struct {
15 | t protocol.MessageType
16 | msg []byte
17 | }
18 | type recvMessage struct {
19 | msg []byte
20 | err error
21 | }
22 | type fakeMessager struct {
23 | sent []sendMessage
24 | recv []recvMessage
25 | c int
26 | }
27 | type fakeServer struct{}
28 |
29 | func (s *fakeServer) SingleServingServer(direction string) (ndt.SingleMeasurementServer, error) {
30 | return nil, nil
31 | }
32 | func (s *fakeServer) ConnectionType() ndt.ConnectionType {
33 | return ndt.Plain
34 | }
35 | func (s *fakeServer) DataDir() string {
36 | return ""
37 | }
38 | func (s *fakeServer) Metadata() []metadata.NameValue {
39 | return []metadata.NameValue{}
40 | }
41 | func (s *fakeServer) LoginCeremony(protocol.Connection) (int, error) {
42 | return 0, nil
43 | }
44 |
45 | func (m *fakeMessager) SendMessage(t protocol.MessageType, msg []byte) error {
46 | m.sent = append(m.sent, sendMessage{t: t, msg: msg})
47 | return nil
48 | }
49 | func (m *fakeMessager) ReceiveMessage(t protocol.MessageType) ([]byte, error) {
50 | if len(m.recv) <= m.c {
51 | return []byte(""), nil
52 | }
53 | msg, err := m.recv[m.c].msg, m.recv[m.c].err
54 | m.c++
55 | if err != nil {
56 | return nil, err
57 | }
58 | return msg, nil
59 | }
60 | func (m *fakeMessager) SendS2CResults(throughputKbps, unsentBytes, totalSentBytes int64) error {
61 | // Unused.
62 | return nil
63 | }
64 | func (m *fakeMessager) Encoding() protocol.Encoding {
65 | // Unused.
66 | return protocol.JSON
67 | }
68 |
69 | var len32 = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZ012345")
70 | var len64 = append(len32, len32...)
71 | var len128 = append(len64, len64...)
72 | var len256 = append(len128, len128...)
73 |
74 | func TestManageTest(t *testing.T) {
75 | tests := []struct {
76 | name string
77 | ctx context.Context
78 | m protocol.Messager
79 | want []metadata.NameValue
80 | wantErr bool
81 | }{
82 | {
83 | name: "success",
84 | ctx: context.Background(),
85 | m: &fakeMessager{
86 | recv: []recvMessage{
87 | {msg: []byte("a:b")},
88 | },
89 | },
90 | want: []metadata.NameValue{{Name: "a", Value: "b"}},
91 | },
92 | {
93 | name: "truncate-name-to-63-bytes",
94 | ctx: context.Background(),
95 | m: &fakeMessager{
96 | recv: []recvMessage{
97 | {msg: append(len64, []byte(":b")...)},
98 | },
99 | },
100 | want: []metadata.NameValue{{Name: string(len64[:63]), Value: "b"}},
101 | },
102 | {
103 | name: "truncate-value-to-255-bytes",
104 | ctx: context.Background(),
105 | m: &fakeMessager{
106 | recv: []recvMessage{
107 | {msg: append([]byte("a:"), len256...)},
108 | },
109 | },
110 | want: []metadata.NameValue{{Name: "a", Value: string(len256[:255])}},
111 | },
112 | {
113 | name: "receive-error",
114 | ctx: context.Background(),
115 | m: &fakeMessager{
116 | recv: []recvMessage{
117 | {err: fmt.Errorf("Fake failure to ReceiveMessage")},
118 | },
119 | },
120 | wantErr: true,
121 | },
122 | {
123 | name: "skip-bad-key",
124 | ctx: context.Background(),
125 | m: &fakeMessager{
126 | recv: []recvMessage{
127 | {msg: []byte("this-key-has-no-colon-separator")},
128 | },
129 | },
130 | want: []metadata.NameValue{},
131 | },
132 | }
133 | for _, tt := range tests {
134 | s := &fakeServer{}
135 | t.Run(tt.name, func(t *testing.T) {
136 | got, err := ManageTest(tt.ctx, tt.m, s)
137 | if (err != nil) != tt.wantErr {
138 | t.Errorf("ManageTest() error = %v, wantErr %v", err, tt.wantErr)
139 | return
140 | }
141 | if !reflect.DeepEqual(got, tt.want) {
142 | t.Errorf("ManageTest() = %v, want %v", got, tt.want)
143 | }
144 | })
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/ndt7/listener/listener.go:
--------------------------------------------------------------------------------
1 | // Package listener provides generic functions which extend the capabilities of
2 | // the http package. This is a fork of github.com/m-lab/go which is specially
3 | // tailored for the needs of ndt7. I believe we will want to enhance the code at
4 | // github.com/m-lab/go and make this code unnecessary.
5 | //
6 | // The code here eliminates an annoying race condition in net/http that prevents
7 | // you from knowing when it is safe to connect to the server socket. For the
8 | // functions in this package, the listening socket is fully estabished when the
9 | // function returns, and it is safe to run an HTTP GET immediately.
10 | package listener
11 |
12 | import (
13 | "log"
14 | "net"
15 | "net/http"
16 | "strings"
17 |
18 | "github.com/m-lab/ndt-server/netx"
19 | )
20 |
21 | var logFatalf = log.Fatalf
22 |
23 | // The code here is adapted from https://golang.org/src/net/http/server.go?s=85391:85432#L2742
24 |
25 | func serve(server *http.Server, listener net.Listener) {
26 | err := server.Serve(listener)
27 | if err != http.ErrServerClosed {
28 | logFatalf("Error, server %v closed with unexpected error %v", server, err)
29 | }
30 | }
31 |
32 | // ListenAndServeAsync starts an http server. The server will run until
33 | // Shutdown() or Close() is called, but this function will return once the
34 | // listening socket is established. This means that when this function
35 | // returns, the server is immediately available for an http GET to be run
36 | // against it.
37 | //
38 | // Returns a non-nil error if the listening socket can't be established. Logs a
39 | // fatal error if the server dies for a reason besides ErrServerClosed. If the
40 | // server.Addr is set to :0, then after this function returns server.Addr will
41 | // contain the address and port which this server is listening on.
42 | func ListenAndServeAsync(server *http.Server) error {
43 | // Start listening synchronously.
44 | listener, err := net.Listen("tcp", server.Addr)
45 | if err != nil {
46 | return err
47 | }
48 | if strings.HasSuffix(server.Addr, ":0") {
49 | // Allow :0 to select a random port, and then update the server with the
50 | // selected port and address. This is very useful for unit tests.
51 | server.Addr = listener.Addr().String()
52 | }
53 | // Serve asynchronously.
54 | go serve(server, netx.NewListener(listener.(*net.TCPListener)))
55 | return nil
56 | }
57 |
58 | func serveTLS(server *http.Server, listener net.Listener, certFile, keyFile string) {
59 | err := server.ServeTLS(listener, certFile, keyFile)
60 | if err != http.ErrServerClosed {
61 | logFatalf("Error, server %v closed with unexpected error %v", server, err)
62 | }
63 | }
64 |
65 | // ListenAndServeTLSAsync starts an https server. The server will run until
66 | // Shutdown() or Close() is called, but this function will return once the
67 | // listening socket is established. This means that when this function
68 | // returns, the server is immediately available for an https GET to be run
69 | // against it.
70 | //
71 | // Returns a non-nil error if the listening socket can't be established. Logs a
72 | // fatal error if the server dies for a reason besides ErrServerClosed.
73 | func ListenAndServeTLSAsync(server *http.Server, certFile, keyFile string) error {
74 | // Start listening synchronously.
75 | listener, err := net.Listen("tcp", server.Addr)
76 | if err != nil {
77 | return err
78 | }
79 |
80 | // Unlike ListenAndServeAsync we don't update the server's Addr when the
81 | // server.Addr ends with :0, because the resulting URL may or may not be
82 | // GET-able. In ipv6-only contexts it could be, for example, "[::]:3232", and
83 | // that URL can't be used for TLS because TLS needs a name or an explicit IP
84 | // and [::] doesn't qualify. It is unclear what the right thing to do is in
85 | // this situation, because names and IPs and TLS are sufficiently complicated
86 | // that no one thing is the right thing in all situations, so we affirmatively
87 | // do nothing in an attempt to avoid making a bad situation worse.
88 |
89 | // Serve asynchronously.
90 | go serveTLS(server, netx.NewListener(listener.(*net.TCPListener)), certFile, keyFile)
91 | return nil
92 | }
93 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://godoc.org/github.com/m-lab/ndt-server) [](https://travis-ci.com/m-lab/ndt-server) [](https://coveralls.io/github/m-lab/ndt-server?branch=main) [](https://goreportcard.com/report/github.com/m-lab/ndt-server)
2 |
3 | # ndt-server
4 |
5 | This repository contains a [ndt5](
6 | https://github.com/ndt-project/ndt/wiki/NDTProtocol) and [ndt7](
7 | spec/ndt7-protocol.md) server written in Go. This code may compile under
8 | many systems, including macOS and Windows, but is specifically designed
9 | and tested for running on Linux 4.19+.
10 |
11 | ## Clients
12 |
13 | Depending on your needs, there are several ways to perform a client measurement
14 | using the NDT7 protocol.
15 |
16 | Officially supported by members of M-Lab staff.
17 |
18 | * https://github.com/m-lab/ndt7-js (javascript)
19 | * https://github.com/m-lab/ndt7-client-go (golang)
20 |
21 | Unofficially supported by members of the M-Lab community.
22 |
23 | * https://github.com/m-lab/ndt7-client-cc (c++)
24 | * https://github.com/m-lab/ndt7-client-ios (swift)
25 | * https://github.com/m-lab/ndt7-client-android (kotlin)
26 | * https://github.com/m-lab/ndt7-client-android-java (java)
27 |
28 | ## Setup
29 |
30 | ### Primary setup & running (Linux)
31 |
32 | Prepare the runtime environment
33 |
34 | ```bash
35 | install -d certs datadir
36 | ```
37 |
38 | To run the server locally, generate local self signed certificates (`key.pem`
39 | and `cert.pem`) using bash and OpenSSL
40 |
41 | ```bash
42 | ./gen_local_test_certs.bash
43 | ```
44 |
45 | build the docker container for `ndt-server`
46 |
47 | ```bash
48 | docker build . -t ndt-server
49 | ```
50 |
51 | enable BBR (with which ndt7 works much better)
52 |
53 | ```
54 | sudo modprobe tcp_bbr
55 | ```
56 |
57 | and run the `ndt-server` binary container
58 |
59 | ```bash
60 | docker run --network=bridge \
61 | --publish 443:4443 \
62 | --publish 80:8080 \
63 | --volume `pwd`/certs:/certs:ro \
64 | --volume `pwd`/datadir:/datadir \
65 | --read-only \
66 | --user `id -u`:`id -g` \
67 | --cap-drop=all \
68 | ndt-server \
69 | -cert /certs/cert.pem \
70 | -key /certs/key.pem \
71 | -datadir /datadir \
72 | -ndt7_addr :4443 \
73 | -ndt7_addr_cleartext :8080
74 | ```
75 |
76 | ### Alternate setup & running (Windows & MacOS)
77 |
78 | These instructions assume you have Docker for Windows/Mac installed.
79 |
80 | **Note: NDT5 does not work on Docker for Windows/Mac as it requires using the host's network, which is only supported on Linux**
81 |
82 | ```
83 | docker-compose run ndt-server ./gen_local_test_certs.bash
84 | docker-compose up
85 | ```
86 |
87 | After making changes you will have to run `docker-compose up --build` to rebuild the ntd-server binary.
88 |
89 | ## Accessing the service
90 |
91 | Once you have done that, you should have a ndt5 server running on ports
92 | `3001` (legacy binary flavour), `3002` (WebSocket flavour), and `3010`
93 | (secure WebSocket flavour); a ndt7 server running on port `443` (over TLS
94 | and using the ndt7 WebSocket protocol); and Prometheus metrics available
95 | on port `9990`.
96 |
97 | Try accessing these URLs in your browser (for URLs using HTTPS, certs will
98 | appear invalid to your browser, but everything is safe because this is a test
99 | deployment, hence you should ignore this warning and continue):
100 |
101 | * ndt7: http://localhost/ndt7.html or https://localhost/ndt7.html
102 | * ndt5+wss: https://localhost:3010/widget.html
103 | * prometheus: http://localhost:9090/metrics
104 |
105 | Replace `localhost` with the IP of the server to access them externally.
106 |
--------------------------------------------------------------------------------
/TestDockerfile:
--------------------------------------------------------------------------------
1 | # TestDockerfile for running ndt-server integration tests.
2 | #
3 | # BUILD STEPS:
4 | # * Setup a base build environment based on the same image as the final image.
5 | # * Build libndt, measurement-kit, ndtrawjson, ndtrawnojson clients
6 | # * Setup the final image by copying clients.
7 | #
8 | # Because client binaries are dynamically linked, the versions must be
9 | # available during build and in the final image. The simplest way to guarantee
10 | # that is to use the same base image.
11 |
12 |
13 | # A base image for building and the final image.
14 | # NOTE: use debian based golang image to easily access libraries and development
15 | # packages that are unavailable or harder to setup in alpine-based images.
16 | FROM golang:1.18-buster as ndtbase
17 | WORKDIR /
18 | RUN sed -i 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' /etc/apt/sources.list && \
19 | sed -i 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' /etc/apt/sources.list && \
20 | sed -i '/buster-updates/d' /etc/apt/sources.list
21 | RUN apt-get update && apt-get install -y git libmaxminddb0 libevent-2.1-6 \
22 | libevent-core-2.1-6 libevent-extra-2.1-6 \
23 | libevent-openssl-2.1-6 libevent-pthreads-2.1-6
24 |
25 |
26 | # A base image for building clients.
27 | FROM ndtbase AS ndtbuild
28 | WORKDIR /
29 | RUN apt-get update && apt-get install -y build-essential autotools-dev \
30 | automake zlib1g-dev cmake libssl-dev libcurl4-openssl-dev \
31 | libmaxminddb-dev libevent-dev libtool-bin libtool
32 | RUN git clone --recursive https://github.com/m-lab/ndt/
33 |
34 |
35 | # Build a libndt client.
36 | FROM ndtbuild AS libndt
37 | RUN git clone --recursive https://github.com/measurement-kit/libndt
38 | WORKDIR /libndt
39 | RUN cmake .
40 | RUN cmake --build .
41 |
42 |
43 | # Build a measurement_kit client.
44 | FROM ndtbuild AS mk
45 | RUN git clone https://github.com/measurement-kit/measurement-kit.git
46 | WORKDIR /measurement-kit
47 | RUN ./autogen.sh
48 | RUN ./configure
49 | RUN make -j 8
50 | RUN make -j 8 install
51 |
52 |
53 | # Build a version of web100clt that uses JSON.
54 | FROM ndtbuild as ndtrawjson
55 | RUN apt-get install -y libjansson-dev
56 | WORKDIR /ndt
57 | RUN ./bootstrap
58 | RUN ./configure --enable-static
59 | WORKDIR /ndt/src
60 | RUN make -j 8 web100clt
61 |
62 |
63 | # Build a version of web100clt that does not use JSON.
64 | FROM ndtbuild AS ndtrawnojson
65 | # I2util used to be a separate library, so make sure to install it from the
66 | # modern NDT repo before we back up to a super-old code version that expects it
67 | # to be installed separately.
68 | WORKDIR /ndt/I2util
69 | RUN ./bootstrap.sh
70 | RUN ./configure
71 | RUN make -j 8 install
72 | WORKDIR /ndt
73 | # Check out a build from before JSON support was in the binary. Because
74 | # libjansson is not installed in this image, if the build succeeds, then it
75 | # does not use JSON.
76 | RUN git checkout 1f918aa4411c5bd3a863127b58bbd3b75c9d8a09
77 | RUN ./bootstrap
78 | RUN ./configure --enable-static
79 | WORKDIR /ndt/src
80 | RUN make -j 8 web100clt
81 |
82 |
83 | # Build the final image in which the server will be tested.
84 | FROM ndtbase AS final
85 | COPY --from=ndtrawjson /ndt/src/web100clt /bin/web100clt-with-json-support
86 | COPY --from=ndtrawnojson /ndt/src/web100clt /bin/web100clt-without-json-support
87 | COPY --from=libndt /libndt/libndt-client /bin/libndt-client
88 | COPY --from=mk /usr/local/bin/measurement_kit /bin/measurement_kit
89 | RUN curl -sL https://deb.nodesource.com/setup_10.x | bash -
90 | RUN apt-get update && apt-get install -y nodejs libjansson4 libssl1.1 libssl1.0
91 | ENV GOPATH=/go
92 | RUN go install github.com/mattn/goveralls@latest
93 | ADD . /go/src/github.com/m-lab/ndt-server
94 | RUN go install github.com/m-lab/ndt7-client-go/cmd/ndt7-client@latest
95 | WORKDIR /go/src/github.com/m-lab/ndt-server/testdata
96 | RUN npm install .
97 | WORKDIR /go/src/github.com/m-lab/ndt-server
98 | RUN ./build.sh
99 | CMD /bin/bash ./test.sh
100 |
101 | # To build everything and run unit and integration tests, we recommend a commandline like:
102 | # docker build -f TestDockerfile . -t ndttest && docker run -it ndttest
103 |
--------------------------------------------------------------------------------
/ndt5/plain/plain_test.go:
--------------------------------------------------------------------------------
1 | package plain
2 |
3 | import (
4 | "context"
5 | "io/ioutil"
6 | "net"
7 | "net/http"
8 | "os"
9 | "testing"
10 | "time"
11 |
12 | "github.com/m-lab/go/httpx"
13 | "github.com/m-lab/go/rtx"
14 | "github.com/m-lab/ndt-server/metadata"
15 | )
16 |
17 | type fakeAccepter struct{}
18 |
19 | func (f *fakeAccepter) Accept(l net.Listener) (net.Conn, error) {
20 | return l.Accept()
21 | }
22 |
23 | func TestNewPlainServer(t *testing.T) {
24 | d, err := ioutil.TempDir("", "TestNewPlainServer")
25 | rtx.Must(err, "Could not create tempdir")
26 | defer os.RemoveAll(d)
27 | // Set up the proxied server
28 | success := 0
29 | h := &http.ServeMux{}
30 | h.HandleFunc("/test_url", func(w http.ResponseWriter, r *http.Request) {
31 | w.WriteHeader(200)
32 | success++
33 | })
34 | // This will transfer data for 100 seconds. Only access it to test whether
35 | // things will timeout in less than 100 seconds.
36 | h.HandleFunc("/test_slow_url", func(w http.ResponseWriter, r *http.Request) {
37 | w.WriteHeader(200)
38 | end := time.Now().Add(100 * time.Second)
39 | var err error
40 | for time.Now().Before(end) && err == nil {
41 | _, err = w.Write([]byte("test"))
42 | }
43 | if err == nil {
44 | t.Error("We expected a write error but it was nil")
45 | }
46 | })
47 | wsSrv := &http.Server{
48 | Addr: ":0",
49 | Handler: h,
50 | }
51 | rtx.Must(httpx.ListenAndServeAsync(wsSrv), "Could not start server")
52 | // Sanity check that the proxied server is up and running.
53 | _, err = http.Get("http://" + wsSrv.Addr + "/test_url")
54 | rtx.Must(err, "Proxied server could not respond to get")
55 | if success != 1 {
56 | t.Error("GET was unsuccessful")
57 | }
58 |
59 | // Set up the plain server
60 | tcpS := NewServer(d, wsSrv.Addr, []metadata.NameValue{})
61 | ctx, cancel := context.WithCancel(context.Background())
62 | defer cancel()
63 | fa := &fakeAccepter{}
64 | rtx.Must(tcpS.ListenAndServe(ctx, ":0", fa), "Could not start tcp server")
65 |
66 | t.Run("Test that GET forwarding works", func(t *testing.T) {
67 | url := "http://" + tcpS.Addr().String() + "/test_url"
68 | r, err := http.Get(url)
69 | if err != nil {
70 | t.Error("Could not get URL", url)
71 | }
72 | if r == nil || r.StatusCode != 200 {
73 | t.Errorf("Bad response: %v", r)
74 | }
75 | if success != 2 {
76 | t.Error("We should have had a second success")
77 | }
78 | })
79 |
80 | t.Run("Test that no data won't crash things", func(t *testing.T) {
81 | conn, err := net.Dial("tcp", tcpS.Addr().String())
82 | rtx.Must(err, "Could not connect")
83 | rtx.Must(conn.Close(), "Could not close")
84 | })
85 |
86 | t.Run("Test that we can't listen and run twice on the same port", func(t *testing.T) {
87 | fa := &fakeAccepter{}
88 | err := tcpS.ListenAndServe(ctx, tcpS.Addr().String(), fa)
89 | if err == nil {
90 | t.Error("We should not have been able to start a second server")
91 | }
92 | })
93 |
94 | t.Run("Test that timeouts actually timeout", func(t *testing.T) {
95 | ps := tcpS.(*plainServer)
96 | ps.timeout = 100 * time.Millisecond
97 | url := "http://" + tcpS.Addr().String() + "/test_slow_url"
98 | start := time.Now()
99 | http.Get(url)
100 | end := time.Now()
101 | // It's a 100 second download happening with a .1 second timeout. It should
102 | // definitely finish in less than 5 seconds, even on a slow cloud machine
103 | // starved for IOps and interrupt processing capacity.
104 | if end.Sub(start) > 5*time.Second {
105 | t.Error("Took more than 5 seconds for forwarded connection to timeout:", end.Sub(start))
106 | }
107 | })
108 | }
109 |
110 | func TestNewPlainServerBrokenForwarding(t *testing.T) {
111 | d, err := ioutil.TempDir("", "TestNewPlainServerBrokenForwarding")
112 | rtx.Must(err, "Could not create tempdir")
113 | defer os.RemoveAll(d)
114 | // Set up the plain server forwarding to a non-open port.
115 | tcpS := NewServer(d, "127.0.0.1:1", []metadata.NameValue{})
116 | ctx, cancel := context.WithCancel(context.Background())
117 | defer cancel()
118 | fa := &fakeAccepter{}
119 | rtx.Must(tcpS.ListenAndServe(ctx, ":0", fa), "Could not start tcp server")
120 |
121 | client := &http.Client{
122 | Timeout: 10 * time.Millisecond,
123 | }
124 | _, err = client.Get("http://" + tcpS.Addr().String() + "/test_url")
125 | if err == nil {
126 | t.Error("This should have failed")
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/ndt5/c2s/c2s_test.go:
--------------------------------------------------------------------------------
1 | package c2s
2 |
3 | import (
4 | "context"
5 | "net"
6 | "net/http"
7 | "strconv"
8 | "testing"
9 | "time"
10 |
11 | "github.com/gorilla/websocket"
12 |
13 | "github.com/m-lab/go/rtx"
14 | "github.com/m-lab/ndt-server/ndt5/protocol"
15 | "github.com/m-lab/ndt-server/ndt5/singleserving"
16 | "github.com/m-lab/ndt-server/netx"
17 | )
18 |
19 | func MustMakeNetConnection(ctx context.Context) (protocol.MeasuredConnection, net.Conn) {
20 | tcpl, err := net.Listen("tcp", "127.0.0.1:0")
21 | rtx.Must(err, "Could not listen")
22 | tl := netx.NewListener(tcpl.(*net.TCPListener))
23 | conns := make(chan net.Conn)
24 | defer close(conns)
25 | go func() {
26 | clientConn, err := net.Dial("tcp", tcpl.Addr().String())
27 | rtx.Must(err, "Could not dial temp conn")
28 | conns <- clientConn
29 | }()
30 | conn, err := tl.Accept()
31 | rtx.Must(err, "Could not accept")
32 | return protocol.AdaptNetConn(conn, conn), <-conns
33 | }
34 |
35 | func Test_DrainForeverButMeasureFor_NormalOperation(t *testing.T) {
36 | ctx, cancel := context.WithCancel(context.Background())
37 | defer cancel()
38 | sConn, cConn := MustMakeNetConnection(ctx)
39 | defer sConn.Close()
40 | defer cConn.Close()
41 | // Send for longer than we measure.
42 | go func() {
43 | ctx2, cancel2 := context.WithTimeout(ctx, 10*time.Second)
44 | defer cancel2() // Useless, but makes the linter happpy.
45 | for ctx2.Err() == nil {
46 | cConn.Write([]byte("hello"))
47 | }
48 | cConn.Close()
49 | }()
50 | metrics, err := drainForeverButMeasureFor(ctx, sConn, time.Duration(500*time.Millisecond))
51 | if err != nil {
52 | t.Fatal("Should not have gotten error:", err)
53 | }
54 | if metrics.TCPInfo.BytesReceived <= 0 {
55 | t.Errorf("Expected positive byte count but got %d", metrics.TCPInfo.BytesReceived)
56 | }
57 | }
58 |
59 | func Test_DrainForeverButMeasureFor_EarlyClientQuit(t *testing.T) {
60 | ctx, cancel := context.WithCancel(context.Background())
61 | defer cancel()
62 | sConn, cConn := MustMakeNetConnection(ctx)
63 | defer sConn.Close()
64 | defer cConn.Close()
65 | // Measure longer than we send.
66 | go func() {
67 | for i := 0; i < 10; i++ {
68 | cConn.Write([]byte("hello"))
69 | }
70 | time.Sleep(150 * time.Millisecond) // Give the drainForever process time to get going
71 | cConn.Close()
72 | }()
73 | metrics, err := drainForeverButMeasureFor(ctx, sConn, time.Duration(4*time.Second))
74 | if err == nil {
75 | t.Fatal("Should have gotten an error")
76 | }
77 | if metrics.TCPInfo.BytesReceived <= 0 {
78 | t.Errorf("Expected positive byte count but got %d", metrics.TCPInfo.BytesReceived)
79 | }
80 | }
81 |
82 | func MustMakeWsConnection(ctx context.Context) (protocol.MeasuredConnection, *websocket.Conn) {
83 | srv, err := singleserving.ListenWS("c2s")
84 | rtx.Must(err, "Could not listen")
85 | conns := make(chan *websocket.Conn)
86 | defer close(conns)
87 | go func() {
88 | d := websocket.Dialer{}
89 | // This will actually result in a failed websocket connection attempt because
90 | // we aren't setting any headers. That's okay for testing purposes, as we are
91 | // trying to make sure that the underlying socket stats are counted, and the
92 | // failed upgrade will still result in non-zero socket stats.
93 | clientConn, _, err := d.Dial("ws://localhost:"+strconv.Itoa(srv.Port())+"/ndt_protocol", http.Header{})
94 | rtx.Must(err, "Could not dial temp conn")
95 | conns <- clientConn
96 | }()
97 | conn, err := srv.ServeOnce(ctx)
98 | rtx.Must(err, "Could not accept")
99 | return conn, <-conns
100 | }
101 |
102 | func Test_DrainForeverButMeasureFor_CountsAllBytesNotJustWsGoodput(t *testing.T) {
103 | ctx, cancel := context.WithCancel(context.Background())
104 | defer cancel()
105 | sConn, cConn := MustMakeWsConnection(ctx)
106 | defer sConn.Close()
107 | defer cConn.Close()
108 | // Send for longer than we measure.
109 | go func() {
110 | // Send nothing. But the websocket handshake used some bytes, so the underlying socket should not measure zero.
111 | ctx2, cancel2 := context.WithTimeout(ctx, 10*time.Second)
112 | defer cancel2() // Useless, but makes the linter happpy.
113 | <-ctx2.Done()
114 | cConn.Close()
115 | }()
116 | metrics, err := drainForeverButMeasureFor(ctx, sConn, time.Duration(100*time.Millisecond))
117 | if err != nil {
118 | t.Fatal("Should not have gotten error:", err)
119 | }
120 | if metrics.TCPInfo.BytesReceived <= 0 {
121 | t.Errorf("Expected positive byte count but got %d", metrics.TCPInfo.BytesReceived)
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/ndt7/receiver/receiver.go:
--------------------------------------------------------------------------------
1 | // Package receiver implements the messages receiver. It can be used
2 | // both by the download and the upload subtests.
3 | package receiver
4 |
5 | import (
6 | "context"
7 | "encoding/json"
8 | "fmt"
9 | "io/ioutil"
10 | "time"
11 |
12 | "github.com/gorilla/websocket"
13 | "github.com/m-lab/ndt-server/logging"
14 | ndt7metrics "github.com/m-lab/ndt-server/ndt7/metrics"
15 | "github.com/m-lab/ndt-server/ndt7/model"
16 | "github.com/m-lab/ndt-server/ndt7/ping"
17 | "github.com/m-lab/ndt-server/ndt7/spec"
18 | )
19 |
20 | type receiverKind int
21 |
22 | const (
23 | downloadReceiver = receiverKind(iota)
24 | uploadReceiver
25 | )
26 |
27 | func start(
28 | ctx context.Context, conn *websocket.Conn, kind receiverKind,
29 | data *model.ArchivalData,
30 | ) {
31 | logging.Logger.Debug("receiver: start")
32 | proto := ndt7metrics.ConnLabel(conn)
33 | defer logging.Logger.Debug("receiver: stop")
34 | conn.SetReadLimit(spec.MaxMessageSize)
35 | receiverctx, cancel := context.WithTimeout(ctx, spec.MaxRuntime)
36 | defer cancel()
37 | err := conn.SetReadDeadline(time.Now().Add(spec.MaxRuntime)) // Liveness!
38 | if err != nil {
39 | logging.Logger.WithError(err).Warn("receiver: conn.SetReadDeadline failed")
40 | ndt7metrics.ClientReceiverErrors.WithLabelValues(
41 | proto, fmt.Sprint(kind), "set-read-deadline").Inc()
42 | return
43 | }
44 | conn.SetPongHandler(func(s string) error {
45 | rtt, err := ping.ParseTicks(s)
46 | if err == nil {
47 | rtt /= int64(time.Millisecond)
48 | logging.Logger.Debugf("receiver: ApplicationLevel RTT: %d ms", rtt)
49 | } else {
50 | ndt7metrics.ClientReceiverErrors.WithLabelValues(
51 | proto, fmt.Sprint(kind), "ping-parse-ticks").Inc()
52 | }
53 | return err
54 | })
55 | for receiverctx.Err() == nil { // Liveness!
56 | // By getting a Reader here we avoid allocating memory for the message
57 | // when the message type is not websocket.TextMessage.
58 | mtype, r, err := conn.NextReader()
59 | if err != nil {
60 | ndt7metrics.ClientReceiverErrors.WithLabelValues(
61 | proto, fmt.Sprint(kind), "read-message-type").Inc()
62 | return
63 | }
64 | if mtype != websocket.TextMessage {
65 | switch kind {
66 | case downloadReceiver:
67 | logging.Logger.Warn("receiver: got non-Text message")
68 | ndt7metrics.ClientReceiverErrors.WithLabelValues(
69 | proto, fmt.Sprint(kind), "wrong-message-type").Inc()
70 | return // Unexpected message type
71 | default:
72 | // NOTE: this is the bulk upload path. In this case, the mdata is not used.
73 | continue // No further processing required
74 | }
75 | }
76 | // This is a TextMessage, so we must read it.
77 | mdata, err := ioutil.ReadAll(r)
78 | if err != nil {
79 | ndt7metrics.ClientReceiverErrors.WithLabelValues(
80 | proto, fmt.Sprint(kind), "read-message").Inc()
81 | return
82 | }
83 | var measurement model.Measurement
84 | err = json.Unmarshal(mdata, &measurement)
85 | if err != nil {
86 | logging.Logger.WithError(err).Warn("receiver: json.Unmarshal failed")
87 | ndt7metrics.ClientReceiverErrors.WithLabelValues(
88 | proto, fmt.Sprint(kind), "unmarshal-client-message").Inc()
89 | return
90 | }
91 | data.ClientMeasurements = append(data.ClientMeasurements, measurement)
92 | }
93 | ndt7metrics.ClientReceiverErrors.WithLabelValues(
94 | proto, fmt.Sprint(kind), "receiver-context-expired").Inc()
95 | }
96 |
97 | // StartDownloadReceiverAsync starts the receiver in a background goroutine and
98 | // saves messages received from the client in the given archival data. The
99 | // returned context may be used to detect when the receiver has completed.
100 | //
101 | // This receiver will not tolerate receiving binary messages. It will terminate
102 | // early if such a message is received.
103 | //
104 | // Liveness guarantee: the goroutine will always terminate after a MaxRuntime
105 | // timeout.
106 | func StartDownloadReceiverAsync(ctx context.Context, conn *websocket.Conn, data *model.ArchivalData) context.Context {
107 | ctx2, cancel2 := context.WithCancel(ctx)
108 | go func() {
109 | start(ctx2, conn, downloadReceiver, data)
110 | cancel2()
111 | }()
112 | return ctx2
113 | }
114 |
115 | // StartUploadReceiverAsync is like StartDownloadReceiverAsync except that it
116 | // tolerates incoming binary messages, sent by "upload" measurement clients to
117 | // create network load, and therefore must be allowed.
118 | func StartUploadReceiverAsync(ctx context.Context, conn *websocket.Conn, data *model.ArchivalData) context.Context {
119 | ctx2, cancel2 := context.WithCancel(ctx)
120 | go func() {
121 | start(ctx2, conn, uploadReceiver, data)
122 | cancel2()
123 | }()
124 | return ctx2
125 | }
126 |
--------------------------------------------------------------------------------
/ndt7/measurer/measurer.go:
--------------------------------------------------------------------------------
1 | // Package measurer collects metrics from a socket connection
2 | // and returns them for consumption.
3 | package measurer
4 |
5 | import (
6 | "context"
7 | "time"
8 |
9 | "github.com/gorilla/websocket"
10 | "github.com/prometheus/client_golang/prometheus"
11 | "github.com/prometheus/client_golang/prometheus/promauto"
12 |
13 | "github.com/m-lab/go/memoryless"
14 | "github.com/m-lab/ndt-server/logging"
15 | "github.com/m-lab/ndt-server/ndt7/model"
16 | "github.com/m-lab/ndt-server/ndt7/spec"
17 | "github.com/m-lab/ndt-server/netx"
18 | )
19 |
20 | var (
21 | BBREnabled = promauto.NewCounterVec(
22 | prometheus.CounterOpts{
23 | Name: "ndt7_measurer_bbr_enabled_total",
24 | Help: "A counter of every attempt to enable bbr.",
25 | },
26 | []string{"status", "error"},
27 | )
28 | )
29 |
30 | // Measurer performs measurements
31 | type Measurer struct {
32 | conn *websocket.Conn
33 | uuid string
34 | ticker *memoryless.Ticker
35 | }
36 |
37 | // New creates a new measurer instance
38 | func New(conn *websocket.Conn, UUID string) *Measurer {
39 | return &Measurer{
40 | conn: conn,
41 | uuid: UUID,
42 | }
43 | }
44 |
45 | func (m *Measurer) getSocketAndPossiblyEnableBBR() (netx.ConnInfo, error) {
46 | ci := netx.ToConnInfo(m.conn.UnderlyingConn())
47 | err := ci.EnableBBR()
48 | success := "true"
49 | errstr := ""
50 | if err != nil {
51 | success = "false"
52 | errstr = err.Error()
53 | uuid, _ := ci.GetUUID() // to log error with uuid.
54 | logging.Logger.WithError(err).Warn("Cannot enable BBR: " + uuid)
55 | // FALLTHROUGH
56 | }
57 | BBREnabled.WithLabelValues(success, errstr).Inc()
58 | return ci, nil
59 | }
60 |
61 | func measure(measurement *model.Measurement, ci netx.ConnInfo, elapsed time.Duration) {
62 | // Implementation note: we always want to sample BBR before TCPInfo so we
63 | // will know from TCPInfo if the connection has been closed.
64 | t := int64(elapsed / time.Microsecond)
65 | bbrinfo, tcpInfo, err := ci.ReadInfo()
66 | if err == nil {
67 | measurement.BBRInfo = &model.BBRInfo{
68 | BBRInfo: bbrinfo,
69 | ElapsedTime: t,
70 | }
71 | measurement.TCPInfo = &model.TCPInfo{
72 | LinuxTCPInfo: tcpInfo,
73 | ElapsedTime: t,
74 | }
75 | }
76 | }
77 |
78 | func (m *Measurer) loop(ctx context.Context, timeout time.Duration, dst chan<- model.Measurement) {
79 | logging.Logger.Debug("measurer: start")
80 | defer logging.Logger.Debug("measurer: stop")
81 | defer close(dst)
82 | measurerctx, cancel := context.WithTimeout(ctx, timeout)
83 | defer cancel()
84 | ci, err := m.getSocketAndPossiblyEnableBBR()
85 | if err != nil {
86 | logging.Logger.WithError(err).Warn("getSocketAndPossiblyEnableBBR failed")
87 | return
88 | }
89 | start := time.Now()
90 | connectionInfo := &model.ConnectionInfo{
91 | Client: m.conn.RemoteAddr().String(),
92 | Server: m.conn.LocalAddr().String(),
93 | UUID: m.uuid,
94 | StartTime: start,
95 | }
96 | // Implementation note: the ticker will close its output channel
97 | // after the controlling context is expired.
98 | ticker, err := memoryless.NewTicker(measurerctx, memoryless.Config{
99 | Min: spec.MinPoissonSamplingInterval,
100 | Expected: spec.AveragePoissonSamplingInterval,
101 | Max: spec.MaxPoissonSamplingInterval,
102 | })
103 | if err != nil {
104 | logging.Logger.WithError(err).Warn("memoryless.NewTicker failed")
105 | return
106 | }
107 | m.ticker = ticker
108 | for now := range ticker.C {
109 | var measurement model.Measurement
110 | measure(&measurement, ci, now.Sub(start))
111 | measurement.ConnectionInfo = connectionInfo
112 | dst <- measurement // Liveness: this is blocking
113 | }
114 | }
115 |
116 | // Start runs the measurement loop in a background goroutine and emits
117 | // the measurements on the returned channel.
118 | //
119 | // Liveness guarantee: the measurer will always terminate after
120 | // the given timeout, provided that the consumer continues reading from the
121 | // returned channel. Measurer may be stopped early by canceling ctx, or by
122 | // calling Stop.
123 | func (m *Measurer) Start(ctx context.Context, timeout time.Duration) <-chan model.Measurement {
124 | dst := make(chan model.Measurement)
125 | go m.loop(ctx, timeout, dst)
126 | return dst
127 | }
128 |
129 | // Stop ends the measurements and drains the measurement channel. Stop
130 | // guarantees that the measurement goroutine completes by draining the
131 | // measurement channel. Users that call Start should also call Stop.
132 | func (m *Measurer) Stop(src <-chan model.Measurement) {
133 | if m.ticker != nil {
134 | m.ticker.Stop()
135 | }
136 | for range src {
137 | // make sure we drain the channel, so the measurement loop can exit.
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/ndt5/protocol/protocol_test.go:
--------------------------------------------------------------------------------
1 | package protocol_test
2 |
3 | import (
4 | "encoding/json"
5 | "net"
6 | "reflect"
7 | "testing"
8 | "time"
9 |
10 | "github.com/m-lab/go/rtx"
11 | "github.com/m-lab/ndt-server/ndt5/protocol"
12 | )
13 |
14 | func Test_verifyStringConversions(t *testing.T) {
15 | for m := protocol.MessageType(0); m < 255; m++ {
16 | if m.String() == "" {
17 | t.Errorf("MessageType(0x%x) should not result in an empty string", m)
18 | }
19 | }
20 | for _, subtest := range []struct {
21 | mt protocol.MessageType
22 | str string
23 | }{
24 | {protocol.SrvQueue, "SrvQueue"},
25 | {protocol.MsgLogin, "MsgLogin"},
26 | {protocol.TestPrepare, "TestPrepare"},
27 | {protocol.TestStart, "TestStart"},
28 | {protocol.TestMsg, "TestMsg"},
29 | {protocol.TestFinalize, "TestFinalize"},
30 | {protocol.MsgError, "MsgError"},
31 | {protocol.MsgResults, "MsgResults"},
32 | {protocol.MsgLogout, "MsgLogout"},
33 | {protocol.MsgWaiting, "MsgWaiting"},
34 | {protocol.MsgExtendedLogin, "MsgExtendedLogin"},
35 | } {
36 | if subtest.mt.String() != subtest.str {
37 | t.Errorf("%q != %q", subtest.mt.String(), subtest.str)
38 | }
39 | }
40 | }
41 |
42 | func Test_netConnReadJSONMessage(t *testing.T) {
43 | // Set up a listener
44 | ln, err := net.Listen("tcp", "")
45 | rtx.Must(err, "Could not start test listener")
46 | type Test struct {
47 | kind protocol.MessageType
48 | msg protocol.JSONMessage
49 | }
50 |
51 | for _, m := range []Test{
52 | {kind: protocol.MsgLogin, msg: protocol.JSONMessage{Tests: "22"}},
53 | } {
54 | // In a goroutine, create a client and send the listener a message
55 | go func(m Test) {
56 | conn, err := net.Dial("tcp", ln.Addr().String())
57 | rtx.Must(err, "Could not connect to local server")
58 | bytes, err := json.Marshal(m.msg)
59 | firstThree := []byte{byte(m.kind), byte(len(bytes) >> 8), byte(len(bytes))}
60 | _, err = conn.Write(append(firstThree, bytes...))
61 | rtx.Must(err, "Could not perform write")
62 | }(m)
63 |
64 | // Ensure that the message was received and parsed properly.
65 | c, err := ln.Accept()
66 | rtx.Must(err, "Could not accept connection")
67 | conn := protocol.AdaptNetConn(c, c)
68 | msg, err := protocol.ReceiveJSONMessage(conn, m.kind)
69 | rtx.Must(err, "Could not read JSON message")
70 | if *msg != m.msg {
71 | t.Errorf("%v != %v", *msg, m.msg)
72 | }
73 | }
74 | }
75 |
76 | type fakeConnection struct {
77 | data []byte
78 | err error
79 | }
80 |
81 | func (fc *fakeConnection) ReadMessage() (int, []byte, error) { return 0, fc.data, fc.err }
82 | func (fc *fakeConnection) ReadBytes() (count int64, err error) { return }
83 | func (fc *fakeConnection) WriteMessage(messageType int, data []byte) error { return nil }
84 | func (fc *fakeConnection) FillUntil(t time.Time, buffer []byte) (bytesWritten int64, err error) {
85 | return
86 | }
87 | func (fc *fakeConnection) ServerIPAndPort() (string, int) { return "", 0 }
88 | func (fc *fakeConnection) ClientIPAndPort() (string, int) { return "", 0 }
89 | func (fc *fakeConnection) Close() error { return nil }
90 | func (fc *fakeConnection) UUID() string { return "" }
91 | func (fc *fakeConnection) String() string { return "" }
92 | func (fc *fakeConnection) Messager() protocol.Messager { return nil }
93 |
94 | func assertFakeConnectionIsConnection(fc *fakeConnection) {
95 | func(c protocol.Connection) {}(fc)
96 | }
97 |
98 | func Test_ReceiveJSONMessage(t *testing.T) {
99 | type args struct {
100 | ws protocol.Connection
101 | expectedType protocol.MessageType
102 | }
103 | tests := []struct {
104 | name string
105 | args args
106 | want *protocol.JSONMessage
107 | wantErr bool
108 | }{
109 | {
110 | name: "No data and no error",
111 | args: args{
112 | ws: &fakeConnection{
113 | data: nil,
114 | err: nil,
115 | },
116 | expectedType: protocol.TestMsg,
117 | },
118 | wantErr: true,
119 | },
120 | {
121 | name: "Good data and no error",
122 | args: args{
123 | ws: &fakeConnection{
124 | data: append([]byte{byte(protocol.TestMsg), 0, 14}, []byte(`{"msg": "125"}`)...),
125 | err: nil,
126 | },
127 | expectedType: protocol.TestMsg,
128 | },
129 | want: &protocol.JSONMessage{
130 | Msg: "125",
131 | },
132 | },
133 | {
134 | name: "Bad data and no connection error",
135 | args: args{
136 | ws: &fakeConnection{
137 | data: append([]byte{byte(protocol.TestMsg), 0, 3}, []byte(`125`)...),
138 | err: nil,
139 | },
140 | expectedType: protocol.TestMsg,
141 | },
142 | want: &protocol.JSONMessage{
143 | Msg: "125",
144 | },
145 | wantErr: true,
146 | },
147 | }
148 | for _, tt := range tests {
149 | t.Run(tt.name, func(t *testing.T) {
150 | got, err := protocol.ReceiveJSONMessage(tt.args.ws, tt.args.expectedType)
151 | if (err != nil) != tt.wantErr {
152 | t.Errorf("ReceiveJSONMessage() error = %v, wantErr %v", err, tt.wantErr)
153 | }
154 | if !reflect.DeepEqual(got, tt.want) {
155 | t.Errorf("ReceiveJSONMessage() = %v, want %v", got, tt.want)
156 | }
157 | })
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/html/widget.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
The Network Diagnostic Tool (NDT) provides a sophisticated speed and diagnostic test. An NDT test reports more than just the upload and download speeds — it also attempts to determine what, if any, problems limited these speeds, differentiating between computer configuration and network infrastructure problems. While the diagnostic messages are most useful for expert users, they can also help novice users by allowing them to provide detailed trouble reports to their network administrator.
28 |
29 |
This NDT test uses WebSockets. Most modern browsers should support running this test. If you are experiencing problems, make sure that your browser has javascript enabled and supports WebSockets.