Add GET /api/stats, GET /api/system, and authenticated /metrics - #683
Conversation
38c5ee4 to
12fe6e8
Compare
12fe6e8 to
4bafef7
Compare
27bef03 to
dc4875d
Compare
dc4875d to
d3193af
Compare
ae884ff to
8f2eac5
Compare
8f2eac5 to
2bb8e01
Compare
2bb8e01 to
d0ca3d7
Compare
d0ca3d7 to
6d05fca
Compare
6d05fca to
8cd25d1
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The stats race and incomplete API-key-only enforcement must be fixed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds permission-protected system statistics and information APIs and authentication for Prometheus metrics.
Changes:
- Adds
/api/statsand/api/system. - Protects endpoints with permission middleware.
- Documents
/metricsAPI-key authentication and adds tests.
File summaries
| File | Review |
|---|---|
pkg/unpackerr/webserver.go |
Registers the new APIs and protects metrics. |
pkg/unpackerr/metrics.go |
Adds JSON fields and aggregate totals. |
pkg/unpackerr/auth.go |
Moderate: Metrics authentication also accepts sessions and trusted proxies instead of enforcing API-key headers. |
pkg/unpackerr/api.go |
Critical: Stats access can race with concurrent map updates and panic. |
pkg/unpackerr/api_test.go |
Tests endpoint authentication and permissions. |
pkg/configdef/definitions.yml |
Documents metrics authentication. |
examples/unpackerr.conf.example |
Updates example configuration guidance. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| } | ||
|
|
||
| func (u *Unpackerr) statsHandler(response http.ResponseWriter, _ *http.Request, _ httprouter.Params) { | ||
| writeJSON(response, http.StatusOK, u.stats()) |
| func (u *Unpackerr) requirePermHTTP(perm string, next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { | ||
| info, ok := u.authenticate(request) | ||
| if !ok { | ||
| writeJSON(response, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) | ||
|
|
||
| return | ||
| } | ||
|
|
||
| if !info.allows(perm) { | ||
| writeJSON(response, http.StatusForbidden, map[string]string{"error": "forbidden"}) | ||
|
|
||
| return | ||
| } | ||
|
|
||
| next.ServeHTTP(response, request) | ||
| }) | ||
| } |
There was a problem hiding this comment.
Reviewed the full PR at 8cd25d1 against ae5fea7: the two new read-only endpoints, the permission middleware, the /metrics auth gate, the Stats JSON tags + Retries/Finished wiring, and the doc updates. Verdict: approve.
What I checked by reading:
requirePerm/requirePermHTTP(pkg/unpackerr/auth.go:83-122) — the deny paths are correct: unknown key → 401 viaauthenticatereturningperms == nil; known key without the permission → 403;PermAllhonored; session/webauth users getAllPermissions()as elsewhere. No bypass found in theX-Api-Key/Bearerextraction path.- Route registration with a non-root
urlbase—/base/api/stats+ the existing/base/api/auth/*coexist in httprouter without a panic (verified live, not just by reading). stats()is now reachable from HTTP handler goroutines in addition to the sync loop and PrometheusCollect. The unguardedu.Map/u.Retries/u.Finishedreads are the same pre-existing exposure the scrape path always had — not introduced here, and race-detector runs came back clean.systemInfoleaks nothing beyond version/uptime/listen addr/auth mode, all behindread:system:info. Fine.- Docs:
examples/unpackerr.conf.exampleanddefinitions.ymlboth describe the new scrape requirement; the breaking/metricschange is called out in the description. One tiny non-blocking nit: the startup line inlogWebserverstill just says "Prometheus metrics enabled at /metrics" — worth mentioning the key requirement there too so operators aren't surprised by 401s in scrape logs.
Executed validation (at 8cd25d1, disposable checkout, nothing from the PR's non-test code executed beyond the compiler and the repo's own test binary):
go build ./...andgo vet ./pkg/unpackerr/ ./pkg/configdef/— clean.go test -race -count=1 ./pkg/unpackerr/ ./pkg/configdef/— both packages pass.- A throwaway harness I wrote (not committed) ran the real
webRoutes()withMetrics: trueandURLBase: /base/, then exercised the router directly:/metricsand/base/metrics→ 401 unauthenticated, 403 with a stats-only key, 200 with the admin key via bothX-Api-KeyandAuthorization: Bearer, and the response body is realpromhttpoutput (unpackerr_gaugespresent)./base/api/stats→ 200 with all 13 JSON keys;/base/api/system→ 403 stats key / 200 admin. A password-login session cookie also reaches/metrics(200). One incident worth noting: my first harness run got 500s from/metricsbecause it skippedPollFolders(), leavingu.foldersnil and panicking inside the pre-existingMetricsCollector.Collect— a harness artifact (production always initializesfoldersat startup), but it confirmsCollectpanics recover as a scrape error rather than killing the server. - Public CI fetched: gotest (ubuntu/macos/windows) and golangci-lint (linux/freebsd/windows/darwin) all completed success on this head.
Nothing blocking. Ship it.
8cd25d1 to
7b8fc66
Compare
4891a16 to
47738f4
Compare
1c37973 to
87ed99e
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The inaccurate listen address and potential metrics-path data race are unresolved moderate issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
pkg/unpackerr/api_test.go:35
- The stats endpoint test only verifies that the response can be unmarshaled into
Stats; an empty or all-zero payload and incorrect/missing counter values still pass. Seed entries covering the queue statuses plusRetries/Finished, then assert the decoded values (and preferably the JSON field names), since those counters are the endpoint's primary contract.
var stats Stats
if err := json.Unmarshal(statsRec.Body.Bytes(), &stats); err != nil {
t.Fatal(err)
- Files reviewed: 19/19 changed files
- Comments generated: 2
- Review effort level: Balanced
| // cookies and trusted-proxy webauth/noauth are ignored. | ||
| func (u *Unpackerr) requirePermHTTP(perm string, next http.Handler) http.Handler { | ||
| return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { | ||
| info, ok := u.authAPIKey(requestAPIKey(request)) |
87ed99e to
021d7de
Compare
There was a problem hiding this comment.
🟡 Changes recommended
There are confirmed functional bugs in the web routing (URLBase index handler) and in Starr extraction callbacks creating unintended Folder entries when map items are missing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
pkg/unpackerr/handlers.go:342
- After running remnant/metrics work outside the lock, handleXtractrCallback re-locks and updates status even if the Extract was removed; this again triggers updateQueueStatus's FolderString fallback for unknown names. Bail out early if the item is no longer present.
u.lockHistory()
defer u.unlockHistory()
item = u.Map[resp.X.Name]
switch {
case remnants && remnantStatus == WAITING:
- Files reviewed: 19/19 changed files
- Comments generated: 2
- Review effort level: Lite
| u.lockHistory() | ||
|
|
||
| item := u.Map[resp.X.Name] | ||
| if resp.Done && item != nil { | ||
| u.updateMetrics(resp, item.App, item.URL) | ||
| } else if item != nil { | ||
| item.XProg.Archives = resp.Archives.Count() + resp.Extras.Count() | ||
| if !resp.Done { | ||
| if item != nil { | ||
| item.XProg.Archives = resp.Archives.Count() + resp.Extras.Count() |
Homepage and Prometheus can share the same API keys; scrapes must send a key with read:system:metrics. Co-authored-by: Cursor <[email protected]>
…only. Co-authored-by: Cursor <[email protected]>
Holding it across RemoveAll blocked /api/stats and Prometheus scrapes for the duration of cleanup. Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
…on /metrics. Co-authored-by: Cursor <[email protected]>
…ex route. Co-authored-by: Cursor <[email protected]>
e658e11 to
05f4ebc
Compare
Summary
GET /api/stats(permissionread:system:stats) returns queue counters for a Homepage custom API widget. Closes Possible to query the api for gethomepage widget? #640.GET /api/system(read:system:info) returns version, uptime, listen address, and auth mode./metricsnow requires an API key withread:system:metricsviaAuthorization: BearerorX-Api-Key. This is a breaking change for existing Prometheus scrapes.Test plan
go test ./pkg/unpackerr/ ./pkg/configdef/golangci-lint run ./pkg/unpackerr/ ./pkg/configdef//api/statswith a stats-only key/api/systemor/metricsbearer_tokenscrapes/metricssuccessfullyMade with Cursor