├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── arm-registry.yml │ └── docker-publish.yml ├── .gitignore ├── .goreleaser.yml ├── .logo ├── LibreSpeed.ai ├── LibreSpeed.svg ├── icon_huge.png ├── logo2.png └── logo3.png ├── Dockerfile ├── LICENSE ├── README.md ├── config └── config.go ├── database ├── bolt │ └── bolt.go ├── database.go ├── memory │ └── memory.go ├── mysql │ ├── mysql.go │ └── telemetry_mysql.sql ├── none │ └── none.go ├── postgresql │ ├── postgresql.go │ └── telemetry_postgresql.sql └── schema │ └── schema.go ├── go.mod ├── go.sum ├── main.go ├── results ├── fonts │ ├── NotoSansDisplay-Light.ttf │ └── NotoSansDisplay-Medium.ttf ├── stats.go └── telemetry.go ├── rpm └── el7 │ ├── README.md │ ├── SOURCES │ ├── librespeedgo.firewalld │ ├── librespeedgo.mainconfig │ └── librespeedgo.service │ └── SPECS │ └── librespeedgo.spec ├── settings.toml ├── systemd ├── README.md ├── speedtest-settings.toml ├── speedtest.service └── speedtest.socket └── web ├── assets ├── example-multipleServers-full.html ├── example-multipleServers-pretty.html ├── example-singleServer-basic.html ├── example-singleServer-chart.html ├── example-singleServer-customSettings.html ├── example-singleServer-gauges.html ├── example-singleServer-pretty.html ├── example-singleServer-progressBar.html ├── index.html ├── speedtest.js └── speedtest_worker.js ├── fs.go ├── helpers.go ├── listener.go ├── listener_linux.go └── web.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug with this project. Don't use this if you need help configuring 4 | it 5 | 6 | --- 7 | 8 | ## Description 9 | A short description of what's going on 10 | 11 | ## Server 12 | Every info you can provide about your server: web server, database, PHP version, ... as well as server-side speedtest settings 13 | If possible, provide an URL to your speedtest so we can check it 14 | 15 | ## Client 16 | Browser, OS, type of connection, unusual software, ... 17 | 18 | ## Steps to reproduce 19 | * Do this 20 | * Do that 21 | * Bad stuff happens 22 | 23 | ## Expected behaviour 24 | What should have happened 25 | 26 | ## Screenshots 27 | If necessary, add screenshots of the test. 28 | F12 > Network screenshots can be particularly useful 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | ## Description 8 | A short description of your idea 9 | 10 | ## Why it should be implemented 11 | Who it would benefit, ... 12 | 13 | ## Optional: implementation suggestions 14 | If you have experience in this field, feel free to give us suggestions 15 | 16 | ## Optional: screenshots 17 | Add some screenshots, mockups, ... if they help clarify what you want 18 | -------------------------------------------------------------------------------- /.github/workflows/arm-registry.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish a Docker image with ARM 2 | on: 3 | workflow_dispatch: 4 | 5 | env: 6 | REGISTRY: ghcr.io 7 | IMAGE_NAME: ${{ github.repository }} 8 | 9 | jobs: 10 | build-and-push-image: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | packages: write 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v3 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v2 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v2 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@v2 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata (tags, labels) for Docker 33 | id: meta 34 | uses: docker/metadata-action@v4 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | 38 | - name: Build and push Docker image 39 | uses: docker/build-push-action@v3 40 | env: 41 | CI: false 42 | with: 43 | context: . 44 | push: true 45 | platforms: linux/amd64,linux/arm64,linux/arm/v7 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | on: 3 | push: 4 | branches: [ "master" ] 5 | # Publish semver tags as releases. 6 | tags: [ 'v*.*.*' ] 7 | pull_request: 8 | branches: [ "master" ] 9 | 10 | env: 11 | # Use docker.io for Docker Hub if empty 12 | REGISTRY: ghcr.io 13 | # github.repository as / 14 | IMAGE_NAME: ${{ github.repository }} 15 | PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7,linux/386 16 | 17 | 18 | jobs: 19 | build: 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | packages: write 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v2 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v2 35 | 36 | # Login against a Docker registry except on PR 37 | # https://github.com/docker/login-action 38 | - name: Log into registry ${{ env.REGISTRY }} 39 | if: github.event_name != 'pull_request' 40 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 41 | with: 42 | registry: ${{ env.REGISTRY }} 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | # Extract metadata (tags, labels) for Docker 47 | # https://github.com/docker/metadata-action 48 | - name: Extract Docker metadata 49 | id: meta 50 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 51 | with: 52 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 53 | 54 | # Build and push Docker image with Buildx (don't push on PR) 55 | # https://github.com/docker/build-push-action 56 | - name: Build and push Docker image 57 | id: build-and-push 58 | uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a 59 | with: 60 | context: . 61 | push: ${{ github.event_name != 'pull_request' }} 62 | tags: ${{ steps.meta.outputs.tags }} 63 | labels: ${{ steps.meta.outputs.labels }} 64 | platforms: ${{ env.PLATFORMS }} 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | ### Linux template 3 | *~ 4 | 5 | # temporary files which can be created if a process still has a handle open of a deleted file 6 | .fuse_hidden* 7 | 8 | # KDE directory preferences 9 | .directory 10 | 11 | # Linux trash folder which might appear on any partition or disk 12 | .Trash-* 13 | 14 | # .nfs files are created when an open file is removed but is still being accessed 15 | .nfs* 16 | 17 | ### JetBrains template 18 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 19 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 20 | 21 | # User-specific stuff 22 | .idea/**/workspace.xml 23 | .idea/**/tasks.xml 24 | .idea/**/usage.statistics.xml 25 | .idea/**/dictionaries 26 | .idea/**/shelf 27 | 28 | # Generated files 29 | .idea/**/contentModel.xml 30 | 31 | # Sensitive or high-churn files 32 | .idea/**/dataSources/ 33 | .idea/**/dataSources.ids 34 | .idea/**/dataSources.local.xml 35 | .idea/**/sqlDataSources.xml 36 | .idea/**/dynamic.xml 37 | .idea/**/uiDesigner.xml 38 | .idea/**/dbnavigator.xml 39 | 40 | # Gradle 41 | .idea/**/gradle.xml 42 | .idea/**/libraries 43 | 44 | # Gradle and Maven with auto-import 45 | # When using Gradle or Maven with auto-import, you should exclude module files, 46 | # since they will be recreated, and may cause churn. Uncomment if using 47 | # auto-import. 48 | # .idea/artifacts 49 | # .idea/compiler.xml 50 | # .idea/jarRepositories.xml 51 | # .idea/modules.xml 52 | # .idea/*.iml 53 | # .idea/modules 54 | # *.iml 55 | # *.ipr 56 | 57 | # CMake 58 | cmake-build-*/ 59 | 60 | # Mongo Explorer plugin 61 | .idea/**/mongoSettings.xml 62 | 63 | # File-based project format 64 | *.iws 65 | 66 | # IntelliJ 67 | out/ 68 | 69 | # mpeltonen/sbt-idea plugin 70 | .idea_modules/ 71 | 72 | # JIRA plugin 73 | atlassian-ide-plugin.xml 74 | 75 | # Cursive Clojure plugin 76 | .idea/replstate.xml 77 | 78 | # Crashlytics plugin (for Android Studio and IntelliJ) 79 | com_crashlytics_export_strings.xml 80 | crashlytics.properties 81 | crashlytics-build.properties 82 | fabric.properties 83 | 84 | # Editor-based Rest Client 85 | .idea/httpRequests 86 | 87 | # Android studio 3.1+ serialized cache file 88 | .idea/caches/build_file_checksums.ser 89 | 90 | ### macOS template 91 | # General 92 | .AppleDouble 93 | .LSOverride 94 | 95 | # Icon must end with two \r 96 | Icon 97 | 98 | # Thumbnails 99 | ._* 100 | 101 | # Files that might appear in the root of a volume 102 | .DocumentRevisions-V100 103 | .fseventsd 104 | .Spotlight-V100 105 | .TemporaryItems 106 | .Trashes 107 | .VolumeIcon.icns 108 | .com.apple.timemachine.donotpresent 109 | 110 | # Directories potentially created on remote AFP share 111 | .AppleDB 112 | .AppleDesktop 113 | Network Trash Folder 114 | Temporary Items 115 | .apdisk 116 | 117 | ### Go template 118 | # Binaries for programs and plugins 119 | *.exe 120 | *.exe~ 121 | *.dll 122 | *.so 123 | *.dylib 124 | 125 | # Test binary, built with `go test -c` 126 | *.test 127 | 128 | # Output of the go coverage tool, specifically when used with LiteIDE 129 | *.out 130 | 131 | # Dependency directories (remove the comment below to include it) 132 | # vendor/ 133 | 134 | speedtest.db 135 | .idea/ 136 | speedtest-backend 137 | dist/ 138 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: 'speedtest-go' 2 | #dist: ./out 3 | before: 4 | hooks: 5 | - go mod download 6 | builds: 7 | - main: ./main.go 8 | id: speedtest-backend 9 | binary: speedtest-backend 10 | env: 11 | - CGO_ENABLED=0 12 | flags: 13 | - -trimpath 14 | ldflags: 15 | - -w -s 16 | goos: 17 | - windows 18 | - linux 19 | - darwin 20 | goarch: 21 | - 386 22 | - amd64 23 | - arm 24 | - arm64 25 | goarm: 26 | - 5 27 | - 6 28 | - 7 29 | ignore: 30 | - goos: darwin 31 | goarch: 386 32 | - goos: windows 33 | goarch: arm 34 | - goos: windows 35 | goarch: arm64 36 | hooks: 37 | post: upx -9 "{{ .Path }}" 38 | - main: ./main.go 39 | id: speedtest-backend-freebsd 40 | binary: speedtest-backend 41 | env: 42 | - CGO_ENABLED=0 43 | flags: 44 | - -trimpath 45 | ldflags: 46 | - -w -s 47 | goos: 48 | - freebsd 49 | goarch: 50 | - 386 51 | - amd64 52 | - arm 53 | - arm64 54 | - mips 55 | - mipsle 56 | goarm: 57 | - 5 58 | - 6 59 | - 7 60 | gomips: 61 | - hardfloat 62 | - softfloat 63 | - main: ./main.go 64 | id: speedtest-backend-noupx-linux 65 | binary: speedtest-backend 66 | env: 67 | - CGO_ENABLED=0 68 | flags: 69 | - -trimpath 70 | ldflags: 71 | - -w -s 72 | goos: 73 | - linux 74 | goarch: 75 | - mips 76 | - mipsle 77 | - mips64 78 | - mips64le 79 | gomips: 80 | - hardfloat 81 | - softfloat 82 | - main: ./main.go 83 | id: speedtest-backend-noupx-windows-arm64 84 | binary: speedtest-backend 85 | env: 86 | - CGO_ENABLED=0 87 | flags: 88 | - -trimpath 89 | ldflags: 90 | - -w -s 91 | goos: 92 | - windows 93 | goarch: 94 | - arm 95 | - arm64 96 | goarm: 97 | - 5 98 | - 6 99 | - 7 100 | archives: 101 | - format_overrides: 102 | - goos: windows 103 | format: zip 104 | files: 105 | - README.md 106 | - LICENSE 107 | - settings.toml 108 | checksum: 109 | name_template: 'checksums.txt' 110 | changelog: 111 | skip: false 112 | sort: asc 113 | release: 114 | github: 115 | owner: librespeed 116 | name: speedtest-go 117 | disable: false 118 | -------------------------------------------------------------------------------- /.logo/LibreSpeed.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librespeed/speedtest-go/7001fa4fa52945cbc6d4c32200bbcce7bbf6145c/.logo/LibreSpeed.ai -------------------------------------------------------------------------------- /.logo/icon_huge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librespeed/speedtest-go/7001fa4fa52945cbc6d4c32200bbcce7bbf6145c/.logo/icon_huge.png -------------------------------------------------------------------------------- /.logo/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librespeed/speedtest-go/7001fa4fa52945cbc6d4c32200bbcce7bbf6145c/.logo/logo2.png -------------------------------------------------------------------------------- /.logo/logo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librespeed/speedtest-go/7001fa4fa52945cbc6d4c32200bbcce7bbf6145c/.logo/logo3.png -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18-alpine AS build_base 2 | RUN apk add --no-cache git gcc ca-certificates libc-dev 3 | WORKDIR /build 4 | COPY go.mod go.sum ./ 5 | RUN go mod download 6 | COPY ./ ./ 7 | RUN go build -ldflags "-w -s" -trimpath -o speedtest . 8 | 9 | FROM alpine:3.16 10 | RUN apk add --no-cache ca-certificates 11 | WORKDIR /app 12 | COPY --from=build_base /build/speedtest ./ 13 | COPY settings.toml ./ 14 | 15 | USER nobody 16 | EXPOSE 8989 17 | 18 | CMD ["./speedtest"] 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![LibreSpeed Logo](https://github.com/librespeed/speedtest-go/blob/master/.logo/logo3.png?raw=true) 2 | 3 | # LibreSpeed 4 | 5 | No Flash, No Java, No WebSocket, No Bullshit. 6 | 7 | This is a very lightweight speed test implemented in JavaScript, using XMLHttpRequest and Web Workers. 8 | 9 | ## Try it 10 | [Take a speed test](https://speedtest.zzz.cat) 11 | 12 | ## Compatibility 13 | All modern browsers are supported: IE11, latest Edge, latest Chrome, latest Firefox, latest Safari. 14 | Works with mobile versions too. 15 | 16 | ## Features 17 | * Download 18 | * Upload 19 | * Ping 20 | * Jitter 21 | * IP Address, ISP, distance from server (optional) 22 | * Telemetry (optional) 23 | * Results sharing (optional) 24 | * Multiple Points of Test (optional) 25 | * Compatible with PHP frontend predefined endpoints (with `.php` suffixes) 26 | * Supports [Proxy Protocol](https://www.haproxy.org/download/2.3/doc/proxy-protocol.txt) (without TLV support yet) 27 | 28 | ![Screencast](https://speedtest.zzz.cat/speedtest.webp) 29 | 30 | ## Server requirements 31 | * Any [Go supported platforms](https://github.com/golang/go/wiki/MinimumRequirements) 32 | * BoltDB, PostgreSQL or MySQL database to store test results (optional) 33 | * A fast! Internet connection 34 | 35 | ## Installation 36 | 37 | ### Install using prebuilt binaries 38 | 39 | 1. Download the appropriate binary file from the [releases](https://github.com/librespeed/speedtest-go/releases/) page. 40 | 2. Unzip the archive. 41 | 3. Make changes to the configuration. 42 | 4. Run the binary. 43 | 5. Optional: Setup a systemd service file. 44 | 45 | ### Use Ansible for automatic installation 46 | 47 | You can use an Ansible role for installing speedtest-go easily. You can find the role on the [Ansible galaxy](https://galaxy.ansible.com/flymia/ansible_speedtest_go). There is a [separate repository](https://github.com/flymia/ansible-speedtest_go) for documentation about the Ansible role. 48 | ### Compile from source 49 | 50 | You need Go 1.16+ to compile the binary. If you have an older version of Go and don't want to install the tarball 51 | manually, you can install newer version of Go into your `GOPATH`: 52 | 53 | 0. Install Go 1.17 54 | 55 | ``` 56 | $ go get golang.org/dl/go1.17.1 57 | # Assuming your GOPATH is default (~/go), Go 1.17.1 will be installed in ~/go/bin 58 | $ ~/go/bin/go1.17.1 version 59 | go version go1.17.1 linux/amd64 60 | ``` 61 | 62 | 1. Clone this repository: 63 | 64 | ``` 65 | $ git clone github.com/librespeed/speedtest-go 66 | ``` 67 | 68 | 2. Build 69 | ``` 70 | # Change current working directory to the repository 71 | $ cd speedtest-go 72 | # Compile 73 | $ go build -ldflags "-w -s" -trimpath -o speedtest main.go 74 | ``` 75 | 76 | 3. Copy the `assets` directory, `settings.toml` file along with the compiled `speedtest` binary into a single directory 77 | 78 | 4. If you have telemetry enabled, 79 | - For PostgreSQL/MySQL, create database and import the corresponding `.sql` file under `database/{postgresql,mysql}` 80 | 81 | ``` 82 | # assume you have already created a database named `speedtest` under current user 83 | $ psql speedtest < database/postgresql/telemetry_postgresql.sql 84 | ``` 85 | 86 | - For embedded BoltDB, make sure to define the `database_file` path in `settings.toml`: 87 | 88 | ``` 89 | database_file="speedtest.db" 90 | ``` 91 | 92 | 5. Put `assets` folder under the same directory as your compiled binary. 93 | - Make sure the font files and JavaScripts are in the `assets` directory 94 | - You can have multiple HTML pages under `assets` directory. They can be access directly under the server root 95 | (e.g. `/example-singleServer-full.html`) 96 | - It's possible to have a default page mapped to `/`, simply put a file named `index.html` under `assets` 97 | 98 | 6. Change `settings.toml` according to your environment: 99 | 100 | ```toml 101 | # bind address, use empty string to bind to all interfaces 102 | bind_address="127.0.0.1" 103 | # backend listen port, default is 8989 104 | listen_port=8989 105 | # proxy protocol port, use 0 to disable 106 | proxyprotocol_port=0 107 | # Server location, use zeroes to fetch from API automatically 108 | server_lat=0 109 | server_lng=0 110 | # ipinfo.io API key, if applicable 111 | ipinfo_api_key="" 112 | 113 | # assets directory path, defaults to `assets` in the same directory 114 | # if the path cannot be found, embedded default assets will be used 115 | assets_path="./assets" 116 | 117 | # password for logging into statistics page, change this to enable stats page 118 | statistics_password="PASSWORD" 119 | # redact IP addresses 120 | redact_ip_addresses=false 121 | 122 | # database type for statistics data, currently supports: none, memory, bolt, mysql, postgresql 123 | # if none is specified, no telemetry/stats will be recorded, and no result PNG will be generated 124 | database_type="postgresql" 125 | database_hostname="localhost" 126 | database_name="speedtest" 127 | database_username="postgres" 128 | database_password="" 129 | 130 | # if you use `bolt` as database, set database_file to database file location 131 | database_file="speedtest.db" 132 | 133 | # TLS and HTTP/2 settings. TLS is required for HTTP/2 134 | enable_tls=false 135 | enable_http2=false 136 | 137 | # if you use HTTP/2 or TLS, you need to prepare certificates and private keys 138 | # tls_cert_file="cert.pem" 139 | # tls_key_file="privkey.pem" 140 | ``` 141 | 142 | ## Differences between Go and PHP implementation and caveats 143 | 144 | - Since there is no CGo-free SQLite implementation available, I've opted to use [BoltDB](https://github.com/etcd-io/bbolt) 145 | instead, as an embedded database alternative to SQLite 146 | - Test IDs are generated ULID, there is no option to change them to plain ID 147 | - You can use the same HTML template from the PHP implementation 148 | - Server location can be defined in settings 149 | - There might be a slight delay on program start if your Internet connection is slow. That's because the program will 150 | attempt to fetch your current network's ISP info for distance calculation between your network and the speed test client's. 151 | This action will only be taken once, and cached for later use. 152 | 153 | ## License 154 | Copyright (C) 2016-2020 Federico Dossena 155 | Copyright (C) 2020 Maddie Zhan 156 | 157 | This program is free software: you can redistribute it and/or modify 158 | it under the terms of the GNU Lesser General Public License as published by 159 | the Free Software Foundation, either version 3 of the License, or 160 | (at your option) any later version. 161 | 162 | This program is distributed in the hope that it will be useful, 163 | but WITHOUT ANY WARRANTY; without even the implied warranty of 164 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 165 | GNU General Public License for more details. 166 | 167 | You should have received a copy of the GNU Lesser General Public License 168 | along with this program. If not, see . -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | type Config struct { 9 | BindAddress string `mapstructure:"bind_address"` 10 | Port string `mapstructure:"listen_port"` 11 | BaseURL string `mapstructure:"url_base"` 12 | ProxyProtocolPort string `mapstructure:"proxyprotocol_port"` 13 | ServerLat float64 `mapstructure:"server_lat"` 14 | ServerLng float64 `mapstructure:"server_lng"` 15 | IPInfoAPIKey string `mapstructure:"ipinfo_api_key"` 16 | 17 | StatsPassword string `mapstructure:"statistics_password"` 18 | RedactIP bool `mapstructure:"redact_ip_addresses"` 19 | 20 | AssetsPath string `mapstructure:"assets_path"` 21 | 22 | DatabaseType string `mapstructure:"database_type"` 23 | DatabaseHostname string `mapstructure:"database_hostname"` 24 | DatabaseName string `mapstructure:"database_name"` 25 | DatabaseUsername string `mapstructure:"database_username"` 26 | DatabasePassword string `mapstructure:"database_password"` 27 | 28 | DatabaseFile string `mapstructure:"database_file"` 29 | 30 | EnableHTTP2 bool `mapstructure:"enable_http2"` 31 | EnableTLS bool `mapstructure:"enable_tls"` 32 | TLSCertFile string `mapstructure:"tls_cert_file"` 33 | TLSKeyFile string `mapstructure:"tls_key_file"` 34 | } 35 | 36 | var ( 37 | configFile string 38 | loadedConfig *Config = nil 39 | ) 40 | 41 | func init() { 42 | viper.SetDefault("listen_port", "8989") 43 | viper.SetDefault("url_base", "") 44 | viper.SetDefault("proxyprotocol_port", "0") 45 | viper.SetDefault("download_chunks", 4) 46 | viper.SetDefault("distance_unit", "K") 47 | viper.SetDefault("enable_cors", false) 48 | viper.SetDefault("statistics_password", "PASSWORD") 49 | viper.SetDefault("redact_ip_addresses", false) 50 | viper.SetDefault("database_type", "postgresql") 51 | viper.SetDefault("database_hostname", "localhost") 52 | viper.SetDefault("database_name", "speedtest") 53 | viper.SetDefault("database_username", "postgres") 54 | viper.SetDefault("enable_tls", false) 55 | viper.SetDefault("enable_http2", false) 56 | 57 | viper.SetConfigName("settings") 58 | viper.AddConfigPath(".") 59 | } 60 | 61 | func Load(configPath string) Config { 62 | var conf Config 63 | 64 | configFile = configPath 65 | viper.SetConfigFile(configPath) 66 | viper.SetEnvPrefix("speedtest") 67 | viper.AutomaticEnv() 68 | viper.ReadInConfig() 69 | 70 | if err := viper.Unmarshal(&conf); err != nil { 71 | log.Fatalf("Error parsing config: %s", err) 72 | } 73 | 74 | loadedConfig = &conf 75 | 76 | return conf 77 | } 78 | 79 | func LoadedConfig() *Config { 80 | if loadedConfig == nil { 81 | Load(configFile) 82 | } 83 | return loadedConfig 84 | } 85 | -------------------------------------------------------------------------------- /database/bolt/bolt.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "time" 7 | 8 | "github.com/librespeed/speedtest/database/schema" 9 | 10 | log "github.com/sirupsen/logrus" 11 | "go.etcd.io/bbolt" 12 | ) 13 | 14 | const ( 15 | bucketName = `speedtest` 16 | ) 17 | 18 | type Bolt struct { 19 | db *bbolt.DB 20 | } 21 | 22 | func Open(databaseFile string) *Bolt { 23 | db, err := bbolt.Open(databaseFile, 0666, nil) 24 | if err != nil { 25 | log.Fatalf("Cannot open BoltDB database file: %s", err) 26 | } 27 | return &Bolt{db: db} 28 | } 29 | 30 | func (p *Bolt) Insert(data *schema.TelemetryData) error { 31 | return p.db.Update(func(tx *bbolt.Tx) error { 32 | data.Timestamp = time.Now() 33 | b, _ := json.Marshal(data) 34 | bucket, err := tx.CreateBucketIfNotExists([]byte(bucketName)) 35 | if err != nil { 36 | return err 37 | } 38 | return bucket.Put([]byte(data.UUID), b) 39 | }) 40 | } 41 | 42 | func (p *Bolt) FetchByUUID(uuid string) (*schema.TelemetryData, error) { 43 | var record schema.TelemetryData 44 | err := p.db.View(func(tx *bbolt.Tx) error { 45 | bucket := tx.Bucket([]byte(bucketName)) 46 | if bucket == nil { 47 | return errors.New("data bucket doesn't exist yet") 48 | } 49 | b := bucket.Get([]byte(uuid)) 50 | return json.Unmarshal(b, &record) 51 | }) 52 | return &record, err 53 | } 54 | 55 | func (p *Bolt) FetchLast100() ([]schema.TelemetryData, error) { 56 | var records []schema.TelemetryData 57 | err := p.db.View(func(tx *bbolt.Tx) error { 58 | var record schema.TelemetryData 59 | bucket := tx.Bucket([]byte(bucketName)) 60 | if bucket == nil { 61 | return errors.New("data bucket doesn't exist yet") 62 | } 63 | 64 | cursor := bucket.Cursor() 65 | _, b := cursor.Last() 66 | 67 | for len(records) < 100 { 68 | if err := json.Unmarshal(b, &record); err != nil { 69 | return err 70 | } 71 | records = append(records, record) 72 | 73 | _, b = cursor.Prev() 74 | if b == nil { 75 | break 76 | } 77 | } 78 | 79 | return nil 80 | }) 81 | return records, err 82 | } 83 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/librespeed/speedtest/config" 5 | "github.com/librespeed/speedtest/database/bolt" 6 | "github.com/librespeed/speedtest/database/memory" 7 | "github.com/librespeed/speedtest/database/mysql" 8 | "github.com/librespeed/speedtest/database/none" 9 | "github.com/librespeed/speedtest/database/postgresql" 10 | "github.com/librespeed/speedtest/database/schema" 11 | 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | var ( 16 | DB DataAccess 17 | ) 18 | 19 | type DataAccess interface { 20 | Insert(*schema.TelemetryData) error 21 | FetchByUUID(string) (*schema.TelemetryData, error) 22 | FetchLast100() ([]schema.TelemetryData, error) 23 | } 24 | 25 | func SetDBInfo(conf *config.Config) { 26 | switch conf.DatabaseType { 27 | case "postgresql": 28 | DB = postgresql.Open(conf.DatabaseHostname, conf.DatabaseUsername, conf.DatabasePassword, conf.DatabaseName) 29 | case "mysql": 30 | DB = mysql.Open(conf.DatabaseHostname, conf.DatabaseUsername, conf.DatabasePassword, conf.DatabaseName) 31 | case "bolt": 32 | DB = bolt.Open(conf.DatabaseFile) 33 | case "memory": 34 | DB = memory.Open("") 35 | case "none": 36 | DB = none.Open("") 37 | default: 38 | log.Fatalf("Unsupported database type: %s", conf.DatabaseType) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /database/memory/memory.go: -------------------------------------------------------------------------------- 1 | package memory 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | "time" 7 | 8 | "github.com/librespeed/speedtest/database/schema" 9 | ) 10 | 11 | const ( 12 | // just enough records to return for FetchLast100 13 | maxRecords = 100 14 | ) 15 | 16 | type Memory struct { 17 | lock sync.RWMutex 18 | records []schema.TelemetryData 19 | } 20 | 21 | func Open(_ string) *Memory { 22 | return &Memory{} 23 | } 24 | 25 | func (mem *Memory) Insert(data *schema.TelemetryData) error { 26 | mem.lock.Lock() 27 | defer mem.lock.Unlock() 28 | data.Timestamp = time.Now() 29 | mem.records = append(mem.records, *data) 30 | if len(mem.records) > maxRecords { 31 | mem.records = mem.records[len(mem.records)-maxRecords:] 32 | } 33 | return nil 34 | } 35 | 36 | func (mem *Memory) FetchByUUID(uuid string) (*schema.TelemetryData, error) { 37 | mem.lock.RLock() 38 | defer mem.lock.RUnlock() 39 | for _, record := range mem.records { 40 | if record.UUID == uuid { 41 | return &record, nil 42 | } 43 | } 44 | return nil, errors.New("record not found") 45 | } 46 | 47 | func (mem *Memory) FetchLast100() ([]schema.TelemetryData, error) { 48 | mem.lock.RLock() 49 | defer mem.lock.RUnlock() 50 | return mem.records, nil 51 | } 52 | -------------------------------------------------------------------------------- /database/mysql/mysql.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/librespeed/speedtest/database/schema" 8 | 9 | _ "github.com/go-sql-driver/mysql" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | const ( 14 | connectionStringTemplate = `%s:%s@%s/%s?parseTime=true` 15 | ) 16 | 17 | type MySQL struct { 18 | db *sql.DB 19 | } 20 | 21 | func Open(hostname, username, password, database string) *MySQL { 22 | connStr := fmt.Sprintf(connectionStringTemplate, username, password, hostname, database) 23 | conn, err := sql.Open("mysql", connStr) 24 | if err != nil { 25 | log.Fatalf("Cannot open MySQL database: %s", err) 26 | } 27 | return &MySQL{db: conn} 28 | } 29 | 30 | func (p *MySQL) Insert(data *schema.TelemetryData) error { 31 | stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` 32 | _, err := p.db.Exec(stmt, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID) 33 | return err 34 | } 35 | 36 | func (p *MySQL) FetchByUUID(uuid string) (*schema.TelemetryData, error) { 37 | var record schema.TelemetryData 38 | row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = ?`, uuid) 39 | if row != nil { 40 | var id string 41 | if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { 42 | return nil, err 43 | } 44 | } 45 | return &record, nil 46 | } 47 | 48 | func (p *MySQL) FetchLast100() ([]schema.TelemetryData, error) { 49 | var records []schema.TelemetryData 50 | rows, err := p.db.Query(`SELECT * FROM speedtest_users ORDER BY "timestamp" DESC LIMIT 100;`) 51 | if err != nil { 52 | return nil, err 53 | } 54 | if rows != nil { 55 | var id string 56 | 57 | for rows.Next() { 58 | var record schema.TelemetryData 59 | if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { 60 | return nil, err 61 | } 62 | records = append(records, record) 63 | } 64 | } 65 | return records, nil 66 | } 67 | -------------------------------------------------------------------------------- /database/mysql/telemetry_mysql.sql: -------------------------------------------------------------------------------- 1 | SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 2 | SET AUTOCOMMIT = 0; 3 | START TRANSACTION; 4 | SET time_zone = "+00:00"; 5 | 6 | 7 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 8 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; 9 | /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; 10 | /*!40101 SET NAMES utf8mb4 */; 11 | 12 | -- 13 | -- Database: `speedtest_telemetry` 14 | -- 15 | 16 | -- -------------------------------------------------------- 17 | 18 | -- 19 | -- Table structure for table `speedtest_users` 20 | -- 21 | 22 | CREATE TABLE `speedtest_users` ( 23 | `id` int(11) NOT NULL, 24 | `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | `ip` text NOT NULL, 26 | `ispinfo` text, 27 | `extra` text, 28 | `ua` text NOT NULL, 29 | `lang` text NOT NULL, 30 | `dl` text, 31 | `ul` text, 32 | `ping` text, 33 | `jitter` text, 34 | `log` longtext, 35 | `uuid` text 36 | ) ENGINE=InnoDB DEFAULT CHARSET=latin1; 37 | 38 | -- 39 | -- Indexes for dumped tables 40 | -- 41 | 42 | -- 43 | -- Indexes for table `speedtest_users` 44 | -- 45 | ALTER TABLE `speedtest_users` 46 | ADD PRIMARY KEY (`id`); 47 | 48 | -- 49 | -- AUTO_INCREMENT for dumped tables 50 | -- 51 | 52 | -- 53 | -- AUTO_INCREMENT for table `speedtest_users` 54 | -- 55 | ALTER TABLE `speedtest_users` 56 | MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;COMMIT; 57 | 58 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 59 | /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; 60 | /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; 61 | -------------------------------------------------------------------------------- /database/none/none.go: -------------------------------------------------------------------------------- 1 | package none 2 | 3 | import ( 4 | "github.com/librespeed/speedtest/database/schema" 5 | ) 6 | 7 | type None struct{} 8 | 9 | func Open(_ string) *None { 10 | return &None{} 11 | } 12 | 13 | func (n *None) Insert(_ *schema.TelemetryData) error { 14 | return nil 15 | } 16 | 17 | func (n *None) FetchByUUID(_ string) (*schema.TelemetryData, error) { 18 | return &schema.TelemetryData{}, nil 19 | } 20 | 21 | func (n *None) FetchLast100() ([]schema.TelemetryData, error) { 22 | return []schema.TelemetryData{}, nil 23 | } 24 | -------------------------------------------------------------------------------- /database/postgresql/postgresql.go: -------------------------------------------------------------------------------- 1 | package postgresql 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/librespeed/speedtest/database/schema" 8 | 9 | _ "github.com/lib/pq" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | const ( 14 | connectionStringTemplate = `postgres://%s:%s@%s/%s?sslmode=disable` 15 | ) 16 | 17 | type PostgreSQL struct { 18 | db *sql.DB 19 | } 20 | 21 | func Open(hostname, username, password, database string) *PostgreSQL { 22 | connStr := fmt.Sprintf(connectionStringTemplate, username, password, hostname, database) 23 | conn, err := sql.Open("postgres", connStr) 24 | if err != nil { 25 | log.Fatalf("Cannot open PostgreSQL database: %s", err) 26 | } 27 | return &PostgreSQL{db: conn} 28 | } 29 | 30 | func (p *PostgreSQL) Insert(data *schema.TelemetryData) error { 31 | stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id;` 32 | _, err := p.db.Exec(stmt, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID) 33 | return err 34 | } 35 | 36 | func (p *PostgreSQL) FetchByUUID(uuid string) (*schema.TelemetryData, error) { 37 | var record schema.TelemetryData 38 | row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = $1`, uuid) 39 | if row != nil { 40 | var id string 41 | if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { 42 | return nil, err 43 | } 44 | } 45 | return &record, nil 46 | } 47 | 48 | func (p *PostgreSQL) FetchLast100() ([]schema.TelemetryData, error) { 49 | var records []schema.TelemetryData 50 | rows, err := p.db.Query(`SELECT * FROM speedtest_users ORDER BY "timestamp" DESC LIMIT 100;`) 51 | if err != nil { 52 | return nil, err 53 | } 54 | if rows != nil { 55 | var id string 56 | 57 | for rows.Next() { 58 | var record schema.TelemetryData 59 | if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { 60 | return nil, err 61 | } 62 | records = append(records, record) 63 | } 64 | } 65 | return records, nil 66 | } 67 | -------------------------------------------------------------------------------- /database/postgresql/telemetry_postgresql.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | -- Dumped from database version 9.6.3 6 | -- Dumped by pg_dump version 9.6.5 7 | 8 | SET statement_timeout = 0; 9 | SET lock_timeout = 0; 10 | SET idle_in_transaction_session_timeout = 0; 11 | SET client_encoding = 'UTF8'; 12 | SET standard_conforming_strings = on; 13 | SET check_function_bodies = false; 14 | SET client_min_messages = warning; 15 | SET row_security = off; 16 | 17 | -- 18 | -- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: 19 | -- 20 | 21 | CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; 22 | 23 | 24 | -- 25 | -- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: 26 | -- 27 | 28 | COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; 29 | 30 | 31 | SET search_path = public, pg_catalog; 32 | 33 | SET default_tablespace = ''; 34 | 35 | SET default_with_oids = false; 36 | 37 | -- 38 | -- Name: speedtest_users; Type: TABLE; Schema: public; Owner: speedtest 39 | -- 40 | 41 | CREATE TABLE speedtest_users ( 42 | id integer NOT NULL, 43 | "timestamp" timestamp without time zone DEFAULT now() NOT NULL, 44 | ip text NOT NULL, 45 | ispinfo text, 46 | extra text, 47 | ua text NOT NULL, 48 | lang text NOT NULL, 49 | dl text, 50 | ul text, 51 | ping text, 52 | jitter text, 53 | log text, 54 | uuid text 55 | ); 56 | 57 | -- Commented out the following line because it assumes the user of the speedtest server, @bplower 58 | -- ALTER TABLE speedtest_users OWNER TO speedtest; 59 | 60 | -- 61 | -- Name: speedtest_users_id_seq; Type: SEQUENCE; Schema: public; Owner: speedtest 62 | -- 63 | 64 | CREATE SEQUENCE speedtest_users_id_seq 65 | START WITH 1 66 | INCREMENT BY 1 67 | NO MINVALUE 68 | NO MAXVALUE 69 | CACHE 1; 70 | 71 | -- Commented out the following line because it assumes the user of the speedtest server, @bplower 72 | -- ALTER TABLE speedtest_users_id_seq OWNER TO speedtest; 73 | 74 | -- 75 | -- Name: speedtest_users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: speedtest 76 | -- 77 | 78 | ALTER SEQUENCE speedtest_users_id_seq OWNED BY speedtest_users.id; 79 | 80 | 81 | -- 82 | -- Name: speedtest_users id; Type: DEFAULT; Schema: public; Owner: speedtest 83 | -- 84 | 85 | ALTER TABLE ONLY speedtest_users ALTER COLUMN id SET DEFAULT nextval('speedtest_users_id_seq'::regclass); 86 | 87 | 88 | -- 89 | -- Data for Name: speedtest_users; Type: TABLE DATA; Schema: public; Owner: speedtest 90 | -- 91 | 92 | COPY speedtest_users (id, "timestamp", ip, ua, lang, dl, ul, ping, jitter, log, uuid) FROM stdin; 93 | \. 94 | 95 | 96 | -- 97 | -- Name: speedtest_users_id_seq; Type: SEQUENCE SET; Schema: public; Owner: speedtest 98 | -- 99 | 100 | SELECT pg_catalog.setval('speedtest_users_id_seq', 1, true); 101 | 102 | 103 | -- 104 | -- Name: speedtest_users speedtest_users_pkey; Type: CONSTRAINT; Schema: public; Owner: speedtest 105 | -- 106 | 107 | ALTER TABLE ONLY speedtest_users 108 | ADD CONSTRAINT speedtest_users_pkey PRIMARY KEY (id); 109 | 110 | 111 | -- 112 | -- PostgreSQL database dump complete 113 | -- 114 | 115 | -------------------------------------------------------------------------------- /database/schema/schema.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type TelemetryData struct { 8 | Timestamp time.Time 9 | IPAddress string 10 | ISPInfo string 11 | Extra string 12 | UserAgent string 13 | Language string 14 | Download string 15 | Upload string 16 | Ping string 17 | Jitter string 18 | Log string 19 | UUID string 20 | } 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/librespeed/speedtest 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/breml/rootcerts v0.2.1 7 | github.com/coreos/go-systemd/v22 v22.4.0 8 | github.com/go-chi/chi/v5 v5.0.7 9 | github.com/go-chi/cors v1.2.0 10 | github.com/go-chi/render v1.0.1 11 | github.com/go-sql-driver/mysql v1.6.0 12 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 13 | github.com/gorilla/securecookie v1.1.1 14 | github.com/gorilla/sessions v1.2.1 15 | github.com/lib/pq v1.10.4 16 | github.com/oklog/ulid/v2 v2.0.2 17 | github.com/pires/go-proxyproto v0.6.1 18 | github.com/sirupsen/logrus v1.8.1 19 | github.com/spf13/afero v1.8.0 // indirect 20 | github.com/spf13/viper v1.10.1 21 | github.com/umahmood/haversine v0.0.0-20151105152445-808ab04add26 22 | go.etcd.io/bbolt v1.3.6 23 | golang.org/x/image v0.0.0-20211028202545-6944b10bf410 24 | golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | _ "time/tzdata" 6 | 7 | "github.com/librespeed/speedtest/config" 8 | "github.com/librespeed/speedtest/database" 9 | "github.com/librespeed/speedtest/results" 10 | "github.com/librespeed/speedtest/web" 11 | 12 | _ "github.com/breml/rootcerts" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | var ( 17 | optConfig = flag.String("c", "", "config file to be used, defaults to settings.toml in the same directory") 18 | ) 19 | 20 | func main() { 21 | flag.Parse() 22 | conf := config.Load(*optConfig) 23 | web.SetServerLocation(&conf) 24 | results.Initialize(&conf) 25 | database.SetDBInfo(&conf) 26 | log.Fatal(web.ListenAndServe(&conf)) 27 | } 28 | -------------------------------------------------------------------------------- /results/fonts/NotoSansDisplay-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librespeed/speedtest-go/7001fa4fa52945cbc6d4c32200bbcce7bbf6145c/results/fonts/NotoSansDisplay-Light.ttf -------------------------------------------------------------------------------- /results/fonts/NotoSansDisplay-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/librespeed/speedtest-go/7001fa4fa52945cbc6d4c32200bbcce7bbf6145c/results/fonts/NotoSansDisplay-Medium.ttf -------------------------------------------------------------------------------- /results/stats.go: -------------------------------------------------------------------------------- 1 | package results 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | 7 | "github.com/go-chi/render" 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/gorilla/securecookie" 11 | "github.com/gorilla/sessions" 12 | "github.com/librespeed/speedtest/config" 13 | "github.com/librespeed/speedtest/database" 14 | "github.com/librespeed/speedtest/database/schema" 15 | ) 16 | 17 | type StatsData struct { 18 | NoPassword bool 19 | LoggedIn bool 20 | Data []schema.TelemetryData 21 | } 22 | 23 | var ( 24 | key = []byte(securecookie.GenerateRandomKey(32)) 25 | store = sessions.NewCookieStore(key) 26 | conf = config.LoadedConfig() 27 | ) 28 | 29 | func init() { 30 | store.Options = &sessions.Options{ 31 | Path: conf.BaseURL+"/stats", 32 | MaxAge: 3600 * 1, // 1 hour 33 | HttpOnly: true, 34 | SameSite: http.SameSiteStrictMode, 35 | } 36 | } 37 | 38 | func Stats(w http.ResponseWriter, r *http.Request) { 39 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 40 | t, err := template.New("template").Parse(htmlTemplate) 41 | if err != nil { 42 | log.Errorf("Failed to parse template: %s", err) 43 | w.WriteHeader(http.StatusInternalServerError) 44 | return 45 | } 46 | 47 | if conf.DatabaseType == "none" { 48 | render.PlainText(w, r, "Statistics are disabled") 49 | return 50 | } 51 | 52 | var data StatsData 53 | 54 | if conf.StatsPassword == "PASSWORD" { 55 | data.NoPassword = true 56 | } 57 | 58 | if !data.NoPassword { 59 | op := r.FormValue("op") 60 | session, _ := store.Get(r, "logged") 61 | auth, ok := session.Values["authenticated"].(bool) 62 | 63 | if auth && ok { 64 | if op == "logout" { 65 | session.Values["authenticated"] = false 66 | session.Options.MaxAge = -1 67 | session.Save(r, w) 68 | http.Redirect(w, r, conf.BaseURL+"/stats", http.StatusTemporaryRedirect) 69 | } else { 70 | data.LoggedIn = true 71 | 72 | id := r.FormValue("id") 73 | switch id { 74 | case "L100": 75 | stats, err := database.DB.FetchLast100() 76 | if err != nil { 77 | log.Errorf("Error fetching data from database: %s", err) 78 | w.WriteHeader(http.StatusInternalServerError) 79 | return 80 | } 81 | data.Data = stats 82 | case "": 83 | default: 84 | stat, err := database.DB.FetchByUUID(id) 85 | if err != nil { 86 | log.Errorf("Error fetching data from database: %s", err) 87 | w.WriteHeader(http.StatusInternalServerError) 88 | return 89 | } 90 | data.Data = append(data.Data, *stat) 91 | } 92 | } 93 | } else { 94 | if op == "login" { 95 | session, _ := store.Get(r, "logged") 96 | password := r.FormValue("password") 97 | if password == conf.StatsPassword { 98 | session.Values["authenticated"] = true 99 | session.Save(r, w) 100 | http.Redirect(w, r, conf.BaseURL+"/stats", http.StatusTemporaryRedirect) 101 | } else { 102 | w.WriteHeader(http.StatusForbidden) 103 | } 104 | } 105 | } 106 | } 107 | 108 | if err := t.Execute(w, data); err != nil { 109 | log.Errorf("Error executing template: %s", err) 110 | w.WriteHeader(http.StatusInternalServerError) 111 | } 112 | } 113 | 114 | const htmlTemplate = ` 115 | 116 | 117 | LibreSpeed - Stats 118 | 160 | 161 | 162 |

LibreSpeed - Stats

163 | {{ if .NoPassword }} 164 | Please set statistics_password in settings.toml to enable access. 165 | {{ else if .LoggedIn }} 166 |
167 |
168 |

Search test results

169 | 170 | 171 | 172 | 173 |
174 | 175 | {{ range $i, $v := .Data }} 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 |
Test ID{{ $v.UUID }}
Date and time{{ $v.Timestamp }}
IP and ISP Info{{ $v.IPAddress }}
{{ $v.ISPInfo }}
User agent and locale{{ $v.UserAgent }}
{{ $v.Language }}
Download speed{{ $v.Download }}
Upload speed{{ $v.Upload }}
Ping{{ $v.Ping }}
Jitter{{ $v.Jitter }}
Log{{ $v.Log }}
Extra info{{ $v.Extra }}
188 | {{ end }} 189 | {{ else }} 190 |
191 |

Login

192 | 193 | 194 |
195 | {{ end }} 196 | 197 | ` 198 | -------------------------------------------------------------------------------- /results/telemetry.go: -------------------------------------------------------------------------------- 1 | package results 2 | 3 | import ( 4 | _ "embed" 5 | "encoding/json" 6 | "image" 7 | "image/color" 8 | "image/draw" 9 | "image/png" 10 | "math/rand" 11 | "net" 12 | "net/http" 13 | "regexp" 14 | "strings" 15 | "time" 16 | 17 | "github.com/go-chi/render" 18 | "github.com/librespeed/speedtest/config" 19 | "github.com/librespeed/speedtest/database" 20 | "github.com/librespeed/speedtest/database/schema" 21 | 22 | "github.com/golang/freetype" 23 | "github.com/golang/freetype/truetype" 24 | "github.com/oklog/ulid/v2" 25 | log "github.com/sirupsen/logrus" 26 | "golang.org/x/image/font" 27 | ) 28 | 29 | const ( 30 | watermark = "LibreSpeed" 31 | 32 | labelMS = " ms" 33 | labelMbps = "Mbit/s" 34 | labelPing = "Ping" 35 | labelJitter = "Jitter" 36 | labelDownload = "Download" 37 | labelUpload = "Upload" 38 | ) 39 | 40 | //go:embed fonts/NotoSansDisplay-Medium.ttf 41 | var fontMediumBytes []byte 42 | 43 | //go:embed fonts/NotoSansDisplay-Light.ttf 44 | var fontLightBytes []byte 45 | 46 | var ( 47 | ipv4Regex = regexp.MustCompile(`(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)`) 48 | ipv6Regex = regexp.MustCompile(`(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))`) 49 | hostnameRegex = regexp.MustCompile(`"hostname":"([^\\\\"]|\\\\")*"`) 50 | 51 | fontLight, fontBold *truetype.Font 52 | pingJitterLabelFace, upDownLabelFace, pingJitterValueFace, upDownValueFace, smallLabelFace, ispFace, watermarkFace font.Face 53 | 54 | canvasWidth, canvasHeight = 500, 286 55 | dpi = 150.0 56 | topOffset = 10 57 | middleOffset = topOffset + 5 58 | bottomOffset = middleOffset - 10 59 | ispOffset = bottomOffset + 8 60 | colorLabel = image.NewUniform(color.RGBA{40, 40, 40, 255}) 61 | colorDownload = image.NewUniform(color.RGBA{96, 96, 170, 255}) 62 | colorUpload = image.NewUniform(color.RGBA{96, 96, 96, 255}) 63 | colorPing = image.NewUniform(color.RGBA{170, 96, 96, 255}) 64 | colorJitter = image.NewUniform(color.RGBA{170, 96, 96, 255}) 65 | colorMeasure = image.NewUniform(color.RGBA{40, 40, 40, 255}) 66 | colorISP = image.NewUniform(color.RGBA{40, 40, 40, 255}) 67 | colorWatermark = image.NewUniform(color.RGBA{160, 160, 160, 255}) 68 | colorSeparator = image.NewUniform(color.RGBA{192, 192, 192, 255}) 69 | ) 70 | 71 | type Result struct { 72 | ProcessedString string `json:"processedString"` 73 | RawISPInfo IPInfoResponse `json:"rawIspInfo"` 74 | } 75 | 76 | type IPInfoResponse struct { 77 | IP string `json:"ip"` 78 | Hostname string `json:"hostname"` 79 | City string `json:"city"` 80 | Region string `json:"region"` 81 | Country string `json:"country"` 82 | Location string `json:"loc"` 83 | Organization string `json:"org"` 84 | Postal string `json:"postal"` 85 | Timezone string `json:"timezone"` 86 | Readme string `json:"readme"` 87 | } 88 | 89 | func Initialize(c *config.Config) { 90 | // changed to use Noto Sans instead of OpenSans, due to issue: 91 | // https://github.com/golang/freetype/issues/8 92 | fLight, err := freetype.ParseFont(fontLightBytes) 93 | if err != nil { 94 | log.Fatalf("Error parsing NotoSansDisplay-Light font: %s", err) 95 | } 96 | fontLight = fLight 97 | 98 | fMedium, err := freetype.ParseFont(fontMediumBytes) 99 | if err != nil { 100 | log.Fatalf("Error parsing NotoSansDisplay-Medium font: %s", err) 101 | } 102 | fontBold = fMedium 103 | 104 | pingJitterLabelFace = truetype.NewFace(fontBold, &truetype.Options{ 105 | Size: 12, 106 | DPI: dpi, 107 | Hinting: font.HintingFull, 108 | }) 109 | 110 | upDownLabelFace = truetype.NewFace(fontBold, &truetype.Options{ 111 | Size: 14, 112 | DPI: dpi, 113 | Hinting: font.HintingFull, 114 | }) 115 | 116 | pingJitterValueFace = truetype.NewFace(fontLight, &truetype.Options{ 117 | Size: 16, 118 | DPI: dpi, 119 | Hinting: font.HintingFull, 120 | }) 121 | 122 | upDownValueFace = truetype.NewFace(fontLight, &truetype.Options{ 123 | Size: 18, 124 | DPI: dpi, 125 | Hinting: font.HintingFull, 126 | }) 127 | 128 | smallLabelFace = truetype.NewFace(fontBold, &truetype.Options{ 129 | Size: 10, 130 | DPI: dpi, 131 | Hinting: font.HintingFull, 132 | }) 133 | 134 | ispFace = truetype.NewFace(fontBold, &truetype.Options{ 135 | Size: 8, 136 | DPI: dpi, 137 | Hinting: font.HintingFull, 138 | }) 139 | 140 | watermarkFace = truetype.NewFace(fontLight, &truetype.Options{ 141 | Size: 6, 142 | DPI: dpi, 143 | Hinting: font.HintingFull, 144 | }) 145 | } 146 | 147 | func Record(w http.ResponseWriter, r *http.Request) { 148 | conf := config.LoadedConfig() 149 | if conf.DatabaseType == "none" { 150 | render.PlainText(w, r, "Telemetry is disabled") 151 | return 152 | } 153 | 154 | ipAddr, _, _ := net.SplitHostPort(r.RemoteAddr) 155 | userAgent := r.UserAgent() 156 | language := r.Header.Get("Accept-Language") 157 | 158 | ispInfo := r.FormValue("ispinfo") 159 | download := r.FormValue("dl") 160 | upload := r.FormValue("ul") 161 | ping := r.FormValue("ping") 162 | jitter := r.FormValue("jitter") 163 | logs := r.FormValue("log") 164 | extra := r.FormValue("extra") 165 | 166 | if config.LoadedConfig().RedactIP { 167 | ipAddr = "0.0.0.0" 168 | ipv4Regex.ReplaceAllString(ispInfo, "0.0.0.0") 169 | ipv4Regex.ReplaceAllString(logs, "0.0.0.0") 170 | ipv6Regex.ReplaceAllString(ispInfo, "0.0.0.0") 171 | ipv6Regex.ReplaceAllString(logs, "0.0.0.0") 172 | hostnameRegex.ReplaceAllString(ispInfo, `"hostname":"REDACTED"`) 173 | hostnameRegex.ReplaceAllString(logs, `"hostname":"REDACTED"`) 174 | } 175 | 176 | var record schema.TelemetryData 177 | record.IPAddress = ipAddr 178 | if ispInfo == "" { 179 | record.ISPInfo = "{}" 180 | } else { 181 | record.ISPInfo = ispInfo 182 | } 183 | record.Extra = extra 184 | record.UserAgent = userAgent 185 | record.Language = language 186 | record.Download = download 187 | record.Upload = upload 188 | record.Ping = ping 189 | record.Jitter = jitter 190 | record.Log = logs 191 | 192 | t := time.Now() 193 | entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0) 194 | uuid := ulid.MustNew(ulid.Timestamp(t), entropy) 195 | record.UUID = uuid.String() 196 | 197 | err := database.DB.Insert(&record) 198 | if err != nil { 199 | log.Errorf("Error inserting into database: %s", err) 200 | w.WriteHeader(http.StatusInternalServerError) 201 | return 202 | } 203 | 204 | if _, err := w.Write([]byte("id " + uuid.String())); err != nil { 205 | log.Errorf("Error writing ID to telemetry request: %s", err) 206 | w.WriteHeader(http.StatusInternalServerError) 207 | } 208 | } 209 | 210 | func DrawPNG(w http.ResponseWriter, r *http.Request) { 211 | conf := config.LoadedConfig() 212 | 213 | if conf.DatabaseType == "none" { 214 | return 215 | } 216 | 217 | uuid := r.FormValue("id") 218 | record, err := database.DB.FetchByUUID(uuid) 219 | if err != nil { 220 | log.Errorf("Error querying database: %s", err) 221 | w.WriteHeader(http.StatusInternalServerError) 222 | return 223 | } 224 | 225 | var result Result 226 | if err := json.Unmarshal([]byte(record.ISPInfo), &result); err != nil { 227 | log.Errorf("Error parsing ISP info: %s", err) 228 | w.WriteHeader(http.StatusInternalServerError) 229 | return 230 | } 231 | 232 | canvas := image.NewRGBA(image.Rectangle{ 233 | Min: image.Point{}, 234 | Max: image.Point{ 235 | X: canvasWidth, 236 | Y: canvasHeight, 237 | }, 238 | }) 239 | 240 | draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) 241 | 242 | drawer := &font.Drawer{ 243 | Dst: canvas, 244 | Face: pingJitterLabelFace, 245 | } 246 | 247 | drawer.Src = colorLabel 248 | 249 | // labels 250 | p := drawer.MeasureString(labelPing) 251 | x := canvasWidth/4 - p.Round()/2 252 | drawer.Dot = freetype.Pt(x, canvasHeight/10+topOffset) 253 | drawer.DrawString(labelPing) 254 | 255 | p = drawer.MeasureString(labelJitter) 256 | x = canvasWidth*3/4 - p.Round()/2 257 | drawer.Dot = freetype.Pt(x, canvasHeight/10+topOffset) 258 | drawer.DrawString(labelJitter) 259 | 260 | drawer.Face = upDownLabelFace 261 | p = drawer.MeasureString(labelDownload) 262 | x = canvasWidth/4 - p.Round()/2 263 | drawer.Dot = freetype.Pt(x, canvasHeight/2-middleOffset) 264 | drawer.DrawString(labelDownload) 265 | 266 | p = drawer.MeasureString(labelUpload) 267 | x = canvasWidth*3/4 - p.Round()/2 268 | drawer.Dot = freetype.Pt(x, canvasHeight/2-middleOffset) 269 | drawer.DrawString(labelUpload) 270 | 271 | drawer.Face = smallLabelFace 272 | drawer.Src = colorMeasure 273 | p = drawer.MeasureString(labelMbps) 274 | x = canvasWidth/4 - p.Round()/2 275 | drawer.Dot = freetype.Pt(x, canvasHeight*8/10-middleOffset) 276 | drawer.DrawString(labelMbps) 277 | 278 | p = drawer.MeasureString(labelMbps) 279 | x = canvasWidth*3/4 - p.Round()/2 280 | drawer.Dot = freetype.Pt(x, canvasHeight*8/10-middleOffset) 281 | drawer.DrawString(labelMbps) 282 | 283 | msLength := drawer.MeasureString(labelMS) 284 | 285 | // ping value 286 | drawer.Face = pingJitterValueFace 287 | pingValue := strings.Split(record.Ping, ".")[0] 288 | p = drawer.MeasureString(pingValue) 289 | 290 | x = canvasWidth/4 - (p.Round()+msLength.Round())/2 291 | drawer.Dot = freetype.Pt(x, canvasHeight*11/40) 292 | drawer.Src = colorPing 293 | drawer.DrawString(pingValue) 294 | x = x + p.Round() 295 | drawer.Dot = freetype.Pt(x, canvasHeight*11/40) 296 | drawer.Src = colorMeasure 297 | drawer.Face = smallLabelFace 298 | drawer.DrawString(labelMS) 299 | 300 | // jitter value 301 | drawer.Face = pingJitterValueFace 302 | p = drawer.MeasureString(record.Jitter) 303 | x = canvasWidth*3/4 - (p.Round()+msLength.Round())/2 304 | drawer.Dot = freetype.Pt(x, canvasHeight*11/40) 305 | drawer.Src = colorJitter 306 | drawer.DrawString(record.Jitter) 307 | drawer.Face = smallLabelFace 308 | x = x + p.Round() 309 | drawer.Dot = freetype.Pt(x, canvasHeight*11/40) 310 | drawer.Src = colorMeasure 311 | drawer.DrawString(labelMS) 312 | 313 | // download value 314 | drawer.Face = upDownValueFace 315 | p = drawer.MeasureString(record.Download) 316 | x = canvasWidth/4 - p.Round()/2 317 | drawer.Dot = freetype.Pt(x, canvasHeight*27/40-middleOffset) 318 | drawer.Src = colorDownload 319 | drawer.DrawString(record.Download) 320 | 321 | // upload value 322 | p = drawer.MeasureString(record.Upload) 323 | x = canvasWidth*3/4 - p.Round()/2 324 | drawer.Dot = freetype.Pt(x, canvasHeight*27/40-middleOffset) 325 | drawer.Src = colorUpload 326 | drawer.DrawString(record.Upload) 327 | 328 | // watermark 329 | ctx := freetype.NewContext() 330 | ctx.SetFont(fontLight) 331 | ctx.SetFontSize(14) 332 | ctx.SetDPI(dpi) 333 | ctx.SetHinting(font.HintingFull) 334 | 335 | drawer.Face = watermarkFace 336 | drawer.Src = colorWatermark 337 | p = drawer.MeasureString(watermark) 338 | x = canvasWidth - p.Round() - 5 339 | drawer.Dot = freetype.Pt(x, canvasHeight-bottomOffset) 340 | drawer.DrawString(watermark) 341 | 342 | // timestamp 343 | ts := record.Timestamp.Format("2006-01-02 15:04:05") 344 | p = drawer.MeasureString(ts) 345 | drawer.Dot = freetype.Pt(8, canvasHeight-bottomOffset) 346 | drawer.DrawString(ts) 347 | 348 | // separator 349 | for i := canvas.Bounds().Min.X; i < canvas.Bounds().Max.X; i++ { 350 | canvas.Set(i, canvasHeight-ctx.PointToFixed(6).Round()-bottomOffset, colorSeparator) 351 | } 352 | 353 | // ISP info 354 | drawer.Face = ispFace 355 | drawer.Src = colorISP 356 | drawer.Dot = freetype.Pt(8, canvasHeight-ctx.PointToFixed(6).Round()-ispOffset) 357 | var ispString string 358 | if strings.Contains(result.ProcessedString, "-") { 359 | str := strings.SplitN(result.ProcessedString, "-", 2) 360 | if strings.Contains(str[1], "(") { 361 | str = strings.SplitN(str[1], "(", 2) 362 | } 363 | ispString = str[0] 364 | } 365 | drawer.DrawString("ISP: " + ispString) 366 | 367 | w.Header().Set("Content-Disposition", "inline; filename="+uuid+".png") 368 | w.Header().Set("Content-Type", "image/png") 369 | if err := png.Encode(w, canvas); err != nil { 370 | log.Errorf("Failed to output image to HTTP client: %s", err) 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /rpm/el7/README.md: -------------------------------------------------------------------------------- 1 | # librespeedgo-rpm 2 | 3 | Librespeedtest Go version package (tested for el7) 4 | upstream: https://github.com/librespeed/speedtest-go 5 | 6 | custom rpmmacro vars: 7 | * hk_version - define version 8 | * hk_build - define build 9 | * godir - change default GOPATH 10 | 11 | example: 12 | ``` 13 | rpmbuild -D 'hk_build 3' -D 'hk_version 1.1.3' -D 'godir %{_builddir}/%{name}/.go' -bb SPECS/librespeedgo.spec 14 | ``` 15 | -------------------------------------------------------------------------------- /rpm/el7/SOURCES/librespeedgo.firewalld: -------------------------------------------------------------------------------- 1 | 2 | 3 | librespeedgo 4 | Libres speedtest service GO-version 5 | 6 | 7 | -------------------------------------------------------------------------------- /rpm/el7/SOURCES/librespeedgo.mainconfig: -------------------------------------------------------------------------------- 1 | # bind address, use empty string to bind to all interfaces 2 | bind_address="" 3 | # backend listen port 4 | listen_port=8989 5 | # proxy protocol port, use 0 to disable 6 | proxyprotocol_port=0 7 | # Server location 8 | server_lat=0 9 | server_lng=0 10 | # ipinfo.io API key, if applicable 11 | ipinfo_api_key="" 12 | 13 | # assets directory path, defaults to `assets` in the same directory 14 | assets_path="/usr/share/librespeedgo/assets" 15 | 16 | # password for logging into statistics page 17 | statistics_password="PASSWORD" 18 | # redact IP addresses 19 | redact_ip_addresses=false 20 | 21 | # database type for statistics data, currently supports: bolt, mysql, postgresql 22 | database_type="bolt" 23 | database_hostname="" 24 | database_name="" 25 | database_username="" 26 | database_password="" 27 | 28 | # if you use `bolt` as database, set database_file to database file location 29 | database_file="/var/lib/librespeedgo/speedtest.db" 30 | -------------------------------------------------------------------------------- /rpm/el7/SOURCES/librespeedgo.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Librespeed speed test 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=librespeedgo 8 | Group=librespeedgo 9 | WorkingDirectory=/usr/share/librespeedgo/ 10 | ExecStart=/usr/bin/librespeedgo -c /etc/librespeedgo/settings.toml 11 | 12 | DevicePolicy=closed 13 | NoNewPrivileges=yes 14 | PrivateTmp=yes 15 | PrivateUsers=yes 16 | ProtectControlGroups=yes 17 | ProtectKernelModules=yes 18 | ProtectKernelTunables=yes 19 | RestrictNamespaces=yes 20 | RestrictRealtime=yes 21 | ReadWritePaths=/var/lib/librespeedgo 22 | ReadWritePaths=/etc/librespeedgo/settings.toml 23 | PrivateDevices=yes 24 | ProtectSystem=strict 25 | ProtectHome=true 26 | MemoryDenyWriteExecute=yes 27 | 28 | [Install] 29 | WantedBy=multi-user.target 30 | -------------------------------------------------------------------------------- /rpm/el7/SPECS/librespeedgo.spec: -------------------------------------------------------------------------------- 1 | %global appname librespeedgo 2 | %global debug_package %{nil} 3 | %global __os_install_post %(echo '%{__os_install_post}' | sed -e 's!/usr/lib[^[:space:]]*/brp-.*[[:space:]].*$!!g') 4 | 5 | Name: %{appname} 6 | Version: %{hk_version} 7 | Release: %{hk_build}%{?dist} 8 | Summary: LibreSpeed go-backend server 9 | 10 | Group: Applications/System 11 | License: LGPL 12 | URL: https://github.com/librespeed/speedtest-go 13 | Source0: %{name}.tar.gz 14 | Source1: %{name}.mainconfig 15 | Source2: %{name}.service 16 | Source3: %{name}.firewalld 17 | 18 | AutoReq: no 19 | AutoProv: no 20 | BuildArch: x86_64 21 | BuildRequires: golang >= 1.13 22 | 23 | %description 24 | Very lightweight speed test implemented in Javascript, using XMLHttpRequest and Web Workers. 25 | 26 | %prep 27 | curl -sL 'https://github.com/librespeed/speedtest-go/archive/refs/tags/v%{version}.tar.gz' -o %{_sourcedir}/%{name}.tar.gz 28 | if [[ -d %{_builddir}/%{name} ]];then 29 | chmod 777 -R %{_builddir}/%{name} 30 | rm -rf %{_builddir}/%{name} 31 | fi 32 | mkdir %{_builddir}/%{name} 33 | tar xf %{_sourcedir}/%{name}.tar.gz -C %{_builddir}/%{name} --strip-components 1 34 | cd %{_builddir}/%{name} 35 | cat << EOF >> %{name}.runtime 36 | d /var/lib/librespeedgo 0750 librespeedgo librespeedgo 37 | f /etc/librespeedgo/settings.toml 0640 root librespeedgo 38 | f /var/lib/librespeedgo/speedtest.db 0640 librespeedgo librespeedgo 39 | EOF 40 | cp -a %{SOURCE1} %{SOURCE2} %{SOURCE3} ./ 41 | pushd %{_builddir}/%{name}/assets 42 | sed -i "s/LibreSpeed Example/LibreSpeed/" *.html 43 | popd 44 | 45 | %build 46 | pushd %{_builddir}/%{name} 47 | %if 0%{?godir:1} 48 | GOPATH=%{godir} go build -ldflags "-w -s" -trimpath -o %{name} main.go 49 | %else 50 | go build -ldflags "-w -s" -trimpath -o %{name} main.go 51 | %endif 52 | popd 53 | 54 | %install 55 | pushd %{_builddir}/%{name} 56 | install -D %{name} %{buildroot}%{_bindir}/%{name} 57 | install -Dm644 %{name}.runtime %{buildroot}%{_sysconfdir}/tmpfiles.d/%{name}.conf 58 | install -Dm640 %{name}.mainconfig %{buildroot}%{_sysconfdir}/%{name}/settings.toml 59 | install -Dm644 %{name}.service %{buildroot}%{_prefix}/lib/systemd/system/%{name}.service 60 | install -Dm644 %{name}.firewalld %{buildroot}%{_prefix}/lib/firewalld/services/%{name}.xml 61 | install -dm750 %{buildroot}/var/lib/%{name} 62 | 63 | install -d %{buildroot}/%{_datadir}/%{name} 64 | cp -r assets %{buildroot}/%{_datadir}/%{name} 65 | install -m644 database/mysql/telemetry_mysql.sql %{buildroot}/%{_datadir}/%{name} 66 | install -m644 database/postgresql/telemetry_postgresql.sql %{buildroot}/%{_datadir}/%{name} 67 | popd 68 | 69 | %files 70 | %config(noreplace) %{_sysconfdir}/%{name}/settings.toml 71 | %config(noreplace) %{_prefix}/lib/firewalld/services/%{name}.xml 72 | %config %{_sysconfdir}/tmpfiles.d/%{name}.conf 73 | %config %{_prefix}/lib/systemd/system/%{name}.service 74 | %{_bindir}/%{name} 75 | %{_datadir}/%{name} 76 | /var/lib/%{name} 77 | 78 | %post 79 | if [ $1 == 1 ];then 80 | if ! getent passwd %{name} > /dev/null; then 81 | useradd -r -s /bin/false -m -d /var/lib/%{name} %{name} 82 | fi 83 | touch /var/lib/%{name}/speedtest.db 84 | chown -R %{name}:%{name} /var/lib/%{name} 85 | systemctl daemon-reload 86 | elif [ $1 == 2 ];then 87 | chown -R %{name}:%{name} /var/lib/%{name} 88 | systemctl daemon-reload 89 | if [ $(systemctl is-active --quiet %{name}.service) ];then 90 | systemctl restart %{name}.service 91 | fi 92 | fi 93 | 94 | %preun 95 | if [ $1 == 0 ];then 96 | if [ $(systemctl is-active --quiet %{name}.service) ];then 97 | systemctl stop %{name}.service 98 | fi 99 | fi 100 | 101 | %changelog 102 | -------------------------------------------------------------------------------- /settings.toml: -------------------------------------------------------------------------------- 1 | # bind address, use empty string to bind to all interfaces 2 | bind_address="" 3 | # backend listen port 4 | listen_port=8989 5 | # change the base URL 6 | # url_base="/librespeed" 7 | # proxy protocol port, use 0 to disable 8 | proxyprotocol_port=0 9 | # Server location 10 | server_lat=1 11 | server_lng=1 12 | # ipinfo.io API key, if applicable 13 | ipinfo_api_key="" 14 | 15 | # assets directory path, defaults to `assets` in the same directory 16 | assets_path="" 17 | 18 | # password for logging into statistics page 19 | statistics_password="PASSWORD" 20 | # redact IP addresses 21 | redact_ip_addresses=false 22 | 23 | # database type for statistics data, currently supports: none, memory, bolt, mysql, postgresql 24 | # if none is specified, no telemetry/stats will be recorded, and no result PNG will be generated 25 | database_type="memory" 26 | database_hostname="" 27 | database_name="" 28 | database_username="" 29 | database_password="" 30 | 31 | # if you use `bolt` as database, set database_file to database file location 32 | database_file="speedtest.db" 33 | 34 | # TLS and HTTP/2 settings. TLS is required for HTTP/2 35 | enable_tls=false 36 | enable_http2=false 37 | 38 | # if you use HTTP/2 or TLS, you need to prepare certificates and private keys 39 | # tls_cert_file="cert.pem" 40 | # tls_key_file="privkey.pem" 41 | -------------------------------------------------------------------------------- /systemd/README.md: -------------------------------------------------------------------------------- 1 | # Example systemd unit files 2 | 3 | To use these, first review the speedtest.* unit files, and then: 4 | 5 | cp ../speedtest /usr/local/bin/ 6 | mkdir -p /usr/local/share/speedtest /usr/local/etc 7 | cp -aR ../web/assets /usr/local/share/speedtest/assets 8 | cp speedtest-settings.toml /usr/local/etc 9 | cp speedtest.* /etc/systemd/system/ 10 | systemctl daemon-reload 11 | 12 | If you wish to use the bolt database type: 13 | 14 | # Create static system user and group 15 | adduser --system --group --no-create-home --disabled-password speedtest 16 | mkdir -p /usr/local/var/speedtest 17 | touch /usr/local/var/speedtest/speedtest.db 18 | chown speedtest. /usr/local/var/speedtest/speedtest.db 19 | 20 | To start (and enable at boot-up): 21 | 22 | systemctl enable --now speedtest.socket 23 | 24 | speedtest-go should now be listening for http request on port 80 on the local 25 | machine. 26 | 27 | You will need to customise the html files e.g. edit 28 | `/usr/local/share/speedtest/assets/index.html` to suit your site. 29 | -------------------------------------------------------------------------------- /systemd/speedtest-settings.toml: -------------------------------------------------------------------------------- 1 | # bind address, use empty string to bind to all interfaces, or when using socket activation 2 | #bind_address="" 3 | # backend listen port. Set this to "" when using socket activation 4 | listen_port="" 5 | # proxy protocol port, use 0 to disable 6 | proxyprotocol_port=0 7 | # Server location 8 | server_lat=50.82589 9 | server_lng=-0.141391 10 | # ipinfo.io API key, if applicable 11 | ipinfo_api_key="" 12 | 13 | # assets directory path, defaults to `assets` in the same directory 14 | assets_path="/usr/local/share/speedtest/assets" 15 | 16 | # password for logging into statistics page 17 | statistics_password="PASSWORD" 18 | # redact IP addresses 19 | redact_ip_addresses=false 20 | 21 | # database type for statistics data, currently supports: none, memory, bolt, mysql, postgresql 22 | # if none is specified, no telemetry/stats will be recorded, and no result PNG will be generated 23 | #database_type="bolt" 24 | database_type="memory" 25 | database_hostname="" 26 | database_name="" 27 | database_username="" 28 | database_password="" 29 | 30 | # if you use `bolt` as database, set database_file to database file location 31 | database_file="/usr/local/var/speedtest/speedtest.db" 32 | -------------------------------------------------------------------------------- /systemd/speedtest.service: -------------------------------------------------------------------------------- 1 | # Systemd unit file for speedtest-go. The defaults below are suitable for 2 | # running all configurations in a medium-security environment. See comments 3 | # below for addtional caveats - particularly those labelled "IMPORTANT". 4 | 5 | # You can edit this file, or alternatively you may prefer to use systemd's 6 | # "override" mechanisms, to avoid editing this file e.g. using: 7 | 8 | # systemctl edit speedtest.service 9 | 10 | [Unit] 11 | Description=Speedtest-go Server 12 | After=syslog.target network.target 13 | 14 | # Default to using socket activation (see accompanying socket unit file to 15 | # configure the bind address etc.). 16 | Requires=speedtest.socket 17 | After=speedtest.socket 18 | 19 | [Service] 20 | Type=simple 21 | # The paths to the installed binary and configuration file: 22 | 23 | ExecStart=/usr/local/bin/speedtest -c /usr/local/etc/speedtest-settings.toml 24 | #WorkingDirectory=/usr/local/share/speedtest 25 | #Restart=always 26 | #RestartSec=5 27 | 28 | # IMPORTANT! 29 | # If you use a database file (not server), then you will need to disable the 30 | # DynamicUser setting, and manually create the UNIX user and group specified 31 | # below, to ensure the file is accessible across multiple invocations of the 32 | # service. 33 | DynamicUser=true 34 | 35 | # You may prefer to use a different user or group name on your system. 36 | User=speedtest 37 | Group=speedtest 38 | 39 | 40 | # The following options will work for all configurations, but are not the 41 | # most secure, so you are advised to customise them as described below: 42 | 43 | # If NOT using socket activation, or if using socket activation AND 44 | # connecting to an external database server (MySQL, postgres) via TCP: 45 | RestrictAddressFamilies=AF_INET AF_INET6 46 | 47 | # If connecting to an external database via unix domain sockets (MySQL 48 | # default to this mode of operation): 49 | RestrictAddressFamilies=AF_UNIX 50 | 51 | # If using 'none', 'memory', or 'bolt' database types, and socket activation 52 | # then the process will not need to bind to any new sockets, so we can remove 53 | # the earlier AF_UNIX option again. In systemd versions before 249 this is 54 | # the only way to say "Restrict the use of all address families": 55 | RestrictAddressFamilies=AF_UNIX 56 | RestrictAddressFamilies=~AF_UNIX 57 | # ...in systemd version 249 and later, we can instead use the much clearer: 58 | #RestrictAddressFamilies=none 59 | 60 | # The following options are available (in systemd v247) to restrict the 61 | # actions of the speedtest server for reasons of increased security. 62 | 63 | # As a whole, the purpose of these are to provide an additional layer of 64 | # security by mitigating any unknown security vulnerabilities which may exist 65 | # in speedtest or in the libraries, tools and operating system components 66 | # which it relies upon. 67 | 68 | # IMPORTANT! 69 | # The following line must be customised to your individual requirements. 70 | # e.g. if using the 'bolt' in-process database type: 71 | ReadWritePaths=/usr/local/var/speedtest 72 | 73 | # Makes created files group-readable, but inaccessible by others 74 | UMask=027 75 | 76 | # Many of the following options are desribed in the systemd.resource-control(5) 77 | # manual page. 78 | 79 | # The following may be useful in your environment: 80 | #IPAddressDeny= 81 | #IPAddressAllow= 82 | #IPAccounting=true 83 | #IPIngressFilterPath= 84 | #SocketBindAllow= 85 | 86 | # If your system doesn't support all of the features below (e.g. because of 87 | # the use of a version of systemd older than 247), you may need to comment-out 88 | # some of the following lines. 89 | 90 | # n.b. It may be possible to further restrict speedtest, but this is a good 91 | # start, and will guard against many potential zero-day vulnerabilities. 92 | 93 | # See the output of `systemd-analyze security speedtest.service` for further 94 | # opportunities. Patches welcome! 95 | 96 | CapabilityBoundingSet= 97 | LockPersonality=true 98 | MemoryDenyWriteExecute=true 99 | NoNewPrivileges=yes 100 | PrivateTmp=yes 101 | PrivateDevices=true 102 | PrivateUsers=true 103 | ProtectSystem=strict 104 | ProtectHome=yes 105 | ProtectClock=true 106 | ProtectControlGroups=true 107 | ProtectKernelLogs=true 108 | ProtectKernelModules=true 109 | ProtectKernelTunables=true 110 | ProtectProc=invisible 111 | ProtectHostname=true 112 | RemoveIPC=true 113 | RestrictNamespaces=true 114 | RestrictSUIDSGID=true 115 | RestrictRealtime=true 116 | SystemCallArchitectures=native 117 | SystemCallFilter=@system-service 118 | 119 | # Additionally, you may wish to use some of the systemd options documented in 120 | # systemd.resource-control(5) to limit the CPU, memory, file-system I/O and 121 | # network I/O that the speedtest server is permitted to consume according to 122 | # the individual requirements of your installation. 123 | 124 | #CPUQuota=25% 125 | #MemoryMax=bytes 126 | #MemorySwapMax=bytes 127 | #TasksMax=N 128 | #IOReadBandwidthMax=device bytes 129 | #IOWriteBandwidthMax=device bytes 130 | #IOReadIOPSMax=device IOPS, IOWriteIOPSMax=device IOPS 131 | #IPAccounting=true 132 | #IPAddressAllow= 133 | 134 | [Install] 135 | WantedBy=multi-user.target 136 | -------------------------------------------------------------------------------- /systemd/speedtest.socket: -------------------------------------------------------------------------------- 1 | # Socket listener systemd unit file for speedtest-go. See the 2 | # systemd.socket(5) manual page for many more options. 3 | [Unit] 4 | Description=Speedtest Web Server (http port 80) Socket 5 | 6 | [Socket] 7 | ListenStream=80 8 | Accept=no 9 | 10 | [Install] 11 | WantedBy=sockets.target 12 | -------------------------------------------------------------------------------- /web/assets/example-multipleServers-full.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 194 | 396 | LibreSpeed Example 397 | 398 | 399 |

LibreSpeed Example

400 |
401 |

Selecting a server...

402 |
403 | 448 | 489 | 490 | 491 | -------------------------------------------------------------------------------- /web/assets/example-multipleServers-pretty.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | LibreSpeed Example 8 | 9 | 100 | 101 | 205 | 206 | 207 |

LibreSpeed Example

208 |
209 |
Selecting server...
210 |
211 |
212 |
213 |
Download
214 |
215 |
Mbps
216 |
217 |
218 |
Upload
219 |
220 |
Mbps
221 |
222 |
223 |
224 |
225 |
Ping
226 |
227 |
ms
228 |
229 |
230 |
Jitter
231 |
232 |
ms
233 |
234 |
235 |
236 | IP Address: 237 |
238 |
239 | Source code 240 | 244 | 245 | 246 | -------------------------------------------------------------------------------- /web/assets/example-singleServer-basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LibreSpeed Example 6 | 7 | 8 | 9 | 10 |

LibreSpeed Example

11 | 12 |

IP Address

13 |

14 | 15 |

Download

16 |

17 | 18 |

Upload

19 |

20 | 21 |

Latency

22 |

23 | 24 | 34 | 35 | Source code 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /web/assets/example-singleServer-chart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LibreSpeed Example 6 | 7 | 39 | 40 | 41 | 238 | 239 | 240 | 241 |

LibreSpeed - Chart.js example

242 | 253 | Run speedtest 254 |

Charts by Chart.js

Source code 255 | 256 | 257 | -------------------------------------------------------------------------------- /web/assets/example-singleServer-customSettings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LibreSpeed Example 7 | 8 | 9 | 49 | 50 | 151 | 152 | 153 |

LibreSpeed Example

154 |
155 |
156 |
157 |
158 |
Download
159 |
160 |
Mbps
161 |
162 |
163 |
Upload
164 |
165 |
Mbps
166 |
167 |
168 |
169 | Source code 170 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /web/assets/example-singleServer-gauges.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 111 | 221 | LibreSpeed Example 222 | 223 | 224 |

LibreSpeed Example

225 |
226 |
227 |
228 |
229 |
230 |
Ping
231 |
232 |
ms
233 |
234 |
235 |
Jitter
236 |
237 |
ms
238 |
239 |
240 |
241 |
242 |
Download
243 | 244 |
245 |
Mbps
246 |
247 |
248 |
Upload
249 | 250 |
251 |
Mbps
252 |
253 |
254 |
255 | 256 |
257 |
258 | Source code 259 |
260 | 261 | 262 | 263 | -------------------------------------------------------------------------------- /web/assets/example-singleServer-pretty.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LibreSpeed Example 7 | 8 | 9 | 49 | 50 | 154 | 155 | 156 |

LibreSpeed Example

157 |
158 |
159 |
160 |
161 |
Download
162 |
163 |
Mbps
164 |
165 |
166 |
Upload
167 |
168 |
Mbps
169 |
170 |
171 |
172 |
173 |
Ping
174 |
175 |
ms
176 |
177 |
178 |
Jitter
179 |
180 |
ms
181 |
182 |
183 |
184 | IP Address: 185 |
186 |
187 | Source code 188 | 191 | 192 | 193 | -------------------------------------------------------------------------------- /web/assets/example-singleServer-progressBar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LibreSpeed Example 7 | 8 | 9 | 51 | 52 | 173 | 174 | 175 |

LibreSpeed Example

176 |
177 |
178 |
179 |
180 |
181 |
Download
182 |
183 |
Mbps
184 |
185 |
186 |
Upload
187 |
188 |
Mbps
189 |
190 |
191 |
192 |
193 |
Ping
194 |
195 |
ms
196 |
197 |
198 |
Jitter
199 |
200 |
ms
201 |
202 |
203 |
204 | IP Address: 205 |
206 |
207 | Source code 208 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /web/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 126 | 276 | LibreSpeed Example 277 | 278 | 279 |

LibreSpeed Example

280 |
281 |

282 | Privacy 283 |
284 |
285 |
286 |
Ping
287 |
288 |
ms
289 |
290 |
291 |
Jitter
292 |
293 |
ms
294 |
295 |
296 |
297 |
298 |
Download
299 | 300 |
301 |
Mbps
302 |
303 |
304 |
Upload
305 | 306 |
307 |
Mbps
308 |
309 |
310 |
311 | 312 |
313 | 319 |
320 | Source code 321 |
322 | 363 | 364 | 365 | 366 | -------------------------------------------------------------------------------- /web/assets/speedtest.js: -------------------------------------------------------------------------------- 1 | /* 2 | LibreSpeed - Main 3 | by Federico Dossena 4 | https://github.com/librespeed/speedtest/ 5 | GNU LGPLv3 License 6 | */ 7 | 8 | /* 9 | This is the main interface between your webpage and the speedtest. 10 | It hides the speedtest web worker to the page, and provides many convenient functions to control the test. 11 | 12 | The best way to learn how to use this is to look at the basic example, but here's some documentation. 13 | 14 | To initialize the test, create a new Speedtest object: 15 | var s=new Speedtest(); 16 | Now you can think of this as a finite state machine. These are the states (use getState() to see them): 17 | - 0: here you can change the speedtest settings (such as test duration) with the setParameter("parameter",value) method. From here you can either start the test using start() (goes to state 3) or you can add multiple test points using addTestPoint(server) or addTestPoints(serverList) (goes to state 1). Additionally, this is the perfect moment to set up callbacks for the onupdate(data) and onend(aborted) events. 18 | - 1: here you can add test points. You only need to do this if you want to use multiple test points. 19 | A server is defined as an object like this: 20 | { 21 | name: "User friendly name", 22 | server:"http://yourBackend.com/", <---- URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol 23 | dlURL:"garbage.php" <----- path to garbage.php or its replacement on the server 24 | ulURL:"empty.php" <----- path to empty.php or its replacement on the server 25 | pingURL:"empty.php" <----- path to empty.php or its replacement on the server. This is used to ping the server by this selector 26 | getIpURL:"getIP.php" <----- path to getIP.php or its replacement on the server 27 | } 28 | While in state 1, you can only add test points, you cannot change the test settings. When you're done, use selectServer(callback) to select the test point with the lowest ping. This is asynchronous, when it's done, it will call your callback function and move to state 2. Calling setSelectedServer(server) will manually select a server and move to state 2. 29 | - 2: test point selected, ready to start the test. Use start() to begin, this will move to state 3 30 | - 3: test running. Here, your onupdate event calback will be called periodically, with data coming from the worker about speed and progress. A data object will be passed to your onupdate function, with the following items: 31 | - dlStatus: download speed in mbps 32 | - ulStatus: upload speed in mbps 33 | - pingStatus: ping in ms 34 | - jitterStatus: jitter in ms 35 | - dlProgress: progress of the download test as a float 0-1 36 | - ulProgress: progress of the upload test as a float 0-1 37 | - pingProgress: progress of the ping/jitter test as a float 0-1 38 | - testState: state of the test (-1=not started, 0=starting, 1=download test, 2=ping+jitter test, 3=upload test, 4=finished, 5=aborted) 39 | - clientIp: IP address of the client performing the test (and optionally ISP and distance) 40 | At the end of the test, the onend function will be called, with a boolean specifying whether the test was aborted or if it ended normally. 41 | The test can be aborted at any time with abort(). 42 | At the end of the test, it will move to state 4 43 | - 4: test finished. You can run it again by calling start() if you want. 44 | */ 45 | 46 | function Speedtest() { 47 | this._serverList = []; //when using multiple points of test, this is a list of test points 48 | this._selectedServer = null; //when using multiple points of test, this is the selected server 49 | this._settings = {}; //settings for the speedtest worker 50 | this._state = 0; //0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done 51 | console.log( 52 | "LibreSpeed by Federico Dossena v5.2.4 - https://github.com/librespeed/speedtest" 53 | ); 54 | } 55 | 56 | Speedtest.prototype = { 57 | constructor: Speedtest, 58 | /** 59 | * Returns the state of the test: 0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done 60 | */ 61 | getState: function() { 62 | return this._state; 63 | }, 64 | /** 65 | * Change one of the test settings from their defaults. 66 | * - parameter: string with the name of the parameter that you want to set 67 | * - value: new value for the parameter 68 | * 69 | * Invalid values or nonexistant parameters will be ignored by the speedtest worker. 70 | */ 71 | setParameter: function(parameter, value) { 72 | if (this._state == 3) 73 | throw "You cannot change the test settings while running the test"; 74 | this._settings[parameter] = value; 75 | if(parameter === "telemetry_extra"){ 76 | this._originalExtra=this._settings.telemetry_extra; 77 | } 78 | }, 79 | /** 80 | * Used internally to check if a server object contains all the required elements. 81 | * Also fixes the server URL if needed. 82 | */ 83 | _checkServerDefinition: function(server) { 84 | try { 85 | if (typeof server.name !== "string") 86 | throw "Name string missing from server definition (name)"; 87 | if (typeof server.server !== "string") 88 | throw "Server address string missing from server definition (server)"; 89 | if (server.server.charAt(server.server.length - 1) != "/") 90 | server.server += "/"; 91 | if (server.server.indexOf("//") == 0) 92 | server.server = location.protocol + server.server; 93 | if (typeof server.dlURL !== "string") 94 | throw "Download URL string missing from server definition (dlURL)"; 95 | if (typeof server.ulURL !== "string") 96 | throw "Upload URL string missing from server definition (ulURL)"; 97 | if (typeof server.pingURL !== "string") 98 | throw "Ping URL string missing from server definition (pingURL)"; 99 | if (typeof server.getIpURL !== "string") 100 | throw "GetIP URL string missing from server definition (getIpURL)"; 101 | } catch (e) { 102 | throw "Invalid server definition"; 103 | } 104 | }, 105 | /** 106 | * Add a test point (multiple points of test) 107 | * server: the server to be added as an object. Must contain the following elements: 108 | * { 109 | * name: "User friendly name", 110 | * server:"http://yourBackend.com/", URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol 111 | * dlURL:"garbage.php" path to garbage.php or its replacement on the server 112 | * ulURL:"empty.php" path to empty.php or its replacement on the server 113 | * pingURL:"empty.php" path to empty.php or its replacement on the server. This is used to ping the server by this selector 114 | * getIpURL:"getIP.php" path to getIP.php or its replacement on the server 115 | * } 116 | */ 117 | addTestPoint: function(server) { 118 | this._checkServerDefinition(server); 119 | if (this._state == 0) this._state = 1; 120 | if (this._state != 1) throw "You can't add a server after server selection"; 121 | this._settings.mpot = true; 122 | this._serverList.push(server); 123 | }, 124 | /** 125 | * Same as addTestPoint, but you can pass an array of servers 126 | */ 127 | addTestPoints: function(list) { 128 | for (var i = 0; i < list.length; i++) this.addTestPoint(list[i]); 129 | }, 130 | /** 131 | * Load a JSON server list from URL (multiple points of test) 132 | * url: the url where the server list can be fetched. Must be an array with objects containing the following elements: 133 | * { 134 | * "name": "User friendly name", 135 | * "server":"http://yourBackend.com/", URL to your server. You can specify http:// or https://. If your server supports both, just write // without the protocol 136 | * "dlURL":"garbage.php" path to garbage.php or its replacement on the server 137 | * "ulURL":"empty.php" path to empty.php or its replacement on the server 138 | * "pingURL":"empty.php" path to empty.php or its replacement on the server. This is used to ping the server by this selector 139 | * "getIpURL":"getIP.php" path to getIP.php or its replacement on the server 140 | * } 141 | * result: callback to be called when the list is loaded correctly. An array with the loaded servers will be passed to this function, or null if it failed 142 | */ 143 | loadServerList: function(url,result) { 144 | if (this._state == 0) this._state = 1; 145 | if (this._state != 1) throw "You can't add a server after server selection"; 146 | this._settings.mpot = true; 147 | var xhr = new XMLHttpRequest(); 148 | xhr.onload = function(){ 149 | try{ 150 | var servers=JSON.parse(xhr.responseText); 151 | for(var i=0;i= 3) 191 | throw "You can't select a server while the test is running"; 192 | } 193 | if (this._selectServerCalled) throw "selectServer already called"; else this._selectServerCalled=true; 194 | /*this function goes through a list of servers. For each server, the ping is measured, then the server with the function selected is called with the best server, or null if all the servers were down. 195 | */ 196 | var select = function(serverList, selected) { 197 | //pings the specified URL, then calls the function result. Result will receive a parameter which is either the time it took to ping the URL, or -1 if something went wrong. 198 | var PING_TIMEOUT = 2000; 199 | var USE_PING_TIMEOUT = true; //will be disabled on unsupported browsers 200 | if (/MSIE.(\d+\.\d+)/i.test(navigator.userAgent)) { 201 | //IE11 doesn't support XHR timeout 202 | USE_PING_TIMEOUT = false; 203 | } 204 | var ping = function(url, rtt) { 205 | url += (url.match(/\?/) ? "&" : "?") + "cors=true"; 206 | var xhr = new XMLHttpRequest(); 207 | var t = new Date().getTime(); 208 | xhr.onload = function() { 209 | if (xhr.responseText.length == 0) { 210 | //we expect an empty response 211 | var instspd = new Date().getTime() - t; //rough timing estimate 212 | try { 213 | //try to get more accurate timing using performance API 214 | var p = performance.getEntriesByName(url); 215 | p = p[p.length - 1]; 216 | var d = p.responseStart - p.requestStart; 217 | if (d <= 0) d = p.duration; 218 | if (d > 0 && d < instspd) instspd = d; 219 | } catch (e) {} 220 | rtt(instspd); 221 | } else rtt(-1); 222 | }.bind(this); 223 | xhr.onerror = function() { 224 | rtt(-1); 225 | }.bind(this); 226 | xhr.open("GET", url); 227 | if (USE_PING_TIMEOUT) { 228 | try { 229 | xhr.timeout = PING_TIMEOUT; 230 | xhr.ontimeout = xhr.onerror; 231 | } catch (e) {} 232 | } 233 | xhr.send(); 234 | }.bind(this); 235 | 236 | //this function repeatedly pings a server to get a good estimate of the ping. When it's done, it calls the done function without parameters. At the end of the execution, the server will have a new parameter called pingT, which is either the best ping we got from the server or -1 if something went wrong. 237 | var PINGS = 3, //up to 3 pings are performed, unless the server is down... 238 | SLOW_THRESHOLD = 500; //...or one of the pings is above this threshold 239 | var checkServer = function(server, done) { 240 | var i = 0; 241 | server.pingT = -1; 242 | if (server.server.indexOf(location.protocol) == -1) done(); 243 | else { 244 | var nextPing = function() { 245 | if (i++ == PINGS) { 246 | done(); 247 | return; 248 | } 249 | ping( 250 | server.server + server.pingURL, 251 | function(t) { 252 | if (t >= 0) { 253 | if (t < server.pingT || server.pingT == -1) server.pingT = t; 254 | if (t < SLOW_THRESHOLD) nextPing(); 255 | else done(); 256 | } else done(); 257 | }.bind(this) 258 | ); 259 | }.bind(this); 260 | nextPing(); 261 | } 262 | }.bind(this); 263 | //check servers in list, one by one 264 | var i = 0; 265 | var done = function() { 266 | var bestServer = null; 267 | for (var i = 0; i < serverList.length; i++) { 268 | if ( 269 | serverList[i].pingT != -1 && 270 | (bestServer == null || serverList[i].pingT < bestServer.pingT) 271 | ) 272 | bestServer = serverList[i]; 273 | } 274 | selected(bestServer); 275 | }.bind(this); 276 | var nextServer = function() { 277 | if (i == serverList.length) { 278 | done(); 279 | return; 280 | } 281 | checkServer(serverList[i++], nextServer); 282 | }.bind(this); 283 | nextServer(); 284 | }.bind(this); 285 | 286 | //parallel server selection 287 | var CONCURRENCY = 6; 288 | var serverLists = []; 289 | for (var i = 0; i < CONCURRENCY; i++) { 290 | serverLists[i] = []; 291 | } 292 | for (var i = 0; i < this._serverList.length; i++) { 293 | serverLists[i % CONCURRENCY].push(this._serverList[i]); 294 | } 295 | var completed = 0; 296 | var bestServer = null; 297 | for (var i = 0; i < CONCURRENCY; i++) { 298 | select( 299 | serverLists[i], 300 | function(server) { 301 | if (server != null) { 302 | if (bestServer == null || server.pingT < bestServer.pingT) 303 | bestServer = server; 304 | } 305 | completed++; 306 | if (completed == CONCURRENCY) { 307 | this._selectedServer = bestServer; 308 | this._state = 2; 309 | if (result) result(bestServer); 310 | } 311 | }.bind(this) 312 | ); 313 | } 314 | }, 315 | /** 316 | * Starts the test. 317 | * During the test, the onupdate(data) callback function will be called periodically with data from the worker. 318 | * At the end of the test, the onend(aborted) function will be called with a boolean telling you if the test was aborted or if it ended normally. 319 | */ 320 | start: function() { 321 | if (this._state == 3) throw "Test already running"; 322 | this.worker = new Worker("speedtest_worker.js?r=" + Math.random()); 323 | this.worker.onmessage = function(e) { 324 | if (e.data === this._prevData) return; 325 | else this._prevData = e.data; 326 | var data = JSON.parse(e.data); 327 | try { 328 | if (this.onupdate) this.onupdate(data); 329 | } catch (e) { 330 | console.error("Speedtest onupdate event threw exception: " + e); 331 | } 332 | if (data.testState >= 4) { 333 | clearInterval(this.updater); 334 | this._state = 4; 335 | try { 336 | if (this.onend) this.onend(data.testState == 5); 337 | } catch (e) { 338 | console.error("Speedtest onend event threw exception: " + e); 339 | } 340 | } 341 | }.bind(this); 342 | this.updater = setInterval( 343 | function() { 344 | this.worker.postMessage("status"); 345 | }.bind(this), 346 | 200 347 | ); 348 | if (this._state == 1) 349 | throw "When using multiple points of test, you must call selectServer before starting the test"; 350 | if (this._state == 2) { 351 | this._settings.url_dl = 352 | this._selectedServer.server + this._selectedServer.dlURL; 353 | this._settings.url_ul = 354 | this._selectedServer.server + this._selectedServer.ulURL; 355 | this._settings.url_ping = 356 | this._selectedServer.server + this._selectedServer.pingURL; 357 | this._settings.url_getIp = 358 | this._selectedServer.server + this._selectedServer.getIpURL; 359 | if (typeof this._originalExtra !== "undefined") { 360 | this._settings.telemetry_extra = JSON.stringify({ 361 | server: this._selectedServer.name, 362 | extra: this._originalExtra 363 | }); 364 | } else 365 | this._settings.telemetry_extra = JSON.stringify({ 366 | server: this._selectedServer.name 367 | }); 368 | } 369 | this._state = 3; 370 | this.worker.postMessage("start " + JSON.stringify(this._settings)); 371 | }, 372 | /** 373 | * Aborts the test while it's running. 374 | */ 375 | abort: function() { 376 | if (this._state < 3) throw "You cannot abort a test that's not started yet"; 377 | if (this._state < 4) this.worker.postMessage("abort"); 378 | } 379 | }; 380 | -------------------------------------------------------------------------------- /web/assets/speedtest_worker.js: -------------------------------------------------------------------------------- 1 | /* 2 | LibreSpeed - Worker 3 | by Federico Dossena 4 | https://github.com/librespeed/speedtest/ 5 | GNU LGPLv3 License 6 | */ 7 | 8 | // data reported to main thread 9 | var testState = -1; // -1=not started, 0=starting, 1=download test, 2=ping+jitter test, 3=upload test, 4=finished, 5=abort 10 | var dlStatus = ""; // download speed in megabit/s with 2 decimal digits 11 | var ulStatus = ""; // upload speed in megabit/s with 2 decimal digits 12 | var pingStatus = ""; // ping in milliseconds with 2 decimal digits 13 | var jitterStatus = ""; // jitter in milliseconds with 2 decimal digits 14 | var clientIp = ""; // client's IP address as reported by getIP.php 15 | var dlProgress = 0; //progress of download test 0-1 16 | var ulProgress = 0; //progress of upload test 0-1 17 | var pingProgress = 0; //progress of ping+jitter test 0-1 18 | var testId = null; //test ID (sent back by telemetry if used, null otherwise) 19 | 20 | var log = ""; //telemetry log 21 | function tlog(s) { 22 | if (settings.telemetry_level >= 2) { 23 | log += Date.now() + ": " + s + "\n"; 24 | } 25 | } 26 | function tverb(s) { 27 | if (settings.telemetry_level >= 3) { 28 | log += Date.now() + ": " + s + "\n"; 29 | } 30 | } 31 | function twarn(s) { 32 | if (settings.telemetry_level >= 2) { 33 | log += Date.now() + " WARN: " + s + "\n"; 34 | } 35 | console.warn(s); 36 | } 37 | 38 | // test settings. can be overridden by sending specific values with the start command 39 | var settings = { 40 | mpot: false, //set to true when in MPOT mode 41 | test_order: "IP_D_U", //order in which tests will be performed as a string. D=Download, U=Upload, P=Ping+Jitter, I=IP, _=1 second delay 42 | time_ul_max: 15, // max duration of upload test in seconds 43 | time_dl_max: 15, // max duration of download test in seconds 44 | time_auto: true, // if set to true, tests will take less time on faster connections 45 | time_ulGraceTime: 3, //time to wait in seconds before actually measuring ul speed (wait for buffers to fill) 46 | time_dlGraceTime: 1.5, //time to wait in seconds before actually measuring dl speed (wait for TCP window to increase) 47 | count_ping: 10, // number of pings to perform in ping test 48 | url_dl: "backend/garbage.php", // path to a large file or garbage.php, used for download test. must be relative to this js file 49 | url_ul: "backend/empty.php", // path to an empty file, used for upload test. must be relative to this js file 50 | url_ping: "backend/empty.php", // path to an empty file, used for ping test. must be relative to this js file 51 | url_getIp: "backend/getIP.php", // path to getIP.php relative to this js file, or a similar thing that outputs the client's ip 52 | getIp_ispInfo: true, //if set to true, the server will include ISP info with the IP address 53 | getIp_ispInfo_distance: "km", //km or mi=estimate distance from server in km/mi; set to false to disable distance estimation. getIp_ispInfo must be enabled in order for this to work 54 | xhr_dlMultistream: 6, // number of download streams to use (can be different if enable_quirks is active) 55 | xhr_ulMultistream: 3, // number of upload streams to use (can be different if enable_quirks is active) 56 | xhr_multistreamDelay: 300, //how much concurrent requests should be delayed 57 | xhr_ignoreErrors: 1, // 0=fail on errors, 1=attempt to restart a stream if it fails, 2=ignore all errors 58 | xhr_dlUseBlob: false, // if set to true, it reduces ram usage but uses the hard drive (useful with large garbagePhp_chunkSize and/or high xhr_dlMultistream) 59 | xhr_ul_blob_megabytes: 20, //size in megabytes of the upload blobs sent in the upload test (forced to 4 on chrome mobile) 60 | garbagePhp_chunkSize: 100, // size of chunks sent by garbage.php (can be different if enable_quirks is active) 61 | enable_quirks: true, // enable quirks for specific browsers. currently it overrides settings to optimize for specific browsers, unless they are already being overridden with the start command 62 | ping_allowPerformanceApi: true, // if enabled, the ping test will attempt to calculate the ping more precisely using the Performance API. Currently works perfectly in Chrome, badly in Edge, and not at all in Firefox. If Performance API is not supported or the result is obviously wrong, a fallback is provided. 63 | overheadCompensationFactor: 1.06, //can be changed to compensatie for transport overhead. (see doc.md for some other values) 64 | useMebibits: false, //if set to true, speed will be reported in mebibits/s instead of megabits/s 65 | telemetry_level: 0, // 0=disabled, 1=basic (results only), 2=full (results and timing) 3=debug (results+log) 66 | url_telemetry: "results/telemetry.php", // path to the script that adds telemetry data to the database 67 | telemetry_extra: "", //extra data that can be passed to the telemetry through the settings 68 | forceIE11Workaround: false //when set to true, it will foce the IE11 upload test on all browsers. Debug only 69 | }; 70 | 71 | var xhr = null; // array of currently active xhr requests 72 | var interval = null; // timer used in tests 73 | var test_pointer = 0; //pointer to the next test to run inside settings.test_order 74 | 75 | /* 76 | this function is used on URLs passed in the settings to determine whether we need a ? or an & as a separator 77 | */ 78 | function url_sep(url) { 79 | return url.match(/\?/) ? "&" : "?"; 80 | } 81 | 82 | /* 83 | listener for commands from main thread to this worker. 84 | commands: 85 | -status: returns the current status as a JSON string containing testState, dlStatus, ulStatus, pingStatus, clientIp, jitterStatus, dlProgress, ulProgress, pingProgress 86 | -abort: aborts the current test 87 | -start: starts the test. optionally, settings can be passed as JSON. 88 | example: start {"time_ul_max":"10", "time_dl_max":"10", "count_ping":"50"} 89 | */ 90 | this.addEventListener("message", function(e) { 91 | var params = e.data.split(" "); 92 | if (params[0] === "status") { 93 | // return status 94 | postMessage( 95 | JSON.stringify({ 96 | testState: testState, 97 | dlStatus: dlStatus, 98 | ulStatus: ulStatus, 99 | pingStatus: pingStatus, 100 | clientIp: clientIp, 101 | jitterStatus: jitterStatus, 102 | dlProgress: dlProgress, 103 | ulProgress: ulProgress, 104 | pingProgress: pingProgress, 105 | testId: testId 106 | }) 107 | ); 108 | } 109 | if (params[0] === "start" && testState === -1) { 110 | // start new test 111 | testState = 0; 112 | try { 113 | // parse settings, if present 114 | var s = {}; 115 | try { 116 | var ss = e.data.substring(5); 117 | if (ss) s = JSON.parse(ss); 118 | } catch (e) { 119 | twarn("Error parsing custom settings JSON. Please check your syntax"); 120 | } 121 | //copy custom settings 122 | for (var key in s) { 123 | if (typeof settings[key] !== "undefined") settings[key] = s[key]; 124 | else twarn("Unknown setting ignored: " + key); 125 | } 126 | var ua = navigator.userAgent; 127 | // quirks for specific browsers. apply only if not overridden. more may be added in future releases 128 | if (settings.enable_quirks || (typeof s.enable_quirks !== "undefined" && s.enable_quirks)) { 129 | if (/Firefox.(\d+\.\d+)/i.test(ua)) { 130 | if (typeof s.ping_allowPerformanceApi === "undefined") { 131 | // ff performance API sucks 132 | settings.ping_allowPerformanceApi = false; 133 | } 134 | } 135 | if (/Edge.(\d+\.\d+)/i.test(ua)) { 136 | if (typeof s.xhr_dlMultistream === "undefined") { 137 | // edge more precise with 3 download streams 138 | settings.xhr_dlMultistream = 3; 139 | } 140 | } 141 | if (/Chrome.(\d+)/i.test(ua) && !!self.fetch) { 142 | if (typeof s.xhr_dlMultistream === "undefined") { 143 | // chrome more precise with 5 streams 144 | settings.xhr_dlMultistream = 5; 145 | } 146 | } 147 | } 148 | if (/Edge.(\d+\.\d+)/i.test(ua)) { 149 | //Edge 15 introduced a bug that causes onprogress events to not get fired, we have to use the "small chunks" workaround that reduces accuracy 150 | settings.forceIE11Workaround = true; 151 | } 152 | if (/PlayStation 4.(\d+\.\d+)/i.test(ua)) { 153 | //PS4 browser has the same bug as IE11/Edge 154 | settings.forceIE11Workaround = true; 155 | } 156 | if (/Chrome.(\d+)/i.test(ua) && /Android|iPhone|iPad|iPod|Windows Phone/i.test(ua)) { 157 | //cheap af 158 | //Chrome mobile introduced a limitation somewhere around version 65, we have to limit XHR upload size to 4 megabytes 159 | settings.xhr_ul_blob_megabytes = 4; 160 | } 161 | if (/^((?!chrome|android|crios|fxios).)*safari/i.test(ua)) { 162 | //Safari also needs the IE11 workaround but only for the MPOT version 163 | settings.forceIE11Workaround = true; 164 | } 165 | //telemetry_level has to be parsed and not just copied 166 | if (typeof s.telemetry_level !== "undefined") settings.telemetry_level = s.telemetry_level === "basic" ? 1 : s.telemetry_level === "full" ? 2 : s.telemetry_level === "debug" ? 3 : 0; // telemetry level 167 | //transform test_order to uppercase, just in case 168 | settings.test_order = settings.test_order.toUpperCase(); 169 | } catch (e) { 170 | twarn("Possible error in custom test settings. Some settings might not have been applied. Exception: " + e); 171 | } 172 | // run the tests 173 | tverb(JSON.stringify(settings)); 174 | test_pointer = 0; 175 | var iRun = false, 176 | dRun = false, 177 | uRun = false, 178 | pRun = false; 179 | var runNextTest = function() { 180 | if (testState == 5) return; 181 | if (test_pointer >= settings.test_order.length) { 182 | //test is finished 183 | if (settings.telemetry_level > 0) 184 | sendTelemetry(function(id) { 185 | testState = 4; 186 | if (id != null) testId = id; 187 | }); 188 | else testState = 4; 189 | return; 190 | } 191 | switch (settings.test_order.charAt(test_pointer)) { 192 | case "I": 193 | { 194 | test_pointer++; 195 | if (iRun) { 196 | runNextTest(); 197 | return; 198 | } else iRun = true; 199 | getIp(runNextTest); 200 | } 201 | break; 202 | case "D": 203 | { 204 | test_pointer++; 205 | if (dRun) { 206 | runNextTest(); 207 | return; 208 | } else dRun = true; 209 | testState = 1; 210 | dlTest(runNextTest); 211 | } 212 | break; 213 | case "U": 214 | { 215 | test_pointer++; 216 | if (uRun) { 217 | runNextTest(); 218 | return; 219 | } else uRun = true; 220 | testState = 3; 221 | ulTest(runNextTest); 222 | } 223 | break; 224 | case "P": 225 | { 226 | test_pointer++; 227 | if (pRun) { 228 | runNextTest(); 229 | return; 230 | } else pRun = true; 231 | testState = 2; 232 | pingTest(runNextTest); 233 | } 234 | break; 235 | case "_": 236 | { 237 | test_pointer++; 238 | setTimeout(runNextTest, 1000); 239 | } 240 | break; 241 | default: 242 | test_pointer++; 243 | } 244 | }; 245 | runNextTest(); 246 | } 247 | if (params[0] === "abort") { 248 | // abort command 249 | if (testState >= 4) return; 250 | tlog("manually aborted"); 251 | clearRequests(); // stop all xhr activity 252 | runNextTest = null; 253 | if (interval) clearInterval(interval); // clear timer if present 254 | if (settings.telemetry_level > 1) sendTelemetry(function() {}); 255 | testState = 5; //set test as aborted 256 | dlStatus = ""; 257 | ulStatus = ""; 258 | pingStatus = ""; 259 | jitterStatus = ""; 260 | clientIp = ""; 261 | dlProgress = 0; 262 | ulProgress = 0; 263 | pingProgress = 0; 264 | } 265 | }); 266 | // stops all XHR activity, aggressively 267 | function clearRequests() { 268 | tverb("stopping pending XHRs"); 269 | if (xhr) { 270 | for (var i = 0; i < xhr.length; i++) { 271 | try { 272 | xhr[i].onprogress = null; 273 | xhr[i].onload = null; 274 | xhr[i].onerror = null; 275 | } catch (e) {} 276 | try { 277 | xhr[i].upload.onprogress = null; 278 | xhr[i].upload.onload = null; 279 | xhr[i].upload.onerror = null; 280 | } catch (e) {} 281 | try { 282 | xhr[i].abort(); 283 | } catch (e) {} 284 | try { 285 | delete xhr[i]; 286 | } catch (e) {} 287 | } 288 | xhr = null; 289 | } 290 | } 291 | // gets client's IP using url_getIp, then calls the done function 292 | var ipCalled = false; // used to prevent multiple accidental calls to getIp 293 | var ispInfo = ""; //used for telemetry 294 | function getIp(done) { 295 | tverb("getIp"); 296 | if (ipCalled) return; 297 | else ipCalled = true; // getIp already called? 298 | var startT = new Date().getTime(); 299 | xhr = new XMLHttpRequest(); 300 | xhr.onload = function() { 301 | tlog("IP: " + xhr.responseText + ", took " + (new Date().getTime() - startT) + "ms"); 302 | try { 303 | var data = JSON.parse(xhr.responseText); 304 | clientIp = data.processedString; 305 | ispInfo = data.rawIspInfo; 306 | } catch (e) { 307 | clientIp = xhr.responseText; 308 | ispInfo = ""; 309 | } 310 | done(); 311 | }; 312 | xhr.onerror = function() { 313 | tlog("getIp failed, took " + (new Date().getTime() - startT) + "ms"); 314 | done(); 315 | }; 316 | xhr.open("GET", settings.url_getIp + url_sep(settings.url_getIp) + (settings.mpot ? "cors=true&" : "") + (settings.getIp_ispInfo ? "isp=true" + (settings.getIp_ispInfo_distance ? "&distance=" + settings.getIp_ispInfo_distance + "&" : "&") : "&") + "r=" + Math.random(), true); 317 | xhr.send(); 318 | } 319 | // download test, calls done function when it's over 320 | var dlCalled = false; // used to prevent multiple accidental calls to dlTest 321 | function dlTest(done) { 322 | tverb("dlTest"); 323 | if (dlCalled) return; 324 | else dlCalled = true; // dlTest already called? 325 | var totLoaded = 0.0, // total number of loaded bytes 326 | startT = new Date().getTime(), // timestamp when test was started 327 | bonusT = 0, //how many milliseconds the test has been shortened by (higher on faster connections) 328 | graceTimeDone = false, //set to true after the grace time is past 329 | failed = false; // set to true if a stream fails 330 | xhr = []; 331 | // function to create a download stream. streams are slightly delayed so that they will not end at the same time 332 | var testStream = function(i, delay) { 333 | setTimeout( 334 | function() { 335 | if (testState !== 1) return; // delayed stream ended up starting after the end of the download test 336 | tverb("dl test stream started " + i + " " + delay); 337 | var prevLoaded = 0; // number of bytes loaded last time onprogress was called 338 | var x = new XMLHttpRequest(); 339 | xhr[i] = x; 340 | xhr[i].onprogress = function(event) { 341 | tverb("dl stream progress event " + i + " " + event.loaded); 342 | if (testState !== 1) { 343 | try { 344 | x.abort(); 345 | } catch (e) {} 346 | } // just in case this XHR is still running after the download test 347 | // progress event, add number of new loaded bytes to totLoaded 348 | var loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; 349 | if (isNaN(loadDiff) || !isFinite(loadDiff) || loadDiff < 0) return; // just in case 350 | totLoaded += loadDiff; 351 | prevLoaded = event.loaded; 352 | }.bind(this); 353 | xhr[i].onload = function() { 354 | // the large file has been loaded entirely, start again 355 | tverb("dl stream finished " + i); 356 | try { 357 | xhr[i].abort(); 358 | } catch (e) {} // reset the stream data to empty ram 359 | testStream(i, 0); 360 | }.bind(this); 361 | xhr[i].onerror = function() { 362 | // error 363 | tverb("dl stream failed " + i); 364 | if (settings.xhr_ignoreErrors === 0) failed = true; //abort 365 | try { 366 | xhr[i].abort(); 367 | } catch (e) {} 368 | delete xhr[i]; 369 | if (settings.xhr_ignoreErrors === 1) testStream(i, 0); //restart stream 370 | }.bind(this); 371 | // send xhr 372 | try { 373 | if (settings.xhr_dlUseBlob) xhr[i].responseType = "blob"; 374 | else xhr[i].responseType = "arraybuffer"; 375 | } catch (e) {} 376 | xhr[i].open("GET", settings.url_dl + url_sep(settings.url_dl) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random() + "&ckSize=" + settings.garbagePhp_chunkSize, true); // random string to prevent caching 377 | xhr[i].send(); 378 | }.bind(this), 379 | 1 + delay 380 | ); 381 | }.bind(this); 382 | // open streams 383 | for (var i = 0; i < settings.xhr_dlMultistream; i++) { 384 | testStream(i, settings.xhr_multistreamDelay * i); 385 | } 386 | // every 200ms, update dlStatus 387 | interval = setInterval( 388 | function() { 389 | tverb("DL: " + dlStatus + (graceTimeDone ? "" : " (in grace time)")); 390 | var t = new Date().getTime() - startT; 391 | if (graceTimeDone) dlProgress = (t + bonusT) / (settings.time_dl_max * 1000); 392 | if (t < 200) return; 393 | if (!graceTimeDone) { 394 | if (t > 1000 * settings.time_dlGraceTime) { 395 | if (totLoaded > 0) { 396 | // if the connection is so slow that we didn't get a single chunk yet, do not reset 397 | startT = new Date().getTime(); 398 | bonusT = 0; 399 | totLoaded = 0.0; 400 | } 401 | graceTimeDone = true; 402 | } 403 | } else { 404 | var speed = totLoaded / (t / 1000.0); 405 | if (settings.time_auto) { 406 | //decide how much to shorten the test. Every 200ms, the test is shortened by the bonusT calculated here 407 | var bonus = (5.0 * speed) / 100000; 408 | bonusT += bonus > 400 ? 400 : bonus; 409 | } 410 | //update status 411 | dlStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits 412 | if ((t + bonusT) / 1000.0 > settings.time_dl_max || failed) { 413 | // test is over, stop streams and timer 414 | if (failed || isNaN(dlStatus)) dlStatus = "Fail"; 415 | clearRequests(); 416 | clearInterval(interval); 417 | dlProgress = 1; 418 | tlog("dlTest: " + dlStatus + ", took " + (new Date().getTime() - startT) + "ms"); 419 | done(); 420 | } 421 | } 422 | }.bind(this), 423 | 200 424 | ); 425 | } 426 | // upload test, calls done function whent it's over 427 | var ulCalled = false; // used to prevent multiple accidental calls to ulTest 428 | function ulTest(done) { 429 | tverb("ulTest"); 430 | if (ulCalled) return; 431 | else ulCalled = true; // ulTest already called? 432 | // garbage data for upload test 433 | var r = new ArrayBuffer(1048576); 434 | var maxInt = Math.pow(2, 32) - 1; 435 | try { 436 | r = new Uint32Array(r); 437 | for (var i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; 438 | } catch (e) {} 439 | var req = []; 440 | var reqsmall = []; 441 | for (var i = 0; i < settings.xhr_ul_blob_megabytes; i++) req.push(r); 442 | req = new Blob(req); 443 | r = new ArrayBuffer(262144); 444 | try { 445 | r = new Uint32Array(r); 446 | for (var i = 0; i < r.length; i++) r[i] = Math.random() * maxInt; 447 | } catch (e) {} 448 | reqsmall.push(r); 449 | reqsmall = new Blob(reqsmall); 450 | var testFunction = function() { 451 | var totLoaded = 0.0, // total number of transmitted bytes 452 | startT = new Date().getTime(), // timestamp when test was started 453 | bonusT = 0, //how many milliseconds the test has been shortened by (higher on faster connections) 454 | graceTimeDone = false, //set to true after the grace time is past 455 | failed = false; // set to true if a stream fails 456 | xhr = []; 457 | // function to create an upload stream. streams are slightly delayed so that they will not end at the same time 458 | var testStream = function(i, delay) { 459 | setTimeout( 460 | function() { 461 | if (testState !== 3) return; // delayed stream ended up starting after the end of the upload test 462 | tverb("ul test stream started " + i + " " + delay); 463 | var prevLoaded = 0; // number of bytes transmitted last time onprogress was called 464 | var x = new XMLHttpRequest(); 465 | xhr[i] = x; 466 | var ie11workaround; 467 | if (settings.forceIE11Workaround) ie11workaround = true; 468 | else { 469 | try { 470 | xhr[i].upload.onprogress; 471 | ie11workaround = false; 472 | } catch (e) { 473 | ie11workaround = true; 474 | } 475 | } 476 | if (ie11workaround) { 477 | // IE11 workarond: xhr.upload does not work properly, therefore we send a bunch of small 256k requests and use the onload event as progress. This is not precise, especially on fast connections 478 | xhr[i].onload = xhr[i].onerror = function() { 479 | tverb("ul stream progress event (ie11wa)"); 480 | totLoaded += reqsmall.size; 481 | testStream(i, 0); 482 | }; 483 | xhr[i].open("POST", settings.url_ul + url_sep(settings.url_ul) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); // random string to prevent caching 484 | try { 485 | xhr[i].setRequestHeader("Content-Encoding", "identity"); // disable compression (some browsers may refuse it, but data is incompressible anyway) 486 | } catch (e) {} 487 | //No Content-Type header in MPOT branch because it triggers bugs in some browsers 488 | xhr[i].send(reqsmall); 489 | } else { 490 | // REGULAR version, no workaround 491 | xhr[i].upload.onprogress = function(event) { 492 | tverb("ul stream progress event " + i + " " + event.loaded); 493 | if (testState !== 3) { 494 | try { 495 | x.abort(); 496 | } catch (e) {} 497 | } // just in case this XHR is still running after the upload test 498 | // progress event, add number of new loaded bytes to totLoaded 499 | var loadDiff = event.loaded <= 0 ? 0 : event.loaded - prevLoaded; 500 | if (isNaN(loadDiff) || !isFinite(loadDiff) || loadDiff < 0) return; // just in case 501 | totLoaded += loadDiff; 502 | prevLoaded = event.loaded; 503 | }.bind(this); 504 | xhr[i].upload.onload = function() { 505 | // this stream sent all the garbage data, start again 506 | tverb("ul stream finished " + i); 507 | testStream(i, 0); 508 | }.bind(this); 509 | xhr[i].upload.onerror = function() { 510 | tverb("ul stream failed " + i); 511 | if (settings.xhr_ignoreErrors === 0) failed = true; //abort 512 | try { 513 | xhr[i].abort(); 514 | } catch (e) {} 515 | delete xhr[i]; 516 | if (settings.xhr_ignoreErrors === 1) testStream(i, 0); //restart stream 517 | }.bind(this); 518 | // send xhr 519 | xhr[i].open("POST", settings.url_ul + url_sep(settings.url_ul) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); // random string to prevent caching 520 | try { 521 | xhr[i].setRequestHeader("Content-Encoding", "identity"); // disable compression (some browsers may refuse it, but data is incompressible anyway) 522 | } catch (e) {} 523 | //No Content-Type header in MPOT branch because it triggers bugs in some browsers 524 | xhr[i].send(req); 525 | } 526 | }.bind(this), 527 | delay 528 | ); 529 | }.bind(this); 530 | // open streams 531 | for (var i = 0; i < settings.xhr_ulMultistream; i++) { 532 | testStream(i, settings.xhr_multistreamDelay * i); 533 | } 534 | // every 200ms, update ulStatus 535 | interval = setInterval( 536 | function() { 537 | tverb("UL: " + ulStatus + (graceTimeDone ? "" : " (in grace time)")); 538 | var t = new Date().getTime() - startT; 539 | if (graceTimeDone) ulProgress = (t + bonusT) / (settings.time_ul_max * 1000); 540 | if (t < 200) return; 541 | if (!graceTimeDone) { 542 | if (t > 1000 * settings.time_ulGraceTime) { 543 | if (totLoaded > 0) { 544 | // if the connection is so slow that we didn't get a single chunk yet, do not reset 545 | startT = new Date().getTime(); 546 | bonusT = 0; 547 | totLoaded = 0.0; 548 | } 549 | graceTimeDone = true; 550 | } 551 | } else { 552 | var speed = totLoaded / (t / 1000.0); 553 | if (settings.time_auto) { 554 | //decide how much to shorten the test. Every 200ms, the test is shortened by the bonusT calculated here 555 | var bonus = (5.0 * speed) / 100000; 556 | bonusT += bonus > 400 ? 400 : bonus; 557 | } 558 | //update status 559 | ulStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits 560 | if ((t + bonusT) / 1000.0 > settings.time_ul_max || failed) { 561 | // test is over, stop streams and timer 562 | if (failed || isNaN(ulStatus)) ulStatus = "Fail"; 563 | clearRequests(); 564 | clearInterval(interval); 565 | ulProgress = 1; 566 | tlog("ulTest: " + ulStatus + ", took " + (new Date().getTime() - startT) + "ms"); 567 | done(); 568 | } 569 | } 570 | }.bind(this), 571 | 200 572 | ); 573 | }.bind(this); 574 | if (settings.mpot) { 575 | tverb("Sending POST request before performing upload test"); 576 | xhr = []; 577 | xhr[0] = new XMLHttpRequest(); 578 | xhr[0].onload = xhr[0].onerror = function() { 579 | tverb("POST request sent, starting upload test"); 580 | testFunction(); 581 | }.bind(this); 582 | xhr[0].open("POST", settings.url_ul); 583 | xhr[0].send(); 584 | } else testFunction(); 585 | } 586 | // ping+jitter test, function done is called when it's over 587 | var ptCalled = false; // used to prevent multiple accidental calls to pingTest 588 | function pingTest(done) { 589 | tverb("pingTest"); 590 | if (ptCalled) return; 591 | else ptCalled = true; // pingTest already called? 592 | var startT = new Date().getTime(); //when the test was started 593 | var prevT = null; // last time a pong was received 594 | var ping = 0.0; // current ping value 595 | var jitter = 0.0; // current jitter value 596 | var i = 0; // counter of pongs received 597 | var prevInstspd = 0; // last ping time, used for jitter calculation 598 | xhr = []; 599 | // ping function 600 | var doPing = function() { 601 | tverb("ping"); 602 | pingProgress = i / settings.count_ping; 603 | prevT = new Date().getTime(); 604 | xhr[0] = new XMLHttpRequest(); 605 | xhr[0].onload = function() { 606 | // pong 607 | tverb("pong"); 608 | if (i === 0) { 609 | prevT = new Date().getTime(); // first pong 610 | } else { 611 | var instspd = new Date().getTime() - prevT; 612 | if (settings.ping_allowPerformanceApi) { 613 | try { 614 | //try to get accurate performance timing using performance api 615 | var p = performance.getEntries(); 616 | p = p[p.length - 1]; 617 | var d = p.responseStart - p.requestStart; 618 | if (d <= 0) d = p.duration; 619 | if (d > 0 && d < instspd) instspd = d; 620 | } catch (e) { 621 | //if not possible, keep the estimate 622 | tverb("Performance API not supported, using estimate"); 623 | } 624 | } 625 | //noticed that some browsers randomly have 0ms ping 626 | if (instspd < 1) instspd = prevInstspd; 627 | if (instspd < 1) instspd = 1; 628 | var instjitter = Math.abs(instspd - prevInstspd); 629 | if (i === 1) ping = instspd; 630 | /* first ping, can't tell jitter yet*/ else { 631 | if (instspd < ping) ping = instspd; // update ping, if the instant ping is lower 632 | if (i === 2) jitter = instjitter; 633 | //discard the first jitter measurement because it might be much higher than it should be 634 | else jitter = instjitter > jitter ? jitter * 0.3 + instjitter * 0.7 : jitter * 0.8 + instjitter * 0.2; // update jitter, weighted average. spikes in ping values are given more weight. 635 | } 636 | prevInstspd = instspd; 637 | } 638 | pingStatus = ping.toFixed(2); 639 | jitterStatus = jitter.toFixed(2); 640 | i++; 641 | tverb("ping: " + pingStatus + " jitter: " + jitterStatus); 642 | if (i < settings.count_ping) doPing(); 643 | else { 644 | // more pings to do? 645 | pingProgress = 1; 646 | tlog("ping: " + pingStatus + " jitter: " + jitterStatus + ", took " + (new Date().getTime() - startT) + "ms"); 647 | done(); 648 | } 649 | }.bind(this); 650 | xhr[0].onerror = function() { 651 | // a ping failed, cancel test 652 | tverb("ping failed"); 653 | if (settings.xhr_ignoreErrors === 0) { 654 | //abort 655 | pingStatus = "Fail"; 656 | jitterStatus = "Fail"; 657 | clearRequests(); 658 | tlog("ping test failed, took " + (new Date().getTime() - startT) + "ms"); 659 | pingProgress = 1; 660 | done(); 661 | } 662 | if (settings.xhr_ignoreErrors === 1) doPing(); //retry ping 663 | if (settings.xhr_ignoreErrors === 2) { 664 | //ignore failed ping 665 | i++; 666 | if (i < settings.count_ping) doPing(); 667 | else { 668 | // more pings to do? 669 | pingProgress = 1; 670 | tlog("ping: " + pingStatus + " jitter: " + jitterStatus + ", took " + (new Date().getTime() - startT) + "ms"); 671 | done(); 672 | } 673 | } 674 | }.bind(this); 675 | // send xhr 676 | xhr[0].open("GET", settings.url_ping + url_sep(settings.url_ping) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); // random string to prevent caching 677 | xhr[0].send(); 678 | }.bind(this); 679 | doPing(); // start first ping 680 | } 681 | // telemetry 682 | function sendTelemetry(done) { 683 | if (settings.telemetry_level < 1) return; 684 | xhr = new XMLHttpRequest(); 685 | xhr.onload = function() { 686 | try { 687 | var parts = xhr.responseText.split(" "); 688 | if (parts[0] == "id") { 689 | try { 690 | var id = parts[1]; 691 | done(id); 692 | } catch (e) { 693 | done(null); 694 | } 695 | } else done(null); 696 | } catch (e) { 697 | done(null); 698 | } 699 | }; 700 | xhr.onerror = function() { 701 | console.log("TELEMETRY ERROR " + xhr.status); 702 | done(null); 703 | }; 704 | xhr.open("POST", settings.url_telemetry + url_sep(settings.url_telemetry) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); 705 | var telemetryIspInfo = { 706 | processedString: clientIp, 707 | rawIspInfo: typeof ispInfo === "object" ? ispInfo : "" 708 | }; 709 | try { 710 | var fd = new FormData(); 711 | fd.append("ispinfo", JSON.stringify(telemetryIspInfo)); 712 | fd.append("dl", dlStatus); 713 | fd.append("ul", ulStatus); 714 | fd.append("ping", pingStatus); 715 | fd.append("jitter", jitterStatus); 716 | fd.append("log", settings.telemetry_level > 1 ? log : ""); 717 | fd.append("extra", settings.telemetry_extra); 718 | xhr.send(fd); 719 | } catch (ex) { 720 | var postData = "extra=" + encodeURIComponent(settings.telemetry_extra) + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo)) + "&dl=" + encodeURIComponent(dlStatus) + "&ul=" + encodeURIComponent(ulStatus) + "&ping=" + encodeURIComponent(pingStatus) + "&jitter=" + encodeURIComponent(jitterStatus) + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : ""); 721 | xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 722 | xhr.send(postData); 723 | } 724 | } 725 | -------------------------------------------------------------------------------- /web/fs.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | // Credit: https://stackoverflow.com/questions/49589685/good-way-to-disable-directory-listing-with-http-fileserver-in-go 10 | type justFilesFilesystem struct { 11 | fs http.FileSystem 12 | // readDirBatchSize - configuration parameter for `Readdir` func 13 | readDirBatchSize int 14 | } 15 | 16 | func (fs justFilesFilesystem) Open(name string) (http.File, error) { 17 | f, err := fs.fs.Open(name) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return neuteredStatFile{File: f, readDirBatchSize: fs.readDirBatchSize}, nil 22 | } 23 | 24 | type neuteredStatFile struct { 25 | http.File 26 | readDirBatchSize int 27 | } 28 | 29 | func (e neuteredStatFile) Stat() (os.FileInfo, error) { 30 | s, err := e.File.Stat() 31 | if err != nil { 32 | return nil, err 33 | } 34 | if s.IsDir() { 35 | LOOP: 36 | for { 37 | fl, err := e.File.Readdir(e.readDirBatchSize) 38 | switch err { 39 | case io.EOF: 40 | break LOOP 41 | case nil: 42 | for _, f := range fl { 43 | if f.Name() == "index.html" { 44 | return s, err 45 | } 46 | } 47 | default: 48 | return nil, err 49 | } 50 | } 51 | return nil, os.ErrNotExist 52 | } 53 | return s, err 54 | } 55 | -------------------------------------------------------------------------------- /web/helpers.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | 12 | log "github.com/sirupsen/logrus" 13 | "github.com/umahmood/haversine" 14 | 15 | "github.com/librespeed/speedtest/config" 16 | "github.com/librespeed/speedtest/results" 17 | ) 18 | 19 | var ( 20 | serverCoord haversine.Coord 21 | ) 22 | 23 | func getRandomData(length int) []byte { 24 | data := make([]byte, length) 25 | if _, err := rand.Read(data); err != nil { 26 | log.Fatalf("Failed to generate random data: %s", err) 27 | } 28 | return data 29 | } 30 | 31 | func getIPInfoURL(address string) string { 32 | apiKey := config.LoadedConfig().IPInfoAPIKey 33 | 34 | ipInfoURL := `https://ipinfo.io/%s/json` 35 | if address != "" { 36 | ipInfoURL = fmt.Sprintf(ipInfoURL, address) 37 | } else { 38 | ipInfoURL = "https://ipinfo.io/json" 39 | } 40 | 41 | if apiKey != "" { 42 | ipInfoURL += "?token=" + apiKey 43 | } 44 | 45 | return ipInfoURL 46 | } 47 | 48 | func getIPInfo(addr string) results.IPInfoResponse { 49 | var ret results.IPInfoResponse 50 | resp, err := http.DefaultClient.Get(getIPInfoURL(addr)) 51 | if err != nil { 52 | log.Errorf("Error getting response from ipinfo.io: %s", err) 53 | return ret 54 | } 55 | 56 | raw, err := ioutil.ReadAll(resp.Body) 57 | if err != nil { 58 | log.Errorf("Error reading response from ipinfo.io: %s", err) 59 | return ret 60 | } 61 | defer resp.Body.Close() 62 | 63 | if err := json.Unmarshal(raw, &ret); err != nil { 64 | log.Errorf("Error parsing response from ipinfo.io: %s", err) 65 | } 66 | 67 | return ret 68 | } 69 | 70 | func SetServerLocation(conf *config.Config) { 71 | if conf.ServerLat != 0 || conf.ServerLng != 0 { 72 | log.Infof("Configured server coordinates: %.6f, %.6f", conf.ServerLat, conf.ServerLng) 73 | serverCoord.Lat = conf.ServerLat 74 | serverCoord.Lon = conf.ServerLng 75 | return 76 | } 77 | 78 | var ret results.IPInfoResponse 79 | resp, err := http.DefaultClient.Get(getIPInfoURL("")) 80 | if err != nil { 81 | log.Errorf("Error getting repsonse from ipinfo.io: %s", err) 82 | return 83 | } 84 | raw, err := ioutil.ReadAll(resp.Body) 85 | if err != nil { 86 | log.Errorf("Error reading response from ipinfo.io: %s", err) 87 | return 88 | } 89 | defer resp.Body.Close() 90 | 91 | if err := json.Unmarshal(raw, &ret); err != nil { 92 | log.Errorf("Error parsing response from ipinfo.io: %s", err) 93 | return 94 | } 95 | 96 | if ret.Location != "" { 97 | serverCoord, err = parseLocationString(ret.Location) 98 | if err != nil { 99 | log.Errorf("Cannot get server coordinates: %s", err) 100 | return 101 | } 102 | } 103 | 104 | log.Infof("Fetched server coordinates: %.6f, %.6f", serverCoord.Lat, serverCoord.Lon) 105 | } 106 | 107 | func parseLocationString(location string) (haversine.Coord, error) { 108 | var coord haversine.Coord 109 | 110 | parts := strings.Split(location, ",") 111 | if len(parts) != 2 { 112 | err := fmt.Errorf("unknown location format: %s", location) 113 | log.Error(err) 114 | return coord, err 115 | } 116 | 117 | lat, err := strconv.ParseFloat(parts[0], 64) 118 | if err != nil { 119 | log.Errorf("Error parsing latitude: %s", parts[0]) 120 | return coord, err 121 | } 122 | 123 | lng, err := strconv.ParseFloat(parts[1], 64) 124 | if err != nil { 125 | log.Errorf("Error parsing longitude: %s", parts[0]) 126 | return coord, err 127 | } 128 | 129 | coord.Lat = lat 130 | coord.Lon = lng 131 | 132 | return coord, nil 133 | } 134 | 135 | func calculateDistance(clientLocation string, unit string) string { 136 | clientCoord, err := parseLocationString(clientLocation) 137 | if err != nil { 138 | log.Errorf("Error parsing client coordinates: %s", err) 139 | return "" 140 | } 141 | 142 | dist, km := haversine.Distance(clientCoord, serverCoord) 143 | unitString := " mi" 144 | 145 | switch unit { 146 | case "km": 147 | dist = km 148 | unitString = " km" 149 | case "NM": 150 | dist = km * 0.539957 151 | unitString = " NM" 152 | } 153 | 154 | return fmt.Sprintf("%.2f%s", dist, unitString) 155 | } 156 | -------------------------------------------------------------------------------- /web/listener.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package web 5 | 6 | import ( 7 | "crypto/tls" 8 | "github.com/go-chi/chi/v5" 9 | "github.com/librespeed/speedtest/config" 10 | log "github.com/sirupsen/logrus" 11 | "net" 12 | "net/http" 13 | ) 14 | 15 | func startListener(conf *config.Config, r *chi.Mux) error { 16 | var s error 17 | 18 | addr := net.JoinHostPort(conf.BindAddress, conf.Port) 19 | log.Infof("Starting backend server on %s", addr) 20 | 21 | // TLS and HTTP/2. 22 | if conf.EnableTLS { 23 | log.Info("Use TLS connection.") 24 | if !(conf.EnableHTTP2) { 25 | srv := &http.Server{ 26 | Addr: addr, 27 | Handler: r, 28 | TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), 29 | } 30 | s = srv.ListenAndServeTLS(conf.TLSCertFile, conf.TLSKeyFile) 31 | } else { 32 | s = http.ListenAndServeTLS(addr, conf.TLSCertFile, conf.TLSKeyFile, r) 33 | } 34 | } else { 35 | if conf.EnableHTTP2 { 36 | log.Errorf("TLS is mandatory for HTTP/2. Ignore settings that enable HTTP/2.") 37 | } 38 | s = http.ListenAndServe(addr, r) 39 | } 40 | 41 | return s 42 | } 43 | -------------------------------------------------------------------------------- /web/listener_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package web 5 | 6 | import ( 7 | "crypto/tls" 8 | "github.com/coreos/go-systemd/v22/activation" 9 | "github.com/go-chi/chi/v5" 10 | "github.com/librespeed/speedtest/config" 11 | log "github.com/sirupsen/logrus" 12 | "net" 13 | "net/http" 14 | ) 15 | 16 | func startListener(conf *config.Config, r *chi.Mux) error { 17 | // See if systemd socket activation has been used when starting our process 18 | listeners, err := activation.Listeners() 19 | if err != nil { 20 | log.Fatalf("Error whilst checking for systemd socket activation %s", err) 21 | } 22 | 23 | var s error 24 | 25 | switch len(listeners) { 26 | case 0: 27 | addr := net.JoinHostPort(conf.BindAddress, conf.Port) 28 | log.Infof("Starting backend server on %s", addr) 29 | 30 | // TLS and HTTP/2. 31 | if conf.EnableTLS { 32 | log.Info("Use TLS connection.") 33 | if !(conf.EnableHTTP2) { 34 | srv := &http.Server{ 35 | Addr: addr, 36 | Handler: r, 37 | TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)), 38 | } 39 | s = srv.ListenAndServeTLS(conf.TLSCertFile, conf.TLSKeyFile) 40 | } else { 41 | s = http.ListenAndServeTLS(addr, conf.TLSCertFile, conf.TLSKeyFile, r) 42 | } 43 | } else { 44 | if conf.EnableHTTP2 { 45 | log.Errorf("TLS is mandatory for HTTP/2. Ignore settings that enable HTTP/2.") 46 | } 47 | s = http.ListenAndServe(addr, r) 48 | } 49 | case 1: 50 | log.Info("Starting backend server on inherited file descriptor via systemd socket activation") 51 | if conf.BindAddress != "" || conf.Port != "" { 52 | log.Errorf("Both an address/port (%s:%s) has been specificed in the config AND externally configured socket activation has been detected", conf.BindAddress, conf.Port) 53 | log.Fatal(`Please deconfigure socket activation (e.g. in systemd unit files), or set both 'bind_address' and 'listen_port' to ''`) 54 | } 55 | s = http.Serve(listeners[0], r) 56 | default: 57 | log.Fatalf("Asked to listen on %d sockets via systemd activation. Sorry we currently only support listening on 1 socket.", len(listeners)) 58 | } 59 | return s 60 | } 61 | -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "io" 7 | "io/fs" 8 | "io/ioutil" 9 | "net" 10 | "net/http" 11 | "os" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/go-chi/chi/v5" 17 | "github.com/go-chi/chi/v5/middleware" 18 | "github.com/go-chi/cors" 19 | "github.com/go-chi/render" 20 | "github.com/pires/go-proxyproto" 21 | log "github.com/sirupsen/logrus" 22 | 23 | "github.com/librespeed/speedtest/config" 24 | "github.com/librespeed/speedtest/results" 25 | ) 26 | 27 | const ( 28 | // chunk size is 1 mib 29 | chunkSize = 1048576 30 | ) 31 | 32 | //go:embed assets 33 | var defaultAssets embed.FS 34 | 35 | var ( 36 | // generate random data for download test on start to minimize runtime overhead 37 | randomData = getRandomData(chunkSize) 38 | ) 39 | 40 | func ListenAndServe(conf *config.Config) error { 41 | r := chi.NewRouter() 42 | r.Use(middleware.RealIP) 43 | r.Use(middleware.GetHead) 44 | 45 | cs := cors.New(cors.Options{ 46 | AllowedOrigins: []string{"*"}, 47 | AllowedMethods: []string{"GET", "POST", "OPTIONS", "HEAD"}, 48 | AllowedHeaders: []string{"*"}, 49 | }) 50 | 51 | r.Use(cs.Handler) 52 | r.Use(middleware.NoCache) 53 | r.Use(middleware.Recoverer) 54 | 55 | var assetFS http.FileSystem 56 | if fi, err := os.Stat(conf.AssetsPath); os.IsNotExist(err) || !fi.IsDir() { 57 | log.Warnf("Configured asset path %s does not exist or is not a directory, using default assets", conf.AssetsPath) 58 | sub, err := fs.Sub(defaultAssets, "assets") 59 | if err != nil { 60 | log.Fatalf("Failed when processing default assets: %s", err) 61 | } 62 | assetFS = http.FS(sub) 63 | } else { 64 | assetFS = justFilesFilesystem{fs: http.Dir(conf.AssetsPath), readDirBatchSize: 2} 65 | } 66 | 67 | r.Get(conf.BaseURL+"/*", pages(assetFS, conf.BaseURL)) 68 | r.HandleFunc(conf.BaseURL+"/empty", empty) 69 | r.HandleFunc(conf.BaseURL+"/backend/empty", empty) 70 | r.Get(conf.BaseURL+"/garbage", garbage) 71 | r.Get(conf.BaseURL+"/backend/garbage", garbage) 72 | r.Get(conf.BaseURL+"/getIP", getIP) 73 | r.Get(conf.BaseURL+"/backend/getIP", getIP) 74 | r.Get(conf.BaseURL+"/results", results.DrawPNG) 75 | r.Get(conf.BaseURL+"/results/", results.DrawPNG) 76 | r.Get(conf.BaseURL+"/backend/results", results.DrawPNG) 77 | r.Get(conf.BaseURL+"/backend/results/", results.DrawPNG) 78 | r.Post(conf.BaseURL+"/results/telemetry", results.Record) 79 | r.Post(conf.BaseURL+"/backend/results/telemetry", results.Record) 80 | r.HandleFunc(conf.BaseURL+"/stats", results.Stats) 81 | r.HandleFunc(conf.BaseURL+"/backend/stats", results.Stats) 82 | 83 | // PHP frontend default values compatibility 84 | r.HandleFunc(conf.BaseURL+"/empty.php", empty) 85 | r.HandleFunc(conf.BaseURL+"/backend/empty.php", empty) 86 | r.Get(conf.BaseURL+"/garbage.php", garbage) 87 | r.Get(conf.BaseURL+"/backend/garbage.php", garbage) 88 | r.Get(conf.BaseURL+"/getIP.php", getIP) 89 | r.Get(conf.BaseURL+"/backend/getIP.php", getIP) 90 | r.Post(conf.BaseURL+"/results/telemetry.php", results.Record) 91 | r.Post(conf.BaseURL+"/backend/results/telemetry.php", results.Record) 92 | r.HandleFunc(conf.BaseURL+"/stats.php", results.Stats) 93 | r.HandleFunc(conf.BaseURL+"/backend/stats.php", results.Stats) 94 | 95 | go listenProxyProtocol(conf, r) 96 | 97 | return startListener(conf, r) 98 | } 99 | 100 | func listenProxyProtocol(conf *config.Config, r *chi.Mux) { 101 | if conf.ProxyProtocolPort != "0" { 102 | addr := net.JoinHostPort(conf.BindAddress, conf.ProxyProtocolPort) 103 | l, err := net.Listen("tcp", addr) 104 | if err != nil { 105 | log.Fatalf("Cannot listen on proxy protocol port %s: %s", conf.ProxyProtocolPort, err) 106 | } 107 | 108 | pl := &proxyproto.Listener{Listener: l} 109 | defer pl.Close() 110 | 111 | log.Infof("Starting proxy protocol listener on %s", addr) 112 | log.Fatal(http.Serve(pl, r)) 113 | } 114 | } 115 | 116 | func pages(fs http.FileSystem, BaseURL string) http.HandlerFunc { 117 | var removeBaseURL *regexp.Regexp 118 | if BaseURL != "" { 119 | removeBaseURL = regexp.MustCompile("^" + BaseURL + "/") 120 | } 121 | fn := func(w http.ResponseWriter, r *http.Request) { 122 | if BaseURL != "" { 123 | r.URL.Path = removeBaseURL.ReplaceAllString(r.URL.Path, "/") 124 | } 125 | if r.RequestURI == "/" { 126 | r.RequestURI = "/index.html" 127 | } 128 | 129 | http.FileServer(fs).ServeHTTP(w, r) 130 | } 131 | 132 | return fn 133 | } 134 | 135 | func empty(w http.ResponseWriter, r *http.Request) { 136 | _, err := io.Copy(ioutil.Discard, r.Body) 137 | if err != nil { 138 | w.WriteHeader(http.StatusBadRequest) 139 | return 140 | } 141 | _ = r.Body.Close() 142 | 143 | w.Header().Set("Connection", "keep-alive") 144 | w.WriteHeader(http.StatusOK) 145 | } 146 | 147 | func garbage(w http.ResponseWriter, r *http.Request) { 148 | w.Header().Set("Content-Description", "File Transfer") 149 | w.Header().Set("Content-Type", "application/octet-stream") 150 | w.Header().Set("Content-Disposition", "attachment; filename=random.dat") 151 | w.Header().Set("Content-Transfer-Encoding", "binary") 152 | 153 | // chunk size set to 4 by default 154 | chunks := 4 155 | 156 | ckSize := r.FormValue("ckSize") 157 | if ckSize != "" { 158 | i, err := strconv.ParseInt(ckSize, 10, 64) 159 | if err != nil { 160 | log.Errorf("Invalid chunk size: %s", ckSize) 161 | log.Warnf("Will use default value %d", chunks) 162 | } else { 163 | // limit max chunk size to 1024 164 | if i > 1024 { 165 | chunks = 1024 166 | } else { 167 | chunks = int(i) 168 | } 169 | } 170 | } 171 | 172 | for i := 0; i < chunks; i++ { 173 | if _, err := w.Write(randomData); err != nil { 174 | log.Errorf("Error writing back to client at chunk number %d: %s", i, err) 175 | break 176 | } 177 | } 178 | } 179 | 180 | func getIP(w http.ResponseWriter, r *http.Request) { 181 | var ret results.Result 182 | 183 | clientIP := r.RemoteAddr 184 | clientIP = strings.ReplaceAll(clientIP, "::ffff:", "") 185 | 186 | ip, _, err := net.SplitHostPort(r.RemoteAddr) 187 | if err == nil { 188 | clientIP = ip 189 | } 190 | 191 | isSpecialIP := true 192 | switch { 193 | case clientIP == "::1": 194 | ret.ProcessedString = clientIP + " - localhost IPv6 access" 195 | case strings.HasPrefix(clientIP, "fe80:"): 196 | ret.ProcessedString = clientIP + " - link-local IPv6 access" 197 | case strings.HasPrefix(clientIP, "127."): 198 | ret.ProcessedString = clientIP + " - localhost IPv4 access" 199 | case strings.HasPrefix(clientIP, "10."): 200 | ret.ProcessedString = clientIP + " - private IPv4 access" 201 | case regexp.MustCompile(`^172\.(1[6-9]|2\d|3[01])\.`).MatchString(clientIP): 202 | ret.ProcessedString = clientIP + " - private IPv4 access" 203 | case strings.HasPrefix(clientIP, "192.168"): 204 | ret.ProcessedString = clientIP + " - private IPv4 access" 205 | case strings.HasPrefix(clientIP, "169.254"): 206 | ret.ProcessedString = clientIP + " - link-local IPv4 access" 207 | case regexp.MustCompile(`^100\.([6-9][0-9]|1[0-2][0-7])\.`).MatchString(clientIP): 208 | ret.ProcessedString = clientIP + " - CGNAT IPv4 access" 209 | default: 210 | isSpecialIP = false 211 | } 212 | 213 | if isSpecialIP { 214 | b, _ := json.Marshal(&ret) 215 | if _, err := w.Write(b); err != nil { 216 | log.Errorf("Error writing to client: %s", err) 217 | } 218 | return 219 | } 220 | 221 | getISPInfo := r.FormValue("isp") == "true" 222 | distanceUnit := r.FormValue("distance") 223 | 224 | ret.ProcessedString = clientIP 225 | 226 | if getISPInfo { 227 | ispInfo := getIPInfo(clientIP) 228 | ret.RawISPInfo = ispInfo 229 | 230 | removeRegexp := regexp.MustCompile(`AS\d+\s`) 231 | isp := removeRegexp.ReplaceAllString(ispInfo.Organization, "") 232 | 233 | if isp == "" { 234 | isp = "Unknown ISP" 235 | } 236 | 237 | if ispInfo.Country != "" { 238 | isp += ", " + ispInfo.Country 239 | } 240 | 241 | if ispInfo.Location != "" { 242 | isp += " (" + calculateDistance(ispInfo.Location, distanceUnit) + ")" 243 | } 244 | 245 | ret.ProcessedString += " - " + isp 246 | } 247 | 248 | render.JSON(w, r, ret) 249 | } 250 | --------------------------------------------------------------------------------