You've already forked speedtest-go
Sync PHP backend feature parity: IP detection, database backends, API endpoints, and frontend
- IP detection: Cloudflare IPv6, ULA IPv6, proxy header chain, offline GeoIP DB - Database: add SQLite (pure Go, no CGo) and MSSQL backends - API: add JSON result sharing endpoint and ID obfuscation - Frontend: add modern CSS design, design switcher, favicon - Compatibility: ?cors parameter support, human-friendly distance rounding - Update Go to 1.21, add modernc.org/sqlite and maxminddb deps
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package results
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ID obfuscation provides an optional privacy layer for test result URLs.
|
||||
// When enabled, the telemetry endpoint returns an obfuscated ULID that must
|
||||
// be deobfuscated before looking up the result.
|
||||
//
|
||||
// This is NOT cryptographically secure — it prevents casual ID guessing,
|
||||
// matching the behavior of the PHP version's idObfuscation.php.
|
||||
|
||||
var (
|
||||
obfuscationSalt uint32
|
||||
obfuscationSaltOnce sync.Once
|
||||
obfuscationSaltErr error
|
||||
)
|
||||
|
||||
const obfuscationSaltFile = "idObfuscation_salt.bin"
|
||||
|
||||
func getOrCreateObfuscationSalt() (uint32, error) {
|
||||
obfuscationSaltOnce.Do(func() {
|
||||
data, err := os.ReadFile(obfuscationSaltFile)
|
||||
if err == nil && len(data) == 4 {
|
||||
obfuscationSalt = binary.LittleEndian.Uint32(data)
|
||||
return
|
||||
}
|
||||
|
||||
saltBytes := make([]byte, 4)
|
||||
if _, err := rand.Read(saltBytes); err != nil {
|
||||
obfuscationSaltErr = fmt.Errorf("failed to generate obfuscation salt: %w", err)
|
||||
return
|
||||
}
|
||||
obfuscationSalt = binary.LittleEndian.Uint32(saltBytes)
|
||||
|
||||
dir := filepath.Dir(obfuscationSaltFile)
|
||||
if dir != "." {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
log.Warnf("Could not create directory for obfuscation salt file: %s", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(obfuscationSaltFile, saltBytes, 0644); err != nil {
|
||||
log.Warnf("Could not save obfuscation salt file: %s", err)
|
||||
}
|
||||
})
|
||||
|
||||
return obfuscationSalt, obfuscationSaltErr
|
||||
}
|
||||
|
||||
// obfuscateBytes applies reversible transform on ULID bytes:
|
||||
// XOR the first 4 bytes with the salt (simple but effective for casual privacy)
|
||||
func obfuscateBytes(data []byte) []byte {
|
||||
salt, err := getOrCreateObfuscationSalt()
|
||||
if err != nil || len(data) < 4 {
|
||||
return data
|
||||
}
|
||||
result := make([]byte, len(data))
|
||||
copy(result, data)
|
||||
val := binary.LittleEndian.Uint32(result[:4])
|
||||
val ^= salt
|
||||
binary.LittleEndian.PutUint32(result[:4], val)
|
||||
return result
|
||||
}
|
||||
|
||||
// deobfuscateBytes reverses obfuscateBytes (XOR is self-inverse)
|
||||
var deobfuscateBytes = obfuscateBytes
|
||||
|
||||
// ObfuscateULID transforms a ULID string to its obfuscated (base64) form
|
||||
func ObfuscateULID(id string) string {
|
||||
parsed, err := ulid.Parse(id)
|
||||
if err != nil {
|
||||
return id
|
||||
}
|
||||
obfuscated := obfuscateBytes(parsed[:])
|
||||
return base64.RawURLEncoding.EncodeToString(obfuscated)
|
||||
}
|
||||
|
||||
// DeobfuscateULID reverses ULID obfuscation
|
||||
func DeobfuscateULID(obfuscated string) (string, error) {
|
||||
data, err := base64.RawURLEncoding.DecodeString(obfuscated)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid obfuscated ID encoding: %w", err)
|
||||
}
|
||||
if len(data) != 16 {
|
||||
return "", fmt.Errorf("invalid obfuscated ID length: %d", len(data))
|
||||
}
|
||||
deobfuscated := deobfuscateBytes(data)
|
||||
var id ulid.ULID
|
||||
copy(id[:], deobfuscated)
|
||||
return id.String(), nil
|
||||
}
|
||||
|
||||
// ResolveID takes an ID string and returns the database ULID.
|
||||
// It tries the raw input first, then attempts deobfuscation.
|
||||
func ResolveID(id string) string {
|
||||
// First try: use as-is (plain ULID)
|
||||
if _, err := ulid.Parse(id); err == nil {
|
||||
return id
|
||||
}
|
||||
|
||||
// Second try: deobfuscate
|
||||
if deobfuscated, err := DeobfuscateULID(id); err == nil {
|
||||
return deobfuscated
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package results
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
"github.com/librespeed/speedtest-go/config"
|
||||
"github.com/librespeed/speedtest-go/database"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// formatValue formats a numeric string for display, matching PHP behavior:
|
||||
// - values < 10: 2 decimal places
|
||||
// - values < 100: 1 decimal place
|
||||
// - values >= 100: 0 decimal places
|
||||
func formatValue(d string) string {
|
||||
val, err := strconv.ParseFloat(d, 64)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
if val < 10 {
|
||||
return strconv.FormatFloat(val, 'f', 2, 64)
|
||||
}
|
||||
if val < 100 {
|
||||
return strconv.FormatFloat(val, 'f', 1, 64)
|
||||
}
|
||||
return strconv.FormatFloat(val, 'f', 0, 64)
|
||||
}
|
||||
|
||||
// extractISPName extracts the ISP name from the processedString format:
|
||||
// "IP - ISP, Country (distance)" → "ISP"
|
||||
func extractISPName(processedString string) string {
|
||||
dash := strings.Index(processedString, "-")
|
||||
if dash == -1 {
|
||||
return ""
|
||||
}
|
||||
isp := strings.TrimSpace(processedString[dash+1:])
|
||||
par := strings.LastIndex(isp, "(")
|
||||
if par != -1 {
|
||||
isp = strings.TrimSpace(isp[:par])
|
||||
}
|
||||
return isp
|
||||
}
|
||||
|
||||
// JSONResponse is the structure returned by the JSON results endpoint
|
||||
type JSONResponse struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Download string `json:"download"`
|
||||
Upload string `json:"upload"`
|
||||
Ping string `json:"ping"`
|
||||
Jitter string `json:"jitter"`
|
||||
ISPInfo string `json:"ispinfo"`
|
||||
}
|
||||
|
||||
// JSONResult handles GET /results/json?id=X and returns test results as JSON
|
||||
func JSONResult(w http.ResponseWriter, r *http.Request) {
|
||||
conf := config.LoadedConfig()
|
||||
|
||||
if conf.DatabaseType == "none" {
|
||||
render.PlainText(w, r, "Telemetry is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
rawID := r.FormValue("id")
|
||||
if rawID == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
render.JSON(w, r, map[string]string{"error": "missing id parameter"})
|
||||
return
|
||||
}
|
||||
|
||||
uuid := ResolveID(rawID)
|
||||
record, err := database.DB.FetchByUUID(uuid)
|
||||
if err != nil {
|
||||
log.Errorf("Error querying database for JSON result: %s", err)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
render.JSON(w, r, map[string]string{"error": "result not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Format values for display (matching PHP json.php behavior)
|
||||
resp := JSONResponse{
|
||||
Timestamp: record.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
Download: formatValue(record.Download),
|
||||
Upload: formatValue(record.Upload),
|
||||
Ping: formatValue(record.Ping),
|
||||
Jitter: formatValue(record.Jitter),
|
||||
}
|
||||
|
||||
// Extract ISP name from ISP info JSON
|
||||
var result Result
|
||||
if err := json.Unmarshal([]byte(record.ISPInfo), &result); err == nil {
|
||||
resp.ISPInfo = extractISPName(result.ProcessedString)
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0, s-maxage=0")
|
||||
w.Header().Add("Cache-Control", "post-check=0, pre-check=0")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
render.JSON(w, r, resp)
|
||||
}
|
||||
@@ -201,7 +201,12 @@ func Record(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write([]byte("id " + uuid.String())); err != nil {
|
||||
responseID := uuid.String()
|
||||
if config.LoadedConfig().EnableIDObfuscation {
|
||||
responseID = ObfuscateULID(uuid.String())
|
||||
}
|
||||
|
||||
if _, err := w.Write([]byte("id " + responseID)); err != nil {
|
||||
log.Errorf("Error writing ID to telemetry request: %s", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
@@ -214,7 +219,8 @@ func DrawPNG(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
uuid := r.FormValue("id")
|
||||
rawID := r.FormValue("id")
|
||||
uuid := ResolveID(rawID)
|
||||
record, err := database.DB.FetchByUUID(uuid)
|
||||
if err != nil {
|
||||
log.Errorf("Error querying database: %s", err)
|
||||
|
||||
Reference in New Issue
Block a user