-
-
Notifications
You must be signed in to change notification settings - Fork 63
feat(speedtest): add result URL support for LibreSpeed #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
cca65ef
b20e8f9
a05d016
2d83cd0
199c218
5b9fabd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| // Copyright (c) 2024-2026, s0up and the autobrr contributors. | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
|
|
||
| package migrations | ||
|
|
||
| import ( | ||
| "path" | ||
| "strconv" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestNoDuplicateMigrationVersions guards against two migration files sharing a | ||
| // numeric version prefix. The migrator (pkg/migrator) applies migrations by | ||
| // position relative to the count already applied, so a duplicate version makes | ||
| // the sorted order ambiguous and silently skips a migration on upgrade (the new | ||
| // file gets ordered before an already-applied one and is never run). | ||
| // | ||
| // The version is parsed from the file basename here rather than via the | ||
| // production getMigrationVersion, which does not strip the directory prefix. | ||
| func TestNoDuplicateMigrationVersions(t *testing.T) { | ||
| for _, dbType := range []DatabaseType{SQLite, Postgres} { | ||
| files, err := GetMigrationFiles(dbType) | ||
| if err != nil { | ||
| t.Fatalf("%s: failed to get migration files: %v", dbType, err) | ||
| } | ||
|
|
||
| seen := make(map[int]string, len(files)) | ||
| for _, file := range files { | ||
| base := path.Base(file) | ||
| prefix, _, _ := strings.Cut(base, "_") | ||
| version, err := strconv.Atoi(prefix) | ||
| if err != nil { | ||
| t.Errorf("%s: migration %q has no numeric version prefix", dbType, file) | ||
| continue | ||
| } | ||
| if prev, ok := seen[version]; ok { | ||
| t.Errorf("%s: duplicate migration version %d: %q and %q", dbType, version, prev, file) | ||
| } | ||
| seen[version] = file | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| -- Add result URL column for shareable speed test links (e.g. LibreSpeed share URLs) | ||
| ALTER TABLE speed_tests ADD COLUMN result_url TEXT; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| -- Add result URL column for shareable speed test links (e.g. LibreSpeed share URLs) | ||
| ALTER TABLE speed_tests ADD COLUMN result_url TEXT; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,11 +4,13 @@ | |||||
| package speedtest | ||||||
|
|
||||||
| import ( | ||||||
| "bytes" | ||||||
| "context" | ||||||
| "encoding/json" | ||||||
| "fmt" | ||||||
| "io" | ||||||
| "net/http" | ||||||
| "net/url" | ||||||
| "os" | ||||||
| "os/exec" | ||||||
| "strconv" | ||||||
|
|
@@ -119,17 +121,26 @@ func (r *LibrespeedRunner) RunTest(ctx context.Context, opts *types.TestOptions) | |||||
|
|
||||||
| cmd := exec.CommandContext(ctx, "librespeed-cli", args...) | ||||||
|
|
||||||
| output, err := cmd.CombinedOutput() | ||||||
| if err != nil { | ||||||
| log.Error().Err(err).Str("output", string(output)).Msg("librespeed-cli failed") | ||||||
| return nil, fmt.Errorf("librespeed-cli failed: %v: %s", err, string(output)) | ||||||
| // Capture stdout and stderr separately: librespeed-cli writes the JSON | ||||||
| // report to stdout, while progress/UI lines and telemetry errors (emitted | ||||||
| // when --share is used) go to stderr. Keeping them apart avoids stderr | ||||||
| // noise corrupting the JSON we parse. | ||||||
| var stdout, stderr bytes.Buffer | ||||||
| cmd.Stdout = &stdout | ||||||
| cmd.Stderr = &stderr | ||||||
|
|
||||||
| if err := cmd.Run(); err != nil { | ||||||
| log.Error().Err(err).Str("stdout", stdout.String()).Str("stderr", stderr.String()).Msg("librespeed-cli failed") | ||||||
| return nil, fmt.Errorf("librespeed-cli failed: %v: %s", err, strings.TrimSpace(stderr.String())) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify non-wrapping fmt.Errorf usages where an error variable is formatted with %v/%s.
rg -nP --type=go 'fmt\.Errorf\([^)]*%[vs][^)]*,\s*err\b'Repository: autobrr/netronome Length of output: 1178 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show the error site and nearby stdout/stderr handling
sed -n '110,170p' internal/speedtest/librespeed.go
# Also show any logging around where stdout/stderr is handled, if farther away
rg -n 'stdout|stderr|TrimSpace\(stderr\.String\(\)\)|TrimSpace\(stdout\.String\(\)\)|librespeed-cli' internal/speedtest/librespeed.go -nRepository: autobrr/netronome Length of output: 4013 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show LibrespeedResult definition
rg -n "type LibrespeedResult|LibrespeedResult struct" internal/speedtest/librespeed.go
sed -n '170,260p' internal/speedtest/librespeed.go
sed -n '260,420p' internal/speedtest/librespeed.goRepository: autobrr/netronome Length of output: 6431 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show the LibrespeedResult struct and related types near the top of the file
sed -n '1,120p' internal/speedtest/librespeed.go
# Confirm which client-identifying fields exist in the JSON struct
rg -n --fixed-strings 'json:"ip"' internal/speedtest/librespeed.go
rg -n --fixed-strings 'json:"hostname"' internal/speedtest/librespeed.go
rg -n --fixed-strings 'json:"location"' internal/speedtest/librespeed.go
rg -n 'Share|share' internal/speedtest/librespeed.goRepository: autobrr/netronome Length of output: 3476 Fix error wrapping and stop logging sensitive librespeed output
Suggested fix- return nil, fmt.Errorf("librespeed-cli failed: %v: %s", err, strings.TrimSpace(stderr.String()))
+ return nil, fmt.Errorf("librespeed-cli failed: %w: %s", err, strings.TrimSpace(stderr.String()))📝 Committable suggestion
Suggested change
🧰 Tools🪛 golangci-lint (2.12.2)[error] 134-134: non-wrapping format verb for fmt.Errorf. Use (errorlint) 🤖 Prompt for AI Agents |
||||||
| } | ||||||
|
|
||||||
| log.Debug().Str("output", string(output)).Msg("librespeed-cli output") | ||||||
| output := stdout.Bytes() | ||||||
|
|
||||||
| log.Debug().Str("stdout", stdout.String()).Str("stderr", stderr.String()).Msg("librespeed-cli output") | ||||||
|
|
||||||
| var librespeedResults []LibrespeedResult | ||||||
| if err := json.Unmarshal(output, &librespeedResults); err != nil { | ||||||
| log.Error().Err(err).Str("output", string(output)).Msg("failed to parse librespeed-cli output") | ||||||
| if err := json.Unmarshal(extractJSONArray(output), &librespeedResults); err != nil { | ||||||
| log.Error().Err(err).Str("stdout", stdout.String()).Msg("failed to parse librespeed-cli output") | ||||||
| return nil, fmt.Errorf("failed to parse librespeed-cli output: %w", err) | ||||||
| } | ||||||
|
|
||||||
|
|
@@ -151,6 +162,7 @@ func (r *LibrespeedRunner) RunTest(ctx context.Context, opts *types.TestOptions) | |||||
| UploadSpeed: librespeedResult.Upload, | ||||||
| Latency: fmt.Sprintf("%.2f", librespeedResult.Ping), | ||||||
| Jitter: librespeedResult.Jitter, | ||||||
| ResultURL: sanitizeResultURL(librespeedResult.Share), | ||||||
| } | ||||||
|
|
||||||
| // Final completion update | ||||||
|
|
@@ -172,6 +184,10 @@ func (r *LibrespeedRunner) RunTest(ctx context.Context, opts *types.TestOptions) | |||||
| func (r *LibrespeedRunner) buildArgs(opts *types.TestOptions) []string { | ||||||
| args := []string{"--json"} | ||||||
|
|
||||||
| if r.config.ShareResults { | ||||||
| args = append(args, "--share") | ||||||
| } | ||||||
|
|
||||||
| if opts.IsPublicServer { | ||||||
| // Keep CLI server IDs in sync with our fetched public list. | ||||||
| args = append(args, "--server-json", librespeedPublicServersURL) | ||||||
|
|
@@ -326,3 +342,31 @@ func parseCountryFromName(name string) string { | |||||
| } | ||||||
| return "Unknown" | ||||||
| } | ||||||
|
|
||||||
| // extractJSONArray trims any leading non-JSON output (e.g. progress/log lines | ||||||
| // librespeed-cli may print before the JSON report) so the result can be | ||||||
| // unmarshalled. It returns b starting at the first '['; if there is no '[' or | ||||||
| // it is already at the start, b is returned unchanged. | ||||||
| func extractJSONArray(b []byte) []byte { | ||||||
| if idx := bytes.IndexByte(b, '['); idx > 0 { | ||||||
| return b[idx:] | ||||||
| } | ||||||
| return b | ||||||
| } | ||||||
|
|
||||||
| // sanitizeResultURL validates a share URL from librespeed-cli output. | ||||||
| // Returns the normalized URL if valid (http/https with a host), empty string otherwise. | ||||||
| func sanitizeResultURL(raw string) string { | ||||||
| raw = strings.TrimSpace(raw) | ||||||
| if raw == "" { | ||||||
| return "" | ||||||
| } | ||||||
| u, err := url.Parse(raw) | ||||||
| if err != nil { | ||||||
| return "" | ||||||
| } | ||||||
| if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { | ||||||
| return "" | ||||||
| } | ||||||
| return u.String() | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid logging raw LibreSpeed stdout/stderr payloads.
Line 133 and Line 139 log full process output. That output can include client-identifying JSON fields and share URLs, which is a privacy/compliance risk in logs.
Suggested hardening
Also applies to: 139-139
🤖 Prompt for AI Agents