├── .gitignore ├── README.md ├── dist └── build-all.sh ├── docs ├── PROTOCOL.md └── PUBLIC-SERVERS.md ├── sparkyfish-cli ├── Dockerfile ├── Makefile ├── ping.go ├── proto.go ├── sparkyfish-cli.go ├── throughput.go └── widgetrenderer.go └── sparkyfish-server ├── Dockerfile ├── Makefile └── sparkyfish-server.go /.gitignore: -------------------------------------------------------------------------------- 1 | binaries/* 2 | dist/upload-to-server.sh 3 | sparkyfish-server/sparkyfish-server 4 | sparkyfish-server/sparkyfish-server-linux-amd64 5 | sparkyfish-cli/sparkyfish-cli 6 | sparkyfish-cli/sparkyfish-cli-linux-amd64 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sparkyfish 2 | An open-source internet speed and latency tester. You can test your bandwidth against a [public Sparkyfish server](https://github.com/chrissnell/sparkyfish/blob/master/docs/PUBLIC-SERVERS.md) or host your own server with the included server-side daemon. 3 | 4 | ![](http://island.nu/github/sparkyfish/sparkyfish-v1.3.gif) 5 | 6 | # About 7 | 8 | Sparkyfish offers several advantages over speedtest.net and its unofficial clients like [speedtest-cli](https://github.com/sivel/speedtest-cli) 9 | 10 | * You can run your own **private** sparkyfish server, free-of-charge. (compare to $1,995+ that Ookla charges for a private speedtest.net server) 11 | * You can test speeds > 1 Gbps if your server and client hosts support them. Most speedtest.net test servers don't have this capacity. 12 | * Sparkyfish comes with a colorful console-based client that runs on *nix and Windows 13 | * [No net-neutrality issues](https://www.techdirt.com/blog/netneutrality/articles/20141124/14064729242/fcc-gives-t-mobile-talking-to-exempting-speedtests-caps-preventing-users-seeing-theyd-been-throttled.shtml) 14 | * Sparkyfish uses an [open protocol](https://github.com/chrissnell/sparkyfish/blob/master/docs/PROTOCOL.md) for testing. You're welcome to implement your own alternative front-end client or server! 15 | 16 | # Getting Started 17 | ### Installation 18 | The easiest way to get started is to [download a binary release](https://github.com/chrissnell/sparkyfish/releases/). Sparkyfish is written in Go and compiles to a static binary so there are no dependencies if you're using the official binaries. 19 | 20 | Once you've downloaded the binary... 21 | ``` 22 | gunzip .gz 23 | chmod 755 24 | mv /usr/local/bin/sparkyfish-cli 25 | ``` 26 | 27 | ### Running the client 28 | Run the client like this: 29 | 30 | ```sparkyfish-cli [:port]``` 31 | 32 | The client takes only one parameter. The IP (with optional :port) of the sparkyfish server. You can use our public server round-robin to try it out: ```us.sparkyfish.chrissnell.com```. Sparkyfish servers default to port 7121. 33 | 34 | **Don't expect massive bandwidth from any of our current public servers. They're mostly just some small public cloud servers that I scrounged up from friends.** For more info on the public sparkyfish servers, see [docs/PUBLIC-SERVERS.md](docs/PUBLIC-SERVERS.md). 35 | 36 | ### Running from Docker (optional) 37 | You can also run ```sparkyfish-cli``` via Docker. I'm not sure if this is the most optimal way to use it, however. After running the client once, the terminal window environment gets a little hosed up and sparkyfish-cli will complain about window size the next time you run it. You can fix these by running ```reset``` in your terminal and then-re-running the image. 38 | 39 | If you want to test it out, here's how to do it: 40 | 41 | ``` 42 | docker pull chrissnell/sparkyfish-cli:latest 43 | docker run --dns 8.8.8.8 -t -i chrissnell/sparkyfish-cli:latest us.sparkyfish.chrissnell.com 44 | reset # Fix the broken terminal size env before you run it again 45 | ``` 46 | 47 | ### Building from source (optional) 48 | If you prefer to build from source, you'll need a working Go environment (v1.5+ recommended) with ```GOROOT``` and ```GOPATH``` env variables properly configured. To build from source, run this command: 49 | 50 | ``` 51 | go get github.com/chrissnell/sparkyfish/sparkyfish-cli 52 | ``` 53 | 54 | Your binaries will be placed in ```$GOPATH/bin/```. 55 | 56 | # Running your own Sparkyfish server 57 | ### Running from command line 58 | You can download the latest ```sparkyfish-server``` release from the [Releases](https://github.com/chrissnell/sparkyfish/releases/) page. Then: 59 | ``` 60 | gunzip .gz 61 | chmod 755 62 | ./ -h # to see options 63 | ./ -location="Your Physical Location, Somewhere" 64 | ``` 65 | 66 | By default, the server listens on port 7121, so make sure that you open a firewall hole for it if needed. If the port is firewalled, the client will hang during the ping testing. 67 | 68 | ### Building from source (optional) 69 | If you prefer to build from source, you'll need a working Go environment (v1.5+ recommended) with ```GOROOT``` and ```GOPATH``` env variables properly configured. To build from source, run this command: 70 | 71 | ``` 72 | go get github.com/chrissnell/sparkyfish/sparkyfish-server 73 | ``` 74 | 75 | ### Docker method 76 | Running a Sparkyfish server in Docker is easy to do but **not suited for production purposes** because the throughput can be limited by flaky networking if you're not running a recent Linux kernel and Docker version. I recommend you test with Docker and then deploy the native binary outside of Docker if you're going to run a permanent or public server. 77 | 78 | To run under Docker: 79 | ``` 80 | docker pull chrissnell/sparkyfish-server:latest 81 | docker run -e LOCATION="My Town, Somewhere, USA" -d -p 7121:7121 chrissnell/sparkyfish-server:latest 82 | ``` 83 | 84 | # Future Efforts 85 | * Proper testing code and automated builds 86 | * A Sparkyfish directory server to allow for auto-registration of public Sparkyfish servers, including Route53 DNS setup 87 | * Adding a HTTP listener to ```sparkyfish-server``` to allow for Route53 health checks 88 | * Use termui's grid layout mode to allow for auto-resizing 89 | * Move to a WebSockets-based protocol for easier client-side support 90 | * HTML/JS web-based client! (Want to write one?) 91 | * iOS and Android native clients (help needed) 92 | 93 | # IRC 94 | You can find the author and some operators of public servers in **#sparkyfish** on Freenode. Come join us! 95 | -------------------------------------------------------------------------------- /dist/build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TAG=`git describe --tag --abbrev=0` 4 | 5 | platforms=( darwin linux freebsd windows openbsd ) 6 | 7 | programs=( sparkyfish-cli sparkyfish-server ) 8 | 9 | for prog in "${programs[@]}" 10 | do 11 | PROG_WITH_TAG=${prog}-${TAG} 12 | cd ${prog} 13 | echo "--> Building ${prog}" 14 | for plat in "${platforms[@]}" 15 | do 16 | echo "----> Building for ${plat}/amd64" 17 | if [ "$plat" = "windows" ]; then 18 | GOOS=$plat GOARCH=amd64 go build -o ${PROG_WITH_TAG}-win64.exe 19 | echo "Compressing..." 20 | zip -9 ${PROG_WITH_TAG}-win64.zip ${PROG_WITH_TAG}-win64.exe 21 | mv ${PROG_WITH_TAG}-win64.zip ../binaries/${prog}/ 22 | rm ${PROG_WITH_TAG}-win64.exe 23 | else 24 | OUT="${PROG_WITH_TAG}-${plat}-amd64" 25 | GOOS=$plat GOARCH=amd64 go build -o $OUT 26 | echo "Compressing..." 27 | gzip -f $OUT 28 | mv ${OUT}.gz ../binaries/${prog}/ 29 | fi 30 | done 31 | 32 | # Build Linux/ARM 33 | echo "----> Building for linux/arm" 34 | OUT="${PROG_WITH_TAG}-linux-arm" 35 | GOOS=linux GOARCH=arm go build -o $OUT 36 | echo "Compressing..." 37 | gzip -f $OUT 38 | mv ${OUT}.gz ../binaries/${prog}/ 39 | cd .. 40 | done 41 | -------------------------------------------------------------------------------- /docs/PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # The Sparkyfish Protocol 2 | Sparkyfish uses a simple TCP-based client-server protocol to perform all testing. The client connects to the server, runs a test, then disconnects. This process is repeated for each of the three tests: ping, download, and upload. Thus, it takes three connection in series to complete a ping+download+upload test sequence. These tests could be conducted in parallel--there's no server-side prohibition against this--but it might render the results inaccurate. 3 | 4 | ### Protocol versioning. 5 | The protocol is versioned but currently there is only one version: ```0```. The client requests a certain version as part of the HELO sequence described below. 6 | 7 | ### Protocol Sequence 8 | ```client>>>``` is used to show commands sent by the client 9 | 10 | ```server<<<``` is used to show responses sent by the server 11 | 12 | ### Signing on to the server - HELO 13 | Every test begins with a client connection to the server, followed by a ```HELO``` command sent by the client. 14 | 15 | 1. The client connects over TCP to the Sparkyfish server, typically running on port 7121. 16 | 2. Once the connection is opened, the client issues a ```HELO``` command, suffixed by the protocol version in use (integer, 0-9): 17 | ``` 18 | client>>> HELO0 # Note: that's a zero after the HELO, which signifies protocol version 0. It's followed by a \n (newline) 19 | server<<< HELO 20 | server<<< my.canonical.hostname.com 21 | server<<< My Location, Some Country 22 | ``` 23 | **Important note: The version number that follows ```HELO``` is not optional and must be sent or the server will rejection the connection.** 24 | 25 | 3. Once the HELO has completed, the server is ready for a testing command. The client sends the command, followed by a : 26 | ``` 27 | client>>> ECO # ECO is the command that requests an echo (ping) test 28 | server<<< 29 | ``` 30 | 31 | ### Echo (Ping) test 32 | The ping test isn't actually an ICMP ping test at all. It's a simple TCP echo. The client requests an echo test with the commend ```ECO``` and then sends one character at a time (***no newline***). As soon as the server receives the client's character, it echoes it back (again, no newline is sent). This continues for up to 30 characters (configurable on server-side) or until the client closes the connection. If the client has not disconnected, the server will close the test after 30 characters are echoed back. to the client. 33 | 34 | Example: 35 | ``` 36 | client>>> ECO 37 | client>>> a 38 | server<<< a 39 | client>>> b 40 | server<<< b 41 | client>>> c 42 | server<<< c 43 | client>>> d 44 | server<<< d 45 | [... and so on ...] 46 | ``` 47 | Note that you don't have to send any particular character in the ECO test. ```sparkyfish-cli``` sends a zero (0) every time. 48 | 49 | ### Download test 50 | The client initiates a server->client download test with the ```SND``` command. The download test consists of a stream of randomly-generated data, sent from the server to the client as fast as the server can send it and the client can accept it. It is up to the client to measure the speed at which the stream is downloaded and report this back to the user. The test continues for a *fixed time period*. The goal is for the client to download as much data as possible within this time period, which defaults to 10 seconds. After 10 seconds has elapsed, the server will close the connection. 51 | 52 | Example: 53 | ``` 54 | client>>> SND 55 | client>>>[A stream of random data is sent to the server for 10 seconds] 56 | [ ... server closes the connection after 10 seconds of receiving ...] 57 | ``` 58 | 59 | ### Upload test 60 | The client initiates a client->server upload test with the ```RCV``` command. The upload test consists of a stream of randomly-generated data, sent from the client to the server as fast as the client can send it and the server can accept it. It is up to the client to measure the speed at which the stream is uploaded and report this back to the user. The test continues for a *fixed time period*. The goal is for the client to send as much data as possible within this time period, which defaults to 10 seconds. After 10 seconds has elapsed, the server will close the connection. **It is up to the client to generate the random data**, though the server does not enforce what the stream actually contains. Random data is recommended to reduce the potential for external compression. 61 | 62 | 63 | Example: 64 | ``` 65 | client>>> RCV 66 | server<<< [A stream of random data is sent for 10 seconds] 67 | [ ... server closes the connection after 10 seconds of sending ...] 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/PUBLIC-SERVERS.md: -------------------------------------------------------------------------------- 1 | # About Public Servers 2 | Members of the Sparkyfish community have kindly made available some public Sparkyfish servers for your testing. 3 | Most of these servers are running on public cloud instances and have ~100-200 Mbit/s up/down capacity so they may 4 | not be suitable for testing your machine if you have a connection that's faster than that. 5 | 6 | # Current Public Servers 7 | The easiest way to test is to use one of the round-robin hostnames below. These rotate amongst their geographic peers and automatically remove any failed servers from the rotation: 8 | 9 | | Hostname | Geographic Area| 10 | |----------|----------------| 11 | |us.sparkyfish.chrissnell.com| North America | 12 | |eu.sparkyfish.chrissnell.com| Europe | 13 | 14 | If you want to try a specific server, here's a list of the current public servers. All of these servers run on port 7121 unless otherwise indicated: 15 | 16 | | Hostname / Port | Location | Sponsor| ISP| Link Capacity | Protocol | 17 | |----------|----------|-----|----|-----------|----------| 18 | | us-seattle.sparkyfish.chrissnell.com | Seattle, WA | Nigel VH | Blue Box | 150 Mbps up/down| IPv4 + IPv6 | 19 | | us-ashburn.sparkyfish.chrissnell.com | Ashburn, VA | Nigel VH | Blue Box | 150 Mbps up/down| IPv4 + IPv6 | 20 | | sparky.ga.hamwan.net | Atlanta, GA | W4AQL | Georgia Institute of Technology | 1 Gbps up/down | IPv4| 21 | | eu-netherlands.sparkyfish.chrissnell.com | Amsterdam, NL | Josh Braegger |DigitalOcean| 30 Mbps up/down | IPv4 | 22 | | eu-germany.sparkyfish.com | Gunzenhausen, DE | Kirk Harr | Hetzner.de | 10 Mbps up/down | IPv4 + IPv6 | 23 | 24 | # Host a Public Server 25 | If you're willing to help us out and host a public server, please add yourself to this page and submit a PR and we'll get you added. **We especially need more servers with 100+ Mbps connections!** 26 | If you have questions or need help, hop on #sparkyfish on Freenode IRC and we'll help you out. 27 | -------------------------------------------------------------------------------- /sparkyfish-cli/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gliderlabs/alpine:latest 2 | MAINTAINER Chris Snell github.com/chrissnell 3 | ADD sparkyfish-cli-linux-amd64 /sparkyfish-cli 4 | 5 | ENTRYPOINT ["/sparkyfish-cli"] 6 | -------------------------------------------------------------------------------- /sparkyfish-cli/Makefile: -------------------------------------------------------------------------------- 1 | all: sparkyfish-cli 2 | 3 | sparkyfish-cli: 4 | go build 5 | 6 | docker-build: Dockerfile 7 | GOOS=linux GOARCH=amd64 go build -o sparkyfish-cli-linux-amd64 8 | docker build -t chrissnell/sparkyfish-cli:$(GIT_TAG) . 9 | 10 | docker-push: 11 | docker push chrissnell/sparkyfish-cli:$(GIT_TAG) 12 | docker tag -f chrissnell/sparkyfish-cli:$(GIT_TAG) chrissnell/sparkyfish-cli:latest 13 | docker push chrissnell/sparkyfish-cli:latest 14 | 15 | docker: docker-build docker-push 16 | 17 | GIT_TAG := $(shell git describe --tag --abbrev=0) 18 | -------------------------------------------------------------------------------- /sparkyfish-cli/ping.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "sort" 8 | "time" 9 | 10 | "github.com/gizak/termui" 11 | ) 12 | 13 | type pingHistory []int64 14 | 15 | func (sc *sparkyClient) pingTest() { 16 | // Reset our progress bar to 0% if it's not there already 17 | sc.progressBarReset <- true 18 | 19 | // start our ping processor 20 | go sc.pingProcessor() 21 | 22 | // Wait for our processor to become ready 23 | <-sc.pingProcessorReady 24 | 25 | buf := make([]byte, 1) 26 | 27 | sc.beginSession() 28 | defer sc.conn.Close() 29 | 30 | // Send the ECO command to the remote server, requesting an echo test 31 | // (remote receives and echoes back). 32 | err := sc.writeCommand("ECO") 33 | if err != nil { 34 | termui.Close() 35 | log.Fatalln(err) 36 | } 37 | 38 | for c := 0; c <= numPings-1; c++ { 39 | startTime := time.Now() 40 | sc.conn.Write([]byte{46}) 41 | 42 | _, err = sc.conn.Read(buf) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | endTime := time.Now() 47 | 48 | sc.pingTime <- endTime.Sub(startTime) 49 | } 50 | 51 | // Kill off the progress bar updater and block until it's gone 52 | sc.testDone <- true 53 | 54 | return 55 | } 56 | 57 | // pingProcessor recieves the ping times from pingTest and updates the UI 58 | func (sc *sparkyClient) pingProcessor() { 59 | var pingCount int 60 | var ptMax, ptMin int 61 | var latencyHist pingHistory 62 | 63 | // We never want to run the ping test beyond maxPingTestLength seconds 64 | timeout := time.NewTimer(time.Duration(maxPingTestLength) * time.Second) 65 | 66 | // Signal pingTest() that we're ready 67 | close(sc.pingProcessorReady) 68 | 69 | for { 70 | select { 71 | case <-timeout.C: 72 | // If we've been pinging for maxPingTestLength, call it quits 73 | return 74 | case pt := <-sc.pingTime: 75 | pingCount++ 76 | 77 | // Calculate our ping time in microseconds 78 | ptMicro := pt.Nanoseconds() / 1000 79 | 80 | // Add this ping to our ping history 81 | latencyHist = append(latencyHist, ptMicro) 82 | 83 | ptMin, ptMax = latencyHist.minMax() 84 | 85 | // Advance the progress bar a bit 86 | sc.pingProgressTicker <- true 87 | 88 | // Update the ping stats widget 89 | sc.wr.jobs["latency"].(*termui.Sparklines).Lines[0].Data = latencyHist.toMilli() 90 | sc.wr.jobs["latencystats"].(*termui.Par).Text = fmt.Sprintf("Cur/Min/Max\n%.2f/%.2f/%.2f ms\nAvg/σ\n%.2f/%.2f ms", 91 | float64(ptMicro/1000), float64(ptMin/1000), float64(ptMax/1000), latencyHist.mean()/1000, latencyHist.stdDev()/1000) 92 | sc.wr.Render() 93 | } 94 | } 95 | } 96 | 97 | // toMilli Converts our ping history to milliseconds for display purposes 98 | func (h *pingHistory) toMilli() []int { 99 | var pingMilli []int 100 | 101 | for _, v := range *h { 102 | pingMilli = append(pingMilli, int(v/1000)) 103 | } 104 | 105 | return pingMilli 106 | } 107 | 108 | // mean generates a statistical mean of our historical ping times 109 | func (h *pingHistory) mean() float64 { 110 | var sum uint64 111 | for _, t := range *h { 112 | sum = sum + uint64(t) 113 | } 114 | 115 | return float64(sum / uint64(len(*h))) 116 | } 117 | 118 | // variance calculates the variance of our historical ping times 119 | func (h *pingHistory) variance() float64 { 120 | var sqDevSum float64 121 | 122 | mean := h.mean() 123 | 124 | for _, t := range *h { 125 | sqDevSum = sqDevSum + math.Pow((float64(t)-mean), 2) 126 | } 127 | return sqDevSum / float64(len(*h)) 128 | } 129 | 130 | // stdDev calculates the standard deviation of our historical ping times 131 | func (h *pingHistory) stdDev() float64 { 132 | return math.Sqrt(h.variance()) 133 | } 134 | 135 | func (h *pingHistory) minMax() (int, int) { 136 | var hist []int 137 | for _, v := range *h { 138 | hist = append(hist, int(v)) 139 | } 140 | sort.Ints(hist) 141 | return hist[0], hist[len(hist)-1] 142 | } 143 | -------------------------------------------------------------------------------- /sparkyfish-cli/proto.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log" 8 | "net" 9 | "strings" 10 | 11 | "github.com/gizak/termui" 12 | ) 13 | 14 | func (sc *sparkyClient) beginSession() { 15 | var err error 16 | 17 | sc.conn, err = net.Dial("tcp", sc.serverHostname) 18 | if err != nil { 19 | fatalError(err) 20 | } 21 | 22 | // Create a bufio.Reader for our connection 23 | sc.reader = bufio.NewReader(sc.conn) 24 | 25 | // First command is always HELO, immediately followed by a single-digit protocol version 26 | // e.g. "HELO0". 27 | err = sc.writeCommand(fmt.Sprint("HELO", protocolVersion)) 28 | if err != nil { 29 | sc.protocolError(err) 30 | } 31 | 32 | // In response to our HELO, the server will respond like this: 33 | // HELO\n 34 | // canonicalName\n 35 | // location\n 36 | // where canonicalName is the server's canonical hostname and 37 | // location is the physical location of the server 38 | 39 | // First, we check for the HELO response 40 | response, err := sc.reader.ReadString('\n') 41 | if err != nil { 42 | sc.protocolError(err) 43 | } 44 | response = strings.TrimSpace(response) 45 | 46 | if response != "HELO" { 47 | sc.protocolError(fmt.Errorf("invalid HELO response from server")) 48 | } 49 | 50 | var serverBanner bytes.Buffer 51 | 52 | // Next, we check to see if the server provided a cname 53 | cname, err := sc.reader.ReadString('\n') 54 | if err != nil { 55 | sc.protocolError(err) 56 | } 57 | cname = strings.TrimSpace(cname) 58 | 59 | if cname == "none" { 60 | // If a cname was not provided, we'll just show the hostname that the 61 | // test was run against 62 | cname, _, _ = net.SplitHostPort(sc.serverHostname) 63 | } 64 | 65 | serverBanner.WriteString(sanitize(cname)) 66 | 67 | // Finally we check to see if the server provided a location 68 | location, err := sc.reader.ReadString('\n') 69 | if err != nil { 70 | sc.protocolError(err) 71 | } 72 | location = strings.TrimSpace(location) 73 | 74 | if location != "none" { 75 | serverBanner.WriteString(" :: ") 76 | serverBanner.WriteString(sanitize(location)) 77 | } 78 | 79 | if serverBanner.Len() > 0 { 80 | // Don't write a banner longer than 60 characters 81 | if serverBanner.Len() > 60 { 82 | sc.wr.jobs["bannerbox"].(*termui.Par).Text = serverBanner.String()[:59] 83 | } else { 84 | sc.wr.jobs["bannerbox"].(*termui.Par).Text = serverBanner.String() 85 | } 86 | sc.wr.Render() 87 | } 88 | 89 | } 90 | 91 | func (sc *sparkyClient) protocolError(err error) { 92 | termui.Clear() 93 | termui.Close() 94 | log.Fatalln(err) 95 | } 96 | 97 | func (sc *sparkyClient) writeCommand(cmd string) error { 98 | s := fmt.Sprintf("%v\r\n", cmd) 99 | _, err := sc.conn.Write([]byte(s)) 100 | return err 101 | } 102 | 103 | func sanitize(str string) string { 104 | b := make([]byte, len(str)) 105 | var bi int 106 | for i := 0; i < len(str); i++ { 107 | c := str[i] 108 | if c >= 32 && c < 127 { 109 | b[bi] = c 110 | bi++ 111 | } 112 | } 113 | return string(b[:bi]) 114 | } 115 | -------------------------------------------------------------------------------- /sparkyfish-cli/sparkyfish-cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "log" 8 | "net" 9 | "os" 10 | "runtime" 11 | "sync" 12 | "time" 13 | 14 | "github.com/dustin/randbo" 15 | "github.com/gizak/termui" 16 | ) 17 | 18 | const ( 19 | protocolVersion uint16 = 0x00 // Protocol Version 20 | blockSize int64 = 200 // size (KB) of each block of data copied to/from remote 21 | reportIntervalMS uint64 = 500 // report interval in milliseconds 22 | throughputTestLength uint = 10 // length of time to conduct each throughput test 23 | maxPingTestLength uint = 10 // maximum time for ping test to complete 24 | numPings int = 30 // number of pings to attempt 25 | ) 26 | 27 | // command is used to indicate the type of test being performed 28 | type command int 29 | 30 | const ( 31 | outbound command = iota // upload test 32 | inbound // download test 33 | echo // echo (ping) test 34 | ) 35 | 36 | type sparkyClient struct { 37 | conn net.Conn 38 | reader *bufio.Reader 39 | randomData []byte 40 | randReader *bytes.Reader 41 | serverCname string 42 | serverLocation string 43 | serverHostname string 44 | pingTime chan time.Duration 45 | blockTicker chan bool 46 | pingProgressTicker chan bool 47 | testDone chan bool 48 | allTestsDone chan struct{} 49 | progressBarReset chan bool 50 | throughputReport chan float64 51 | statsGeneratorDone chan struct{} 52 | changeToUpload chan struct{} 53 | pingProcessorReady chan struct{} 54 | wr *widgetRenderer 55 | rendererMu *sync.Mutex 56 | } 57 | 58 | func main() { 59 | if len(os.Args) < 2 { 60 | log.Fatal("Usage: ", os.Args[0], " [:port]") 61 | } 62 | 63 | dest := os.Args[1] 64 | i := last(dest, ':') 65 | if i < 0 { 66 | dest = fmt.Sprint(dest, ":7121") 67 | } 68 | 69 | // Initialize our screen 70 | err := termui.Init() 71 | if err != nil { 72 | panic(err) 73 | } 74 | 75 | if termui.TermWidth() < 60 || termui.TermHeight() < 28 { 76 | fmt.Println("sparkyfish needs a terminal window at least 60x28 to run.") 77 | os.Exit(1) 78 | } 79 | 80 | defer termui.Close() 81 | 82 | // 'q' quits the program 83 | termui.Handle("/sys/kbd/q", func(termui.Event) { 84 | termui.StopLoop() 85 | }) 86 | // 'Q' also works 87 | termui.Handle("/sys/kbd/Q", func(termui.Event) { 88 | termui.StopLoop() 89 | }) 90 | 91 | sc := newsparkyClient() 92 | sc.serverHostname = dest 93 | 94 | sc.prepareChannels() 95 | 96 | sc.wr = newwidgetRenderer() 97 | 98 | // Begin our tests 99 | go sc.runTestSequence() 100 | 101 | termui.Loop() 102 | } 103 | 104 | // NewsparkyClient creates a new sparkyClient object 105 | func newsparkyClient() *sparkyClient { 106 | m := sparkyClient{} 107 | 108 | // Make a 10MB byte slice to hold our random data blob 109 | m.randomData = make([]byte, 1024*1024*10) 110 | 111 | // Use a randbo Reader to fill our big slice with random data 112 | _, err := randbo.New().Read(m.randomData) 113 | if err != nil { 114 | log.Fatalln("error generating random data:", err) 115 | } 116 | 117 | // Create a bytes.Reader over this byte slice 118 | m.randReader = bytes.NewReader(m.randomData) 119 | 120 | return &m 121 | } 122 | 123 | func (sc *sparkyClient) prepareChannels() { 124 | 125 | // Prepare some channels that we'll use for measuring 126 | // throughput and latency 127 | sc.blockTicker = make(chan bool, 200) 128 | sc.throughputReport = make(chan float64) 129 | sc.pingTime = make(chan time.Duration, 10) 130 | sc.pingProgressTicker = make(chan bool, numPings) 131 | 132 | // Prepare some channels that we'll use to signal 133 | // various state changes in the testing process 134 | sc.pingProcessorReady = make(chan struct{}) 135 | sc.changeToUpload = make(chan struct{}) 136 | sc.statsGeneratorDone = make(chan struct{}) 137 | sc.testDone = make(chan bool) 138 | sc.progressBarReset = make(chan bool) 139 | sc.allTestsDone = make(chan struct{}) 140 | 141 | } 142 | 143 | func (sc *sparkyClient) runTestSequence() { 144 | // First, we need to build the widgets on our screen. 145 | 146 | // Build our title box 147 | titleBox := termui.NewPar("──────[ sparkyfish ]────────────────────────────────────────") 148 | titleBox.Height = 1 149 | titleBox.Width = 60 150 | titleBox.Y = 0 151 | titleBox.Border = false 152 | titleBox.TextFgColor = termui.ColorWhite | termui.AttrBold 153 | 154 | // Build the server name/location banner line 155 | bannerBox := termui.NewPar("") 156 | bannerBox.Height = 1 157 | bannerBox.Width = 60 158 | bannerBox.Y = 1 159 | bannerBox.Border = false 160 | bannerBox.TextFgColor = termui.ColorRed | termui.AttrBold 161 | 162 | // Build a download graph widget 163 | dlGraph := termui.NewLineChart() 164 | dlGraph.BorderLabel = " Download Speed (Mbit/s)" 165 | dlGraph.Data = []float64{0} 166 | dlGraph.Width = 30 167 | dlGraph.Height = 12 168 | dlGraph.PaddingTop = 1 169 | dlGraph.X = 0 170 | dlGraph.Y = 6 171 | // Windows Command Prompt doesn't support our Unicode characters with the default font 172 | if runtime.GOOS == "windows" { 173 | dlGraph.Mode = "dot" 174 | dlGraph.DotStyle = '+' 175 | } 176 | dlGraph.AxesColor = termui.ColorWhite 177 | dlGraph.LineColor = termui.ColorGreen | termui.AttrBold 178 | 179 | // Build an upload graph widget 180 | ulGraph := termui.NewLineChart() 181 | ulGraph.BorderLabel = " Upload Speed (Mbit/s)" 182 | ulGraph.Data = []float64{0} 183 | ulGraph.Width = 30 184 | ulGraph.Height = 12 185 | ulGraph.PaddingTop = 1 186 | ulGraph.X = 30 187 | ulGraph.Y = 6 188 | // Windows Command Prompt doesn't support our Unicode characters with the default font 189 | if runtime.GOOS == "windows" { 190 | ulGraph.Mode = "dot" 191 | ulGraph.DotStyle = '+' 192 | } 193 | ulGraph.AxesColor = termui.ColorWhite 194 | ulGraph.LineColor = termui.ColorGreen | termui.AttrBold 195 | 196 | latencyGraph := termui.NewSparkline() 197 | latencyGraph.LineColor = termui.ColorCyan 198 | latencyGraph.Height = 3 199 | 200 | latencyGroup := termui.NewSparklines(latencyGraph) 201 | latencyGroup.Y = 3 202 | latencyGroup.Height = 3 203 | latencyGroup.Width = 30 204 | latencyGroup.Border = false 205 | latencyGroup.Lines[0].Data = []int{0} 206 | 207 | latencyTitle := termui.NewPar("Latency") 208 | latencyTitle.Height = 1 209 | latencyTitle.Width = 30 210 | latencyTitle.Border = false 211 | latencyTitle.TextFgColor = termui.ColorGreen 212 | latencyTitle.Y = 2 213 | 214 | latencyStats := termui.NewPar("") 215 | latencyStats.Height = 4 216 | latencyStats.Width = 30 217 | latencyStats.X = 32 218 | latencyStats.Y = 2 219 | latencyStats.Border = false 220 | latencyStats.TextFgColor = termui.ColorWhite | termui.AttrBold 221 | latencyStats.Text = "Last: 30ms\nMin: 2ms\nMax: 34ms" 222 | 223 | // Build a stats summary widget 224 | statsSummary := termui.NewPar("") 225 | statsSummary.Height = 7 226 | statsSummary.Width = 60 227 | statsSummary.Y = 18 228 | statsSummary.BorderLabel = " Throughput Summary " 229 | statsSummary.Text = fmt.Sprintf("DOWNLOAD \nCurrent: -- Mbit/s\tMax: --\tAvg: --\n\nUPLOAD\nCurrent: -- Mbit/s\tMax: --\tAvg: --") 230 | statsSummary.TextFgColor = termui.ColorWhite | termui.AttrBold 231 | 232 | // Build out progress gauge widget 233 | progress := termui.NewGauge() 234 | progress.Percent = 40 235 | progress.Width = 60 236 | progress.Height = 3 237 | progress.Y = 25 238 | progress.X = 0 239 | progress.Border = true 240 | progress.BorderLabel = " Test Progress " 241 | progress.Percent = 0 242 | progress.BarColor = termui.ColorRed 243 | progress.BorderFg = termui.ColorWhite 244 | progress.PercentColorHighlighted = termui.ColorWhite | termui.AttrBold 245 | progress.PercentColor = termui.ColorWhite | termui.AttrBold 246 | 247 | // Build our helpbox widget 248 | helpBox := termui.NewPar(" COMMANDS: [q]uit") 249 | helpBox.Height = 1 250 | helpBox.Width = 60 251 | helpBox.Y = 28 252 | helpBox.Border = false 253 | helpBox.TextBgColor = termui.ColorBlue 254 | helpBox.TextFgColor = termui.ColorYellow | termui.AttrBold 255 | helpBox.Bg = termui.ColorBlue 256 | 257 | // Add the widgets to the rendering jobs and render the screen 258 | sc.wr.Add("titlebox", titleBox) 259 | sc.wr.Add("bannerbox", bannerBox) 260 | sc.wr.Add("dlgraph", dlGraph) 261 | sc.wr.Add("ulgraph", ulGraph) 262 | sc.wr.Add("latency", latencyGroup) 263 | sc.wr.Add("latencytitle", latencyTitle) 264 | sc.wr.Add("latencystats", latencyStats) 265 | sc.wr.Add("statsSummary", statsSummary) 266 | sc.wr.Add("progress", progress) 267 | sc.wr.Add("helpbox", helpBox) 268 | sc.wr.Render() 269 | 270 | // Launch a progress bar updater 271 | go sc.updateProgressBar() 272 | 273 | // Start our ping test and block until it's complete 274 | sc.pingTest() 275 | 276 | // Start our stats generator, which receives realtime measurements from the throughput 277 | // reporter and generates metrics from them 278 | go sc.generateStats() 279 | 280 | // Run our download tests and block until that's done 281 | sc.runThroughputTest(inbound) 282 | 283 | // Signal to our MeasureThroughput that we're about to begin the upload test 284 | close(sc.changeToUpload) 285 | 286 | // Run an outbound (upload) throughput test and block until it's complete 287 | sc.runThroughputTest(outbound) 288 | 289 | // Signal to our generators that the upload test is complete 290 | close(sc.statsGeneratorDone) 291 | 292 | // Notify the progress bar updater to change the bar color to green 293 | close(sc.allTestsDone) 294 | 295 | return 296 | } 297 | 298 | // updateProgressBar updates the progress bar as tests run 299 | func (sc *sparkyClient) updateProgressBar() { 300 | var updateIntervalMS uint = 500 301 | var progress uint 302 | 303 | sc.wr.jobs["progress"].(*termui.Gauge).BarColor = termui.ColorRed 304 | 305 | //progressPerUpdate := throughputTestLength / (updateIntervalMS / 1000) 306 | var progressPerUpdate uint = 100 / 20 307 | 308 | // Set a ticker for advancing the progress bar 309 | tick := time.NewTicker(time.Duration(updateIntervalMS) * time.Millisecond) 310 | 311 | for { 312 | select { 313 | case <-tick.C: 314 | // Update via our update interval ticker, but never beyond 100% 315 | progress = progress + progressPerUpdate 316 | if progress > 100 { 317 | progress = 100 318 | } 319 | sc.wr.jobs["progress"].(*termui.Gauge).Percent = int(progress) 320 | sc.wr.Render() 321 | 322 | case <-sc.pingProgressTicker: 323 | // Update as each ping comes back, but never beyond 100% 324 | progress = progress + uint(100/numPings) 325 | if progress > 100 { 326 | progress = 100 327 | } 328 | sc.wr.jobs["progress"].(*termui.Gauge).Percent = int(progress) 329 | sc.wr.Render() 330 | 331 | // No need to render, since it's already happening with each ping 332 | case <-sc.testDone: 333 | // As each test completes, we set the progress bar to 100% completion. 334 | // It will be reset to 0% at the start of the next test. 335 | sc.wr.jobs["progress"].(*termui.Gauge).Percent = 100 336 | sc.wr.Render() 337 | case <-sc.progressBarReset: 338 | // Reset our progress tracker 339 | progress = 0 340 | // Reset the progress bar 341 | sc.wr.jobs["progress"].(*termui.Gauge).Percent = 0 342 | sc.wr.Render() 343 | case <-sc.allTestsDone: 344 | // Make sure that our progress bar always ends at 100%. :) 345 | sc.wr.jobs["progress"].(*termui.Gauge).Percent = 100 346 | sc.wr.jobs["progress"].(*termui.Gauge).BarColor = termui.ColorGreen 347 | sc.wr.Render() 348 | return 349 | } 350 | } 351 | 352 | } 353 | 354 | func fatalError(err error) { 355 | termui.Clear() 356 | termui.Close() 357 | log.Fatal(err) 358 | } 359 | 360 | // Index of rightmost occurrence of b in s. 361 | // Borrowed from golang.org/pkg/net/net.go 362 | func last(s string, b byte) int { 363 | i := len(s) 364 | for i--; i >= 0; i-- { 365 | if s[i] == b { 366 | break 367 | } 368 | } 369 | return i 370 | } 371 | -------------------------------------------------------------------------------- /sparkyfish-cli/throughput.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "strconv" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/gizak/termui" 13 | ) 14 | 15 | // Kick off a throughput measurement test 16 | func (sc *sparkyClient) runThroughputTest(testType command) { 17 | // Notify the progress bar updater to reset the bar 18 | sc.progressBarReset <- true 19 | 20 | // Used to signal test completion to the throughput measurer 21 | measurerDone := make(chan struct{}) 22 | 23 | // Launch a throughput measurer and then kick off the metered copy, 24 | // blocking until it completes. 25 | go sc.MeasureThroughput(measurerDone) 26 | sc.MeteredCopy(testType, measurerDone) 27 | 28 | // Notify the progress bar updater that the test is done 29 | sc.testDone <- true 30 | } 31 | 32 | // Kicks off a metered copy (throughput test) by sending a command to the server 33 | // and then performing the appropriate I/O copy, sending "ticks" by channel as 34 | // each block of data passes through. 35 | func (sc *sparkyClient) MeteredCopy(testType command, measurerDone chan<- struct{}) { 36 | var tl time.Duration 37 | 38 | // Connect to the remote sparkyfish server 39 | sc.beginSession() 40 | 41 | defer sc.conn.Close() 42 | 43 | // Send the appropriate command to the sparkyfish server to initiate our 44 | // throughput test 45 | switch testType { 46 | case inbound: 47 | // For inbound tests, we bump our timer by 2 seconds to account for 48 | // the remote server's test startup time 49 | tl = time.Second * time.Duration(throughputTestLength+2) 50 | 51 | // Send the SND command to the remote server, requesting a download test 52 | // (remote sends). 53 | err := sc.writeCommand("SND") 54 | if err != nil { 55 | termui.Close() 56 | log.Fatalln(err) 57 | } 58 | case outbound: 59 | tl = time.Second * time.Duration(throughputTestLength) 60 | 61 | // Send the RCV command to the remote server, requesting an upload test 62 | // (remote receives). 63 | err := sc.writeCommand("RCV") 64 | if err != nil { 65 | termui.Close() 66 | log.Fatalln(err) 67 | } 68 | } 69 | 70 | // Set a timer for running the tests 71 | timer := time.NewTimer(tl) 72 | 73 | switch testType { 74 | case inbound: 75 | // Receive, tally, and discard incoming data as fast as we can until the sender stops sending or the timer expires 76 | for { 77 | select { 78 | case <-timer.C: 79 | // Timer has elapsed and test is finished 80 | close(measurerDone) 81 | return 82 | default: 83 | // Copy data from our net.Conn to the rubbish bin in (blockSize) KB chunks 84 | _, err := io.CopyN(ioutil.Discard, sc.conn, 1024*blockSize) 85 | if err != nil { 86 | // Handle the EOF when the test timer has expired at the remote end. 87 | if err == io.EOF || err == io.ErrClosedPipe || err == syscall.EPIPE { 88 | close(measurerDone) 89 | return 90 | } 91 | log.Println("Error copying:", err) 92 | return 93 | } 94 | // With each chunk copied, we send a message on our blockTicker channel 95 | sc.blockTicker <- true 96 | 97 | } 98 | } 99 | case outbound: 100 | // Send and tally outgoing data as fast as we can until the receiver stops receiving or the timer expires 101 | for { 102 | select { 103 | case <-timer.C: 104 | // Timer has elapsed and test is finished 105 | close(measurerDone) 106 | return 107 | default: 108 | // Copy data from our pre-filled bytes.Reader to the net.Conn in (blockSize) KB chunks 109 | _, err := io.CopyN(sc.conn, sc.randReader, 1024*blockSize) 110 | if err != nil { 111 | // If we get any of these errors, it probably just means that the server closed the connection 112 | if err == io.EOF || err == io.ErrClosedPipe || err == syscall.EPIPE { 113 | close(measurerDone) 114 | return 115 | } 116 | log.Println("Error copying:", err) 117 | return 118 | } 119 | 120 | // Make sure that we have enough runway in our bytes.Reader to handle the next read 121 | if sc.randReader.Len() <= int(1024*blockSize) { 122 | // We're nearing the end of the Reader, so seek back to the beginning and start again 123 | sc.randReader.Seek(0, 0) 124 | } 125 | 126 | // With each chunk copied, we send a message on our blockTicker channel 127 | sc.blockTicker <- true 128 | } 129 | } 130 | } 131 | } 132 | 133 | // MeasureThroughput receives ticks sent by MeteredCopy() and derives a throughput rate, which is then sent 134 | // to the throughput reporter. 135 | func (sc *sparkyClient) MeasureThroughput(measurerDone <-chan struct{}) { 136 | var testType = inbound 137 | var blockCount, prevBlockCount uint64 138 | var throughput float64 139 | var throughputHist []float64 140 | 141 | tick := time.NewTicker(time.Duration(reportIntervalMS) * time.Millisecond) 142 | for { 143 | select { 144 | case <-sc.blockTicker: 145 | // Increment our block counter when we get a ticker 146 | blockCount++ 147 | case <-measurerDone: 148 | tick.Stop() 149 | return 150 | case <-sc.changeToUpload: 151 | // The download test has completed, so we switch to tallying upload chunks 152 | testType = outbound 153 | case <-tick.C: 154 | throughput = (float64(blockCount - prevBlockCount)) * float64(blockSize*8) / float64(reportIntervalMS) 155 | 156 | // We discard the first element of the throughputHist slice once we have 70 157 | // elements stored. This gives the user a chart that appears to scroll to 158 | // the left as new measurements come in and old ones are discarded. 159 | if len(throughputHist) >= 70 { 160 | throughputHist = throughputHist[1:] 161 | } 162 | 163 | // Add our latest measurement to the slice of historical measurements 164 | throughputHist = append(throughputHist, throughput) 165 | 166 | // Update the appropriate graph with the latest measurements 167 | switch testType { 168 | case inbound: 169 | sc.wr.jobs["dlgraph"].(*termui.LineChart).Data = throughputHist 170 | case outbound: 171 | sc.wr.jobs["ulgraph"].(*termui.LineChart).Data = throughputHist 172 | } 173 | 174 | // Send the latest measurement on to the stats generator 175 | sc.throughputReport <- throughput 176 | 177 | // Update the current block counter 178 | prevBlockCount = blockCount 179 | } 180 | } 181 | } 182 | 183 | // generateStats receives download and upload speed reports and computes metrics 184 | // which are displayed in the stats widget. 185 | func (sc *sparkyClient) generateStats() { 186 | var measurement float64 187 | var currentDL, maxDL, avgDL float64 188 | var currentUL, maxUL, avgUL float64 189 | var dlReadingCount, dlReadingSum float64 190 | var ulReadingCount, ulReadingSum float64 191 | var testType = inbound 192 | 193 | for { 194 | select { 195 | case measurement = <-sc.throughputReport: 196 | switch testType { 197 | case inbound: 198 | currentDL = measurement 199 | dlReadingCount++ 200 | dlReadingSum = dlReadingSum + currentDL 201 | avgDL = dlReadingSum / dlReadingCount 202 | if currentDL > maxDL { 203 | maxDL = currentDL 204 | } 205 | // Update our stats widget with the latest readings 206 | sc.wr.jobs["statsSummary"].(*termui.Par).Text = fmt.Sprintf("DOWNLOAD \nCurrent: %v Mbit/s\tMax: %v\tAvg: %v\n\nUPLOAD\nCurrent: %v Mbit/s\tMax: %v\tAvg: %v", 207 | strconv.FormatFloat(currentDL, 'f', 1, 64), strconv.FormatFloat(maxDL, 'f', 1, 64), strconv.FormatFloat(avgDL, 'f', 1, 64), 208 | strconv.FormatFloat(currentUL, 'f', 1, 64), strconv.FormatFloat(maxUL, 'f', 1, 64), strconv.FormatFloat(avgUL, 'f', 1, 64)) 209 | sc.wr.Render() 210 | case outbound: 211 | currentUL = measurement 212 | ulReadingCount++ 213 | ulReadingSum = ulReadingSum + currentUL 214 | avgUL = ulReadingSum / ulReadingCount 215 | if currentUL > maxUL { 216 | maxUL = currentUL 217 | } 218 | // Update our stats widget with the latest readings 219 | sc.wr.jobs["statsSummary"].(*termui.Par).Text = fmt.Sprintf("DOWNLOAD \nCurrent: %v Mbit/s\tMax: %v\tAvg: %v\n\nUPLOAD\nCurrent: %v Mbit/s\tMax: %v\tAvg: %v", 220 | strconv.FormatFloat(currentDL, 'f', 1, 64), strconv.FormatFloat(maxDL, 'f', 1, 64), strconv.FormatFloat(avgDL, 'f', 1, 64), 221 | strconv.FormatFloat(currentUL, 'f', 1, 64), strconv.FormatFloat(maxUL, 'f', 1, 64), strconv.FormatFloat(avgUL, 'f', 1, 64)) 222 | sc.wr.Render() 223 | 224 | } 225 | case <-sc.changeToUpload: 226 | testType = outbound 227 | case <-sc.statsGeneratorDone: 228 | return 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /sparkyfish-cli/widgetrenderer.go: -------------------------------------------------------------------------------- 1 | // Routines for managing a collection of termui widgets concurrently 2 | package main 3 | 4 | import ( 5 | "github.com/gizak/termui" 6 | ) 7 | 8 | type widgetRenderer struct { 9 | jobs map[string]termui.Bufferer 10 | } 11 | 12 | func newwidgetRenderer() *widgetRenderer { 13 | wr := widgetRenderer{} 14 | wr.jobs = make(map[string]termui.Bufferer) 15 | return &wr 16 | } 17 | 18 | func (wr *widgetRenderer) Add(name string, job termui.Bufferer) { 19 | wr.jobs[name] = job 20 | } 21 | 22 | func (wr *widgetRenderer) Delete(name string) { 23 | delete(wr.jobs, name) 24 | } 25 | 26 | func (wr *widgetRenderer) Render() { 27 | var jobs []termui.Bufferer 28 | for _, j := range wr.jobs { 29 | jobs = append(jobs, j) 30 | } 31 | termui.Render(jobs...) 32 | } 33 | -------------------------------------------------------------------------------- /sparkyfish-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gliderlabs/alpine:latest 2 | MAINTAINER Chris Snell github.com/chrissnell 3 | ADD sparkyfish-server-linux-amd64 /sparkyfish-server 4 | ENV DEBUG false 5 | ENV CNAME none 6 | ENV LOCATION none 7 | ENV LISTEN_ADDR :7121 8 | EXPOSE 7121 9 | 10 | CMD /sparkyfish-server -listen-addr=$LISTEN_ADDR -debug=$DEBUG -cname=$CNAME -location="$LOCATION" 11 | -------------------------------------------------------------------------------- /sparkyfish-server/Makefile: -------------------------------------------------------------------------------- 1 | all: sparkyfish-server 2 | 3 | sparkyfish-server: 4 | go build 5 | 6 | docker-build: Dockerfile 7 | GOOS=linux GOARCH=amd64 go build -o sparkyfish-server-linux-amd64 8 | docker build -t chrissnell/sparkyfish-server:$(GIT_TAG) . 9 | 10 | docker-push: 11 | docker push chrissnell/sparkyfish-server:$(GIT_TAG) 12 | docker tag -f chrissnell/sparkyfish-server:$(GIT_TAG) chrissnell/sparkyfish-server:latest 13 | docker push chrissnell/sparkyfish-server:latest 14 | 15 | docker: docker-build docker-push 16 | 17 | GIT_TAG := $(shell git describe --tag --abbrev=0) 18 | -------------------------------------------------------------------------------- /sparkyfish-server/sparkyfish-server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "net" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/dustin/randbo" 17 | ) 18 | 19 | var ( 20 | cname *string 21 | location *string 22 | debug *bool 23 | ) 24 | 25 | const ( 26 | protocolVersion uint16 = 0x00 // The latest version of the sparkyfish protocol supported 27 | blockSize int64 = 1024 // size of each block copied to/from remote 28 | reportIntervalMS uint64 = 1000 // report interval in milliseconds 29 | testLength uint = 10 // length of throughput tests (sec) 30 | pingTestLength int = 30 // number of pings allowed in a ping test 31 | 32 | ) 33 | 34 | // TestType is used to indicate the type of test being performed 35 | type TestType int 36 | 37 | const ( 38 | outbound TestType = iota 39 | inbound 40 | echo 41 | ) 42 | 43 | type sparkyServer struct { 44 | randomData []byte 45 | } 46 | 47 | // newsparkyServer creates a sparkyServer object and pre-fills some random data 48 | func newsparkyServer() sparkyServer { 49 | ss := sparkyServer{} 50 | 51 | // Make a 10MB byte slice 52 | ss.randomData = make([]byte, 1024*1024*10) 53 | 54 | // Fill our 10MB byte slice with random data 55 | _, err := randbo.New().Read(ss.randomData) 56 | if err != nil { 57 | log.Fatalln("error generating random data:", err) 58 | } 59 | 60 | return ss 61 | } 62 | 63 | // sparkyClient handles requests for throughput and latency tests 64 | type sparkyClient struct { 65 | client net.Conn 66 | testType TestType 67 | reader *bufio.Reader 68 | randReader *bytes.Reader 69 | blockTicker chan bool 70 | done chan bool 71 | } 72 | 73 | func newsparkyClient(client net.Conn) sparkyClient { 74 | sc := sparkyClient{client: client} 75 | return sc 76 | } 77 | 78 | func startListener(listenAddr string, ss *sparkyServer) { 79 | listener, err := net.Listen("tcp", listenAddr) 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | for { 85 | conn, err := listener.Accept() 86 | if err != nil { 87 | log.Println("error accepting connection:", err) 88 | continue 89 | } 90 | go handler(conn, ss) 91 | } 92 | 93 | } 94 | 95 | func handler(conn net.Conn, ss *sparkyServer) { 96 | var version uint64 97 | 98 | sc := newsparkyClient(conn) 99 | 100 | sc.done = make(chan bool) 101 | sc.blockTicker = make(chan bool, 200) 102 | 103 | // Create a bytes.Reader over our pre-filled 10MB byte slice 104 | sc.randReader = bytes.NewReader(ss.randomData) 105 | 106 | defer sc.client.Close() 107 | 108 | sc.reader = bufio.NewReader(sc.client) 109 | 110 | // Every connection begins with a HELO command, 111 | // where is one byte that will be converted to a uint16 112 | helo, err := sc.reader.ReadString('\n') 113 | if err != nil { 114 | // If a client hangs up, just hang up silently 115 | return 116 | } 117 | helo = strings.TrimSpace(helo) 118 | 119 | if *debug { 120 | log.Println("COMMAND RECEIVED:", helo) 121 | } 122 | 123 | // The HELO command must be exactly 7 bytes long, including version and CRLF 124 | if len(helo) != 5 { 125 | sc.client.Write([]byte("ERR:Invalid HELO received\n")) 126 | return 127 | } 128 | 129 | if helo[:4] != "HELO" { 130 | sc.client.Write([]byte("ERR:Invalid HELO received\n")) 131 | return 132 | } 133 | 134 | // Parse the version number 135 | version, err = strconv.ParseUint(helo[4:], 10, 16) 136 | if err != nil { 137 | sc.client.Write([]byte("ERR:Invalid HELO received\n")) 138 | log.Println("error parsing version", err) 139 | return 140 | } 141 | 142 | if *debug { 143 | log.Printf("HELO received. Version: %#x", version) 144 | } 145 | 146 | // Close the connection if the client requests a protocol version 147 | // greater than what we support 148 | if uint16(version) > protocolVersion { 149 | sc.client.Write([]byte("ERR:Protocol version not supported\n")) 150 | log.Println("Invalid protocol version requested", version) 151 | return 152 | } 153 | 154 | banner := bytes.NewBufferString("HELO\n") 155 | if *cname != "" { 156 | banner.WriteString(fmt.Sprintln(*cname)) 157 | } else { 158 | banner.WriteString("none\n") 159 | } 160 | if *location != "" { 161 | banner.WriteString(fmt.Sprintln(*location)) 162 | } else { 163 | banner.WriteString("none\n") 164 | } 165 | 166 | _, err = banner.WriteTo(sc.client) 167 | if err != nil { 168 | log.Println("error writing HELO response to client:", err) 169 | return 170 | } 171 | 172 | cmd, err := sc.reader.ReadString('\n') 173 | if err != nil { 174 | return 175 | } 176 | cmd = strings.TrimSpace(cmd) 177 | 178 | if *debug { 179 | log.Println("COMMAND RECEIVED:", string(cmd)) 180 | } 181 | 182 | if len(cmd) != 3 { 183 | sc.client.Write([]byte("ERR:Invalid command received\n")) 184 | return 185 | } 186 | 187 | switch cmd { 188 | case "SND": 189 | sc.testType = outbound 190 | log.Printf("[%v] initiated download test", sc.client.RemoteAddr()) 191 | case "RCV": 192 | sc.testType = inbound 193 | log.Printf("[%v] initiated upload test", sc.client.RemoteAddr()) 194 | case "ECO": 195 | sc.testType = echo 196 | log.Printf("[%v] initiated echo test", sc.client.RemoteAddr()) 197 | default: 198 | sc.client.Write([]byte("ERR:Invalid command received")) 199 | return 200 | } 201 | 202 | if sc.testType == echo { 203 | // Start an echo/ping test and block until it finishes 204 | sc.echoTest() 205 | } else { 206 | // Start an upload/download test 207 | 208 | // Launch our throughput reporter in a goroutine 209 | go sc.ReportThroughput() 210 | 211 | // Start our metered copier and block until it finishes 212 | sc.MeteredCopy() 213 | 214 | // When our metered copy unblocks, the speed test is done, so we close 215 | // this channel to signal the throughput reporter to halt 216 | sc.done <- true 217 | } 218 | } 219 | 220 | func (sc *sparkyClient) echoTest() { 221 | for c := 0; c <= pingTestLength-1; c++ { 222 | chr, err := sc.reader.ReadByte() 223 | if err != nil { 224 | log.Println("Error reading byte:", err) 225 | break 226 | } 227 | if *debug { 228 | log.Println("Copying byte:", chr) 229 | } 230 | _, err = sc.client.Write([]byte{chr}) 231 | if err != nil { 232 | log.Println("Error writing byte:", err) 233 | break 234 | } 235 | } 236 | return 237 | } 238 | 239 | // MeteredCopy copies to or from a net.Conn, keeping count of the data it passes 240 | func (sc *sparkyClient) MeteredCopy() { 241 | var err error 242 | var timer *time.Timer 243 | 244 | // Set a timer that we'll use to stop the test. If we're running an inbound test, 245 | // we extend the timer by two seconds to allow the client to finish its sending. 246 | if sc.testType == inbound { 247 | timer = time.NewTimer(time.Second * time.Duration(testLength+2)) 248 | } else if sc.testType == outbound { 249 | timer = time.NewTimer(time.Second * time.Duration(testLength)) 250 | } 251 | 252 | for { 253 | select { 254 | case <-timer.C: 255 | if *debug { 256 | log.Println(testLength, "seconds have elapsed.") 257 | } 258 | return 259 | default: 260 | // Copy our random data from randbo to our ResponseWriter, 100KB at a time 261 | switch sc.testType { 262 | case outbound: 263 | // Try to copy the entire 10MB bytes.Reader to the client. 264 | _, err = io.CopyN(sc.client, sc.randReader, 1024*1024*10) 265 | case inbound: 266 | _, err = io.CopyN(ioutil.Discard, sc.client, 1024*blockSize) 267 | } 268 | 269 | // io.EOF is normal when a client drops off after the test 270 | if err != nil { 271 | if err != io.EOF { 272 | log.Println("Error copying:", err) 273 | } 274 | return 275 | } 276 | 277 | // Seek back to the beginning of the bytes.Reader 278 | sc.randReader.Seek(0, 0) 279 | 280 | // // With each 100K copied, we send a message on our blockTicker channel 281 | sc.blockTicker <- true 282 | } 283 | } 284 | } 285 | 286 | // ReportThroughput reports on throughput of data passed by MeterWrite 287 | func (sc *sparkyClient) ReportThroughput() { 288 | var blockCount, prevBlockCount uint64 289 | 290 | tick := time.NewTicker(time.Duration(reportIntervalMS) * time.Millisecond) 291 | 292 | start := time.Now() 293 | 294 | blockcounter: 295 | for { 296 | select { 297 | case <-sc.blockTicker: 298 | // Increment our block counter when we get a ticker 299 | blockCount++ 300 | case <-sc.done: 301 | tick.Stop() 302 | break blockcounter 303 | case <-tick.C: 304 | // Every second, we calculate how many blocks were received 305 | // and derive an average throughput rate. 306 | if *debug { 307 | log.Printf("[%v] %v Kbit/sec", sc.client.RemoteAddr(), (blockCount-prevBlockCount)*uint64(blockSize*8)*(1000/reportIntervalMS)) 308 | } 309 | prevBlockCount = blockCount 310 | } 311 | } 312 | 313 | duration := time.Now().Sub(start).Seconds() 314 | if sc.testType == outbound { 315 | mbCopied := float64(blockCount * 10) 316 | log.Printf("[%v] Sent %v MB in %.2f seconds (%.2f Mbit/s)", sc.client.RemoteAddr(), mbCopied, duration, (mbCopied/duration)*8) 317 | } else if sc.testType == inbound { 318 | mbCopied := float64(blockCount * uint64(blockSize) / 1024) 319 | log.Printf("[%v] Recd %v MB in %.2f seconds (%.2f) Mbit/s", sc.client.RemoteAddr(), mbCopied, duration, (mbCopied/duration)*8) 320 | } 321 | } 322 | 323 | func main() { 324 | listenAddr := flag.String("listen-addr", ":7121", "IP:Port to listen on for speed tests (default: all IPs, port 7121)") 325 | debug = flag.Bool("debug", false, "Print debugging information to stdout") 326 | 327 | // Fetch our hostname. Reported to the client after a successful HELO 328 | cname = flag.String("cname", "", "Canonical hostname or IP address to optionally report to client. If you specify one, it must be DNS-resolvable.") 329 | location = flag.String("location", "", "Location of server (e.g. \"Dallas, TX\") [optional]") 330 | flag.Parse() 331 | 332 | ss := newsparkyServer() 333 | 334 | startListener(*listenAddr, &ss) 335 | } 336 | --------------------------------------------------------------------------------