├── VERSION ├── DEV-VERSION ├── .gitignore ├── web ├── pages │ ├── favicon.ico │ ├── favicon.png │ ├── favicon_inverted.png │ ├── script.js │ ├── theme-variables.css │ ├── login.html │ ├── styles.css │ ├── single.html │ ├── speedtest.js │ ├── index.html │ └── speedtest_worker.js ├── fs.go ├── empty.go ├── getip.go ├── auth.go ├── route.go ├── revping.go ├── chart.go └── garbage.go ├── docker ├── compose │ └── docker-compose.yml └── dockerfile │ ├── dev │ ├── init.sh │ └── Dockerfile │ └── release │ ├── init.sh │ └── Dockerfile ├── docs ├── docker │ ├── docker-cli_zh-tw.md │ ├── docker-cli_zh-cn.md │ ├── docker-cli_en.md │ ├── docker-compose_zh-cn.md │ ├── docker-compose_zh-tw.md │ └── docker-compose_en.md ├── flags │ ├── flags_hk.md │ ├── flags_zh-cn.md │ └── flags-en.md ├── openwrt │ ├── openwrt_hk.md │ ├── openwrt_zh-tw.md │ ├── openwrt_zh-cn.md │ └── openwrt_en.md ├── config │ ├── config_zh-tw.md │ ├── config_zh-cn.md │ └── config_en.md ├── README_hk.md ├── README_zh-tw.md ├── README_zh-cn.md └── README_en.md ├── database ├── schema │ └── schema.go ├── database.go └── bolt │ └── bolt.go ├── cli ├── go.mod └── go.sum ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── dev-build.yml ├── deploy ├── config.toml ├── install.sh └── install-dev.sh ├── config ├── config.toml └── config.go ├── go.mod ├── ipinfo ├── schema.go ├── self-host.go ├── ipinfo.go └── getip.go ├── results └── telemetry.go ├── README.md ├── main.go ├── go.sum ├── CHANGELOG.md └── LICENSE /VERSION: -------------------------------------------------------------------------------- 1 | 1.1.3 -------------------------------------------------------------------------------- /DEV-VERSION: -------------------------------------------------------------------------------- 1 | 25w17a -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | dist/ 3 | 4 | *.bak 5 | demo 6 | demo.toml 7 | *.log 8 | tmp/ 9 | -------------------------------------------------------------------------------- /web/pages/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/HEAD/web/pages/favicon.ico -------------------------------------------------------------------------------- /web/pages/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/HEAD/web/pages/favicon.png -------------------------------------------------------------------------------- /web/pages/favicon_inverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/HEAD/web/pages/favicon_inverted.png -------------------------------------------------------------------------------- /docker/compose/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | speedtest-ex: 4 | image: 'wjqserver/speedtest-ex:latest' 5 | restart: always 6 | volumes: 7 | - './speedtest-ex/config:/data/speedtest-ex/config' 8 | - './speedtest-ex/log:/data/speedtest-ex/log' 9 | - './speedtest-ex/db:/data/speedtest-ex/db' 10 | ports: 11 | - '8989:8989' 12 | -------------------------------------------------------------------------------- /web/fs.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io/fs" 7 | ) 8 | 9 | var ( 10 | //go:embed pages/* 11 | assetsFS embed.FS 12 | pages fs.FS 13 | ) 14 | 15 | func init() { 16 | // assets 嵌入文件系统 17 | var err error 18 | pages, err = fs.Sub(assetsFS, "pages") 19 | if err != nil { 20 | fmt.Printf("Failed when processing pages: %s", err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/docker/docker-cli_zh-tw.md: -------------------------------------------------------------------------------- 1 | # 使用 Docker-Cli 部署 SpeedTest-EX 2 | 3 | ```bash 4 | # 執行容器 5 | docker run -d \ 6 | --name speedtest-ex \ 7 | --restart always \ 8 | -v ./speedtest-ex/config:/data/speedtest-ex/config \ 9 | -v ./speedtest-ex/log:/data/speedtest-ex/log \ 10 | -v ./speedtest-ex/db:/data/speedtest-ex/db \ 11 | -p 8989:8989 \ 12 | wjqserver/speedtest-ex:latest 13 | ``` -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/docker/docker-cli_zh-cn.md: -------------------------------------------------------------------------------- 1 | # 使用 Docker-Cli 部署 SpeedTest-EX 2 | 3 | ````bash 4 | # 运行容器 5 | docker run -d \ 6 | --name speedtest-ex \ 7 | --restart always \ 8 | -v ./speedtest-ex/config:/data/speedtest-ex/config \ 9 | -v ./speedtest-ex/log:/data/speedtest-ex/log \ 10 | -v ./speedtest-ex/db:/data/speedtest-ex/db \ 11 | -p 8989:8989 \ 12 | wjqserver/speedtest-ex:latest 13 | 14 | ``` -------------------------------------------------------------------------------- /docs/docker/docker-cli_en.md: -------------------------------------------------------------------------------- 1 | # Deploying SpeedTest-EX Using Docker-Cli 2 | 3 | ```bash 4 | # Run the container 5 | docker run -d \ 6 | --name speedtest-ex \ 7 | --restart always \ 8 | -v ./speedtest-ex/config:/data/speedtest-ex/config \ 9 | -v ./speedtest-ex/log:/data/speedtest-ex/log \ 10 | -v ./speedtest-ex/db:/data/speedtest-ex/db \ 11 | -p 8989:8989 \ 12 | wjqserver/speedtest-ex:latest 13 | ``` -------------------------------------------------------------------------------- /docs/flags/flags_hk.md: -------------------------------------------------------------------------------- 1 | # 傳入參數 2 | 3 | ``` bash 4 | speedtest-ex -cfg /path/to/config/config.toml # 傳入配置文件路徑(必選) 5 | 6 | # 以下參數可選 7 | -port 8080 # 設置服務端口,默認8989 8 | 9 | -auth #開啟鑒權,默認關閉 10 | 11 | -username admin # 設置用戶名(需要開啟鑒權) 12 | 13 | -password admin # 設置密碼(需要開啟鑒權) 14 | 15 | -secret rand # 設置密鑰(需要開啟鑒權) (rand為隨機生成) 16 | 17 | -initcfg # 初始化配置模式, 傳入並保存配置, 用於快速初始化配置(保存配置後將會退出) 18 | 19 | -dev # 開啟開發模式,默認關閉(非開發用戶請不要開啟) 20 | 21 | -version # 顯示版本資訊 22 | 23 | ``` -------------------------------------------------------------------------------- /docker/dockerfile/dev/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | APPLICATION=speedtest-ex 4 | 5 | # 检查并复制 config.toml 6 | if [ ! -f /data/${APPLICATION}/config/config.toml ]; then 7 | cp /data/${APPLICATION}/config.toml /data/${APPLICATION}/config/config.toml 8 | fi 9 | 10 | # 启动 Go 应用 11 | /data/${APPLICATION}/${APPLICATION} -cfg /data/${APPLICATION}/config/config.toml > /data/${APPLICATION}/log/run.log 2>&1 & 12 | 13 | # 保持脚本运行 14 | while true; do 15 | sleep 1 16 | done -------------------------------------------------------------------------------- /docker/dockerfile/release/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | APPLICATION=speedtest-ex 4 | 5 | # 检查并复制 config.toml 6 | if [ ! -f /data/${APPLICATION}/config/config.toml ]; then 7 | cp /data/${APPLICATION}/config.toml /data/${APPLICATION}/config/config.toml 8 | fi 9 | 10 | # 启动 Go 应用 11 | /data/${APPLICATION}/${APPLICATION} -cfg /data/${APPLICATION}/config/config.toml > /data/${APPLICATION}/log/run.log 2>&1 & 12 | 13 | # 保持脚本运行 14 | while true; do 15 | sleep 1 16 | done -------------------------------------------------------------------------------- /docs/flags/flags_zh-cn.md: -------------------------------------------------------------------------------- 1 | # 传入参数 2 | 3 | ``` bash 4 | speedtest-ex -cfg /path/to/config/config.toml # 传入配置文件路径(必选) 5 | 6 | # 以下参数可选 7 | -port 8080 # 设置服务端口,默认8989 8 | 9 | -auth #开启鉴权,默认关闭 10 | 11 | -username admin # 设置用户名(需要开启鉴权) 12 | 13 | -password admin # 设置密码(需要开启鉴权) 14 | 15 | -secret rand # 设置密钥(需要开启鉴权) (rand为随机生成) 16 | 17 | -initcfg # 初始化配置模式, 传入并保存配置, 用于快速初始化配置(保存配置后将会退出) 18 | 19 | -dev # 开启开发模式,默认关闭(非开发用户请不要开启) 20 | 21 | -version # 显示版本信息 22 | 23 | ``` -------------------------------------------------------------------------------- /cli/go.mod: -------------------------------------------------------------------------------- 1 | module speedtest-ex-cli 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/briandowns/spinner v1.23.2 7 | github.com/fatih/color v1.18.0 8 | github.com/schollz/progressbar/v3 v3.18.0 9 | ) 10 | 11 | require ( 12 | github.com/mattn/go-colorable v0.1.14 // indirect 13 | github.com/mattn/go-isatty v0.0.20 // indirect 14 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 15 | github.com/rivo/uniseg v0.4.7 // indirect 16 | golang.org/x/sys v0.33.0 // indirect 17 | golang.org/x/term v0.32.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "speedtest/config" 5 | "speedtest/database/bolt" 6 | "speedtest/database/schema" 7 | ) 8 | 9 | var ( 10 | DB DataAccess 11 | ) 12 | 13 | type DataAccess interface { 14 | SaveTelemetry(*schema.TelemetryData) error 15 | GetTelemetryByUUID(string) (*schema.TelemetryData, error) 16 | GetLastNRecords(int) ([]schema.TelemetryData, error) 17 | GetAllTelemetry() ([]schema.TelemetryData, error) 18 | } 19 | 20 | func SetDBInfo(cfg *config.Config) { 21 | DB = bolt.OpenDatabase(cfg.Database.Path) 22 | } 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /deploy/config.toml: -------------------------------------------------------------------------------- 1 | [Server] 2 | basePath = "" 3 | host = "0.0.0.0" 4 | port = 8989 5 | 6 | [Speedtest] 7 | downDataChunkSize = 4 #mb 8 | downDataChunkCount = 4 9 | 10 | [Log] 11 | logFilePath = "./log/speedtest-ex.log" 12 | maxLogSize = 5 13 | 14 | [IPinfo] 15 | ipinfo_api_key = "" 16 | ipinfo_url = "" 17 | model = "ipinfo" 18 | 19 | [Database] 20 | model = "bolt" 21 | path = "./db/speedtest-ex.db" 22 | 23 | [Frontend] 24 | chartlist = 100 25 | 26 | [RevPing] 27 | enable = true 28 | 29 | [Auth] 30 | enable = false 31 | password = "password" 32 | secret = "cW9hT1HntVO5Rf9w2obVxPvy3i2DHmFyrorq7O1lu1Y" 33 | username = "admin" 34 | -------------------------------------------------------------------------------- /docs/openwrt/openwrt_hk.md: -------------------------------------------------------------------------------- 1 | # 喺 OpenWrt 上運行 Speedtest-EX 2 | 3 | > 非項目官方適配,若有問題請轉至 https://github.com/JohnsonRan/packages_builder 4 | 5 | ## 下載構建 6 | - 目前只提供 `arm64` 以及 `amd64` 架構嘅 `apk` (OpenWrt-SNAPSHOT) 以及 `ipk` 文件 7 | [下載連結](https://github.com/JohnsonRan/packages_builder/releases) 8 | 9 | ## 進行安裝 10 | - 前往 OpenWrt 管理介面,轉到 `系統 -> 軟件包` 頁面上傳並安裝軟件包 11 | 12 | ## 開始使用 13 | - 配置文件位於 `/etc/speedtest-ex/config.toml` 14 | - 修改配置後執行 `/etc/init.d/speedtest-ex restart` 重啟服務 15 | - 默認運行喺 `8989` 端口 16 | - 若唔再需要直接前往軟件包處刪除 `speedtest-ex` 即可 17 | 18 | ### 自行編譯 19 | 項目亦提供咗可自行編譯嘅庫 20 | [編譯庫連結](https://github.com/JohnsonRan/packages_net_speedtest-ex) 21 | 你可以用此庫編譯出任何架構嘅軟件包以供安裝 -------------------------------------------------------------------------------- /docs/openwrt/openwrt_zh-tw.md: -------------------------------------------------------------------------------- 1 | # 在 OpenWrt 上運行 Speedtest-EX 2 | 3 | > 非項目官方適配,若有問題請轉至 https://github.com/JohnsonRan/packages_builder 4 | 5 | ## 下載構建 6 | - 目前僅提供 `arm64` 以及 `amd64` 架構的 `apk` (OpenWrt-SNAPSHOT) 以及 `ipk` 文件 7 | [下載連結](https://github.com/JohnsonRan/packages_builder/releases) 8 | 9 | ## 進行安裝 10 | - 前往 OpenWrt 管理介面,轉到 `系統 -> 軟體包` 頁面上傳並安裝軟體包 11 | 12 | ## 開始使用 13 | - 配置文件位於 `/etc/speedtest-ex/config.toml` 14 | - 修改配置後執行 `/etc/init.d/speedtest-ex restart` 重啟服務 15 | - 默認運行在 `8989` 端口 16 | - 若不再需要直接前往軟體包處刪除 `speedtest-ex` 即可 17 | 18 | ### 自行編譯 19 | 項目也提供了可自行編譯的庫 20 | [編譯庫連結](https://github.com/JohnsonRan/packages_net_speedtest-ex) 21 | 你可以用此庫編譯出任何架構的軟體包以供安裝 22 | 23 | -------------------------------------------------------------------------------- /config/config.toml: -------------------------------------------------------------------------------- 1 | [Server] 2 | host = "0.0.0.0" 3 | port = 8989 4 | basePath = "" 5 | 6 | [Speedtest] 7 | downDataChunkSize = 4 8 | downDataChunkCount = 4 9 | downloadGenStream = true 10 | 11 | [Log] 12 | logFilePath = "/data/speedtest-ex/log/speedtest-ex.log" 13 | maxLogSize = 5 14 | 15 | [IPinfo] 16 | model = "ipinfo" 17 | ipinfo_url = "" 18 | ipinfo_api_key = "" 19 | 20 | [Database] 21 | model = "bolt" 22 | path = "/data/speedtest-ex/db/speedtest.db" 23 | 24 | [Frontend] 25 | chartlist = 100 26 | 27 | [RevPing] 28 | enable = true 29 | 30 | [Auth] 31 | enable = false 32 | username = "admin" 33 | password = "password" 34 | secret = "secret" 35 | -------------------------------------------------------------------------------- /docs/openwrt/openwrt_zh-cn.md: -------------------------------------------------------------------------------- 1 | # 在 OpenWrt 上运行 Speedtest-EX 2 | 3 | > 非项目官方适配,若有问题请转至 https://github.com/JohnsonRan/InfinitySubstance 4 | 5 | ## 添加源 6 | - 目前仅提供 `arm64` 以及 `amd64` 架构的 `apk`(OpenWrt-SNAPSHOT) 以及 `ipk` 软件包 7 | ```shell 8 | # 只需要运行一次 9 | curl -s -L https://github.com/JohnsonRan/InfinitySubstance/raw/main/feed.sh | ash 10 | ``` 11 | 12 | ## 进行安装 13 | - 前往 OpenWrt 管理界面,转到 `系统 -> 软件包` 搜索并安装 `speedtest-ex` 14 | 15 | ## 开始使用 16 | - 配置文件位于 `/etc/speedtest-ex/config.toml` 17 | - 修改配置后执行 `/etc/init.d/speedtest-ex restart` 重启服务 18 | - 默认运行在 `8989` 端口 19 | - 若不再需要直接前往软件包处删除 `speedtest-ex` 即可 20 | 21 | ### 自行编译 22 | 项目也提供了可自行编译的库 23 | https://github.com/JohnsonRan/packages_net_speedtest-ex 24 | 你可以用此库编译出任何架构的软件包以供安装 25 | -------------------------------------------------------------------------------- /docs/docker/docker-compose_zh-cn.md: -------------------------------------------------------------------------------- 1 | # 使用 Docker Compose 部署 SpeedTest-EX 2 | 3 | ## 创建目录 4 | 5 | 此处以`/root/data/docker_data/speedtest-ex`为例,创建目录并进入: 6 | 7 | ```bash 8 | mkdir -p /root/data/docker_data/speedtest-ex 9 | cd /root/data/docker_data/speedtest-ex 10 | ``` 11 | 12 | ## 创建compose文件 13 | 14 | ```bash 15 | touch docker-compose.yml 16 | ``` 17 | 18 | ## 编辑compose文件 19 | 20 | ```yaml 21 | version: '3' 22 | services: 23 | speedtest-ex: 24 | image: 'wjqserver/speedtest-ex:latest' 25 | restart: always 26 | volumes: 27 | - './speedtest-ex/config:/data/speedtest-ex/config' # 配置文件 28 | - './speedtest-ex/log:/data/speedtest-ex/log' # 日志文件 29 | - './speedtest-ex/db:/data/speedtest-ex/db' # 数据库文件 30 | ports: 31 | - '8989:8989' # 端口映射 32 | ``` 33 | 34 | ## 启动服务 35 | 36 | ```bash 37 | docker compose up -d 38 | ``` -------------------------------------------------------------------------------- /docs/docker/docker-compose_zh-tw.md: -------------------------------------------------------------------------------- 1 | # 使用 Docker Compose 部署 SpeedTest-EX 2 | 3 | ## 創建目錄 4 | 5 | 此處以 `/root/data/docker_data/speedtest-ex` 為例,創建目錄並進入: 6 | 7 | ```bash 8 | mkdir -p /root/data/docker_data/speedtest-ex 9 | cd /root/data/docker_data/speedtest-ex 10 | ``` 11 | 12 | ## 創建 Compose 文件 13 | 14 | ```bash 15 | touch docker-compose.yml 16 | ``` 17 | 18 | ## 編輯 Compose 文件 19 | 20 | ```yaml 21 | version: '3' 22 | services: 23 | speedtest-ex: 24 | image: 'wjqserver/speedtest-ex:latest' 25 | restart: always 26 | volumes: 27 | - './speedtest-ex/config:/data/speedtest-ex/config' # 配置文件 28 | - './speedtest-ex/log:/data/speedtest-ex/log' # 日誌文件 29 | - './speedtest-ex/db:/data/speedtest-ex/db' # 資料庫文件 30 | ports: 31 | - '8989:8989' # 端口映射 32 | ``` 33 | 34 | ## 啟動服務 35 | 36 | ```bash 37 | docker compose up -d 38 | ``` -------------------------------------------------------------------------------- /docs/flags/flags-en.md: -------------------------------------------------------------------------------- 1 | # Input Parameters 2 | 3 | ``` bash 4 | speedtest-ex -cfg /path/to/config/config.toml # Path to the configuration file (required) 5 | 6 | # The following parameters are optional 7 | -port 8080 # Set the server port, default is 8989 8 | 9 | -auth # Enable authentication, default is off 10 | 11 | -username admin # Set the username (authentication must be enabled) 12 | 13 | -password admin # Set the password (authentication must be enabled) 14 | 15 | -secret rand # Set the secret key (authentication must be enabled) (rand is randomly generated) 16 | 17 | -initcfg # Initialise configuration mode, input and save configuration for quick setup (will exit after saving configuration) 18 | 19 | -dev # Enable development mode, default is off (do not enable for non-development users) 20 | 21 | -version # Show the version of speedtest-ex 22 | 23 | ``` -------------------------------------------------------------------------------- /docs/docker/docker-compose_en.md: -------------------------------------------------------------------------------- 1 | # Deploying SpeedTest-EX Using Docker Compose 2 | 3 | ## Create Directory 4 | 5 | Here, we will use `/root/data/docker_data/speedtest-ex` as an example. Create the directory and navigate into it: 6 | 7 | ```bash 8 | mkdir -p /root/data/docker_data/speedtest-ex 9 | cd /root/data/docker_data/speedtest-ex 10 | ``` 11 | 12 | ## Create Compose File 13 | 14 | ```bash 15 | touch docker-compose.yml 16 | ``` 17 | 18 | ## Edit Compose File 19 | 20 | ```yaml 21 | version: '3' 22 | services: 23 | speedtest-ex: 24 | image: 'wjqserver/speedtest-ex:latest' 25 | restart: always 26 | volumes: 27 | - './speedtest-ex/config:/data/speedtest-ex/config' # Configuration file 28 | - './speedtest-ex/log:/data/speedtest-ex/log' # Log file 29 | - './speedtest-ex/db:/data/speedtest-ex/db' # Database file 30 | ports: 31 | - '8989:8989' # Port mapping 32 | ``` 33 | 34 | ## Start the Service 35 | 36 | ```bash 37 | docker compose up -d 38 | ``` -------------------------------------------------------------------------------- /docs/config/config_zh-tw.md: -------------------------------------------------------------------------------- 1 | # 配置文件 2 | 3 | 以下以Docker容器内的配置文件为例。 4 | 5 | 配置文件位於 `{安裝目錄}/speedtest-ex/config/config.toml` 6 | 7 | ```toml 8 | [server] 9 | host = "0.0.0.0" # 監聽地址 10 | port = 8989 # 監聽端口 11 | basePath = "" # 兼容LiberSpeed而保留, 無需求請不要修改 12 | 13 | [Speedtest] 14 | downDataChunkSize = 4 #mb 下載數據分塊大小 15 | downDataChunkCount = 4 # 下載數據分塊數量 16 | 17 | [log] 18 | logFilePath = "/data/speedtest-ex/log/speedtest-ex.log" # 日誌文件路徑 19 | maxLogSize = 5 # MB 日誌文件最大容量 20 | 21 | [ipinfo] 22 | model = "ipinfo" # ip(自托管) 或 ipinfo(ipinfo.io) 23 | ipinfo_url = "" #自托管時請填寫您的IP程式的API地址 24 | ipinfo_api_key = "" # ipinfo.io API key 若有可以填寫 25 | 26 | [database] 27 | model = "bolt" # 資料庫類型, 目前僅支持BoltDB 28 | path = "/data/speedtest-ex/db/speedtest.db" # 資料庫文件路徑 29 | 30 | [frontend] 31 | chartlist = 100 # 預設顯示最近100條數據 32 | 33 | [revping] 34 | enable = true # 是否啟用反向Ping測試 35 | 36 | [auth] 37 | enable = false # 是否啟用驗證 38 | username = "admin" # 驗證用戶名 39 | password = "password" # 驗證密碼 40 | secret = "secret" # 加密密鑰,用於生成會話cookie,請務必修改 41 | 42 | ``` -------------------------------------------------------------------------------- /docs/config/config_zh-cn.md: -------------------------------------------------------------------------------- 1 | # 配置文件 2 | 3 | 以下以Docker容器内的配置文件为例。 4 | 5 | 配置文件位于 `{安装目录}/speedtest-ex/config/config.toml` 6 | 7 | ```toml 8 | [server] 9 | host = "0.0.0.0" # 监听地址 10 | port = 8989 # 监听端口 11 | basePath = "" # 兼容LiberSpeed而保留, 无需求请不要修改 12 | 13 | [Speedtest] 14 | downDataChunkSize = 4 #mb 下载数据分块大小 15 | downDataChunkCount = 4 # 下载数据分块数量 16 | 17 | [log] 18 | logFilePath = "/data/speedtest-ex/log/speedtest-ex.log" # 日志文件路径 19 | maxLogSize = 5 # MB 日志文件最大容量 20 | 21 | [ipinfo] 22 | model = "ipinfo" # ip(自托管) 或 ipinfo(ipinfo.io) 23 | ipinfo_url = "" #使用自托管时请填写您的ip服务地址 24 | ipinfo_api_key = "" # ipinfo.io API key 若有可以填写 25 | 26 | [database] 27 | model = "bolt" # 数据库类型, 目前仅支持BoltDB 28 | path = "/data/speedtest-ex/db/speedtest.db" # 数据库文件路径 29 | 30 | [frontend] 31 | chartlist = 100 # 默认显示最近100条数据 32 | 33 | [revping] 34 | enable = true # 是否开启反向延迟测试 35 | 36 | [auth] 37 | enable = false # 是否开启鉴权 38 | username = "admin" # 鉴权用户名 39 | password = "password" # 鉴权密码 40 | secret = "secret" # 加密密钥, 用于生产session cookie, 请务必修改 41 | 42 | ``` -------------------------------------------------------------------------------- /docs/openwrt/openwrt_en.md: -------------------------------------------------------------------------------- 1 | # Running Speedtest-EX on OpenWrt 2 | 3 | > This is not an official adaptation of the project. If you have any issues, please refer to https://github.com/JohnsonRan/InfinitySubstance 4 | 5 | ## Add feed 6 | - Currently, only `arm64` and `amd64` architecture `apk` (OpenWrt-SNAPSHOT) and `ipk` packages are provided. 7 | ```shell 8 | # only needs to be run once 9 | curl -s -L https://github.com/JohnsonRan/InfinitySubstance/raw/main/feed.sh | ash 10 | ``` 11 | 12 | ## Installation 13 | - Navigate to the OpenWrt management interface and go to the `System -> Packages` page, search and install `speedtest-ex` 14 | 15 | ## Getting Started 16 | - The configuration file can be found at `/etc/speedtest-ex/config.toml` 17 | - After modifying the configuration, execute `/etc/init.d/speedtest-ex restart` to restart the service. 18 | - It runs by default on port `8989`. 19 | - If no longer needed, simply go to the packages section to remove `speedtest-ex`. 20 | 21 | ### Self-Compilation 22 | The project also provides libraries for self-compilation. 23 | https://github.com/JohnsonRan/packages_net_speedtest-ex 24 | You can use this library to compile packages for any architecture for installation. 25 | -------------------------------------------------------------------------------- /docker/dockerfile/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS builder 2 | 3 | ARG USER=WJQSERVER 4 | ARG REPO=speedtest-ex 5 | ARG APPLICATION=speedtest-ex 6 | ARG TARGETOS 7 | ARG TARGETARCH 8 | ARG TARGETPLATFORM 9 | 10 | # 拉取依赖 11 | RUN apk add --no-cache wget curl 12 | 13 | # 创建目录 14 | RUN mkdir -p /data/${APPLICATION} 15 | RUN mkdir -p /data/${APPLICATION}/config 16 | RUN mkdir -p /data/${APPLICATION}/logs 17 | RUN mkdir -p /data/${APPLICATION}/db 18 | 19 | # 后端 20 | RUN VERSION=$(curl -s https://raw.githubusercontent.com/${USER}/${REPO}/dev/DEV-VERSION) && \ 21 | wget -O /data/${APPLICATION}/${APPLICATION} https://github.com/${USER}/${REPO}/releases/download/$VERSION/${APPLICATION}-${TARGETOS}-${TARGETARCH} 22 | RUN wget -O /data/${APPLICATION}/config.toml https://raw.githubusercontent.com/${USER}/${REPO}/dev/config/config.toml 23 | RUN wget -O /usr/local/bin/init.sh https://raw.githubusercontent.com/${USER}/${REPO}/dev/docker/dockerfile/dev/init.sh 24 | 25 | # 权限 26 | RUN chmod +x /data/${APPLICATION}/${APPLICATION} 27 | RUN chmod +x /usr/local/bin/init.sh 28 | 29 | FROM alpine:latest 30 | 31 | COPY --from=builder /data/${APPLICATION} /data/${APPLICATION} 32 | COPY --from=builder /usr/local/bin/init.sh /usr/local/bin/init.sh 33 | 34 | # 权限 35 | RUN chmod +x /data/${APPLICATION}/${APPLICATION} 36 | RUN chmod +x /usr/local/bin/init.sh 37 | 38 | CMD ["/usr/local/bin/init.sh"] 39 | 40 | 41 | -------------------------------------------------------------------------------- /docker/dockerfile/release/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS builder 2 | 3 | ARG USER=WJQSERVER 4 | ARG REPO=speedtest-ex 5 | ARG APPLICATION=speedtest-ex 6 | ARG TARGETOS 7 | ARG TARGETARCH 8 | ARG TARGETPLATFORM 9 | 10 | # 拉取依赖 11 | RUN apk add --no-cache wget curl 12 | 13 | # 创建目录 14 | RUN mkdir -p /data/${APPLICATION} 15 | RUN mkdir -p /data/${APPLICATION}/config 16 | RUN mkdir -p /data/${APPLICATION}/logs 17 | RUN mkdir -p /data/${APPLICATION}/db 18 | 19 | # 后端 20 | RUN VERSION=$(curl -s https://raw.githubusercontent.com/${USER}/${REPO}/main/VERSION) && \ 21 | wget -O /data/${APPLICATION}/${APPLICATION} https://github.com/${USER}/${REPO}/releases/download/$VERSION/${APPLICATION}-${TARGETOS}-${TARGETARCH} 22 | RUN wget -O /data/${APPLICATION}/config.toml https://raw.githubusercontent.com/${USER}/${REPO}/main/config/config.toml 23 | RUN wget -O /usr/local/bin/init.sh https://raw.githubusercontent.com/${USER}/${REPO}/main/docker/dockerfile/dev/init.sh 24 | 25 | # 权限 26 | RUN chmod +x /data/${APPLICATION}/${APPLICATION} 27 | RUN chmod +x /usr/local/bin/init.sh 28 | 29 | FROM alpine:latest 30 | 31 | COPY --from=builder /data/${APPLICATION} /data/${APPLICATION} 32 | COPY --from=builder /usr/local/bin/init.sh /usr/local/bin/init.sh 33 | 34 | # 权限 35 | RUN chmod +x /data/${APPLICATION}/${APPLICATION} 36 | RUN chmod +x /usr/local/bin/init.sh 37 | 38 | CMD ["/usr/local/bin/init.sh"] 39 | 40 | 41 | -------------------------------------------------------------------------------- /web/empty.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/WJQSERVER-STUDIO/go-utils/copyb" 9 | "github.com/infinite-iroha/touka" 10 | ) 11 | 12 | var ( 13 | BufferPool *sync.Pool 14 | BufferSize int = 32 * 1024 // 32KB 15 | ) 16 | 17 | func InitEmptyBuf() { 18 | // 初始化固定大小的缓存池 19 | BufferPool = &sync.Pool{ 20 | New: func() interface{} { 21 | return make([]byte, BufferSize) 22 | }, 23 | } 24 | } 25 | 26 | // empty 处理对/empty的请求,丢弃请求体并返回成功的状态码 27 | func empty(c *touka.Context) { 28 | 29 | var err error 30 | /* 31 | // 使用固定32KB缓冲池 32 | buffer := BufferPool.Get().([]byte) 33 | defer BufferPool.Put(buffer) 34 | 35 | _, err = io.CopyBuffer(io.Discard, c.Request.Body, buffer) 36 | if err != nil { 37 | logWarning("empty > io.CopyBuffer error: %v", err) 38 | return 39 | } 40 | c.Status(http.StatusOK) 41 | */ 42 | 43 | //_, err = io.Copy(io.Discard, c.Request.Body) 44 | //if err != nil { 45 | // return 46 | //} 47 | c.Status(http.StatusOK) 48 | 49 | _, err = copyb.Copy(io.Discard, c.Request.Body) 50 | if err != nil { 51 | return 52 | } 53 | c.Status(http.StatusOK) 54 | 55 | // for debug 56 | /* 57 | bodySize, err := io.Copy(io.Discard, c.Request.Body) 58 | if err != nil { 59 | c.Status(http.StatusInternalServerError) 60 | return 61 | } 62 | c.Status(http.StatusOK) 63 | logInfo("empty > body size: %d", bodySize) 64 | */ 65 | } 66 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module speedtest 2 | 3 | go 1.24.5 4 | 5 | require ( 6 | github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6 7 | github.com/WJQSERVER-STUDIO/httpc v0.8.1 8 | github.com/breml/rootcerts v0.3.0 9 | github.com/fenthope/compress v0.0.3 10 | github.com/fenthope/cors v0.0.2 11 | github.com/fenthope/reco v0.0.3 12 | github.com/fenthope/record v0.0.3 13 | github.com/fenthope/sessions v0.0.1 14 | github.com/infinite-iroha/touka v0.3.0 15 | github.com/oklog/ulid/v2 v2.1.1 16 | go.etcd.io/bbolt v1.4.2 17 | ) 18 | 19 | require ( 20 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 21 | github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d // indirect 22 | github.com/google/uuid v1.6.0 // indirect 23 | github.com/gorilla/context v1.1.2 // indirect 24 | github.com/gorilla/securecookie v1.1.2 // indirect 25 | github.com/gorilla/sessions v1.4.0 // indirect 26 | github.com/klauspost/compress v1.18.0 // indirect 27 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 28 | github.com/valyala/bytebufferpool v1.0.0 // indirect 29 | golang.org/x/net v0.42.0 // indirect 30 | golang.org/x/sync v0.16.0 // indirect 31 | ) 32 | 33 | require ( 34 | github.com/BurntSushi/toml v1.5.0 35 | github.com/gorilla/websocket v1.5.3 36 | github.com/prometheus-community/pro-bing v0.7.0 37 | golang.org/x/sys v0.34.0 // indirect 38 | ) 39 | 40 | //replace github.com/infinite-iroha/touka => /data/github/WJQSERVER/touka 41 | -------------------------------------------------------------------------------- /docs/config/config_en.md: -------------------------------------------------------------------------------- 1 | # Configuration File 2 | 3 | Below is an example of the configuration file within a Docker container. 4 | 5 | The configuration file is located at `{installation_directory}/speedtest-ex/config/config.toml` 6 | 7 | ```toml 8 | [server] 9 | host = "0.0.0.0" # Listening address 10 | port = 8989 # Listening port 11 | basePath = "" # Retained for compatibility with LiberSpeed; do not modify if not needed 12 | 13 | [Speedtest] 14 | downDataChunkSize = 4 #mb Download data chunk size 15 | downDataChunkCount = 4 # Download data chunk count 16 | 17 | [log] 18 | logFilePath = "/data/speedtest-ex/log/speedtest-ex.log" # Path to the log file 19 | maxLogSize = 5 # MB Maximum size of the log file 20 | 21 | [ipinfo] 22 | model = "ipinfo" # ip (self-hosted) or ipinfo (ipinfo.io) 23 | ipinfo_url = "" # Please fill in the API address of your self-hosted ipinfo service when self-hosting 24 | ipinfo_api_key = "" # ipinfo.io API key, if available 25 | 26 | [database] 27 | model = "bolt" # Database type, currently only supports BoltDB 28 | path = "/data/speedtest-ex/db/speedtest.db" # Path to the database file 29 | 30 | [frontend] 31 | chartlist = 100 # Default to display the most recent 100 entries 32 | 33 | [revping] 34 | enable = true # Enable reverse ping test 35 | 36 | [auth] 37 | enable = false # Enable authentication 38 | username = "admin" # Username for authentication 39 | password = "password" # Password for authentication 40 | secret = "secret" # Secret key for Generating Session Cookies. You should change this to a secure value. 41 | ``` 42 | -------------------------------------------------------------------------------- /ipinfo/schema.go: -------------------------------------------------------------------------------- 1 | package ipinfo 2 | 3 | // IPHostResponse format (self-host version) 4 | type IPHostResponse struct { 5 | IP string `json:"ip"` // IP address (IPv4 or IPv6) 6 | ASN string `json:"asn"` // Autonomous System Number 7 | Domain string `json:"domain"` // Domain name 8 | ISP string `json:"isp"` // Internet Service Provider 9 | ContinentCode string `json:"continent_code"` // Continent code 10 | ContinentName string `json:"continent_name"` // Continent name 11 | CountryCode string `json:"country_code"` 12 | CountryName string `json:"country_name"` 13 | UserAgent string `json:"user_agent"` 14 | } 15 | 16 | // IPInfoResponse format (ipinfo.io version) 17 | type IPInfoResponse struct { 18 | IP string `json:"ip"` 19 | Hostname string `json:"hostname"` 20 | City string `json:"city"` 21 | Region string `json:"region"` 22 | Country string `json:"country"` 23 | Loc string `json:"loc"` 24 | Org string `json:"org"` 25 | Postal string `json:"postal"` 26 | Timezone string `json:"timezone"` 27 | } 28 | 29 | // 通用响应格式 30 | type CommonIPInfoResponse struct { 31 | IP string `json:"ip"` 32 | Org string `json:"org"` // ipinfo = org, self-host = ASN + ISP 33 | Region string `json:"region"` // ipinfo = region, self-host = nil 34 | City string `json:"city"` // ipinfo = city, self-host = nil 35 | Country string `json:"country"` // ipinfo = Country, self-host = CountryCode 36 | Continent string `json:"continent"` // ipinfo = nil, self-host = continent_name 37 | } 38 | -------------------------------------------------------------------------------- /web/getip.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "regexp" 5 | "speedtest/config" 6 | "speedtest/ipinfo" 7 | "speedtest/results" 8 | 9 | "github.com/infinite-iroha/touka" 10 | ) 11 | 12 | // 预编译的正则表达式变量 13 | var ( 14 | localIPv6Regex = regexp.MustCompile(`^::1$`) // 匹配本地 IPv6 地址 15 | linkLocalIPv6Regex = regexp.MustCompile(`^fe80:`) // 匹配链路本地 IPv6 地址 16 | localIPv4Regex = regexp.MustCompile(`^127\.`) // 匹配本地 IPv4 地址 17 | privateIPv4Regex10 = regexp.MustCompile(`^10\.`) // 匹配私有 IPv4 地址(10.0.0.0/8) 18 | privateIPv4Regex172 = regexp.MustCompile(`^172\.(1[6-9]|2\d|3[01])\.`) // 匹配私有 IPv4 地址(172.16.0.0/12) 19 | privateIPv4Regex192 = regexp.MustCompile(`^192\.168\.`) // 匹配私有 IPv4 地址(192.168.0.0/16) 20 | linkLocalIPv4Regex = regexp.MustCompile(`^169\.254\.`) // 匹配链路本地 IPv4 地址(169.254.0.0/16) 21 | cgnatIPv4Regex = regexp.MustCompile(`^100\.([6-9][0-9]|1[0-2][0-7])\.`) // 匹配 CGNAT IPv4 地址(100.64.0.0/10) 22 | unspecifiedAddressRegex = regexp.MustCompile(`^0\.0\.0\.0$`) // 匹配未指定地址(0.0.0.0) 23 | broadcastAddressRegex = regexp.MustCompile(`^255\.255\.255\.255$`) // 匹配广播地址(255.255.255.255) 24 | removeASRegexp = regexp.MustCompile(`AS\d+\s`) // 用于去除 ISP 信息中的自治系统编号 25 | ) 26 | 27 | func getIP(c *touka.Context, cfg *config.Config) { 28 | clientIP := c.ClientIP() // 获取客户端 IP 地址 29 | // clientIP := "1.1.1.1" // for debug 30 | var ret results.Result // 创建结果结构体实例 31 | ret, err := ipinfo.GetIP(clientIP, cfg) 32 | if err != nil { 33 | c.Errorf("Error getting IP info: %s", err) 34 | c.JSON(500, touka.H{"error": "Failed to get IP info"}) // 返回错误响应 35 | return 36 | } 37 | c.JSON(200, ret) // 返回 JSON 响应 38 | } 39 | -------------------------------------------------------------------------------- /web/auth.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "speedtest/config" 6 | 7 | "github.com/fenthope/sessions" 8 | "github.com/infinite-iroha/touka" 9 | ) 10 | 11 | //var sessionKey = "session_key" // for debug only (change it to a random string in production) 12 | 13 | func SessionMiddleware() touka.HandlerFunc { 14 | return func(c *touka.Context) { 15 | session := sessions.Default(c) 16 | //if session.Get("authenticated") != true && c.Request.URL.Path != "/api/login" && c.Request.URL.Path != "/login.html" && c.Request.URL.Path != "/login" && !strings.HasPrefix(c.Request.URL.Path, "/backend") { 17 | if session.Get("authenticated") != true && c.Request.URL.Path != "/api/login" && c.Request.URL.Path != "/login.html" && c.Request.URL.Path != "/login" { 18 | c.Redirect(http.StatusFound, "/login.html") 19 | c.Abort() 20 | return 21 | } /*else if session.Get("authenticated") == true { 22 | // 记录路径 23 | logInfo("passed path: %s", c.Request.URL.Path) 24 | } */ //for debug 25 | c.Next() 26 | } 27 | } 28 | 29 | func AuthLogin(c *touka.Context, cfg *config.Config) { 30 | username := c.PostForm("username") 31 | password := c.PostForm("password") 32 | // 输入验证 33 | if username == "" || password == "" { 34 | c.JSON(http.StatusBadRequest, touka.H{"error": "请提供用户名和密码"}) 35 | return 36 | } 37 | 38 | // 重新生成会话ID防止会话固定攻击 39 | session := sessions.Default(c) 40 | session.Clear() 41 | session.Save() 42 | 43 | // 这里应该验证用户名和密码 44 | if username == cfg.Auth.Username && password == cfg.Auth.Password { 45 | session := sessions.Default(c) 46 | session.Set("authenticated", true) 47 | session.Save() 48 | c.JSON(http.StatusOK, touka.H{"success": true}) 49 | } else { 50 | c.JSON(http.StatusUnauthorized, touka.H{"error": "无效的凭证"}) 51 | } 52 | } 53 | 54 | func AuthLogout(c *touka.Context) { 55 | session := sessions.Default(c) 56 | session.Clear() 57 | session.Save() 58 | c.Redirect(http.StatusFound, "/login") 59 | } 60 | -------------------------------------------------------------------------------- /docs/README_hk.md: -------------------------------------------------------------------------------- 1 | ![SpeedTest-EX Logo](https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/web/pages/favicon_inverted.png) 2 | 3 | # SpeedTest-EX 4 | 5 | 本項目係基於[speedtest-go](https://github.com/librespeed/speedtest-go)項目嘅大幅度重構。[speedtest-go](https://github.com/librespeed/speedtest-go)係使用Go語言重新實現嘅[librespeed](https://github.com/librespeed/speedtest)後端,而本項目係基於[speedtest-go](https://github.com/librespeed/speedtest-go)項目再次重構。LiberSpeed係一個開源嘅網絡測速項目,其使用PHP實現後端,而本項目則使用Go語言同Gin框架實現後端,使程序更加輕量化且易於部署。 6 | 7 | **❗ 注意**:基於網頁測速嘅原理,程序會生成無用塊供測速者下載來計算真實下行帶寬,一定程度上存在被惡意刷流量嘅風險,喺對外分享你嘅測速頁面後,請注意觀察伺服器流量使用情況,避免流量使用異常。 8 | 9 | ## 特性 10 | - 輕量化:無需額外環境,僅需下載二進制文件即可運行。(Docker鏡像亦更加輕量化) 11 | - 易於部署:內嵌前端頁面,無需額外配置即可部署。 12 | - 高效:基於Gin框架,並發處理能力強,響應速度快。 13 | 14 | ## 與speedtest-go嘅區別 15 | - **Web框架**:speedtest-go使用Chi框架,本項目使用Gin框架。 16 | - **IPinfo**:speedtest-go使用ipinfo.io API獲取IP信息,本項目兼容ipinfo.io API,亦可使用[WJQSERVER-STUDIO/ip](https://github.com/WJQSERVER-STUDIO/ip)為自托管服務提供IP信息。 17 | - **結果圖表**:本項目加入咗結果圖表,方便用戶查看測速結果。 18 | - **更加清晰嘅配置文件**:改進配置文件結構。 19 | - **前端頁面**:內嵌前端頁面,無需額外配置即可部署。(仍與liberspeed前端保持兼容性) 20 | - **重寫**:對大部分組件進行重寫與優化,使程序更加易於維護嘅同時提升部分性能。 21 | 22 | ## 部署與使用 23 | 24 | ### 使用Docker進行部署 25 | 26 | 參看[docker-cli部署SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-cli_zh-tw.md) 27 | 28 | 參看[docker-compose部署SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-compose_zh-tw.md) 29 | 30 | ### 在 OpenWrt 上部署 31 | 參看[在 OpenWrt 上運行 Speedtest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/openwrt/openwrt_hk.md) 32 | 33 | ### 配置文件 34 | 參看[配置文件說明](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/config/config_zh-tw.md) 35 | 36 | ## 截圖 37 | ![SpeedTest-EX Index Page](https://webp.wjqserver.com/speedtest-ex/index.png) 38 | 39 | ![SpeedTest-EX Chart Page](https://webp.wjqserver.com/speedtest-ex/chart.png) 40 | 41 | ## 许可证 42 | 版權 (C) 2016-2020 Federico Dossena 43 | 44 | 版權 (C) 2020 Maddie Zhan 45 | 46 | 版權 (C) 2025 WJQSERVER 47 | 48 | 本程序係自由軟件:你可以根據自由軟件基金會發布嘅GNU較寬鬆公共許可證條款重新分發同/或修改佢,許可證版本為3,或(根據你嘅選擇)任何更高版本。本程序嘅分發係希望佢能有用,但唔提供任何保證;甚至唔提供適銷性或特定用途適用性嘅隱含保證。關於更多詳細信息,請參見GNU通用公共許可證。你應該已經收到與本程序一齊提供嘅GNU較寬鬆公共許可證嘅副本。如果冇,請訪問。 49 | -------------------------------------------------------------------------------- /docs/README_zh-tw.md: -------------------------------------------------------------------------------- 1 | ![SpeedTest-EX Logo](https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/web/pages/favicon_inverted.png) 2 | 3 | # SpeedTest-EX 4 | 5 | 本專案是基於[speedtest-go](https://github.com/librespeed/speedtest-go)專案的大幅度重構。[speedtest-go](https://github.com/librespeed/speedtest-go)是使用Go語言重新實現的[librespeed](https://github.com/librespeed/speedtest)後端,而本專案是基於[speedtest-go](https://github.com/librespeed/speedtest-go)專案再次重構。LiberSpeed是一個開源的網路測速專案,其使用PHP實現後端,而本專案則使用Go語言和Gin框架實現後端,使程式更加輕量化且易於部署。 6 | 7 | **❗ 注意**:基於網頁測速的原理,程式會生成無用塊供測速者下載來計算真實下行帶寬,一定程度上存在被惡意刷流量的風險,在對外分享你的測速頁面後,請注意觀察伺服器流量使用情況,避免流量使用異常。 8 | 9 | ## 特性 10 | - 輕量化:無需額外環境,僅需下載二進制檔案即可運行。(Docker映像也更加輕量化) 11 | - 易於部署:內嵌前端頁面,無需額外配置即可部署。 12 | - 高效:基於Gin框架,並發處理能力強,響應速度快。 13 | 14 | ## 與speedtest-go的區別 15 | - **Web框架**:speedtest-go使用Chi框架,本專案使用Gin框架。 16 | - **IPinfo**:speedtest-go使用ipinfo.io API獲取IP資訊,本專案兼容ipinfo.io API,也可使用[WJQSERVER-STUDIO/ip](https://github.com/WJQSERVER-STUDIO/ip)為自托管服務提供IP資訊。 17 | - **結果圖表**:本專案加入了結果圖表,方便用戶查看測速結果。 18 | - **更加清晰的配置檔案**:改進配置檔案結構。 19 | - **前端頁面**:內嵌前端頁面,無需額外配置即可部署。(仍與liberspeed前端保持兼容性) 20 | - **重寫**:對大部分元件進行重寫與優化,使程式更加易於維護的同時提升部分性能。 21 | 22 | ## 部署與使用 23 | 24 | ### 使用Docker進行部署 25 | 26 | 參考[docker-cli配置SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-cli_zh-cn.md) 27 | 28 | 請參閱[docker-compose 設定SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-compose_zh-cn.md) 29 | 30 | ### 在 OpenWrt 上部署 31 | 參看[在 OpenWrt 上運行 Speedtest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/openwrt/openwrt_zh-tw.md) 32 | 33 | ### 配置檔案 34 | 參看[配置檔案說明](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/config/config_zh-tw.md) 35 | 36 | ## 截圖 37 | 38 | ![SpeedTest-EX Index Page](https://webp.wjqserver.com/speedtest-ex/index.png) 39 | 40 | ![SpeedTest-EX Chart Page](https://webp.wjqserver.com/speedtest-ex/chart.png) 41 | 42 | ## 许可证 43 | 版權 (C) 2016-2020 Federico Dossena 44 | 45 | 版權 (C) 2020 Maddie Zhan 46 | 47 | 版權 (C) 2025 WJQSERVER 48 | 49 | 本程式是自由軟體:您可以根據自由軟體基金會發布的GNU較寬鬆公共授權條款重新分發和/或修改它,授權版本為3,或(根據您的選擇)任何更高版本。本程式的分發是希望它能有用,但不提供任何保證;甚至不提供適銷性或特定用途適用性的隱含保證。關於更多詳細資訊,請參見GNU通用公共授權。您應該已經收到與本程式一起提供的GNU較寬鬆公共授權的副本。如果沒有,請訪問。 50 | -------------------------------------------------------------------------------- /docs/README_zh-cn.md: -------------------------------------------------------------------------------- 1 | ![SpeedTest-EX Logo](https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/web/pages/favicon_inverted.png) 2 | 3 | # SpeedTest-EX 4 | 5 | 本项目是基于[speedtest-go](https://github.com/librespeed/speedtest-go)项目的大幅度重构. 6 | [speedtest-go](https://github.com/librespeed/speedtest-go)是使用Go语言重新实现的[librespeed](https://github.com/librespeed/speedtest)后端, 而本项目是基于[speedtest-go](https://github.com/librespeed/speedtest-go)项目再次重构. 7 | LiberSpeed是一个开源的网络测速项目, 其使用php实现后端, 而本项目则使用Go语言和Gin框架实现后端, 使程序更加轻量化且易于部署. 8 | 9 | **❗ 注意**:基于网页测速的原理,程序会生成无用块供测速者下载来计算真实下行带宽,一定程度上存在被恶意刷流量的风险,在对外分享你的测速页面后,请注意观察服务器流量使用情况,避免流量使用异常。 10 | 11 | ## 特性 12 | 13 | - 轻量化: 无需额外环境, 仅需下载二进制文件即可运行.(Docker镜像也更加轻量化) 14 | - 易于部署: 内嵌前端页面, 无需额外配置即可部署. 15 | - 高效: 基于Gin框架, 并发处理能力强, 响应速度快. 16 | 17 | ## 与speedtest-go的区别 18 | 19 | - Web框架: speedtest-go使用Chi框架, 本项目使用Gin框架. 20 | - IPinfo: speedtest-go使用ipinfo.io API获取IP信息, 本项目兼容ipinfo.io API, 也可使用[WJQSERVER-STUDIO/ip](https://github.com/WJQSERVER-STUDIO/ip)为自托管服务提供IP信息. 21 | - 结果图表: 本项目加入了结果图表, 方便用户查看测速结果. 22 | - 更加清晰的配置文件: 改进配置文件结构 23 | - 前端页面: 内嵌前端页面, 无需额外配置即可部署.(仍与liberspeed前端保持兼容性) 24 | - 重写: 对大部分组件进行重写与优化, 使程序更加易于维护的同时提升部分性能. 25 | 26 | ## 部署与使用 27 | 28 | ### 使用Docker进行部署 29 | 30 | 参看[docker-cli部署SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-cli_zh-cn.md) 31 | 32 | 参看[docker-compose部署SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-compose_zh-cn.md) 33 | 34 | ### 在 OpenWrt 上部署 35 | 参看[在 OpenWrt 上运行 Speedtest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/openwrt/openwrt_zh-cn.md) 36 | 37 | ### 配置文件 38 | 39 | 参看[配置文件说明](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/config/config_zh-cn.md) 40 | 41 | ## 前端页面 42 | 43 | ![SpeedTest-EX Index Page](https://webp.wjqserver.com/speedtest-ex/index.png) 44 | 45 | ![SpeedTest-EX Chart Page](https://webp.wjqserver.com/speedtest-ex/chart.png) 46 | 47 | 48 | ## License 49 | Copyright (C) 2016-2020 Federico Dossena 50 | 51 | Copyright (C) 2020 Maddie Zhan 52 | 53 | Copyright (C) 2025 WJQSERVER 54 | 55 | This program is free software: you can redistribute it and/or modify 56 | it under the terms of the GNU Lesser General Public License as published by 57 | the Free Software Foundation, either version 3 of the License, or 58 | (at your option) any later version. 59 | 60 | This program is distributed in the hope that it will be useful, 61 | but WITHOUT ANY WARRANTY; without even the implied warranty of 62 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 63 | GNU General Public License for more details. 64 | 65 | You should have received a copy of the GNU Lesser General Public License 66 | along with this program. If not, see . 67 | -------------------------------------------------------------------------------- /ipinfo/self-host.go: -------------------------------------------------------------------------------- 1 | package ipinfo 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "speedtest/config" 7 | 8 | "github.com/WJQSERVER-STUDIO/httpc" 9 | ) 10 | 11 | /* 12 | // IPHostResponse format (self-host version) 13 | type IPHostResponse struct { 14 | IP string `json:"ip"` // IP address (IPv4 or IPv6) 15 | ASN string `json:"asn"` // Autonomous System Number 16 | Domain string `json:"domain"` // Domain name 17 | ISP string `json:"isp"` // Internet Service Provider 18 | ContinentCode string `json:"continent_code"` // Continent code 19 | ContinentName string `json:"continent_name"` // Continent name 20 | CountryCode string `json:"country_code"` 21 | CountryName string `json:"country_name"` 22 | UserAgent string `json:"user_agent"` 23 | } 24 | 25 | // 通用响应格式 26 | type CommonIPInfoResponse struct { 27 | IP string `json:"ip"` 28 | Org string `json:"org"` // ipinfo = org, self-host = ASN + ISP 29 | Rrgion string `json:"region"` // ipinfo = region, self-host = nil 30 | City string `json:"city"` // ipinfo = city, self-host = nil 31 | Country string `json:"country"` // ipinfo = Country, self-host = CountryCode 32 | Continent string `json:"continent"` // ipinfo = nil, self-host = continent_name 33 | } 34 | */ 35 | 36 | func getHostIPInfo(ip string, cfg *config.Config) (CommonIPInfoResponse, error) { 37 | selfhostApi := cfg.IPinfo.IPinfoURL + "/api/ip-lookup?ip=" + ip 38 | // 使用req库发送请求并使用chrome的TLS指纹 39 | /* 40 | client := req.C(). 41 | SetTLSFingerprintChrome(). 42 | ImpersonateChrome() 43 | 44 | resp, err := client.R().Get(selfhostApi) 45 | */ 46 | resp, err := httpc.New().NewRequestBuilder("GET", selfhostApi).NoDefaultHeaders().SetHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36").Execute() 47 | if err != nil { 48 | return CommonIPInfoResponse{}, err 49 | } 50 | defer resp.Body.Close() 51 | 52 | // 检查非200状态码 53 | if resp.StatusCode != 200 { 54 | return CommonIPInfoResponse{}, err 55 | } 56 | 57 | // 读取body 58 | bodyBytes, err := io.ReadAll(resp.Body) 59 | if err != nil { 60 | 61 | return CommonIPInfoResponse{}, err 62 | } 63 | 64 | // 使用IPHostResponse结构体解析json数据 65 | var ipHost IPHostResponse 66 | err = json.Unmarshal(bodyBytes, &ipHost) 67 | if err != nil { 68 | return CommonIPInfoResponse{}, err 69 | } 70 | 71 | // 通过现有IPHostResponse结构体制作CommonIPInfoResponse结构体 72 | commonIPInfo := CommonIPInfoResponse{ 73 | IP: ip, 74 | Org: ipHost.ISP + " " + ipHost.ASN, 75 | Region: "", 76 | City: "", 77 | Country: ipHost.CountryCode, 78 | Continent: ipHost.ContinentCode, 79 | } 80 | return commonIPInfo, nil 81 | } 82 | -------------------------------------------------------------------------------- /cli/go.sum: -------------------------------------------------------------------------------- 1 | github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= 2 | github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= 3 | github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= 4 | github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 8 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 9 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 10 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 11 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 12 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 13 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 14 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 15 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 16 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 20 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 21 | github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= 22 | github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= 23 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 24 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 25 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 27 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 28 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 29 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /ipinfo/ipinfo.go: -------------------------------------------------------------------------------- 1 | package ipinfo 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "speedtest/config" 7 | 8 | "github.com/WJQSERVER-STUDIO/httpc" 9 | ) 10 | 11 | /* 12 | // IPInfoResponse format (ipinfo.io version) 13 | type IPInfoResponse struct { 14 | IP string `json:"ip"` 15 | Hostname string `json:"hostname"` 16 | City string `json:"city"` 17 | Region string `json:"region"` 18 | Country string `json:"country"` 19 | Loc string `json:"loc"` 20 | Org string `json:"org"` 21 | Postal string `json:"postal"` 22 | Timezone string `json:"timezone"` 23 | } 24 | 25 | // 通用响应格式 26 | type CommonIPInfoResponse struct { 27 | IP string `json:"ip"` 28 | Org string `json:"org"` // ipinfo = org, self-host = ASN + ISP 29 | Region string `json:"region"` // ipinfo = region, self-host = nil 30 | City string `json:"city"` // ipinfo = city, self-host = nil 31 | Country string `json:"country"` // ipinfo = Country, self-host = CountryCode 32 | Continent string `json:"continent"` // ipinfo = nil, self-host = continent_name 33 | } 34 | */ 35 | 36 | func getIPInfoURL(ip string, apiKey string) string { 37 | if apiKey == "" { 38 | return "https://ipinfo.io/" + ip + "/json" 39 | } else { 40 | return "https://ipinfo.io/" + ip + "/json?token=" + apiKey 41 | } 42 | } 43 | 44 | func getIPInfoIO(ip string, cfg *config.Config) (CommonIPInfoResponse, error) { 45 | selfhostApi := getIPInfoURL(ip, cfg.IPinfo.IPinfoKey) 46 | // 使用req库发送请求并使用chrome的TLS指纹 47 | /* 48 | client := req.C(). 49 | SetTLSFingerprintChrome(). 50 | ImpersonateChrome() 51 | 52 | resp, err := client.R().Get(selfhostApi) 53 | if err != nil { 54 | return CommonIPInfoResponse{}, err 55 | } 56 | defer resp.Body.Close() 57 | */ 58 | resp, err := httpc.New().NewRequestBuilder("GET", selfhostApi).NoDefaultHeaders().SetHeader("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36").Execute() 59 | if err != nil { 60 | return CommonIPInfoResponse{}, err 61 | } 62 | defer resp.Body.Close() 63 | 64 | // 处理非200状态码 65 | if resp.StatusCode != 200 { 66 | return CommonIPInfoResponse{}, err 67 | } 68 | 69 | // 读取body 70 | bodyBytes, err := io.ReadAll(resp.Body) 71 | if err != nil { 72 | return CommonIPInfoResponse{}, err 73 | } 74 | 75 | // 使用IPInfoResponse结构体解析json数据 76 | var ipHost IPInfoResponse 77 | err = json.Unmarshal(bodyBytes, &ipHost) 78 | if err != nil { 79 | return CommonIPInfoResponse{}, err 80 | } 81 | 82 | // 通过现有IPHostResponse结构体制作CommonIPInfoResponse结构体 83 | commonIPInfo := CommonIPInfoResponse{ 84 | IP: ip, 85 | Org: ipHost.Org, 86 | Region: ipHost.Region, 87 | City: ipHost.City, 88 | Continent: "", 89 | } 90 | return commonIPInfo, nil 91 | 92 | } 93 | -------------------------------------------------------------------------------- /results/telemetry.go: -------------------------------------------------------------------------------- 1 | package results 2 | 3 | import ( 4 | _ "embed" 5 | "math/rand" 6 | "net" 7 | "net/http" 8 | "time" 9 | 10 | "speedtest/config" 11 | "speedtest/database" 12 | "speedtest/database/schema" 13 | 14 | "github.com/infinite-iroha/touka" 15 | "github.com/oklog/ulid/v2" 16 | ) 17 | 18 | type Result struct { 19 | ProcessedString string `json:"processedString"` 20 | RawISPInfo CommonIPInfoResponse `json:"rawIspInfo"` 21 | } 22 | 23 | // IPInfoResponse format (self-host version) 24 | type IPInfoResponse struct { 25 | IP string `json:"ip"` // IP address (IPv4 or IPv6) 26 | ASN string `json:"asn"` // Autonomous System Number 27 | Domain string `json:"domain"` // Domain name 28 | ISP string `json:"isp"` // Internet Service Provider 29 | ContinentCode string `json:"continent_code"` // Continent code 30 | ContinentName string `json:"continent_name"` // Continent name 31 | CountryCode string `json:"country_code"` 32 | CountryName string `json:"country_name"` 33 | UserAgent string `json:"user_agent"` 34 | } 35 | 36 | // 通用响应格式 37 | type CommonIPInfoResponse struct { 38 | IP string `json:"ip"` 39 | Org string `json:"org"` // ipinfo = org, self-host = ASN + ISP 40 | Region string `json:"region"` // ipinfo = region, self-host = nil 41 | City string `json:"city"` // ipinfo = city, self-host = nil 42 | Country string `json:"country"` // ipinfo = Country, self-host = CountryCode 43 | Continent string `json:"continent"` // ipinfo = nil, self-host = continent_name 44 | } 45 | 46 | func Record(c *touka.Context, cfg *config.Config) { 47 | if cfg.Database.Model == "none" { 48 | c.String(http.StatusOK, "Telemetry is disabled") 49 | return 50 | } 51 | 52 | ipAddr, _, _ := net.SplitHostPort(c.Request.RemoteAddr) 53 | userAgent := c.Request.UserAgent() 54 | language := c.Request.Header.Get("Accept-Language") 55 | 56 | ispInfo := c.PostForm("ispinfo") 57 | //logInfo("debug > result > ispInfo: %s", ispInfo) 58 | download := c.PostForm("dl") 59 | upload := c.PostForm("ul") 60 | ping := c.PostForm("ping") 61 | jitter := c.PostForm("jitter") 62 | logs := c.PostForm("log") 63 | extra := c.PostForm("extra") 64 | 65 | var record schema.TelemetryData 66 | record.IPAddress = ipAddr 67 | if ispInfo == "" { 68 | record.ISPInfo = "{}" 69 | } else { 70 | record.ISPInfo = ispInfo 71 | } 72 | record.Extra = extra 73 | record.UserAgent = userAgent 74 | record.Language = language 75 | record.Download = download 76 | record.Upload = upload 77 | record.Ping = ping 78 | record.Jitter = jitter 79 | record.Log = logs 80 | 81 | t := time.Now() 82 | entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0) 83 | uuid := ulid.MustNew(ulid.Timestamp(t), entropy) 84 | record.UUID = uuid.String() 85 | 86 | err := database.DB.SaveTelemetry(&record) 87 | if err != nil { 88 | c.Errorf("Error saving telemetry data: %s", err) 89 | c.String(http.StatusInternalServerError, "Internal Server Error") 90 | return 91 | } 92 | 93 | c.String(http.StatusOK, "%s", "id "+uuid.String()) 94 | } 95 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/BurntSushi/toml" 7 | ) 8 | 9 | type Config struct { 10 | Server ServerConfig 11 | Speedtest SpeedtestConfig 12 | Log LogConfig 13 | IPinfo IPinfoConfig 14 | Database DatabaseConfig 15 | Frontend FrontendConfig 16 | RevPing RevPingConfig 17 | Auth AuthConfig 18 | } 19 | 20 | /* 21 | [server] 22 | host = "" 23 | port = 8989 24 | basePath = "" 25 | */ 26 | type ServerConfig struct { 27 | Host string `toml:"host"` 28 | Port int `toml:"port"` 29 | BasePath string `toml:"basePath"` 30 | } 31 | 32 | /* 33 | [Speedtest] 34 | downDataChunkSize = 4 #mb 35 | downDataChunkCount = 4 36 | downloadGenStream = true 37 | */ 38 | 39 | type SpeedtestConfig struct { 40 | DownDataChunkSize int `toml:"downDataChunkSize"` // mb 41 | DownDataChunkCount int `toml:"downDataChunkCount"` // 下载数据块数量 42 | DownloadGenStream bool `toml:"downloadGenStream"` // 是否使用流 43 | } 44 | 45 | /* 46 | [log] 47 | logFilePath = "/data/speedtest-go/log/speedtest-go.log" 48 | maxLogSize = 5 # MB 49 | */ 50 | type LogConfig struct { 51 | LogFilePath string `toml:"logFilePath"` 52 | MaxLogSize int `toml:"maxLogSize"` // MB 53 | } 54 | 55 | /* 56 | [ipinfo] 57 | model = "ip" # ip(self-hosted) or ipinfo(ipinfo.io) todo 58 | ipinfo_url = "https://ip.1888866.xyz" #self-hosted only 59 | ipinfo_api_key = "" # ipinfo.io API key 60 | */ 61 | type IPinfoConfig struct { 62 | Model string `toml:"model"` 63 | IPinfoURL string `toml:"ipinfo_url"` 64 | IPinfoKey string `toml:"ipinfo_api_key"` 65 | } 66 | 67 | /* 68 | [database] 69 | model = "bolt" 70 | path = "/data/speedtest-go/db/speedtest.db" 71 | */ 72 | type DatabaseConfig struct { 73 | Model string `toml:"model"` 74 | Path string `toml:"path"` // bolt file path 75 | } 76 | 77 | /* 78 | [frontend] 79 | chartlist = 100 # 默认显示最近100条数据 80 | */ 81 | type FrontendConfig struct { 82 | Chartlist int `toml:"chartlist"` 83 | } 84 | 85 | /* 86 | [revping] 87 | enable = true # 是否开启反向延迟测试 88 | */ 89 | type RevPingConfig struct { 90 | Enable bool `toml:"enable"` 91 | } 92 | 93 | /* 94 | [auth] 95 | enable = false # 是否开启鉴权 96 | username = "admin" # 鉴权用户名 97 | password = "password" # 鉴权密码 98 | secret = "secret" # 加密密钥, 用于生产session cookie, 请务必修改 99 | */ 100 | type AuthConfig struct { 101 | Enable bool `toml:"enable"` 102 | Username string `toml:"username"` 103 | Password string `toml:"password"` 104 | Secret string `toml:"secret"` 105 | } 106 | 107 | // LoadConfig 从 TOML 配置文件加载配置 108 | func LoadConfig(filePath string) (*Config, error) { 109 | var config Config 110 | if _, err := toml.DecodeFile(filePath, &config); err != nil { 111 | return nil, err 112 | } 113 | return &config, nil 114 | } 115 | 116 | // SaveConfig 保存配置到 TOML 配置文件 117 | func SaveConfig(filePath string, config *Config) error { 118 | file, err := os.Create(filePath) 119 | if err != nil { 120 | return err 121 | } 122 | defer file.Close() 123 | 124 | encoder := toml.NewEncoder(file) 125 | if err := encoder.Encode(config); err != nil { 126 | return err 127 | } 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /web/pages/script.js: -------------------------------------------------------------------------------- 1 | function startStop() { 2 | if (s.getState() == 3) { 3 | s.abort(); 4 | I("startStopBtn").className = ""; 5 | I("startStopBtn").textContent = "开始"; 6 | initUI(); 7 | } else { 8 | I("startStopBtn").className = "running"; 9 | I("startStopBtn").textContent = "停止"; 10 | s.onupdate = function (data) { 11 | I("ip").textContent = data.clientIp; 12 | I("dlText").textContent = (data.testState == 1 && data.dlStatus == 0) ? "..." : data.dlStatus; 13 | I("ulText").textContent = (data.testState == 3 && data.ulStatus == 0) ? "..." : data.ulStatus; 14 | I("pingText").textContent = data.pingStatus; 15 | I("jitText").textContent = data.jitterStatus; 16 | }; 17 | s.onend = function (aborted) { 18 | I("startStopBtn").className = ""; 19 | I("startStopBtn").textContent = "开始"; 20 | }; 21 | s.start(); 22 | } 23 | } 24 | 25 | function initUI() { 26 | I("dlText").textContent = ""; 27 | I("ulText").textContent = ""; 28 | I("pingText").textContent = ""; 29 | I("jitText").textContent = ""; 30 | I("ip").textContent = ""; 31 | I("startStopBtn").textContent = "开始"; 32 | } 33 | 34 | setTimeout(function () { initUI(); }, 100); 35 | 36 | let socket; 37 | let timeoutCount = 0; 38 | 39 | function setupWebSocket() { 40 | const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; 41 | socket = new WebSocket(`${protocol}://${window.location.host}/ws`); 42 | socket.onopen = function() { 43 | console.log('WebSocket 连接已建立'); 44 | // 可选:可以发送初始消息以开始 ping 过程 45 | }; 46 | 47 | socket.onmessage = function(event) { 48 | const data = JSON.parse(event.data); 49 | const pingValueDiv = document.getElementById('pingValue'); 50 | 51 | if (data.success) { 52 | pingValueDiv.textContent = data.rtt.toFixed(3); // 更新当前 Ping 值 53 | timeoutCount = 0; // 重置超时计数器 54 | } else { 55 | pingValueDiv.textContent = '-'; // 重置 Ping 值 56 | if (data.error === "timeout" || data.error === "revping-not-online") { 57 | timeoutCount++; // 超时计数器加一 58 | if (timeoutCount >= 5) { // 超时计数器达到5次,停止 WebSocket 连接 59 | console.log("RevPing: 检测到 5 次超时;停止 WebSocket 连接。后端无法使用 ICMP Echo 接收来自您的 IP 的回复。/ RevPing 功能已禁用。"); 60 | socket.close(); 61 | } 62 | } else { 63 | timeoutCount = 0; // 重置超时计数器 64 | } 65 | } 66 | }; 67 | 68 | socket.onerror = function(error) { 69 | console.error('WebSocket 错误:', error); 70 | }; 71 | 72 | socket.onclose = function() { 73 | console.log('WebSocket 连接已关闭'); 74 | }; 75 | } 76 | 77 | function fetchVersion() { 78 | fetch('/api/version') 79 | .then(response => response.json()) 80 | .then(data => { 81 | document.getElementById('versionBadge').textContent = data.Version; 82 | }) 83 | .catch(error => { 84 | console.error('获取版本失败:', error); 85 | }); 86 | } 87 | 88 | document.addEventListener('DOMContentLoaded', fetchVersion); 89 | 90 | // 网页加载时开始 WebSocket 连接 91 | window.onload = setupWebSocket; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![SpeedTest-EX Logo](https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/web/pages/favicon_inverted.png) 2 | 3 | # SpeedTest-EX 4 | 5 | SpeedTest-EX是一个基于[Touka框架](https://github.com/infinite-iroha/touka)的高性能网页测速程序, 高性能的同时带有身份验证 6 | 7 | zh-cn | [en](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/README_en.md) | [zh-tw](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/README_zh-tw.md) | [cantonese|hk](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/README_hk.md) 8 | 9 | [SpeedTest-EX 讨论群组](https://t.me/speedtestex) 10 | 11 | **❗ 注意**:基于网页测速的原理,程序会生成无用块供测速者下载来计算真实下行带宽,一定程度上存在被恶意刷流量的风险,在对外分享你的测速页面后,请注意观察服务器流量使用情况,避免流量使用异常。推荐开启身份验证功能 12 | 13 | ## 特性 14 | 15 | - 轻量化: 无需额外环境, 仅需下载二进制文件即可运行.(Docker镜像也更加轻量化) 16 | - 易于部署: 内嵌前端页面, 无需额外配置即可部署. 17 | - 高效可控: 基于[Touka框架](https://github.com/infinite-iroha/touka), 并发处理能力强, 响应速度快. 也可以针对项目需求, 对框架进行自定义功能增加. 18 | 19 | ## 与speedtest-go的区别 20 | 21 | - Web框架: speedtest-go使用Chi框架, 本项目使用自有[Touka框架](https://github.com/infinite-iroha/touka). 22 | - IPinfo: speedtest-go使用ipinfo.io API获取IP信息, 本项目兼容ipinfo.io API, 也可使用[WJQSERVER-STUDIO/ip](https://github.com/WJQSERVER-STUDIO/ip)为自托管服务提供IP信息. 23 | - 结果图表: 本项目加入了结果图表, 方便用户查看测速结果. 24 | - 更加清晰的配置文件: 改进配置文件结构 25 | - 前端页面: 内嵌前端页面, 无需额外配置即可部署.(仍与liberspeed前端保持兼容性) 26 | - 重写: 对大部分组件进行重写与优化, 使程序更加易于维护的同时提升部分性能. 27 | 28 | ## 部署与使用 29 | 30 | ### 使用Docker进行部署 31 | 32 | 参看[docker-cli部署SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-cli_zh-cn.md) 33 | 34 | 参看[docker-compose部署SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-compose_zh-cn.md) 35 | 36 | ### 在 OpenWrt 上部署 37 | 38 | 参看[在 OpenWrt 上运行 Speedtest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/openwrt/openwrt_zh-cn.md) 39 | 40 | ### 配置文件 41 | 42 | 参看[配置文件说明](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/config/config_zh-cn.md) 43 | 44 | ## 前端页面 45 | 46 | ![SpeedTest-EX Index Page](https://webp.wjqserver.com/speedtest-ex/index.png) 47 | 48 | ![SpeedTest-EX Chart Page](https://webp.wjqserver.com/speedtest-ex/chart.png) 49 | 50 | ## 项目相关背景 51 | 52 | 本项目是基于[speedtest-go](https://github.com/librespeed/speedtest-go)项目的大幅度重构. 53 | [speedtest-go](https://github.com/librespeed/speedtest-go)是使用Go语言重新实现的[librespeed](https://github.com/librespeed/speedtest)后端, 而本项目是基于[speedtest-go](https://github.com/librespeed/speedtest-go)项目再次重构. 54 | LiberSpeed是一个开源的网络测速项目, 其使用php实现后端, 而本项目则使用Go语言和Gin框架实现后端, 使程序更加轻量化且易于部署. 55 | 56 | ## License 57 | Copyright (C) 2016-2020 Federico Dossena 58 | 59 | Copyright (C) 2020 Maddie Zhan 60 | 61 | Copyright (C) 2025 WJQSERVER 62 | 63 | This program is free software: you can redistribute it and/or modify 64 | it under the terms of the GNU Lesser General Public License as published by 65 | the Free Software Foundation, either version 3 of the License, or 66 | (at your option) any later version. 67 | 68 | This program is distributed in the hope that it will be useful, 69 | but WITHOUT ANY WARRANTY; without even the implied warranty of 70 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 71 | GNU General Public License for more details. 72 | 73 | You should have received a copy of the GNU Lesser General Public License 74 | along with this program. If not, see . 75 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - 'main' 8 | paths: 9 | - 'VERSION' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | goos: [linux] 17 | goarch: [amd64, arm64] 18 | env: 19 | OUTPUT_BINARY: speedtest-ex 20 | GO_VERSION: 1.24 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Load VERSION 25 | run: | 26 | if [ -f VERSION ]; then 27 | echo "VERSION=$(cat VERSION)" >> $GITHUB_ENV 28 | else 29 | echo "VERSION file not found!" && exit 1 30 | fi 31 | - name: Set up Go 32 | uses: actions/setup-go@v3 33 | with: 34 | go-version: ${{ env.GO_VERSION }} 35 | - name: Build 36 | env: 37 | GOOS: ${{ matrix.goos }} 38 | GOARCH: ${{ matrix.goarch }} 39 | run: | 40 | CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${{ env.VERSION }}" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} ./main.go 41 | - name: Package 42 | env: 43 | GOOS: ${{ matrix.goos }} 44 | GOARCH: ${{ matrix.goarch }} 45 | run: | 46 | tar -czvf ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}}.tar.gz ./${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} 47 | - name: Upload to GitHub Artifacts 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} 51 | path: | 52 | ./${{ env.OUTPUT_BINARY }}* 53 | - name: 上传至Release 54 | id: create_release 55 | uses: ncipollo/release-action@v1 56 | with: 57 | name: ${{ env.VERSION }} 58 | artifacts: ./${{ env.OUTPUT_BINARY }}* 59 | token: ${{ secrets.GITHUB_TOKEN }} 60 | tag: ${{ env.VERSION }} 61 | allowUpdates: true 62 | env: 63 | export PATH: $PATH:/usr/local/go/bin 64 | docker: 65 | runs-on: ubuntu-latest 66 | # 等待build完成 67 | needs: build 68 | env: 69 | IMAGE_NAME: wjqserver/speedtest-ex # 定义镜像名称变量 70 | DOCKERFILE: docker/dockerfile/release/Dockerfile # 定义 Dockerfile 路径变量 71 | #DOCKERFILE: Dockerfile # 定义 Dockerfile 路径变量 72 | 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | - name: Load VERSION 77 | run: | 78 | if [ -f VERSION ]; then 79 | echo "VERSION=$(cat VERSION)" >> $GITHUB_ENV 80 | else 81 | echo "VERSION file not found!" && exit 1 82 | fi 83 | 84 | - name: Set up QEMU 85 | uses: docker/setup-qemu-action@v3 86 | 87 | - name: Set up Docker Buildx 88 | uses: docker/setup-buildx-action@v3 89 | 90 | - name: Login to Docker Hub 91 | uses: docker/login-action@v3 92 | with: 93 | username: ${{ secrets.DOCKERHUB_USERNAME }} 94 | password: ${{ secrets.DOCKERHUB_TOKEN }} 95 | 96 | - name: 构建镜像 97 | uses: docker/build-push-action@v6 98 | with: 99 | file: ./${{ env.DOCKERFILE }} 100 | platforms: linux/amd64,linux/arm64 101 | push: true 102 | tags: | 103 | ${{ env.IMAGE_NAME }}:${{ env.VERSION }} 104 | ${{ env.IMAGE_NAME }}:latest 105 | -------------------------------------------------------------------------------- /database/bolt/bolt.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "time" 7 | 8 | "speedtest/database/schema" 9 | 10 | "go.etcd.io/bbolt" 11 | ) 12 | 13 | const ( 14 | // 数据存储的桶名称 15 | dataBucketName = `speedtest-ex` 16 | ) 17 | 18 | type Storage struct { 19 | db *bbolt.DB 20 | } 21 | 22 | // OpenDatabase 打开一个 BoltDB 数据库 23 | func OpenDatabase(dbFilePath string) *Storage { 24 | db, err := bbolt.Open(dbFilePath, 0666, nil) 25 | if err != nil { 26 | panic(err) // 直接终止程序,确保问题被及时发现 27 | } 28 | return &Storage{db: db} 29 | } 30 | 31 | // SaveTelemetry 插入一条 TelemetryData 数据记录 32 | func (s *Storage) SaveTelemetry(data *schema.TelemetryData) error { 33 | return s.db.Update(func(tx *bbolt.Tx) error { 34 | // 设置时间戳 35 | data.Timestamp = time.Now() 36 | 37 | // 序列化为 JSON 格式 38 | dataBytes, err := json.Marshal(data) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | // 创建或获取存储桶 44 | bucket, err := tx.CreateBucketIfNotExists([]byte(dataBucketName)) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | // 根据 UUID 存储数据 50 | return bucket.Put([]byte(data.UUID), dataBytes) 51 | }) 52 | } 53 | 54 | // GetTelemetryByUUID 根据 UUID 获取单条 TelemetryData 数据 55 | func (s *Storage) GetTelemetryByUUID(id string) (*schema.TelemetryData, error) { 56 | var telemetry schema.TelemetryData 57 | 58 | err := s.db.View(func(tx *bbolt.Tx) error { 59 | bucket := tx.Bucket([]byte(dataBucketName)) 60 | if bucket == nil { 61 | return errors.New("storage bucket does not exist") 62 | } 63 | 64 | // 获取数据 65 | dataBytes := bucket.Get([]byte(id)) 66 | if dataBytes == nil { 67 | return errors.New("record not found") 68 | } 69 | 70 | // 反序列化 JSON 数据 71 | return json.Unmarshal(dataBytes, &telemetry) 72 | }) 73 | 74 | return &telemetry, err 75 | } 76 | 77 | // GetLastNRecords 获取最新的 N 条 TelemetryData 数据 78 | func (s *Storage) GetLastNRecords(limit int) ([]schema.TelemetryData, error) { 79 | var records []schema.TelemetryData 80 | 81 | err := s.db.View(func(tx *bbolt.Tx) error { 82 | bucket := tx.Bucket([]byte(dataBucketName)) 83 | if bucket == nil { 84 | return errors.New("storage bucket does not exist") 85 | } 86 | 87 | cursor := bucket.Cursor() 88 | _, dataBytes := cursor.Last() 89 | 90 | for len(records) < limit { 91 | if dataBytes == nil { 92 | break 93 | } 94 | 95 | var record schema.TelemetryData 96 | if err := json.Unmarshal(dataBytes, &record); err != nil { 97 | return err 98 | } 99 | 100 | records = append(records, record) 101 | _, dataBytes = cursor.Prev() 102 | } 103 | 104 | return nil 105 | }) 106 | 107 | //logInfo("Fetched %d records from storage", len(records)) 108 | return records, err 109 | } 110 | 111 | // GetAllTelemetry 获取所有 TelemetryData 数据 (仅用于调试) 112 | func (s *Storage) GetAllTelemetry() ([]schema.TelemetryData, error) { 113 | var records []schema.TelemetryData 114 | 115 | err := s.db.View(func(tx *bbolt.Tx) error { 116 | bucket := tx.Bucket([]byte(dataBucketName)) 117 | if bucket == nil { 118 | return errors.New("storage bucket does not exist") 119 | } 120 | 121 | cursor := bucket.Cursor() 122 | for key, value := cursor.First(); key != nil; key, value = cursor.Next() { 123 | var record schema.TelemetryData 124 | if err := json.Unmarshal(value, &record); err != nil { 125 | return err 126 | } 127 | records = append(records, record) 128 | } 129 | 130 | return nil 131 | }) 132 | 133 | return records, err 134 | } 135 | -------------------------------------------------------------------------------- /.github/workflows/dev-build.yml: -------------------------------------------------------------------------------- 1 | name: Dev Build 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - 'dev' 8 | paths: 9 | - 'DEV-VERSION' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | goos: [linux] 17 | goarch: [amd64, arm64] 18 | env: 19 | OUTPUT_BINARY: speedtest-ex 20 | GO_VERSION: 1.24 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | ref: dev 26 | 27 | - name: Load VERSION 28 | run: | 29 | if [ -f DEV-VERSION ]; then 30 | echo "VERSION=$(cat DEV-VERSION)" >> $GITHUB_ENV 31 | else 32 | echo "DEV-VERSION file not found!" && exit 1 33 | fi 34 | 35 | - name: Print VERSION 36 | run: | 37 | echo "VERSION=${{ env.VERSION }}" 38 | 39 | - name: Set up Go 40 | uses: actions/setup-go@v3 41 | with: 42 | go-version: ${{ env.GO_VERSION }} 43 | - name: Build 44 | env: 45 | GOOS: ${{ matrix.goos }} 46 | GOARCH: ${{ matrix.goarch }} 47 | 48 | run: | 49 | CGO_ENABLED=0 go build -ldflags "-X main.version=${{ env.VERSION }}" -o ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} ./main.go 50 | - name: Package 51 | env: 52 | GOOS: ${{ matrix.goos }} 53 | GOARCH: ${{ matrix.goarch }} 54 | run: | 55 | tar -czvf ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}}.tar.gz ./${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} 56 | - name: Upload to GitHub Artifacts 57 | uses: actions/upload-artifact@v4 58 | with: 59 | name: ${{ env.OUTPUT_BINARY }}-${{matrix.goos}}-${{matrix.goarch}} 60 | path: | 61 | ./${{ env.OUTPUT_BINARY }}* 62 | - name: 上传至Release 63 | id: create_release 64 | uses: ncipollo/release-action@v1 65 | with: 66 | name: ${{ env.VERSION }} 67 | artifacts: ./${{ env.OUTPUT_BINARY }}* 68 | token: ${{ secrets.GITHUB_TOKEN }} 69 | tag: ${{ env.VERSION }} 70 | allowUpdates: true 71 | prerelease: true 72 | env: 73 | export PATH: $PATH:/usr/local/go/bin 74 | docker: 75 | runs-on: ubuntu-latest 76 | # 等待build完成后再构建镜像 77 | needs: build 78 | env: 79 | IMAGE_NAME: wjqserver/speedtest-ex # 定义镜像名称变量 80 | DOCKERFILE: docker/dockerfile/dev/Dockerfile # 定义 Dockerfile 路径变量 81 | #DOCKERFILE: Dockerfile # 定义 Dockerfile 路径变量 82 | 83 | steps: 84 | - name: Checkout 85 | uses: actions/checkout@v4 86 | - name: Load VERSION 87 | run: | 88 | if [ -f DEV-VERSION ]; then 89 | echo "VERSION=$(cat DEV-VERSION)" >> $GITHUB_ENV 90 | else 91 | echo "DEV-VERSION file not found!" && exit 1 92 | fi 93 | 94 | - name: Set up QEMU 95 | uses: docker/setup-qemu-action@v3 96 | 97 | - name: Set up Docker Buildx 98 | uses: docker/setup-buildx-action@v3 99 | 100 | - name: Login to Docker Hub 101 | uses: docker/login-action@v3 102 | with: 103 | username: ${{ secrets.DOCKERHUB_USERNAME }} 104 | password: ${{ secrets.DOCKERHUB_TOKEN }} 105 | 106 | - name: 构建镜像 107 | uses: docker/build-push-action@v6 108 | with: 109 | file: ./${{ env.DOCKERFILE }} 110 | platforms: linux/amd64,linux/arm64 111 | push: true 112 | tags: | 113 | ${{ env.IMAGE_NAME }}:${{ env.VERSION }} 114 | ${{ env.IMAGE_NAME }}:dev 115 | -------------------------------------------------------------------------------- /docs/README_en.md: -------------------------------------------------------------------------------- 1 | ![SpeedTest-EX Logo](https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/web/pages/favicon_inverted.png) 2 | 3 | # SpeedTest-EX 4 | 5 | This project is a significant refactor of the [speedtest-go](https://github.com/librespeed/speedtest-go) project. [speedtest-go](https://github.com/librespeed/speedtest-go) is a Go language reimplementation of the backend for [librespeed](https://github.com/librespeed/speedtest), while this project is a further refactor based on [speedtest-go](https://github.com/librespeed/speedtest-go). LiberSpeed is an open-source network speed testing project that uses PHP for its backend, whereas this project uses Go and the Gin framework for its backend, making the program more lightweight and easier to deploy. 6 | 7 | **❗ Note**: Based on the principle of web speed testing, the program generates useless blocks for testers to download to calculate the actual downstream bandwidth, which carries a risk of being maliciously flooded with traffic. After sharing your speed test page externally, please monitor the server's traffic usage to avoid abnormal traffic usage. 8 | 9 | ## Features 10 | - **Lightweight**: No additional environment required; just download the binary file to run. (Docker images are also more lightweight) 11 | - **Easy to deploy**: Embedded frontend page, no additional configuration needed for deployment. 12 | - **Efficient**: Based on the Gin framework, with strong concurrency handling capabilities and fast response times. 13 | 14 | ## Differences from speedtest-go 15 | - **Web Framework**: speedtest-go uses the Chi framework, while this project uses the Gin framework. 16 | - **IPinfo**: speedtest-go uses the ipinfo.io API to obtain IP information; this project is compatible with the ipinfo.io API and can also use [WJQSERVER-STUDIO/ip](https://github.com/WJQSERVER-STUDIO/ip) to provide IP information for self-hosted services. 17 | - **Result Charts**: This project includes result charts for easier viewing of speed test results. 18 | - **Clearer Configuration Files**: Improved configuration file structure. 19 | - **Frontend Page**: Embedded frontend page, no additional configuration needed for deployment. (Still maintains compatibility with the liberspeed frontend) 20 | - **Rewritten**: Most components have been rewritten and optimized, making the program easier to maintain while improving some performance. 21 | 22 | ## Deployment and Usage 23 | ### Deploy with Docker 24 | Refer to [docker-cli deployment of SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-cli_en.md) 25 | Refer to [docker-compose deployment of SpeedTest-EX](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/docker/docker-compose_en.md) 26 | 27 | ### Deploy Speedtest-EX on OpenWrt 28 | Refer to [Running Speedtest-EX on OpenWrt](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/openwrt/openwrt_en.md) 29 | 30 | ### Configuration File 31 | Refer to [configuration file description](https://github.com/WJQSERVER/speedtest-ex/blob/main/docs/config/config_en.md) 32 | 33 | ## Frontend Page 34 | 35 | ![SpeedTest-EX Index Page](https://webp.wjqserver.com/speedtest-ex/index.png) 36 | 37 | ![SpeedTest-EX Chart Page](https://webp.wjqserver.com/speedtest-ex/chart.png) 38 | 39 | ## License 40 | Copyright (C) 2016-2020 Federico Dossena 41 | 42 | Copyright (C) 2020 Maddie Zhan 43 | 44 | Copyright (C) 2025 WJQSERVER 45 | 46 | This program is free software: you can redistribute it and/or modify 47 | it under the terms of the GNU Lesser General Public License as published by 48 | the Free Software Foundation, either version 3 of the License, or 49 | (at your option) any later version. 50 | 51 | This program is distributed in the hope that it will be useful, 52 | but WITHOUT ANY WARRANTY; without even the implied warranty of 53 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 54 | GNU General Public License for more details. 55 | 56 | You should have received a copy of the GNU Lesser General Public License 57 | along with this program. If not, see . 58 | -------------------------------------------------------------------------------- /web/route.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "regexp" 7 | "speedtest/config" 8 | "speedtest/database" 9 | "speedtest/results" 10 | "time" 11 | 12 | "github.com/fenthope/compress" 13 | "github.com/fenthope/cors" 14 | "github.com/fenthope/reco" 15 | "github.com/fenthope/record" 16 | "github.com/fenthope/sessions" 17 | "github.com/fenthope/sessions/cookie" 18 | 19 | "github.com/infinite-iroha/touka" 20 | ) 21 | 22 | var pagesPathRegex = regexp.MustCompile(`^[\w/]+$`) 23 | 24 | // ListenAndServe 启动HTTP服务器并设置路由处理程序 25 | func ListenAndServe(cfg *config.Config, version string) error { 26 | router := touka.New() 27 | router.Use(touka.Recovery()) 28 | router.Use(record.Middleware()) 29 | router.Use(compress.Compression(compress.DefaultCompressionConfig())) 30 | var ( 31 | logPath string 32 | logSize int 33 | ) 34 | if cfg.Log.LogFilePath != "" { 35 | logPath = cfg.Log.LogFilePath 36 | } else { 37 | logPath = "speedtest-ex.log" 38 | } 39 | if cfg.Log.MaxLogSize != 0 { 40 | logSize = cfg.Log.MaxLogSize 41 | } else { 42 | logSize = 5 43 | } 44 | 45 | router.SetLoggerCfg(reco.Config{ 46 | Level: reco.LevelInfo, 47 | Mode: reco.ModeText, 48 | TimeFormat: time.RFC3339, 49 | FilePath: logPath, 50 | EnableRotation: true, 51 | MaxFileSizeMB: int64(logSize), 52 | MaxBackups: 5, 53 | CompressBackups: true, 54 | Async: true, 55 | DefaultFields: nil, 56 | }) 57 | 58 | if cfg.Auth.Enable { 59 | // 设置 session 中间件 60 | store := cookie.NewStore([]byte(cfg.Auth.Secret)) 61 | store.Options(sessions.Options{ 62 | Path: "/", 63 | MaxAge: 86400 * 7, // 7 days 64 | HttpOnly: true, 65 | }) 66 | router.Use(sessions.Sessions("mysession", store)) 67 | // 应用 session 中间件 68 | router.Use(SessionMiddleware()) 69 | 70 | } 71 | 72 | // CORS 73 | 74 | router.Use(cors.New(cors.Config{ 75 | AllowAllOrigins: true, 76 | AllowMethods: []string{"GET", "POST", "OPTIONS", "HEAD"}, 77 | AllowHeaders: []string{"*"}, 78 | })) 79 | 80 | if cfg.Auth.Enable { 81 | // 添加登录路由 82 | router.POST("/api/login", func(c *touka.Context) { 83 | AuthLogin(c, cfg) 84 | }) 85 | 86 | // 添加登出路由 87 | router.GET("/api/logout", func(c *touka.Context) { 88 | AuthLogout(c) 89 | }) 90 | } 91 | 92 | // 版本信息接口 93 | router.GET("/api/version", func(c *touka.Context) { 94 | c.JSON(200, touka.H{ 95 | "Version": version, 96 | }) 97 | }) 98 | 99 | backendUrl := "/backend" 100 | // 记录遥测数据 101 | router.POST(backendUrl+"/results/telemetry", func(c *touka.Context) { 102 | results.Record(c, cfg) 103 | }) 104 | // 获取客户端 IP 地址 105 | router.GET(backendUrl+"/getIP", func(c *touka.Context) { 106 | getIP(c, cfg) 107 | }) 108 | // 垃圾数据接口 109 | if cfg.Speedtest.DownloadGenStream { 110 | router.GET(backendUrl+"/garbage", garbageStream) 111 | } else { 112 | router.GET(backendUrl+"/garbage", garbage) 113 | } 114 | // 空接口 115 | router.ANY(backendUrl+"/empty", empty) 116 | // 获取图表数据 117 | router.GET(backendUrl+"/api/chart-data", func(c *touka.Context) { 118 | GetChartData(database.DB, cfg, c) 119 | }) 120 | 121 | basePath := cfg.Server.BasePath 122 | // 记录遥测数据 123 | router.POST(basePath+"/results/telemetry", func(c *touka.Context) { 124 | results.Record(c, cfg) 125 | }) 126 | // 获取客户端 IP 地址 127 | router.GET(basePath+"/getIP", func(c *touka.Context) { 128 | getIP(c, cfg) 129 | }) 130 | // 垃圾数据接口 131 | router.GET(basePath+"/garbage", garbage) 132 | // 空接口 133 | router.ANY(basePath+"/empty", empty) 134 | // 获取图表数据 135 | router.GET(basePath+"/api/chart-data", func(c *touka.Context) { 136 | GetChartData(database.DB, cfg, c) 137 | }) 138 | // 反向ping ws 139 | router.GET(basePath+"/ws", func(c *touka.Context) { 140 | handleWebSocket(c, cfg) 141 | }) 142 | 143 | // PHP 前端默认值兼容性 144 | router.ANY(basePath+"/empty.php", empty) 145 | router.GET(basePath+"/garbage.php", garbageStream) 146 | router.GET(basePath+"/getIP.php", func(c *touka.Context) { 147 | getIP(c, cfg) 148 | }) 149 | router.POST(basePath+"/results/telemetry.php", func(c *touka.Context) { 150 | results.Record(c, cfg) 151 | }) 152 | router.SetUnMatchFS(http.FS(pages)) 153 | return StartServer(cfg, router) 154 | } 155 | 156 | func StartServer(cfg *config.Config, r *touka.Engine) error { 157 | addr := cfg.Server.Host 158 | 159 | if addr == "" { 160 | addr = "0.0.0.0" 161 | } 162 | 163 | port := cfg.Server.Port 164 | if port == 0 { 165 | port = 8989 166 | } 167 | 168 | if err := r.Run(fmt.Sprintf("%s:%d", addr, port)); err != nil { 169 | return fmt.Errorf("failed to run server: %w", err) 170 | } 171 | 172 | return nil 173 | } 174 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | LGPL-3.0 License 3 | 4 | Copyright (c) 2025 WJQSERVER 5 | */ 6 | 7 | package main 8 | 9 | import ( 10 | "crypto/rand" 11 | "encoding/base64" 12 | "flag" 13 | "fmt" 14 | "log" 15 | "os" 16 | _ "time/tzdata" 17 | 18 | "speedtest/config" 19 | "speedtest/database" 20 | "speedtest/web" 21 | 22 | _ "github.com/breml/rootcerts" 23 | ) 24 | 25 | var ( 26 | cfg *config.Config 27 | ) 28 | 29 | var ( 30 | version string 31 | ) 32 | 33 | var ( 34 | cfgfile string 35 | configfile string 36 | port int 37 | initcfg bool 38 | auth bool 39 | user string 40 | password string 41 | secret string 42 | dev bool 43 | showVersion bool 44 | ) 45 | 46 | func ReadFlag() { 47 | cfgfilePtr := flag.String("cfg", "", "config file path(Deprecated)") // 配置文件路径(弃用) 48 | configfilePtr := flag.String("c", "./config/config.toml", "config file path") // 配置文件路径 49 | portPtr := flag.Int("port", 0, "port to listen on") // 监听端口 50 | initcfgPtr := flag.Bool("initcfg", false, "init config mode to run") // 初始化配置模式 51 | authPtr := flag.Bool("auth", false, "Enbale auth mode") // 授权模式 52 | userPtr := flag.String("user", "", "User name for auth mode") // 用户名 53 | passwordPtr := flag.String("password", "", "Password for auth mode") // 密码 54 | secretPtr := flag.String("secret", "", "Secret key for auth mode") // 密钥 55 | devPtr := flag.Bool("dev", false, "Development mode") // 开发模式 56 | versionPtr := flag.Bool("version", false, "Show version") // 显示版本 57 | 58 | flag.Parse() 59 | configfile = *configfilePtr 60 | cfgfile = *cfgfilePtr 61 | port = *portPtr 62 | initcfg = *initcfgPtr 63 | auth = *authPtr 64 | user = *userPtr 65 | password = *passwordPtr 66 | secret = *secretPtr 67 | dev = *devPtr 68 | showVersion = *versionPtr 69 | 70 | if cfgfile != "" && configfile == "./config/config.toml" { 71 | configfile = cfgfile 72 | fmt.Printf("-cfg is Deprecated, using -c \n") 73 | } 74 | } 75 | 76 | func loadConfig() { 77 | var err error 78 | // 初始化配置 79 | //cfg, err = config.LoadConfig(configfile) 80 | cfg, err = config.LoadConfig(configfile) 81 | if err != nil { 82 | log.Fatalf("Failed to load config: %v", err) 83 | } 84 | fmt.Printf("Loaded config: %v\n", cfg) 85 | } 86 | 87 | func saveNewConfig() { 88 | err := config.SaveConfig(configfile, cfg) 89 | if err != nil { 90 | log.Printf("Failed to save config: %v", err) 91 | } 92 | } 93 | 94 | func updateConfig() { 95 | // 写入新配置 96 | saveNewConfig() 97 | // 重新加载配置 98 | loadConfig() 99 | } 100 | 101 | func generateSecret(length int) (string, error) { 102 | // 生成随机字节 103 | bytes := make([]byte, length) 104 | _, err := rand.Read(bytes) 105 | if err != nil { 106 | return "", err 107 | } 108 | 109 | // 将随机字节编码为 Base64 110 | secret := base64.RawStdEncoding.EncodeToString(bytes) 111 | return secret, nil 112 | } 113 | 114 | func initConfig() { 115 | // 初始化配置 116 | 117 | // 端口 118 | if port != 0 { 119 | cfg.Server.Port = port 120 | } 121 | 122 | // 开启鉴权模式 123 | if auth { 124 | if user != "" && password != "" { 125 | cfg.Auth.Enable = true 126 | cfg.Auth.Username = user 127 | cfg.Auth.Password = password 128 | } else { 129 | fmt.Println("User name and password must be set for auth mode") 130 | return 131 | } 132 | if secret != "" { 133 | if secret == "rand" { 134 | var err error 135 | secret, err = generateSecret(32) 136 | if err != nil { 137 | fmt.Println("Failed to generate secret key:", err) 138 | return 139 | } 140 | fmt.Println("Generated secret key") 141 | cfg.Auth.Secret = secret 142 | } else if len(secret) < 8 { 143 | fmt.Println("Secret key must be at least 8 characters long") 144 | return 145 | } 146 | fmt.Println("Secret key:", secret) 147 | cfg.Auth.Secret = secret 148 | } else { 149 | fmt.Println("Secret key must be set for auth mode") 150 | return 151 | } 152 | } 153 | 154 | // 保存并重载 155 | updateConfig() 156 | 157 | } 158 | 159 | func debugOutput() { 160 | // 输出调试 161 | fmt.Printf("ConfigFile: %s\n", configfile) 162 | fmt.Printf("Port: %d\n", port) 163 | fmt.Printf("InitCfg: %t\n", initcfg) 164 | fmt.Printf("Auth: %t\n", auth) 165 | fmt.Printf("User: %s\n", user) 166 | fmt.Printf("Password: %s\n", password) 167 | fmt.Printf("Secret: %s\n", secret) 168 | } 169 | 170 | func init() { 171 | ReadFlag() 172 | if showVersion { 173 | fmt.Printf("SpeedTest-EX Version: %s\n", version) 174 | os.Exit(0) 175 | } 176 | loadConfig() 177 | if initcfg { 178 | initConfig() 179 | fmt.Printf("Config file initialized, exit.\n") 180 | os.Exit(0) 181 | } else { 182 | initConfig() 183 | } 184 | //updateConfig() 185 | web.RandomDataInit(cfg) 186 | web.InitEmptyBuf() 187 | } 188 | 189 | func main() { 190 | flag.Parse() 191 | database.SetDBInfo(cfg) 192 | if dev { 193 | version = "dev" 194 | debugOutput() 195 | } 196 | web.ListenAndServe(cfg, version) 197 | } 198 | -------------------------------------------------------------------------------- /web/revping.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "speedtest/config" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/gorilla/websocket" 13 | "github.com/infinite-iroha/touka" 14 | ping "github.com/prometheus-community/pro-bing" 15 | ) 16 | 17 | // 基于WS实现的RevPing 18 | type PingResult struct { 19 | IP string `json:"ip"` 20 | Success bool `json:"success"` 21 | RTT float64 `json:"rtt"` 22 | Error string `json:"error,omitempty"` 23 | } 24 | 25 | var ( 26 | upgrader = websocket.Upgrader{ 27 | CheckOrigin: func(r *http.Request) bool { 28 | return true // 允许所有来源 29 | }, 30 | } 31 | useUnprivilegedPing = false // 全局标志, 记录是否应强制使用非特权ping 32 | checkUnprivilegedPingMux sync.Mutex // 互斥锁, 确保首次检测和标志设置是线程安全的 33 | ) 34 | 35 | func pingIP(ip string, cfg *config.Config, c *touka.Context) (PingResult, error) { 36 | if ip == "" { 37 | return PingResult{}, errors.New("IP address is required") 38 | } 39 | 40 | if !cfg.RevPing.Enable { 41 | return PingResult{IP: ip, Success: false, Error: "revping-not-online"}, nil 42 | } 43 | 44 | // 内部函数, 封装单次ping的逻辑, 便于重试 45 | runPing := func(privileged bool) (*ping.Statistics, error) { 46 | pinger, err := ping.NewPinger(ip) 47 | if err != nil { 48 | return nil, err 49 | } 50 | pinger.SetPrivileged(privileged) 51 | pinger.Count = 1 52 | 53 | timeout := 3 * time.Second 54 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 55 | defer cancel() 56 | 57 | err = pinger.RunWithContext(ctx) 58 | if err != nil { 59 | return nil, err 60 | } 61 | stats := pinger.Statistics() 62 | if stats.PacketsRecv == 0 { 63 | return nil, context.DeadlineExceeded // 如果没有收到包, 视作超时 64 | } 65 | return stats, nil 66 | } 67 | 68 | // --- 自动检测与切换的核心逻辑 --- 69 | 70 | // 首先, 根据全局标志决定是否一开始就使用非特权模式 71 | privilegedAttempt := !useUnprivilegedPing 72 | 73 | stats, err := runPing(privilegedAttempt) 74 | 75 | // 如果第一次尝试(特权模式)失败了 76 | if err != nil { 77 | // 检查是否是权限错误, 并且我们确实是在特权模式下尝试的 78 | isPermissionError := strings.Contains(err.Error(), "permission denied") || strings.Contains(err.Error(), "operation not permitted") 79 | 80 | checkUnprivilegedPingMux.Lock() 81 | // 再次检查, 防止并发写入 82 | if !useUnprivilegedPing && privilegedAttempt && isPermissionError { 83 | c.Warnf("Permission denied for ICMP ping, switching to unprivileged (UDP) mode for all subsequent pings.") 84 | useUnprivilegedPing = true // 设置全局标志 85 | checkUnprivilegedPingMux.Unlock() 86 | 87 | // 立即用非特权模式重试一次 88 | stats, err = runPing(false) 89 | } else { 90 | checkUnprivilegedPingMux.Unlock() 91 | } 92 | } 93 | 94 | // --- 处理最终结果 --- 95 | 96 | if err != nil { 97 | // 如果(重试后)仍然有错误 98 | if errors.Is(err, context.DeadlineExceeded) { 99 | return PingResult{IP: ip, Success: false, Error: "timeout"}, nil 100 | } 101 | // 对于其他错误(包括权限错误, 如果重试也失败了), 返回具体错误信息 102 | return PingResult{IP: ip, Success: false, Error: err.Error()}, err 103 | } 104 | 105 | // 如果成功 106 | return PingResult{ 107 | IP: ip, 108 | Success: true, 109 | RTT: stats.AvgRtt.Seconds() * 1000, 110 | }, nil 111 | } 112 | 113 | func handleWebSocket(c *touka.Context, cfg *config.Config) { 114 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) 115 | if err != nil { 116 | c.Errorf("WebSocket upgrade error: %v", err) 117 | c.JSON(http.StatusInternalServerError, touka.H{"error": "无法升级到 WebSocket"}) 118 | return 119 | } 120 | defer conn.Close() 121 | 122 | // 在此处设置一个上下文,用于控制后续所有goroutine的生命周期 123 | ctx, cancel := context.WithCancel(context.Background()) 124 | defer cancel() // 确保在 handleWebSocket 函数退出时取消上下文 125 | 126 | ip := c.ClientIP() // 获取客户端 IP 地址 127 | 128 | // 启动一个 goroutine 来定期推送数据 129 | go func() { 130 | // 首次推送 131 | result, err := pingIP(ip, cfg, c) 132 | if err != nil { 133 | c.Warnf("Ping error: %v", err) 134 | } else { 135 | err = conn.WriteJSON(result) 136 | if err != nil { 137 | c.Warnf("WebSocket write error: %v", err) 138 | return // 如果写入失败,退出 goroutine 139 | } 140 | } 141 | 142 | ticker := time.NewTicker(2 * time.Second) // 每 2 秒推送一次数据 143 | defer ticker.Stop() 144 | 145 | for { 146 | select { 147 | case <-ticker.C: 148 | // 调用 pingIP 函数获取 Ping 结果 149 | result, err := pingIP(ip, cfg, c) 150 | if err != nil { 151 | c.Warnf("Ping error: %v", err) 152 | continue // 继续下一个周期 153 | } 154 | 155 | err = conn.WriteJSON(result) 156 | if err != nil { 157 | c.Warnf("WebSocket write error: %v", err) 158 | return // 如果写入失败,退出 goroutine 159 | } 160 | case <-ctx.Done(): 161 | // 如果上下文被取消,退出 goroutine 162 | c.Infof("WebSocket context cancelled, closing connection.") 163 | return 164 | } 165 | } 166 | }() 167 | 168 | go func() { 169 | // 处理客户端的关闭连接 170 | for { 171 | select { 172 | default: 173 | _, _, err := conn.ReadMessage() 174 | if err != nil { 175 | c.Warnf("WebSocket read error: %v", err) 176 | cancel() 177 | return 178 | } 179 | case <-ctx.Done(): 180 | // 如果上下文被取消,退出 goroutine 181 | return 182 | } 183 | 184 | } 185 | }() 186 | 187 | <-ctx.Done() 188 | } 189 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6 h1:/50VJYXd6jcu+p5BnEBDyiX0nAyGxas1W3DCnrYMxMY= 4 | github.com/WJQSERVER-STUDIO/go-utils/copyb v0.0.6/go.mod h1:FZ6XE+4TKy4MOfX1xWKe6Rwsg0ucYFCdNh1KLvyKTfc= 5 | github.com/WJQSERVER-STUDIO/httpc v0.8.1 h1:/eG8aYKL3WfQILIRbG+cbzQjPkNHEPTqfGUdQS5rtI4= 6 | github.com/WJQSERVER-STUDIO/httpc v0.8.1/go.mod h1:mxXBf2hqbQGNHkVy/7wfU7Xi2s09MyZpbY2hyR+4uD4= 7 | github.com/breml/rootcerts v0.3.0 h1:lED3QcIJvBsWta8faA/EXq9L+5nTwNMRyMTbA9UkzCM= 8 | github.com/breml/rootcerts v0.3.0/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= 9 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 10 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fenthope/compress v0.0.3 h1:HerAPZjRwpXzhnC5iunUE0rb1CtcDkAvQHNtKtLH5Ec= 12 | github.com/fenthope/compress v0.0.3/go.mod h1:/3+aXXRWs9HOOf7fe1m4UhV04/aHco8YxuxeXJeWlzE= 13 | github.com/fenthope/cors v0.0.2 h1:K/awxNE0kyNo1RJp2FSmOHyVMhVtb4qj37TH/60gUdE= 14 | github.com/fenthope/cors v0.0.2/go.mod h1:HEN+4FJSU1iMHR0g4VJMdKqXkDkfWTQIwYxSaZ8I+60= 15 | github.com/fenthope/reco v0.0.3 h1:RmnQ0D9a8PWtwOODawitTe4BztTnS9wYwrDbipISNq4= 16 | github.com/fenthope/reco v0.0.3/go.mod h1:mDkGLHte5udWTIcjQTxrABRcf56SSdxBOCLgrRDwI/Y= 17 | github.com/fenthope/record v0.0.3 h1:v5urgs5LAkLMlljAT/MjW8fWuRHXPnAraTem5ui7rm4= 18 | github.com/fenthope/record v0.0.3/go.mod h1:KFEkSc4TDZ3QIhP/wglD32uYVA6X1OUcripiao1DEE4= 19 | github.com/fenthope/sessions v0.0.1 h1:Dw4mY2yvSuyTqW+1CrojdO0gzv2gfsNsavRZcaAl7LM= 20 | github.com/fenthope/sessions v0.0.1/go.mod h1:4aI2BN1jb8MF1qTzXb4QX+GnAzmJWb57S3lDKxUyK8A= 21 | github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d h1:+d6m5Bjvv0/RJct1VcOw2P5bvBOGjENmxORJYnSYDow= 22 | github.com/go-json-experiment/json v0.0.0-20250714165856-be8212f5270d/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 23 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 24 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 25 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 26 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 27 | github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 28 | github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 29 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 30 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 31 | github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 32 | github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 33 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 34 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 35 | github.com/infinite-iroha/touka v0.3.0 h1:J1JMF0zUpdjOCeeW4QhL6D+2F15RrksdhO3zxefwhBE= 36 | github.com/infinite-iroha/touka v0.3.0/go.mod h1:Cmok9Xs8yNRNEUSqiZfi3xtdO1UZYw/yP+phf+zjH2Y= 37 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 38 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 39 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= 40 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 41 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 42 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 43 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 44 | github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= 45 | github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= 46 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 47 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 48 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 49 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 50 | go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I= 51 | go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM= 52 | golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 53 | golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 54 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 55 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 56 | golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 57 | golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 58 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 59 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 60 | -------------------------------------------------------------------------------- /ipinfo/getip.go: -------------------------------------------------------------------------------- 1 | package ipinfo 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "speedtest/config" 7 | "speedtest/results" 8 | ) 9 | 10 | // 预编译的正则表达式变量 11 | var ( 12 | localIPv6Regex = regexp.MustCompile(`^::1$`) // 匹配本地 IPv6 地址 13 | linkLocalIPv6Regex = regexp.MustCompile(`^fe80:`) // 匹配链路本地 IPv6 地址 14 | localIPv4Regex = regexp.MustCompile(`^127\.`) // 匹配本地 IPv4 地址 15 | privateIPv4Regex10 = regexp.MustCompile(`^10\.`) // 匹配私有 IPv4 地址(10.0.0.0/8) 16 | privateIPv4Regex172 = regexp.MustCompile(`^172\.(1[6-9]|2\d|3[01])\.`) // 匹配私有 IPv4 地址(172.16.0.0/12) 17 | privateIPv4Regex192 = regexp.MustCompile(`^192\.168\.`) // 匹配私有 IPv4 地址(192.168.0.0/16) 18 | linkLocalIPv4Regex = regexp.MustCompile(`^169\.254\.`) // 匹配链路本地 IPv4 地址(169.254.0.0/16) 19 | cgnatIPv4Regex = regexp.MustCompile(`^100\.([6-9][0-9]|1[0-2][0-7])\.`) // 匹配 CGNAT IPv4 地址(100.64.0.0/10) 20 | unspecifiedAddressRegex = regexp.MustCompile(`^0\.0\.0\.0$`) // 匹配未指定地址(0.0.0.0) 21 | broadcastAddressRegex = regexp.MustCompile(`^255\.255\.255\.255$`) // 匹配广播地址(255.255.255.255) 22 | removeASRegexp = regexp.MustCompile(`AS\d+\s`) // 用于去除 ISP 信息中的自治系统编号 23 | ) 24 | 25 | func GetIP(clientIP string, cfg *config.Config) (results.Result, error) { 26 | var ret results.Result // 创建结果结构体实例 27 | // 使用正则表达式匹配不同类型的 IP 地址 28 | switch { 29 | case localIPv6Regex.MatchString(clientIP): 30 | ret.ProcessedString = clientIP + " - localhost IPv6 access" // 本地 IPv6 地址 31 | case linkLocalIPv6Regex.MatchString(clientIP): 32 | ret.ProcessedString = clientIP + " - link-local IPv6 access" // 链路本地 IPv6 地址 33 | case localIPv4Regex.MatchString(clientIP): 34 | ret.ProcessedString = clientIP + " - localhost IPv4 access" // 本地 IPv4 地址 35 | case privateIPv4Regex10.MatchString(clientIP): 36 | ret.ProcessedString = clientIP + " - private IPv4 access" // 私有 IPv4 地址(10.0.0.0/8) 37 | case privateIPv4Regex172.MatchString(clientIP): 38 | ret.ProcessedString = clientIP + " - private IPv4 access" // 私有 IPv4 地址(172.16.0.0/12) 39 | case privateIPv4Regex192.MatchString(clientIP): 40 | ret.ProcessedString = clientIP + " - private IPv4 access" // 私有 IPv4 地址(192.168.0.0/16) 41 | case linkLocalIPv4Regex.MatchString(clientIP): 42 | ret.ProcessedString = clientIP + " - link-local IPv4 access" // 链路本地 IPv4 地址 43 | case cgnatIPv4Regex.MatchString(clientIP): 44 | ret.ProcessedString = clientIP + " - CGNAT IPv4 access" // CGNAT IPv4 地址(100.64.0.0/10) 45 | case unspecifiedAddressRegex.MatchString(clientIP): 46 | ret.ProcessedString = clientIP + " - unspecified address" // 未指定地址(0.0.0.0) 47 | case broadcastAddressRegex.MatchString(clientIP): 48 | ret.ProcessedString = clientIP + " - broadcast address" // 广播地址(255.255.255.255) 49 | default: 50 | ret.ProcessedString = clientIP // 其他情况,返回原始 IP 地址 51 | } 52 | /* 53 | // 检查处理结果中是否包含特定信息 54 | if strings.Contains(ret.ProcessedString, " - ") { 55 | // 将 ret 转换为 JSON 字符串 56 | jsonData, err := json.Marshal(ret) 57 | if err != nil { 58 | // 如果转换失败,记录错误信息 59 | logInfo("Error marshaling JSON: " + err.Error()) 60 | } else { 61 | // 如果转换成功,记录 JSON 字符串 62 | logInfo(string(jsonData)) 63 | } 64 | return ret // 返回结果 65 | } */ 66 | 67 | ispInfo, err := getIPInfo(clientIP, cfg) 68 | if err != nil { 69 | return results.Result{}, err 70 | } 71 | //ret.RawISPInfo = ispInfo // 存储原始 ISP 信息 72 | // 转写 ISP 信息 73 | ret.RawISPInfo = results.CommonIPInfoResponse{ 74 | IP: ispInfo.IP, 75 | Org: ispInfo.Org, 76 | Region: ispInfo.Region, 77 | City: ispInfo.City, 78 | Country: ispInfo.Country, 79 | Continent: ispInfo.Continent, 80 | } 81 | /* 82 | 83 | 84 | isp := removeASRegexp.ReplaceAllString(ispInfo.ISP, "") // 去除 ISP 信息中的自治系统编号 85 | 86 | if isp == "" { 87 | isp = "Unknown ISP" // 如果 ISP 信息为空,设置为未知 88 | } 89 | 90 | if ispInfo.CountryName != "" { 91 | isp += ", " + ispInfo.CountryName // 如果有国家名称,添加到 ISP 信息中 92 | } 93 | 94 | ret.ProcessedString += " - " + isp // 更新处理后的字符串 95 | */ 96 | ret.ProcessedString = MakeProcessedString(ret.ProcessedString, ispInfo) // 更新处理后的字符串 97 | return ret, nil // 返回结果 98 | } 99 | 100 | // 获取 IP 地址信息 101 | func getIPInfo(ip string, cfg *config.Config) (CommonIPInfoResponse, error) { 102 | 103 | switch cfg.IPinfo.Model { 104 | case "ip": 105 | // 自托管 IP 信息查询 106 | var ret CommonIPInfoResponse // 创建结果结构体实例 107 | ret, err := getHostIPInfo(ip, cfg) 108 | if err != nil { 109 | return CommonIPInfoResponse{}, err 110 | } 111 | return ret, nil 112 | case "ipinfo": 113 | // ipinfo.io 信息查询 114 | var ret CommonIPInfoResponse // 创建结果结构体实例 115 | ret, err := getIPInfoIO(ip, cfg) 116 | if err != nil { 117 | return CommonIPInfoResponse{}, err 118 | } 119 | return ret, nil 120 | default: 121 | // 模型不支持 122 | return CommonIPInfoResponse{}, fmt.Errorf("Unsupported IPinfo model: " + cfg.IPinfo.Model) 123 | } 124 | } 125 | 126 | func MakeProcessedString(processedString string, ispInfo CommonIPInfoResponse) string { 127 | info := processedString 128 | if ispInfo.Org != "" { 129 | info += " - " + ispInfo.Org 130 | } 131 | if ispInfo.Region != "" { 132 | info += " - " + ispInfo.Region 133 | } 134 | if ispInfo.City != "" { 135 | info += " - " + ispInfo.City 136 | } 137 | if ispInfo.Country != "" { 138 | info += " - " + ispInfo.Country 139 | } 140 | if ispInfo.Continent != "" { 141 | info += " - " + ispInfo.Continent 142 | } 143 | return info 144 | } 145 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | 1.1.3 - 2025-07-18 4 | --- 5 | - CHANGE: 更新依赖, 提升性能与安全性 6 | 7 | 1.1.2 - 2025-06-13 8 | --- 9 | - CHANGE: 更新依赖 10 | 11 | 25w17a - 2025-06-13 12 | --- 13 | - PRE-RELEASE: 此版本是v1.1.2的预发布版本; 14 | - CHANGE: 更新依赖 15 | 16 | 1.1.1 - 2025-06-08 17 | --- 18 | - CHANGE: 为`revping`加入非特权回退功能 19 | 20 | 25w16a - 2025-06-08 21 | --- 22 | - PRE-RELEASE: 此版本是v1.1.1的预发布版本; 23 | - CHANGE: 为`revping`加入非特权回退功能 24 | 25 | 1.1.0 - 2025-06-07 26 | --- 27 | - CHANGE: 转向Touka框架 28 | - CHANGE: 弃用-cfg, 换为-c (保留兼容性支持, -cfg仍然正常工作) 29 | - CHANGE: 下载数据生成增加流模式 30 | - CHANGE: 加入Compress支持 31 | - CHANGE: 优化测速脚本性能 32 | - CHANGE: 对实现进行优化, 避免ws的一些关闭问题, 提升下载数据生成效率 33 | - CHANGE: 改进前端内容handle处置 34 | - ADD: 加入CLI 35 | 36 | 25w15b - 2025-06-07 37 | --- 38 | - PRE-RELEASE: 此版本是v1.1.0的预发布版本; 39 | - CHANGE: 下载数据生成增加流模式 40 | - CHANGE: 加入Compress支持 41 | 42 | 25w15a - 2025-06-06 43 | --- 44 | - PRE-RELEASE: 此版本是v1.1.0的预发布版本; 45 | - CHANGE: 转向Touka框架 46 | - CHANGE: 弃用-cfg, 换为-c (保留兼容性支持, -cfg仍然正常工作) 47 | - CHANGE: 优化测速脚本性能 48 | - CHANGE: 对实现进行优化, 避免ws的一些关闭问题, 提升下载数据生成效率 49 | - CHANGE: 改进前端内容handle处置 50 | - ADD: 加入CLI 51 | 52 | 1.0.1 - 2025-06-04 53 | --- 54 | - CHANGE: 更新依赖 55 | - CHANGE: 弃用对req的依赖, 转向touka-httpc 56 | 57 | 25w14b - 2025-06-04 58 | --- 59 | - PRE-RELEASE: 此版本是v1.0.1的预发布版本; 60 | - CHANGE: 弃用对req的依赖, 转向touka-httpc 61 | 62 | 25w14a - 2025-06-04 63 | --- 64 | - PRE-RELEASE: 此版本是v1.0.1的预发布版本; 65 | - CHANGE: 更新依赖 66 | 67 | 1.0.0 - 2025-03-19 68 | --- 69 | - RELEASE: 首个正式稳定版 70 | 71 | 25w13a - 2025-03-19 72 | --- 73 | - PRE-RELEASE: 此版本是v1.0.0的预发布版本; 此项目已趋于稳定; 74 | 75 | 0.0.12 76 | --- 77 | - CHANGE: 关闭Gin上传缓冲区 78 | - CHANGE: 更新Go版本至1.24 79 | - CHANGE: 更新相关依赖库 80 | 81 | 25w12a 82 | --- 83 | - CHANGE: 关闭Gin上传缓冲区 84 | - CHANGE: 更新Go版本至1.24 85 | 86 | 0.0.11 87 | --- 88 | - CHANGE: 优化`empty`部分实现 89 | - CHANGE: 将`logger`升级至`v1.2.0`版本 90 | - CHANGE: 更新相关依赖库 91 | - CHANGE: 更新Go版本至1.23.6 92 | 93 | 25w11b 94 | --- 95 | - CHANGE: 优化`empty`部分实现 96 | 97 | 25w11a 98 | --- 99 | - CHANGE: 改进`empty`部分实现 100 | - CHANGE: 将`logger`升级至`v1.2.0`版本 101 | 102 | 0.0.10 103 | --- 104 | - CHANGE: 更新Go版本至1.23.5 105 | - ADD: 加入`-version`命令行参数, 用于显示当前版本信息 106 | - CHANGE: 将`net`换为`net/netip` 107 | 108 | 25w10a 109 | --- 110 | - PRE-RELEASE: 作为0.0.10的预发布版本, 请勿用于生产环境 111 | - CHANGE: 更新Go版本至1.23.5 112 | - ADD: 加入`-version`命令行参数, 用于显示当前版本信息 113 | - CHANGE: 将`net`换为`net/netip` 114 | 115 | 0.0.9 116 | --- 117 | - CHANGE: 将`Revping`转为通过`WebSocket`通道传递数据 118 | - CHANGE: 完善bin安装脚本 119 | - REMOVE: 移除部分无用保留页面 120 | - CHANGE: 关闭`gin`日志输出, 避免影响性能(go终端日志输出性能较差, 易成为性能瓶颈) 121 | - CHANGE: 新增`[Speedtest]`配置块,`downDataChunkSize = 4 #mb` `downDataChunkCount = 4` 分别用于设置下载数据块大小与块数量, 配置更加灵活 122 | 123 | 25w09b 124 | --- 125 | - PRE-RELEASE: 作为0.0.9的预发布版本, 请勿用于生产环境 126 | - CHANGE: 关闭`gin`日志输出, 避免影响性能(go终端日志输出性能较差, 易成为性能瓶颈) 127 | - CHANGE: 新增`[Speedtest]`配置块,`downDataChunkSize = 4 #mb` `downDataChunkCount = 4` 分别用于设置下载数据块大小与块数量, 配置更加灵活 128 | 129 | 25w09a 130 | --- 131 | - PRE-RELEASE: 作为0.0.9的预发布版本, 请勿用于生产环境 132 | - CHANGE: 将`Revping`转为通过`WebSocket`通道传递数据 133 | - CHANGE: 完善bin安装脚本 134 | - REMOVE: 移除部分保留页面 135 | 136 | 0.0.8 137 | --- 138 | - CHANGE: 大量扩充可传入的flag 139 | - CHANGE: 修改`config`模块, 加入保存配置与重载配置 140 | - CHANGE: 加入通过`crypto/rand`生成secret key的功能 141 | - CHANGE: 增加前端版本号显示 142 | 143 | 25w08b 144 | --- 145 | - PRE-RELEASE: 作为0.0.8的预发布版本, 请勿用于生产环境 146 | - CHANGE: 大量扩充可传入的flag 147 | - CHANGE: 修改`config`模块, 加入保存配置与重载配置 148 | - CHANGE: 加入通过`crypto/rand`生成secret key的功能 149 | - CHANGE: 增加前端版本号显示 150 | 151 | 25w08a 152 | --- 153 | - PRE-RELEASE: 由于当日Github故障造成的CI/CD流程错误, 此版本弃用 154 | 155 | 0.0.7 156 | --- 157 | - ADD: 加入鉴权功能与对应的前端页面, 同时对核心`speedtest.js`与`speedtest_worker.js`内请求后端的部分进行了适应性修改 (实验性, 需要更多测试) 158 | - CHANGE: 改进前端静态文件处理, 进行一定改进 159 | - CHANGE: 优化前端显示 160 | - CHANGE: 改进`revping`功能, 加入熔断, 若由于`timeout`或`revping-not-online`导致的无法返回结果, 在连续失败后会停止发起请求, 避免阻塞 161 | - CHANGE: 对`route.go`进行了优化, 独立部分处理逻辑 162 | 163 | 25w07b 164 | --- 165 | - PRE-RELEASE: 作为0.0.7的预发布版本, 请勿用于生产环境 166 | - CHANGE: 改进`revping`功能, 加入熔断, 若由于`timeout`或`revping-not-online`导致的无法返回结果, 在连续失败后会停止发起请求, 避免阻塞 167 | 168 | 25w07a 169 | --- 170 | - PRE-RELEASE: 作为0.0.7的预发布版本, 请勿用于生产环境 171 | - CHANGE: 优化前端显示 172 | - ADD: 加入鉴权功能与对于的前端页面, 同时对核心`speedtest.js`与`speedtest_worker.js`内请求后端的部分进行了适应性修改 (实验性, 需要更多测试) 173 | - CHANGE: 改进前端静态文件处理, 进行一定改进 174 | - CHANGE: 对`route.go`进行了优化, 独立部分处理逻辑 175 | 176 | 0.0.6 177 | --- 178 | - FIX: 修复工作流配置导致的编译问题 179 | - CHANGE: 改进revping前端显示 180 | 181 | 25w06a 182 | --- 183 | - PRE-RELEASE: 作为0.0.6的预发布版本, 请勿用于生产环境 184 | - FIX: 修复工作流配置导致的编译问题 185 | - CHANGE: 改进revping前端显示 186 | 187 | 0.0.5 188 | --- 189 | - FIX: 修复遥测数据上传问题 190 | - PR: [PR #8](https://github.com/WJQSERVER/speedtest-ex/pull/8) 191 | 192 | 25w05a 193 | --- 194 | - PRE-RELEASE: 作为的修复的预发布版本, 请勿用于生产环境 195 | - FIX: 修复遥测数据上传问题 196 | - PR: [PR #7](https://github.com/WJQSERVER/speedtest-ex/pull/7) 197 | 198 | 0.0.4 199 | --- 200 | - ADD: 加入反向ping显示 201 | - ADD: 加入单下载流测速功能 202 | 203 | 25w04a 204 | --- 205 | - PRE-RELEASE: 作为0.0.4的预发布版本, 请勿用于生产环境 206 | - ADD: 加入反向ping显示 207 | - ADD: 加入单下载流测速功能 208 | 209 | 0.0.3 210 | --- 211 | - RELEASE: 0.0.3正式版本发布 212 | - CHANGE: 优化前端页面显示 213 | - CHANGE: 更新文档 214 | 215 | 25w03a 216 | --- 217 | - PRE-RELEASE: 作为0.0.3的预发布版本, 请勿用于生产环境 218 | - FIX: 修复前端页面显示问题 219 | 220 | 0.0.2 221 | --- 222 | - RELEASE: 0.0.2正式版本发布 223 | - CHANGE: 优化部分错误处理 224 | - CHANGE&FIX: 修正文档错误 225 | 226 | 25w02a 227 | --- 228 | - PRE-RELEASE: 作为0.0.2的预发布版本, 请勿用于生产环境 229 | - CHANGE&FIX: 修正文档错误 230 | - CHANGE: 优化部分错误处理 231 | 232 | 0.0.1 233 | --- 234 | - RELEASE: 0.0.1正式版本发布 235 | 236 | 25w01d 237 | --- 238 | - PRE-RELEASE: 作为0.0.1的预发布版本, 请勿用于生产环境 239 | - FIX: 修复配置问题 240 | 241 | 25w01c 242 | --- 243 | - PRE-RELEASE: 作为0.0.1的预发布版本, 请勿用于生产环境 244 | - FIX: 修复了一些已知问题 245 | - CHANGE: 更新依赖库 246 | 247 | 25w01b 248 | --- 249 | - PRE-RELEASE: 首个对外预发布版本 250 | 251 | 25w01a 252 | --- 253 | - PRE-RELEASE: 由于.gitignore造成的CI/CD流程错误,此版本弃用 254 | -------------------------------------------------------------------------------- /deploy/install.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # https://github.com/WJQSERVER/speedtest-ex 3 | # LGPL-3.0 License 4 | # Copyright (c) 2025 WJQSERVER 5 | 6 | bin_dir_default="/usr/local/speedtest-ex" 7 | bin_dir=$bin_dir_default 8 | 9 | # install packages 10 | install() { 11 | if [ $# -eq 0 ]; then 12 | echo "ARGS NOT FOUND" 13 | return 1 14 | fi 15 | 16 | for package in "$@"; do 17 | if ! command -v "$package" &>/dev/null; then 18 | echo "Installing $package..." 19 | if command -v dnf &>/dev/null; then 20 | dnf -y update && dnf install -y "$package" 21 | elif command -v yum &>/dev/null; then 22 | yum -y update && yum -y install "$package" 23 | elif command -v apt &>/dev/null; then 24 | apt update -y && apt install -y "$package" 25 | elif command -v apk &>/dev/null; then 26 | apk update && apk add "$package" 27 | else 28 | echo "UNKNOWN PACKAGE MANAGER" 29 | return 1 30 | fi 31 | else 32 | echo "$package is already installed." 33 | fi 34 | done 35 | 36 | return 0 37 | } 38 | 39 | # 检查是否为root用户 40 | if [ "$EUID" -ne 0 ]; then 41 | echo "请以root用户运行此脚本" 42 | exit 1 43 | fi 44 | 45 | # 安装依赖包 46 | install curl wget sed 47 | 48 | # 查看当前架构是否为linux/amd64或linux/arm64 49 | ARCH=$(uname -m) 50 | if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then 51 | echo "$ARCH 架构不被支持" 52 | exit 1 53 | fi 54 | 55 | # 重写架构值,改为amd64或arm64 56 | if [ "$ARCH" == "x86_64" ]; then 57 | ARCH="amd64" 58 | elif [ "$ARCH" == "aarch64" ]; then 59 | ARCH="arm64" 60 | fi 61 | 62 | # 获取监听端口 63 | read -p "请输入程序监听的端口(默认8989): " PORT 64 | if [ -z "$PORT" ]; then 65 | PORT=8989 66 | fi 67 | 68 | # 泛监听(0.0.0.0) 69 | read -p "请键入程序监听的IP(默认泛监听0.0.0.0): " IP 70 | if [ -z "$IP" ]; then 71 | IP="0.0.0.0" 72 | fi 73 | 74 | # 安装目录 75 | read -p "请输入安装目录(默认${bin_dir_default}): " bin_dir 76 | if [ -z "$bin_dir" ]; then 77 | bin_dir=${bin_dir_default} 78 | fi 79 | 80 | make_systemd_service() { 81 | cat < /etc/systemd/system/speedtest-ex.service 82 | [Unit] 83 | Description=SpeedTest-EX 84 | After=network.target 85 | 86 | [Service] 87 | ExecStart=/bin/bash -c '${bin_dir}/speedtest-ex -cfg ${bin_dir}/config/config.toml > ${bin_dir}/log/run.log 2>&1' 88 | WorkingDirectory=${bin_dir} 89 | Restart=always 90 | User=root 91 | Group=root 92 | 93 | [Install] 94 | WantedBy=multi-user.target 95 | 96 | EOF 97 | } 98 | 99 | make_openrc_service() { 100 | cat < /etc/init.d/speedtest-ex 101 | #!/sbin/openrc-run 102 | 103 | command="${bin_dir}/speedtest-ex" 104 | command_args="-cfg ${bin_dir}/config/config.toml" 105 | pidfile="/run/speedtest-ex.pid" 106 | name="speedtest-ex" 107 | 108 | depend() { 109 | need net 110 | } 111 | 112 | start_pre() { 113 | checkpath --directory --mode 0755 /run 114 | } 115 | 116 | start() { 117 | ebegin "Starting ${name}" 118 | start-stop-daemon --start --make-pidfile --pidfile ${pidfile} --background --exec ${command} -- ${command_args} 119 | eend $? 120 | } 121 | 122 | stop() { 123 | ebegin "Stopping ${name}" 124 | start-stop-daemon --stop --pidfile ${pidfile} 125 | eend $? 126 | } 127 | EOF 128 | chmod +x /etc/init.d/speedtest-ex 129 | } 130 | 131 | make_procd_service() { 132 | config_path="${bin_dir}/config/config.toml" 133 | 134 | cat < ${bin_dir}/boot.sh 135 | #!/bin/sh 136 | cd ${bin_dir} 137 | ${bin_dir}/speedtest-ex -cfg ${config_path} 138 | 139 | EOF 140 | 141 | chmod +x ${bin_dir}/boot.sh 142 | 143 | cat < /etc/init.d/speedtest-ex 144 | #!/bin/sh /etc/rc.common 145 | 146 | START=99 147 | USE_PROCD=1 148 | 149 | start_service() { 150 | procd_open_instance 151 | procd_set_param command ${bin_dir}/boot.sh 152 | procd_close_instance 153 | } 154 | 155 | stop_service() { 156 | pid=\$(pidof speedtest-ex) 157 | [ -n "\$pid" ] && kill \$pid 158 | } 159 | 160 | 161 | 162 | EOF 163 | chmod +x /etc/init.d/speedtest-ex 164 | } 165 | 166 | 167 | # 创建目录 168 | mkdir -p ${bin_dir} 169 | mkdir -p ${bin_dir}/config 170 | mkdir -p ${bin_dir}/log 171 | mkdir -p ${bin_dir}/db 172 | 173 | # 获取最新版本号 174 | VERSION=$(curl -s https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/VERSION) 175 | if [ -z "$VERSION" ]; then 176 | echo "无法获取版本号" 177 | exit 1 178 | fi 179 | wget -q -O ${bin_dir}/VERSION https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/VERSION 180 | 181 | # 下载speedtest-ex 182 | wget -q -O ${bin_dir}/speedtest-ex.tar.gz https://github.com/WJQSERVER/speedtest-ex/releases/download/${VERSION}/speedtest-ex-linux-${ARCH}.tar.gz 183 | if [ $? -ne 0 ]; then 184 | echo "下载失败,请检查网络连接" 185 | exit 1 186 | fi 187 | 188 | install tar 189 | tar -zxvf ${bin_dir}/speedtest-ex.tar.gz -C ${bin_dir} 190 | mv ${bin_dir}/speedtest-ex-linux-${ARCH} ${bin_dir}/speedtest-ex 191 | rm ${bin_dir}/speedtest-ex.tar.gz 192 | chmod +x ${bin_dir}/speedtest-ex 193 | 194 | # 下载配置文件 195 | if [ -f ${bin_dir}/config/config.toml ]; then 196 | echo "配置文件已存在, 跳过下载" 197 | echo "[WARNING] 请检查配置文件是否正确,DEV版本升级时请注意配置文件兼容性" 198 | sleep 2 199 | else 200 | wget -q -O ${bin_dir}/config/config.toml https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/main/deploy/config.toml 201 | fi 202 | 203 | 204 | 205 | # 询问是否开启鉴权(Y/N) 206 | read -p "是否开启鉴权(Y/N): " isAuth 207 | if [[ "$isAuth" =~ ^[Yy]$ ]]; then 208 | read -p "请输入用户名: " username 209 | read -p "请输入密码: " password 210 | read -p "请输入密钥(默认rand): " secret 211 | # 若secret不为空,则写入配置文件 212 | cd ${bin_dir} 213 | ./speedtest-ex -cfg ./config/config.toml -port ${PORT} -user ${username} -password ${password} -secret ${secret:-rand} -auth -initcfg 214 | else 215 | cd ${bin_dir} 216 | ./speedtest-ex -cfg ./config/config.toml -port ${PORT} -initcfg 217 | fi 218 | 219 | # 判断发行版并创建相应的服务 220 | if command -v systemctl &>/dev/null; then 221 | make_systemd_service 222 | systemctl daemon-reload 223 | systemctl enable speedtest-ex 224 | systemctl start speedtest-ex 225 | elif [ -x /sbin/openrc ] || [ -x /usr/bin/openrc ]; then 226 | make_openrc_service 227 | rc-update add speedtest-ex default 228 | service speedtest-ex start 229 | elif [ -x /sbin/procd ]; then 230 | make_procd_service 231 | /etc/init.d/speedtest-ex enable 232 | /etc/init.d/speedtest-ex start 233 | else 234 | echo "不支持的服务管理器" 235 | exit 1 236 | fi 237 | 238 | echo "speedtest-ex 安装成功" -------------------------------------------------------------------------------- /deploy/install-dev.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | # https://github.com/WJQSERVER/speedtest-ex 3 | # LGPL-3.0 License 4 | # Copyright (c) 2025 WJQSERVER 5 | 6 | bin_dir_default="/usr/local/speedtest-ex" 7 | bin_dir=$bin_dir_default 8 | 9 | # install packages 10 | install() { 11 | if [ $# -eq 0 ]; then 12 | echo "ARGS NOT FOUND" 13 | return 1 14 | fi 15 | 16 | for package in "$@"; do 17 | if ! command -v "$package" &>/dev/null; then 18 | echo "Installing $package..." 19 | if command -v dnf &>/dev/null; then 20 | dnf -y update && dnf install -y "$package" 21 | elif command -v yum &>/dev/null; then 22 | yum -y update && yum -y install "$package" 23 | elif command -v apt &>/dev/null; then 24 | apt update -y && apt install -y "$package" 25 | elif command -v apk &>/dev/null; then 26 | apk update && apk add "$package" 27 | else 28 | echo "UNKNOWN PACKAGE MANAGER" 29 | return 1 30 | fi 31 | else 32 | echo "$package is already installed." 33 | fi 34 | done 35 | 36 | return 0 37 | } 38 | 39 | # 检查是否为root用户 40 | if [ "$EUID" -ne 0 ]; then 41 | echo "请以root用户运行此脚本" 42 | exit 1 43 | fi 44 | 45 | # 安装依赖包 46 | install curl wget sed 47 | 48 | # 查看当前架构是否为linux/amd64或linux/arm64 49 | ARCH=$(uname -m) 50 | if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "aarch64" ]; then 51 | echo "$ARCH 架构不被支持" 52 | exit 1 53 | fi 54 | 55 | # 重写架构值,改为amd64或arm64 56 | if [ "$ARCH" == "x86_64" ]; then 57 | ARCH="amd64" 58 | elif [ "$ARCH" == "aarch64" ]; then 59 | ARCH="arm64" 60 | fi 61 | 62 | # 获取监听端口 63 | read -p "请输入程序监听的端口(默认8989): " PORT 64 | if [ -z "$PORT" ]; then 65 | PORT=8989 66 | fi 67 | 68 | # 泛监听(0.0.0.0) 69 | read -p "请键入程序监听的IP(默认泛监听0.0.0.0): " IP 70 | if [ -z "$IP" ]; then 71 | IP="0.0.0.0" 72 | fi 73 | 74 | # 安装目录 75 | read -p "请输入安装目录(默认${bin_dir_default}): " bin_dir 76 | if [ -z "$bin_dir" ]; then 77 | bin_dir=${bin_dir_default} 78 | fi 79 | 80 | make_systemd_service() { 81 | cat < /etc/systemd/system/speedtest-ex.service 82 | [Unit] 83 | Description=SpeedTest-EX 84 | After=network.target 85 | 86 | [Service] 87 | ExecStart=/bin/bash -c '${bin_dir}/speedtest-ex -cfg ${bin_dir}/config/config.toml > ${bin_dir}/log/run.log 2>&1' 88 | WorkingDirectory=${bin_dir} 89 | Restart=always 90 | User=root 91 | Group=root 92 | 93 | [Install] 94 | WantedBy=multi-user.target 95 | 96 | EOF 97 | } 98 | 99 | make_openrc_service() { 100 | cat < /etc/init.d/speedtest-ex 101 | #!/sbin/openrc-run 102 | 103 | command="${bin_dir}/speedtest-ex" 104 | command_args="-cfg ${bin_dir}/config/config.toml" 105 | pidfile="/run/speedtest-ex.pid" 106 | name="speedtest-ex" 107 | 108 | depend() { 109 | need net 110 | } 111 | 112 | start_pre() { 113 | checkpath --directory --mode 0755 /run 114 | } 115 | 116 | start() { 117 | ebegin "Starting ${name}" 118 | start-stop-daemon --start --make-pidfile --pidfile ${pidfile} --background --exec ${command} -- ${command_args} 119 | eend $? 120 | } 121 | 122 | stop() { 123 | ebegin "Stopping ${name}" 124 | start-stop-daemon --stop --pidfile ${pidfile} 125 | eend $? 126 | } 127 | EOF 128 | chmod +x /etc/init.d/speedtest-ex 129 | } 130 | 131 | make_procd_service() { 132 | config_path="${bin_dir}/config/config.toml" 133 | 134 | cat < ${bin_dir}/boot.sh 135 | #!/bin/sh 136 | cd ${bin_dir} 137 | ${bin_dir}/speedtest-ex -cfg ${config_path} 138 | 139 | EOF 140 | 141 | chmod +x ${bin_dir}/boot.sh 142 | 143 | cat < /etc/init.d/speedtest-ex 144 | #!/bin/sh /etc/rc.common 145 | 146 | START=99 147 | USE_PROCD=1 148 | 149 | start_service() { 150 | procd_open_instance 151 | procd_set_param command ${bin_dir}/boot.sh 152 | procd_close_instance 153 | } 154 | 155 | stop_service() { 156 | pid=\$(pidof speedtest-ex) 157 | [ -n "\$pid" ] && kill \$pid 158 | } 159 | 160 | 161 | 162 | EOF 163 | chmod +x /etc/init.d/speedtest-ex 164 | } 165 | 166 | 167 | # 创建目录 168 | mkdir -p ${bin_dir} 169 | mkdir -p ${bin_dir}/config 170 | mkdir -p ${bin_dir}/log 171 | mkdir -p ${bin_dir}/db 172 | 173 | # 获取最新版本号 174 | VERSION=$(curl -s https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/dev/DEV-VERSION) 175 | if [ -z "$VERSION" ]; then 176 | echo "无法获取版本号" 177 | exit 1 178 | fi 179 | wget -q -O ${bin_dir}/VERSION https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/dev/DEV-VERSION 180 | 181 | # 下载speedtest-ex 182 | wget -q -O ${bin_dir}/speedtest-ex.tar.gz https://github.com/WJQSERVER/speedtest-ex/releases/download/${VERSION}/speedtest-ex-linux-${ARCH}.tar.gz 183 | if [ $? -ne 0 ]; then 184 | echo "下载失败,请检查网络连接" 185 | exit 1 186 | fi 187 | 188 | install tar 189 | tar -zxvf ${bin_dir}/speedtest-ex.tar.gz -C ${bin_dir} 190 | mv ${bin_dir}/speedtest-ex-linux-${ARCH} ${bin_dir}/speedtest-ex 191 | rm ${bin_dir}/speedtest-ex.tar.gz 192 | chmod +x ${bin_dir}/speedtest-ex 193 | 194 | # 下载配置文件 195 | if [ -f ${bin_dir}/config/config.toml ]; then 196 | echo "配置文件已存在, 跳过下载" 197 | echo "[WARNING] 请检查配置文件是否正确,DEV版本升级时请注意配置文件兼容性" 198 | sleep 2 199 | else 200 | wget -q -O ${bin_dir}/config/config.toml https://raw.githubusercontent.com/WJQSERVER/speedtest-ex/dev/deploy/config.toml 201 | fi 202 | 203 | 204 | 205 | # 询问是否开启鉴权(Y/N) 206 | read -p "是否开启鉴权(Y/N): " isAuth 207 | if [[ "$isAuth" =~ ^[Yy]$ ]]; then 208 | read -p "请输入用户名: " username 209 | read -p "请输入密码: " password 210 | read -p "请输入密钥(默认rand): " secret 211 | # 若secret不为空,则写入配置文件 212 | cd ${bin_dir} 213 | ./speedtest-ex -cfg ./config/config.toml -port ${PORT} -user ${username} -password ${password} -secret ${secret:-rand} -auth -initcfg 214 | else 215 | cd ${bin_dir} 216 | ./speedtest-ex -cfg ./config/config.toml -port ${PORT} -initcfg 217 | fi 218 | 219 | # 判断发行版并创建相应的服务 220 | if command -v systemctl &>/dev/null; then 221 | make_systemd_service 222 | systemctl daemon-reload 223 | systemctl enable speedtest-ex 224 | systemctl start speedtest-ex 225 | elif [ -x /sbin/openrc ] || [ -x /usr/bin/openrc ]; then 226 | make_openrc_service 227 | rc-update add speedtest-ex default 228 | service speedtest-ex start 229 | elif [ -x /sbin/procd ]; then 230 | make_procd_service 231 | /etc/init.d/speedtest-ex enable 232 | /etc/init.d/speedtest-ex start 233 | else 234 | echo "不支持的服务管理器" 235 | exit 1 236 | fi 237 | 238 | echo "speedtest-ex 安装成功" -------------------------------------------------------------------------------- /web/pages/theme-variables.css: -------------------------------------------------------------------------------- 1 | /* theme-variables.css */ 2 | :root { 3 | /* --- Light Theme Variables (Default) --- */ 4 | --theme-primary: #007bff; 5 | --theme-primary-hover: #0056b3; 6 | --theme-secondary: #6c757d; 7 | --theme-secondary-hover: #545b62; 8 | 9 | --theme-background: #f8f9fa; /* Overall page background */ 10 | --theme-surface: #ffffff; /* Cards, panels, modals */ 11 | --theme-surface-alt: #f0f2f5; /* Slightly off-white surfaces like test boxes */ 12 | 13 | --theme-text-strong: #212529; /* Headings, important text */ 14 | --theme-text-default: #343a40; /* Regular text */ 15 | --theme-text-muted: #6c757d; /* Subtitles, less important text */ 16 | --theme-text-on-primary: #ffffff; /* Text on primary color buttons/backgrounds */ 17 | --theme-text-on-secondary: #ffffff; 18 | 19 | --theme-border: #dee2e6; 20 | --theme-input-bg: #ffffff; 21 | --theme-input-border: #ced4da; /* Input field border */ 22 | --theme-input-text: #212529; 23 | --theme-input-focus-border: #86b7fe; /* Bootstrap focus blue */ 24 | --theme-input-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 25 | 26 | --theme-link: var(--theme-primary); 27 | --theme-link-hover: var(--theme-primary-hover); 28 | 29 | --theme-shadow-light: rgba(0, 0, 0, 0.075); 30 | --theme-shadow-strong: rgba(0, 0, 0, 0.125); 31 | 32 | /* Chart specific - Light */ 33 | --chart-grid: rgba(0, 0, 0, 0.1); 34 | --chart-ticks: #666; 35 | --chart-labels: #333; /* Includes legend, title */ 36 | --chart-tooltip-bg: rgba(0, 0, 0, 0.8); 37 | --chart-tooltip-text: #fff; 38 | 39 | /* Toast specific - Light */ 40 | --toast-bg: rgba(255, 255, 255, 0.95); 41 | --toast-text: var(--theme-text-strong); 42 | --toast-header-bg: #f1f1f1; 43 | --toast-border: var(--theme-border); 44 | --toast-btn-close-filter: invert(0); 45 | --toast-success-bg-mix: #28a745; /* Green for success */ 46 | --toast-danger-bg-mix: #dc3545; /* Red for danger */ 47 | 48 | /* Bootstrap specific overrides (for login.html) - Light */ 49 | --bs-body-color: var(--theme-text-default); 50 | --bs-body-bg: var(--theme-background); 51 | --bs-primary: var(--theme-primary); 52 | --bs-secondary: var(--theme-secondary); 53 | --bs-border-color: var(--theme-border); 54 | --bs-card-bg: var(--theme-surface); 55 | --bs-card-border-color: var(--theme-border); 56 | --bs-card-cap-bg: transparent; 57 | --bs-form-control-bg: var(--theme-input-bg); 58 | --bs-form-control-color: var(--theme-input-text); 59 | --bs-form-control-border-color: var(--theme-input-border); 60 | --bs-btn-color: var(--theme-text-on-primary); 61 | --bs-btn-bg: var(--theme-primary); 62 | --bs-btn-border-color: var(--theme-primary); 63 | --bs-btn-hover-bg: var(--theme-primary-hover); 64 | --bs-btn-hover-border-color: var(--theme-primary-hover); 65 | --bs-toast-bg: var(--toast-bg); 66 | --bs-toast-color: var(--toast-text); 67 | --bs-toast-header-bg: var(--toast-header-bg); 68 | --bs-toast-header-color: var(--theme-text-strong); 69 | --bs-toast-border-color: var(--toast-border); 70 | --bs-btn-close-color: var(--theme-text-strong); 71 | --bs-body-emphasis-color: var(--theme-text-strong); 72 | } 73 | 74 | @media (prefers-color-scheme: dark) { 75 | :root { 76 | /* --- Dark Theme Variables --- */ 77 | --theme-primary: #4dabf7; 78 | --theme-primary-hover: #74c0fc; 79 | --theme-secondary: #868e96; 80 | --theme-secondary-hover: #adb5bd; 81 | 82 | --theme-background: #121212; 83 | --theme-surface: #1e1e1e; 84 | --theme-surface-alt: #252525; 85 | 86 | --theme-text-strong: #f8f9fa; 87 | --theme-text-default: #e0e0e0; 88 | --theme-text-muted: #adb5bd; 89 | --theme-text-on-primary: #050505; 90 | --theme-text-on-secondary: #050505; 91 | 92 | --theme-border: #424242; 93 | --theme-input-bg: #2a2a2a; 94 | --theme-input-border: #555555; 95 | --theme-input-text: #e0e0e0; 96 | --theme-input-focus-border: var(--theme-primary); 97 | --theme-input-focus-shadow: 0 0 0 0.25rem rgba(77, 171, 247, 0.35); 98 | 99 | --theme-link: var(--theme-primary); 100 | --theme-link-hover: var(--theme-primary-hover); 101 | 102 | --theme-shadow-light: rgba(255, 255, 255, 0.04); 103 | --theme-shadow-strong: rgba(255, 255, 255, 0.07); 104 | 105 | /* Chart specific - Dark */ 106 | --chart-grid: rgba(255, 255, 255, 0.12); 107 | --chart-ticks: #b0b0b0; 108 | --chart-labels: #d0d0d0; 109 | --chart-legend-color: #c0c0c0; 110 | --chart-tooltip-bg: rgba(40, 40, 40, 0.9); 111 | --chart-tooltip-text: #f0f0f0; 112 | 113 | /* Toast specific - Dark */ 114 | --toast-bg: rgba(42, 42, 42, 0.95); 115 | --toast-text: var(--theme-text-default); 116 | --toast-header-bg: #303030; 117 | --toast-border: var(--theme-border); 118 | --toast-btn-close-filter: invert(1) grayscale(100%) brightness(200%); 119 | --toast-success-bg-mix: color-mix(in srgb, #28a745 70%, #000000); 120 | --toast-danger-bg-mix: color-mix(in srgb, #dc3545 70%, #000000); 121 | 122 | /* Bootstrap specific overrides (for login.html) - Dark */ 123 | --bs-body-color: var(--theme-text-default); 124 | --bs-body-bg: var(--theme-background); 125 | --bs-primary: var(--theme-primary); 126 | --bs-secondary: var(--theme-secondary); 127 | --bs-border-color: var(--theme-border); 128 | --bs-card-bg: var(--theme-surface); 129 | --bs-card-border-color: var(--theme-border); 130 | --bs-card-cap-bg: transparent; 131 | --bs-form-control-bg: var(--theme-input-bg); 132 | --bs-form-control-color: var(--theme-input-text); 133 | --bs-form-control-border-color: var(--theme-input-border); 134 | --bs-btn-color: var(--theme-text-on-primary); 135 | --bs-btn-bg: var(--theme-primary); 136 | --bs-btn-border-color: var(--theme-primary); 137 | --bs-btn-hover-bg: var(--theme-primary-hover); 138 | --bs-btn-hover-border-color: var(--theme-primary-hover); 139 | --bs-toast-bg: var(--toast-bg); 140 | --bs-toast-color: var(--toast-text); 141 | --bs-toast-header-bg: var(--toast-header-bg); 142 | --bs-toast-header-color: var(--theme-text-strong); 143 | --bs-toast-border-color: var(--toast-border); 144 | --bs-btn-close-color: var(--theme-text-strong); 145 | --bs-body-emphasis-color: var(--theme-text-strong); 146 | } 147 | } -------------------------------------------------------------------------------- /web/chart.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/netip" 8 | "regexp" 9 | "speedtest/config" 10 | "speedtest/database" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/infinite-iroha/touka" 17 | ) 18 | 19 | // RawIspInfo 结构体表示原始 ISP 信息 20 | type RawIspInfo struct { 21 | IP string `json:"ip"` 22 | Hostname string `json:"hostname"` 23 | City string `json:"city"` 24 | Region string `json:"region"` 25 | Country string `json:"country"` 26 | Loc string `json:"loc"` 27 | Org string `json:"org"` 28 | Postal string `json:"postal"` 29 | Timezone string `json:"timezone"` 30 | Readme string `json:"readme"` 31 | } 32 | 33 | // 通用响应格式 34 | type CommonIPInfoResponse struct { 35 | IP string `json:"ip"` 36 | Org string `json:"org"` // ipinfo = org, self-host = ASN + ISP 37 | Region string `json:"region"` // ipinfo = region, self-host = nil 38 | City string `json:"city"` // ipinfo = city, self-host = nil 39 | Country string `json:"country"` // ipinfo = Country, self-host = CountryCode 40 | Continent string `json:"continent"` // ipinfo = nil, self-host = continent_name 41 | } 42 | 43 | // IspInfo 结构体表示处理后的 ISP 信息 44 | type IspInfo struct { 45 | ProcessedString string `json:"processedString"` 46 | RawIspInfo CommonIPInfoResponse `json:"rawIspInfo"` 47 | } 48 | 49 | // SimpleRateLimiter 定义一个简单的计数器限流器 50 | type SimpleRateLimiter struct { 51 | mu sync.Mutex 52 | maxReqs int // 最大请求数 53 | window time.Duration // 时间窗口 54 | count int // 当前请求计数 55 | resetTime time.Time // 窗口重置时间 56 | } 57 | 58 | var rateLimiter = NewSimpleRateLimiter(5, 10*time.Second) 59 | 60 | // NewSimpleRateLimiter 创建一个新的限流器 61 | func NewSimpleRateLimiter(maxReqs int, window time.Duration) *SimpleRateLimiter { 62 | return &SimpleRateLimiter{ 63 | maxReqs: maxReqs, 64 | window: window, 65 | count: 0, 66 | resetTime: time.Now().Add(window), 67 | } 68 | } 69 | 70 | // Allow 检查是否允许请求 71 | func (rl *SimpleRateLimiter) Allow() bool { 72 | rl.mu.Lock() 73 | defer rl.mu.Unlock() 74 | 75 | now := time.Now() 76 | 77 | // 如果当前时间超过了窗口重置时间,重置计数器 78 | if now.After(rl.resetTime) { 79 | rl.count = 0 80 | rl.resetTime = now.Add(rl.window) 81 | } 82 | 83 | // 检查是否超过最大请求数 84 | if rl.count < rl.maxReqs { 85 | rl.count++ 86 | return true 87 | } 88 | 89 | return false 90 | } 91 | 92 | // GetChartData 处理获取图表数据的请求 93 | func GetChartData(db database.DataAccess, cfg *config.Config, c *touka.Context) { 94 | 95 | if !rateLimiter.Allow() { 96 | // 如果限流,返回 429 Too Many Requests 97 | c.JSON(http.StatusTooManyRequests, touka.H{ 98 | "error": "Too Many Requests", 99 | }) 100 | return 101 | } 102 | 103 | // 获取最近N条记录 104 | records, err := db.GetLastNRecords(cfg.Frontend.Chartlist) 105 | if err != nil { 106 | c.JSON(http.StatusInternalServerError, touka.H{"error": err.Error()}) 107 | return 108 | } 109 | 110 | // 转换数据格式用于图表显示 111 | var chartData []map[string]interface{} 112 | for _, record := range records { 113 | // 转换字符串为浮点数 114 | download, _ := strconv.ParseFloat(record.Download, 64) 115 | upload, _ := strconv.ParseFloat(record.Upload, 64) 116 | ping, _ := strconv.ParseFloat(record.Ping, 64) 117 | jitter, _ := strconv.ParseFloat(record.Jitter, 64) 118 | 119 | // 解码 ISP 信息 120 | var ispInfo IspInfo 121 | err := json.Unmarshal([]byte(record.ISPInfo), &ispInfo) 122 | if err != nil { 123 | c.Errorf("解码 ISP 信息失败: %s", err) 124 | ispInfo = IspInfo{ProcessedString: "未知", RawIspInfo: CommonIPInfoResponse{}} 125 | } 126 | // 对IP信息进行预处理 127 | psIP, psRemaining, err := GetIPFromProcessedString(ispInfo.ProcessedString) 128 | if err != nil { 129 | c.Errorf("处理IP信息失败: %s", err) 130 | } 131 | ispIP := PreprocessIPInfo(ispInfo.RawIspInfo.IP) 132 | psIP = PreprocessIPInfo(psIP) 133 | newProcessedString := fmt.Sprintf("%s%s", psIP, psRemaining) 134 | // 更新ProcessedString字段 135 | ispInfo.ProcessedString = newProcessedString 136 | // 更新ispInfo.RawIspInfo.IP字段 137 | ispInfo.RawIspInfo.IP = ispIP 138 | // 重新编码ISP信息 139 | ispInfoJSON, err := json.Marshal(ispInfo) 140 | if err != nil { 141 | c.Errorf("编码 ISP 信息失败: %s", err) 142 | ispInfoJSON = []byte("{}") 143 | } 144 | record.ISPInfo = string(ispInfoJSON) 145 | 146 | //logInfo("%s", record.ISPInfo) 147 | //logInfo("%s", ispInfo.ProcessedString) 148 | chartData = append(chartData, map[string]interface{}{ 149 | "timestamp": record.Timestamp, 150 | "download": download, 151 | "upload": upload, 152 | "ping": ping, 153 | "jitter": jitter, 154 | "isp": record.ISPInfo, 155 | }) 156 | } 157 | 158 | c.JSON(http.StatusOK, chartData) 159 | } 160 | 161 | // GetIPFromProcessedString 分割ProcessedString字段, 取出IP 162 | func GetIPFromProcessedString(processedString string) (string, string, error) { 163 | // 查找 ' - ' 的位置 164 | index := strings.Index(processedString, " - ") 165 | if index == -1 { 166 | return "", "", fmt.Errorf("ProcessedString不符合规范: %s", processedString) 167 | } 168 | 169 | ip := processedString[:index] 170 | _, isRegularIP := isIP(ip) 171 | if !isRegularIP { 172 | //logWarning("IP信息不符合规范: %s", ip) 173 | return "", "", fmt.Errorf("IP信息不符合规范: %s", ip) 174 | } 175 | 176 | // 取出IP和剩余部分 177 | remaining := processedString[index:] // 包含 ' - ' 和后面的内容 178 | return ip, remaining, nil 179 | } 180 | 181 | // 检测IP是否符合规范 182 | func isIP(ip string) (netip.Addr, bool) { 183 | // 使用net包进行检测 184 | addr, err := netip.ParseAddr(ip) 185 | if err != nil { 186 | return netip.Addr{}, false 187 | } 188 | return addr, true 189 | } 190 | 191 | // 对IP信息进行预处理, 一定程度上减少隐私问题 192 | func PreprocessIPInfo(ip string) string { 193 | // 判断是否为特殊IP 194 | if isSpecialIP(ip) { 195 | return ip // 直接返回原始 IP 196 | } 197 | 198 | //string to netip.Addr 199 | addr, _ := isIP(ip) 200 | if addr.Is4() { 201 | parts := strings.Split(ip, ".") 202 | if len(parts) == 4 { 203 | return fmt.Sprintf("%s.%s.%s.x", parts[0], parts[1], parts[2]) // 保留前 24 位,最后一部分用 x 代替 204 | } 205 | } else if addr.Is6() { 206 | parts := strings.Split(ip, ":") 207 | if len(parts) >= 3 { 208 | return fmt.Sprintf("%s:%s:%s::", parts[0], parts[1], parts[2]) // 保留前 48 位 209 | } 210 | } 211 | 212 | return ip // 如果不匹配,返回原始 IP 213 | } 214 | 215 | var specialIPPatterns = []*regexp.Regexp{ 216 | localIPv6Regex, 217 | linkLocalIPv6Regex, 218 | localIPv4Regex, 219 | privateIPv4Regex10, 220 | privateIPv4Regex172, 221 | privateIPv4Regex192, 222 | linkLocalIPv4Regex, 223 | cgnatIPv4Regex, 224 | unspecifiedAddressRegex, 225 | broadcastAddressRegex, 226 | } 227 | 228 | // 特殊IP模式 229 | func isSpecialIP(ip string) bool { 230 | for _, pattern := range specialIPPatterns { 231 | if pattern.MatchString(ip) { 232 | return true 233 | } 234 | } 235 | return false 236 | } 237 | -------------------------------------------------------------------------------- /web/garbage.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "math/rand" 8 | "net/http" 9 | "speedtest/config" 10 | "strconv" 11 | 12 | "github.com/WJQSERVER-STUDIO/go-utils/copyb" 13 | "github.com/infinite-iroha/touka" 14 | ) 15 | 16 | const ( 17 | // defaultChunkSize 块尺寸为 1 MiB 18 | defaultChunkSize = 1 * 1024 * 1024 19 | defaultChunks = 4 // 默认 chunk 数量 20 | maxChunks = 1024 // 最大允许的 chunk 数量,防止滥用 21 | ) 22 | 23 | var ( 24 | // 随机数据块 25 | dlChunkSize int 26 | dlChunks int 27 | randomData []byte // 声明为 slice,在 init 中初始化 28 | ) 29 | 30 | // getRandomData 生成指定大小的随机数据块 31 | func getRandomData(size int) ([]byte, error) { 32 | data := make([]byte, size) 33 | _, err := rand.Read(data) // 使用 crypto/rand.Read 34 | if err != nil { 35 | //return nil, err 36 | panic("Failed to initialize random data pool") // 启动时失败就直接 panic 37 | } 38 | if len(data) != size { 39 | panic(fmt.Sprintf("getRandomData generated data of size %d, expected %d", len(randomData), size)) 40 | } 41 | 42 | return data, nil 43 | } 44 | 45 | // RandomDataInit 初始化随机数据块,在程序启动时调用 46 | func RandomDataInit(cfg *config.Config) { 47 | var err error 48 | dlChunkSize = defaultChunkSize 49 | if cfg.Speedtest.DownDataChunkSize > 0 { 50 | dlChunkSize = cfg.Speedtest.DownDataChunkSize * 1024 * 1024 51 | } 52 | 53 | dlChunks = defaultChunks 54 | if cfg.Speedtest.DownDataChunkCount > 0 { 55 | dlChunks = cfg.Speedtest.DownDataChunkCount 56 | } 57 | 58 | randomData, err = getRandomData(dlChunkSize) // 初始化 randomData 59 | if err != nil { 60 | fmt.Printf("Failed to initialize random data: %v", err) 61 | return 62 | } 63 | if randomData == nil { // 检查 randomData 是否生成成功 64 | fmt.Printf("Failed to initialize random data. Program cannot continue.") 65 | // panic 退出程序,因为依赖的随机数据无法生成 66 | panic("Failed to initialize random data. Program cannot continue.") 67 | } 68 | 69 | fmt.Printf("RandomDataInit: dlChunkSize=%d, dlChunks=%d\n", dlChunkSize, dlChunks) 70 | } 71 | 72 | // garbage 处理对 /garbage 的请求,返回指定数量的随机数据块 73 | func garbage(c *touka.Context) { 74 | c.SetHeader("Content-Description", "File Transfer") 75 | c.SetHeader("Content-Type", "application/octet-stream") 76 | c.SetHeader("Content-Disposition", "attachment; filename=random.dat") 77 | c.SetHeader("Content-Transfer-Encoding", "binary") 78 | 79 | chunks := dlChunks // 默认 chunk 数量 80 | 81 | ckSizeStr := c.Query("ckSize") 82 | if ckSizeStr != "" { 83 | ckSize, err := strconv.ParseInt(ckSizeStr, 10, 64) 84 | if err != nil { 85 | c.String(http.StatusBadRequest, "%s", "Invalid ckSize parameter: "+err.Error()) // 返回 400 错误,告知客户端参数错误和具体错误信息 86 | return 87 | } 88 | 89 | if ckSize > maxChunks { 90 | chunks = maxChunks 91 | } else if ckSize > 0 { 92 | chunks = int(ckSize) 93 | } else { 94 | c.String(http.StatusBadRequest, "ckSize must be greater than 0") // 返回 400 错误,告知客户端参数错误 95 | return 96 | } 97 | } 98 | 99 | // 发送随机数据块 100 | //for i := 0; i < chunks; i++ { 101 | // _, err := c.Writer.Write(randomData) 102 | // c.Writer.Flush() // 刷新缓冲区 103 | // if err != nil { 104 | // //c.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to write chunk %d: %w", i, err)) // 包含 chunk 索引,方便调试 105 | // c.AbortWithStatus(http.StatusInternalServerError) // 返回 500 错误 106 | // return 107 | // } 108 | // } 109 | 110 | writer := c.Writer 111 | for i := 0; i < chunks; i++ { 112 | _, err := writer.Write(randomData) 113 | if err != nil { 114 | // 检查是否是客户端断开连接导致的错误 115 | if err == http.ErrAbortHandler { 116 | c.Warnf("Client disconnected while writing garbage data") 117 | return // 客户端断开连接,直接返回 118 | } 119 | c.Errorf("Failed to write chunk %d: %v", i, err) 120 | //c.AbortWithStatus(http.StatusInternalServerError) // 返回 500 错误 121 | return // 写入失败,直接返回 122 | } 123 | 124 | writer.Flush() 125 | 126 | //time.Sleep(5 * time.Millisecond) 127 | } 128 | } 129 | 130 | var ( 131 | // randomSeedData 是在程序启动时生成并常驻内存的随机数据"种子". 132 | // 所有下载测试都将重复使用这个数据块来生成流. 133 | randomSeedData []byte 134 | ) 135 | 136 | // RandomDataInit 初始化随机数据种子, 必须在程序启动时调用一次. 137 | func RandomDataInitStream(cfg *config.Config) { 138 | var err error 139 | dlChunkSize = defaultChunkSize 140 | if cfg.Speedtest.DownDataChunkSize > 0 { 141 | dlChunkSize = cfg.Speedtest.DownDataChunkSize * 1024 * 1024 142 | } 143 | 144 | dlChunks = defaultChunks 145 | if cfg.Speedtest.DownDataChunkCount > 0 { 146 | dlChunks = cfg.Speedtest.DownDataChunkCount 147 | } 148 | 149 | fmt.Printf("Initializing random data seed (%d bytes)...\n", dlChunkSize) 150 | // 使用 crypto/rand 生成高质量的随机种子 151 | seed := make([]byte, dlChunkSize) 152 | _, err = rand.Read(seed) 153 | if err != nil { 154 | // 如果在启动时无法生成种子数据, 这是一个致命错误, 程序无法继续. 155 | panic(fmt.Sprintf("FATAL: Failed to initialize random data seed: %v", err)) 156 | } 157 | randomSeedData = seed 158 | fmt.Println("Random data seed initialized successfully.") 159 | } 160 | 161 | // garbage 处理下载请求, 高效地流式传输重复的随机数据. 162 | func garbageStream(c *touka.Context) { 163 | // 设置标准的下载文件响应头 164 | c.SetHeader("Content-Description", "File Transfer") 165 | c.SetHeader("Content-Type", "application/octet-stream") 166 | c.SetHeader("Content-Disposition", "attachment; filename=random.dat") 167 | c.SetHeader("Content-Transfer-Encoding", "binary") 168 | 169 | // 定义默认值和限制 (与之前相同) 170 | const defaultChunks = 4 // 默认下载的块数量 171 | const maxChunks = 4096 // 可以适当调大最大值 172 | chunkSize := int64(dlChunkSize) // 每个块的大小就是我们种子的大小 173 | 174 | chunks := int64(defaultChunks) 175 | ckSizeStr := c.Query("ckSize") 176 | if ckSizeStr != "" { 177 | ckSize, err := strconv.ParseInt(ckSizeStr, 10, 64) 178 | if err != nil || ckSize <= 0 { 179 | c.String(http.StatusBadRequest, "Invalid ckSize parameter: must be a positive integer") 180 | return 181 | } 182 | chunks = ckSize 183 | if chunks > maxChunks { 184 | chunks = maxChunks 185 | } 186 | } 187 | 188 | // 计算总共需要发送的数据量 (字节) 189 | totalBytesToSend := chunkSize * chunks 190 | 191 | // --- 高性能流式处理的核心 --- 192 | 193 | // 1. 创建一个 readers 切片. 194 | // 我们需要重复种子数据 `chunks` 次, 所以创建 `chunks` 个指向种子的 Reader. 195 | readers := make([]io.Reader, chunks) 196 | for i := range readers { 197 | // bytes.NewReader 从一个字节切片创建一个 io.Reader. 198 | // 这个操作非常轻量, 它只是创建了一个指向内存中 `randomSeedData` 的视图. 199 | readers[i] = bytes.NewReader(randomSeedData) 200 | } 201 | 202 | // 2. 使用 io.MultiReader 将多个 Reader 连接成一个单一的逻辑流. 203 | // 当一个 Reader 被读完(返回EOF)时, MultiReader 会自动开始读取下一个. 204 | // 这就实现了一个重复 `randomSeedData` `chunks` 次的虚拟大文件流. 205 | repeatingStream := io.MultiReader(readers...) 206 | 207 | // 3. 使用 io.Copy 将这个逻辑流高效地写入HTTP响应. 208 | // 注意: 这里不再需要 io.LimitedReader, 因为 MultiReader 本身就是有限的. 209 | 210 | // 测试用代码, 限制速度, 避免回环CPU瓶颈造成的速度下降 211 | //limitSpeed, _ := toukautil.ParseRate("9gbps") 212 | //limitwriter := toukautil.NewRateLimitedWriter(c.Writer, limitSpeed, int(limitSpeed), c.Request.Context()) 213 | //written, err := copyb.Copy(limitwriter, repeatingStream) 214 | 215 | written, err := copyb.Copy(c.Writer, repeatingStream) 216 | if err != nil { 217 | c.Warnf("Error during streaming garbage data: %v. Bytes written: %d", err, written) 218 | return 219 | } 220 | 221 | // (可选) 调试日志 222 | if written != totalBytesToSend { 223 | c.Warnf("Data stream truncated: expected to write %d bytes, but only wrote %d", totalBytesToSend, written) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /web/pages/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 登录 - SpeedTest-EX 7 | 8 | 11 | 108 | 109 | 110 | 131 | 132 | 133 |
134 | 143 |
144 | 145 | 148 | 213 | 219 | 224 | 225 | -------------------------------------------------------------------------------- /web/pages/styles.css: -------------------------------------------------------------------------------- 1 | /* styles.css - Common styles for all pages */ 2 | body { 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; 4 | margin: 0; 5 | background-color: var(--theme-background); 6 | color: var(--theme-text-default); 7 | line-height: 1.6; 8 | padding: 20px; 9 | box-sizing: border-box; 10 | transition: background-color 0.3s ease, color 0.3s ease; 11 | } 12 | 13 | .container { /* General container for index, login */ 14 | background: var(--theme-surface); 15 | border-radius: 16px; 16 | box-shadow: 0 6px 20px var(--theme-shadow-strong); 17 | padding: 30px; /* Default padding */ 18 | width: 100%; 19 | max-width: 600px; /* Default max-width */ 20 | margin: 20px auto; 21 | transition: background-color 0.3s ease, box-shadow 0.3s ease; 22 | } 23 | 24 | h1 { 25 | color: var(--theme-primary); 26 | margin-bottom: 25px; 27 | font-size: 2rem; 28 | font-weight: 600; 29 | text-align: center; 30 | } 31 | 32 | /* Buttons (shared styles) */ 33 | .button, .start-stop-button, .back-home-button, .btn-primary /* Bootstrap button */ { 34 | background-color: var(--theme-primary) !important; 35 | color: var(--theme-text-on-primary) !important; 36 | border: 1px solid var(--theme-primary) !important; 37 | padding: 10px 20px; 38 | border-radius: 25px; 39 | cursor: pointer; 40 | font-size: 0.95rem; 41 | font-weight: 500; 42 | text-decoration: none; 43 | display: inline-block; 44 | text-align: center; 45 | transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, transform 0.1s ease; 46 | } 47 | .button:hover, .start-stop-button:hover, .back-home-button:hover, .btn-primary:hover { 48 | background-color: var(--theme-primary-hover) !important; 49 | border-color: var(--theme-primary-hover) !important; 50 | transform: translateY(-1px); 51 | } 52 | .button.secondary, .btn-secondary { 53 | background-color: var(--theme-secondary) !important; 54 | border-color: var(--theme-secondary) !important; 55 | color: var(--theme-text-on-secondary) !important; 56 | } 57 | .button.secondary:hover, .btn-secondary:hover { 58 | background-color: var(--theme-secondary-hover) !important; 59 | border-color: var(--theme-secondary-hover) !important; 60 | } 61 | .start-stop-button.running { background-color: #dc3545 !important; border-color: #dc3545 !important; } 62 | .start-stop-button.running:hover { background-color: #c82333 !important; border-color: #c82333 !important; } 63 | 64 | 65 | /* --- Chart Page Specific --- */ 66 | body[data-page="chart"] .container { 67 | max-width: 1200px; 68 | background: transparent; 69 | box-shadow: none; 70 | padding: 0 15px; 71 | } 72 | .header-controls { 73 | display: flex; 74 | justify-content: space-between; 75 | align-items: center; 76 | margin-bottom: 25px; 77 | padding: 15px 20px; 78 | background-color: var(--theme-surface); /* Changed from --header-controls-bg */ 79 | border-radius: 12px; 80 | box-shadow: 0 4px 12px var(--theme-shadow-light); 81 | } 82 | .unit-switcher label, .unit-switcher-main label { 83 | font-size: 0.9rem; 84 | color: var(--theme-text-muted); 85 | margin-right: 8px; 86 | } 87 | .unit-switcher select, .unit-switcher-main select { 88 | padding: 8px 12px; border-radius: 6px; border: 1px solid var(--theme-border); 89 | background-color: var(--theme-input-bg); color: var(--theme-input-text); 90 | font-size: 0.9rem; cursor: pointer; 91 | } 92 | .info-panel, .chart-wrapper { 93 | background: var(--theme-surface); border-radius: 12px; 94 | padding: 25px; margin-bottom: 25px; box-shadow: 0 4px 12px var(--theme-shadow-light); 95 | } 96 | .info-title, .chart-title { 97 | font-size: 1.2rem; font-weight: 600; color: var(--theme-text-strong); 98 | margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid var(--theme-border); 99 | } 100 | .info-content { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 25px; } 101 | .info-item div { margin-bottom: 8px; font-size: 0.95rem; color: var(--theme-text-default); } 102 | .info-item span { font-weight: 600; color: var(--theme-primary); } 103 | .info-item #latest-isp { color: var(--theme-text-default); } 104 | canvas { max-height: 400px; } 105 | .loader { text-align: center; padding: 40px; font-size: 1.1rem; color: var(--theme-text-muted); } 106 | .error-message { 107 | text-align: center; padding: 20px; color: #dc3545; 108 | background-color: color-mix(in srgb, #dc3545 15%, transparent); 109 | border: 1px solid color-mix(in srgb, #dc3545 30%, transparent); 110 | border-radius: 8px; 111 | } 112 | 113 | /* --- Index/Single Page Specific --- */ 114 | body[data-page="main-speedtest"] .container, 115 | body[data-page="single-speedtest"] .container { 116 | padding-top: 30px; padding-bottom: 30px; text-align: center; 117 | } 118 | body[data-page="main-speedtest"] h1, body[data-page="single-speedtest"] h1 { 119 | font-size: 2rem; 120 | } 121 | .start-stop-button { /* This is used by index/single */ 122 | padding: 12px 30px; font-size: 1.1rem; margin: 0 auto 30px auto; display: block; min-width: 180px; 123 | } 124 | .test-area { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 20px; margin-bottom: 30px; } 125 | .test-box { 126 | background-color: var(--theme-surface-alt); border-radius: 12px; 127 | padding: 20px 15px; box-shadow: 0 2px 6px var(--theme-shadow-light); 128 | } 129 | .test-name { font-size: 0.85rem; color: var(--theme-text-muted); margin-bottom: 8px; font-weight: 500; } 130 | .meter-text { font-size: 1.8rem; font-weight: 700; color: var(--theme-primary); line-height: 1.2; } 131 | .meter-text.sub { color: var(--theme-secondary); font-size: 1.6rem; } 132 | .unit { font-size: 0.8rem; color: var(--theme-text-muted); } 133 | .ip-area { margin-bottom: 25px; font-size: 0.9rem; color: var(--theme-text-muted); } 134 | .ip-area strong { color: var(--theme-text-strong); } 135 | .controls-area { display: flex; flex-direction: column; align-items: center; gap: 15px; margin-bottom: 25px; } 136 | .link-buttons { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-bottom: 10px; } 137 | .link-button { 138 | padding: 8px 18px; font-size: 0.85rem; color: var(--theme-link); 139 | background-color: transparent; border: 1px solid var(--theme-link); 140 | border-radius: 20px; text-decoration: none; transition: all 0.2s ease; 141 | } 142 | .link-button:hover { background-color: var(--theme-link); color: var(--theme-text-on-primary); } 143 | .rev-ping-area { font-size: 0.9rem; color: var(--theme-text-muted); } 144 | .rev-ping-area strong { color: var(--theme-text-strong); } 145 | 146 | /* --- Login Page Specific --- */ 147 | body[data-page="login"] { 148 | display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; 149 | } 150 | body[data-page="login"] .container { max-width: 420px; margin-top: -5vh; } 151 | .login-app-title { 152 | text-align: center; color: var(--theme-primary); margin-bottom: 25px; 153 | font-size: 1.8rem; font-weight: 600; 154 | } 155 | body[data-page="login"] .card { /* Bootstrap card uses --bs-card-bg from :root */ 156 | border-radius: 12px; /* Custom radius */ 157 | box-shadow: 0 6px 20px var(--theme-shadow-strong); 158 | } 159 | body[data-page="login"] .card-header { /* Uses --bs-card-cap-bg, --bs-border-color, --bs-body-color */ 160 | padding: 1.25rem 1.5rem; font-size: 1.2rem; font-weight: 500; text-align: center; 161 | } 162 | body[data-page="login"] .card-body { padding: 1.5rem 2rem; } 163 | .form-label { /* Bootstrap label, uses general text colors */ 164 | font-weight: 500; margin-bottom: 0.3rem; color: var(--theme-text-muted); font-size: 0.9rem; 165 | } 166 | /* .form-control and .btn-primary will use --bs-* variables defined in :root */ 167 | body[data-page="login"] .btn-primary { width: 100%; } 168 | 169 | 170 | /* Version Badge (common) */ 171 | .version-badge { 172 | position: fixed; bottom: 15px; right: 15px; 173 | background-color: var(--theme-secondary); /* Default to secondary */ 174 | color: var(--theme-text-on-secondary); 175 | padding: 5px 10px; border-radius: 15px; font-size: 0.75rem; opacity: 0.9; 176 | box-shadow: 0 2px 6px var(--theme-shadow-light); 177 | transition: background-color 0.3s ease, color 0.3s ease; 178 | } 179 | 180 | /* Toast (Bootstrap override for dark/light mode from variables) */ 181 | .toast-container .toast { 182 | background-color: var(--bs-toast-bg); /* Uses BS var which is our theme var */ 183 | color: var(--bs-toast-color); 184 | border: 1px solid var(--bs-toast-border-color); 185 | border-radius: 0.5rem; 186 | box-shadow: 0 0.5rem 1rem var(--theme-shadow-strong); 187 | } 188 | .toast-container .toast .toast-header { 189 | background-color: var(--bs-toast-header-bg); 190 | color: var(--bs-toast-header-color); 191 | border-bottom: 1px solid var(--bs-toast-border-color); 192 | } 193 | .toast-container .toast .btn-close { 194 | filter: var(--toast-btn-close-filter); /* Uses variable for filter */ 195 | } 196 | /* Contextual toasts need to use the mixed colors for better visibility */ 197 | .toast.text-bg-success { 198 | background-color: color-mix(in srgb, var(--toast-success-bg-mix) 90%, var(--bs-toast-bg)) !important; 199 | color: var(--theme-text-strong) !important; /* Ensure high contrast text */ 200 | } 201 | .toast.text-bg-danger { 202 | background-color: color-mix(in srgb, var(--toast-danger-bg-mix) 90%, var(--bs-toast-bg)) !important; 203 | color: var(--theme-text-strong) !important; 204 | } 205 | 206 | 207 | /* Footer (common) */ 208 | footer { 209 | width: 100%; text-align: center; font-size: 0.85rem; 210 | color: var(--theme-text-muted); padding: 20px 0 10px 0; 211 | margin-top: auto; 212 | border-top: 1px solid var(--theme-border); 213 | transition: color 0.3s ease, border-color 0.3s ease; 214 | } 215 | footer a { color: var(--theme-link); text-decoration: none; } 216 | footer a:hover { text-decoration: underline; } -------------------------------------------------------------------------------- /web/pages/single.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SpeedTest-EX 网络测速 (单线程) 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

SpeedTest-EX (单线程)

17 | 18 | 19 |
20 |
21 |
下载速度
22 |
-
23 |
Mbps
24 |
25 |
26 |
上传速度
27 |
-
28 |
Mbps
29 |
30 |
31 |
Ping
32 |
-
33 |
ms
34 |
35 |
36 |
抖动
37 |
-
38 |
ms
39 |
40 |
41 | 42 |
43 | 您的IP: 获取中... 44 |
45 | 46 |
47 |
48 | 49 | 53 |
54 | 58 |
59 | 反向 Ping: - ms 60 |
61 |
62 |
63 | 64 |
版本加载中...
65 | 66 | 73 | 74 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /web/pages/speedtest.js: -------------------------------------------------------------------------------- 1 | /* 2 | LibreSpeed - 主程序 3 | 作者:Federico Dossena 4 | https://github.com/librespeed/speedtest/ 5 | GNU LGPLv3 许可证 6 | */ 7 | 8 | 9 | // 定义测速状态常量, 提高代码可读性 10 | const SpeedtestStatus = { 11 | WAITING: 0, // 等待配置或添加服务器 12 | ADDING_SERVERS: 1, // 正在添加服务器 13 | SERVER_SELECTED: 2, // 服务器选择完成 14 | RUNNING: 3, // 测试进行中 15 | DONE: 4, // 测试完成 16 | ABORTED: 5 // 测试已中止(此状态在worker内部使用) 17 | }; 18 | 19 | class Speedtest { 20 | /** 21 | * Speedtest构造函数 22 | */ 23 | constructor() { 24 | this._serverList = []; // 测试节点服务器列表 25 | this._selectedServer = null; // 已选择的服务器 26 | this._settings = {}; // 传递给worker的测速设置 27 | this._status = SpeedtestStatus.WAITING; // 当前状态机状态 28 | this._worker = null; // Web Worker实例 29 | this._updateInterval = null; // 状态更新定时器 30 | this._originalExtra = undefined; // 原始的遥测附加数据 31 | 32 | console.log("LibreSpeed by Federico Dossena v5.4.1 - https://github.com/librespeed/speedtest"); 33 | console.log("Refactored by WJQSERVER & Gemini-AI - https://github.com/WJQSERVER/speedtest-ex"); 34 | } 35 | 36 | /** 37 | * 获取当前测试状态 38 | * @returns {number} 状态码 (参考 SpeedtestStatus) 39 | */ 40 | getStatus() { 41 | return this._status; 42 | } 43 | 44 | /** 45 | * 设置测速参数 46 | * @param {string} parameter - 参数名 47 | * @param {*} value - 参数值 48 | */ 49 | setParameter(parameter, value) { 50 | if (this._status === SpeedtestStatus.RUNNING) { 51 | throw new Error("Cannot change settings while test is running"); 52 | } 53 | this._settings[parameter] = value; 54 | if (parameter === "telemetry_extra") { 55 | this._originalExtra = value; 56 | } 57 | } 58 | 59 | /** 60 | * 校验服务器对象定义是否合法 61 | * @param {object} server - 服务器对象 62 | * @private 63 | */ 64 | _checkServerDefinition(server) { 65 | try { 66 | if (typeof server.name !== "string") throw new Error("Server definition missing 'name' string"); 67 | if (typeof server.server !== "string") throw new Error("Server definition missing 'server' string address"); 68 | if (server.server.slice(-1) !== "/") server.server += "/"; 69 | if (server.server.startsWith("//")) server.server = window.location.protocol + server.server; 70 | if (typeof server.dlURL !== "string") throw new Error("Server definition missing 'dlURL' string"); 71 | if (typeof server.ulURL !== "string") throw new Error("Server definition missing 'ulURL' string"); 72 | if (typeof server.pingURL !== "string") throw new Error("Server definition missing 'pingURL' string"); 73 | if (typeof server.getIpURL !== "string") throw new Error("Server definition missing 'getIpURL' string"); 74 | } catch (e) { 75 | throw new Error(`Invalid server definition: ${e.message}`); 76 | } 77 | } 78 | 79 | /** 80 | * 添加一个测速节点 81 | * @param {object} server - 服务器对象 82 | */ 83 | addTestPoint(server) { 84 | this._checkServerDefinition(server); 85 | if (this._status === SpeedtestStatus.WAITING) this._status = SpeedtestStatus.ADDING_SERVERS; 86 | if (this._status !== SpeedtestStatus.ADDING_SERVERS) throw new Error("Cannot add server after server selection"); 87 | this._settings.mpot = true; 88 | this._serverList.push(server); 89 | } 90 | 91 | /** 92 | * 添加一个服务器对象数组作为测速节点 93 | * @param {Array} list - 服务器对象数组 94 | */ 95 | addTestPoints(list) { 96 | list.forEach(server => this.addTestPoint(server)); 97 | } 98 | 99 | /** 100 | * 从指定URL加载JSON格式的服务器列表 101 | * @param {string} url - 服务器列表的URL 102 | * @param {function(Array|null)} callback - 加载完成后的回调函数 103 | */ 104 | loadServerList(url, callback) { 105 | if (this._status === SpeedtestStatus.WAITING) this._status = SpeedtestStatus.ADDING_SERVERS; 106 | if (this._status !== SpeedtestStatus.ADDING_SERVERS) throw new Error("Cannot add server after server selection"); 107 | this._settings.mpot = true; 108 | 109 | const xhr = new XMLHttpRequest(); 110 | xhr.withCredentials = true; 111 | xhr.onload = () => { 112 | try { 113 | const servers = JSON.parse(xhr.responseText); 114 | servers.forEach(server => this._checkServerDefinition(server)); 115 | this.addTestPoints(servers); 116 | callback(servers); 117 | } catch (e) { 118 | console.error("Failed to parse server list", e); 119 | callback(null); 120 | } 121 | }; 122 | xhr.onerror = () => callback(null); 123 | xhr.open("GET", url); 124 | xhr.send(); 125 | } 126 | 127 | /** 128 | * 获取当前选定的服务器 129 | * @returns {object} 130 | */ 131 | getSelectedServer() { 132 | if (this._status < SpeedtestStatus.SERVER_SELECTED || !this._selectedServer) { 133 | throw new Error("No server selected"); 134 | } 135 | return this._selectedServer; 136 | } 137 | 138 | /** 139 | * 手动设置要使用的测速服务器 140 | * @param {object} server - 服务器对象 141 | */ 142 | setSelectedServer(server) { 143 | this._checkServerDefinition(server); 144 | if (this._status === SpeedtestStatus.RUNNING) { 145 | throw new Error("Cannot select server while test is running"); 146 | } 147 | this._selectedServer = server; 148 | this._status = SpeedtestStatus.SERVER_SELECTED; 149 | } 150 | 151 | /** 152 | * 内部方法: 对单个服务器进行ping测试 153 | * @param {object} server - 要ping的服务器对象 154 | * @param {number} pings - ping的次数 155 | * @returns {Promise} 返回最佳ping值 156 | * @private 157 | */ 158 | _pingServer(server) { 159 | return new Promise((resolve, reject) => { 160 | const PING_TIMEOUT = 2000; 161 | const PINGS_TO_PERFORM = 3; // 每个服务器ping 3次取最优值 162 | const SLOW_THRESHOLD = 500; // 如果ping值高于此值则停止后续ping 163 | let pings = []; 164 | let completedPings = 0; 165 | 166 | const performPing = () => { 167 | if (completedPings >= PINGS_TO_PERFORM) { 168 | if (pings.length > 0) { 169 | resolve(Math.min(...pings)); 170 | } else { 171 | reject(new Error("Pings failed to return a value")); 172 | } 173 | return; 174 | } 175 | 176 | const url = `${server.server}${server.pingURL}${(server.pingURL.includes('?') ? '&' : '?')}cors=true`; 177 | const request = new XMLHttpRequest(); 178 | request.withCredentials = true; 179 | let startTime = Date.now(); 180 | 181 | request.onload = () => { 182 | if (request.status === 200) { 183 | let latency = Date.now() - startTime; 184 | // 尝试使用更精确的 Performance API 185 | try { 186 | const perfEntry = performance.getEntriesByName(url).pop(); 187 | if (perfEntry) { 188 | const perfLatency = perfEntry.responseStart - perfEntry.requestStart; 189 | if (perfLatency > 0 && perfLatency < latency) { 190 | latency = perfLatency; 191 | } 192 | } 193 | } catch (e) { 194 | // 忽略错误, 使用XHR的时间 195 | } 196 | pings.push(latency); 197 | completedPings++; 198 | if (latency < SLOW_THRESHOLD) { 199 | performPing(); // 如果延迟低, 继续ping 200 | } else { 201 | resolve(Math.min(...pings)); // 延迟高, 提前结束 202 | } 203 | } else { 204 | reject(new Error(`Ping request failed with status ${request.status}`)); 205 | } 206 | }; 207 | 208 | request.onerror = () => { 209 | completedPings++; 210 | performPing(); 211 | }; 212 | request.ontimeout = () => { 213 | completedPings++; 214 | performPing(); 215 | }; 216 | 217 | request.open('GET', url, true); 218 | request.timeout = PING_TIMEOUT; 219 | request.send(); 220 | }; 221 | 222 | performPing(); 223 | }); 224 | } 225 | 226 | /** 227 | * 自动选择延迟最低的服务器, 这是一个异步操作 228 | * @param {function(object|null)} resultCallback - 选择完成后的回调函数, 参数为选中的服务器或null 229 | */ 230 | async selectServer(resultCallback) { 231 | if (this._status === SpeedtestStatus.WAITING) throw new Error("No test points added"); 232 | if (this._status >= SpeedtestStatus.SERVER_SELECTED) throw new Error("Server already selected"); 233 | if (this._status === SpeedtestStatus.RUNNING) throw new Error("Cannot select server while test is running"); 234 | 235 | const pingPromises = this._serverList.map(server => this._pingServer(server).then(ping => ({ server, ping })).catch(() => null)); 236 | 237 | const results = await Promise.all(pingPromises); 238 | 239 | const validResults = results.filter(r => r !== null); 240 | 241 | if (validResults.length === 0) { 242 | this._selectedServer = null; 243 | if (resultCallback) resultCallback(null); 244 | return; 245 | } 246 | 247 | const bestResult = validResults.reduce((best, current) => (current.ping < best.ping ? current : best)); 248 | 249 | this._selectedServer = bestResult.server; 250 | this._status = SpeedtestStatus.SERVER_SELECTED; 251 | 252 | if (resultCallback) { 253 | resultCallback(this._selectedServer); 254 | } 255 | } 256 | 257 | /** 258 | * 开始测速 259 | */ 260 | start() { 261 | if (this._status === SpeedtestStatus.RUNNING) throw new Error("Test is already running"); 262 | if (this._status === SpeedtestStatus.WAITING || this._status === SpeedtestStatus.ADDING_SERVERS) { 263 | throw new Error("You must select a server before starting the test"); 264 | } 265 | 266 | this._worker = new Worker("speedtest_worker.js?r=" + Math.random()); 267 | 268 | this._worker.onmessage = (event) => { 269 | const data = event.data; 270 | if (this.onupdate) this.onupdate(data); 271 | 272 | if (data.testState >= SpeedtestStatus.DONE) { 273 | if (this._updateInterval) clearInterval(this._updateInterval); 274 | this._status = SpeedtestStatus.DONE; 275 | if (this.onend) this.onend(data.testState === SpeedtestStatus.ABORTED); 276 | } 277 | }; 278 | 279 | this._updateInterval = setInterval(() => { 280 | this._worker.postMessage({ command: "status" }); 281 | }, 200); 282 | 283 | if (this._status === SpeedtestStatus.SERVER_SELECTED) { 284 | this._settings.url_dl = this._selectedServer.server + this._selectedServer.dlURL; 285 | this._settings.url_ul = this._selectedServer.server + this._selectedServer.ulURL; 286 | this._settings.url_ping = this._selectedServer.server + this._selectedServer.pingURL; 287 | this._settings.url_getIp = this._selectedServer.server + this._selectedServer.getIpURL; 288 | 289 | const extra = { server: this._selectedServer.name }; 290 | if (this._originalExtra) { 291 | extra.extra = this._originalExtra; 292 | } 293 | this._settings.telemetry_extra = JSON.stringify(extra); 294 | } 295 | 296 | this._status = SpeedtestStatus.RUNNING; 297 | this._worker.postMessage({ command: "start", settings: this._settings }); 298 | } 299 | 300 | /** 301 | * 中止测试 302 | */ 303 | abort() { 304 | if (this._status < SpeedtestStatus.RUNNING) { 305 | throw new Error("Cannot abort a test that is not running"); 306 | } 307 | if (this._status === SpeedtestStatus.RUNNING && this._worker) { 308 | this._worker.postMessage({ command: "abort" }); 309 | } 310 | } 311 | 312 | // 事件回调钩子, 由用户定义 313 | onupdate = (data) => {}; // 接收 {dlStatus, ulStatus, pingStatus, jitterStatus, ...} 314 | onend = (aborted) => {}; // 接收一个布尔值, 表示测试是否被中止 315 | } -------------------------------------------------------------------------------- /web/pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SpeedTest-EX 网络测速 (多线程) 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

SpeedTest-EX (多线程)

17 | 18 | 19 |
20 |
21 |
下载速度
22 |
-
23 |
Mbps
24 |
25 |
26 |
上传速度
27 |
-
28 |
Mbps
29 |
30 |
31 |
Ping
32 |
-
33 |
ms
34 |
35 |
36 |
抖动
37 |
-
38 |
ms
39 |
40 |
41 | 42 |
43 | 您的IP: 获取中... 44 |
45 | 46 |
47 |
48 | 49 | 53 |
54 | 58 |
59 | 反向 Ping: - ms 60 |
61 |
62 |
63 | 64 |
版本加载中...
65 | 66 | 73 | 74 | 237 | 238 | 239 | -------------------------------------------------------------------------------- /web/pages/speedtest_worker.js: -------------------------------------------------------------------------------- 1 | /* 2 | LibreSpeed - Worker 3 | 作者:Federico Dossena 4 | https://github.com/librespeed/speedtest/ 5 | GNU LGPLv3 许可证 6 | */ 7 | 8 | // --- 状态与数据 --- 9 | let testState = -1; // -1=未开始, 0=启动中, 1=下载, 2=Ping, 3=上传, 4=完成, 5=中止 10 | let dlStatus = ""; // 下载速度 (Mbps) 11 | let ulStatus = ""; // 上传速度 (Mbps) 12 | let pingStatus = ""; // Ping (ms) 13 | let jitterStatus = ""; // Jitter (ms) 14 | let clientIp = ""; // 客户端IP 15 | let dlProgress = 0; // 下载进度 (0-1) 16 | let ulProgress = 0; // 上传进度 (0-1) 17 | let pingProgress = 0; // Ping进度 (0-1) 18 | let testId = null; // 遥测测试ID 19 | 20 | // --- 日志记录 --- 21 | let log = ""; 22 | function tlog(s) { if (settings.telemetry_level >= 2) log += `${Date.now()}: ${s}\n`; } 23 | function tverb(s) { if (settings.telemetry_level >= 3) log += `${Date.now()}: ${s}\n`; } 24 | function twarn(s) { 25 | if (settings.telemetry_level >= 1) log += `${Date.now()} WARN: ${s}\n`; 26 | console.warn(s); 27 | } 28 | 29 | // --- 默认设置 (可被主线程覆盖) --- 30 | let settings = { 31 | test_order: "IP_D_U", 32 | time_ul_max: 15, 33 | time_dl_max: 15, 34 | time_auto: true, 35 | time_ulGraceTime: 3, 36 | time_dlGraceTime: 1.5, 37 | count_ping: 10, 38 | url_dl: "backend/garbage", 39 | url_ul: "backend/empty", 40 | url_ping: "backend/empty", 41 | url_getIp: "backend/getIP", 42 | getIp_ispInfo: true, 43 | getIp_ispInfo_distance: "km", 44 | xhr_dlMultistream: 6, 45 | xhr_ulMultistream: 3, 46 | xhr_multistreamDelay: 300, 47 | xhr_ignoreErrors: 1, // 0=失败时中止, 1=重启流, 2=忽略错误 48 | xhr_dlUseBlob: false, 49 | xhr_ul_blob_megabytes: 20, 50 | garbagePhp_chunkSize: 100, 51 | enable_quirks: true, 52 | ping_allowPerformanceApi: true, 53 | overheadCompensationFactor: 1.06, 54 | useMebibits: false, 55 | telemetry_level: 0, // 默认值为0, 必须被主线程设置 56 | url_telemetry: "results/telemetry", 57 | telemetry_extra: "", 58 | forceIE11Workaround: false, 59 | mpot: false 60 | }; 61 | 62 | let xhr = null; // 保存活动XHR请求的数组 63 | let testInterval = null; // 用于测试中的setInterval 64 | let testPointer = 0; // 指向settings.test_order中下一个测试的指针 65 | let ispInfo = ""; // 用于遥测的ISP信息 66 | 67 | function getUrlSeparator(url) { return url.includes('?') ? "&" : "?"; } 68 | 69 | /** 70 | * 监听主线程消息 71 | */ 72 | self.addEventListener("message", (event) => { 73 | const { command, settings: newSettings } = event.data; 74 | 75 | if (command === "status") { 76 | postMessage({ 77 | testState, dlStatus, ulStatus, pingStatus, clientIp, jitterStatus, 78 | dlProgress, ulProgress, pingProgress, testId 79 | }); 80 | return; 81 | } 82 | 83 | if (command === "abort") { 84 | tlog("Test aborted by user"); 85 | stopTest(); 86 | testState = 5; // 设置为中止状态 87 | return; 88 | } 89 | 90 | if (command === "start" && testState === -1) { 91 | testState = 0; 92 | try { 93 | if (newSettings) { 94 | // 合并基础设置 95 | Object.assign(settings, newSettings); 96 | 97 | // --- 核心修复: 恢复遥测等级的字符串解析 --- 98 | if (typeof newSettings.telemetry_level === 'string') { 99 | settings.telemetry_level = { "basic": 1, "full": 2, "debug": 3 }[newSettings.telemetry_level] || 0; 100 | } 101 | } 102 | applyBrowserQuirks(newSettings || {}); 103 | settings.test_order = settings.test_order.toUpperCase(); 104 | } catch (e) { 105 | twarn(`Error parsing settings: ${e}`); 106 | } 107 | 108 | tverb(`Starting test with settings: ${JSON.stringify(settings)}`); 109 | testPointer = 0; 110 | runNextTest(); 111 | } 112 | }); 113 | 114 | function applyBrowserQuirks(userSettings) { /* ... 此函数保持不变 ... */ 115 | if (!settings.enable_quirks) return; 116 | const ua = navigator.userAgent; 117 | 118 | if (/Firefox/i.test(ua) && userSettings.ping_allowPerformanceApi === undefined) { 119 | settings.ping_allowPerformanceApi = false; 120 | } 121 | if (/Edge/i.test(ua) && userSettings.xhr_dlMultistream === undefined) { 122 | settings.xhr_dlMultistream = 3; 123 | } 124 | if (/Chrome/i.test(ua) && self.fetch && userSettings.xhr_dlMultistream === undefined) { 125 | settings.xhr_dlMultistream = 5; 126 | } 127 | if (/Edge|PlayStation 4/i.test(ua)) { 128 | settings.forceIE11Workaround = true; 129 | } 130 | if (/Chrome/i.test(ua) && /Android|iPhone|iPad|iPod|Windows Phone/i.test(ua)) { 131 | settings.xhr_ul_blob_megabytes = 4; 132 | } 133 | if (/^((?!chrome|android|crios|fxios).)*safari/i.test(ua)) { 134 | settings.forceIE11Workaround = true; 135 | } 136 | } 137 | 138 | function clearRequests() { /* ... 此函数保持不变 ... */ 139 | tverb("Clearing pending XHRs"); 140 | if (!xhr) return; 141 | xhr.forEach(request => { 142 | if (request) { 143 | try { request.onprogress = request.onload = request.onerror = null; } catch (e) { } 144 | try { request.upload.onprogress = request.upload.onload = request.upload.onerror = null; } catch (e) { } 145 | try { request.abort(); } catch (e) { } 146 | } 147 | }); 148 | xhr = null; 149 | } 150 | 151 | function stopTest() { /* ... 此函数保持不变 ... */ 152 | clearRequests(); 153 | if (testInterval) clearInterval(testInterval); 154 | if (settings.telemetry_level > 1) sendTelemetry(() => { }); 155 | dlStatus = ulStatus = pingStatus = jitterStatus = ""; 156 | dlProgress = ulProgress = pingProgress = 0; 157 | } 158 | 159 | /** 160 | * 按照test_order顺序执行下一个测试, 并在最后发送遥测数据 161 | */ 162 | function runNextTest() { 163 | if (testState === 5) return; 164 | 165 | if (testPointer >= settings.test_order.length) { 166 | // --- 核心逻辑: 所有测试完成, 准备发送遥测数据 --- 167 | tlog(`All tests finished. Telemetry level: ${settings.telemetry_level}.`); 168 | if (settings.telemetry_level > 0) { 169 | sendTelemetry(id => { 170 | testState = 4; // 报告已发送, 标记为完成 171 | if (id) testId = id; 172 | }); 173 | } else { 174 | testState = 4; // 无需报告, 直接标记为完成 175 | } 176 | return; 177 | } 178 | 179 | const nextTest = settings.test_order.charAt(testPointer++); 180 | switch (nextTest) { 181 | case 'I': getIp(runNextTest); break; 182 | case 'D': testState = 1; dlTest(runNextTest); break; 183 | case 'U': testState = 3; ulTest(runNextTest); break; 184 | case 'P': testState = 2; pingTest(runNextTest); break; 185 | case '_': setTimeout(runNextTest, 1000); break; 186 | default: runNextTest(); 187 | } 188 | } 189 | 190 | // getIp, dlTest, pingTest 函数保持不变, 这里省略以节约篇幅 191 | function getIp(done) { /* ... 此函数保持不变 ... */ 192 | tverb("getIp"); 193 | const request = new XMLHttpRequest(); 194 | request.withCredentials = true; 195 | request.onload = function () { 196 | try { 197 | const data = JSON.parse(this.responseText); 198 | clientIp = data.processedString; 199 | ispInfo = data.rawIspInfo; 200 | } catch (e) { 201 | clientIp = this.responseText; 202 | ispInfo = ""; 203 | } 204 | done(); 205 | }; 206 | request.onerror = function () { 207 | twarn("getIp failed"); 208 | done(); 209 | }; 210 | const url = `${settings.url_getIp}${getUrlSeparator(settings.url_getIp)}${settings.mpot ? "cors=true&" : ""}${settings.getIp_ispInfo ? `isp=true&distance=${settings.getIp_ispInfo_distance}&` : "&"}r=${Math.random()}`; 211 | request.open("GET", url, true); 212 | request.send(); 213 | } 214 | 215 | 216 | /** 217 | * 下载测速 (基于用户提供的优秀实现集成) 218 | * @param {function} done 完成回调 219 | */ 220 | function dlTest(done) { 221 | tverb("dlTest: Starting download test..."); 222 | testState = 1; 223 | 224 | let totalLoadedBytes = 0, startTime = Date.now(), bonusTime = 0, graceTimeDone = false, failed = false; 225 | xhr = []; 226 | 227 | const createStream = (streamIndex, delay) => { 228 | setTimeout(() => { 229 | if (testState !== 1) return; 230 | let prevLoaded = 0; 231 | const request = new XMLHttpRequest(); 232 | xhr[streamIndex] = request; 233 | request.withCredentials = settings.mpot; 234 | request.onprogress = event => { 235 | if (testState !== 1) { try { request.abort(); } catch (e) {} return; } 236 | const diffLoaded = event.loaded - prevLoaded; 237 | if (!isNaN(diffLoaded) && isFinite(diffLoaded) && diffLoaded >= 0) { 238 | totalLoadedBytes += diffLoaded; 239 | prevLoaded = event.loaded; 240 | } 241 | }; 242 | request.onload = () => { 243 | tverb(`dlTest: Stream ${streamIndex} finished, restarting.`); 244 | try { request.abort(); } catch (e) {} 245 | if (testState === 1) createStream(streamIndex, 0); 246 | }; 247 | request.onerror = () => { 248 | twarn(`dlTest: Stream ${streamIndex} encountered an error.`); 249 | if (testState !== 1) return; 250 | if (settings.xhr_ignoreErrors === 0) failed = true; 251 | xhr[streamIndex] = null; 252 | if (settings.xhr_ignoreErrors === 1 && testState === 1) createStream(streamIndex, 0); 253 | }; 254 | try { request.responseType = settings.xhr_dlUseBlob ? "blob" : "arraybuffer"; } catch (e) {} 255 | const url = new URL(settings.url_dl, self.location.origin); 256 | url.searchParams.append("r", Math.random()); 257 | url.searchParams.append("ckSize", settings.garbagePhp_chunkSize); 258 | if (settings.mpot) url.searchParams.append("cors", "true"); 259 | request.open("GET", url.toString(), true); 260 | request.send(); 261 | }, 1 + delay); 262 | }; 263 | 264 | tlog(`dlTest: Starting with ${settings.xhr_dlMultistream} streams.`); 265 | for (let i = 0; i < settings.xhr_dlMultistream; i++) { 266 | createStream(i, settings.xhr_multistreamDelay * i); 267 | } 268 | 269 | if (testInterval) clearInterval(testInterval); 270 | testInterval = setInterval(() => { 271 | const elapsedTime = Date.now() - startTime; 272 | if (!graceTimeDone) { 273 | dlProgress = elapsedTime / (settings.time_dlGraceTime * 1000); 274 | if (dlProgress > 1) dlProgress = 1; 275 | if (elapsedTime >= settings.time_dlGraceTime * 1000) { 276 | if (totalLoadedBytes > 0) { 277 | startTime = Date.now(); bonusTime = 0; totalLoadedBytes = 0; 278 | } 279 | graceTimeDone = true; 280 | } 281 | } else { 282 | const measurementTime = Date.now() - startTime; 283 | dlProgress = (measurementTime + bonusTime) / (settings.time_dl_max * 1000); 284 | if(dlProgress > 1) dlProgress = 1; 285 | 286 | const speedBps = totalLoadedBytes / (measurementTime / 1000.0); 287 | if (settings.time_auto) { 288 | const bonus = (5.0 * speedBps) / 100000; 289 | bonusTime += bonus > 400 ? 400 : bonus; 290 | } 291 | if (totalLoadedBytes > 0 && measurementTime > 100) { 292 | dlStatus = ((speedBps * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); 293 | } else { 294 | dlStatus = "0.00"; 295 | } 296 | 297 | if (((measurementTime + bonusTime) / 1000.0 > settings.time_dl_max) || failed) { 298 | if (failed || isNaN(parseFloat(dlStatus))) dlStatus = "Fail"; 299 | clearRequests(); clearInterval(testInterval); testInterval = null; 300 | dlProgress = 1; 301 | tlog(`dlTest: Final download speed: ${dlStatus} Mbps.`); 302 | done(); 303 | return; 304 | } 305 | } 306 | }, 200); 307 | } 308 | 309 | function pingTest(done) { /* ... 此函数保持不变 ... */ 310 | tverb("pingTest"); 311 | const startTime = Date.now(); 312 | let prevTime = null, ping = 0.0, jitter = 0.0, receivedPongs = 0, prevInstSpeed = 0; 313 | xhr = []; 314 | 315 | const doPing = () => { 316 | pingProgress = receivedPongs / settings.count_ping; 317 | prevTime = Date.now(); 318 | const request = new XMLHttpRequest(); 319 | request.withCredentials = true; 320 | xhr[0] = request; 321 | 322 | request.onload = () => { 323 | let instSpeed; 324 | if (receivedPongs === 0) { 325 | instSpeed = Date.now() - prevTime; 326 | } else { 327 | instSpeed = Date.now() - prevTime; 328 | if (settings.ping_allowPerformanceApi) { 329 | try { 330 | const perfEntry = performance.getEntries().pop(); 331 | const d = perfEntry.responseStart - perfEntry.requestStart; 332 | if (d > 0 && d < instSpeed) instSpeed = d; 333 | } catch (e) { } 334 | } 335 | } 336 | if (instSpeed < 1) instSpeed = prevInstSpeed || 1; 337 | 338 | const instJitter = Math.abs(instSpeed - prevInstSpeed); 339 | if (receivedPongs === 1) { 340 | ping = instSpeed; 341 | jitter = instJitter; 342 | } else { 343 | if (instSpeed < ping) ping = instSpeed; 344 | jitter = instJitter > jitter ? (jitter * 0.3 + instJitter * 0.7) : (jitter * 0.8 + instJitter * 0.2); 345 | } 346 | prevInstSpeed = instSpeed; 347 | 348 | pingStatus = ping.toFixed(2); 349 | jitterStatus = jitter.toFixed(2); 350 | receivedPongs++; 351 | tverb(`Ping: ${pingStatus} Jitter: ${jitterStatus}`); 352 | if (receivedPongs < settings.count_ping) doPing(); 353 | else { 354 | pingProgress = 1; 355 | tlog(`pingTest result: ping ${pingStatus} jitter ${jitterStatus}`); 356 | done(); 357 | } 358 | }; 359 | 360 | request.onerror = () => { 361 | tverb("Ping failed"); 362 | if (settings.xhr_ignoreErrors === 0) { 363 | pingStatus = "Fail"; 364 | jitterStatus = "Fail"; 365 | clearRequests(); 366 | pingProgress = 1; 367 | done(); 368 | } else { 369 | receivedPongs++; 370 | if (receivedPongs < settings.count_ping) doPing(); 371 | else done(); 372 | } 373 | }; 374 | 375 | request.open("GET", `${settings.url_ping}${getUrlSeparator(settings.url_ping)}${settings.mpot ? "cors=true&" : ""}r=${Math.random()}`, true); 376 | request.send(); 377 | }; 378 | doPing(); 379 | } 380 | 381 | /** 382 | * 上传测速 (已确认与遥测功能衔接正常) 383 | */ 384 | function ulTest(done) { /* ... 使用上一轮已修复的版本即可 ... */ 385 | tverb("ulTest: Starting upload test..."); 386 | let ulCalled = false; // 用于防止重复执行 387 | if (ulCalled) { 388 | tverb("ulTest: Upload test already attempted, skipping."); 389 | done(); 390 | return; 391 | } 392 | ulCalled = true; 393 | testState = 3; 394 | 395 | const blobBaseChunk = new ArrayBuffer(1024 * 1024); 396 | try { 397 | const tempUint32Array = new Uint32Array(blobBaseChunk); 398 | for (let i = 0; i < tempUint32Array.length; i++) { 399 | tempUint32Array[i] = Math.random() * Math.pow(2, 32); 400 | } 401 | } catch (e) { 402 | twarn(`ulTest: Failed to create upload data template: ${e}`); 403 | ulStatus = "Fail"; ulProgress = 1; done(); return; 404 | } 405 | 406 | const uploadChunks = []; 407 | for (let i = 0; i < settings.xhr_ul_blob_megabytes; i++) { 408 | uploadChunks.push(blobBaseChunk); 409 | } 410 | const fullUploadBlob = new Blob(uploadChunks, { type: 'application/octet-stream' }); 411 | const smallUploadBlob = new Blob([blobBaseChunk.slice(0, 256 * 1024)], { type: 'application/octet-stream' }); 412 | 413 | const startUploadProcess = () => { 414 | tverb("ulTest: startUploadProcess function has been called"); 415 | let totalUploadedBytes = 0.0, startTime = Date.now(), bonusTime = 0, graceTimeDone = false, failed = false; 416 | xhr = []; 417 | 418 | const createStream = (streamIndex, delay) => { 419 | setTimeout(() => { 420 | if (testState !== 3) return; 421 | let prevLoadedInStream = 0; 422 | const request = new XMLHttpRequest(); 423 | xhr[streamIndex] = request; 424 | request.withCredentials = settings.mpot; 425 | 426 | let useIE11Workaround = settings.forceIE11Workaround; 427 | if (!useIE11Workaround) { 428 | try { 429 | if (!(request.upload && typeof request.upload.onprogress === 'object')) { 430 | useIE11Workaround = true; 431 | tlog(`ulTest: Stream ${streamIndex} - xhr.upload.onprogress not available, switching to IE11 workaround.`); 432 | } 433 | } catch (e) { 434 | useIE11Workaround = true; 435 | tlog(`ulTest: Stream ${streamIndex} - Error checking for onprogress (${e}), switching to IE11 workaround.`); 436 | } 437 | } 438 | 439 | const dataToSend = useIE11Workaround ? smallUploadBlob : fullUploadBlob; 440 | 441 | if (useIE11Workaround) { 442 | request.onload = () => { 443 | if (testState !== 3) { try { request.abort(); } catch (e) { } return; } 444 | totalUploadedBytes += smallUploadBlob.size; 445 | if (testState === 3) createStream(streamIndex, 0); 446 | }; 447 | request.onerror = () => { 448 | if (testState !== 3) return; 449 | if (settings.xhr_ignoreErrors === 0) failed = true; 450 | xhr[streamIndex] = null; 451 | if (settings.xhr_ignoreErrors === 1 && testState === 3) createStream(streamIndex, 0); 452 | }; 453 | } else { 454 | if (request.upload) { 455 | request.upload.onprogress = (event) => { 456 | if (testState !== 3) { try { request.abort(); } catch (e) { } return; } 457 | const diffLoaded = event.loaded > 0 ? event.loaded - prevLoadedInStream : 0; 458 | if (isNaN(diffLoaded) || !isFinite(diffLoaded) || diffLoaded < 0) return; 459 | totalUploadedBytes += diffLoaded; 460 | prevLoadedInStream = event.loaded; 461 | }; 462 | request.upload.onload = () => { 463 | prevLoadedInStream = 0; 464 | if (testState === 3) createStream(streamIndex, 0); 465 | }; 466 | request.upload.onerror = () => { 467 | if (testState !== 3) return; 468 | if (settings.xhr_ignoreErrors === 0) failed = true; 469 | xhr[streamIndex] = null; 470 | if (settings.xhr_ignoreErrors === 1 && testState === 3) createStream(streamIndex, 0); 471 | }; 472 | } else { 473 | failed = true; 474 | } 475 | } 476 | 477 | const requestURL = new URL(settings.url_ul, self.location.origin); 478 | requestURL.searchParams.append("r", Math.random()); 479 | if (settings.mpot) requestURL.searchParams.append("cors", "true"); 480 | request.open("POST", requestURL.toString(), true); 481 | try { request.setRequestHeader("Content-Encoding", "identity"); } catch (e) { } 482 | try { request.send(dataToSend); } catch (e) { 483 | if (testState !== 3) return; 484 | if (settings.xhr_ignoreErrors === 0) failed = true; 485 | xhr[streamIndex] = null; 486 | if (settings.xhr_ignoreErrors === 1 && testState === 3) createStream(streamIndex, 0); 487 | } 488 | }, 1 + delay); 489 | }; 490 | 491 | for (let i = 0; i < settings.xhr_ulMultistream; i++) { 492 | createStream(i, settings.xhr_multistreamDelay * i); 493 | } 494 | 495 | if (testInterval) clearInterval(testInterval); 496 | testInterval = setInterval(() => { 497 | const elapsedTime = Date.now() - startTime; 498 | if (!graceTimeDone) { 499 | ulProgress = elapsedTime / (settings.time_ulGraceTime * 1000); 500 | if (ulProgress > 1) ulProgress = 1; 501 | ulStatus = "0.00"; 502 | if (elapsedTime >= settings.time_ulGraceTime * 1000) { 503 | if (totalUploadedBytes > 0) { 504 | startTime = Date.now(); 505 | bonusTime = 0; 506 | totalUploadedBytes = 0.0; 507 | } 508 | graceTimeDone = true; 509 | } 510 | } else { 511 | const measurementTime = Date.now() - startTime; 512 | ulProgress = (measurementTime + bonusTime) / (settings.time_ul_max * 1000); 513 | if (ulProgress > 1) ulProgress = 1; 514 | const speedBps = totalUploadedBytes / (measurementTime / 1000.0); 515 | if (settings.time_auto) { 516 | const bonus = (5.0 * speedBps) / 100000; 517 | bonusTime += bonus > 400 ? 400 : bonus; 518 | } 519 | if (totalUploadedBytes > 0 && measurementTime > 100) { 520 | ulStatus = ((speedBps * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); 521 | } else { 522 | ulStatus = "0.00"; 523 | } 524 | if (((measurementTime + bonusTime) / 1000.0 > settings.time_ul_max) || failed) { 525 | if (failed || isNaN(parseFloat(ulStatus))) { 526 | ulStatus = "Fail"; 527 | } 528 | clearRequests(); 529 | clearInterval(testInterval); 530 | testInterval = null; 531 | ulProgress = 1; 532 | tlog(`ulTest: Final upload speed: ${ulStatus} Mbps.`); 533 | done(); 534 | return; 535 | } 536 | } 537 | }, 200); 538 | }; 539 | if (settings.mpot) { 540 | const preRequest = new XMLHttpRequest(); 541 | preRequest.withCredentials = true; 542 | preRequest.onload = preRequest.onerror = () => { startUploadProcess(); }; 543 | const requestURL = new URL(settings.url_ul, self.location.origin); 544 | requestURL.searchParams.append("cors", "true"); 545 | preRequest.open("POST", requestURL.toString()); 546 | preRequest.send(); 547 | } else { 548 | startUploadProcess(); 549 | } 550 | } 551 | 552 | /** 553 | * 发送遥测数据到后端 554 | */ 555 | function sendTelemetry(done) { 556 | tlog(`sendTelemetry: Preparing to send data. dl=${dlStatus}, ul=${ulStatus}, ping=${pingStatus}`); 557 | const request = new XMLHttpRequest(); 558 | request.withCredentials = true; 559 | request.onload = () => { 560 | try { 561 | const [tag, id] = request.responseText.split(" "); 562 | done(tag === "id" ? id : null); 563 | } catch (e) { done(null); } 564 | }; 565 | request.onerror = () => { 566 | twarn("Telemetry submission failed with status " + request.status); 567 | done(null); 568 | }; 569 | request.open("POST", `${settings.url_telemetry}${getUrlSeparator(settings.url_telemetry)}${settings.mpot ? "cors=true&" : ""}`, true); 570 | 571 | const telemetryIspInfo = { processedString: clientIp, rawIspInfo: ispInfo || "" }; 572 | try { 573 | const fd = new FormData(); 574 | fd.append("ispinfo", JSON.stringify(telemetryIspInfo)); 575 | fd.append("dl", dlStatus); 576 | fd.append("ul", ulStatus); 577 | fd.append("ping", pingStatus); 578 | fd.append("jitter", jitterStatus); 579 | if (settings.telemetry_level > 1) fd.append("log", log); 580 | fd.append("extra", settings.telemetry_extra); 581 | request.send(fd); 582 | } catch (ex) { 583 | const 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 : "")}`; 584 | request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); 585 | request.send(postData); 586 | } 587 | } --------------------------------------------------------------------------------