Compare commits

...
135 Commits
Author SHA1 Message Date
github-actions[bot]andGitHub 37874387e7 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 10:34:08 +00:00
lukaszraczylo 70d6c8988a chore(deps): update tailwindcss to v4
Switch to the @tailwindcss/vite plugin and the @import entry. Load the existing config with @config and keep class-based dark mode through a custom variant. Remove the postcss config and its autoprefixer dependency, which v4 no longer needs.
2026-10-04 10:15:44 +01:00
lukaszraczylo 20470bfd3e chore(deps): update typescript to v6
TypeScript 6 deprecates baseUrl and checks side-effect imports. Drop baseUrl, which paths does not need, and load the vite/client types so the CSS import resolves. Remove trailing commas from tsconfig.json so the JSON hook accepts it.
2026-10-04 10:15:10 +01:00
lukaszraczylo dbbe254bfc chore(deps): update vue-router to v5
The lockfile now resolves vue-router 5.3.1 on top of vite 8. Install, tests and build pass.
2026-10-04 10:14:04 +01:00
github-actions[bot]andGitHub 08552d8968 chore(deps): update dependency vite to v8
Automated dependency update. All tests passed.
2026-10-04 04:39:10 +00:00
github-actions[bot]andGitHub 7e8ba3d4e2 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-04 04:36:22 +00:00
lukaszraczylo f72b481720 chore(deps): migrate fiber from v2 to v3
The automated update changed only go.mod, so the build failed on the old v2 imports. This commit moves the code to the v3 API.

The commit replaces BodyParser with Bind().Body, Static with the static middleware, and ListenTLS with ListenConfig. It also updates the tests for TestConfig.

Fixes #179
2026-10-04 00:50:37 +01:00
lukaszraczylo 7d75dac29c ci(deps): update dependencies with Renovate [skip ci] 2026-10-03 18:44:33 +01:00
github-actions[bot]andGitHub 80928c6e21 chore(deps): update dependency @vueuse/core to v15
Automated dependency update. All tests passed.
2026-10-03 17:19:50 +00:00
github-actions[bot]andGitHub 5bcbe08573 chore(deps): update dependency @testing-library/jest-dom to v7
Automated dependency update. All tests passed.
2026-10-03 17:18:18 +00:00
github-actions[bot]andGitHub 5c86b9a5e6 chore(deps): update dependency pinia to v4
Automated dependency update. All tests passed.
2026-10-03 17:16:50 +00:00
github-actions[bot]andGitHub e246206e9c chore(deps): update dependency lucide-vue-next to v1
Automated dependency update. All tests passed.
2026-10-03 17:15:47 +00:00
github-actions[bot]andGitHub 3c33bcdd61 chore(deps): update module github.com/cenkalti/backoff/v4 to v7
Automated dependency update. All tests passed.
2026-10-03 17:13:15 +00:00
github-actions[bot]andGitHub cc7d822a0a chore(deps): update dependency postcss-selector-parser@<6.1.4 to v7
Automated dependency update. All tests passed.
2026-10-03 16:50:54 +00:00
github-actions[bot]andGitHub 30e113fbbe chore(deps): update dependency minimatch@<9.0.9 to v10
Automated dependency update. All tests passed.
2026-10-03 16:48:32 +00:00
github-actions[bot]andGitHub cbceb6101b chore(deps): update dependency marked to v18
Automated dependency update. All tests passed.
2026-10-03 16:47:22 +00:00
github-actions[bot]andGitHub 3b6d2e7258 chore(deps): update dependency jsdom to v30
Automated dependency update. All tests passed.
2026-10-03 16:45:11 +00:00
github-actions[bot]andGitHub 328f764024 chore(deps): update module github.com/gofiber/fiber/v2 to v3
Automated dependency update. All tests passed.
2026-10-03 16:44:01 +00:00
github-actions[bot]andGitHub 8f5ed9c972 chore(deps): update module github.com/cenkalti/backoff/v4 to v7
Automated dependency update. All tests passed.
2026-10-03 16:39:46 +00:00
github-actions[bot]andGitHub 21d004eae9 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-03 16:35:25 +00:00
github-actions[bot]andGitHub 436633bc68 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-03 16:12:39 +00:00
lukaszraczyloandGitHub 8fae680cbe Update go.mod and go.sum (#151) 2026-10-03 04:29:22 +01:00
lukaszraczyloGitHubcopilot-swe-agent[bot] <[email protected]>
b172788741 fix(frontend): override vulnerable transitive dependencies (#150)
Dependabot security updates report update_not_possible because the
vulnerable packages are transitive and their parents pin them. The
Dependabot job fails on every run.

pnpm overrides raise minimatch to 9.0.9, esbuild to 0.28.2 and
postcss-selector-parser to 6.1.4. All three versions are older than the
seven day minimum release age. pnpm audit reports no known
vulnerabilities. The build and the 47 tests pass.

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-10-03 00:56:29 +01:00
lukaszraczylo 48c12a4869 fix(deps): override vulnerable frontend packages
Dependabot could not raise esbuild, minimatch and postcss-selector-parser, because their parents pin older ranges. The workspace now overrides them to the patched versions. The frontend builds and its tests pass.
2026-10-03 00:30:11 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8396284de3 chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates (#148)
Bumps the npm_and_yarn group with 8 updates in the /frontend directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.20.0` |
| [@vitest/mocker](https://github.com/vitest-dev/vitest/tree/HEAD/packages/mocker) | `4.1.0` | `5.0.3` |
| [brace-expansion](https://github.com/juliangruber/brace-expansion) | `2.0.2` | `2.1.7` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.28.1` | `4.29.3` |
| [defu](https://github.com/unjs/defu) | `6.1.4` | `6.1.7` |
| [js-cookie](https://github.com/js-cookie/js-cookie) | `3.0.5` | `3.0.8` |
| [picomatch](https://github.com/micromatch/picomatch) | `2.3.1` | `2.3.2` |
| [ws](https://github.com/websockets/ws) | `8.18.3` | `8.22.0` |



Updates `axios` from 1.18.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.20.0)

Updates `@vitest/mocker` from 4.1.0 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/mocker)

Updates `brace-expansion` from 2.0.2 to 2.1.7
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v2.1.7)

Updates `browserslist` from 4.28.1 to 4.29.3
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.1...4.29.3)

Updates `defu` from 6.1.4 to 6.1.7
- [Release notes](https://github.com/unjs/defu/releases)
- [Changelog](https://github.com/unjs/defu/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unjs/defu/compare/v6.1.4...v6.1.7)

Updates `js-cookie` from 3.0.5 to 3.0.8
- [Release notes](https://github.com/js-cookie/js-cookie/releases)
- [Commits](https://github.com/js-cookie/js-cookie/compare/v3.0.5...v3.0.8)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

Updates `ws` from 8.18.3 to 8.22.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.18.3...8.22.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@vitest/mocker"
  dependency-version: 5.0.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 2.1.7
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: browserslist
  dependency-version: 4.29.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: defu
  dependency-version: 6.1.7
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: js-cookie
  dependency-version: 3.0.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: ws
  dependency-version: 8.22.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 13:56:28 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d57a21817b chore(deps): bump go.opentelemetry.io/otel/sdk (#149)
Bumps the go_modules group with 1 update in the / directory: [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go).


Updates `go.opentelemetry.io/otel/sdk` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.45.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 13:56:19 +01:00
lukaszraczylo 1ebb4cfd95 fix(smb): use the cloudsoda go-smb2 fork
hirochachacha/go-smb2 has no fix for GO-2026-5051, an out-of-bounds read in ReadDir that govulncheck reports as called from walkPath. The cloudsoda fork carries the fix. Dialer.DialConn replaces Dial.
2026-10-02 13:24:13 +01:00
lukaszraczylo 8112b4859f fix(auth): restore ValidationCache.Stop
Stop was deleted while its field and doc comment stayed. The cleanup goroutine could not be stopped, and staticcheck flagged stopOnce as unused.
2026-10-02 13:23:58 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a412b61d8d chore(deps): bump the npm_and_yarn group across 1 directory with 8 updates (#128)
Bumps the npm_and_yarn group with 4 updates in the /frontend directory: [axios](https://github.com/axios/axios), [postcss](https://github.com/postcss/postcss), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `axios` from 1.13.5 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.5...v1.18.0)

Updates `postcss` from 8.5.6 to 8.5.23
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.23)

Updates `vite` from 7.3.0 to 7.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.5/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.5/packages/vite)

Updates `vitest` from 4.0.16 to 4.1.0
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.0/packages/vitest)

Updates `follow-redirects` from 1.15.11 to 1.16.0
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

Updates `form-data` from 4.0.5 to 4.0.6
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6)

Updates `nanoid` from 3.3.11 to 3.3.18
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.11...3.3.18)

Updates `rollup` from 4.54.0 to 4.63.1
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.54.0...v4.63.1)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 7.3.5
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: vitest
  dependency-version: 4.1.0
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: form-data
  dependency-version: 4.0.6
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: nanoid
  dependency-version: 3.3.18
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: rollup
  dependency-version: 4.63.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 13:14:56 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cbfb16aebd chore(deps): bump the go_modules group across 1 directory with 3 updates (#129)
Bumps the go_modules group with 3 updates in the / directory: [github.com/moby/go-archive](https://github.com/moby/go-archive), [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) and [google.golang.org/grpc](https://github.com/grpc/grpc-go).


Updates `github.com/moby/go-archive` from 0.2.0 to 0.3.0
- [Release notes](https://github.com/moby/go-archive/releases)
- [Changelog](https://github.com/moby/go-archive/blob/main/changes_test.go)
- [Commits](https://github.com/moby/go-archive/compare/v0.2.0...v0.3.0)

Updates `go.opentelemetry.io/otel` from 1.39.0 to 1.41.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.39.0...v1.41.0)

Updates `google.golang.org/grpc` from 1.79.3 to 1.83.2
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.83.2)

---
updated-dependencies:
- dependency-name: github.com/moby/go-archive
  dependency-version: 0.3.0
  dependency-type: indirect
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.41.0
  dependency-type: indirect
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 13:14:41 +01:00
lukaszraczylo 854245c4f3 ci: push benchmark data to gh-pages
The action wrote results to a local gh-pages branch and never pushed them. The next step then failed because docs/bench does not exist on main. The action now pushes gh-pages itself, and the manual push step is gone.
2026-10-02 12:43:30 +01:00
lukaszraczylo d96e724755 build(frontend): allow esbuild and vue-demi build scripts
pnpm 11 stops a frozen install when a dependency build script is not approved.
2026-10-02 12:04:18 +01:00
lukaszraczylo d0b3d82508 ci: build with Go 1.27 and grant PR checks access
go.mod needs Go 1.26 but the workflows pinned Go 1.25. The pull request workflow also lacked the permissions that the shared workflow requires, so it never started.
2026-10-02 12:04:17 +01:00
lukaszraczyloandGitHub 6d4ce976c7 Update go.mod and go.sum (#147) 2026-10-01 04:24:41 +01:00
lukaszraczyloandGitHub d760757c46 Update go.mod and go.sum (#146) 2026-09-30 04:23:17 +01:00
lukaszraczyloandGitHub 67d87ed66b Update go.mod and go.sum (#145) 2026-09-29 04:20:53 +01:00
lukaszraczyloandGitHub 22fc1b454b Update go.mod and go.sum (#144) 2026-09-28 04:23:33 +01:00
lukaszraczyloandGitHub 2c483445a1 Update go.mod and go.sum (#143) 2026-09-26 04:19:22 +01:00
lukaszraczyloandGitHub f5beaea3e0 Update go.mod and go.sum (#142) 2026-09-25 04:19:21 +01:00
lukaszraczyloandGitHub 4d61ed1557 Update go.mod and go.sum (#141) 2026-09-24 04:18:16 +01:00
lukaszraczyloandGitHub f1bcfa02f5 Update go.mod and go.sum (#140) 2026-09-22 04:19:59 +01:00
lukaszraczyloandGitHub 16e09c0515 Update go.mod and go.sum (#139) 2026-09-19 04:17:35 +01:00
lukaszraczyloandGitHub 0ff7719d8a Update go.mod and go.sum (#138) 2026-09-16 04:20:31 +01:00
lukaszraczyloandGitHub 1d7ee6c27b Update go.mod and go.sum (#137) 2026-09-15 04:18:48 +01:00
lukaszraczyloandGitHub e00233943b Update go.mod and go.sum (#136) 2026-09-12 04:19:03 +01:00
lukaszraczyloandGitHub 6c2448bb03 Update go.mod and go.sum (#135) 2026-09-11 04:19:38 +01:00
lukaszraczyloandGitHub a511c380ba Update go.mod and go.sum (#134) 2026-09-10 04:20:11 +01:00
lukaszraczyloandGitHub fa66501e5c Update go.mod and go.sum (#133) 2026-09-09 04:21:00 +01:00
lukaszraczyloandGitHub 35ea5b8a5b Update go.mod and go.sum (#132) 2026-09-08 04:18:09 +01:00
lukaszraczyloandGitHub e5d7242dc1 Update go.mod and go.sum (#131) 2026-09-06 04:19:50 +01:00
lukaszraczyloandGitHub 3c994687ff Update go.mod and go.sum (#130) 2026-09-05 04:19:01 +01:00
lukaszraczylo 171386f7a7 fix: remediate severity-ordered review findings across cache, scanners, and proxies
- H2: gormstore soft-delete + storage_key UNIQUE collision; re-save after eviction succeeds
- H1: cache getOrFetch cache-hit branch no longer runs CheckVulnerabilities on metadata entries
- M2: cache store() streams upstream body to temp file + SHA256 instead of double-buffering
- H3/L3: FetchResult/FetchFunc plumbing (+RequiresAuth/AuthProvider); proxy closures adapted;
        goproxy strips git+ prefix from UpstreamURL
- H4: scanner normalizes bypass package names; CheckVulnerabilities builds normalized packageKey
- M4: requireAdmin routes through extractAPIKey -> ValidateAPIKey -> admin role check
- M5: network client keeps only final RecordFailure (one count per attempt cycle)
- M3: normalizeOriginHost + buildCheckOrigin rewrite; origin_test.go added
- M1: app lifecycle cancel func; Run -> Shutdown on listen error (sole cache owner); JoinHostPort
- M6: VulnerabilityThresholds defaults all -1 so BlockOnSeverity governs
- L4: metadata.NormalizeSeverity at 4 severity-count sites (MEDIUM->MODERATE consistency)
- L1: handleDeleteBypass uses structured *errors.Error + ErrCodeNotFound
- L2: pypi extractVersionFromFilename (wheel/2nd segment, non-wheel/last digit segment);
      cacheVersion uses full unique basename; regression tests added
- L5: document per-instance ValidationCache key scoping; rename packageURL param to resource

All: go build ./... && go vet ./... && go test ./... pass; golangci-lint clean.
2026-09-04 14:24:13 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>copilot-swe-agent[bot] <[email protected]>lukaszraczylo
edeb2eca0c chore(deps): bump google.golang.org/grpc from 1.78.0 to 1.79.3 in the go_modules group across 1 directory (#36)
* chore(deps): bump google.golang.org/grpc

Bumps the go_modules group with 1 update in the / directory: [google.golang.org/grpc](https://github.com/grpc/grpc-go).


Updates `google.golang.org/grpc` from 1.78.0 to 1.79.3
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.78.0...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>

* Resolve merge conflicts and harden storage paths

Co-authored-by: lukaszraczylo <[email protected]>

---------

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: lukaszraczylo <[email protected]>
2026-09-03 11:16:29 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7fb596aafd chore(deps): bump axios (#14)
Bumps the npm_and_yarn group with 1 update in the /frontend directory: [axios](https://github.com/axios/axios).


Updates `axios` from 1.13.2 to 1.13.5
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.13.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 10:47:50 +01:00
lukaszraczyloandGitHub eb4517a45d Update go.mod and go.sum (#127) 2026-09-03 05:32:05 +01:00
lukaszraczyloandGitHub fa1b8cf6c4 Update go.mod and go.sum (#126) 2026-09-02 04:20:02 +01:00
lukaszraczyloandGitHub a5ac6f41e2 Update go.mod and go.sum (#125) 2026-09-01 04:20:37 +01:00
lukaszraczyloandGitHub 7de67d6a7a Update go.mod and go.sum (#124) 2026-08-29 04:17:50 +01:00
lukaszraczyloandGitHub db5dfef2d5 Update go.mod and go.sum (#123) 2026-08-28 06:49:21 +01:00
lukaszraczyloandGitHub b6a0a0800f Update go.mod and go.sum (#122) 2026-08-27 05:55:12 +01:00
lukaszraczyloandGitHub 36b99d528e Update go.mod and go.sum (#121) 2026-08-26 04:25:36 +01:00
lukaszraczyloandGitHub 601455cb9b Update go.mod and go.sum (#120) 2026-08-22 04:20:58 +01:00
lukaszraczyloandGitHub e0a489e471 Update go.mod and go.sum (#119) 2026-08-21 04:25:00 +01:00
lukaszraczyloandGitHub aa4b79ab0c Update go.mod and go.sum (#118) 2026-08-18 04:21:39 +01:00
lukaszraczyloandGitHub 8db5aa099b Update go.mod and go.sum (#117) 2026-08-15 04:18:04 +01:00
lukaszraczyloandGitHub 47f63e3b06 Update go.mod and go.sum (#116) 2026-08-13 04:51:47 +01:00
lukaszraczyloandGitHub 43f23900e0 Update go.mod and go.sum (#115) 2026-08-12 04:48:48 +01:00
lukaszraczyloandGitHub 3a113557cd Update go.mod and go.sum (#114) 2026-08-11 04:36:08 +01:00
lukaszraczyloandGitHub 22163595fd Update go.mod and go.sum (#113) 2026-08-08 04:35:19 +01:00
lukaszraczyloandGitHub 4dc7e2fcfb Update go.mod and go.sum (#112) 2026-08-07 05:01:46 +01:00
lukaszraczyloandGitHub 557ebc8410 Update go.mod and go.sum (#111) 2026-08-06 05:05:19 +01:00
lukaszraczyloandGitHub 2c82cd8a7a Update go.mod and go.sum (#110) 2026-08-04 06:51:26 +01:00
lukaszraczyloandGitHub 47cc2900a6 Update go.mod and go.sum (#109) 2026-08-01 06:58:29 +01:00
lukaszraczyloandGitHub c6e7c0d4c8 Update go.mod and go.sum (#108) 2026-07-30 06:45:43 +01:00
lukaszraczyloandGitHub c6ad90f742 Update go.mod and go.sum (#107) 2026-07-29 06:56:36 +01:00
lukaszraczyloandGitHub dfc7d0c917 Update go.mod and go.sum (#106) 2026-07-28 06:51:45 +01:00
lukaszraczyloandGitHub 4442cb3062 Update go.mod and go.sum (#105) 2026-07-27 07:33:24 +01:00
lukaszraczyloandGitHub 3ce6d274d5 Update go.mod and go.sum (#104) 2026-07-25 06:48:25 +01:00
lukaszraczyloandGitHub ce7cb769b3 Update go.mod and go.sum (#103) 2026-07-24 06:54:19 +01:00
lukaszraczyloandGitHub f3a210fd8d Update go.mod and go.sum (#102) 2026-07-23 06:59:04 +01:00
lukaszraczyloandGitHub c7f05c426f Update go.mod and go.sum (#101) 2026-07-22 06:55:12 +01:00
lukaszraczyloandGitHub bd3e8f024c Update go.mod and go.sum (#100) 2026-07-21 06:54:11 +01:00
lukaszraczyloandGitHub c390074f1c Update go.mod and go.sum (#99) 2026-07-17 06:46:38 +01:00
lukaszraczyloandGitHub 43d39bb07a Update go.mod and go.sum (#98) 2026-07-14 06:35:56 +01:00
lukaszraczyloandGitHub a95cd85155 Update go.mod and go.sum (#97) 2026-07-13 07:23:08 +01:00
lukaszraczyloandGitHub d48079e859 Update go.mod and go.sum (#96) 2026-07-11 05:05:30 +01:00
lukaszraczylo 12a629c652 fixup! feat: comprehensive audit + Tier 3 wiring (security/correctness/features) 2026-07-10 09:38:46 +01:00
lukaszraczylo e39d6a0f0d feat: comprehensive audit + Tier 3 wiring (security/correctness/features)
Multi-agent audit + fix pass covering bugs, security, dead config wiring,
and missing features. All quality gates green: build/vet/test -race/govulncheck/
golangci-lint. Frontend tests 47/47.

SECURITY & CORRECTNESS

- Scanner pipeline fail-closed: cache.go scan timeout now 503 (was goto servePkg);
  scanner.go all-scanners-fail saves ScanStatusError; CheckVulnerabilities blocks
  on missing/error result instead of allowing.
- Scanner argv corrections: govulncheck source-mode + go.mod discovery;
  npm-audit generates lockfile via npm install --package-lock-only --ignore-scripts;
  pip-audit per-extension dispatch (wheel direct / sdist extract+pyproject).
- GHSA version-range filtering implemented (was always-include); url.QueryEscape
  on package name.
- pypi SSRF closed via host allowlist on original_url; url.QueryEscape on
  rewriteURL output.
- Path traversal: cache temp file uses os.CreateTemp; smb keyToPath sanitized;
  filesystem keyToPath returns error + filepath.Abs prefix-verify.
- Goroutine leaks plugged: cache.cleanupWorker stop channel; auth.ValidationCache
  Stop() with sync.Once; ws.unregister buffered with non-blocking send.
- WebSocket double-close panic fixed via sync.Once Client.closeSend().
- Race conditions: gormstore.registryCache sync.RWMutex (was concurrent map
  panic); auth.LastUsedAt no longer mutated under RLock; analytics strict
  lock-ordering invariant (statsMu before downloadsMu).
- Auth validator fails CLOSED on transport/unknown-status errors (was returning
  true,err 'allow cache fallback').
- Credential cache hash full SHA256 (was 8-byte truncate; eliminates 2^32
  collision risk).
- filesystem.fs.used incremented after successful rename (no quota inflation
  race).
- gormstore aggregation events deleted in same tx as insert (no double-counting).
- gormstore.SavePackage uses Updates(map) so zero-value security flags
  (RequiresAuth=false) can be cleared.
- partition_manager validates partition name regex before DROP TABLE.
- vcs/git: version regex validation rejects --option-injection; checkout uses
  --detach -- separator; removed TrimPrefix(repo,'v') that corrupted vault/
  vitess/vim-go.
- WebSocket CheckOrigin allowlist (was return true; CSWSH closed); same-origin
  default + ServerConfig.AllowedOrigins.
- fiber CVE GO-2026-4543 patched (v2.52.10 -> v2.52.12).

FUNCTIONAL FEATURES

- API key DB persistence: APIKeyModel + migration 202604280002, full CRUD,
  async LastUsedAt updates with WaitGroup-tracked goroutines drained on Close.
- Admin bootstrap via GOHOARDER_BOOTSTRAP_ADMIN_KEY env var; idempotent
  (skipped when non-revoked admin already exists).
- Auth middleware factory in pkg/app: RequireAuth, RequireRole, RequirePermission,
  OptionalAuth. Mounted on proxy + read APIs when Auth.Enabled=true.
  DELETE /api/packages/* requires admin role. /health public for k8s probes.
- NFS storage backend: pkg/storage/nfs wraps filesystem with /proc/mounts
  detection (Linux), post-write fsync, read-after-write health probe.
- WebSocket real-time pipeline: pkg/events Broadcaster interface; cache + scanner
  managers emit EventPackageCached / EventPackageDownloaded / EventScanComplete;
  app.go runs 30s EventStatsUpdate ticker. Frontend has full WS client (reconnect
  with exponential backoff 1s->30s, 25s heartbeat, subscriber pattern), Pinia
  realtime store, Dashboard live indicator + activity feed + reactive stats
  override.
- TLS termination: fiber.ListenTLS when Server.TLS.Enabled.
- Pre-warming: Prewarming.Enabled flag wired (was hardcoded false); Interval,
  MaxConcurrent, TopPackages plumbed.
- NetworkConfig wired (timeouts, retry, rate-limit, circuit-breaker).
- AuthConfig.BcryptCost wired.
- Security.Scanners.Static.MaxPackageSize plumbed to cache.io.LimitReader.
- Handlers.{Go,NPM,PyPI}.Enabled gates route mounting.
- Graceful shutdown: authManager.Close drains async writes.

LINT/HYGIENE

- 80 golangci-lint issues resolved: errcheck (Close/Write/RemoveAll wrapped),
  gofmt, gosec G104/G306, govet shadow renames + fieldalignment reorders,
  staticcheck ST1000 pkg comments + QF1003 tagged switches + QF1008 embedded
  field selectors + QF1001 De Morgan's law.

BEHAVIOR CHANGES

- Scanner now fail-closed: deployments without scanner binaries
  (trivy/govulncheck/npm-audit/pip-audit/grype) BLOCK packages instead of
  serving unscanned. Add binaries or plan a future allow-on-scan-error flag.
- WS origin defaults to same-origin; cross-origin dashboards must set
  server.allowed_origins.
- Validator no longer fails open; private packages won't serve from cache when
  validation can't be performed.
- download_events retention dropped from 24h to ~5min (deleted in agg tx).
  Migration 202604280001 purges pre-upgrade events.
- DELETE /api/packages/* requires admin role when auth enabled.

NEW PACKAGES

- pkg/events  - Broadcaster interface
- pkg/storage/nfs  - NFS-aware filesystem wrapper

DEFERRED (out of scope this pass)

- Static scanner implementation (config defines AllowedLicenses/MaxPackageSize/
  BlockSuspicious but pkg/scanner/static/ does not exist).
- AuthConfig.AuditLog wiring (no audit log infra yet).
- CacheConfig.TTLOverrides passthrough (would touch cache.Config beyond
  integrator scope).
2026-07-10 09:37:55 +01:00
lukaszraczylo 3c8b3eb46c fixup! fix: exclude Go module metadata files from package count stats 2026-07-10 09:35:45 +01:00
lukaszraczylo c8bc8eca8f fix: exclude Go module metadata files from package count stats
The dashboard was showing 335 packages while the packages page showed 141
because GetStats was counting .mod and .info files as separate packages.

Changes:
- Filter out .mod and .info files in GetStats query
- Now only counts actual packages (.zip files)
- Dashboard and packages page counts will match

This fixes the discrepancy where:
- Dashboard (/api/stats): was 335, now will be ~141
- Packages page (/api/packages): already 141 (deduplicated)

Resolves: "Dashboard showing 335 packages but /packages showing 141"
2026-07-10 09:35:45 +01:00
lukaszraczylo d5979a8b88 fix: skip scanning Go module metadata files (.mod, .info)
Go module metadata files (.mod and .info) are not actual packages
and should not be scanned for vulnerabilities. Only .zip files
contain the actual module code.

Changes:
- Added .mod and .info suffix checks to isMetadataEntry filter
- These files are now excluded from database storage
- Scanner won't attempt to scan them during rescan cycles

This fixes OSV scanner 404 errors for metadata files like:
- github.com/mattn/go-isatty/@v/v0.0.20.mod
- github.com/clipperhouse/stringish/@v/v0.1.1.info

Resolves: OSV API errors "The current request is not defined by this API"
for Go module metadata files
2026-07-10 09:35:45 +01:00
lukaszraczyloandGitHub c38d6bec0d Update go.mod and go.sum (#95) 2026-07-09 05:23:50 +01:00
lukaszraczyloandGitHub 1616a7d42d Update go.mod and go.sum (#94) 2026-07-07 05:25:04 +01:00
lukaszraczyloandGitHub ec21194229 Update go.mod and go.sum (#93) 2026-07-05 05:27:13 +01:00
lukaszraczyloandGitHub 8967d313e0 Update go.mod and go.sum (#92) 2026-07-02 05:30:08 +01:00
lukaszraczyloandGitHub 75eda36638 Update go.mod and go.sum (#91) 2026-07-01 05:38:54 +01:00
lukaszraczyloandGitHub 2a3666ae7c Update go.mod and go.sum (#90) 2026-06-30 05:31:08 +01:00
lukaszraczyloandGitHub ab45e2f511 Update go.mod and go.sum (#89) 2026-06-28 05:37:58 +01:00
lukaszraczyloandGitHub 6b8f8443fb Update go.mod and go.sum (#88) 2026-06-27 05:27:45 +01:00
lukaszraczyloandGitHub e451ec5f1f Update go.mod and go.sum (#87) 2026-06-26 05:32:55 +01:00
lukaszraczyloandGitHub 3898756bf6 Update go.mod and go.sum (#86) 2026-06-24 05:31:06 +01:00
lukaszraczyloandGitHub 3c22e53c2e Update go.mod and go.sum (#85) 2026-06-23 05:30:05 +01:00
lukaszraczyloandGitHub 12a75bf77d Update go.mod and go.sum (#84) 2026-06-22 05:38:46 +01:00
lukaszraczyloandGitHub 1211320939 Update go.mod and go.sum (#83) 2026-06-19 05:39:39 +01:00
lukaszraczyloandGitHub 86f7d974e7 Update go.mod and go.sum (#82) 2026-06-18 05:36:29 +01:00
lukaszraczyloandGitHub f0eaf695ab Update go.mod and go.sum (#81) 2026-06-17 05:37:06 +01:00
lukaszraczyloandGitHub cfceb063ee Update go.mod and go.sum (#80) 2026-06-11 05:36:25 +01:00
lukaszraczyloandGitHub 76b38c5c10 Update go.mod and go.sum (#79) 2026-06-09 05:30:46 +01:00
lukaszraczyloandGitHub ae85e74094 Update go.mod and go.sum (#78) 2026-06-06 05:29:56 +01:00
lukaszraczyloandGitHub e08cab3311 Update go.mod and go.sum (#77) 2026-06-05 05:36:18 +01:00
lukaszraczyloandGitHub c9a124e708 Update go.mod and go.sum (#76) 2026-06-04 05:37:15 +01:00
lukaszraczyloandGitHub 640c54201f Update go.mod and go.sum (#75) 2026-06-03 05:37:50 +01:00
lukaszraczyloandGitHub ba632c4c14 Update go.mod and go.sum (#74) 2026-05-30 05:26:59 +01:00
lukaszraczyloandGitHub bd07e45c05 Update go.mod and go.sum (#73) 2026-05-29 05:34:39 +01:00
lukaszraczyloandGitHub 69ef5a478a Update go.mod and go.sum (#72) 2026-05-28 05:33:24 +01:00
lukaszraczyloandGitHub 2b425d4012 Update go.mod and go.sum (#71) 2026-05-27 05:35:38 +01:00
lukaszraczyloandGitHub 875fd9025a Update go.mod and go.sum (#70) 2026-05-23 05:21:35 +01:00
lukaszraczyloandGitHub 1fc61b1ec3 Update go.mod and go.sum (#69) 2026-05-22 05:30:56 +01:00
lukaszraczyloandGitHub c38209d8d1 Update go.mod and go.sum (#68) 2026-05-09 05:14:14 +01:00
lukaszraczyloandGitHub 4ffb17757c Update go.mod and go.sum (#67) 2026-05-07 05:21:07 +01:00
lukaszraczyloandGitHub 5dcf1cf6b0 Update go.mod and go.sum (#66) 2026-05-05 05:08:46 +01:00
lukaszraczyloandGitHub 923651d11a Update go.mod and go.sum (#65) 2026-05-02 05:11:11 +01:00
lukaszraczyloandGitHub fedb40c826 Update go.mod and go.sum (#64) 2026-05-01 05:28:57 +01:00
lukaszraczyloandGitHub b9cd641db3 Update go.mod and go.sum (#63) 2026-04-30 05:20:27 +01:00
lukaszraczyloandGitHub ddad5883fe Update go.mod and go.sum (#62) 2026-04-27 05:19:04 +01:00
lukaszraczyloandGitHub b25dfcd263 Update go.mod and go.sum (#61) 2026-04-26 05:15:01 +01:00
lukaszraczyloandGitHub 8c85532c86 Update go.mod and go.sum (#60) 2026-04-24 05:10:16 +01:00
lukaszraczyloandGitHub 7e9b0067f1 Update go.mod and go.sum (#59) 2026-04-23 05:07:32 +01:00
lukaszraczyloandGitHub 7fb2ed6d8d Update go.mod and go.sum (#58) 2026-04-21 05:07:17 +01:00
lukaszraczyloandGitHub 65446ddbe7 Update go.mod and go.sum (#57) 2026-04-19 05:08:17 +01:00
lukaszraczyloandGitHub 205a8c0ee5 Update go.mod and go.sum (#56) 2026-04-18 05:01:42 +01:00
lukaszraczyloandGitHub 9462c66edf Update go.mod and go.sum (#55) 2026-04-17 05:07:50 +01:00
lukaszraczyloandGitHub 7c727df4f8 Update go.mod and go.sum (#54) 2026-04-16 05:08:18 +01:00
lukaszraczyloandGitHub 26fd95cea9 Update go.mod and go.sum (#53) 2026-04-10 05:06:27 +01:00
lukaszraczyloandGitHub 79c46f3b1e Update go.mod and go.sum (#52) 2026-04-09 05:01:23 +01:00
105 changed files with 9489 additions and 3041 deletions
+8 -7
View File
@@ -1,4 +1,4 @@
name: Autoupdate go.mod and go.sum name: Update dependencies
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,14 +6,15 @@ on:
- cron: "0 3 * * *" - cron: "0 3 * * *"
permissions: permissions:
contents: write
actions: write actions: write
contents: write
issues: write
pull-requests: write pull-requests: write
jobs: jobs:
autoupdate: update:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main uses: lukaszraczylo/shared-actions/.github/workflows/renovate-autoupdate.yaml@main
with: with:
go-version: ">=1.25" go-version: ">=1.27"
release-workflow: "release.yaml" node-working-directory: frontend
secrets: inherit release-workflow: release.yaml
+6 -1
View File
@@ -9,8 +9,13 @@ on:
- "**" - "**"
- "!main" - "!main"
permissions:
contents: read
pull-requests: write
security-events: write
jobs: jobs:
pr-checks: pr-checks:
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
with: with:
go-version: "1.25" go-version: ">=1.27"
+3 -13
View File
@@ -22,7 +22,7 @@ jobs:
release: release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main uses: lukaszraczylo/shared-actions/.github/workflows/go-release-cgo.yaml@main
with: with:
go-version: "1.25" go-version: ">=1.27"
# Enable frontend build # Enable frontend build
node-enabled: true node-enabled: true
node-version: "20" node-version: "20"
@@ -45,7 +45,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.25" go-version: ">=1.27"
- name: Run benchmarks - name: Run benchmarks
run: go test -bench=. -benchmem ./... -run=^# | tee output.txt run: go test -bench=. -benchmem ./... -run=^# | tee output.txt
@@ -59,19 +59,9 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
comment-on-alert: true comment-on-alert: true
summary-always: true summary-always: true
auto-push: false auto-push: true
benchmark-data-dir-path: "docs/bench" benchmark-data-dir-path: "docs/bench"
- name: Push benchmark results
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/bench
git diff --staged --quiet || git commit -m "Update benchmark results"
git push origin main
publish-helm-chart: publish-helm-chart:
name: Publish Helm Chart name: Publish Helm Chart
needs: release needs: release
+11
View File
@@ -151,6 +151,17 @@ release:
# Uses PRE-BUILT binaries from native builds (no Docker compilation - much faster!) # Uses PRE-BUILT binaries from native builds (no Docker compilation - much faster!)
# GoReleaser injects the platform-specific binary into each Docker image automatically # GoReleaser injects the platform-specific binary into each Docker image automatically
# This avoids slow QEMU emulation for cross-architecture builds # This avoids slow QEMU emulation for cross-architecture builds
#
# Split/Merge Build Support (GoReleaser Pro):
# - Run `goreleaser release --split` on multiple CI runners in parallel
# - Each runner builds a subset of targets (e.g., runner 1: linux/amd64, runner 2: linux/arm64)
# - Run `goreleaser release --merge` to combine all partial builds into final release
# - Significantly speeds up builds by parallelizing across multiple machines
#
# Binary Path Convention:
# - Binaries must be in linux/${TARGETARCH}/ subdirectory format
# - GoReleaser automatically places binaries there when using --split
# - COPY linux/${TARGETARCH}/binary_name in Dockerfiles
dockers_v2: dockers_v2:
# 1. Application Engine - Main GoHoarder server # 1. Application Engine - Main GoHoarder server
# Uses pre-built binary from 'gohoarder' build (no Docker compilation) # Uses pre-built binary from 'gohoarder' build (no Docker compilation)
+4 -3
View File
@@ -1,3 +1,4 @@
// Package commands hosts the cobra commands wired into the gohoarder CLI.
package commands package commands
import ( import (
@@ -35,11 +36,11 @@ func runServe(cmd *cobra.Command, args []string) error {
} }
// Initialize logger // Initialize logger
if err := logger.Init(logger.Config{ if logErr := logger.Init(logger.Config{
Level: cfg.Logging.Level, Level: cfg.Logging.Level,
Format: cfg.Logging.Format, Format: cfg.Logging.Format,
}); err != nil { }); logErr != nil {
return fmt.Errorf("failed to initialize logger: %w", err) return fmt.Errorf("failed to initialize logger: %w", logErr)
} }
log.Info(). log.Info().
+7 -7
View File
@@ -23,16 +23,16 @@ var VersionCmd = &cobra.Command{
if jsonOutput { if jsonOutput {
data, err := json.MarshalIndent(info, "", " ") data, err := json.MarshalIndent(info, "", " ")
if err != nil { if err != nil {
fmt.Fprintf(cmd.OutOrStderr(), "Error: %v\n", err) _, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error: %v\n", err)
return return
} }
fmt.Fprintln(cmd.OutOrStdout(), string(data)) _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(data))
} else { } else {
fmt.Fprintf(cmd.OutOrStdout(), "GoHoarder %s\n", info.Version) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "GoHoarder %s\n", info.Version)
fmt.Fprintf(cmd.OutOrStdout(), "Git Commit: %s\n", info.GitCommit) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Git Commit: %s\n", info.GitCommit)
fmt.Fprintf(cmd.OutOrStdout(), "Built: %s\n", info.BuildTime) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Built: %s\n", info.BuildTime)
fmt.Fprintf(cmd.OutOrStdout(), "Go Version: %s\n", info.GoVersion) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Go Version: %s\n", info.GoVersion)
fmt.Fprintf(cmd.OutOrStdout(), "Platform: %s\n", info.Platform) _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Platform: %s\n", info.Platform)
} }
}, },
} }
+2 -2
View File
@@ -25,10 +25,10 @@ import (
type MigrationConfig struct { type MigrationConfig struct {
Driver string Driver string
DSN string DSN string
Timeout time.Duration
Action string // migrate, rollback, rollback-to, list Action string // migrate, rollback, rollback-to, list
TargetID string // For rollback-to TargetID string // For rollback-to
LogLevel string LogLevel string
Timeout time.Duration
} }
func main() { func main() {
@@ -97,7 +97,7 @@ func RunMigration(cfg MigrationConfig) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to get sql.DB: %w", err) return fmt.Errorf("failed to get sql.DB: %w", err)
} }
defer sqlDB.Close() defer func() { _ = sqlDB.Close() }()
// Wait for database to be ready // Wait for database to be ready
if err := waitForDB(ctx, sqlDB, 60*time.Second); err != nil { if err := waitForDB(ctx, sqlDB, 60*time.Second); err != nil {
+129
View File
@@ -0,0 +1,129 @@
# Example GoHoarder values for Traefik IngressRoute
#
# This configuration uses Traefik IngressRoute instead of standard Kubernetes Ingress
# to better handle scoped npm packages with encoded slashes.
#
# IMPORTANT: You must also configure your Traefik deployment to allow encoded slashes.
# See docs/TRAEFIK_ENCODED_SLASHES.md for Traefik configuration steps.
nameOverride: "gohoarder"
fullnameOverride: ""
global:
domain: "i.raczylo.com"
imagePullSecrets: []
replicaCount:
server: 1
frontend: 1
scanner: 1
image:
server:
repository: ghcr.io/lukaszraczylo/gohoarder-server
pullPolicy: IfNotPresent
tag: "latest"
frontend:
repository: ghcr.io/lukaszraczylo/gohoarder-frontend
pullPolicy: IfNotPresent
tag: "latest"
scanner:
repository: ghcr.io/lukaszraczylo/gohoarder-scanner
pullPolicy: IfNotPresent
tag: "latest"
# Ingress configuration for Traefik
ingress:
enabled: true
className: "traefik" # This triggers IngressRoute creation instead of Ingress
# Your GoHoarder hostname
host: "hoarder.i.raczylo.com"
# TLS configuration
tls:
enabled: true
secretName: "cert-i.raczylo.com"
# Traefik-specific settings
# These are used by the IngressRoute template
traefik:
# Reference middlewares from the traefik namespace
# These will be applied to all routes
middlewares:
- default-chain # Your existing middleware chain from traefik namespace
# ServersTransport configuration (optional)
transport:
serverName: ""
# Storage configuration
storage:
backend: "filesystem"
filesystem:
storageClass: "longhorn"
size: "100Gi"
accessMode: "ReadWriteOnce"
# Metadata storage
metadata:
backend: "sqlite"
sqlite:
persistence:
enabled: false # Using emptyDir for metadata
walMode: false
# Cache configuration
cache:
defaultTTL: "168h" # 7 days
maxSizeBytes: 536870912000 # 500GB
# Package handlers
handlers:
npm:
enabled: true
upstreamRegistry: "https://registry.npmjs.org"
pypi:
enabled: true
upstreamUrl: "https://pypi.org"
go:
enabled: true
upstreamProxy: "https://proxy.golang.org"
# Authentication
auth:
enabled: true
adminApiKey: "" # Will be auto-generated if empty
# Logging
logging:
level: "info"
format: "json"
# Resource limits
server:
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 512Mi
frontend:
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
scanner:
resources:
limits:
cpu: 2000m
memory: 4Gi
requests:
cpu: 500m
memory: 1Gi
+13 -14
View File
@@ -11,33 +11,32 @@
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^14.1.0", "@vueuse/core": "^15.0.0",
"axios": "^1.13.2", "axios": "^1.20.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-vue-next": "^0.562.0", "lucide-vue-next": "^1.0.0",
"marked": "^17.0.1", "marked": "^18.0.0",
"pinia": "^3.0.4", "pinia": "^4.0.0",
"reka-ui": "^2.7.0", "reka-ui": "^2.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-router": "^4.6.4" "vue-router": "^5.3.1"
}, },
"devDependencies": { "devDependencies": {
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^7.1.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1", "@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/vue": "^8.1.0", "@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.23", "jsdom": "^30.0.0",
"jsdom": "^27.4.0", "tailwindcss": "^4.3.3",
"postcss": "^8.5.6", "typescript": "^6.0.3",
"tailwindcss": "^3.4.19", "vite": "^8.0.0",
"typescript": "^5.9.3", "vitest": "^5.0.3",
"vite": "^7.3.0",
"vitest": "^4.0.16",
"vue-tsc": "^3.2.1" "vue-tsc": "^3.2.1"
} }
} }
+1555 -1785
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
allowBuilds:
esbuild: true
vue-demi: true
# Patched versions of transitive dependencies that Dependabot cannot raise on its own.
overrides:
"minimatch@<9.0.9": ^10.0.0
"esbuild@<0.28.2": ^0.28.2
"postcss-selector-parser@<6.1.4": ^7.0.0
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+31
View File
@@ -3,10 +3,12 @@ import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import Dashboard from './Dashboard.vue' import Dashboard from './Dashboard.vue'
import { usePackageStore } from '../stores/packages' import { usePackageStore } from '../stores/packages'
import { useRealtimeStore, __resetRealtimeSingleton } from '../stores/realtime'
describe('Dashboard.vue', () => { describe('Dashboard.vue', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
__resetRealtimeSingleton()
// Mock the fetch functions to prevent actual API calls // Mock the fetch functions to prevent actual API calls
const store = usePackageStore() const store = usePackageStore()
vi.spyOn(store, 'fetchStats').mockResolvedValue() vi.spyOn(store, 'fetchStats').mockResolvedValue()
@@ -213,4 +215,33 @@ describe('Dashboard.vue', () => {
expect(wrapper.text()).toContain('0') expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('0 B') expect(wrapper.text()).toContain('0 B')
}) })
it('renders realtime lastStats over polled stats when present', async () => {
const wrapper = mount(Dashboard)
const store = usePackageStore()
const rt = useRealtimeStore()
store.loading = false
store.stats = {
registry: '',
total_packages: 10,
total_size: 1024,
max_cache_size: 10737418240,
total_downloads: 5,
scanned_packages: 0,
vulnerable_packages: 0,
blocked_packages: 0,
}
rt.lastStats = {
total_packages: 999,
total_size: 2048,
total_downloads: 777,
scanned_packages: 0,
}
await wrapper.vm.$nextTick()
// Realtime values should be visible (override polled values).
expect(wrapper.text()).toContain('999')
expect(wrapper.text()).toContain('777')
})
}) })
+111 -6
View File
@@ -1,6 +1,23 @@
<template> <template>
<div> <div>
<h2 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h2> <div class="flex items-center justify-between mb-8">
<h2 class="text-3xl font-bold text-gray-900">Dashboard</h2>
<div
class="flex items-center gap-2 px-3 py-1 rounded-full border text-xs font-medium"
:class="
rt.connected
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
: 'bg-gray-100 border-gray-200 text-gray-500'
"
data-testid="ws-live-indicator"
>
<span
class="w-2 h-2 rounded-full"
:class="rt.connected ? 'bg-emerald-500 animate-pulse' : 'bg-gray-400'"
></span>
{{ rt.connected ? 'Live' : 'Offline' }}
</div>
</div>
<!-- Error Alert --> <!-- Error Alert -->
<Alert v-if="error" variant="destructive" class="mb-4"> <Alert v-if="error" variant="destructive" class="mb-4">
@@ -22,7 +39,7 @@
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Packages</p> <p class="text-sm font-medium text-muted-foreground">Total Packages</p>
<p class="text-3xl font-bold text-foreground tracking-tight"> <p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.total_packages || 0) }} {{ formatNumber(displayStats?.total_packages || 0) }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-slate-100 rounded-xl flex items-center justify-center"> <div class="w-12 h-12 bg-slate-100 rounded-xl flex items-center justify-center">
@@ -38,7 +55,7 @@
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Size</p> <p class="text-sm font-medium text-muted-foreground">Total Size</p>
<p class="text-3xl font-bold text-foreground tracking-tight"> <p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatBytes(stats?.total_size || 0) }} {{ formatBytes(displayStats?.total_size || 0) }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-sky-50 rounded-xl flex items-center justify-center"> <div class="w-12 h-12 bg-sky-50 rounded-xl flex items-center justify-center">
@@ -54,7 +71,7 @@
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Total Downloads</p> <p class="text-sm font-medium text-muted-foreground">Total Downloads</p>
<p class="text-3xl font-bold text-foreground tracking-tight"> <p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.total_downloads || 0) }} {{ formatNumber(displayStats?.total_downloads || 0) }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center"> <div class="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
@@ -70,7 +87,7 @@
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">Scanned Packages</p> <p class="text-sm font-medium text-muted-foreground">Scanned Packages</p>
<p class="text-3xl font-bold text-foreground tracking-tight"> <p class="text-3xl font-bold text-foreground tracking-tight">
{{ formatNumber(stats?.scanned_packages || 0) }} {{ formatNumber(displayStats?.scanned_packages || 0) }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-violet-50 rounded-xl flex items-center justify-center"> <div class="w-12 h-12 bg-violet-50 rounded-xl flex items-center justify-center">
@@ -81,6 +98,31 @@
</Card> </Card>
</div> </div>
<!-- Realtime Feed -->
<Card v-if="recentRealtimeEvents.length > 0" class="border-0 shadow-lg mb-10" data-testid="ws-event-feed">
<CardContent class="p-6">
<h3 class="text-xl font-semibold text-foreground mb-4">
<i class="fas fa-bolt mr-2 text-amber-500"></i>Live Activity
</h3>
<ul class="space-y-2">
<li
v-for="(evt, idx) in recentRealtimeEvents"
:key="`${evt.timestamp}-${idx}`"
class="flex items-center gap-3 text-sm"
>
<component :is="iconForEvent(evt.type)" class="w-4 h-4 text-muted-foreground" />
<span class="font-medium">{{ labelForEvent(evt.type) }}</span>
<span class="text-muted-foreground truncate">
{{ describeEvent(evt) }}
</span>
<span class="ml-auto text-xs text-muted-foreground">
{{ formatTimestamp(evt.timestamp) }}
</span>
</li>
</ul>
</CardContent>
</Card>
<!-- Downloads Chart --> <!-- Downloads Chart -->
<Card class="border-0 shadow-lg mb-10"> <Card class="border-0 shadow-lg mb-10">
<CardContent class="p-6"> <CardContent class="p-6">
@@ -178,10 +220,19 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch, type Component } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import axios from 'axios' import axios from 'axios'
import {
Package as PackageIcon,
Download as DownloadIcon,
Trash2 as TrashIcon,
ShieldCheck as ShieldIcon,
Activity as ActivityIcon,
} from 'lucide-vue-next'
import { usePackageStore } from '../stores/packages' import { usePackageStore } from '../stores/packages'
import { useRealtimeStore } from '../stores/realtime'
import type { EventType, RealtimeEvent } from '@/lib/ws'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -191,6 +242,56 @@ import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
const store = usePackageStore() const store = usePackageStore()
const { packages, stats, loading, error } = storeToRefs(store) const { packages, stats, loading, error } = storeToRefs(store)
const rt = useRealtimeStore()
const { lastStats, events: rtEvents } = storeToRefs(rt)
// Realtime stats override polled stats when available; otherwise fall back.
const displayStats = computed(() => lastStats.value ?? stats.value)
const recentRealtimeEvents = computed<RealtimeEvent[]>(() => {
const list = rtEvents.value
return list.slice(Math.max(list.length - 5, 0)).reverse()
})
const EVENT_LABELS: Record<EventType, string> = {
package_cached: 'Cached',
package_deleted: 'Deleted',
package_downloaded: 'Downloaded',
scan_complete: 'Scan complete',
stats_update: 'Stats update',
}
const EVENT_ICONS: Record<EventType, Component> = {
package_cached: PackageIcon,
package_deleted: TrashIcon,
package_downloaded: DownloadIcon,
scan_complete: ShieldIcon,
stats_update: ActivityIcon,
}
function iconForEvent(type: EventType): Component {
return EVENT_ICONS[type] ?? ActivityIcon
}
function labelForEvent(type: EventType): string {
return EVENT_LABELS[type] ?? type
}
function describeEvent(evt: RealtimeEvent): string {
const p = evt.payload
const name = typeof p.name === 'string' ? p.name : ''
const version = typeof p.version === 'string' ? `@${p.version}` : ''
const registry = typeof p.registry === 'string' ? `[${p.registry}] ` : ''
if (evt.type === 'stats_update') return ''
if (name) return `${registry}${name}${version}`
return ''
}
function formatTimestamp(ts: number): string {
if (!ts) return ''
return new Date(ts).toLocaleTimeString()
}
// Chart periods and data // Chart periods and data
const selectedPeriod = ref<string>('1day') const selectedPeriod = ref<string>('1day')
const chartPeriods = [ const chartPeriods = [
@@ -286,6 +387,10 @@ watch(selectedPeriod, () => {
}) })
onMounted(async () => { onMounted(async () => {
// Connect to realtime stream first so we can pick up live events while
// initial polling resolves. Singleton survives navigation, so we
// intentionally do not disconnect on unmount.
rt.connect()
await store.fetchStats() await store.fetchStats()
await store.fetchPackages() await store.fetchPackages()
await fetchTimeSeriesData() await fetchTimeSeriesData()
+52 -15
View File
@@ -1,22 +1,56 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia' import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import { defineComponent, h } from 'vue'
import PackageList from './PackageList.vue' import PackageList from './PackageList.vue'
import { usePackageStore } from '../stores/packages' import { usePackageStore } from '../stores/packages'
const Stub = defineComponent({ render: () => h('div') })
function createTestRouter(): Router {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'dashboard', component: Stub },
{ path: '/packages/:registry?', name: 'packages', component: Stub, props: true },
{
path: '/package/:registry/:name+/:version',
name: 'package-details',
component: Stub,
props: true,
},
],
})
}
async function mountWithRouter() {
const router = createTestRouter()
router.push('/packages')
await router.isReady()
return mount(PackageList, {
global: {
plugins: [router],
},
})
}
describe('PackageList.vue', () => { describe('PackageList.vue', () => {
beforeEach(() => { beforeEach(() => {
// Create a fresh pinia instance before each test // Create a fresh pinia instance before each test
setActivePinia(createPinia()) setActivePinia(createPinia())
// Prevent real network calls from store actions on mount
const store = usePackageStore()
vi.spyOn(store, 'fetchPackages').mockResolvedValue()
}) })
it('renders package list component', () => { it('renders package list component', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
expect(wrapper.find('h2').text()).toBe('Packages') expect(wrapper.find('h2').text()).toBe('Packages')
}) })
it('displays loading state when loading', async () => { it('displays loading state when loading', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
const store = usePackageStore() const store = usePackageStore()
store.loading = true store.loading = true
@@ -26,7 +60,7 @@ describe('PackageList.vue', () => {
}) })
it('displays error message when error occurs', async () => { it('displays error message when error occurs', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
const store = usePackageStore() const store = usePackageStore()
store.error = 'Failed to fetch packages' store.error = 'Failed to fetch packages'
@@ -36,7 +70,7 @@ describe('PackageList.vue', () => {
}) })
it('displays empty state when no packages', async () => { it('displays empty state when no packages', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
const store = usePackageStore() const store = usePackageStore()
store.loading = false store.loading = false
@@ -47,7 +81,7 @@ describe('PackageList.vue', () => {
}) })
it('displays package accordion when packages exist', async () => { it('displays package accordion when packages exist', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
const store = usePackageStore() const store = usePackageStore()
store.loading = false store.loading = false
@@ -69,17 +103,19 @@ describe('PackageList.vue', () => {
expect(wrapper.text()).toContain('1 version') expect(wrapper.text()).toContain('1 version')
}) })
it('calls fetchPackages on mount', () => { it('calls fetchPackages on mount', async () => {
const store = usePackageStore() const store = usePackageStore()
const fetchSpy = vi.spyOn(store, 'fetchPackages') // beforeEach already spied with mockResolvedValue; reuse that spy
const fetchSpy = store.fetchPackages as ReturnType<typeof vi.spyOn>
fetchSpy.mockClear()
mount(PackageList) await mountWithRouter()
expect(fetchSpy).toHaveBeenCalled() expect(fetchSpy).toHaveBeenCalled()
}) })
it('groups packages and displays version counts', async () => { it('groups packages and displays version counts', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
const store = usePackageStore() const store = usePackageStore()
store.loading = false store.loading = false
@@ -112,7 +148,7 @@ describe('PackageList.vue', () => {
}) })
it('formats bytes correctly', async () => { it('formats bytes correctly', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
const store = usePackageStore() const store = usePackageStore()
store.loading = false store.loading = false
@@ -134,7 +170,7 @@ describe('PackageList.vue', () => {
}) })
it('applies correct registry badge classes', async () => { it('applies correct registry badge classes', async () => {
const wrapper = mount(PackageList) const wrapper = await mountWithRouter()
const store = usePackageStore() const store = usePackageStore()
store.loading = false store.loading = false
@@ -179,9 +215,10 @@ describe('PackageList.vue', () => {
expect(wrapper.text()).toContain('go') expect(wrapper.text()).toContain('go')
// Verify badge component is used with correct classes // Verify badge component is used with correct classes
// (matches getRegistryBadgeClass in src/composables/useBadgeStyles.ts)
const html = wrapper.html() const html = wrapper.html()
expect(html).toContain('bg-blue-100') // npm badge expect(html).toContain('bg-red-100') // npm badge
expect(html).toContain('bg-green-100') // pypi badge expect(html).toContain('bg-blue-100') // pypi badge
expect(html).toContain('bg-yellow-100') // go badge expect(html).toContain('bg-cyan-100') // go badge
}) })
}) })
+4 -1
View File
@@ -172,7 +172,10 @@ describe('Stats.vue', () => {
} }
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const containers = wrapper.findAll('.rounded-full') // Scope to the registry icon containers (w-12 h-12 rounded-full),
// excluding the storage progress bar which also uses rounded-full.
const containers = wrapper.findAll('.w-12.h-12.rounded-full')
expect(containers).toHaveLength(3)
expect(containers[0].classes()).toContain('bg-red-100') // npm expect(containers[0].classes()).toContain('bg-red-100') // npm
expect(containers[1].classes()).toContain('bg-blue-100') // pypi expect(containers[1].classes()).toContain('bg-blue-100') // pypi
expect(containers[2].classes()).toContain('bg-cyan-100') // go expect(containers[2].classes()).toContain('bg-cyan-100') // go
+223
View File
@@ -0,0 +1,223 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { WSClient, __wsInternals, type RealtimeEvent } from './ws'
/**
* Minimal WebSocket stub mimicking the parts of the browser API the WSClient
* touches: readyState, onopen, onmessage, onclose, onerror, send, close.
*/
class StubWebSocket {
static OPEN = 1
static CLOSED = 3
static instances: StubWebSocket[] = []
url: string
readyState = 0
sent: string[] = []
onopen: ((ev: Event) => void) | null = null
onmessage: ((ev: MessageEvent) => void) | null = null
onclose: ((ev: CloseEvent) => void) | null = null
onerror: ((ev: Event) => void) | null = null
constructor(url: string) {
this.url = url
StubWebSocket.instances.push(this)
}
// Test helpers
open(): void {
this.readyState = StubWebSocket.OPEN
this.onopen?.(new Event('open'))
}
receive(data: unknown): void {
const text = typeof data === 'string' ? data : JSON.stringify(data)
this.onmessage?.({ data: text } as MessageEvent)
}
triggerClose(): void {
this.readyState = StubWebSocket.CLOSED
this.onclose?.({} as CloseEvent)
}
// Real API
send(payload: string): void {
this.sent.push(payload)
}
close(): void {
this.readyState = StubWebSocket.CLOSED
this.onclose?.({} as CloseEvent)
}
}
const originalWebSocket = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket
beforeEach(() => {
StubWebSocket.instances = []
;(globalThis as unknown as { WebSocket: unknown }).WebSocket = StubWebSocket
})
afterEach(() => {
;(globalThis as unknown as { WebSocket: unknown }).WebSocket = originalWebSocket
vi.useRealTimers()
})
describe('parseEvent', () => {
it('normalizes backend envelope (data + RFC3339 timestamp)', () => {
const evt = __wsInternals.parseEvent(
JSON.stringify({
type: 'package_cached',
timestamp: '2026-04-28T12:00:00Z',
data: { name: 'lodash', version: '4.17.21', registry: 'npm' },
}),
)
expect(evt).not.toBeNull()
expect(evt!.type).toBe('package_cached')
expect(evt!.payload).toMatchObject({ name: 'lodash' })
expect(evt!.timestamp).toBe(Date.parse('2026-04-28T12:00:00Z'))
})
it('drops malformed JSON', () => {
expect(__wsInternals.parseEvent('not-json{{')).toBeNull()
})
it('drops unknown event types (e.g. control frames)', () => {
expect(
__wsInternals.parseEvent(JSON.stringify({ type: 'pong' })),
).toBeNull()
})
it('accepts numeric timestamp', () => {
const evt = __wsInternals.parseEvent(
JSON.stringify({ type: 'stats_update', timestamp: 1234567890, data: {} }),
)
expect(evt!.timestamp).toBe(1234567890)
})
})
describe('WSClient', () => {
it('connects and dispatches typed events to subscribers', () => {
const client = new WSClient('ws://test/ws')
const received: RealtimeEvent[] = []
client.on('package_cached', (e) => received.push(e))
client.connect()
const sock = StubWebSocket.instances[0]
expect(sock).toBeDefined()
sock.open()
sock.receive({
type: 'package_cached',
timestamp: '2026-04-28T00:00:00Z',
data: { name: 'foo' },
})
expect(received).toHaveLength(1)
expect(received[0].type).toBe('package_cached')
expect(received[0].payload.name).toBe('foo')
client.close()
})
it('forwards events to wildcard subscribers', () => {
const client = new WSClient('ws://test/ws')
const seen: string[] = []
client.on('*', (e) => seen.push(e.type))
client.connect()
const sock = StubWebSocket.instances[0]
sock.open()
sock.receive({ type: 'scan_complete', data: {} })
sock.receive({ type: 'package_deleted', data: {} })
expect(seen).toEqual(['scan_complete', 'package_deleted'])
client.close()
})
it('unsubscribe stops further dispatches', () => {
const client = new WSClient('ws://test/ws')
let count = 0
const off = client.on('package_cached', () => {
count += 1
})
client.connect()
const sock = StubWebSocket.instances[0]
sock.open()
sock.receive({ type: 'package_cached', data: {} })
expect(count).toBe(1)
off()
sock.receive({ type: 'package_cached', data: {} })
expect(count).toBe(1)
client.close()
})
it('auto-reconnects with backoff after unexpected close', () => {
vi.useFakeTimers()
const client = new WSClient('ws://test/ws')
client.connect()
const first = StubWebSocket.instances[0]
first.open()
first.triggerClose()
// First reconnect happens after RECONNECT_BASE_MS (1s).
vi.advanceTimersByTime(__wsInternals.RECONNECT_BASE_MS)
expect(StubWebSocket.instances.length).toBe(2)
// Second drop -> next backoff is 2s.
StubWebSocket.instances[1].open()
StubWebSocket.instances[1].triggerClose()
vi.advanceTimersByTime(__wsInternals.RECONNECT_BASE_MS * 2)
expect(StubWebSocket.instances.length).toBe(3)
client.close()
})
it('does not reconnect after explicit close', () => {
vi.useFakeTimers()
const client = new WSClient('ws://test/ws')
client.connect()
const sock = StubWebSocket.instances[0]
sock.open()
client.close()
vi.advanceTimersByTime(60_000)
expect(StubWebSocket.instances.length).toBe(1)
})
it('emits heartbeat ping every 25s', () => {
vi.useFakeTimers()
const client = new WSClient('ws://test/ws')
client.connect()
const sock = StubWebSocket.instances[0]
sock.open()
expect(sock.sent).toHaveLength(0)
vi.advanceTimersByTime(__wsInternals.HEARTBEAT_INTERVAL_MS)
expect(sock.sent).toHaveLength(1)
expect(JSON.parse(sock.sent[0])).toEqual({ action: 'ping' })
vi.advanceTimersByTime(__wsInternals.HEARTBEAT_INTERVAL_MS)
expect(sock.sent).toHaveLength(2)
client.close()
})
it('queues outbound messages while disconnected and flushes on open', () => {
const client = new WSClient('ws://test/ws')
client.send({ action: 'subscribe', data: ['package_cached'] })
client.connect()
const sock = StubWebSocket.instances[0]
expect(sock.sent).toHaveLength(0)
sock.open()
expect(sock.sent).toHaveLength(1)
expect(JSON.parse(sock.sent[0])).toMatchObject({ action: 'subscribe' })
client.close()
})
it('drops malformed inbound JSON without throwing', () => {
const client = new WSClient('ws://test/ws')
let count = 0
client.on('*', () => {
count += 1
})
client.connect()
const sock = StubWebSocket.instances[0]
sock.open()
sock.receive('not-json{{{')
sock.receive({ type: 'unknown_type', data: {} })
expect(count).toBe(0)
client.close()
})
})
+301
View File
@@ -0,0 +1,301 @@
/**
* Lightweight WebSocket client with auto-reconnect, heartbeat, and pub/sub.
*
* Backend (pkg/websocket/server.go) emits envelopes shaped like:
* { "type": "package_cached", "timestamp": "2026-04-28T12:34:56Z", "data": {...} }
*
* We normalize this into RealtimeEvent { type, payload, timestamp(number ms) }.
*/
export type EventType =
| 'package_deleted'
| 'package_cached'
| 'package_downloaded'
| 'scan_complete'
| 'stats_update'
export interface RealtimeEvent {
type: EventType
payload: Record<string, unknown>
timestamp: number
}
type WildcardListener = '*'
type ListenerKey = EventType | WildcardListener
type Listener = (e: RealtimeEvent) => void
const KNOWN_EVENT_TYPES: ReadonlySet<string> = new Set<EventType>([
'package_deleted',
'package_cached',
'package_downloaded',
'scan_complete',
'stats_update',
])
const RECONNECT_BASE_MS = 1_000
const RECONNECT_MAX_MS = 30_000
const HEARTBEAT_INTERVAL_MS = 25_000
const RECONNECT_QUEUE_CAP = 50
interface OutboundMessage {
action: string
data?: unknown
}
function defaultUrl(): string {
if (typeof window === 'undefined' || !window.location) {
return 'ws://localhost/ws'
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}/ws`
}
function isKnownEventType(value: unknown): value is EventType {
return typeof value === 'string' && KNOWN_EVENT_TYPES.has(value)
}
function coerceTimestamp(raw: unknown): number {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return raw
}
if (typeof raw === 'string') {
const parsed = Date.parse(raw)
if (!Number.isNaN(parsed)) return parsed
}
return Date.now()
}
function parseEvent(text: string): RealtimeEvent | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') return null
const obj = parsed as Record<string, unknown>
// Server may also emit `{ type: "pong" }` style control frames; ignore those.
if (!isKnownEventType(obj.type)) return null
const payloadSource = obj.payload ?? obj.data
const payload =
payloadSource && typeof payloadSource === 'object'
? (payloadSource as Record<string, unknown>)
: {}
return {
type: obj.type,
payload,
timestamp: coerceTimestamp(obj.timestamp),
}
}
export class WSClient {
readonly url: string
private socket: WebSocket | null = null
private listeners: Map<ListenerKey, Set<Listener>> = new Map()
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
private explicitlyClosed = false
private outboundQueue: OutboundMessage[] = []
private _connected = false
constructor(url?: string) {
this.url = url ?? defaultUrl()
}
get connected(): boolean {
return this._connected
}
connect(): void {
this.explicitlyClosed = false
this.openSocket()
}
close(): void {
this.explicitlyClosed = true
this.clearReconnect()
this.stopHeartbeat()
this._connected = false
if (this.socket) {
try {
this.socket.close()
} catch {
/* swallow */
}
this.socket = null
}
}
on(type: ListenerKey, cb: Listener): () => void {
let bucket = this.listeners.get(type)
if (!bucket) {
bucket = new Set()
this.listeners.set(type, bucket)
}
bucket.add(cb)
return () => {
const current = this.listeners.get(type)
if (!current) return
current.delete(cb)
if (current.size === 0) this.listeners.delete(type)
}
}
/**
* Send a message. If not connected, queue (capped) and flush on open.
*/
send(msg: OutboundMessage): void {
if (this.socket && this._connected && this.socket.readyState === 1) {
try {
this.socket.send(JSON.stringify(msg))
return
} catch {
/* fall through to queue */
}
}
if (this.outboundQueue.length >= RECONNECT_QUEUE_CAP) {
this.outboundQueue.shift()
}
this.outboundQueue.push(msg)
}
private openSocket(): void {
const Ctor: typeof WebSocket | undefined =
typeof WebSocket !== 'undefined'
? WebSocket
: (globalThis as unknown as { WebSocket?: typeof WebSocket }).WebSocket
if (!Ctor) {
// No WebSocket available (e.g. SSR); silently bail.
return
}
let socket: WebSocket
try {
socket = new Ctor(this.url)
} catch {
this.scheduleReconnect()
return
}
this.socket = socket
socket.onopen = () => {
this._connected = true
this.reconnectAttempts = 0
this.startHeartbeat()
this.flushQueue()
}
socket.onmessage = (ev: MessageEvent) => {
const data = ev.data
if (typeof data !== 'string') return
const evt = parseEvent(data)
if (!evt) return
this.dispatch(evt)
}
socket.onerror = () => {
// onclose handles reconnect; nothing to do here.
}
socket.onclose = () => {
this._connected = false
this.stopHeartbeat()
this.socket = null
if (!this.explicitlyClosed) {
this.scheduleReconnect()
}
}
}
private dispatch(event: RealtimeEvent): void {
const exact = this.listeners.get(event.type)
if (exact) {
for (const cb of exact) {
try {
cb(event)
} catch (err) {
console.error('[ws] listener error', err)
}
}
}
const wild = this.listeners.get('*')
if (wild) {
for (const cb of wild) {
try {
cb(event)
} catch (err) {
console.error('[ws] listener error', err)
}
}
}
}
private flushQueue(): void {
if (!this.socket || !this._connected) return
const queued = this.outboundQueue.splice(0)
for (const msg of queued) {
try {
this.socket.send(JSON.stringify(msg))
} catch {
// requeue and bail
this.outboundQueue.unshift(msg)
return
}
}
}
private startHeartbeat(): void {
this.stopHeartbeat()
this.heartbeatTimer = setInterval(() => {
if (this.socket && this._connected) {
try {
this.socket.send(JSON.stringify({ action: 'ping' }))
} catch {
/* swallow; reconnect path will handle */
}
}
}, HEARTBEAT_INTERVAL_MS)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
private scheduleReconnect(): void {
if (this.explicitlyClosed) return
this.clearReconnect()
const delay = Math.min(
RECONNECT_MAX_MS,
RECONNECT_BASE_MS * 2 ** this.reconnectAttempts,
)
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.openSocket()
}, delay)
}
private clearReconnect(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}
// Test-only helpers — exported under a namespace so production code does not
// reach into reconnect math accidentally.
export const __wsInternals = {
RECONNECT_BASE_MS,
RECONNECT_MAX_MS,
HEARTBEAT_INTERVAL_MS,
parseEvent,
defaultUrl,
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useRealtimeStore, __resetRealtimeSingleton } from './realtime'
import type { RealtimeEvent } from '@/lib/ws'
function makeEvent(
type: RealtimeEvent['type'],
payload: Record<string, unknown> = {},
timestamp = Date.now(),
): RealtimeEvent {
return { type, payload, timestamp }
}
describe('useRealtimeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
__resetRealtimeSingleton()
})
it('appends events on ingest', () => {
const store = useRealtimeStore()
store.ingest(makeEvent('package_cached', { name: 'a' }))
store.ingest(makeEvent('package_deleted', { name: 'b' }))
expect(store.events).toHaveLength(2)
expect(store.events[0].type).toBe('package_cached')
})
it('caps events at 100 with FIFO eviction', () => {
const store = useRealtimeStore()
for (let i = 0; i < 150; i += 1) {
store.ingest(makeEvent('package_cached', { i }))
}
expect(store.events).toHaveLength(100)
// First event should be index 50 (0..49 dropped)
expect((store.events[0].payload as { i: number }).i).toBe(50)
expect((store.events[99].payload as { i: number }).i).toBe(149)
})
it('updates lastStats only on stats_update events', () => {
const store = useRealtimeStore()
expect(store.lastStats).toBeNull()
store.ingest(makeEvent('package_cached', { name: 'x' }))
expect(store.lastStats).toBeNull()
store.ingest(
makeEvent('stats_update', {
total_packages: 42,
total_size: 1024,
}),
)
expect(store.lastStats).toMatchObject({ total_packages: 42, total_size: 1024 })
})
it('exposes filtered computed slices', () => {
const store = useRealtimeStore()
store.ingest(makeEvent('package_cached'))
store.ingest(makeEvent('package_downloaded'))
store.ingest(makeEvent('scan_complete'))
store.ingest(makeEvent('stats_update'))
expect(store.recentCacheActivity).toHaveLength(2)
expect(store.recentScans).toHaveLength(1)
})
})
+118
View File
@@ -0,0 +1,118 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { WSClient, type EventType, type RealtimeEvent } from '@/lib/ws'
const EVENTS_CAP = 100
export interface RealtimeStats {
registry?: string
total_packages?: number
total_size?: number
max_cache_size?: number
total_downloads?: number
scanned_packages?: number
vulnerable_packages?: number
blocked_packages?: number
[key: string]: unknown
}
let singletonClient: WSClient | null = null
function getClient(url?: string): WSClient {
if (!singletonClient) {
singletonClient = new WSClient(url)
}
return singletonClient
}
// Test-only reset hook. Avoids leaking a singleton across tests.
export function __resetRealtimeSingleton(): void {
if (singletonClient) {
singletonClient.close()
}
singletonClient = null
}
export const useRealtimeStore = defineStore('realtime', () => {
const connected = ref(false)
const events = ref<RealtimeEvent[]>([])
const lastStats = ref<RealtimeStats | null>(null)
const unsubscribers = ref<Array<() => void>>([])
const recentCacheActivity = computed(() =>
events.value.filter(
(e) => e.type === 'package_cached' || e.type === 'package_downloaded',
),
)
const recentScans = computed(() =>
events.value.filter((e) => e.type === 'scan_complete'),
)
function appendEvent(event: RealtimeEvent): void {
events.value.push(event)
if (events.value.length > EVENTS_CAP) {
events.value.splice(0, events.value.length - EVENTS_CAP)
}
if (event.type === 'stats_update') {
lastStats.value = event.payload as RealtimeStats
}
}
function ingest(event: RealtimeEvent): void {
appendEvent(event)
}
function connect(url?: string): void {
// Bail in SSR / non-browser test environments where there is no
// WebSocket constructor. Keeps Dashboard.spec.ts (which mounts the
// component without a WS stub) from leaking timers.
const hasWS =
typeof globalThis !== 'undefined' &&
typeof (globalThis as { WebSocket?: unknown }).WebSocket !== 'undefined'
if (!hasWS) return
const client = getClient(url)
if (unsubscribers.value.length === 0) {
const types: EventType[] = [
'package_cached',
'package_deleted',
'package_downloaded',
'scan_complete',
'stats_update',
]
for (const t of types) {
unsubscribers.value.push(client.on(t, ingest))
}
// Poll connected status — WSClient does not yet emit connection events;
// a 1s tick is acceptable here and avoids tight coupling.
const tick = setInterval(() => {
connected.value = client.connected
}, 1_000)
unsubscribers.value.push(() => clearInterval(tick))
}
client.connect()
// Reflect the current state immediately so tests/UX don't wait a tick.
connected.value = client.connected
}
function disconnect(): void {
for (const off of unsubscribers.value) off()
unsubscribers.value = []
if (singletonClient) {
singletonClient.close()
}
connected.value = false
}
return {
connected,
events,
lastStats,
recentCacheActivity,
recentScans,
connect,
disconnect,
ingest,
}
})
+3 -3
View File
@@ -1,8 +1,8 @@
@import '@fortawesome/fontawesome-free/css/all.min.css'; @import '@fortawesome/fontawesome-free/css/all.min.css';
@tailwind base; @import 'tailwindcss';
@tailwind components; @config '../../tailwind.config.js';
@tailwind utilities; @custom-variant dark (&:is(.dark *));
@layer base { @layer base {
:root { :root {
+4 -4
View File
@@ -11,15 +11,15 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "preserve", "jsx": "preserve",
"types": ["vite/client"],
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"]
}, }
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }], "references": [{ "path": "./tsconfig.node.json" }]
} }
+2 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
// Get backend URL from environment or use default // Get backend URL from environment or use default
@@ -7,7 +8,7 @@ const BACKEND_URL = process.env.VITE_BACKEND_URL || 'http://localhost:8080'
const FRONTEND_PORT = parseInt(process.env.VITE_PORT || '5173') const FRONTEND_PORT = parseInt(process.env.VITE_PORT || '5173')
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
+91 -86
View File
@@ -1,136 +1,141 @@
module github.com/lukaszraczylo/gohoarder module github.com/lukaszraczylo/gohoarder
go 1.25.5 go 1.26.0
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2 v1.47.1
github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0
github.com/go-gormigrate/gormigrate/v2 v2.1.5 github.com/cloudsoda/go-smb2 v0.0.0-20260918041005-0c5d69b69701
github.com/go-sql-driver/mysql v1.9.3 github.com/go-gormigrate/gormigrate/v2 v2.1.7
github.com/goccy/go-json v0.10.6 github.com/go-sql-driver/mysql v1.10.1
github.com/gofiber/fiber/v2 v2.52.12 github.com/goccy/go-json v0.11.2
github.com/gofiber/fiber/v3 v3.5.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/hirochachacha/go-smb2 v1.1.0
github.com/lib/pq v1.12.3 github.com/lib/pq v1.12.3
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.24.1
github.com/rs/zerolog v1.35.0 github.com/rs/zerolog v1.35.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.1
github.com/testcontainers/testcontainers-go v0.40.0 github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0 github.com/testcontainers/testcontainers-go/modules/mysql v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.57.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.23.0
golang.org/x/time v0.15.0 golang.org/x/time v0.16.0
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.3
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.2
) )
require ( require (
dario.cat/mergo v1.0.2 // indirect dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Azure/go-ansiterm v0.0.0-20260917205352-e937bb47801a // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect github.com/aws/smithy-go v1.28.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.2.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/go-connections v0.8.1 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.11.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect github.com/geoffgarside/ber v1.2.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofiber/schema v1.8.8 // indirect
github.com/gofiber/utils/v2 v2.6.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect github.com/hashicorp/go-uuid v1.0.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/pgx/v5 v5.11.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.20.1 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.18.12 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.22 // indirect github.com/mattn/go-sqlite3 v1.14.52 // indirect
github.com/mattn/go-sqlite3 v1.14.41 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect github.com/moby/go-archive v0.3.3 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/moby/api v1.56.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/moby/client v0.6.1 // indirect
github.com/moby/sys/user v0.4.0 // indirect github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.1 // indirect
github.com/moby/sys/userns v0.2.1 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect github.com/molecule-man/go-brrr v1.1.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.22.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.12 // indirect github.com/shirou/gopsutil/v4 v4.26.9 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.10.2 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tinylib/msgp v1.6.5 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect github.com/valyala/fasthttp v1.74.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel v1.47.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect go.opentelemetry.io/otel/trace v1.47.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/net v0.59.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/sys v0.48.0 // indirect
google.golang.org/grpc v1.78.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect golang.org/x/tools v0.50.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect google.golang.org/protobuf v1.36.12 // indirect
) )
+241 -197
View File
@@ -4,137 +4,153 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20260917205352-e937bb47801a h1:4hxax9ktNjSDoFn1tZSeL6gXMTRMpO0FzjdKfT01jgY=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20260917205352-e937bb47801a/go.mod h1:3EWSSOZ50kb+arhww0qIaEXHToib/3FjBj9Jj5lcP5g=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk= github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= github.com/aws/smithy-go v1.28.2 h1:myhcykQcatTul2B/zITjDk203G7t0awUAs1hVry5Bvg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= github.com/aws/smithy-go v1.28.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/cloudsoda/go-smb2 v0.0.0-20260918041005-0c5d69b69701 h1:xiTow1n7oNJPzn9QH+U2sT65mO2F12zNNlY4ZzOew70=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudsoda/go-smb2 v0.0.0-20260918041005-0c5d69b69701/go.mod h1:1pQXB0vAlzRlqcY7LYKOOZMw0wKfJPFxTLsJRF2Gswo=
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc h1:0xCWmFKBmarCqqqLeM7jFBSw/Or81UEElFqO8MY+GDs=
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc/go.mod h1:uvR42Hb/t52HQd7x5/ZLzZEK8oihrFpgnodIJ1vte2E=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64= github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/go-gormigrate/gormigrate/v2 v2.1.5 h1:1OyorA5LtdQw12cyJDEHuTrEV3GiXiIhS4/QTTa/SM8= github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whiq1Qi4necaZdg=
github.com/go-gormigrate/gormigrate/v2 v2.1.5/go.mod h1:mj9ekk/7CPF3VjopaFvWKN2v7fN3D9d3eEOAXRhi/+M= github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8=
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w=
github.com/gofiber/schema v1.8.8 h1:md5t65+Z9LttOuj7FdMcQ63D2+6GniT2ganLXwvTJM4=
github.com/gofiber/schema v1.8.8/go.mod h1:Hzf0L/fTPP1IwOAwKipKflm9C2722aebnSKJCMOgb1I=
github.com/gofiber/utils/v2 v2.6.1 h1:x3zsS/oUQz0ia2Zx8XXtG/7naR5a+/95FRO8mwQ5QQ4=
github.com/gofiber/utils/v2 v2.6.1/go.mod h1:tc9KqdRAQZRD/u0mN2d1ZHROGheSZv+0FcC9cg5OEdw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= github.com/hashicorp/go-uuid v1.0.4 h1:ZrN80XjMzpRYk+2FxMDy2A2zz0d5QjJ7GMFSkZLj12A=
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= github.com/hashicorp/go-uuid v1.0.4/go.mod h1:x2Ds7vSkQ2n/yQj8Synnxmt0zt1l26uCAjxIhChisLU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -143,71 +159,76 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.18.12 h1:sT9zQpvTB3B4gzrX0tmZNTEaGyg8Zw55MFYRE32Mr9I=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.18.12/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk= github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.41 h1:8p7Pwz5NHkEbWSqc/ygU4CBGubhFFkpgP9KwcdkAHNA=
github.com/mattn/go-sqlite3 v1.14.41/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/moby/client v0.6.1 h1:gvfKmdcWw+9MzXYP0vAUKQtBTGu1L8475K2nsmL7O98=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/moby/client v0.6.1/go.mod h1:XHgTFqz9NCgS/VuoxXMmEDVmVgXQ4vFEc15wsCIwb24=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0=
github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y=
github.com/moby/sys/userns v0.2.1 h1:4OvdM7BcPkASbuouHsbW3aeMJSFlYDldBRnXVZhaRk8=
github.com/moby/sys/userns v0.2.1/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/molecule-man/go-brrr v1.1.1 h1:KYwusQhjtV3smALnhmafZf4OBrh7vP4p8O47DyUmmuY=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/molecule-man/go-brrr v1.1.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY= github.com/shamaton/msgpack/v3 v3.2.3 h1:0r0aZtrXDFOPpwWJTEOpFq+Ih/JmMLm0pqLN5mSilrw=
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU= github.com/shamaton/msgpack/v3 v3.2.3/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -220,105 +241,128 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE=
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0 h1:P9Txfy5Jothx2wFdcus0QoSmX/PKSIXZxrTbZPVJswA= github.com/testcontainers/testcontainers-go/modules/mysql v0.44.0 h1:oJPJPxNE6YQ0zlq6mZKh06JOlyimCky4ruQUimdDet4=
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0/go.mod h1:oZPHHqJqXG7FD8OB/yWH7gLnDvZUlFHAVJNrGftL+eg= github.com/testcontainers/testcontainers-go/modules/mysql v0.44.0/go.mod h1:MSOAU6ukCpehJVHQDN1k9JgOZXZuqHD+2pT20M3JkIg=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA= github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE= github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
@@ -0,0 +1,129 @@
{{- if and .Values.ingress.enabled (eq .Values.ingress.className "traefik") -}}
---
# Traefik IngressRoute for package registry paths
# This handles package downloads including scoped packages with encoded slashes
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ include "gohoarder.fullname" . }}-packages
labels:
{{- include "gohoarder.labels" . | nindent 4 }}
spec:
entryPoints:
- websecure
routes:
# Package registry routes with high priority
# PathPrefix matching to avoid Go's encoded slash rejection in regex
- match: Host(`{{ .Values.ingress.host | default (printf "%s.%s" "gohoarder" .Values.global.domain) }}`) && PathPrefix(`/npm/`)
kind: Rule
priority: 100
services:
- name: {{ include "gohoarder.fullname" . }}-frontend
port: {{ .Values.frontend.service.port }}
middlewares:
- name: {{ include "gohoarder.fullname" . }}-strip-encoded-slash
namespace: {{ .Release.Namespace }}
{{- if .Values.ingress.traefik }}
{{- if .Values.ingress.traefik.middlewares }}
{{- range .Values.ingress.traefik.middlewares }}
- name: {{ . }}
namespace: traefik
{{- end }}
{{- end }}
{{- end }}
- match: Host(`{{ .Values.ingress.host | default (printf "%s.%s" "gohoarder" .Values.global.domain) }}`) && PathPrefix(`/pypi/`)
kind: Rule
priority: 100
services:
- name: {{ include "gohoarder.fullname" . }}-frontend
port: {{ .Values.frontend.service.port }}
middlewares:
- name: {{ include "gohoarder.fullname" . }}-strip-encoded-slash
namespace: {{ .Release.Namespace }}
{{- if .Values.ingress.traefik }}
{{- if .Values.ingress.traefik.middlewares }}
{{- range .Values.ingress.traefik.middlewares }}
- name: {{ . }}
namespace: traefik
{{- end }}
{{- end }}
{{- end }}
- match: Host(`{{ .Values.ingress.host | default (printf "%s.%s" "gohoarder" .Values.global.domain) }}`) && PathPrefix(`/go/`)
kind: Rule
priority: 100
services:
- name: {{ include "gohoarder.fullname" . }}-frontend
port: {{ .Values.frontend.service.port }}
middlewares:
- name: {{ include "gohoarder.fullname" . }}-strip-encoded-slash
namespace: {{ .Release.Namespace }}
{{- if .Values.ingress.traefik }}
{{- if .Values.ingress.traefik.middlewares }}
{{- range .Values.ingress.traefik.middlewares }}
- name: {{ . }}
namespace: traefik
{{- end }}
{{- end }}
{{- end }}
# All other routes (frontend, API, etc.) with lower priority
- match: Host(`{{ .Values.ingress.host | default (printf "%s.%s" "gohoarder" .Values.global.domain) }}`)
kind: Rule
priority: 50
services:
- name: {{ include "gohoarder.fullname" . }}-frontend
port: {{ .Values.frontend.service.port }}
{{- if .Values.ingress.traefik }}
{{- if .Values.ingress.traefik.middlewares }}
middlewares:
{{- range .Values.ingress.traefik.middlewares }}
- name: {{ . }}
namespace: traefik
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.ingress.tls.enabled }}
tls:
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
---
# Middleware to handle encoded slashes in package paths
# Note: This middleware attempts to work around Traefik's encoded slash limitation
# but may not fully resolve the issue due to Go stdlib constraints
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: {{ include "gohoarder.fullname" . }}-strip-encoded-slash
labels:
{{- include "gohoarder.labels" . | nindent 4 }}
spec:
# Use headers to pass original path information
# The backend can reconstruct the original URL from these headers
headers:
customRequestHeaders:
X-Original-URI: "{{ .Request.URL.Path }}"
X-Forwarded-Path: "{{ .Request.URL.Path }}"
---
# ServersTransport configuration for backend communication
# This ensures the backend connection doesn't have additional restrictions
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
name: {{ include "gohoarder.fullname" . }}-transport
labels:
{{- include "gohoarder.labels" . | nindent 4 }}
spec:
insecureSkipVerify: false
{{- if .Values.ingress.traefik }}
{{- if .Values.ingress.traefik.transport }}
{{- with .Values.ingress.traefik.transport }}
serverName: {{ .serverName | default "" }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+10
View File
@@ -514,6 +514,16 @@ ingress:
enabled: false enabled: false
secretName: "gohoarder-tls" secretName: "gohoarder-tls"
# Traefik-specific configuration (when className: "traefik")
# Used by IngressRoute template for advanced routing
traefik:
# Middleware references (from traefik namespace)
# Example: ["default-chain", "rate-limit"]
middlewares: []
# ServersTransport configuration
transport:
serverName: ""
# Autoscaling configuration # Autoscaling configuration
autoscaling: autoscaling:
enabled: false enabled: false
+2
View File
@@ -1,3 +1,5 @@
// Package version exposes build-time version metadata (semver, commit, build
// time, Go toolchain version) for the binary, populated via -ldflags.
package version package version
import "runtime" import "runtime"
+73 -25
View File
@@ -1,3 +1,5 @@
// Package analytics tracks and analyzes package download events with
// in-memory aggregation and periodic background flushing.
package analytics package analytics
import ( import (
@@ -46,15 +48,22 @@ type PopularPackage struct {
Trend float64 // Growth rate Trend float64 // Growth rate
} }
// Engine tracks and analyzes package downloads // Engine tracks and analyzes package downloads.
//
// Lock ordering: never hold both downloadsMu and statsMu at the same time.
// Code paths must acquire only one of the two; if they need both views, they
// must take a snapshot under the first lock and release it before taking the
// second.
type Engine struct { type Engine struct {
stats map[string]*PackageStats stats map[string]*PackageStats
flushTicker *time.Ticker flushTicker *time.Ticker
stopChan chan struct{} stopChan chan struct{}
doneChan chan struct{}
downloads []PackageDownload downloads []PackageDownload
maxEvents int maxEvents int
downloadsMu sync.RWMutex downloadsMu sync.RWMutex
statsMu sync.RWMutex statsMu sync.RWMutex
closeOnce sync.Once
} }
// Config holds analytics engine configuration // Config holds analytics engine configuration
@@ -78,6 +87,7 @@ func NewEngine(cfg Config) *Engine {
maxEvents: cfg.MaxEvents, maxEvents: cfg.MaxEvents,
flushTicker: time.NewTicker(cfg.FlushInterval), flushTicker: time.NewTicker(cfg.FlushInterval),
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
doneChan: make(chan struct{}),
} }
// Load existing stats from metadata store // Load existing stats from metadata store
@@ -94,19 +104,26 @@ func NewEngine(cfg Config) *Engine {
return engine return engine
} }
// TrackDownload records a package download event // TrackDownload records a package download event.
//
// Locks are taken sequentially (never nested) to avoid the lock-ordering
// inversion between downloadsMu and statsMu that exists elsewhere in the
// engine: append to the buffer under downloadsMu, release it, then update
// stats under statsMu.
func (e *Engine) TrackDownload(download PackageDownload) { func (e *Engine) TrackDownload(download PackageDownload) {
// Append to buffer and decide whether buffer is full.
e.downloadsMu.Lock() e.downloadsMu.Lock()
defer e.downloadsMu.Unlock()
// Add to event buffer
e.downloads = append(e.downloads, download) e.downloads = append(e.downloads, download)
bufferFull := len(e.downloads) >= e.maxEvents
e.downloadsMu.Unlock()
// Update in-memory stats // Update in-memory stats outside downloadsMu to keep the two locks
// strictly disjoint.
e.updateStats(download) e.updateStats(download)
// Flush if buffer is full // Flush if buffer is full. The flush goroutine acquires downloadsMu
if len(e.downloads) >= e.maxEvents { // itself; we must not hold any lock when spawning it.
if bufferFull {
go e.flush() go e.flush()
} }
@@ -183,28 +200,47 @@ func (e *Engine) GetTopPackages(limit int) []PopularPackage {
return packages return packages
} }
// GetTrendingPackages returns packages with growing popularity // GetTrendingPackages returns packages with growing popularity.
//
// The implementation takes a snapshot of stats under statsMu, releases it,
// and only then queries the downloads buffer. This preserves the invariant
// that downloadsMu and statsMu are never held simultaneously.
func (e *Engine) GetTrendingPackages(limit int) []PopularPackage { func (e *Engine) GetTrendingPackages(limit int) []PopularPackage {
// Snapshot stats under statsMu only.
type statsSnapshot struct {
registry string
name string
downloads int64
}
e.statsMu.RLock() e.statsMu.RLock()
defer e.statsMu.RUnlock() snapshot := make([]statsSnapshot, 0, len(e.stats))
for _, stats := range e.stats {
snapshot = append(snapshot, statsSnapshot{
registry: stats.Registry,
name: stats.Name,
downloads: stats.TotalDownloads,
})
}
e.statsMu.RUnlock()
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour) sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour)
packages := make([]PopularPackage, 0) packages := make([]PopularPackage, 0, len(snapshot))
for _, stats := range e.stats { for _, s := range snapshot {
// Calculate recent downloads (last 7 days) // Calculate recent downloads (last 7 days). Acquires downloadsMu
recent := e.getRecentDownloads(stats.Registry, stats.Name, sevenDaysAgo) // only; statsMu is no longer held here.
recent := e.getRecentDownloads(s.registry, s.name, sevenDaysAgo)
// Calculate trend (simple growth rate) // Calculate trend (simple growth rate)
trend := 0.0 trend := 0.0
if stats.TotalDownloads > 0 { if s.downloads > 0 {
trend = float64(recent) / float64(stats.TotalDownloads) * 100 trend = float64(recent) / float64(s.downloads) * 100
} }
packages = append(packages, PopularPackage{ packages = append(packages, PopularPackage{
Registry: stats.Registry, Registry: s.registry,
Name: stats.Name, Name: s.name,
Downloads: stats.TotalDownloads, Downloads: s.downloads,
RecentDownloads: recent, RecentDownloads: recent,
Trend: trend, Trend: trend,
}) })
@@ -297,8 +333,10 @@ func (e *Engine) GetTotalStats() map[string]interface{} {
} }
} }
// flushLoop periodically flushes download events to metadata store // flushLoop periodically flushes download events to metadata store.
// Closes doneChan after the final flush so Close can wait for it.
func (e *Engine) flushLoop() { func (e *Engine) flushLoop() {
defer close(e.doneChan)
for { for {
select { select {
case <-e.flushTicker.C: case <-e.flushTicker.C:
@@ -336,12 +374,22 @@ func (e *Engine) loadStats() {
log.Debug().Msg("Loading analytics stats from metadata store") log.Debug().Msg("Loading analytics stats from metadata store")
} }
// Close stops the analytics engine // Close stops the analytics engine. Safe to call multiple times.
//
// Shutdown sequence:
// 1. signal stopChan (sync.Once-guarded so we never close twice)
// 2. stop the ticker
// 3. wait for flushLoop to drain and run its final flush via doneChan
//
// We do not call flush() directly here — flushLoop owns the final flush, so
// there is exactly one flush at shutdown.
func (e *Engine) Close() { func (e *Engine) Close() {
close(e.stopChan) e.closeOnce.Do(func() {
e.flushTicker.Stop() close(e.stopChan)
e.flush() // Final flush e.flushTicker.Stop()
log.Info().Msg("Analytics engine stopped") <-e.doneChan
log.Info().Msg("Analytics engine stopped")
})
} }
// GetRegistryStats returns per-registry statistics // GetRegistryStats returns per-registry statistics
+450 -89
View File
@@ -1,16 +1,23 @@
// Package app wires the GoHoarder HTTP application together: config,
// storage, metadata, scanners, and route handlers.
package app package app
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings"
"syscall" "syscall"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v2/middleware/adaptor" "github.com/gofiber/fiber/v3/middleware/adaptor"
"github.com/gofiber/fiber/v3/middleware/static"
"github.com/lukaszraczylo/gohoarder/pkg/analytics" "github.com/lukaszraczylo/gohoarder/pkg/analytics"
"github.com/lukaszraczylo/gohoarder/pkg/auth" "github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/lukaszraczylo/gohoarder/pkg/cache" "github.com/lukaszraczylo/gohoarder/pkg/cache"
@@ -29,6 +36,7 @@ import (
"github.com/lukaszraczylo/gohoarder/pkg/scanner" "github.com/lukaszraczylo/gohoarder/pkg/scanner"
"github.com/lukaszraczylo/gohoarder/pkg/storage" "github.com/lukaszraczylo/gohoarder/pkg/storage"
"github.com/lukaszraczylo/gohoarder/pkg/storage/filesystem" "github.com/lukaszraczylo/gohoarder/pkg/storage/filesystem"
nfsstore "github.com/lukaszraczylo/gohoarder/pkg/storage/nfs"
"github.com/lukaszraczylo/gohoarder/pkg/storage/s3" "github.com/lukaszraczylo/gohoarder/pkg/storage/s3"
"github.com/lukaszraczylo/gohoarder/pkg/storage/smb" "github.com/lukaszraczylo/gohoarder/pkg/storage/smb"
"github.com/lukaszraczylo/gohoarder/pkg/vcs" "github.com/lukaszraczylo/gohoarder/pkg/vcs"
@@ -39,6 +47,7 @@ import (
// App represents the main application // App represents the main application
type App struct { type App struct {
config *config.Config config *config.Config
cancel context.CancelFunc
app *fiber.App app *fiber.App
healthChecker *health.Checker healthChecker *health.Checker
cache *cache.Manager cache *cache.Manager
@@ -54,23 +63,24 @@ type App struct {
cdnMiddleware *cdn.Middleware cdnMiddleware *cdn.Middleware
} }
// New creates a new application instance // New creates a new application instance.
// The local receiver is named "a" to avoid shadowing the package name "app".
func New(cfg *config.Config) (*App, error) { func New(cfg *config.Config) (*App, error) {
app := &App{ a := &App{
config: cfg, config: cfg,
} }
// Initialize components // Initialize components
if err := app.initializeComponents(); err != nil { if err := a.initializeComponents(); err != nil {
return nil, err return nil, err
} }
// Setup HTTP server and routes // Setup HTTP server and routes
if err := app.setupServer(); err != nil { if err := a.setupServer(); err != nil {
return nil, err return nil, err
} }
return app, nil return a, nil
} }
// initializeComponents initializes all application components // initializeComponents initializes all application components
@@ -105,6 +115,18 @@ func (a *App) initializeComponents() error {
MaxSizeBytes: a.config.Cache.MaxSizeBytes, MaxSizeBytes: a.config.Cache.MaxSizeBytes,
PoolSize: 5, // Default connection pool size PoolSize: 5, // Default connection pool size
}) })
case "nfs":
// SyncWrites defaults to true (durable). Pointer-bool lets operators
// opt out via explicit `sync_writes: false` in config.
syncWrites := true
if a.config.Storage.NFS.SyncWrites != nil {
syncWrites = *a.config.Storage.NFS.SyncWrites
}
a.storage, err = nfsstore.New(nfsstore.Config{
Path: a.config.Storage.Path,
MaxSize: a.config.Cache.MaxSizeBytes,
SyncWrites: syncWrites,
}, log.Logger)
default: default:
log.Warn(). log.Warn().
Str("backend", a.config.Storage.Backend). Str("backend", a.config.Storage.Backend).
@@ -200,36 +222,88 @@ func (a *App) initializeComponents() error {
FlushInterval: 5 * time.Minute, FlushInterval: 5 * time.Minute,
}) })
// Initialize cache manager with scanner and analytics // Initialize cache manager with scanner and analytics. MaxPackageSize is
// taken from Security.Scanners.Static (the static analyser already exposes
// this knob and it doubles as a cache hard cap).
log.Info().Msg("Initializing cache manager") log.Info().Msg("Initializing cache manager")
cleanupInterval := a.config.Cache.CleanupInterval
if cleanupInterval == 0 {
cleanupInterval = 5 * time.Minute
}
a.cache, err = cache.New(a.storage, a.metadata, a.scanManager, a.analyticsEngine, cache.Config{ a.cache, err = cache.New(a.storage, a.metadata, a.scanManager, a.analyticsEngine, cache.Config{
DefaultTTL: a.config.Cache.DefaultTTL, DefaultTTL: a.config.Cache.DefaultTTL,
CleanupInterval: 5 * time.Minute, CleanupInterval: cleanupInterval,
MaxPackageSize: a.config.Security.Scanners.Static.MaxPackageSize,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize cache: %w", err) return fmt.Errorf("failed to initialize cache: %w", err)
} }
// Initialize network client // Initialize network client. Pull values from cfg.Network where set;
// fall back to existing literals so unset fields preserve current
// behaviour. cfg.Network.* uses different semantic names (per-IP rate,
// retry struct, etc.) so we map explicitly rather than blindly assign.
log.Info().Msg("Initializing network client") log.Info().Msg("Initializing network client")
netTimeout := a.config.Network.ReadTimeout
if netTimeout == 0 {
netTimeout = 5 * time.Minute
}
maxRetries := a.config.Network.Retry.MaxAttempts
if maxRetries == 0 {
maxRetries = 3
}
retryDelay := a.config.Network.Retry.InitialBackoff
if retryDelay == 0 {
retryDelay = 1 * time.Second
}
rateLimit := float64(a.config.Network.RateLimit.PerIP)
if rateLimit == 0 {
rateLimit = 100
}
rateBurst := a.config.Network.RateLimit.BurstSize
if rateBurst == 0 {
rateBurst = 10
}
cbThreshold := a.config.Network.CircuitBreaker.Threshold
if cbThreshold == 0 {
cbThreshold = 5
}
cbTimeout := a.config.Network.CircuitBreaker.Timeout
if cbTimeout == 0 {
cbTimeout = 30 * time.Second
}
a.networkClient = network.NewClient(network.Config{ a.networkClient = network.NewClient(network.Config{
Timeout: 5 * time.Minute, Timeout: netTimeout,
MaxRetries: 3, MaxRetries: maxRetries,
RetryDelay: 1 * time.Second, RetryDelay: retryDelay,
RateLimit: 100, RateLimit: rateLimit,
RateBurst: 10, RateBurst: rateBurst,
CircuitBreaker: network.CircuitBreakerConfig{ CircuitBreaker: network.CircuitBreakerConfig{
Enabled: true, Enabled: true,
FailureThreshold: 5, FailureThreshold: cbThreshold,
SuccessThreshold: 2, SuccessThreshold: 2,
Timeout: 30 * time.Second, Timeout: cbTimeout,
}, },
UserAgent: "GoHoarder/1.0", UserAgent: "GoHoarder/1.0",
}) })
// Initialize authentication manager // Initialize authentication manager. NewWithStore persists keys via the
// metadata layer; Load hydrates the in-memory cache from prior state.
// Bootstrap creates an admin from env on first boot when no admin exists.
log.Info().Msg("Initializing authentication manager") log.Info().Msg("Initializing authentication manager")
a.authManager = auth.New() authCfg := auth.Config{BcryptCost: a.config.Auth.BcryptCost}
a.authManager = auth.NewWithStore(authCfg, a.metadata)
loadCtx, loadCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := a.authManager.Load(loadCtx); err != nil {
// Empty key set is recoverable: log and continue rather than fail boot.
log.Warn().Err(err).Msg("Failed to load API keys from metadata store; starting with empty key set")
}
loadCancel()
bootCtx, bootCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := auth.BootstrapAdminFromEnv(bootCtx, a.metadata, authCfg); err != nil {
log.Warn().Err(err).Msg("Failed to bootstrap admin API key from environment")
}
bootCancel()
// Initialize rescan worker if enabled // Initialize rescan worker if enabled
if a.config.Security.Enabled && a.config.Security.RescanInterval > 0 { if a.config.Security.Enabled && a.config.Security.RescanInterval > 0 {
@@ -242,17 +316,23 @@ func (a *App) initializeComponents() error {
a.wsServer = websocket.NewServer(websocket.Config{ a.wsServer = websocket.NewServer(websocket.Config{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(_ *http.Request) bool { CheckOrigin: buildCheckOrigin(a.config.Server.AllowedOrigins),
return true // Allow all origins in development
},
}) })
// Initialize pre-warming worker // Wire WebSocket server as the broadcaster for cache + scanner so
// lifecycle events (cached/downloaded/scan_*) reach connected clients.
// websocket.Server satisfies events.Broadcaster via BroadcastEvent.
a.cache.SetBroadcaster(a.wsServer)
a.scanManager.SetBroadcaster(a.wsServer)
// Initialize pre-warming worker. Operator-controlled via cfg.Prewarming;
// defaults are baked into config.Default() so unset fields stay sane.
log.Info().Msg("Initializing pre-warming worker") log.Info().Msg("Initializing pre-warming worker")
a.prewarmWorker = prewarming.NewWorker(prewarming.Config{ a.prewarmWorker = prewarming.NewWorker(prewarming.Config{
Enabled: false, // Disabled by default Enabled: a.config.Prewarming.Enabled,
Interval: 1 * time.Hour, Interval: a.config.Prewarming.Interval,
MaxConcurrent: 5, MaxConcurrent: a.config.Prewarming.MaxConcurrent,
TopPackages: a.config.Prewarming.TopPackages,
CacheManager: a.cache, CacheManager: a.cache,
Analytics: a.analyticsEngine, Analytics: a.analyticsEngine,
NetworkClient: a.networkClient, NetworkClient: a.networkClient,
@@ -312,29 +392,75 @@ func (a *App) setupServer() error {
AppName: "GoHoarder v1.0", AppName: "GoHoarder v1.0",
}) })
// Health and metrics endpoints (adapted from net/http) // Auth middleware factories. Built once, reused on every gated route.
// When auth is disabled the variables stay nil and we skip wiring; routes
// are mounted bare to preserve backward-compatible public access.
authEnabled := a.config.Auth.Enabled
var (
mwAuth fiber.Handler
mwAdmin fiber.Handler
mwOptional fiber.Handler
)
if authEnabled {
mwAuth = RequireAuth(a.authManager)
mwAdmin = RequireRole(a.authManager, string(auth.RoleAdmin))
mwOptional = OptionalAuth(a.authManager)
_ = mwOptional // reserved for future endpoints with anonymous fallback
}
// Health endpoints — ALWAYS public (k8s liveness/readiness probes).
a.app.Get("/health", adaptor.HTTPHandlerFunc(a.healthChecker.HealthHandler())) a.app.Get("/health", adaptor.HTTPHandlerFunc(a.healthChecker.HealthHandler()))
a.app.Get("/health/ready", adaptor.HTTPHandlerFunc(a.healthChecker.ReadyHandler())) a.app.Get("/health/ready", adaptor.HTTPHandlerFunc(a.healthChecker.ReadyHandler()))
a.app.Get("/metrics", adaptor.HTTPHandler(metrics.Handler()))
// WebSocket endpoint (adapted from net/http) // Metrics endpoint — public by default, optionally auth-gated.
a.app.Get("/ws", adaptor.HTTPHandlerFunc(a.wsServer.HandleWebSocket)) if authEnabled && a.config.Metrics.RequireAuth {
a.app.Get("/metrics", mwAuth, adaptor.HTTPHandler(metrics.Handler()))
} else {
a.app.Get("/metrics", adaptor.HTTPHandler(metrics.Handler()))
}
// API endpoints // WebSocket endpoint — gated when auth enabled. Clients must send the
a.app.Get("/api/config", a.handleConfig) // API key via Authorization or X-API-Key on the upgrade request.
a.app.All("/api/packages/*", a.handlePackages) // Handles packages and vulnerabilities if authEnabled {
a.app.Get("/api/stats", a.handleStats) a.app.Get("/ws", mwAuth, adaptor.HTTPHandlerFunc(a.wsServer.HandleWebSocket))
a.app.Get("/api/stats/timeseries", a.handleTimeSeriesStats) } else {
a.app.Get("/api/info", a.handleInfo) a.app.Get("/ws", adaptor.HTTPHandlerFunc(a.wsServer.HandleWebSocket))
}
// Analytics endpoints // API endpoints. Read endpoints require any valid key; package DELETE
a.app.Get("/api/analytics/top", a.handleAnalyticsTopPackages) // requires admin (split route from the All() so role-check runs only on
a.app.Get("/api/analytics/trending", a.handleAnalyticsTrendingPackages) // destructive verbs). The combined handler still dispatches by method.
a.app.Get("/api/analytics/trends", a.handleAnalyticsTrends) if authEnabled {
a.app.Get("/api/analytics/total", a.handleAnalyticsTotalStats) a.app.Get("/api/config", mwAuth, a.handleConfig)
a.app.Get("/api/analytics/registry/:registry", a.handleAnalyticsRegistryStats) a.app.Get("/api/packages/*", mwAuth, a.handlePackages)
a.app.Get("/api/analytics/package/:registry/:name", a.handleAnalyticsPackageStats) a.app.Delete("/api/packages/*", mwAdmin, a.handlePackages)
a.app.Get("/api/analytics/search", a.handleAnalyticsSearch) a.app.All("/api/packages/*", mwAuth, a.handlePackages) // OPTIONS, preflight, vulnerabilities
a.app.Get("/api/stats", mwAuth, a.handleStats)
a.app.Get("/api/stats/timeseries", mwAuth, a.handleTimeSeriesStats)
a.app.Get("/api/info", mwAuth, a.handleInfo)
a.app.Get("/api/analytics/top", mwAuth, a.handleAnalyticsTopPackages)
a.app.Get("/api/analytics/trending", mwAuth, a.handleAnalyticsTrendingPackages)
a.app.Get("/api/analytics/trends", mwAuth, a.handleAnalyticsTrends)
a.app.Get("/api/analytics/total", mwAuth, a.handleAnalyticsTotalStats)
a.app.Get("/api/analytics/registry/:registry", mwAuth, a.handleAnalyticsRegistryStats)
a.app.Get("/api/analytics/package/:registry/:name", mwAuth, a.handleAnalyticsPackageStats)
a.app.Get("/api/analytics/search", mwAuth, a.handleAnalyticsSearch)
} else {
a.app.Get("/api/config", a.handleConfig)
a.app.All("/api/packages/*", a.handlePackages)
a.app.Get("/api/stats", a.handleStats)
a.app.Get("/api/stats/timeseries", a.handleTimeSeriesStats)
a.app.Get("/api/info", a.handleInfo)
a.app.Get("/api/analytics/top", a.handleAnalyticsTopPackages)
a.app.Get("/api/analytics/trending", a.handleAnalyticsTrendingPackages)
a.app.Get("/api/analytics/trends", a.handleAnalyticsTrends)
a.app.Get("/api/analytics/total", a.handleAnalyticsTotalStats)
a.app.Get("/api/analytics/registry/:registry", a.handleAnalyticsRegistryStats)
a.app.Get("/api/analytics/package/:registry/:name", a.handleAnalyticsPackageStats)
a.app.Get("/api/analytics/search", a.handleAnalyticsSearch)
}
// Admin endpoints (bypass management) // Admin endpoints (bypass management)
a.app.All("/api/admin/bypasses/:id?", a.requireAdmin, a.handleAdminBypasses) a.app.All("/api/admin/bypasses/:id?", a.requireAdmin, a.handleAdminBypasses)
@@ -368,37 +494,71 @@ func (a *App) setupServer() error {
} }
} }
// Go proxy with CDN caching // Per-registry proxies. Mounted only when the corresponding handler is
goProxyHandler := goproxy.New(a.cache, a.networkClient, goproxy.Config{ // enabled in config. Each Upstream falls back to the canonical URL if
Upstream: "https://proxy.golang.org", // unset so partially-configured deployments keep working.
SumDBURL: "https://sum.golang.org", if a.config.Handlers.Go.Enabled {
CredStore: credStore, goUpstream := a.config.Handlers.Go.UpstreamProxy
}) if goUpstream == "" {
goProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/go", goProxyHandler)) goUpstream = "https://proxy.golang.org"
a.app.All("/go/*", adaptor.HTTPHandler(goProxyWithCDN)) }
sumDB := a.config.Handlers.Go.ChecksumDB
if sumDB == "" {
sumDB = "https://sum.golang.org"
}
goProxyHandler := goproxy.New(a.cache, a.networkClient, goproxy.Config{
Upstream: goUpstream,
SumDBURL: sumDB,
CredStore: credStore,
})
goProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/go", goProxyHandler))
if authEnabled {
a.app.All("/go/*", mwAuth, adaptor.HTTPHandler(goProxyWithCDN))
} else {
a.app.All("/go/*", adaptor.HTTPHandler(goProxyWithCDN))
}
}
// NPM proxy with CDN caching if a.config.Handlers.NPM.Enabled {
npmProxyHandler := npm.New(a.cache, a.networkClient, npm.Config{ npmUpstream := a.config.Handlers.NPM.UpstreamRegistry
Upstream: "https://registry.npmjs.org", if npmUpstream == "" {
}) npmUpstream = "https://registry.npmjs.org"
npmProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/npm", npmProxyHandler)) }
a.app.All("/npm/*", adaptor.HTTPHandler(npmProxyWithCDN)) npmProxyHandler := npm.New(a.cache, a.networkClient, npm.Config{
Upstream: npmUpstream,
})
npmProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/npm", npmProxyHandler))
if authEnabled {
a.app.All("/npm/*", mwAuth, adaptor.HTTPHandler(npmProxyWithCDN))
} else {
a.app.All("/npm/*", adaptor.HTTPHandler(npmProxyWithCDN))
}
}
// PyPI proxy with CDN caching if a.config.Handlers.PyPI.Enabled {
pypiProxyHandler := pypi.New(a.cache, a.networkClient, pypi.Config{ pypiUpstream := a.config.Handlers.PyPI.SimpleAPIURL
Upstream: "https://pypi.org/simple", if pypiUpstream == "" {
}) pypiUpstream = "https://pypi.org/simple"
pypiProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/pypi", pypiProxyHandler)) }
a.app.All("/pypi/*", adaptor.HTTPHandler(pypiProxyWithCDN)) pypiProxyHandler := pypi.New(a.cache, a.networkClient, pypi.Config{
Upstream: pypiUpstream,
})
pypiProxyWithCDN := a.cdnMiddleware.Handler(http.StripPrefix("/pypi", pypiProxyHandler))
if authEnabled {
a.app.All("/pypi/*", mwAuth, adaptor.HTTPHandler(pypiProxyWithCDN))
} else {
a.app.All("/pypi/*", adaptor.HTTPHandler(pypiProxyWithCDN))
}
}
// Serve frontend static files // Serve frontend static files
frontendDir := "frontend/dist" frontendDir := "frontend/dist"
if _, err := os.Stat(frontendDir); err == nil { if _, err := os.Stat(frontendDir); err == nil {
log.Info().Str("dir", frontendDir).Msg("Serving frontend static files") log.Info().Str("dir", frontendDir).Msg("Serving frontend static files")
a.app.Static("/", frontendDir) a.app.Use("/", static.New(frontendDir))
} else { } else {
log.Warn().Msg("Frontend dist directory not found, frontend won't be served") log.Warn().Msg("Frontend dist directory not found, frontend won't be served")
a.app.Get("/", func(c *fiber.Ctx) error { a.app.Get("/", func(c fiber.Ctx) error {
return c.Type("html").SendString(` return c.Type("html").SendString(`
<html> <html>
<head><title>GoHoarder</title></head> <head><title>GoHoarder</title></head>
@@ -424,28 +584,35 @@ func (a *App) setupServer() error {
return nil return nil
} }
// Run starts the application
func (a *App) Run() error { func (a *App) Run() error {
ctx := context.Background() // Cancellable worker context: aggregation + stats broadcaster goroutines
// watch ctx.Done() and exit, so Shutdown can stop them before closing
// Start WebSocket server // the storage/metadata backends they touch.
a.wsServer.Start(ctx) ctx, cancel := context.WithCancel(context.Background())
a.cancel = cancel
// Start pre-warming worker defer cancel()
a.prewarmWorker.Start(ctx)
// Start rescan worker if enabled
if a.rescanWorker != nil {
go a.rescanWorker.Start(ctx)
}
// Start download data aggregation worker (runs every hour) // Start download data aggregation worker (runs every hour)
go a.startAggregationWorker(ctx) go a.startAggregationWorker(ctx)
// Start Fiber server in goroutine // Start periodic stats broadcaster (every 30s) so connected WS clients
// see live counter updates without polling.
go a.startStatsBroadcaster(ctx)
// Start Fiber server in goroutine. Branch on TLS to pick Listen vs ListenTLS.
errChan := make(chan error, 1) errChan := make(chan error, 1)
go func() { go func() {
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port) addr := net.JoinHostPort(a.config.Server.Host, strconv.Itoa(a.config.Server.Port))
if a.config.Server.TLS.Enabled {
log.Info().
Str("addr", addr).
Str("cert_file", a.config.Server.TLS.CertFile).
Msg("Starting Fiber server with TLS")
if err := a.app.Listen(addr, fiber.ListenConfig{CertFile: a.config.Server.TLS.CertFile, CertKeyFile: a.config.Server.TLS.KeyFile}); err != nil {
errChan <- err
}
return
}
log.Info(). log.Info().
Str("addr", addr). Str("addr", addr).
Msg("Starting Fiber server") Msg("Starting Fiber server")
@@ -460,6 +627,13 @@ func (a *App) Run() error {
select { select {
case err := <-errChan: case err := <-errChan:
// Server failed to start or stopped unexpectedly. Cancel the worker
// contexts and run the same teardown Shutdown performs so background
// goroutines don't keep running against closed storage/metadata.
shutdownErr := a.Shutdown()
if shutdownErr != nil {
log.Error().Err(shutdownErr).Msg("Error during shutdown after server error")
}
return fmt.Errorf("server error: %w", err) return fmt.Errorf("server error: %w", err)
case sig := <-sigChan: case sig := <-sigChan:
log.Info(). log.Info().
@@ -480,6 +654,17 @@ func (a *App) Shutdown() error {
log.Error().Err(err).Msg("Error shutting down Fiber server") log.Error().Err(err).Msg("Error shutting down Fiber server")
} }
// Cancel worker contexts first so the aggregation and stats broadcaster
// goroutines stop before the storage/metadata backends they touch close.
if a.cancel != nil {
a.cancel()
}
// Drain async auth writes (LastUsedAt updates) before closing metadata.
if a.authManager != nil {
a.authManager.Close()
}
// Stop pre-warming worker // Stop pre-warming worker
a.prewarmWorker.Stop() a.prewarmWorker.Stop()
@@ -491,20 +676,61 @@ func (a *App) Shutdown() error {
// Close analytics engine // Close analytics engine
a.analyticsEngine.Close() // #nosec G104 -- Cleanup, error not critical a.analyticsEngine.Close() // #nosec G104 -- Cleanup, error not critical
// Close storage // Close cache manager. This stops the cache cleanup worker and closes the
if err := a.storage.Close(); err != nil { // storage + metadata backends (a.cache is the sole owner of both, sharing
log.Error().Err(err).Msg("Error closing storage") // the same pointers with the App). Calling Close on storage/metadata here
} // instead of via a.cache.Close() would double-close and leave the cleanup
// worker running against closed backends.
// Close metadata store if a.cache != nil {
if err := a.metadata.Close(); err != nil { if err := a.cache.Close(); err != nil {
log.Error().Err(err).Msg("Error closing metadata store") log.Error().Err(err).Msg("Error closing cache")
}
} }
log.Info().Msg("Shutdown complete") log.Info().Msg("Shutdown complete")
return nil return nil
} }
// startStatsBroadcaster periodically pulls aggregate cache stats and pushes
// them onto the WebSocket broadcast channel so connected dashboards see
// live counters without polling. Lightweight: a single GetStats call every
// 30s, drop-on-overflow at the WS layer.
func (a *App) startStatsBroadcaster(ctx context.Context) {
if a.wsServer == nil {
return
}
const interval = 30 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
emit := func() {
statsCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
stats, err := a.cache.GetStats(statsCtx, "")
if err != nil {
log.Debug().Err(err).Msg("stats broadcaster: GetStats failed")
return
}
a.wsServer.BroadcastEvent("stats_update", map[string]interface{}{
"stats": stats,
"timestamp": time.Now().UTC().Format(time.RFC3339),
})
}
// Emit once immediately so newly-connected clients see fresh data.
emit()
for {
select {
case <-ctx.Done():
log.Info().Msg("Stats broadcaster stopped")
return
case <-ticker.C:
emit()
}
}
}
// startAggregationWorker runs download data aggregation periodically // startAggregationWorker runs download data aggregation periodically
func (a *App) startAggregationWorker(ctx context.Context) { func (a *App) startAggregationWorker(ctx context.Context) {
log.Info().Msg("Starting download data aggregation worker (runs every hour)") log.Info().Msg("Starting download data aggregation worker (runs every hour)")
@@ -546,3 +772,138 @@ func getOrDefaultStr(value, defaultValue string) string {
} }
return value return value
} }
// normalizeOriginHost lowercases a host and strips the scheme's default port
// (443 for https, 80 for http) so "https://app.example.com:443" and
// "https://app.example.com" are treated as the same origin. Non-default ports
// are preserved. Returns ("", false) for an empty/invalid host.
func normalizeOriginHost(scheme, host string) (string, bool) {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return "", false
}
// Split off a port, if present. url.Parse stores the full host; use
// net.SplitHostPort so IPv6 literals like [::1]:8080 are handled.
h := host
var port string
if hh, p, err := net.SplitHostPort(host); err == nil {
h = hh
port = p
}
defaultPort := ""
switch scheme {
case "https":
defaultPort = "443"
case "http":
defaultPort = "80"
}
if port != "" && port == defaultPort {
return h, true
}
// Rebuild the host with the port (when present) for exact comparisons.
if port != "" {
// Reuse the original port form; net.JoinHostPort preserves IPv6 brackets.
return net.JoinHostPort(h, port), true
}
return h, true
}
// buildCheckOrigin returns a websocket Origin validator.
//
// Behaviour:
// - allowed entries may be exact origins ("https://app.example.com")
// or wildcard host patterns ("https://*.example.com").
// - when allowed is empty, only same-origin upgrades are permitted: the
// Origin host must match the Host header of the incoming request.
// - requests with no Origin header are accepted (non-browser clients).
//
// Scheme-default ports (443/80) are normalized out of both the allowlist and
// the incoming Origin, so "https://app.example.com" and
// "https://app.example.com:443" are treated identically. Wildcard matching is
// performed on the hostname only.
//
// We deliberately do NOT default to allow-all to avoid Cross-Site WebSocket
// Hijacking (CSWSH).
func buildCheckOrigin(allowed []string) func(*http.Request) bool {
// Pre-parse allowlist once.
type originRule struct {
scheme string
host string // exact normalized host:port (default port stripped), or "" if hostGlob is set
hostGlob string // suffix match including leading "."
}
rules := make([]originRule, 0, len(allowed))
for _, raw := range allowed {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
log.Warn().Str("entry", raw).Msg("Ignoring invalid AllowedOrigins entry")
continue
}
scheme := strings.ToLower(u.Scheme)
normHost, ok := normalizeOriginHost(scheme, u.Host)
if !ok {
log.Warn().Str("entry", raw).Msg("Ignoring invalid AllowedOrigins entry")
continue
}
r := originRule{scheme: scheme}
if strings.HasPrefix(normHost, "*.") {
// "*.example.com" → match any subdomain plus the apex.
r.hostGlob = normHost[1:] // ".example.com"
} else {
r.host = normHost
}
rules = append(rules, r)
}
return func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// Non-browser clients (e.g., CLI, server-to-server) omit Origin.
return true
}
ou, err := url.Parse(origin)
if err != nil || ou.Scheme == "" || ou.Host == "" {
log.Warn().Str("origin", origin).Msg("Rejecting WebSocket upgrade: malformed Origin header")
return false
}
originScheme := strings.ToLower(ou.Scheme)
originHost, ok := normalizeOriginHost(originScheme, ou.Host)
if !ok {
log.Warn().Str("origin", origin).Msg("Rejecting WebSocket upgrade: malformed Origin header")
return false
}
// Empty allowlist → same-origin only.
if len(rules) == 0 {
reqHost, ok := normalizeOriginHost(originScheme, r.Host)
if !ok {
return false
}
return originHost == reqHost
}
for _, rule := range rules {
if rule.scheme != originScheme {
continue
}
if rule.host != "" && rule.host == originHost {
return true
}
if rule.hostGlob != "" {
// Match apex (".example.com" → "example.com") and any subdomain.
apex := strings.TrimPrefix(rule.hostGlob, ".")
if originHost == apex || strings.HasSuffix(originHost, rule.hostGlob) {
return true
}
}
}
log.Warn().
Str("origin", origin).
Str("host", r.Host).
Msg("Rejecting WebSocket upgrade: Origin not in allowlist")
return false
}
}
+9 -9
View File
@@ -4,7 +4,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/internal/version" "github.com/lukaszraczylo/gohoarder/internal/version"
"github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/websocket" "github.com/lukaszraczylo/gohoarder/pkg/websocket"
@@ -12,7 +12,7 @@ import (
) )
// handlePackages handles /api/packages endpoint // handlePackages handles /api/packages endpoint
func (a *App) handlePackages(c *fiber.Ctx) error { func (a *App) handlePackages(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS") c.Set("Access-Control-Allow-Methods", "GET, DELETE, OPTIONS")
@@ -38,7 +38,7 @@ func (a *App) handlePackages(c *fiber.Ctx) error {
} }
// handleListPackages returns list of cached packages // handleListPackages returns list of cached packages
func (a *App) handleListPackages(c *fiber.Ctx) error { func (a *App) handleListPackages(c fiber.Ctx) error {
ctx := c.Context() ctx := c.Context()
// Get packages from metadata store // Get packages from metadata store
@@ -139,7 +139,7 @@ func (a *App) handleListPackages(c *fiber.Ctx) error {
// Count vulnerabilities by severity // Count vulnerabilities by severity
severityCounts := make(map[string]int) severityCounts := make(map[string]int)
for _, vuln := range scanResult.Vulnerabilities { for _, vuln := range scanResult.Vulnerabilities {
severityCounts[strings.ToUpper(vuln.Severity)]++ severityCounts[metadata.NormalizeSeverity(vuln.Severity)]++
} }
// Check if package should be blocked based on thresholds // Check if package should be blocked based on thresholds
@@ -201,7 +201,7 @@ func (a *App) handleListPackages(c *fiber.Ctx) error {
} }
// handleDeletePackage deletes a cached package // handleDeletePackage deletes a cached package
func (a *App) handleDeletePackage(c *fiber.Ctx) error { func (a *App) handleDeletePackage(c fiber.Ctx) error {
ctx := c.Context() ctx := c.Context()
// Parse path: /api/packages/{registry}/{name}/{version} // Parse path: /api/packages/{registry}/{name}/{version}
@@ -331,7 +331,7 @@ func (a *App) handleDeletePackage(c *fiber.Ctx) error {
} }
// handleStats handles /api/stats endpoint // handleStats handles /api/stats endpoint
func (a *App) handleStats(c *fiber.Ctx) error { func (a *App) handleStats(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") c.Set("Access-Control-Allow-Methods", "GET, OPTIONS")
@@ -421,7 +421,7 @@ func (a *App) handleStats(c *fiber.Ctx) error {
// handleTimeSeriesStats handles /api/stats/timeseries endpoint // handleTimeSeriesStats handles /api/stats/timeseries endpoint
// Returns time-series download statistics for charts // Returns time-series download statistics for charts
func (a *App) handleTimeSeriesStats(c *fiber.Ctx) error { func (a *App) handleTimeSeriesStats(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") c.Set("Access-Control-Allow-Methods", "GET, OPTIONS")
@@ -463,7 +463,7 @@ func (a *App) handleTimeSeriesStats(c *fiber.Ctx) error {
// handleConfig handles /api/config endpoint // handleConfig handles /api/config endpoint
// Returns runtime configuration for the frontend // Returns runtime configuration for the frontend
func (a *App) handleConfig(c *fiber.Ctx) error { func (a *App) handleConfig(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") c.Set("Access-Control-Allow-Methods", "GET, OPTIONS")
@@ -497,7 +497,7 @@ func (a *App) handleConfig(c *fiber.Ctx) error {
} }
// handleInfo handles /api/info endpoint // handleInfo handles /api/info endpoint
func (a *App) handleInfo(c *fiber.Ctx) error { func (a *App) handleInfo(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") c.Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+19 -27
View File
@@ -1,36 +1,28 @@
package app package app
import ( import (
"strings"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/auth" "github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/uuid" "github.com/lukaszraczylo/gohoarder/pkg/uuid"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// requireAdmin middleware checks for admin authentication // requireAdmin middleware checks for admin authentication. It accepts the API
func (a *App) requireAdmin(c *fiber.Ctx) error { // key via either the Authorization Bearer header or the X-API-Key header
// Get API key from Authorization header // (shared extractAPIKey), so admin clients are not limited to the bearer form.
authHeader := c.Get("Authorization") func (a *App) requireAdmin(c fiber.Ctx) error {
if authHeader == "" { // Get API key from Authorization or X-API-Key header
apiKey, ok := extractAPIKey(c)
if !ok {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "missing authorization header", "error": "missing API key; provide Authorization: Bearer <key> or X-API-Key header",
}) })
} }
// Extract bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": "invalid authorization header format, expected: Bearer <token>",
})
}
apiKey := parts[1]
// Validate API key // Validate API key
key, err := a.authManager.ValidateAPIKey(c.Context(), apiKey) key, err := a.authManager.ValidateAPIKey(c.Context(), apiKey)
if err != nil { if err != nil {
@@ -51,7 +43,7 @@ func (a *App) requireAdmin(c *fiber.Ctx) error {
} }
// handleAdminBypasses handles /api/admin/bypasses endpoint // handleAdminBypasses handles /api/admin/bypasses endpoint
func (a *App) handleAdminBypasses(c *fiber.Ctx) error { func (a *App) handleAdminBypasses(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") c.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
@@ -82,7 +74,7 @@ func (a *App) handleAdminBypasses(c *fiber.Ctx) error {
} }
// handleListBypasses lists all CVE bypasses // handleListBypasses lists all CVE bypasses
func (a *App) handleListBypasses(c *fiber.Ctx) error { func (a *App) handleListBypasses(c fiber.Ctx) error {
ctx := c.Context() ctx := c.Context()
// Parse query parameters // Parse query parameters
@@ -120,11 +112,11 @@ type CreateBypassRequest struct {
} }
// handleCreateBypass creates a new CVE bypass // handleCreateBypass creates a new CVE bypass
func (a *App) handleCreateBypass(c *fiber.Ctx) error { func (a *App) handleCreateBypass(c fiber.Ctx) error {
ctx := c.Context() ctx := c.Context()
var req CreateBypassRequest var req CreateBypassRequest
if err := c.BodyParser(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON in request body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON in request body"})
} }
@@ -187,7 +179,7 @@ func (a *App) handleCreateBypass(c *fiber.Ctx) error {
} }
// handleGetBypass gets a specific bypass by ID // handleGetBypass gets a specific bypass by ID
func (a *App) handleGetBypass(c *fiber.Ctx) error { func (a *App) handleGetBypass(c fiber.Ctx) error {
ctx := c.Context() ctx := c.Context()
// Extract ID from parameter // Extract ID from parameter
@@ -225,7 +217,7 @@ type UpdateBypassRequest struct {
} }
// handleUpdateBypass updates a bypass (activate/deactivate or extend expiration) // handleUpdateBypass updates a bypass (activate/deactivate or extend expiration)
func (a *App) handleUpdateBypass(c *fiber.Ctx) error { func (a *App) handleUpdateBypass(c fiber.Ctx) error {
ctx := c.Context() ctx := c.Context()
// Extract ID from parameter // Extract ID from parameter
@@ -236,7 +228,7 @@ func (a *App) handleUpdateBypass(c *fiber.Ctx) error {
} }
var req UpdateBypassRequest var req UpdateBypassRequest
if err := c.BodyParser(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON in request body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON in request body"})
} }
@@ -292,7 +284,7 @@ func (a *App) handleUpdateBypass(c *fiber.Ctx) error {
} }
// handleDeleteBypass deletes a bypass // handleDeleteBypass deletes a bypass
func (a *App) handleDeleteBypass(c *fiber.Ctx) error { func (a *App) handleDeleteBypass(c fiber.Ctx) error {
ctx := c.Context() ctx := c.Context()
// Extract ID from parameter // Extract ID from parameter
@@ -304,7 +296,7 @@ func (a *App) handleDeleteBypass(c *fiber.Ctx) error {
// Delete bypass // Delete bypass
if err := a.metadata.DeleteCVEBypass(ctx, bypassID); err != nil { if err := a.metadata.DeleteCVEBypass(ctx, bypassID); err != nil {
if strings.Contains(err.Error(), "not found") { if appErr, ok := err.(*errors.Error); ok && appErr.Code == errors.ErrCodeNotFound {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "bypass not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "bypass not found"})
} }
log.Error().Err(err).Msg("Failed to delete bypass") log.Error().Err(err).Msg("Failed to delete bypass")
+8 -8
View File
@@ -3,12 +3,12 @@ package app
import ( import (
"strconv" "strconv"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// handleAnalyticsTopPackages returns the most downloaded packages // handleAnalyticsTopPackages returns the most downloaded packages
func (a *App) handleAnalyticsTopPackages(c *fiber.Ctx) error { func (a *App) handleAnalyticsTopPackages(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
@@ -29,7 +29,7 @@ func (a *App) handleAnalyticsTopPackages(c *fiber.Ctx) error {
} }
// handleAnalyticsTrendingPackages returns trending packages // handleAnalyticsTrendingPackages returns trending packages
func (a *App) handleAnalyticsTrendingPackages(c *fiber.Ctx) error { func (a *App) handleAnalyticsTrendingPackages(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
@@ -50,7 +50,7 @@ func (a *App) handleAnalyticsTrendingPackages(c *fiber.Ctx) error {
} }
// handleAnalyticsTrends returns download trends over time // handleAnalyticsTrends returns download trends over time
func (a *App) handleAnalyticsTrends(c *fiber.Ctx) error { func (a *App) handleAnalyticsTrends(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
@@ -62,7 +62,7 @@ func (a *App) handleAnalyticsTrends(c *fiber.Ctx) error {
} }
// handleAnalyticsTotalStats returns overall statistics // handleAnalyticsTotalStats returns overall statistics
func (a *App) handleAnalyticsTotalStats(c *fiber.Ctx) error { func (a *App) handleAnalyticsTotalStats(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
@@ -72,7 +72,7 @@ func (a *App) handleAnalyticsTotalStats(c *fiber.Ctx) error {
} }
// handleAnalyticsRegistryStats returns per-registry statistics // handleAnalyticsRegistryStats returns per-registry statistics
func (a *App) handleAnalyticsRegistryStats(c *fiber.Ctx) error { func (a *App) handleAnalyticsRegistryStats(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
@@ -89,7 +89,7 @@ func (a *App) handleAnalyticsRegistryStats(c *fiber.Ctx) error {
} }
// handleAnalyticsPackageStats returns statistics for a specific package // handleAnalyticsPackageStats returns statistics for a specific package
func (a *App) handleAnalyticsPackageStats(c *fiber.Ctx) error { func (a *App) handleAnalyticsPackageStats(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
@@ -115,7 +115,7 @@ func (a *App) handleAnalyticsPackageStats(c *fiber.Ctx) error {
} }
// handleAnalyticsSearch searches for packages matching a query // handleAnalyticsSearch searches for packages matching a query
func (a *App) handleAnalyticsSearch(c *fiber.Ctx) error { func (a *App) handleAnalyticsSearch(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/analytics" "github.com/lukaszraczylo/gohoarder/pkg/analytics"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@@ -286,9 +286,9 @@ func (s *AnalyticsHandlersTestSuite) TestHandleAnalyticsSearch() {
if !tt.expectError { if !tt.expectError {
var result struct { var result struct {
Query string `json:"query"`
Results []analytics.PackageStats `json:"results"` Results []analytics.PackageStats `json:"results"`
Total int `json:"total"` Total int `json:"total"`
Query string `json:"query"`
} }
err = json.NewDecoder(resp.Body).Decode(&result) err = json.NewDecoder(resp.Body).Decode(&result)
s.NoError(err) s.NoError(err)
+5 -5
View File
@@ -3,7 +3,7 @@ package app
import ( import (
"time" "time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/auth" "github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -16,11 +16,11 @@ type GenerateAPIKeyRequest struct {
} }
// handleGenerateAPIKey generates a new API key // handleGenerateAPIKey generates a new API key
func (a *App) handleGenerateAPIKey(c *fiber.Ctx) error { func (a *App) handleGenerateAPIKey(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
var req GenerateAPIKeyRequest var req GenerateAPIKeyRequest
if err := c.BodyParser(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid JSON in request body", "error": "invalid JSON in request body",
}) })
@@ -82,7 +82,7 @@ func (a *App) handleGenerateAPIKey(c *fiber.Ctx) error {
} }
// handleListAPIKeys lists all API keys // handleListAPIKeys lists all API keys
func (a *App) handleListAPIKeys(c *fiber.Ctx) error { func (a *App) handleListAPIKeys(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
keys := a.authManager.ListAPIKeys() keys := a.authManager.ListAPIKeys()
@@ -108,7 +108,7 @@ func (a *App) handleListAPIKeys(c *fiber.Ctx) error {
} }
// handleRevokeAPIKey revokes an API key // handleRevokeAPIKey revokes an API key
func (a *App) handleRevokeAPIKey(c *fiber.Ctx) error { func (a *App) handleRevokeAPIKey(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
keyID := c.Params("key_id") keyID := c.Params("key_id")
+14 -13
View File
@@ -5,8 +5,9 @@ import (
"encoding/json" "encoding/json"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/auth" "github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
@@ -44,8 +45,8 @@ func (s *AuthHandlersTestSuite) TestHandleGenerateAPIKey() {
tests := []struct { tests := []struct {
requestBody map[string]string requestBody map[string]string
name string name string
expectedStatus int
expectedRole string expectedRole string
expectedStatus int
expectKey bool expectKey bool
}{ }{
{ {
@@ -114,7 +115,7 @@ func (s *AuthHandlersTestSuite) TestHandleGenerateAPIKey() {
req := httptest.NewRequest("POST", "/api/admin/keys", bytes.NewReader(bodyBytes)) req := httptest.NewRequest("POST", "/api/admin/keys", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := s.app.Test(req, 5000) // 5 second timeout for CI environments resp, err := s.app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(tt.expectedStatus, resp.StatusCode) s.Equal(tt.expectedStatus, resp.StatusCode)
@@ -139,12 +140,12 @@ func (s *AuthHandlersTestSuite) TestHandleGenerateAPIKey() {
func (s *AuthHandlersTestSuite) TestHandleListAPIKeys() { func (s *AuthHandlersTestSuite) TestHandleListAPIKeys() {
// Generate some test keys first // Generate some test keys first
s.authManager.GenerateAPIKey("test-key-1", auth.RoleReadOnly, nil) _, _, _ = s.authManager.GenerateAPIKey("test-key-1", auth.RoleReadOnly, nil)
s.authManager.GenerateAPIKey("test-key-2", auth.RoleReadWrite, nil) _, _, _ = s.authManager.GenerateAPIKey("test-key-2", auth.RoleReadWrite, nil)
s.authManager.GenerateAPIKey("test-key-3", auth.RoleAdmin, nil) _, _, _ = s.authManager.GenerateAPIKey("test-key-3", auth.RoleAdmin, nil)
req := httptest.NewRequest("GET", "/api/admin/keys", nil) req := httptest.NewRequest("GET", "/api/admin/keys", nil)
resp, err := s.app.Test(req, 5000) // 5 second timeout for CI environments resp, err := s.app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(200, resp.StatusCode) s.Equal(200, resp.StatusCode)
@@ -189,7 +190,7 @@ func (s *AuthHandlersTestSuite) TestHandleRevokeAPIKey() {
for _, tt := range tests { for _, tt := range tests {
s.Run(tt.name, func() { s.Run(tt.name, func() {
req := httptest.NewRequest("DELETE", "/api/admin/keys/"+tt.keyID, nil) req := httptest.NewRequest("DELETE", "/api/admin/keys/"+tt.keyID, nil)
resp, err := s.app.Test(req, 5000) // 5 second timeout for CI environments resp, err := s.app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(tt.expectedStatus, resp.StatusCode) s.Equal(tt.expectedStatus, resp.StatusCode)
@@ -207,7 +208,7 @@ func (s *AuthHandlersTestSuite) TestHandleGenerateAPIKeyInvalidJSON() {
req := httptest.NewRequest("POST", "/api/admin/keys", bytes.NewReader([]byte("invalid json"))) req := httptest.NewRequest("POST", "/api/admin/keys", bytes.NewReader([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := s.app.Test(req, 5000) // 5 second timeout for CI environments resp, err := s.app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(400, resp.StatusCode) s.Equal(400, resp.StatusCode)
} }
@@ -221,7 +222,7 @@ func (s *AuthHandlersTestSuite) TestGenerateAndRevokeKeyFlow() {
req1 := httptest.NewRequest("POST", "/api/admin/keys", bytes.NewReader(bodyBytes)) req1 := httptest.NewRequest("POST", "/api/admin/keys", bytes.NewReader(bodyBytes))
req1.Header.Set("Content-Type", "application/json") req1.Header.Set("Content-Type", "application/json")
resp1, err := s.app.Test(req1, 5000) // 5 second timeout for CI environments resp1, err := s.app.Test(req1, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(201, resp1.StatusCode) s.Equal(201, resp1.StatusCode)
@@ -235,7 +236,7 @@ func (s *AuthHandlersTestSuite) TestGenerateAndRevokeKeyFlow() {
// List keys - should include our new key // List keys - should include our new key
req2 := httptest.NewRequest("GET", "/api/admin/keys", nil) req2 := httptest.NewRequest("GET", "/api/admin/keys", nil)
resp2, err := s.app.Test(req2, 5000) // 5 second timeout for CI environments resp2, err := s.app.Test(req2, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(200, resp2.StatusCode) s.Equal(200, resp2.StatusCode)
@@ -257,13 +258,13 @@ func (s *AuthHandlersTestSuite) TestGenerateAndRevokeKeyFlow() {
// Revoke the key // Revoke the key
req3 := httptest.NewRequest("DELETE", "/api/admin/keys/"+keyID, nil) req3 := httptest.NewRequest("DELETE", "/api/admin/keys/"+keyID, nil)
resp3, err := s.app.Test(req3, 5000) // 5 second timeout for CI environments resp3, err := s.app.Test(req3, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(200, resp3.StatusCode) s.Equal(200, resp3.StatusCode)
// List keys again - should not include the revoked key // List keys again - should not include the revoked key
req4 := httptest.NewRequest("GET", "/api/admin/keys", nil) req4 := httptest.NewRequest("GET", "/api/admin/keys", nil)
resp4, err := s.app.Test(req4, 5000) // 5 second timeout for CI environments resp4, err := s.app.Test(req4, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true}) // 5 second timeout for CI environments
s.Require().NoError(err) s.Require().NoError(err)
s.Equal(200, resp4.StatusCode) s.Equal(200, resp4.StatusCode)
+5 -5
View File
@@ -1,12 +1,12 @@
package app package app
import ( import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// handlePrewarmingStatus returns the status of the pre-warming worker // handlePrewarmingStatus returns the status of the pre-warming worker
func (a *App) handlePrewarmingStatus(c *fiber.Ctx) error { func (a *App) handlePrewarmingStatus(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
status := a.prewarmWorker.GetStatus() status := a.prewarmWorker.GetStatus()
@@ -15,7 +15,7 @@ func (a *App) handlePrewarmingStatus(c *fiber.Ctx) error {
} }
// handlePrewarmingTrigger manually triggers a pre-warming cycle // handlePrewarmingTrigger manually triggers a pre-warming cycle
func (a *App) handlePrewarmingTrigger(c *fiber.Ctx) error { func (a *App) handlePrewarmingTrigger(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
ctx := c.Context() ctx := c.Context()
@@ -36,11 +36,11 @@ type PrewarmPackageRequest struct {
} }
// handlePrewarmingPackage pre-warms a specific package // handlePrewarmingPackage pre-warms a specific package
func (a *App) handlePrewarmingPackage(c *fiber.Ctx) error { func (a *App) handlePrewarmingPackage(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
var req PrewarmPackageRequest var req PrewarmPackageRequest
if err := c.BodyParser(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid JSON in request body", "error": "invalid JSON in request body",
}) })
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/prewarming" "github.com/lukaszraczylo/gohoarder/pkg/prewarming"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
) )
+15 -7
View File
@@ -3,13 +3,13 @@ package app
import ( import (
"strings" "strings"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// handleVulnerabilities handles /api/packages/{registry}/{name}/{version}/vulnerabilities endpoint // handleVulnerabilities handles /api/packages/{registry}/{name}/{version}/vulnerabilities endpoint
func (a *App) handleVulnerabilities(c *fiber.Ctx) error { func (a *App) handleVulnerabilities(c fiber.Ctx) error {
c.Set("Content-Type", "application/json") c.Set("Content-Type", "application/json")
c.Set("Access-Control-Allow-Origin", "*") c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Methods", "GET, OPTIONS") c.Set("Access-Control-Allow-Methods", "GET, OPTIONS")
@@ -77,11 +77,19 @@ func (a *App) handleVulnerabilities(c *fiber.Ctx) error {
bypasses = []*metadata.CVEBypass{} bypasses = []*metadata.CVEBypass{}
} }
// Build bypass map for fast lookup // Build bypass map for fast lookup.
// Normalize Go cache-key names (modulePath/@v/version.zip) to the
// natural module path so admin bypass targets like
// "go/github.com/foo/[email protected]" match the package.
bypassName := name
if registry == "go" {
if idx := strings.Index(bypassName, "/@v/"); idx >= 0 {
bypassName = bypassName[:idx]
}
}
packageKey := registry + "/" + bypassName + "@" + version
packageKeyNoVersion := registry + "/" + bypassName
bypassedCVEs := make(map[string]*metadata.CVEBypass) bypassedCVEs := make(map[string]*metadata.CVEBypass)
packageKey := registry + "/" + name + "@" + version
packageKeyNoVersion := registry + "/" + name
for _, bypass := range bypasses { for _, bypass := range bypasses {
if bypass.Type == metadata.BypassTypeCVE && bypass.Active { if bypass.Type == metadata.BypassTypeCVE && bypass.Active {
// Check if bypass applies to this package // Check if bypass applies to this package
@@ -110,7 +118,7 @@ func (a *App) handleVulnerabilities(c *fiber.Ctx) error {
} }
} else { } else {
// Count non-bypassed vulnerabilities by severity // Count non-bypassed vulnerabilities by severity
severityCounts[strings.ToUpper(vuln.Severity)]++ severityCounts[metadata.NormalizeSeverity(vuln.Severity)]++
} }
enrichedVuln := map[string]interface{}{ enrichedVuln := map[string]interface{}{
+193
View File
@@ -0,0 +1,193 @@
package app
import (
"strings"
"github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/rs/zerolog/log"
)
// Locals keys used by the auth middleware. Exported so other handlers in the
// app package (and the integrator) can fetch the resolved key/role without
// guessing at string literals.
const (
LocalAuthKey = "auth_key"
LocalAuthRole = "auth_role"
)
// extractAPIKey pulls the raw API key from either the Authorization bearer
// header or the X-API-Key header. Returns ("", false) when neither is set or
// the Authorization header has the wrong shape.
func extractAPIKey(c fiber.Ctx) (string, bool) {
if h := strings.TrimSpace(c.Get("Authorization")); h != "" {
// Expect: "Bearer <token>". Be tolerant of casing on the scheme.
parts := strings.SplitN(h, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
token := strings.TrimSpace(parts[1])
if token != "" {
return token, true
}
}
}
if h := strings.TrimSpace(c.Get("X-API-Key")); h != "" {
return h, true
}
return "", false
}
// writeAuthError sends a structured error response matching the project's
// errors envelope (errors.Error JSON shape). Uses the HTTPStatusCode map so
// callers don't need to remember which status maps to which code.
func writeAuthError(c fiber.Ctx, code, message string) error {
status, ok := errors.HTTPStatusCode[code]
if !ok {
status = fiber.StatusInternalServerError
}
return c.Status(status).JSON(errors.New(code, message))
}
// requestIDFromCtx returns the request ID set by an upstream middleware or
// the X-Request-ID header. Empty string when neither is available — callers
// should treat empty as "no correlation id".
func requestIDFromCtx(c fiber.Ctx) string {
if v := c.Locals("request_id"); v != nil {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return c.Get("X-Request-ID")
}
// validateAndAttach is the shared core of all auth middlewares: pull the key,
// validate via the manager, and on success store it in Locals. Returns the
// resolved key plus a boolean indicating whether a key was present at all
// (false when no header). The error is non-nil only when validation failed
// (i.e. a key was present but invalid/expired).
func validateAndAttach(c fiber.Ctx, authMgr *auth.Manager) (key *auth.APIKey, present bool, err error) {
rawKey, ok := extractAPIKey(c)
if !ok {
return nil, false, nil
}
apiKey, err := authMgr.ValidateAPIKey(c.Context(), rawKey)
if err != nil {
return nil, true, err
}
c.Locals(LocalAuthKey, apiKey)
c.Locals(LocalAuthRole, string(apiKey.Role))
return apiKey, true, nil
}
// RequireAuth returns a fiber.Handler that rejects requests without a valid
// API key. On success the resolved *auth.APIKey is stored in c.Locals under
// LocalAuthKey.
func RequireAuth(authMgr *auth.Manager) fiber.Handler {
return func(c fiber.Ctx) error {
key, present, err := validateAndAttach(c, authMgr)
if !present {
log.Debug().
Str("path", c.Path()).
Str("method", c.Method()).
Str("request_id", requestIDFromCtx(c)).
Msg("auth: missing API key")
return writeAuthError(c, errors.ErrCodeUnauthorized, "missing API key; provide Authorization: Bearer <key> or X-API-Key header")
}
if err != nil {
log.Warn().
Err(err).
Str("path", c.Path()).
Str("method", c.Method()).
Str("request_id", requestIDFromCtx(c)).
Msg("auth: invalid API key")
return writeAuthError(c, errors.ErrCodeInvalidAPIKey, "invalid or expired API key")
}
log.Debug().
Str("key_id", key.ID).
Str("role", string(key.Role)).
Str("request_id", requestIDFromCtx(c)).
Msg("auth: key validated")
return c.Next()
}
}
// RequireRole returns a fiber.Handler that authenticates the caller and then
// requires the resolved key's role to be present in the supplied list (any-of
// semantics). An empty roles list behaves like RequireAuth.
func RequireRole(authMgr *auth.Manager, roles ...string) fiber.Handler {
return func(c fiber.Ctx) error {
key, present, err := validateAndAttach(c, authMgr)
if !present {
return writeAuthError(c, errors.ErrCodeUnauthorized, "missing API key; provide Authorization: Bearer <key> or X-API-Key header")
}
if err != nil {
return writeAuthError(c, errors.ErrCodeInvalidAPIKey, "invalid or expired API key")
}
if len(roles) == 0 {
return c.Next()
}
actual := string(key.Role)
for _, r := range roles {
if r == actual {
return c.Next()
}
}
log.Warn().
Str("key_id", key.ID).
Str("role", actual).
Strs("required_roles", roles).
Str("request_id", requestIDFromCtx(c)).
Msg("auth: role mismatch")
return writeAuthError(c, errors.ErrCodeForbidden, "insufficient role for this resource")
}
}
// RequirePermission returns a fiber.Handler that authenticates the caller and
// then requires the resolved key to hold at least one of the supplied
// permissions. An empty perms list behaves like RequireAuth.
func RequirePermission(authMgr *auth.Manager, perms ...string) fiber.Handler {
return func(c fiber.Ctx) error {
key, present, err := validateAndAttach(c, authMgr)
if !present {
return writeAuthError(c, errors.ErrCodeUnauthorized, "missing API key; provide Authorization: Bearer <key> or X-API-Key header")
}
if err != nil {
return writeAuthError(c, errors.ErrCodeInvalidAPIKey, "invalid or expired API key")
}
if len(perms) == 0 {
return c.Next()
}
for _, p := range perms {
if key.HasPermission(auth.Permission(p)) {
return c.Next()
}
}
log.Warn().
Str("key_id", key.ID).
Str("role", string(key.Role)).
Strs("required_perms", perms).
Str("request_id", requestIDFromCtx(c)).
Msg("auth: permission denied")
return writeAuthError(c, errors.ErrCodeForbidden, "insufficient permissions for this resource")
}
}
// OptionalAuth returns a fiber.Handler that attempts to validate any provided
// API key but never rejects the request. Handy for endpoints whose behavior
// changes when the caller is authenticated (e.g. per-key rate limits) but
// which still serve anonymous traffic. Locals are populated on success.
func OptionalAuth(authMgr *auth.Manager) fiber.Handler {
return func(c fiber.Ctx) error {
_, present, err := validateAndAttach(c, authMgr)
if present && err != nil {
// Key was provided but invalid — log for observability, continue
// anonymously rather than 401-ing.
log.Debug().
Err(err).
Str("path", c.Path()).
Str("request_id", requestIDFromCtx(c)).
Msg("auth: optional auth ignored invalid key")
}
return c.Next()
}
}
+287
View File
@@ -0,0 +1,287 @@
package app
import (
"encoding/json"
"io"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v3"
"github.com/lukaszraczylo/gohoarder/pkg/auth"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/stretchr/testify/require"
)
// mwTestSetup builds a fiber app wired with the given middleware on /protected.
// The handler echoes the resolved auth_key locals so tests can assert that
// downstream handlers see them. Returns the app plus a freshly issued raw key
// for the supplied role.
func mwTestSetup(t *testing.T, role auth.Role, mw fiber.Handler) (*fiber.App, *auth.Manager, string, *auth.APIKey) {
t.Helper()
mgr := auth.New()
apiKey, raw, err := mgr.GenerateAPIKey("test-"+string(role), role, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", mw)
app.Get("/protected", func(c fiber.Ctx) error {
got, _ := c.Locals(LocalAuthKey).(*auth.APIKey)
gotRole, _ := c.Locals(LocalAuthRole).(string)
body := fiber.Map{"ok": true, "have_key": got != nil}
if got != nil {
body["key_id"] = got.ID
body["role"] = gotRole
}
return c.Status(fiber.StatusOK).JSON(body)
})
return app, mgr, raw, apiKey
}
func decodeErrorBody(t *testing.T, body io.Reader) errors.Error {
t.Helper()
var e errors.Error
require.NoError(t, json.NewDecoder(body).Decode(&e))
return e
}
func TestRequireAuth_NoHeader_Returns401(t *testing.T) {
app, _, _, _ := mwTestSetup(t, auth.RoleReadOnly, RequireAuth(auth.New()))
req := httptest.NewRequest("GET", "/protected", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
body := decodeErrorBody(t, resp.Body)
require.Equal(t, errors.ErrCodeUnauthorized, body.Code)
}
func TestRequireAuth_BadKey_Returns401(t *testing.T) {
mgr := auth.New()
_, _, err := mgr.GenerateAPIKey("real", auth.RoleReadOnly, nil)
require.NoError(t, err)
app, _, _, _ := mwTestSetup(t, auth.RoleReadOnly, RequireAuth(mgr))
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer not-a-real-key")
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
body := decodeErrorBody(t, resp.Body)
require.Equal(t, errors.ErrCodeInvalidAPIKey, body.Code)
}
func TestRequireAuth_BadHeaderShape_Returns401(t *testing.T) {
cases := []struct {
name string
header string
value string
}{
{"non-bearer scheme", "Authorization", "Basic abcdef"},
{"bearer no token", "Authorization", "Bearer "},
{"empty x-api-key", "X-API-Key", ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
mgr := auth.New()
app := fiber.New(fiber.Config{})
app.Use("/protected", RequireAuth(mgr))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
if tc.value != "" {
req.Header.Set(tc.header, tc.value)
}
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
})
}
}
func TestRequireAuth_ValidBearer_Returns200_AndPopulatesLocals(t *testing.T) {
mgr := auth.New()
apiKey, raw, err := mgr.GenerateAPIKey("good", auth.RoleReadWrite, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", RequireAuth(mgr))
app.Get("/protected", func(c fiber.Ctx) error {
k, _ := c.Locals(LocalAuthKey).(*auth.APIKey)
require.NotNil(t, k)
require.Equal(t, apiKey.ID, k.ID)
require.Equal(t, string(auth.RoleReadWrite), c.Locals(LocalAuthRole))
return c.Status(200).JSON(fiber.Map{"key_id": k.ID})
})
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
var body map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, apiKey.ID, body["key_id"])
}
func TestRequireAuth_ValidXAPIKey_Returns200(t *testing.T) {
mgr := auth.New()
_, raw, err := mgr.GenerateAPIKey("good", auth.RoleReadOnly, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", RequireAuth(mgr))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("X-API-Key", raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
func TestRequireAuth_ExpiredKey_Returns401(t *testing.T) {
mgr := auth.New()
d := -1 * time.Hour
_, raw, err := mgr.GenerateAPIKey("expired", auth.RoleReadOnly, &d)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", RequireAuth(mgr))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
}
func TestRequireRole_Match_Returns200(t *testing.T) {
mgr := auth.New()
_, raw, err := mgr.GenerateAPIKey("admin", auth.RoleAdmin, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", RequireRole(mgr, string(auth.RoleAdmin), string(auth.RoleReadWrite)))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
func TestRequireRole_Mismatch_Returns403(t *testing.T) {
mgr := auth.New()
_, raw, err := mgr.GenerateAPIKey("ro", auth.RoleReadOnly, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", RequireRole(mgr, string(auth.RoleAdmin)))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, fiber.StatusForbidden, resp.StatusCode)
body := decodeErrorBody(t, resp.Body)
require.Equal(t, errors.ErrCodeForbidden, body.Code)
}
func TestRequireRole_NoHeader_Returns401(t *testing.T) {
mgr := auth.New()
app := fiber.New(fiber.Config{})
app.Use("/protected", RequireRole(mgr, string(auth.RoleAdmin)))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, fiber.StatusUnauthorized, resp.StatusCode)
}
func TestRequirePermission_Match_Returns200(t *testing.T) {
mgr := auth.New()
_, raw, err := mgr.GenerateAPIKey("rw", auth.RoleReadWrite, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", RequirePermission(mgr, string(auth.PermissionWritePackage)))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
func TestRequirePermission_Mismatch_Returns403(t *testing.T) {
mgr := auth.New()
_, raw, err := mgr.GenerateAPIKey("ro", auth.RoleReadOnly, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", RequirePermission(mgr, string(auth.PermissionDeletePackage)))
app.Get("/protected", func(c fiber.Ctx) error { return c.SendStatus(200) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, fiber.StatusForbidden, resp.StatusCode)
}
func TestOptionalAuth_NoHeader_Continues(t *testing.T) {
mgr := auth.New()
app := fiber.New(fiber.Config{})
app.Use("/protected", OptionalAuth(mgr))
app.Get("/protected", func(c fiber.Ctx) error {
require.Nil(t, c.Locals(LocalAuthKey))
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/protected", nil)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
func TestOptionalAuth_BadKey_ContinuesAnonymously(t *testing.T) {
mgr := auth.New()
app := fiber.New(fiber.Config{})
app.Use("/protected", OptionalAuth(mgr))
app.Get("/protected", func(c fiber.Ctx) error {
// Bad key should NOT populate locals.
require.Nil(t, c.Locals(LocalAuthKey))
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer not-real")
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
func TestOptionalAuth_GoodKey_PopulatesLocals(t *testing.T) {
mgr := auth.New()
apiKey, raw, err := mgr.GenerateAPIKey("good", auth.RoleAdmin, nil)
require.NoError(t, err)
app := fiber.New(fiber.Config{})
app.Use("/protected", OptionalAuth(mgr))
app.Get("/protected", func(c fiber.Ctx) error {
k, _ := c.Locals(LocalAuthKey).(*auth.APIKey)
require.NotNil(t, k)
require.Equal(t, apiKey.ID, k.ID)
return c.SendStatus(200)
})
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+raw)
resp, err := app.Test(req, fiber.TestConfig{Timeout: 5 * time.Second, FailOnTimeout: true})
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
+73
View File
@@ -0,0 +1,73 @@
package app
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func reqWithOrigin(origin string) *http.Request {
r, _ := http.NewRequest(http.MethodGet, "/ws", nil)
if origin != "" {
r.Header.Set("Origin", origin)
}
r.Host = "app.example.com"
return r
}
// TestBuildCheckOrigin_DefaultPortNormalization verifies that scheme-default
// ports (443/80) are ignored, so "https://app.example.com" and
// "https://app.example.com:443" are treated as the same origin.
func TestBuildCheckOrigin_DefaultPortNormalization(t *testing.T) {
check := buildCheckOrigin([]string{"https://app.example.com"})
assert.True(t, check(reqWithOrigin("https://app.example.com")))
assert.True(t, check(reqWithOrigin("https://app.example.com:443")))
assert.False(t, check(reqWithOrigin("https://app.example.com:8443")))
// Default http port strips too.
checkHTTP := buildCheckOrigin([]string{"http://app.example.com"})
assert.True(t, checkHTTP(reqWithOrigin("http://app.example.com:80")))
assert.True(t, checkHTTP(reqWithOrigin("http://app.example.com")))
}
// TestBuildCheckOrigin_NonDefaultPortPreserved verifies a non-default port is
// not stripped and must match exactly.
func TestBuildCheckOrigin_NonDefaultPortPreserved(t *testing.T) {
check := buildCheckOrigin([]string{"https://app.example.com:8443"})
assert.True(t, check(reqWithOrigin("https://app.example.com:8443")))
assert.False(t, check(reqWithOrigin("https://app.example.com")))
}
// TestBuildCheckOrigin_Wildcard verifies wildcard host patterns match the apex
// and subdomains, and are unaffected by ports on the incoming origin.
func TestBuildCheckOrigin_Wildcard(t *testing.T) {
check := buildCheckOrigin([]string{"https://*.example.com"})
assert.True(t, check(reqWithOrigin("https://example.com")))
assert.True(t, check(reqWithOrigin("https://cdn.example.com")))
assert.True(t, check(reqWithOrigin("https://cdn.example.com:443")))
assert.False(t, check(reqWithOrigin("https://example.org")))
}
// TestBuildCheckOrigin_SchemeMismatch verifies the scheme must match.
func TestBuildCheckOrigin_SchemeMismatch(t *testing.T) {
check := buildCheckOrigin([]string{"https://app.example.com"})
assert.False(t, check(reqWithOrigin("http://app.example.com")))
}
// TestBuildCheckOrigin_SameOriginFallback verifies that with an empty allowlist
// only a same-origin upgrade (matching Host) is permitted.
func TestBuildCheckOrigin_SameOriginFallback(t *testing.T) {
check := buildCheckOrigin(nil)
r := reqWithOrigin("https://app.example.com")
assert.True(t, check(r))
// Host defaults to app.example.com; a different Origin host is rejected.
r2 := reqWithOrigin("https://other.example.com")
assert.False(t, check(r2))
}
+283 -25
View File
@@ -1,20 +1,48 @@
// Package auth implements API key issuance, validation, and credential
// extraction for upstream-registry pass-through authentication.
package auth package auth
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
stderrors "errors"
"fmt"
"sync" "sync"
"time" "time"
"github.com/lukaszraczylo/gohoarder/pkg/errors" "github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// Manager handles authentication and authorization // Config controls Manager behaviour. Zero value is valid: bcrypt cost defaults
// to bcrypt.DefaultCost, store is nil (in-memory only).
type Config struct {
// BcryptCost is the cost parameter passed to bcrypt.GenerateFromPassword.
// 0 means use bcrypt.DefaultCost. Tests typically use bcrypt.MinCost.
BcryptCost int
}
// Manager handles authentication and authorization.
//
// Persistence model:
// - All keys live in an in-memory map keyed by APIKey.ID for O(1) revoke
// and fast bcrypt iteration during validation.
// - When a metadata.MetadataStore is wired in via NewWithStore, mutations
// (Generate, Revoke) are mirrored to the store synchronously and
// LastUsedAt updates are flushed asynchronously to avoid blocking the
// hot validation path on a DB round-trip.
// - When the store is nil, Manager is purely in-memory (back-compat for
// tests and unconfigured deployments).
type Manager struct { type Manager struct {
keys map[string]*APIKey keys map[string]*APIKey
mu sync.RWMutex store metadata.MetadataStore
cfg Config
mu sync.RWMutex
bgWG sync.WaitGroup
closed bool
} }
// APIKey represents an API key // APIKey represents an API key
@@ -23,6 +51,7 @@ type APIKey struct {
Name string Name string
HashedKey string HashedKey string
Role Role Role Role
Project string
CreatedAt time.Time CreatedAt time.Time
ExpiresAt *time.Time ExpiresAt *time.Time
LastUsedAt time.Time LastUsedAt time.Time
@@ -38,6 +67,15 @@ const (
RoleAdmin Role = "admin" RoleAdmin Role = "admin"
) )
// Persistent role identifiers stored in metadata.APIKey.Role. Different from
// the in-memory Role values for backward compatibility with existing API
// surfaces while matching the storage schema described in the spec.
const (
storedRoleReadOnly = "read_only"
storedRoleReadWrite = "read_write"
storedRoleAdmin = "admin"
)
// Permission represents a specific permission // Permission represents a specific permission
type Permission string type Permission string
@@ -52,14 +90,63 @@ const (
PermissionManageBypasses Permission = "bypasses:manage" PermissionManageBypasses Permission = "bypasses:manage"
) )
// New creates a new authentication manager // New creates a new authentication manager (in-memory only).
// Equivalent to NewWithStore(Config{}, nil).
func New() *Manager { func New() *Manager {
return NewWithStore(Config{}, nil)
}
// NewWithStore creates a Manager backed by an optional metadata store.
// Pass store=nil for purely in-memory mode (tests, ephemeral setups).
func NewWithStore(cfg Config, store metadata.MetadataStore) *Manager {
if cfg.BcryptCost == 0 {
cfg.BcryptCost = bcrypt.DefaultCost
}
return &Manager{ return &Manager{
keys: make(map[string]*APIKey), keys: make(map[string]*APIKey),
store: store,
cfg: cfg,
} }
} }
// GenerateAPIKey generates a new API key // Load pulls all non-revoked keys from the store into the in-memory map.
// Safe to call multiple times: it replaces the in-memory snapshot.
// No-op when store is nil or returns ErrNotImplemented.
func (m *Manager) Load(ctx context.Context) error {
if m.store == nil {
return nil
}
stored, err := m.store.ListAPIKeys(ctx)
if err != nil {
if stderrors.Is(err, metadata.ErrNotImplemented) {
return nil
}
return err
}
loaded := make(map[string]*APIKey, len(stored))
for _, k := range stored {
if k == nil || k.Revoked {
continue
}
loaded[k.ID] = storedToMemory(k)
}
m.mu.Lock()
m.keys = loaded
m.mu.Unlock()
log.Info().Int("count", len(loaded)).Msg("Loaded API keys from metadata store")
return nil
}
// GenerateAPIKey generates a new API key.
//
// When a store is configured, the key is persisted before returning. If the
// store rejects the write the in-memory state is rolled back and the error
// is propagated; we never return a key the caller cannot actually use after
// a restart.
func (m *Manager) GenerateAPIKey(name string, role Role, expiresIn *time.Duration) (*APIKey, string, error) { func (m *Manager) GenerateAPIKey(name string, role Role, expiresIn *time.Duration) (*APIKey, string, error) {
// Generate random key // Generate random key
keyBytes := make([]byte, 32) keyBytes := make([]byte, 32)
@@ -69,8 +156,8 @@ func (m *Manager) GenerateAPIKey(name string, role Role, expiresIn *time.Duratio
rawKey := base64.URLEncoding.EncodeToString(keyBytes) rawKey := base64.URLEncoding.EncodeToString(keyBytes)
// Hash the key // Hash the key with the configured cost.
hashedKey, err := bcrypt.GenerateFromPassword([]byte(rawKey), bcrypt.DefaultCost) hashedKey, err := bcrypt.GenerateFromPassword([]byte(rawKey), m.cfg.BcryptCost)
if err != nil { if err != nil {
return nil, "", errors.Wrap(err, errors.ErrCodeInternalServer, "failed to hash key") return nil, "", errors.Wrap(err, errors.ErrCodeInternalServer, "failed to hash key")
} }
@@ -95,24 +182,57 @@ func (m *Manager) GenerateAPIKey(name string, role Role, expiresIn *time.Duratio
m.keys[apiKey.ID] = apiKey m.keys[apiKey.ID] = apiKey
m.mu.Unlock() m.mu.Unlock()
if m.store != nil {
stored := memoryToStored(apiKey)
if err := m.store.SaveAPIKey(context.Background(), stored); err != nil {
if !stderrors.Is(err, metadata.ErrNotImplemented) {
// Rollback in-memory insert so caller sees a consistent failure.
m.mu.Lock()
delete(m.keys, apiKey.ID)
m.mu.Unlock()
return nil, "", errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to persist api key")
}
}
}
return apiKey, rawKey, nil return apiKey, rawKey, nil
} }
// ValidateAPIKey validates an API key and returns the associated key object // ValidateAPIKey validates an API key and returns the associated key object.
// Hot path: snapshots candidates, runs bcrypt without holding the lock.
// LastUsedAt is updated in-memory under the write lock and flushed to the
// store via a fire-and-forget goroutine.
func (m *Manager) ValidateAPIKey(ctx context.Context, rawKey string) (*APIKey, error) { func (m *Manager) ValidateAPIKey(ctx context.Context, rawKey string) (*APIKey, error) {
// Phase 1: snapshot non-expired candidates under RLock. We hold pointers
// to APIKey; APIKey.HashedKey is an immutable string so reading it
// without a lock is safe even if the key is later removed from the map.
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() candidates := make([]*APIKey, 0, len(m.keys))
now := time.Now()
for _, apiKey := range m.keys { for _, apiKey := range m.keys {
// Check if key is expired if apiKey.ExpiresAt != nil && now.After(*apiKey.ExpiresAt) {
if apiKey.ExpiresAt != nil && time.Now().After(*apiKey.ExpiresAt) {
continue continue
} }
candidates = append(candidates, apiKey)
}
m.mu.RUnlock()
// Compare hashed key // Phase 2: run bcrypt comparisons without holding any lock so concurrent
// auth checks can proceed in parallel.
for _, apiKey := range candidates {
if err := bcrypt.CompareHashAndPassword([]byte(apiKey.HashedKey), []byte(rawKey)); err == nil { if err := bcrypt.CompareHashAndPassword([]byte(apiKey.HashedKey), []byte(rawKey)); err == nil {
// Update last used // Phase 3: briefly take the write lock to update LastUsedAt.
apiKey.LastUsedAt = time.Now() // Re-check the key still exists in the map to avoid resurrecting
// a revoked key's metadata.
usedAt := time.Now()
m.mu.Lock()
if _, ok := m.keys[apiKey.ID]; ok {
apiKey.LastUsedAt = usedAt
}
m.mu.Unlock()
// Async store update: never block validation on a DB write.
m.persistLastUsedAsync(apiKey.ID, usedAt)
return apiKey, nil return apiKey, nil
} }
} }
@@ -120,16 +240,63 @@ func (m *Manager) ValidateAPIKey(ctx context.Context, rawKey string) (*APIKey, e
return nil, errors.New(errors.ErrCodeUnauthorized, "invalid API key") return nil, errors.New(errors.ErrCodeUnauthorized, "invalid API key")
} }
// RevokeAPIKey revokes an API key // persistLastUsedAsync flushes a LastUsedAt update to the store off the hot
func (m *Manager) RevokeAPIKey(keyID string) error { // path. Errors are logged but not propagated — the validation succeeded
m.mu.Lock() // in-memory and that is the source of truth for liveness.
defer m.mu.Unlock() func (m *Manager) persistLastUsedAsync(id string, t time.Time) {
if m.store == nil {
if _, exists := m.keys[keyID]; !exists { return
return errors.NotFound("API key not found")
} }
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return
}
m.bgWG.Add(1)
m.mu.Unlock()
go func() {
defer m.bgWG.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := m.store.UpdateAPIKeyLastUsed(ctx, id, t); err != nil {
if !stderrors.Is(err, metadata.ErrNotImplemented) {
log.Warn().Err(err).Str("key_id", id).Msg("Failed to persist api key LastUsedAt")
}
}
}()
}
// RevokeAPIKey revokes an API key. With a store configured, revocation is
// persisted (Revoked=true) so the key remains queryable for audit but never
// authenticates again.
func (m *Manager) RevokeAPIKey(keyID string) error {
m.mu.Lock()
existing, ok := m.keys[keyID]
if !ok {
m.mu.Unlock()
return errors.NotFound("API key not found")
}
delete(m.keys, keyID) delete(m.keys, keyID)
m.mu.Unlock()
if m.store != nil {
stored := memoryToStored(existing)
stored.Revoked = true
if err := m.store.SaveAPIKey(context.Background(), stored); err != nil {
if !stderrors.Is(err, metadata.ErrNotImplemented) {
// Rollback: re-add to memory so subsequent validation still
// sees it (rather than silently leaving a window where the
// key works in-memory only on this replica).
m.mu.Lock()
m.keys[keyID] = existing
m.mu.Unlock()
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to persist revocation")
}
}
}
return nil return nil
} }
@@ -145,6 +312,19 @@ func (m *Manager) ListAPIKeys() []*APIKey {
return keys return keys
} }
// Close waits for any in-flight background writes (LastUsedAt updates) to
// finish. Safe to call multiple times.
func (m *Manager) Close() {
m.mu.Lock()
if m.closed {
m.mu.Unlock()
return
}
m.closed = true
m.mu.Unlock()
m.bgWG.Wait()
}
// HasPermission checks if an API key has a specific permission // HasPermission checks if an API key has a specific permission
func (k *APIKey) HasPermission(permission Permission) bool { func (k *APIKey) HasPermission(permission Permission) bool {
for _, p := range k.Permissions { for _, p := range k.Permissions {
@@ -185,9 +365,87 @@ func getPermissionsForRole(role Role) []Permission {
} }
} }
// generateID generates a unique ID // generateID generates a unique ID. Panics on crypto/rand failure: a
// zero-byte ID would be a security catastrophe (collisions, predictability).
func generateID() string { func generateID() string {
b := make([]byte, 16) b := make([]byte, 16)
_, _ = rand.Read(b) // #nosec G104 -- Rand read always succeeds if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("auth: crypto/rand read failed: %v", err))
}
return base64.URLEncoding.EncodeToString(b) return base64.URLEncoding.EncodeToString(b)
} }
// memoryToStored converts an in-memory APIKey to its metadata-layer form.
// Permissions are deliberately not encoded here: they are derived from Role
// at load time, keeping the persisted representation compact.
func memoryToStored(k *APIKey) *metadata.APIKey {
var lastUsed *time.Time
if !k.LastUsedAt.IsZero() {
t := k.LastUsedAt
lastUsed = &t
}
return &metadata.APIKey{
ID: k.ID,
KeyHash: k.HashedKey,
Project: k.Project,
Role: roleToStored(k.Role),
CreatedAt: k.CreatedAt,
ExpiresAt: k.ExpiresAt,
LastUsedAt: lastUsed,
Revoked: false,
}
}
// storedToMemory converts a metadata.APIKey to its runtime form. Permissions
// are derived from Role to keep storage minimal.
func storedToMemory(k *metadata.APIKey) *APIKey {
role := storedToRole(k.Role)
out := &APIKey{
ID: k.ID,
Name: "", // not persisted
HashedKey: k.KeyHash,
Role: role,
Project: k.Project,
CreatedAt: k.CreatedAt,
ExpiresAt: k.ExpiresAt,
Permissions: getPermissionsForRole(role),
}
if k.LastUsedAt != nil {
out.LastUsedAt = *k.LastUsedAt
}
return out
}
// roleToStored maps the in-memory Role enum to its persisted string form.
func roleToStored(r Role) string {
switch r {
case RoleReadOnly:
return storedRoleReadOnly
case RoleReadWrite:
return storedRoleReadWrite
case RoleAdmin:
return storedRoleAdmin
default:
return string(r)
}
}
// storedToRole inverts roleToStored. Unknown values fall back to RoleReadOnly
// (least-privilege) rather than failing — guards against schema drift.
func storedToRole(s string) Role {
switch s {
case storedRoleAdmin:
return RoleAdmin
case storedRoleReadWrite:
return RoleReadWrite
case storedRoleReadOnly:
return RoleReadOnly
}
// Tolerate legacy in-memory values that may have been persisted.
switch Role(s) {
case RoleAdmin, RoleReadWrite, RoleReadOnly:
return Role(s)
}
return RoleReadOnly
}
+372
View File
@@ -0,0 +1,372 @@
package auth
import (
"context"
stderrors "errors"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"golang.org/x/crypto/bcrypt"
)
// fakeStore is a minimal in-memory metadata.MetadataStore. Only the API key
// methods are exercised; everything else returns ErrNotImplemented to make
// accidental calls obvious.
type fakeStore struct {
keys map[string]*metadata.APIKey
saveErr error
listErr error
updateErr error
mu sync.Mutex
saveCalls int
updateCalls int
disabled bool // when true, every API key method returns ErrNotImplemented
}
func newFakeStore() *fakeStore {
return &fakeStore{keys: make(map[string]*metadata.APIKey)}
}
func (f *fakeStore) SaveAPIKey(ctx context.Context, key *metadata.APIKey) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.disabled {
return metadata.ErrNotImplemented
}
f.saveCalls++
if f.saveErr != nil {
return f.saveErr
}
cp := *key
f.keys[key.ID] = &cp
return nil
}
func (f *fakeStore) GetAPIKey(ctx context.Context, id string) (*metadata.APIKey, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.disabled {
return nil, metadata.ErrNotImplemented
}
k, ok := f.keys[id]
if !ok {
return nil, stderrors.New("not found")
}
cp := *k
return &cp, nil
}
func (f *fakeStore) ListAPIKeys(ctx context.Context) ([]*metadata.APIKey, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.disabled {
return nil, metadata.ErrNotImplemented
}
if f.listErr != nil {
return nil, f.listErr
}
out := make([]*metadata.APIKey, 0, len(f.keys))
for _, k := range f.keys {
cp := *k
out = append(out, &cp)
}
return out, nil
}
func (f *fakeStore) DeleteAPIKey(ctx context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.disabled {
return metadata.ErrNotImplemented
}
delete(f.keys, id)
return nil
}
func (f *fakeStore) UpdateAPIKeyLastUsed(ctx context.Context, id string, t time.Time) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.disabled {
return metadata.ErrNotImplemented
}
f.updateCalls++
if f.updateErr != nil {
return f.updateErr
}
if k, ok := f.keys[id]; ok {
tt := t
k.LastUsedAt = &tt
}
return nil
}
// All other methods are unreachable from auth.Manager — return ErrNotImplemented
// so accidental usage in future tests fails loudly.
func (f *fakeStore) SavePackage(context.Context, *metadata.Package) error {
return metadata.ErrNotImplemented
}
func (f *fakeStore) GetPackage(context.Context, string, string, string) (*metadata.Package, error) {
return nil, metadata.ErrNotImplemented
}
func (f *fakeStore) DeletePackage(context.Context, string, string, string) error {
return metadata.ErrNotImplemented
}
func (f *fakeStore) ListPackages(context.Context, *metadata.ListOptions) ([]*metadata.Package, error) {
return nil, metadata.ErrNotImplemented
}
func (f *fakeStore) UpdateDownloadCount(context.Context, string, string, string) error {
return metadata.ErrNotImplemented
}
func (f *fakeStore) GetStats(context.Context, string) (*metadata.Stats, error) {
return nil, metadata.ErrNotImplemented
}
func (f *fakeStore) SaveScanResult(context.Context, *metadata.ScanResult) error {
return metadata.ErrNotImplemented
}
func (f *fakeStore) GetScanResult(context.Context, string, string, string) (*metadata.ScanResult, error) {
return nil, metadata.ErrNotImplemented
}
func (f *fakeStore) SaveCVEBypass(context.Context, *metadata.CVEBypass) error {
return metadata.ErrNotImplemented
}
func (f *fakeStore) GetActiveCVEBypasses(context.Context) ([]*metadata.CVEBypass, error) {
return nil, metadata.ErrNotImplemented
}
func (f *fakeStore) ListCVEBypasses(context.Context, *metadata.BypassListOptions) ([]*metadata.CVEBypass, error) {
return nil, metadata.ErrNotImplemented
}
func (f *fakeStore) DeleteCVEBypass(context.Context, string) error { return metadata.ErrNotImplemented }
func (f *fakeStore) CleanupExpiredBypasses(context.Context) (int, error) {
return 0, metadata.ErrNotImplemented
}
func (f *fakeStore) Count(context.Context) (int, error) { return 0, metadata.ErrNotImplemented }
func (f *fakeStore) Health(context.Context) error { return metadata.ErrNotImplemented }
func (f *fakeStore) GetTimeSeriesStats(context.Context, string, string) (*metadata.TimeSeriesStats, error) {
return nil, metadata.ErrNotImplemented
}
func (f *fakeStore) AggregateDownloadData(context.Context) error { return metadata.ErrNotImplemented }
func (f *fakeStore) Close() error { return nil }
// fastCfg uses bcrypt.MinCost so tests that hash multiple keys stay snappy.
func fastCfg() Config { return Config{BcryptCost: bcrypt.MinCost} }
func TestManager_GenerateAPIKey_PersistsToStore(t *testing.T) {
store := newFakeStore()
m := NewWithStore(fastCfg(), store)
defer m.Close()
apiKey, raw, err := m.GenerateAPIKey("svc-token", RoleReadWrite, nil)
if err != nil {
t.Fatalf("GenerateAPIKey: %v", err)
}
if raw == "" {
t.Fatalf("raw key empty")
}
if apiKey.ID == "" {
t.Fatalf("apiKey.ID empty")
}
if store.saveCalls != 1 {
t.Errorf("saveCalls = %d, want 1", store.saveCalls)
}
persisted, err := store.GetAPIKey(context.Background(), apiKey.ID)
if err != nil {
t.Fatalf("GetAPIKey persisted: %v", err)
}
if persisted.Role != storedRoleReadWrite {
t.Errorf("persisted Role = %q, want %q", persisted.Role, storedRoleReadWrite)
}
if persisted.Revoked {
t.Errorf("persisted Revoked = true, want false")
}
}
func TestManager_GenerateAPIKey_StoreFailure_RollsBack(t *testing.T) {
store := newFakeStore()
store.saveErr = stderrors.New("disk full")
m := NewWithStore(fastCfg(), store)
defer m.Close()
_, _, err := m.GenerateAPIKey("svc", RoleReadOnly, nil)
if err == nil {
t.Fatalf("want error from store, got nil")
}
if got := len(m.ListAPIKeys()); got != 0 {
t.Errorf("in-memory keys = %d, want 0 after rollback", got)
}
}
func TestManager_RevokeAPIKey_PersistsRevocation(t *testing.T) {
store := newFakeStore()
m := NewWithStore(fastCfg(), store)
defer m.Close()
key, _, err := m.GenerateAPIKey("svc", RoleAdmin, nil)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if revokeErr := m.RevokeAPIKey(key.ID); revokeErr != nil {
t.Fatalf("Revoke: %v", revokeErr)
}
persisted, err := store.GetAPIKey(context.Background(), key.ID)
if err != nil {
t.Fatalf("GetAPIKey after revoke: %v", err)
}
if !persisted.Revoked {
t.Errorf("persisted Revoked = false, want true")
}
if got := len(m.ListAPIKeys()); got != 0 {
t.Errorf("in-memory keys after revoke = %d, want 0", got)
}
}
func TestManager_RevokeAPIKey_StoreFailure_KeepsInMemory(t *testing.T) {
store := newFakeStore()
m := NewWithStore(fastCfg(), store)
defer m.Close()
key, _, err := m.GenerateAPIKey("svc", RoleAdmin, nil)
if err != nil {
t.Fatalf("Generate: %v", err)
}
store.saveErr = stderrors.New("DB exploded")
if err := m.RevokeAPIKey(key.ID); err == nil {
t.Fatalf("want error from Revoke, got nil")
}
if got := len(m.ListAPIKeys()); got != 1 {
t.Errorf("in-memory keys = %d, want 1 (rollback)", got)
}
}
func TestManager_Load_HydratesFromStore(t *testing.T) {
store := newFakeStore()
// Pre-populate the store directly.
for _, tc := range []struct {
id string
role string
revoked bool
}{
{"a", storedRoleAdmin, false},
{"b", storedRoleReadOnly, false},
{"c", storedRoleReadWrite, true}, // revoked: should be skipped
} {
_ = store.SaveAPIKey(context.Background(), &metadata.APIKey{
ID: tc.id, KeyHash: "h", Role: tc.role, Revoked: tc.revoked, CreatedAt: time.Now(),
})
}
m := NewWithStore(fastCfg(), store)
defer m.Close()
if err := m.Load(context.Background()); err != nil {
t.Fatalf("Load: %v", err)
}
keys := m.ListAPIKeys()
if len(keys) != 2 {
t.Fatalf("loaded %d keys, want 2 (revoked excluded)", len(keys))
}
for _, k := range keys {
if k.Role != RoleAdmin && k.Role != RoleReadOnly {
t.Errorf("unexpected role %q", k.Role)
}
if len(k.Permissions) == 0 {
t.Errorf("permissions empty for role %q (should be derived)", k.Role)
}
}
}
func TestManager_Load_NilStore_NoOp(t *testing.T) {
m := NewWithStore(fastCfg(), nil)
defer m.Close()
if err := m.Load(context.Background()); err != nil {
t.Fatalf("Load with nil store: %v", err)
}
}
func TestManager_Load_NotImplemented_NoOp(t *testing.T) {
store := newFakeStore()
store.disabled = true
m := NewWithStore(fastCfg(), store)
defer m.Close()
if err := m.Load(context.Background()); err != nil {
t.Fatalf("Load with disabled store: %v", err)
}
}
func TestManager_ValidateAPIKey_AsyncLastUsed(t *testing.T) {
store := newFakeStore()
m := NewWithStore(fastCfg(), store)
defer m.Close()
_, raw, err := m.GenerateAPIKey("svc", RoleReadOnly, nil)
if err != nil {
t.Fatalf("Generate: %v", err)
}
if _, err := m.ValidateAPIKey(context.Background(), raw); err != nil {
t.Fatalf("Validate: %v", err)
}
// Wait for the fire-and-forget goroutine.
m.Close()
if store.updateCalls < 1 {
t.Errorf("UpdateAPIKeyLastUsed not called (calls=%d)", store.updateCalls)
}
}
func TestManager_ValidateAPIKey_RejectsUnknown(t *testing.T) {
m := NewWithStore(fastCfg(), nil)
defer m.Close()
if _, err := m.ValidateAPIKey(context.Background(), "no-such-key"); err == nil {
t.Fatalf("Validate(unknown): want error")
}
}
func TestManager_InMemoryMode_StillWorks(t *testing.T) {
// Back-compat: the legacy New() constructor must continue working
// without a store.
m := New()
defer m.Close()
apiKey, raw, err := m.GenerateAPIKey("legacy", RoleReadOnly, nil)
if err != nil {
t.Fatalf("Generate: %v", err)
}
got, err := m.ValidateAPIKey(context.Background(), raw)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if got.ID != apiKey.ID {
t.Errorf("validated wrong key: got %q want %q", got.ID, apiKey.ID)
}
}
func TestManager_RoleRoundTrip(t *testing.T) {
cases := []struct {
role Role
stored string
}{
{RoleReadOnly, storedRoleReadOnly},
{RoleReadWrite, storedRoleReadWrite},
{RoleAdmin, storedRoleAdmin},
}
for _, tc := range cases {
t.Run(string(tc.role), func(t *testing.T) {
if got := roleToStored(tc.role); got != tc.stored {
t.Errorf("roleToStored(%q) = %q, want %q", tc.role, got, tc.stored)
}
if got := storedToRole(tc.stored); got != tc.role {
t.Errorf("storedToRole(%q) = %q, want %q", tc.stored, got, tc.role)
}
})
}
if got := storedToRole("garbage"); got != RoleReadOnly {
t.Errorf("storedToRole(garbage) = %q, want least-privilege RoleReadOnly", got)
}
}
+99
View File
@@ -0,0 +1,99 @@
package auth
import (
"context"
stderrors "errors"
"os"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
// BootstrapAdminEnvVar is the env var consulted by BootstrapAdminFromEnv.
// Exported so callers and operators can reference a single source of truth.
const BootstrapAdminEnvVar = "GOHOARDER_BOOTSTRAP_ADMIN_KEY"
// bootstrapProject is the marker project assigned to the env-bootstrapped
// admin key. Callers can list keys filtered by this project to inventory
// bootstrap-origin credentials.
const bootstrapProject = "bootstrap"
// BootstrapAdminFromEnv creates an initial admin API key from the
// GOHOARDER_BOOTSTRAP_ADMIN_KEY environment variable when no admin key
// already exists in the store.
//
// Behaviour matrix:
// - env unset/empty -> no-op, nil
// - store nil -> no-op, nil (caller must provide a store)
// - store has any non-revoked
// admin key -> log and return nil (idempotent)
// - else -> bcrypt-hash env value, persist as admin
//
// The env var holds the RAW key the operator wants to use; we hash it with
// the configured bcrypt cost (default DefaultCost) before storing. The
// operator is expected to clear the env var after first successful boot.
func BootstrapAdminFromEnv(ctx context.Context, store metadata.MetadataStore, cfg Config) error {
rawKey := os.Getenv(BootstrapAdminEnvVar)
if rawKey == "" {
return nil
}
if store == nil {
log.Warn().Msg("Bootstrap admin key requested but no metadata store configured; skipping")
return nil
}
cost := cfg.BcryptCost
if cost == 0 {
cost = bcrypt.DefaultCost
}
// Idempotency: if any non-revoked admin already exists, skip.
existing, err := store.ListAPIKeys(ctx)
if err != nil {
if stderrors.Is(err, metadata.ErrNotImplemented) {
log.Warn().Msg("Bootstrap admin: store does not implement API key persistence; skipping")
return nil
}
return errors.Wrap(err, errors.ErrCodeStorageFailure, "bootstrap admin: failed to list existing keys")
}
for _, k := range existing {
if k == nil || k.Revoked {
continue
}
if k.Role == storedRoleAdmin {
log.Info().Msg("Bootstrap admin skipped — admin key already exists")
return nil
}
}
hashed, err := bcrypt.GenerateFromPassword([]byte(rawKey), cost)
if err != nil {
return errors.Wrap(err, errors.ErrCodeInternalServer, "bootstrap admin: failed to hash key")
}
now := time.Now()
key := &metadata.APIKey{
ID: generateID(),
KeyHash: string(hashed),
Project: bootstrapProject,
Role: storedRoleAdmin,
CreatedAt: now,
Revoked: false,
}
if err := store.SaveAPIKey(ctx, key); err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "bootstrap admin: failed to save key")
}
// SECURITY: never log the raw key or its hash.
log.Info().
Str("key_id", key.ID).
Str("project", bootstrapProject).
Msg("Bootstrap admin created — delete " + BootstrapAdminEnvVar + " after first run")
return nil
}
+142
View File
@@ -0,0 +1,142 @@
package auth
import (
"context"
"testing"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"golang.org/x/crypto/bcrypt"
)
func TestBootstrapAdminFromEnv_EnvEmpty_NoOp(t *testing.T) {
t.Setenv(BootstrapAdminEnvVar, "")
store := newFakeStore()
if err := BootstrapAdminFromEnv(context.Background(), store, fastCfg()); err != nil {
t.Fatalf("BootstrapAdminFromEnv: %v", err)
}
if got := len(store.keys); got != 0 {
t.Errorf("store has %d keys, want 0 when env empty", got)
}
}
func TestBootstrapAdminFromEnv_NilStore_NoOp(t *testing.T) {
t.Setenv(BootstrapAdminEnvVar, "secret")
if err := BootstrapAdminFromEnv(context.Background(), nil, fastCfg()); err != nil {
t.Fatalf("BootstrapAdminFromEnv(nil store): %v", err)
}
}
func TestBootstrapAdminFromEnv_FreshStore_CreatesAdmin(t *testing.T) {
const raw = "super-secret-bootstrap-key"
t.Setenv(BootstrapAdminEnvVar, raw)
store := newFakeStore()
if err := BootstrapAdminFromEnv(context.Background(), store, fastCfg()); err != nil {
t.Fatalf("BootstrapAdminFromEnv: %v", err)
}
if got := len(store.keys); got != 1 {
t.Fatalf("store has %d keys, want 1", got)
}
var created *metadata.APIKey
for _, k := range store.keys {
created = k
break
}
if created.Role != storedRoleAdmin {
t.Errorf("Role = %q, want %q", created.Role, storedRoleAdmin)
}
if created.Project != bootstrapProject {
t.Errorf("Project = %q, want %q", created.Project, bootstrapProject)
}
if created.Revoked {
t.Errorf("Revoked = true, want false")
}
if created.ID == "" {
t.Errorf("ID empty")
}
// Hash must verify against the raw env value.
if err := bcrypt.CompareHashAndPassword([]byte(created.KeyHash), []byte(raw)); err != nil {
t.Errorf("bcrypt mismatch: %v", err)
}
// Hash must NOT equal the raw key (would indicate plaintext storage).
if created.KeyHash == raw {
t.Errorf("KeyHash equals raw key — plaintext storage bug")
}
}
func TestBootstrapAdminFromEnv_AdminAlreadyExists_Idempotent(t *testing.T) {
t.Setenv(BootstrapAdminEnvVar, "another-secret")
store := newFakeStore()
// Pre-existing admin.
_ = store.SaveAPIKey(context.Background(), &metadata.APIKey{
ID: "existing-admin",
KeyHash: "$2a$04$dummy",
Role: storedRoleAdmin,
Project: "default",
CreatedAt: time.Now(),
})
if err := BootstrapAdminFromEnv(context.Background(), store, fastCfg()); err != nil {
t.Fatalf("BootstrapAdminFromEnv: %v", err)
}
if got := len(store.keys); got != 1 {
t.Errorf("store has %d keys, want 1 (no new admin created)", got)
}
if _, ok := store.keys["existing-admin"]; !ok {
t.Errorf("existing admin was removed/overwritten")
}
}
func TestBootstrapAdminFromEnv_RevokedAdmin_StillCreatesNewOne(t *testing.T) {
t.Setenv(BootstrapAdminEnvVar, "fresh-secret")
store := newFakeStore()
// Revoked admin should NOT count as "admin already exists".
_ = store.SaveAPIKey(context.Background(), &metadata.APIKey{
ID: "old-admin",
KeyHash: "x",
Role: storedRoleAdmin,
Project: "bootstrap",
Revoked: true,
CreatedAt: time.Now(),
})
if err := BootstrapAdminFromEnv(context.Background(), store, fastCfg()); err != nil {
t.Fatalf("BootstrapAdminFromEnv: %v", err)
}
if got := len(store.keys); got != 2 {
t.Errorf("store has %d keys, want 2 (revoked + fresh)", got)
}
}
func TestBootstrapAdminFromEnv_NonAdminKeysIgnored(t *testing.T) {
t.Setenv(BootstrapAdminEnvVar, "secret-x")
store := newFakeStore()
_ = store.SaveAPIKey(context.Background(), &metadata.APIKey{
ID: "reader",
KeyHash: "x",
Role: storedRoleReadOnly,
Project: "default",
CreatedAt: time.Now(),
})
if err := BootstrapAdminFromEnv(context.Background(), store, fastCfg()); err != nil {
t.Fatalf("BootstrapAdminFromEnv: %v", err)
}
if got := len(store.keys); got != 2 {
t.Errorf("store has %d keys, want 2 (read-only existing + new admin)", got)
}
}
func TestBootstrapAdminFromEnv_StoreNotImplemented_NoOp(t *testing.T) {
t.Setenv(BootstrapAdminEnvVar, "secret-y")
store := newFakeStore()
store.disabled = true
if err := BootstrapAdminFromEnv(context.Background(), store, fastCfg()); err != nil {
t.Fatalf("BootstrapAdminFromEnv: %v", err)
}
}
+6 -4
View File
@@ -14,16 +14,18 @@ func NewCredentialHasher() *CredentialHasher {
return &CredentialHasher{} return &CredentialHasher{}
} }
// Hash generates a short hash of credentials for use in cache keys // Hash generates a hash of credentials for use in cache keys.
// Returns "public" if no credentials provided // Returns "public" if no credentials provided.
// Returns the full 64-char SHA256 hex digest otherwise: truncating to 8 bytes
// gives a ~2^32 birthday bound which is unsafe for a security-sensitive
// cache key (cross-credential cache poisoning).
func (h *CredentialHasher) Hash(credentials string) string { func (h *CredentialHasher) Hash(credentials string) string {
if credentials == "" { if credentials == "" {
return "public" return "public"
} }
// Use SHA256 and take first 16 characters (8 bytes)
hash := sha256.Sum256([]byte(credentials)) hash := sha256.Sum256([]byte(credentials))
return hex.EncodeToString(hash[:8]) return hex.EncodeToString(hash[:])
} }
// GenerateCacheKey generates a cache key that includes credential hash // GenerateCacheKey generates a cache key that includes credential hash
+74 -34
View File
@@ -12,53 +12,79 @@ type ValidationResult struct {
Allowed bool Allowed bool
} }
// ValidationCache caches credential validation results to reduce upstream checks // ValidationCache caches credential validation results to reduce upstream checks.
//
// Scope: each instance is owned by exactly one proxy handler (goproxy, npm,
// or pypi) and caches only that registry's credential checks. Because the
// instances are never shared across handlers, the second key component may be
// whatever uniquely identifies the resource for that registry: goproxy passes
// a module path, npm and pypi pass a URL. There is no cross-registry key
// collision even though the resource shapes differ, because each handler has
// its own map. Callers must pass the same resource value to every Get/Set for
// a given resource so the cache miss wrapper (Get then Set) is internally
// consistent.
type ValidationCache struct { type ValidationCache struct {
cache map[string]*ValidationResult cache map[string]*ValidationResult
mu sync.RWMutex stopCh chan struct{}
ttl time.Duration stopOnce sync.Once
mu sync.RWMutex
ttl time.Duration
} }
// NewValidationCache creates a new validation cache // NewValidationCache creates a new validation cache. Callers must call Stop()
// during shutdown to terminate the background cleanup goroutine.
func NewValidationCache(ttl time.Duration) *ValidationCache { func NewValidationCache(ttl time.Duration) *ValidationCache {
vc := &ValidationCache{ vc := &ValidationCache{
cache: make(map[string]*ValidationResult), cache: make(map[string]*ValidationResult),
ttl: ttl, stopCh: make(chan struct{}),
ttl: ttl,
} }
// Start cleanup goroutine // Start cleanup goroutine
go vc.cleanupExpired() go vc.cleanupExpired()
return vc return vc
} }
// Get retrieves a validation result from cache // Get retrieves a validation result from cache.
// Returns (allowed bool, cached bool, reason string) // resource names the registry resource the check was for (a module path for
func (vc *ValidationCache) Get(credHash, packageURL string) (bool, bool, string) { // goproxy, or a URL for npm/pypi). It is only meaningful within this cache
// instance. Returns (allowed bool, cached bool, reason string).
func (vc *ValidationCache) Get(credHash, resource string) (bool, bool, string) {
key := credHash + ":" + resource
// Fast path: read-locked lookup.
vc.mu.RLock() vc.mu.RLock()
defer vc.mu.RUnlock()
key := credHash + ":" + packageURL
result, exists := vc.cache[key] result, exists := vc.cache[key]
if !exists { if !exists {
vc.mu.RUnlock()
return false, false, "" return false, false, ""
} }
expired := time.Now().After(result.ExpiresAt)
// Check if expired if !expired {
if time.Now().After(result.ExpiresAt) { allowed, reason := result.Allowed, result.Reason
return false, false, "" vc.mu.RUnlock()
return allowed, true, reason
} }
vc.mu.RUnlock()
return result.Allowed, true, result.Reason // Expired: drop it under the write lock so callers don't all stampede
// the upstream while waiting for the periodic cleanup goroutine.
vc.mu.Lock()
if cur, ok := vc.cache[key]; ok && time.Now().After(cur.ExpiresAt) {
delete(vc.cache, key)
}
vc.mu.Unlock()
return false, false, ""
} }
// Set stores a validation result in cache // Set stores a validation result in cache.
func (vc *ValidationCache) Set(credHash, packageURL string, allowed bool, reason string) { // resource is the registry resource the result belongs to; it must match the
// resource value passed to the corresponding Get.
func (vc *ValidationCache) Set(credHash, resource string, allowed bool, reason string) {
vc.mu.Lock() vc.mu.Lock()
defer vc.mu.Unlock() defer vc.mu.Unlock()
key := credHash + ":" + packageURL key := credHash + ":" + resource
vc.cache[key] = &ValidationResult{ vc.cache[key] = &ValidationResult{
Allowed: allowed, Allowed: allowed,
ExpiresAt: time.Now().Add(vc.ttl), ExpiresAt: time.Now().Add(vc.ttl),
@@ -66,12 +92,12 @@ func (vc *ValidationCache) Set(credHash, packageURL string, allowed bool, reason
} }
} }
// Invalidate removes a specific entry from cache // Invalidate removes a specific entry from cache.
func (vc *ValidationCache) Invalidate(credHash, packageURL string) { func (vc *ValidationCache) Invalidate(credHash, resource string) {
vc.mu.Lock() vc.mu.Lock()
defer vc.mu.Unlock() defer vc.mu.Unlock()
key := credHash + ":" + packageURL key := credHash + ":" + resource
delete(vc.cache, key) delete(vc.cache, key)
} }
@@ -91,19 +117,33 @@ func (vc *ValidationCache) Size() int {
return len(vc.cache) return len(vc.cache)
} }
// cleanupExpired removes expired entries periodically // Stop terminates the background cleanup goroutine. Safe to call multiple
// times; subsequent calls are no-ops.
func (vc *ValidationCache) Stop() {
vc.stopOnce.Do(func() {
close(vc.stopCh)
})
}
// cleanupExpired removes expired entries periodically. Exits when Stop is
// called.
func (vc *ValidationCache) cleanupExpired() { func (vc *ValidationCache) cleanupExpired() {
ticker := time.NewTicker(1 * time.Minute) ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for {
vc.mu.Lock() select {
now := time.Now() case <-vc.stopCh:
for key, result := range vc.cache { return
if now.After(result.ExpiresAt) { case <-ticker.C:
delete(vc.cache, key) vc.mu.Lock()
now := time.Now()
for key, result := range vc.cache {
if now.After(result.ExpiresAt) {
delete(vc.cache, key)
}
} }
vc.mu.Unlock()
} }
vc.mu.Unlock()
} }
} }
+60 -27
View File
@@ -18,6 +18,9 @@ type CredentialValidator interface {
// ValidateAccess checks if credentials grant access to a package // ValidateAccess checks if credentials grant access to a package
// Returns (allowed bool, error) // Returns (allowed bool, error)
ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error)
// Provider returns the upstream auth provider identifier stored on
// packages that require auth (e.g. github, gitlab, npm, pypi).
Provider() string
} }
// NPMValidator validates npm registry credentials // NPMValidator validates npm registry credentials
@@ -36,6 +39,11 @@ func NewNPMValidator() *NPMValidator {
} }
} }
// Provider identifies this validator's upstream provider.
func (v *NPMValidator) Provider() string {
return "npm"
}
// ValidateAccess validates npm package access using HEAD request // ValidateAccess validates npm package access using HEAD request
func (v *NPMValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) { func (v *NPMValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil) req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil)
@@ -50,11 +58,11 @@ func (v *NPMValidator) ValidateAccess(ctx context.Context, packageURL string, cr
resp, err := v.client.Do(req) resp, err := v.client.Do(req)
if err != nil { if err != nil {
// Network error - allow cache fallback with warning // Network error - fail closed. Caller decides any fallback policy.
log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, allowing cache fallback") log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, denying access")
return true, fmt.Errorf("validation failed: %w (allowing cache fallback)", err) return false, fmt.Errorf("validation failed: %w", err)
} }
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Check status code // Check status code
switch resp.StatusCode { switch resp.StatusCode {
@@ -65,9 +73,9 @@ func (v *NPMValidator) ValidateAccess(ctx context.Context, packageURL string, cr
// Access denied // Access denied
return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode) return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode)
default: default:
// Unexpected status - allow cache fallback with warning // Unexpected status - fail closed.
log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, allowing cache fallback") log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, denying access")
return true, fmt.Errorf("unexpected status %d (allowing cache fallback)", resp.StatusCode) return false, fmt.Errorf("unexpected status %d", resp.StatusCode)
} }
} }
@@ -87,6 +95,11 @@ func NewPyPIValidator() *PyPIValidator {
} }
} }
// Provider identifies this validator's upstream provider.
func (v *PyPIValidator) Provider() string {
return "pypi"
}
// ValidateAccess validates PyPI package access using HEAD request // ValidateAccess validates PyPI package access using HEAD request
func (v *PyPIValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) { func (v *PyPIValidator) ValidateAccess(ctx context.Context, packageURL string, credentials string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil) req, err := http.NewRequestWithContext(ctx, "HEAD", packageURL, nil)
@@ -101,11 +114,11 @@ func (v *PyPIValidator) ValidateAccess(ctx context.Context, packageURL string, c
resp, err := v.client.Do(req) resp, err := v.client.Do(req)
if err != nil { if err != nil {
// Network error - allow cache fallback with warning // Network error - fail closed. Caller decides any fallback policy.
log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, allowing cache fallback") log.Warn().Err(err).Str("url", packageURL).Msg("Validation request failed, denying access")
return true, fmt.Errorf("validation failed: %w (allowing cache fallback)", err) return false, fmt.Errorf("validation failed: %w", err)
} }
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Check status code // Check status code
switch resp.StatusCode { switch resp.StatusCode {
@@ -116,9 +129,9 @@ func (v *PyPIValidator) ValidateAccess(ctx context.Context, packageURL string, c
// Access denied // Access denied
return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode) return false, fmt.Errorf("access denied: HTTP %d", resp.StatusCode)
default: default:
// Unexpected status - allow cache fallback with warning // Unexpected status - fail closed.
log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, allowing cache fallback") log.Warn().Int("status", resp.StatusCode).Str("url", packageURL).Msg("Unexpected validation status, denying access")
return true, fmt.Errorf("unexpected status %d (allowing cache fallback)", resp.StatusCode) return false, fmt.Errorf("unexpected status %d", resp.StatusCode)
} }
} }
@@ -134,6 +147,26 @@ func NewGoValidator() *GoValidator {
} }
} }
// Provider returns the base upstream provider identifier used when the
// registry does not distinguish hosts (e.g. a generic git host).
func (v *GoValidator) Provider() string {
return "git"
}
// ProviderFor derives the specific provider identifier from a module path.
// Mirrors the host branching in ValidateAccess so the stored AuthProvider
// reflects the actual upstream host (github, gitlab, or a generic git host).
func (v *GoValidator) ProviderFor(modulePath string) string {
switch {
case strings.HasPrefix(modulePath, "github.com/"):
return "github"
case strings.HasPrefix(modulePath, "gitlab.com/"):
return "gitlab"
default:
return "git"
}
}
// ValidateAccess validates Go module access using git ls-remote // ValidateAccess validates Go module access using git ls-remote
func (v *GoValidator) ValidateAccess(ctx context.Context, modulePath string, credentials string) (bool, error) { func (v *GoValidator) ValidateAccess(ctx context.Context, modulePath string, credentials string) (bool, error) {
// Create context with timeout // Create context with timeout
@@ -171,13 +204,13 @@ func (v *GoValidator) validateGitHub(ctx context.Context, modulePath, credential
if err != nil { if err != nil {
return false, err return false, err
} }
defer os.RemoveAll(tempDir) defer func() { _ = os.RemoveAll(tempDir) }()
// Create .netrc file with credentials // Create .netrc file with credentials
netrcPath := filepath.Join(tempDir, ".netrc") netrcPath := filepath.Join(tempDir, ".netrc")
netrcContent := fmt.Sprintf("machine github.com\nlogin oauth2\npassword %s\n", token) netrcContent := fmt.Sprintf("machine github.com\nlogin oauth2\npassword %s\n", token)
if err := os.WriteFile(netrcPath, []byte(netrcContent), 0600); err != nil { if writeErr := os.WriteFile(netrcPath, []byte(netrcContent), 0600); writeErr != nil {
return false, err return false, writeErr
} }
// Run git ls-remote (lightweight, just checks access) // Run git ls-remote (lightweight, just checks access)
@@ -199,9 +232,9 @@ func (v *GoValidator) validateGitHub(ctx context.Context, modulePath, credential
return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg)) return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg))
} }
// Other error (network, etc.) - allow cache fallback // Other error (network, etc.) - fail closed.
log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, allowing cache fallback") log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, denying access")
return true, fmt.Errorf("validation error (allowing cache): %w", err) return false, fmt.Errorf("validation error: %w", err)
} }
// Success - repository accessible // Success - repository accessible
@@ -227,13 +260,13 @@ func (v *GoValidator) validateGitLab(ctx context.Context, modulePath, credential
if err != nil { if err != nil {
return false, err return false, err
} }
defer os.RemoveAll(tempDir) defer func() { _ = os.RemoveAll(tempDir) }()
// Create .netrc file with credentials // Create .netrc file with credentials
netrcPath := filepath.Join(tempDir, ".netrc") netrcPath := filepath.Join(tempDir, ".netrc")
netrcContent := fmt.Sprintf("machine gitlab.com\nlogin oauth2\npassword %s\n", token) netrcContent := fmt.Sprintf("machine gitlab.com\nlogin oauth2\npassword %s\n", token)
if err := os.WriteFile(netrcPath, []byte(netrcContent), 0600); err != nil { if writeErr := os.WriteFile(netrcPath, []byte(netrcContent), 0600); writeErr != nil {
return false, err return false, writeErr
} }
// Run git ls-remote // Run git ls-remote
@@ -252,8 +285,8 @@ func (v *GoValidator) validateGitLab(ctx context.Context, modulePath, credential
return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg)) return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg))
} }
log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, allowing cache fallback") log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, denying access")
return true, fmt.Errorf("validation error (allowing cache): %w", err) return false, fmt.Errorf("validation error: %w", err)
} }
return true, nil return true, nil
@@ -276,8 +309,8 @@ func (v *GoValidator) validateGit(ctx context.Context, modulePath, credentials s
return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg)) return false, fmt.Errorf("access denied: %s", strings.TrimSpace(errMsg))
} }
log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, allowing cache fallback") log.Warn().Err(err).Str("module", modulePath).Msg("Git validation failed, denying access")
return true, fmt.Errorf("validation error (allowing cache): %w", err) return false, fmt.Errorf("validation error: %w", err)
} }
return true, nil return true, nil
+269 -104
View File
@@ -1,22 +1,25 @@
// Package cache implements the unified cache manager that coordinates
// metadata, storage, scanning, and singleflight for upstream packages.
package cache package cache
import ( import (
"bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "strings"
"sync" "sync"
"time" "time"
"github.com/lukaszraczylo/gohoarder/pkg/analytics" "github.com/lukaszraczylo/gohoarder/pkg/analytics"
"github.com/lukaszraczylo/gohoarder/pkg/errors" "github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/events"
"github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/metrics" "github.com/lukaszraczylo/gohoarder/pkg/metrics"
"github.com/lukaszraczylo/gohoarder/pkg/storage" "github.com/lukaszraczylo/gohoarder/pkg/storage"
"github.com/lukaszraczylo/gohoarder/pkg/uuid" "github.com/lukaszraczylo/gohoarder/pkg/uuid"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
) )
@@ -35,14 +38,43 @@ type AnalyticsInterface interface {
// Manager coordinates caching operations between storage and metadata // Manager coordinates caching operations between storage and metadata
type Manager struct { type Manager struct {
storage storage.StorageBackend storage storage.StorageBackend
metadata metadata.MetadataStore metadata metadata.MetadataStore
scanner ScannerInterface scanner ScannerInterface
analytics AnalyticsInterface analytics AnalyticsInterface
sf singleflight.Group broadcaster events.Broadcaster
config Config stopCh chan struct{}
mu sync.RWMutex sf singleflight.Group
evicting bool config Config
cleanupWG sync.WaitGroup
mu sync.RWMutex
bcMu sync.RWMutex
closeOnce sync.Once
evicting bool
}
// SetBroadcaster wires an events.Broadcaster onto the manager so cache
// lifecycle events (cached / downloaded) are published. Pass nil to
// disable broadcasting. Safe to call after construction; concurrent
// access is guarded.
func (m *Manager) SetBroadcaster(b events.Broadcaster) {
m.bcMu.Lock()
m.broadcaster = b
m.bcMu.Unlock()
}
// emit publishes an event via the configured broadcaster, if any.
// Fire-and-forget: never blocks the caller. The websocket server's
// BroadcastEvent is itself non-blocking (channel-drop on overflow),
// so we call directly rather than spawning a goroutine.
func (m *Manager) emit(eventType string, payload map[string]interface{}) {
m.bcMu.RLock()
b := m.broadcaster
m.bcMu.RUnlock()
if b == nil {
return
}
b.BroadcastEvent(eventType, payload)
} }
// Config holds cache manager configuration // Config holds cache manager configuration
@@ -51,6 +83,7 @@ type Config struct {
CleanupInterval time.Duration // How often to run cleanup CleanupInterval time.Duration // How often to run cleanup
EvictionThreshold float64 // Trigger eviction when usage > threshold (0.0-1.0) EvictionThreshold float64 // Trigger eviction when usage > threshold (0.0-1.0)
MaxConcurrent int // Max concurrent upstream fetches MaxConcurrent int // Max concurrent upstream fetches
MaxPackageSize int64 // Maximum package size in bytes (0 = default 2GB)
} }
// CacheEntry represents a cached package // CacheEntry represents a cached package
@@ -62,6 +95,27 @@ type CacheEntry struct {
FromCache bool FromCache bool
} }
// FetchResult carries the outcome of an upstream fetch closure. In addition
// to the response body, it communicates auth state so the cache manager can
// persist RequiresAuth/AuthProvider on the stored package. The downstream
// security gate reads entry.Package.RequiresAuth, so this state must be stored
// in the database (not just set on the ephemeral cache entry).
type FetchResult struct {
// Data is the response body. Caller closes it (or Get closes it after store).
Data io.ReadCloser
// UpstreamURL is the upstream URL the body came from.
UpstreamURL string
// AuthProvider identifies the auth system (e.g. github, gitlab, custom).
AuthProvider string
// RequiresAuth is true when the upstream indicated the package is behind
// authentication (e.g. HTTP 401/403, or a private registry that demanded
// credentials).
RequiresAuth bool
}
// FetchFunc fetches a package from upstream and reports auth state.
type FetchFunc func(context.Context) (*FetchResult, error)
// New creates a new cache manager // New creates a new cache manager
func New(storage storage.StorageBackend, metadata metadata.MetadataStore, scanner ScannerInterface, analytics AnalyticsInterface, config Config) (*Manager, error) { func New(storage storage.StorageBackend, metadata metadata.MetadataStore, scanner ScannerInterface, analytics AnalyticsInterface, config Config) (*Manager, error) {
if storage == nil { if storage == nil {
@@ -98,26 +152,35 @@ func New(storage storage.StorageBackend, metadata metadata.MetadataStore, scanne
config.MaxConcurrent = 100 config.MaxConcurrent = 100
} }
if config.MaxPackageSize == 0 {
config.MaxPackageSize = 2 * 1024 * 1024 * 1024 // 2GB default
}
manager := &Manager{ manager := &Manager{
storage: storage, storage: storage,
metadata: metadata, metadata: metadata,
scanner: scanner, scanner: scanner,
analytics: analytics, analytics: analytics,
config: config, config: config,
stopCh: make(chan struct{}),
} }
// Start background cleanup worker // Start background cleanup worker
manager.cleanupWG.Add(1)
go manager.cleanupWorker() go manager.cleanupWorker()
return manager, nil return manager, nil
} }
// Get retrieves a package from cache or upstream func (m *Manager) Get(ctx context.Context, registry, name, version string, fetchFunc FetchFunc) (*CacheEntry, error) {
func (m *Manager) Get(ctx context.Context, registry, name, version string, fetchFunc func(context.Context) (io.ReadCloser, string, error)) (*CacheEntry, error) {
// Use singleflight to deduplicate concurrent requests // Use singleflight to deduplicate concurrent requests
key := fmt.Sprintf("%s/%s/%s", registry, name, version) key := fmt.Sprintf("%s/%s/%s", registry, name, version)
result, err, _ := m.sf.Do(key, func() (interface{}, error) { result, err, _ := m.sf.Do(key, func() (interface{}, error) {
// getOrFetch returns a CacheEntry with Data == nil. Each caller
// re-opens its own storage reader below to avoid sharing a
// single io.ReadCloser across concurrent waiters.
return m.getOrFetch(ctx, registry, name, version, fetchFunc) return m.getOrFetch(ctx, registry, name, version, fetchFunc)
}) })
@@ -125,11 +188,29 @@ func (m *Manager) Get(ctx context.Context, registry, name, version string, fetch
return nil, err return nil, err
} }
return result.(*CacheEntry), nil entry := result.(*CacheEntry)
if entry == nil || entry.Package == nil {
return nil, errors.New(errors.ErrCodeStorageFailure, "cache entry missing package metadata")
}
// Open a fresh ReadCloser per caller from storage.
data, err := m.storage.Get(ctx, entry.Package.StorageKey)
if err != nil {
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to retrieve cached package")
}
// Return a copy so concurrent waiters don't share the Data field.
return &CacheEntry{
Data: data,
Package: entry.Package,
UpstreamURL: entry.UpstreamURL,
CacheControl: entry.CacheControl,
FromCache: entry.FromCache,
}, nil
} }
// getOrFetch implements the actual get-or-fetch logic // getOrFetch implements the actual get-or-fetch logic
func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string, fetchFunc func(context.Context) (io.ReadCloser, string, error)) (*CacheEntry, error) { func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string, fetchFunc FetchFunc) (*CacheEntry, error) {
// Check metadata first // Check metadata first
pkg, err := m.metadata.GetPackage(ctx, registry, name, version) pkg, err := m.metadata.GetPackage(ctx, registry, name, version)
if err == nil { if err == nil {
@@ -140,16 +221,20 @@ func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string
// Delete expired package // Delete expired package
_ = m.deletePackage(ctx, pkg) // #nosec G104 -- Async cleanup _ = m.deletePackage(ctx, pkg) // #nosec G104 -- Async cleanup
} else { } else {
// Try to get from storage // Probe storage by opening then immediately closing the reader.
data, err := m.storage.Get(ctx, pkg.StorageKey) // Singleflight callers can't share a live ReadCloser; each caller in
if err == nil { // Get() opens its own reader after the singleflight returns.
data, getErr := m.storage.Get(ctx, pkg.StorageKey)
if getErr == nil {
_ = data.Close() // #nosec G104 -- probe only; Get() reopens per caller
// Cache hit! // Cache hit!
metrics.RecordCacheHit(registry) metrics.RecordCacheHit(registry)
// Update download count (log errors for debugging) // Update download count (log errors for debugging)
if err := m.metadata.UpdateDownloadCount(ctx, registry, name, version); err != nil { if updErr := m.metadata.UpdateDownloadCount(ctx, registry, name, version); updErr != nil {
log.Warn(). log.Warn().
Err(err). Err(updErr).
Str("registry", registry). Str("registry", registry).
Str("package", name). Str("package", name).
Str("version", version). Str("version", version).
@@ -182,22 +267,37 @@ func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string
m.trackDownload(registry, name, version, pkg.Size) m.trackDownload(registry, name, version, pkg.Size)
} }
// Check for vulnerabilities if scanner is enabled // Check for vulnerabilities if scanner is enabled. Metadata
if m.scanner != nil { // entries (index pages, lists, .mod/.info) are not scannable
blocked, reason, err := m.scanner.CheckVulnerabilities(ctx, registry, name, version) // packages, so skip them to avoid a bogus "clean" verdict and
if err != nil { // the extra database round-trip.
log.Warn().Err(err).Str("package", name).Msg("Failed to check vulnerabilities") isMetadataEntry := version == "list" || version == "page" || version == "latest" || version == "metadata" ||
strings.HasSuffix(name, ".mod") || strings.HasSuffix(name, ".info")
if m.scanner != nil && !isMetadataEntry {
blocked, reason, vulnErr := m.scanner.CheckVulnerabilities(ctx, registry, name, version)
if vulnErr != nil {
log.Warn().Err(vulnErr).Str("package", name).Msg("Failed to check vulnerabilities")
} }
if blocked { if blocked {
metrics.RecordCacheHit(registry) // Record as blocked
_ = data.Close() // #nosec G104 // Close the data reader
return nil, errors.New(errors.ErrCodeSecurityViolation, reason) return nil, errors.New(errors.ErrCodeSecurityViolation, reason)
} }
} }
// Broadcast cache-hit serve event. Fire-and-forget; the
// underlying transport is non-blocking. Only emitted on
// the cache-hit path — the miss-then-fetch path is
// covered by EventPackageCached emitted from store().
m.emit(string(websocket.EventPackageDownloaded), map[string]interface{}{
"registry": registry,
"name": name,
"version": version,
"cache_hit": true,
})
// Data is intentionally nil; Get() opens a fresh reader per caller.
return &CacheEntry{ return &CacheEntry{
Package: pkg, Package: pkg,
Data: data, Data: nil,
FromCache: true, FromCache: true,
}, nil }, nil
} }
@@ -218,26 +318,34 @@ func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string
log.Debug().Str("package", name).Str("version", version).Msg("Fetching from upstream") log.Debug().Str("package", name).Str("version", version).Msg("Fetching from upstream")
// Fetch from upstream // Fetch from upstream
data, upstreamURL, err := fetchFunc(ctx) fr, err := fetchFunc(ctx)
if err != nil { if err != nil {
metrics.RecordUpstreamRequest(registry, "error") metrics.RecordUpstreamRequest(registry, "error")
return nil, errors.Wrap(err, errors.ErrCodeUpstreamFailure, "failed to fetch from upstream") return nil, errors.Wrap(err, errors.ErrCodeUpstreamFailure, "failed to fetch from upstream")
} }
defer data.Close() // #nosec G104 -- Cleanup, error not critical if fr == nil || fr.Data == nil {
return nil, errors.New(errors.ErrCodeUpstreamFailure, "upstream fetch returned no data")
}
data := fr.Data
defer func() { _ = data.Close() }() // #nosec G104 -- Cleanup, error not critical
metrics.RecordUpstreamRequest(registry, "success") metrics.RecordUpstreamRequest(registry, "success")
// Store in cache (this will also trigger background scan) // Store in cache (this will also trigger background scan). Auth state is
storedPkg, err := m.store(ctx, registry, name, version, data, upstreamURL) // plumbed through so the persisted package carries RequiresAuth.
storedPkg, err := m.store(ctx, registry, name, version, data, fr.UpstreamURL, fr.RequiresAuth, fr.AuthProvider)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Skip security scan wait for metadata entries (index pages, lists, etc.) // Skip security scan wait for metadata entries (index pages, lists, etc.)
isMetadataEntry := version == "list" || version == "page" || version == "latest" || version == "metadata" // Also skip Go module metadata files (.mod, .info)
isMetadataEntry := version == "list" || version == "page" || version == "latest" || version == "metadata" ||
strings.HasSuffix(name, ".mod") || strings.HasSuffix(name, ".info")
// Wait briefly for initial scan to complete if scanner is enabled // Wait briefly for initial scan to complete if scanner is enabled
// This prevents serving vulnerable packages on first request // This prevents serving vulnerable packages on first request.
// SECURITY: timeouts MUST fail closed — never serve unscanned content.
if m.scanner != nil && !isMetadataEntry { if m.scanner != nil && !isMetadataEntry {
// Wait up to 30 seconds for scan to complete // Wait up to 30 seconds for scan to complete
scanCtx, cancel := context.WithTimeout(ctx, 30*time.Second) scanCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
@@ -246,16 +354,18 @@ func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string
ticker := time.NewTicker(100 * time.Millisecond) ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
scanWait:
for { for {
select { select {
case <-scanCtx.Done(): case <-scanCtx.Done():
// Timeout or context cancelled - proceed anyway // Fail closed: do NOT serve unscanned packages on timeout/cancel.
// Package is cached, will be blocked on next request if vulnerable // Package remains cached; subsequent requests can retry once
// the scan completes.
log.Warn(). log.Warn().
Str("package", name). Str("package", name).
Str("version", version). Str("version", version).
Msg("Scan timeout - allowing first download, will block on subsequent requests if vulnerable") Msg("Scan timeout - refusing to serve unscanned package (fail-closed)")
goto servePkg return nil, errors.New(errors.ErrCodeServiceUnavailable, "package scan in progress, retry shortly")
case <-ticker.C: case <-ticker.C:
// First check if scan has completed by checking the SecurityScanned flag // First check if scan has completed by checking the SecurityScanned flag
@@ -308,18 +418,11 @@ func (m *Manager) getOrFetch(ctx context.Context, registry, name, version string
Str("package", name). Str("package", name).
Str("version", version). Str("version", version).
Msg("Scan completed, package is clean") Msg("Scan completed, package is clean")
goto servePkg break scanWait
} }
} }
} }
servePkg:
// Re-open from storage for consistency
storedData, err := m.storage.Get(ctx, storedPkg.StorageKey)
if err != nil {
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to retrieve just-stored package")
}
// Track download count for first-time download (cache miss) // Track download count for first-time download (cache miss)
// This ensures download count increments regardless of cache hit/miss // This ensures download count increments regardless of cache hit/miss
if err := m.metadata.UpdateDownloadCount(ctx, registry, name, version); err != nil { if err := m.metadata.UpdateDownloadCount(ctx, registry, name, version); err != nil {
@@ -336,58 +439,88 @@ servePkg:
m.trackDownload(registry, name, version, storedPkg.Size) m.trackDownload(registry, name, version, storedPkg.Size)
} }
// Data is intentionally nil; Get() opens a fresh reader per caller.
return &CacheEntry{ return &CacheEntry{
Package: storedPkg, Package: storedPkg,
Data: storedData, UpstreamURL: fr.UpstreamURL,
FromCache: false, FromCache: false,
UpstreamURL: upstreamURL,
}, nil }, nil
} }
// store stores a package in cache // store stores a package in cache
func (m *Manager) store(ctx context.Context, registry, name, version string, data io.ReadCloser, upstreamURL string) (*metadata.Package, error) { func (m *Manager) store(ctx context.Context, registry, name, version string, data io.ReadCloser, upstreamURL string, requiresAuth bool, authProvider string) (*metadata.Package, error) {
// Generate storage key // Generate storage key
storageKey := m.generateStorageKey(registry, name, version) storageKey := m.generateStorageKey(registry, name, version)
// Calculate checksums while storing // Stream upstream to a temp file while computing checksums, instead of
// We need to read the data, calculate checksums, and store it // buffering the full payload in memory (avoids OOM on large packages,
// This requires buffering the data // e.g. the default 2 GB MaxPackageSize). The temp file also becomes the
var buf []byte // scan source for backends that can't expose a local path, so the payload
var err error // is never held twice in RAM.
maxSize := m.config.MaxPackageSize
// Read all data if maxSize <= 0 {
buf, err = io.ReadAll(data) maxSize = 2 * 1024 * 1024 * 1024 // 2GB safety floor
if err != nil {
return nil, errors.Wrap(err, errors.ErrCodeUpstreamFailure, "failed to read upstream data")
} }
// Calculate checksums tempFile, err := os.CreateTemp(os.TempDir(), "gohoarder-store-*")
h := sha256.New() if err != nil {
h.Write(buf) return nil, errors.Wrap(err, errors.ErrCodeUpstreamFailure, "failed to create temp file for upstream data")
checksumSHA256 := fmt.Sprintf("%x", h.Sum(nil)) }
tempPath := tempFile.Name()
// Temp file is removed when store() returns, unless ownership is handed
// to a scan goroutine (remote backends reuse it for scanning).
scanOwnsTemp := false
defer func() {
if !scanOwnsTemp {
_ = os.Remove(tempPath) // #nosec G104 -- cleanup
}
}()
size := int64(len(buf)) // Compute SHA256 as the payload is streamed to the temp file. Cap at
// maxSize+1 so we can detect overflow without reading unbounded data.
hash := sha256.New()
limited := io.LimitReader(data, maxSize+1)
written, err := io.Copy(io.MultiWriter(tempFile, hash), limited)
if err != nil {
_ = tempFile.Close() // #nosec G104 -- cleanup
return nil, errors.Wrap(err, errors.ErrCodeUpstreamFailure, "failed to read upstream data")
}
if written > maxSize {
_ = tempFile.Close() // #nosec G104 -- cleanup
return nil, errors.New(errors.ErrCodePayloadTooLarge,
fmt.Sprintf("upstream package exceeds max size (%d bytes)", maxSize))
}
checksumSHA256 := fmt.Sprintf("%x", hash.Sum(nil))
size := written
// Check quota before storing // Check quota before storing
quota, err := m.storage.GetQuota(ctx) quota, err := m.storage.GetQuota(ctx)
if err == nil && quota.Limit > 0 { if err == nil && quota.Limit > 0 {
if quota.Used+size > quota.Limit { if quota.Used+size > quota.Limit {
// Trigger eviction // Trigger eviction
if err := m.evict(ctx, size); err != nil { if evictErr := m.evict(ctx, size); evictErr != nil {
_ = tempFile.Close() // #nosec G104 -- cleanup
return nil, errors.QuotaExceeded(quota.Limit) return nil, errors.QuotaExceeded(quota.Limit)
} }
} }
} }
// Seek temp file back to start and stream it to the storage backend.
if _, err := tempFile.Seek(0, io.SeekStart); err != nil {
_ = tempFile.Close() // #nosec G104 -- cleanup
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to seek temp file")
}
// Store in storage backend // Store in storage backend
opts := &storage.PutOptions{ opts := &storage.PutOptions{
ChecksumSHA256: checksumSHA256, ChecksumSHA256: checksumSHA256,
} }
err = m.storage.Put(ctx, storageKey, io.NopCloser(bytes.NewReader(buf)), opts) if err := m.storage.Put(ctx, storageKey, tempFile, opts); err != nil {
if err != nil { _ = tempFile.Close() // #nosec G104 -- cleanup
return nil, err return nil, err
} }
_ = tempFile.Close() // #nosec G104 -- cleanup; scan may still read the file
// Create metadata entry // Create metadata entry
now := time.Now() now := time.Now()
@@ -402,6 +535,8 @@ func (m *Manager) store(ctx context.Context, registry, name, version string, dat
Size: size, Size: size,
ChecksumSHA256: checksumSHA256, ChecksumSHA256: checksumSHA256,
UpstreamURL: upstreamURL, UpstreamURL: upstreamURL,
RequiresAuth: requiresAuth,
AuthProvider: authProvider,
CachedAt: now, CachedAt: now,
LastAccessed: now, LastAccessed: now,
ExpiresAt: &expiresAt, ExpiresAt: &expiresAt,
@@ -409,19 +544,54 @@ func (m *Manager) store(ctx context.Context, registry, name, version string, dat
Metadata: make(map[string]string), Metadata: make(map[string]string),
} }
// Save metadata (skip metadata entries like index pages, lists, etc.) // Persist metadata for ALL entries (including metadata pages and Go .mod/.info).
isMetadataEntry := version == "list" || version == "page" || version == "latest" || version == "metadata" // Skipping persistence for metadata entries caused unconditional upstream re-fetch
if !isMetadataEntry { // on every metadata request. SavePackage upserts safely; the metadata-entry flag
if err := m.metadata.SavePackage(ctx, pkg); err != nil { // below is still used to skip security scanning (these are not scannable packages).
// Clean up storage if metadata save fails //
_ = m.storage.Delete(ctx, storageKey) // #nosec G104 -- Cleanup, error logged // TRADEOFF: metadata pages share the cache's DefaultTTL and are not refreshed
return nil, err // based on upstream Cache-Control. Plumbing per-response TTL from registry handlers
// is out of scope here and tracked separately.
isMetadataEntry := version == "list" || version == "page" || version == "latest" || version == "metadata" ||
strings.HasSuffix(name, ".mod") || strings.HasSuffix(name, ".info")
if err := m.metadata.SavePackage(ctx, pkg); err != nil {
// Clean up storage if metadata save fails
_ = m.storage.Delete(ctx, storageKey) // #nosec G104 -- Cleanup, error logged
return nil, err
}
// Broadcast cache-store event. The scan_status reflects the
// initial state ("pending" if scanning is enabled and applicable;
// "skipped" for metadata entries; "disabled" when scanner is nil).
scanStatus := "disabled"
if m.scanner != nil {
if isMetadataEntry {
scanStatus = "skipped"
} else {
scanStatus = "pending"
} }
} }
m.emit(string(websocket.EventPackageCached), map[string]interface{}{
"registry": registry,
"name": name,
"version": version,
"size": size,
"scan_status": scanStatus,
})
// Scan package if scanner is enabled (run in background to not block cache operations) // Scan package if scanner is enabled (run in background to not block cache operations)
// Skip scanning metadata entries (index pages, lists, etc.) // Skip scanning metadata entries (index pages, lists, etc.)
if m.scanner != nil && !isMetadataEntry { if m.scanner != nil && !isMetadataEntry {
// Decide synchronously whether the streaming temp file must outlive
// store(). Remote backends (no GetLocalPath) reuse it as the scan
// source; local backends scan from GetLocalPath and don't need it.
_, hasLocalPath := m.storage.(interface {
GetLocalPath(ctx context.Context, key string) (string, error)
})
if !hasLocalPath {
scanOwnsTemp = true
}
go func() { go func() {
scanCtx := context.Background() scanCtx := context.Background()
var filePath string var filePath string
@@ -441,32 +611,11 @@ func (m *Manager) store(ctx context.Context, registry, name, version string, dat
cleanupFunc = func() {} // No cleanup needed for direct path cleanupFunc = func() {} // No cleanup needed for direct path
log.Debug().Str("package", name).Str("path", filePath).Msg("Scanning package from storage path") log.Debug().Str("package", name).Str("path", filePath).Msg("Scanning package from storage path")
} else { } else {
// Fallback: Create temp file for remote storage (S3, SMB, etc.) // Remote storage (S3, SMB, etc.) — reuse the temp file store()
tempFilePath := filepath.Join(os.TempDir(), storageKey) // already wrote during streaming. This avoids re-uploading the
// payload into another temp file and avoids holding it in RAM.
// Create parent directories if they don't exist filePath = tempPath
if err := os.MkdirAll(filepath.Dir(tempFilePath), 0750); err != nil { cleanupFunc = func() { _ = os.Remove(tempPath) } // #nosec G104 -- Cleanup
log.Error().Err(err).Str("package", name).Msg("Failed to create temp directory for scanning")
return
}
tempFile, err := os.Create(tempFilePath) // #nosec G304 -- Temp file path is constructed from validated package name
if err != nil {
log.Error().Err(err).Str("package", name).Msg("Failed to create temp file for scanning")
return
}
// Write package data to temp file
if _, err := tempFile.Write(buf); err != nil {
tempFile.Close() // #nosec G104 -- Cleanup, error not critical
_ = os.Remove(tempFilePath) // #nosec G104 -- Cleanup, error not critical
log.Error().Err(err).Str("package", name).Msg("Failed to write temp file for scanning")
return
}
tempFile.Close() // #nosec G104 -- Cleanup, error not critical
filePath = tempFilePath
cleanupFunc = func() { _ = os.Remove(tempFilePath) } // #nosec G104 -- Cleanup
log.Debug().Str("package", name).Str("path", filePath).Msg("Scanning package from temp file") log.Debug().Str("package", name).Str("path", filePath).Msg("Scanning package from temp file")
} }
@@ -558,14 +707,22 @@ func (m *Manager) evict(ctx context.Context, needed int64) error {
return nil return nil
} }
// cleanupWorker runs periodic cleanup of expired packages // cleanupWorker runs periodic cleanup of expired packages.
// Exits when stopCh is closed (via Close()).
func (m *Manager) cleanupWorker() { func (m *Manager) cleanupWorker() {
defer m.cleanupWG.Done()
ticker := time.NewTicker(m.config.CleanupInterval) ticker := time.NewTicker(m.config.CleanupInterval)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for {
ctx := context.Background() select {
m.cleanup(ctx) case <-m.stopCh:
return
case <-ticker.C:
ctx := context.Background()
m.cleanup(ctx)
}
} }
} }
@@ -638,10 +795,18 @@ func (m *Manager) trackDownload(registry, name, version string, size int64) {
m.analytics.TrackDownload(download) m.analytics.TrackDownload(download)
} }
// Close closes the cache manager // Close closes the cache manager.
// Stops the cleanup worker, then closes storage and metadata backends.
// Safe to call multiple times.
func (m *Manager) Close() error { func (m *Manager) Close() error {
var err error var err error
// Stop cleanup worker (idempotent via sync.Once).
m.closeOnce.Do(func() {
close(m.stopCh)
m.cleanupWG.Wait()
})
if closeErr := m.storage.Close(); closeErr != nil { if closeErr := m.storage.Close(); closeErr != nil {
err = closeErr err = closeErr
} }
+246 -13
View File
@@ -6,16 +6,52 @@ import (
"errors" "errors"
"io" "io"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/storage" "github.com/lukaszraczylo/gohoarder/pkg/storage"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// fakeBroadcaster records BroadcastEvent calls for assertions.
type fakeBroadcaster struct {
events []fakeBroadcastEvent
mu sync.Mutex
}
type fakeBroadcastEvent struct {
Payload any
Type string
}
func (f *fakeBroadcaster) BroadcastEvent(eventType string, payload any) {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, fakeBroadcastEvent{Type: eventType, Payload: payload})
}
func (f *fakeBroadcaster) snapshot() []fakeBroadcastEvent {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]fakeBroadcastEvent, len(f.events))
copy(out, f.events)
return out
}
func (f *fakeBroadcaster) typesOnly() []string {
snap := f.snapshot()
out := make([]string, len(snap))
for i, e := range snap {
out[i] = e.Type
}
return out
}
// MockStorageBackend is a mock for storage.StorageBackend // MockStorageBackend is a mock for storage.StorageBackend
type MockStorageBackend struct { type MockStorageBackend struct {
mock.Mock mock.Mock
@@ -194,6 +230,29 @@ func (m *MockMetadataStore) AggregateDownloadData(ctx context.Context) error {
return args.Error(0) return args.Error(0)
} }
// API key methods are not used by the cache package; stub them out so the
// mock continues to satisfy metadata.MetadataStore after the interface gained
// API key persistence methods.
func (m *MockMetadataStore) SaveAPIKey(ctx context.Context, key *metadata.APIKey) error {
return metadata.ErrNotImplemented
}
func (m *MockMetadataStore) GetAPIKey(ctx context.Context, id string) (*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
func (m *MockMetadataStore) ListAPIKeys(ctx context.Context) ([]*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
func (m *MockMetadataStore) DeleteAPIKey(ctx context.Context, id string) error {
return metadata.ErrNotImplemented
}
func (m *MockMetadataStore) UpdateAPIKeyLastUsed(ctx context.Context, id string, t time.Time) error {
return metadata.ErrNotImplemented
}
// TestNew tests cache manager creation // TestNew tests cache manager creation
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
tests := []struct { tests := []struct {
@@ -296,7 +355,7 @@ func TestNew(t *testing.T) {
func TestGet(t *testing.T) { func TestGet(t *testing.T) {
tests := []struct { tests := []struct {
setupMock func(*MockStorageBackend, *MockMetadataStore) setupMock func(*MockStorageBackend, *MockMetadataStore)
fetchFunc func(context.Context) (io.ReadCloser, string, error) fetchFunc FetchFunc
name string name string
registry string registry string
packageName string packageName string
@@ -345,8 +404,8 @@ func TestGet(t *testing.T) {
s.On("Get", mock.Anything, "npm/lodash/4.17.21").Return(io.NopCloser(strings.NewReader("upstream data")), nil) s.On("Get", mock.Anything, "npm/lodash/4.17.21").Return(io.NopCloser(strings.NewReader("upstream data")), nil)
m.On("UpdateDownloadCount", mock.Anything, "npm", "lodash", "4.17.21").Return(nil) m.On("UpdateDownloadCount", mock.Anything, "npm", "lodash", "4.17.21").Return(nil)
}, },
fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { fetchFunc: func(ctx context.Context) (*FetchResult, error) {
return io.NopCloser(strings.NewReader("upstream data")), "https://registry.npmjs.org/lodash", nil return &FetchResult{Data: io.NopCloser(strings.NewReader("upstream data")), UpstreamURL: "https://registry.npmjs.org/lodash"}, nil
}, },
wantFromCache: false, wantFromCache: false,
wantErr: false, wantErr: false,
@@ -377,8 +436,8 @@ func TestGet(t *testing.T) {
s.On("Get", mock.Anything, "npm/expired-pkg/1.0.0").Return(io.NopCloser(strings.NewReader("refreshed data")), nil) s.On("Get", mock.Anything, "npm/expired-pkg/1.0.0").Return(io.NopCloser(strings.NewReader("refreshed data")), nil)
m.On("UpdateDownloadCount", mock.Anything, "npm", "expired-pkg", "1.0.0").Return(nil) m.On("UpdateDownloadCount", mock.Anything, "npm", "expired-pkg", "1.0.0").Return(nil)
}, },
fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { fetchFunc: func(ctx context.Context) (*FetchResult, error) {
return io.NopCloser(strings.NewReader("refreshed data")), "https://registry.npmjs.org/expired-pkg", nil return &FetchResult{Data: io.NopCloser(strings.NewReader("refreshed data")), UpstreamURL: "https://registry.npmjs.org/expired-pkg"}, nil
}, },
wantFromCache: false, wantFromCache: false,
wantErr: false, wantErr: false,
@@ -405,8 +464,8 @@ func TestGet(t *testing.T) {
setupMock: func(s *MockStorageBackend, m *MockMetadataStore) { setupMock: func(s *MockStorageBackend, m *MockMetadataStore) {
m.On("GetPackage", mock.Anything, "npm", "fail-pkg", "1.0.0").Return(nil, errors.New("not found")) m.On("GetPackage", mock.Anything, "npm", "fail-pkg", "1.0.0").Return(nil, errors.New("not found"))
}, },
fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { fetchFunc: func(ctx context.Context) (*FetchResult, error) {
return nil, "", errors.New("upstream error") return nil, errors.New("upstream error")
}, },
wantErr: true, wantErr: true,
errContains: "failed to fetch from upstream", errContains: "failed to fetch from upstream",
@@ -439,8 +498,8 @@ func TestGet(t *testing.T) {
s.On("Get", mock.Anything, "npm/inconsistent/1.0.0").Return(io.NopCloser(strings.NewReader("recovered data")), nil).Once() s.On("Get", mock.Anything, "npm/inconsistent/1.0.0").Return(io.NopCloser(strings.NewReader("recovered data")), nil).Once()
m.On("UpdateDownloadCount", mock.Anything, "npm", "inconsistent", "1.0.0").Return(nil) m.On("UpdateDownloadCount", mock.Anything, "npm", "inconsistent", "1.0.0").Return(nil)
}, },
fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { fetchFunc: func(ctx context.Context) (*FetchResult, error) {
return io.NopCloser(strings.NewReader("recovered data")), "https://registry.npmjs.org/inconsistent", nil return &FetchResult{Data: io.NopCloser(strings.NewReader("recovered data")), UpstreamURL: "https://registry.npmjs.org/inconsistent"}, nil
}, },
wantFromCache: false, wantFromCache: false,
wantErr: false, wantErr: false,
@@ -456,8 +515,8 @@ func TestGet(t *testing.T) {
s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil) s.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
s.On("Put", mock.Anything, "npm/save-fail/1.0.0", mock.Anything, mock.Anything).Return(errors.New("storage error")) s.On("Put", mock.Anything, "npm/save-fail/1.0.0", mock.Anything, mock.Anything).Return(errors.New("storage error"))
}, },
fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { fetchFunc: func(ctx context.Context) (*FetchResult, error) {
return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/save-fail", nil return &FetchResult{Data: io.NopCloser(strings.NewReader("data")), UpstreamURL: "https://registry.npmjs.org/save-fail"}, nil
}, },
wantErr: true, wantErr: true,
errContains: "storage error", errContains: "storage error",
@@ -475,8 +534,8 @@ func TestGet(t *testing.T) {
m.On("SavePackage", mock.Anything, mock.Anything).Return(errors.New("metadata error")) m.On("SavePackage", mock.Anything, mock.Anything).Return(errors.New("metadata error"))
s.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil) s.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil)
}, },
fetchFunc: func(ctx context.Context) (io.ReadCloser, string, error) { fetchFunc: func(ctx context.Context) (*FetchResult, error) {
return io.NopCloser(strings.NewReader("data")), "https://registry.npmjs.org/meta-fail", nil return &FetchResult{Data: io.NopCloser(strings.NewReader("data")), UpstreamURL: "https://registry.npmjs.org/meta-fail"}, nil
}, },
wantErr: true, wantErr: true,
errContains: "metadata error", errContains: "metadata error",
@@ -981,3 +1040,177 @@ func TestConcurrentGet(t *testing.T) {
// Verify at least one call was made (singleflight may deduplicate others) // Verify at least one call was made (singleflight may deduplicate others)
mockMetadata.AssertCalled(t, "GetPackage", mock.Anything, "npm", "concurrent", "1.0.0") mockMetadata.AssertCalled(t, "GetPackage", mock.Anything, "npm", "concurrent", "1.0.0")
} }
// TestBroadcaster_CacheHit verifies EventPackageDownloaded fires on a
// cache-hit serve and no other events are emitted.
func TestBroadcaster_CacheHit(t *testing.T) {
mockStorage := &MockStorageBackend{}
mockMetadata := &MockMetadataStore{}
now := time.Now()
expiresAt := now.Add(24 * time.Hour)
pkg := &metadata.Package{
ID: "test-id",
Registry: "npm",
Name: "react",
Version: "18.2.0",
StorageKey: "npm/react/18.2.0",
CachedAt: now,
LastAccessed: now,
ExpiresAt: &expiresAt,
}
mockMetadata.On("GetPackage", mock.Anything, "npm", "react", "18.2.0").Return(pkg, nil)
mockStorage.On("Get", mock.Anything, "npm/react/18.2.0").Return(io.NopCloser(strings.NewReader("cached data")), nil)
mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "react", "18.2.0").Return(nil)
manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
require.NoError(t, err)
bc := &fakeBroadcaster{}
manager.SetBroadcaster(bc)
entry, err := manager.Get(context.Background(), "npm", "react", "18.2.0", nil)
require.NoError(t, err)
require.NotNil(t, entry)
events := bc.snapshot()
require.Len(t, events, 1, "expected exactly one event on cache hit")
assert.Equal(t, string(websocket.EventPackageDownloaded), events[0].Type)
payload, ok := events[0].Payload.(map[string]interface{})
require.True(t, ok, "payload should be map[string]interface{}")
assert.Equal(t, "npm", payload["registry"])
assert.Equal(t, "react", payload["name"])
assert.Equal(t, "18.2.0", payload["version"])
assert.Equal(t, true, payload["cache_hit"])
}
// TestBroadcaster_CacheMissStore verifies EventPackageCached fires on a
// cache-miss-then-store path and EventPackageDownloaded does NOT fire
// on that path (avoid double counting).
func TestBroadcaster_CacheMissStore(t *testing.T) {
mockStorage := &MockStorageBackend{}
mockMetadata := &MockMetadataStore{}
mockMetadata.On("GetPackage", mock.Anything, "npm", "lodash", "4.17.21").Return(nil, errors.New("not found"))
mockStorage.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
mockStorage.On("Put", mock.Anything, "npm/lodash/4.17.21", mock.Anything, mock.Anything).Return(nil)
mockMetadata.On("SavePackage", mock.Anything, mock.Anything).Return(nil)
mockStorage.On("Get", mock.Anything, "npm/lodash/4.17.21").Return(io.NopCloser(strings.NewReader("upstream data")), nil)
mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "lodash", "4.17.21").Return(nil)
manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
require.NoError(t, err)
bc := &fakeBroadcaster{}
manager.SetBroadcaster(bc)
fetch := func(ctx context.Context) (*FetchResult, error) {
return &FetchResult{Data: io.NopCloser(strings.NewReader("upstream data")), UpstreamURL: "https://registry.npmjs.org/lodash"}, nil
}
entry, err := manager.Get(context.Background(), "npm", "lodash", "4.17.21", fetch)
require.NoError(t, err)
require.NotNil(t, entry)
types := bc.typesOnly()
require.Contains(t, types, string(websocket.EventPackageCached))
require.NotContains(t, types, string(websocket.EventPackageDownloaded),
"miss-then-fetch path should not emit EventPackageDownloaded")
// Inspect EventPackageCached payload.
var cachedEv *fakeBroadcastEvent
for i := range bc.events {
if bc.events[i].Type == string(websocket.EventPackageCached) {
cachedEv = &bc.events[i]
break
}
}
require.NotNil(t, cachedEv)
payload, ok := cachedEv.Payload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "npm", payload["registry"])
assert.Equal(t, "lodash", payload["name"])
assert.Equal(t, "4.17.21", payload["version"])
assert.Equal(t, int64(len("upstream data")), payload["size"])
// Scanner is nil in this test → scan_status should be "disabled".
assert.Equal(t, "disabled", payload["scan_status"])
}
// TestBroadcaster_NoEmitOnError ensures error paths (storage Put fail,
// metadata Save fail) do NOT emit EventPackageCached.
func TestBroadcaster_NoEmitOnError(t *testing.T) {
t.Run("storage put fails", func(t *testing.T) {
mockStorage := &MockStorageBackend{}
mockMetadata := &MockMetadataStore{}
mockMetadata.On("GetPackage", mock.Anything, "npm", "fail", "1.0.0").Return(nil, errors.New("not found"))
mockStorage.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
mockStorage.On("Put", mock.Anything, "npm/fail/1.0.0", mock.Anything, mock.Anything).Return(errors.New("storage error"))
manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
require.NoError(t, err)
bc := &fakeBroadcaster{}
manager.SetBroadcaster(bc)
fetch := func(ctx context.Context) (*FetchResult, error) {
return &FetchResult{Data: io.NopCloser(strings.NewReader("data")), UpstreamURL: "https://registry.npmjs.org/fail"}, nil
}
_, err = manager.Get(context.Background(), "npm", "fail", "1.0.0", fetch)
require.Error(t, err)
assert.Empty(t, bc.snapshot(), "no events should be emitted when storage Put fails")
})
t.Run("metadata save fails", func(t *testing.T) {
mockStorage := &MockStorageBackend{}
mockMetadata := &MockMetadataStore{}
mockMetadata.On("GetPackage", mock.Anything, "npm", "meta-fail", "1.0.0").Return(nil, errors.New("not found"))
mockStorage.On("GetQuota", mock.Anything).Return(&storage.QuotaInfo{Used: 100, Available: 900, Limit: 1000}, nil)
mockStorage.On("Put", mock.Anything, "npm/meta-fail/1.0.0", mock.Anything, mock.Anything).Return(nil)
mockMetadata.On("SavePackage", mock.Anything, mock.Anything).Return(errors.New("metadata error"))
mockStorage.On("Delete", mock.Anything, "npm/meta-fail/1.0.0").Return(nil)
manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
require.NoError(t, err)
bc := &fakeBroadcaster{}
manager.SetBroadcaster(bc)
fetch := func(ctx context.Context) (*FetchResult, error) {
return &FetchResult{Data: io.NopCloser(strings.NewReader("data")), UpstreamURL: "https://registry.npmjs.org/meta-fail"}, nil
}
_, err = manager.Get(context.Background(), "npm", "meta-fail", "1.0.0", fetch)
require.Error(t, err)
assert.Empty(t, bc.snapshot(), "no events should be emitted when metadata SavePackage fails")
})
t.Run("nil broadcaster is safe", func(t *testing.T) {
mockStorage := &MockStorageBackend{}
mockMetadata := &MockMetadataStore{}
now := time.Now()
expiresAt := now.Add(24 * time.Hour)
pkg := &metadata.Package{
ID: "id",
Registry: "npm",
Name: "x",
Version: "1",
StorageKey: "npm/x/1",
CachedAt: now,
LastAccessed: now,
ExpiresAt: &expiresAt,
}
mockMetadata.On("GetPackage", mock.Anything, "npm", "x", "1").Return(pkg, nil)
mockStorage.On("Get", mock.Anything, "npm/x/1").Return(io.NopCloser(strings.NewReader("d")), nil)
mockMetadata.On("UpdateDownloadCount", mock.Anything, "npm", "x", "1").Return(nil)
manager, err := New(mockStorage, mockMetadata, nil, nil, Config{})
require.NoError(t, err)
// No SetBroadcaster — manager.broadcaster is nil.
_, err = manager.Get(context.Background(), "npm", "x", "1", nil)
require.NoError(t, err)
})
}
+2
View File
@@ -1,3 +1,5 @@
// Package cdn provides CDN-friendly HTTP middleware (ETag, Cache-Control)
// for proxying cached package responses.
package cdn package cdn
import ( import (
+6 -6
View File
@@ -32,7 +32,7 @@ func TestCDNMiddlewareTestSuite(t *testing.T) {
func (s *CDNMiddlewareTestSuite) TestCacheControlHeader() { func (s *CDNMiddlewareTestSuite) TestCacheControlHeader() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("test response")) _, _ = w.Write([]byte("test response"))
}) })
wrappedHandler := s.middleware.Handler(handler) wrappedHandler := s.middleware.Handler(handler)
@@ -51,7 +51,7 @@ func (s *CDNMiddlewareTestSuite) TestCacheControlHeader() {
func (s *CDNMiddlewareTestSuite) TestETagGeneration() { func (s *CDNMiddlewareTestSuite) TestETagGeneration() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("test response content")) _, _ = w.Write([]byte("test response content"))
}) })
wrappedHandler := s.middleware.Handler(handler) wrappedHandler := s.middleware.Handler(handler)
@@ -71,7 +71,7 @@ func (s *CDNMiddlewareTestSuite) TestETagConsistencyAcrossRequests() {
responseBody := []byte("test response content") responseBody := []byte("test response content")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write(responseBody) _, _ = w.Write(responseBody)
}) })
wrappedHandler := s.middleware.Handler(handler) wrappedHandler := s.middleware.Handler(handler)
@@ -95,7 +95,7 @@ func (s *CDNMiddlewareTestSuite) TestETagConsistencyAcrossRequests() {
func (s *CDNMiddlewareTestSuite) TestVaryHeader() { func (s *CDNMiddlewareTestSuite) TestVaryHeader() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("test")) _, _ = w.Write([]byte("test"))
}) })
wrappedHandler := s.middleware.Handler(handler) wrappedHandler := s.middleware.Handler(handler)
@@ -246,7 +246,7 @@ func (s *CDNMiddlewareTestSuite) TestETagConsistency() {
func (s *CDNMiddlewareTestSuite) TestNoCacheFor4xxErrors() { func (s *CDNMiddlewareTestSuite) TestNoCacheFor4xxErrors() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found")) _, _ = w.Write([]byte("not found"))
}) })
wrappedHandler := s.middleware.Handler(handler) wrappedHandler := s.middleware.Handler(handler)
@@ -264,7 +264,7 @@ func (s *CDNMiddlewareTestSuite) TestNoCacheFor4xxErrors() {
func (s *CDNMiddlewareTestSuite) TestNoCacheFor5xxErrors() { func (s *CDNMiddlewareTestSuite) TestNoCacheFor5xxErrors() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("error")) _, _ = w.Write([]byte("error"))
}) })
wrappedHandler := s.middleware.Handler(handler) wrappedHandler := s.middleware.Handler(handler)
+79 -25
View File
@@ -1,3 +1,5 @@
// Package config defines the typed configuration schema and loader for
// GoHoarder, sourced from YAML files and environment variables.
package config package config
import ( import (
@@ -7,25 +9,32 @@ import (
// Config is the main configuration struct // Config is the main configuration struct
type Config struct { type Config struct {
Storage StorageConfig `mapstructure:"storage" json:"storage"` Metadata MetadataConfig `mapstructure:"metadata" json:"metadata"`
Cache CacheConfig `mapstructure:"cache" json:"cache"` Logging LoggingConfig `mapstructure:"logging" json:"logging"`
Security SecurityConfig `mapstructure:"security" json:"security"` Storage StorageConfig `mapstructure:"storage" json:"storage"`
Metadata MetadataConfig `mapstructure:"metadata" json:"metadata"` Handlers HandlersConfig `mapstructure:"handlers" json:"handlers"`
Handlers HandlersConfig `mapstructure:"handlers" json:"handlers"` Cache CacheConfig `mapstructure:"cache" json:"cache"`
Server ServerConfig `mapstructure:"server" json:"server"` Server ServerConfig `mapstructure:"server" json:"server"`
Logging LoggingConfig `mapstructure:"logging" json:"logging"` Security SecurityConfig `mapstructure:"security" json:"security"`
Network NetworkConfig `mapstructure:"network" json:"network"` Network NetworkConfig `mapstructure:"network" json:"network"`
Auth AuthConfig `mapstructure:"auth" json:"auth"` Prewarming PrewarmingConfig `mapstructure:"prewarming" json:"prewarming"`
Auth AuthConfig `mapstructure:"auth" json:"auth"`
Metrics MetricsConfig `mapstructure:"metrics" json:"metrics"`
} }
// ServerConfig contains HTTP server configuration // ServerConfig contains HTTP server configuration
type ServerConfig struct { type ServerConfig struct {
TLS TLSConfig `mapstructure:"tls" json:"tls"` Host string `mapstructure:"host" json:"host"`
Host string `mapstructure:"host" json:"host"` TLS TLSConfig `mapstructure:"tls" json:"tls"`
Port int `mapstructure:"port" json:"port"` // AllowedOrigins is the WebSocket Origin allowlist. Each entry is either
ReadTimeout time.Duration `mapstructure:"read_timeout" json:"read_timeout"` // an exact origin (e.g. "https://app.example.com") or a wildcard host
WriteTimeout time.Duration `mapstructure:"write_timeout" json:"write_timeout"` // pattern (e.g. "https://*.example.com"). When empty, only same-origin
IdleTimeout time.Duration `mapstructure:"idle_timeout" json:"idle_timeout"` // WebSocket upgrades are allowed.
AllowedOrigins []string `mapstructure:"allowed_origins" json:"allowed_origins"`
Port int `mapstructure:"port" json:"port"`
ReadTimeout time.Duration `mapstructure:"read_timeout" json:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout" json:"write_timeout"`
IdleTimeout time.Duration `mapstructure:"idle_timeout" json:"idle_timeout"`
} }
// TLSConfig contains TLS/HTTPS configuration // TLSConfig contains TLS/HTTPS configuration
@@ -39,12 +48,21 @@ type TLSConfig struct {
type StorageConfig struct { type StorageConfig struct {
Options map[string]interface{} `mapstructure:"options" json:"options"` Options map[string]interface{} `mapstructure:"options" json:"options"`
SMB SMBConfig `mapstructure:"smb" json:"smb"` SMB SMBConfig `mapstructure:"smb" json:"smb"`
NFS NFSConfig `mapstructure:"nfs" json:"nfs"`
Backend string `mapstructure:"backend" json:"backend"` Backend string `mapstructure:"backend" json:"backend"`
Path string `mapstructure:"path" json:"path"` Path string `mapstructure:"path" json:"path"`
Filesystem FilesystemConfig `mapstructure:"filesystem" json:"filesystem"` Filesystem FilesystemConfig `mapstructure:"filesystem" json:"filesystem"`
S3 S3Config `mapstructure:"s3" json:"s3"` S3 S3Config `mapstructure:"s3" json:"s3"`
} }
// NFSConfig contains NFS-specific storage configuration. The path is taken
// from StorageConfig.Path (mount point). This struct only carries flags
// specific to NFS semantics. SyncWrites pointer-bool so absent config
// defaults to true (durable by default).
type NFSConfig struct {
SyncWrites *bool `mapstructure:"sync_writes" json:"sync_writes"`
}
// FilesystemConfig contains local filesystem storage configuration // FilesystemConfig contains local filesystem storage configuration
type FilesystemConfig struct { type FilesystemConfig struct {
BasePath string `mapstructure:"base_path" json:"base_path"` BasePath string `mapstructure:"base_path" json:"base_path"`
@@ -75,15 +93,17 @@ type SMBConfig struct {
type MetadataConfig struct { type MetadataConfig struct {
Backend string `mapstructure:"backend" json:"backend"` Backend string `mapstructure:"backend" json:"backend"`
Connection string `mapstructure:"connection" json:"connection"` Connection string `mapstructure:"connection" json:"connection"`
SQLite SQLiteConfig `mapstructure:"sqlite" json:"sqlite"` LogLevel string `mapstructure:"log_level" json:"log_level"` // "silent", "error", "warn", "info"
PostgreSQL PostgreSQLConfig `mapstructure:"postgresql" json:"postgresql"` PostgreSQL PostgreSQLConfig `mapstructure:"postgresql" json:"postgresql"`
MySQL MySQLConfig `mapstructure:"mysql" json:"mysql"`
SQLite SQLiteConfig `mapstructure:"sqlite" json:"sqlite"`
MySQL MySQLConfig `mapstructure:"mysql" json:"mysql"`
// GORM-specific settings // GORM-specific settings
MaxOpenConns int `mapstructure:"max_open_conns" json:"max_open_conns"` MaxOpenConns int `mapstructure:"max_open_conns" json:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns" json:"max_idle_conns"` MaxIdleConns int `mapstructure:"max_idle_conns" json:"max_idle_conns"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime" json:"conn_max_lifetime"` // seconds ConnMaxLifetime int `mapstructure:"conn_max_lifetime" json:"conn_max_lifetime"` // seconds
LogLevel string `mapstructure:"log_level" json:"log_level"` // "silent", "error", "warn", "info"
} }
// SQLiteConfig contains SQLite-specific configuration // SQLiteConfig contains SQLite-specific configuration
@@ -95,21 +115,21 @@ type SQLiteConfig struct {
// PostgreSQLConfig contains PostgreSQL-specific configuration // PostgreSQLConfig contains PostgreSQL-specific configuration
type PostgreSQLConfig struct { type PostgreSQLConfig struct {
Host string `mapstructure:"host" json:"host"` Host string `mapstructure:"host" json:"host"`
Port int `mapstructure:"port" json:"port"`
Database string `mapstructure:"database" json:"database"` Database string `mapstructure:"database" json:"database"`
User string `mapstructure:"user" json:"user"` User string `mapstructure:"user" json:"user"`
Password string `mapstructure:"password" json:"-"` Password string `mapstructure:"password" json:"-"`
SSLMode string `mapstructure:"ssl_mode" json:"ssl_mode"` SSLMode string `mapstructure:"ssl_mode" json:"ssl_mode"`
Port int `mapstructure:"port" json:"port"`
} }
// MySQLConfig contains MySQL/MariaDB-specific configuration // MySQLConfig contains MySQL/MariaDB-specific configuration
type MySQLConfig struct { type MySQLConfig struct {
Host string `mapstructure:"host" json:"host"` Host string `mapstructure:"host" json:"host"`
Port int `mapstructure:"port" json:"port"`
Database string `mapstructure:"database" json:"database"` Database string `mapstructure:"database" json:"database"`
User string `mapstructure:"user" json:"user"` User string `mapstructure:"user" json:"user"`
Password string `mapstructure:"password" json:"-"` // Don't serialize Password string `mapstructure:"password" json:"-"` // Don't serialize
Charset string `mapstructure:"charset" json:"charset"` Charset string `mapstructure:"charset" json:"charset"`
Port int `mapstructure:"port" json:"port"`
ParseTime bool `mapstructure:"parse_time" json:"parse_time"` ParseTime bool `mapstructure:"parse_time" json:"parse_time"`
} }
@@ -124,10 +144,10 @@ type CacheConfig struct {
// SecurityConfig contains security scanning configuration // SecurityConfig contains security scanning configuration
type SecurityConfig struct { type SecurityConfig struct {
Scanners ScannersConfig `mapstructure:"scanners" json:"scanners"`
BlockOnSeverity string `mapstructure:"block_on_severity" json:"block_on_severity"` BlockOnSeverity string `mapstructure:"block_on_severity" json:"block_on_severity"`
AllowedPackages []string `mapstructure:"allowed_packages" json:"allowed_packages"` AllowedPackages []string `mapstructure:"allowed_packages" json:"allowed_packages"`
IgnoredCVEs []string `mapstructure:"ignored_cves" json:"ignored_cves"` IgnoredCVEs []string `mapstructure:"ignored_cves" json:"ignored_cves"`
Scanners ScannersConfig `mapstructure:"scanners" json:"scanners"`
BlockThresholds VulnerabilityThresholds `mapstructure:"block_thresholds" json:"block_thresholds"` BlockThresholds VulnerabilityThresholds `mapstructure:"block_thresholds" json:"block_thresholds"`
RescanInterval time.Duration `mapstructure:"rescan_interval" json:"rescan_interval"` RescanInterval time.Duration `mapstructure:"rescan_interval" json:"rescan_interval"`
Enabled bool `mapstructure:"enabled" json:"enabled"` Enabled bool `mapstructure:"enabled" json:"enabled"`
@@ -147,8 +167,8 @@ type VulnerabilityThresholds struct {
type ScannersConfig struct { type ScannersConfig struct {
Trivy TrivyConfig `mapstructure:"trivy" json:"trivy"` Trivy TrivyConfig `mapstructure:"trivy" json:"trivy"`
GHSA GHSAConfig `mapstructure:"ghsa" json:"ghsa"` GHSA GHSAConfig `mapstructure:"ghsa" json:"ghsa"`
Static StaticConfig `mapstructure:"static" json:"static"`
OSV OSVConfig `mapstructure:"osv" json:"osv"` OSV OSVConfig `mapstructure:"osv" json:"osv"`
Static StaticConfig `mapstructure:"static" json:"static"`
Grype GrypeConfig `mapstructure:"grype" json:"grype"` Grype GrypeConfig `mapstructure:"grype" json:"grype"`
Govulncheck GovulncheckConfig `mapstructure:"govulncheck" json:"govulncheck"` Govulncheck GovulncheckConfig `mapstructure:"govulncheck" json:"govulncheck"`
NpmAudit NpmAuditConfig `mapstructure:"npm_audit" json:"npm_audit"` NpmAudit NpmAuditConfig `mapstructure:"npm_audit" json:"npm_audit"`
@@ -256,6 +276,21 @@ type LoggingConfig struct {
Format string `mapstructure:"format" json:"format"` // json, pretty Format string `mapstructure:"format" json:"format"` // json, pretty
} }
// PrewarmingConfig controls the popular-package pre-warming worker.
type PrewarmingConfig struct {
Interval time.Duration `mapstructure:"interval" json:"interval"`
MaxConcurrent int `mapstructure:"max_concurrent" json:"max_concurrent"`
TopPackages int `mapstructure:"top_packages" json:"top_packages"`
Enabled bool `mapstructure:"enabled" json:"enabled"`
}
// MetricsConfig controls /metrics endpoint behaviour.
type MetricsConfig struct {
// RequireAuth, when true and Auth.Enabled is also true, gates /metrics
// behind RequireAuth middleware. Default false (Prometheus-friendly).
RequireAuth bool `mapstructure:"require_auth" json:"require_auth"`
}
// HandlersConfig contains package manager handler configurations // HandlersConfig contains package manager handler configurations
type HandlersConfig struct { type HandlersConfig struct {
Go GoHandlerConfig `mapstructure:"go" json:"go"` Go GoHandlerConfig `mapstructure:"go" json:"go"`
@@ -327,6 +362,16 @@ func Default() *Config {
Security: SecurityConfig{ Security: SecurityConfig{
Enabled: false, Enabled: false,
BlockOnSeverity: "high", BlockOnSeverity: "high",
// Default all severity caps to unlimited (-1) so the operator's
// BlockOnSeverity decision governs by default. A zero cap means
// "block any", which would make a single LOW vulnerability block
// every package even though the configured default is "high".
BlockThresholds: VulnerabilityThresholds{
Critical: -1,
High: -1,
Medium: -1,
Low: -1,
},
Scanners: ScannersConfig{ Scanners: ScannersConfig{
Trivy: TrivyConfig{ Trivy: TrivyConfig{
Enabled: false, Enabled: false,
@@ -399,6 +444,15 @@ func Default() *Config {
Level: "info", Level: "info",
Format: "json", Format: "json",
}, },
Prewarming: PrewarmingConfig{
Enabled: false,
Interval: 1 * time.Hour,
MaxConcurrent: 5,
TopPackages: 100,
},
Metrics: MetricsConfig{
RequireAuth: false,
},
Handlers: HandlersConfig{ Handlers: HandlersConfig{
Go: GoHandlerConfig{ Go: GoHandlerConfig{
Enabled: true, Enabled: true,
+3 -3
View File
@@ -287,13 +287,13 @@ auth:
s.Run(tt.name, func() { s.Run(tt.name, func() {
// Write config file // Write config file
configPath := filepath.Join(s.tempDir, "config.yaml") configPath := filepath.Join(s.tempDir, "config.yaml")
err := os.WriteFile(configPath, []byte(tt.configYAML), 0644) err := os.WriteFile(configPath, []byte(tt.configYAML), 0600)
s.Require().NoError(err) s.Require().NoError(err)
// Set environment variables // Set environment variables
for k, v := range tt.envVars { for k, v := range tt.envVars {
os.Setenv(k, v) _ = os.Setenv(k, v) // #nosec G104 -- test setup, error not actionable
defer os.Unsetenv(k) defer os.Unsetenv(k) //nolint:errcheck // test cleanup
} }
// Load config // Load config
+2
View File
@@ -1,3 +1,5 @@
// Package errors provides typed application errors with structured codes
// and wrapping helpers used across the GoHoarder service.
package errors package errors
import ( import (
+24
View File
@@ -0,0 +1,24 @@
// Package events defines the cross-package event broadcaster interface.
//
// Sub-systems such as cache and scanner emit lifecycle events but must
// not import pkg/websocket directly (would create circular import once
// websocket grows callers in those packages or in app wiring).
//
// The Broadcaster contract is intentionally minimal: a single method
// that accepts an event type string and an arbitrary payload. The
// concrete implementation (websocket.Server) decides how to marshal
// and dispatch the payload.
package events
// Broadcaster is the minimal contract sub-systems use to publish events.
//
// Implementations MUST be safe for concurrent use and MUST NOT block
// the caller (callers expect fire-and-forget semantics). If the
// underlying transport is full or unavailable, implementations should
// drop the event and log, never block or panic.
type Broadcaster interface {
// BroadcastEvent publishes an event of the given type with the
// supplied payload. Payload is typically map[string]any but
// implementations may accept arbitrary serialisable values.
BroadcastEvent(eventType string, payload any)
}
+5 -2
View File
@@ -1,3 +1,5 @@
// Package health exposes liveness and readiness checks consumed by HTTP
// probes and orchestrators.
package health package health
import ( import (
@@ -132,9 +134,10 @@ func (c *Checker) HealthHandler() http.HandlerFunc {
} }
statusCode := http.StatusOK statusCode := http.StatusOK
if healthData.Status == StatusUnhealthy { switch healthData.Status {
case StatusUnhealthy:
statusCode = http.StatusServiceUnavailable statusCode = http.StatusServiceUnavailable
} else if healthData.Status == StatusDegraded { case StatusDegraded:
statusCode = http.StatusOK // 200 but degraded statusCode = http.StatusOK // 200 but degraded
} }
+2
View File
@@ -1,3 +1,5 @@
// Package logger configures the zerolog-based application logger used by
// the GoHoarder server and CLI commands.
package logger package logger
import ( import (
+28
View File
@@ -1,3 +1,5 @@
// Package file implements a JSON-on-disk metadata store used for local
// development and tests.
package file package file
import ( import (
@@ -544,3 +546,29 @@ func (s *Store) Close() error {
// Nothing to close for file-based store // Nothing to close for file-based store
return nil return nil
} }
// SaveAPIKey is a stub: file-backed metadata does not persist API keys.
// The auth.Manager treats ErrNotImplemented as "in-memory only" mode.
func (s *Store) SaveAPIKey(ctx context.Context, key *metadata.APIKey) error {
return metadata.ErrNotImplemented
}
// GetAPIKey is a stub. See SaveAPIKey.
func (s *Store) GetAPIKey(ctx context.Context, id string) (*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
// ListAPIKeys is a stub. See SaveAPIKey.
func (s *Store) ListAPIKeys(ctx context.Context) ([]*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
// DeleteAPIKey is a stub. See SaveAPIKey.
func (s *Store) DeleteAPIKey(ctx context.Context, id string) error {
return metadata.ErrNotImplemented
}
// UpdateAPIKeyLastUsed is a stub. See SaveAPIKey.
func (s *Store) UpdateAPIKeyLastUsed(ctx context.Context, id string, t time.Time) error {
return metadata.ErrNotImplemented
}
+14 -8
View File
@@ -66,7 +66,7 @@ func (w *AggregationWorker) AggregateHourly() error {
log.Debug().Msg("Starting hourly aggregation") log.Debug().Msg("Starting hourly aggregation")
// Get dialect name // Get dialect name
dialectName := w.db.Dialector.Name() dialectName := w.db.Name()
// Calculate cutoff time (aggregate events older than 5 minutes to avoid partial data) // Calculate cutoff time (aggregate events older than 5 minutes to avoid partial data)
cutoff := time.Now().Add(-5 * time.Minute).Truncate(time.Hour) cutoff := time.Now().Add(-5 * time.Minute).Truncate(time.Hour)
@@ -147,16 +147,22 @@ func (w *AggregationWorker) AggregateHourly() error {
return err return err
} }
// Delete aggregated events (older than 24 hours to keep recent data for debugging) // Delete aggregated events in the SAME transaction using the SAME cutoff
deleteOlder := time.Now().Add(-24 * time.Hour) // that drove the aggregation. This guarantees an event is counted exactly
deleteResult := tx.Exec("DELETE FROM download_events WHERE downloaded_at < ?", deleteOlder) // once: prior to this commit it lives in download_events; after this commit
// it's reflected only in download_stats_hourly. Any later run sees only
// fresh events (downloaded_at >= cutoff at write time) and cannot
// double-count rows that were already aggregated.
// Trade-off: raw events older than `cutoff` are not retained for debugging.
deleteResult := tx.Exec("DELETE FROM download_events WHERE downloaded_at < ?", cutoff)
if deleteResult.Error != nil { if deleteResult.Error != nil {
return deleteResult.Error return deleteResult.Error
} }
// Also update package-level stats (NULL package_id = registry totals) // Also update package-level stats (NULL package_id = registry totals)
var registryAggSQL string var registryAggSQL string
if dialectName == "postgres" { switch dialectName {
case "postgres":
registryAggSQL = ` registryAggSQL = `
INSERT INTO download_stats_hourly (registry_id, package_id, time_bucket, download_count, unique_ips, auth_downloads, created_at, updated_at) INSERT INTO download_stats_hourly (registry_id, package_id, time_bucket, download_count, unique_ips, auth_downloads, created_at, updated_at)
SELECT SELECT
@@ -178,7 +184,7 @@ func (w *AggregationWorker) AggregateHourly() error {
auth_downloads = EXCLUDED.auth_downloads, auth_downloads = EXCLUDED.auth_downloads,
updated_at = NOW() updated_at = NOW()
` `
} else if dialectName == "mysql" { case "mysql":
registryAggSQL = ` registryAggSQL = `
INSERT INTO download_stats_hourly (registry_id, package_id, time_bucket, download_count, unique_ips, auth_downloads, created_at, updated_at) INSERT INTO download_stats_hourly (registry_id, package_id, time_bucket, download_count, unique_ips, auth_downloads, created_at, updated_at)
SELECT SELECT
@@ -199,7 +205,7 @@ func (w *AggregationWorker) AggregateHourly() error {
auth_downloads = VALUES(auth_downloads), auth_downloads = VALUES(auth_downloads),
updated_at = NOW() updated_at = NOW()
` `
} else { default:
// SQLite // SQLite
registryAggSQL = ` registryAggSQL = `
INSERT OR REPLACE INTO download_stats_hourly (registry_id, package_id, time_bucket, download_count, unique_ips, auth_downloads, created_at, updated_at) INSERT OR REPLACE INTO download_stats_hourly (registry_id, package_id, time_bucket, download_count, unique_ips, auth_downloads, created_at, updated_at)
@@ -237,7 +243,7 @@ func (w *AggregationWorker) AggregateDaily() error {
startTime := time.Now() startTime := time.Now()
log.Debug().Msg("Starting daily aggregation") log.Debug().Msg("Starting daily aggregation")
dialectName := w.db.Dialector.Name() dialectName := w.db.Name()
// Aggregate yesterday's data // Aggregate yesterday's data
yesterday := time.Now().AddDate(0, 0, -1).Truncate(24 * time.Hour) yesterday := time.Now().AddDate(0, 0, -1).Truncate(24 * time.Hour)
+115
View File
@@ -0,0 +1,115 @@
package gormstore
import (
"context"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SaveAPIKey persists an API key. Insert-or-update by primary key.
func (s *GORMStoreV2) SaveAPIKey(ctx context.Context, key *metadata.APIKey) error {
if key == nil || key.ID == "" {
return errors.New(errors.ErrCodeBadRequest, "api key id required")
}
model := &APIKeyModel{
ID: key.ID,
KeyHash: key.KeyHash,
Project: key.Project,
Role: key.Role,
Permissions: key.Permissions,
CreatedAt: key.CreatedAt,
ExpiresAt: key.ExpiresAt,
LastUsedAt: key.LastUsedAt,
Revoked: key.Revoked,
}
if model.CreatedAt.IsZero() {
model.CreatedAt = time.Now()
}
// Upsert on primary key. Updates all columns on conflict — keeps the call
// site simple (Generate then Revoke both flow through here).
err := s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).
Create(model).Error
if err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to save api key")
}
return nil
}
// GetAPIKey retrieves an API key by ID. Returns a not-found error if missing.
func (s *GORMStoreV2) GetAPIKey(ctx context.Context, id string) (*metadata.APIKey, error) {
var model APIKeyModel
err := s.db.WithContext(ctx).Where("id = ?", id).First(&model).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.ErrCodeNotFound, "api key not found")
}
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get api key")
}
return apiKeyModelToMetadata(&model), nil
}
// ListAPIKeys returns all API keys (revoked included). Caller filters as needed.
func (s *GORMStoreV2) ListAPIKeys(ctx context.Context) ([]*metadata.APIKey, error) {
var models []APIKeyModel
if err := s.db.WithContext(ctx).Order("created_at DESC").Find(&models).Error; err != nil {
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to list api keys")
}
out := make([]*metadata.APIKey, len(models))
for i := range models {
out[i] = apiKeyModelToMetadata(&models[i])
}
return out, nil
}
// DeleteAPIKey hard-deletes an API key by ID. Use SaveAPIKey with Revoked=true
// for audit-friendly revocation; this method is for explicit purges.
func (s *GORMStoreV2) DeleteAPIKey(ctx context.Context, id string) error {
result := s.db.WithContext(ctx).Where("id = ?", id).Delete(&APIKeyModel{})
if result.Error != nil {
return errors.Wrap(result.Error, errors.ErrCodeStorageFailure, "failed to delete api key")
}
if result.RowsAffected == 0 {
return errors.New(errors.ErrCodeNotFound, "api key not found")
}
return nil
}
// UpdateAPIKeyLastUsed updates only last_used_at. Cheap (single column UPDATE).
// Auth manager calls this asynchronously per-request; do not perform extra
// work here without measuring impact.
func (s *GORMStoreV2) UpdateAPIKeyLastUsed(ctx context.Context, id string, t time.Time) error {
result := s.db.WithContext(ctx).
Model(&APIKeyModel{}).
Where("id = ?", id).
Update("last_used_at", t)
if result.Error != nil {
return errors.Wrap(result.Error, errors.ErrCodeStorageFailure, "failed to update api key last used")
}
// Missing rows are not fatal: caller validated the key against an
// in-memory snapshot that may have lagged a concurrent revoke.
return nil
}
func apiKeyModelToMetadata(m *APIKeyModel) *metadata.APIKey {
return &metadata.APIKey{
ID: m.ID,
KeyHash: m.KeyHash,
Project: m.Project,
Role: m.Role,
Permissions: m.Permissions,
CreatedAt: m.CreatedAt,
ExpiresAt: m.ExpiresAt,
LastUsedAt: m.LastUsedAt,
Revoked: m.Revoked,
}
}
+231
View File
@@ -0,0 +1,231 @@
package gormstore
import (
"context"
stderrors "errors"
"testing"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
)
// newAPIKeyTestStore spins up an isolated SQLite store. Each test gets a
// fresh DSN to avoid the shared-cache cross-test bleeding that the suite
// uses elsewhere (we want a clean api_keys table).
func newAPIKeyTestStore(t *testing.T) *GORMStoreV2 {
t.Helper()
// Use :memory: in the DSN so NewV2 skips the background aggregation
// worker (it would otherwise contend on the shared cache and produce
// "database table is locked" errors mid-test). MaxOpenConns=1 pins the
// connection so the in-memory database survives across statements
// (sqlite drops the DB when the only connection closes).
cfg := Config{
Driver: "sqlite",
DSN: "file::memory:?_busy_timeout=5000",
MaxOpenConns: 1,
MaxIdleConns: 1,
ConnMaxLifetime: time.Hour,
LogLevel: "silent",
}
store, err := NewV2(cfg)
if err != nil {
t.Fatalf("NewV2: %v", err)
}
t.Cleanup(func() {
// Truncate first so cross-test isolation holds with shared cache.
store.db.Exec("DELETE FROM api_keys")
_ = store.Close()
})
return store
}
func TestAPIKey_RoundTrip(t *testing.T) {
store := newAPIKeyTestStore(t)
ctx := context.Background()
expires := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)
key := &metadata.APIKey{
ID: "test-id-1",
KeyHash: "$2a$04$abcdefghijklmnopqrstuv",
Project: "default",
Role: "admin",
CreatedAt: time.Now().UTC().Truncate(time.Second),
ExpiresAt: &expires,
}
if err := store.SaveAPIKey(ctx, key); err != nil {
t.Fatalf("SaveAPIKey: %v", err)
}
got, err := store.GetAPIKey(ctx, key.ID)
if err != nil {
t.Fatalf("GetAPIKey: %v", err)
}
if got.ID != key.ID {
t.Errorf("ID = %q, want %q", got.ID, key.ID)
}
if got.KeyHash != key.KeyHash {
t.Errorf("KeyHash mismatch")
}
if got.Project != "default" {
t.Errorf("Project = %q, want default", got.Project)
}
if got.Role != "admin" {
t.Errorf("Role = %q, want admin", got.Role)
}
if got.Revoked {
t.Errorf("Revoked = true, want false")
}
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) {
t.Errorf("ExpiresAt = %v, want %v", got.ExpiresAt, expires)
}
}
func TestAPIKey_Upsert(t *testing.T) {
store := newAPIKeyTestStore(t)
ctx := context.Background()
key := &metadata.APIKey{
ID: "upsert-id",
KeyHash: "hash1",
Project: "p1",
Role: "read_only",
CreatedAt: time.Now().UTC().Truncate(time.Second),
}
if err := store.SaveAPIKey(ctx, key); err != nil {
t.Fatalf("first SaveAPIKey: %v", err)
}
// Update via Save (revoke).
key.Revoked = true
key.KeyHash = "hash2"
if err := store.SaveAPIKey(ctx, key); err != nil {
t.Fatalf("second SaveAPIKey: %v", err)
}
got, err := store.GetAPIKey(ctx, key.ID)
if err != nil {
t.Fatalf("GetAPIKey: %v", err)
}
if !got.Revoked {
t.Errorf("Revoked = false after revoke")
}
if got.KeyHash != "hash2" {
t.Errorf("KeyHash = %q, want hash2", got.KeyHash)
}
}
func TestAPIKey_List_OrdersByCreatedDesc(t *testing.T) {
store := newAPIKeyTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
keys := []*metadata.APIKey{
{ID: "k-old", KeyHash: "h", Project: "p", Role: "read_only", CreatedAt: now.Add(-2 * time.Hour)},
{ID: "k-mid", KeyHash: "h", Project: "p", Role: "read_only", CreatedAt: now.Add(-1 * time.Hour)},
{ID: "k-new", KeyHash: "h", Project: "p", Role: "read_only", CreatedAt: now},
}
for _, k := range keys {
if err := store.SaveAPIKey(ctx, k); err != nil {
t.Fatalf("save %s: %v", k.ID, err)
}
}
listed, err := store.ListAPIKeys(ctx)
if err != nil {
t.Fatalf("ListAPIKeys: %v", err)
}
if len(listed) != 3 {
t.Fatalf("len = %d, want 3", len(listed))
}
wantOrder := []string{"k-new", "k-mid", "k-old"}
for i, want := range wantOrder {
if listed[i].ID != want {
t.Errorf("listed[%d].ID = %q, want %q", i, listed[i].ID, want)
}
}
}
func TestAPIKey_UpdateLastUsed(t *testing.T) {
store := newAPIKeyTestStore(t)
ctx := context.Background()
key := &metadata.APIKey{
ID: "lu-id",
KeyHash: "h",
Project: "p",
Role: "admin",
CreatedAt: time.Now().UTC().Truncate(time.Second),
}
if err := store.SaveAPIKey(ctx, key); err != nil {
t.Fatalf("SaveAPIKey: %v", err)
}
used := time.Now().UTC().Truncate(time.Second).Add(time.Minute)
if err := store.UpdateAPIKeyLastUsed(ctx, key.ID, used); err != nil {
t.Fatalf("UpdateAPIKeyLastUsed: %v", err)
}
got, err := store.GetAPIKey(ctx, key.ID)
if err != nil {
t.Fatalf("GetAPIKey: %v", err)
}
if got.LastUsedAt == nil || !got.LastUsedAt.Equal(used) {
t.Errorf("LastUsedAt = %v, want %v", got.LastUsedAt, used)
}
// Updating a missing key must not error: validation can race revoke.
if err := store.UpdateAPIKeyLastUsed(ctx, "no-such-id", used); err != nil {
t.Errorf("UpdateAPIKeyLastUsed(missing) returned err: %v", err)
}
}
func TestAPIKey_Delete(t *testing.T) {
store := newAPIKeyTestStore(t)
ctx := context.Background()
key := &metadata.APIKey{
ID: "del-id",
KeyHash: "h",
Project: "p",
Role: "read_only",
CreatedAt: time.Now().UTC().Truncate(time.Second),
}
if err := store.SaveAPIKey(ctx, key); err != nil {
t.Fatalf("SaveAPIKey: %v", err)
}
if err := store.DeleteAPIKey(ctx, key.ID); err != nil {
t.Fatalf("DeleteAPIKey: %v", err)
}
if _, err := store.GetAPIKey(ctx, key.ID); err == nil {
t.Errorf("GetAPIKey after delete: want error, got nil")
}
// Second delete returns not-found.
if err := store.DeleteAPIKey(ctx, key.ID); err == nil {
t.Errorf("second DeleteAPIKey: want error, got nil")
}
}
func TestAPIKey_SaveAPIKey_RejectsEmptyID(t *testing.T) {
store := newAPIKeyTestStore(t)
ctx := context.Background()
err := store.SaveAPIKey(ctx, &metadata.APIKey{KeyHash: "h"})
if err == nil {
t.Fatalf("want error for empty ID")
}
}
func TestAPIKey_GetMissing_ReturnsNotFound(t *testing.T) {
store := newAPIKeyTestStore(t)
ctx := context.Background()
_, err := store.GetAPIKey(ctx, "missing")
if err == nil {
t.Fatalf("want error")
}
// Sanity: error chain doesn't accidentally surface ErrNotImplemented.
if stderrors.Is(err, metadata.ErrNotImplemented) {
t.Errorf("got ErrNotImplemented for missing key, expected not-found")
}
}
+5 -3
View File
@@ -1,3 +1,5 @@
// Package gormstore implements the GORM-backed metadata store used for
// production persistence (Postgres, MySQL, SQLite).
package gormstore package gormstore
import ( import (
@@ -13,13 +15,13 @@ type Config struct {
Driver string // "sqlite", "postgres", "mysql" Driver string // "sqlite", "postgres", "mysql"
DSN string // Data Source Name DSN string // Data Source Name
// GORM settings
LogLevel string // "silent", "error", "warn", "info"
// Connection pool // Connection pool
MaxOpenConns int MaxOpenConns int
MaxIdleConns int MaxIdleConns int
ConnMaxLifetime time.Duration ConnMaxLifetime time.Duration
// GORM settings
LogLevel string // "silent", "error", "warn", "info"
} }
// Validate validates the configuration // Validate validates the configuration
+81 -13
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/lukaszraczylo/gohoarder/pkg/errors" "github.com/lukaszraczylo/gohoarder/pkg/errors"
@@ -23,6 +24,7 @@ type GORMStoreV2 struct {
registryCache map[string]int32 // Cache registry name -> ID mapping registryCache map[string]int32 // Cache registry name -> ID mapping
aggregationWorker *AggregationWorker aggregationWorker *AggregationWorker
partitionManager *PartitionManager partitionManager *PartitionManager
registryCacheMu sync.RWMutex // Protects registryCache for concurrent access
} }
// NewV2 creates a new GORM-based metadata store with V2 schema // NewV2 creates a new GORM-based metadata store with V2 schema
@@ -102,7 +104,7 @@ func NewV2(cfg Config) (*GORMStoreV2, error) {
} }
// Seed default registries if empty // Seed default registries if empty
if len(store.registryCache) == 0 { if store.registryCacheLen() == 0 {
if err := store.seedDefaultRegistries(); err != nil { if err := store.seedDefaultRegistries(); err != nil {
return nil, err return nil, err
} }
@@ -133,12 +135,21 @@ func (s *GORMStoreV2) loadRegistryCache() error {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to load registries") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to load registries")
} }
s.registryCacheMu.Lock()
for _, r := range registries { for _, r := range registries {
s.registryCache[r.Name] = r.ID s.registryCache[r.Name] = r.ID
} }
s.registryCacheMu.Unlock()
return nil return nil
} }
// registryCacheLen returns current cache size (used after loading default seed data)
func (s *GORMStoreV2) registryCacheLen() int {
s.registryCacheMu.RLock()
defer s.registryCacheMu.RUnlock()
return len(s.registryCache)
}
// seedDefaultRegistries creates default registry entries // seedDefaultRegistries creates default registry entries
func (s *GORMStoreV2) seedDefaultRegistries() error { func (s *GORMStoreV2) seedDefaultRegistries() error {
defaultRegistries := []RegistryModel{ defaultRegistries := []RegistryModel{
@@ -151,7 +162,9 @@ func (s *GORMStoreV2) seedDefaultRegistries() error {
if err := s.db.Create(&reg).Error; err != nil { if err := s.db.Create(&reg).Error; err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to seed registry: "+reg.Name) return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to seed registry: "+reg.Name)
} }
s.registryCacheMu.Lock()
s.registryCache[reg.Name] = reg.ID s.registryCache[reg.Name] = reg.ID
s.registryCacheMu.Unlock()
} }
log.Info().Msg("Seeded default registries: npm, pypi, go") log.Info().Msg("Seeded default registries: npm, pypi, go")
@@ -160,9 +173,13 @@ func (s *GORMStoreV2) seedDefaultRegistries() error {
// getRegistryID returns the registry ID from cache or database // getRegistryID returns the registry ID from cache or database
func (s *GORMStoreV2) getRegistryID(name string) (int32, error) { func (s *GORMStoreV2) getRegistryID(name string) (int32, error) {
// Fast path: read lock for cache hit
s.registryCacheMu.RLock()
if id, ok := s.registryCache[name]; ok { if id, ok := s.registryCache[name]; ok {
s.registryCacheMu.RUnlock()
return id, nil return id, nil
} }
s.registryCacheMu.RUnlock()
// Not in cache, try to load from database // Not in cache, try to load from database
var reg RegistryModel var reg RegistryModel
@@ -173,7 +190,15 @@ func (s *GORMStoreV2) getRegistryID(name string) (int32, error) {
return 0, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to query registry") return 0, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to query registry")
} }
// Promote to write lock to populate cache
s.registryCacheMu.Lock()
// Re-check: another goroutine may have populated between RUnlock and Lock
if existing, ok := s.registryCache[name]; ok {
s.registryCacheMu.Unlock()
return existing, nil
}
s.registryCache[name] = reg.ID s.registryCache[name] = reg.ID
s.registryCacheMu.Unlock()
return reg.ID, nil return reg.ID, nil
} }
@@ -212,20 +237,61 @@ func (s *GORMStoreV2) SavePackage(ctx context.Context, pkg *metadata.Package) er
AuthProvider: pkg.AuthProvider, AuthProvider: pkg.AuthProvider,
} }
// Upsert package: first try to update, if no rows affected then create // Upsert package: first try to update, if no rows affected then create.
// IMPORTANT: use map[string]interface{} so that zero values
// (e.g. RequiresAuth=false, Size=0) are written explicitly. With Updates(struct)
// GORM silently skips zero fields, which leaves stale security-relevant flags.
updateFields := map[string]interface{}{
"registry_id": registryID,
"name": pkg.Name,
"version": pkg.Version,
"storage_key": pkg.StorageKey,
"size": pkg.Size,
"checksum_md5": pkg.ChecksumMD5,
"checksum_sha256": pkg.ChecksumSHA256,
"upstream_url": pkg.UpstreamURL,
"cached_at": pkg.CachedAt,
"last_accessed": pkg.LastAccessed,
"expires_at": pkg.ExpiresAt,
"requires_auth": pkg.RequiresAuth,
"auth_provider": pkg.AuthProvider,
}
result := tx.Model(&PackageModel{}). result := tx.Model(&PackageModel{}).
Where("registry_id = ? AND name = ? AND version = ?", registryID, pkg.Name, pkg.Version). Where("registry_id = ? AND name = ? AND version = ?", registryID, pkg.Name, pkg.Version).
Updates(model) Updates(updateFields)
if result.Error != nil { if result.Error != nil {
return errors.Wrap(result.Error, errors.ErrCodeStorageFailure, "failed to update package") return errors.Wrap(result.Error, errors.ErrCodeStorageFailure, "failed to update package")
} }
// If no rows were updated, create new record // If no rows were updated, create new record.
// RowsAffected == 0 here means no LIVE row matches the key, because
// GORM's default scope appends `deleted_at IS NULL` to the UPDATE.
// The only possible match is a soft-deleted row (e.g. after TTL/evict
// called DeletePackage). That retained row still holds the same
// `storage_key`, which has a UNIQUE index, so a plain Create would
// collide (UNIQUE constraint failed: packages.storage_key) and the
// re-fetch would permanently 502. Hard-delete any such stale row
// before creating.
if result.RowsAffected == 0 { if result.RowsAffected == 0 {
if err := tx.Unscoped().
Where("registry_id = ? AND name = ? AND version = ?", registryID, pkg.Name, pkg.Version).
Delete(&PackageModel{}).Error; err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to purge stale soft-deleted package before create")
}
if err := tx.Create(model).Error; err != nil { if err := tx.Create(model).Error; err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create package") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create package")
} }
} else {
// On update, fetch the row's primary key for downstream metadata save
if err := tx.Model(&PackageModel{}).
Select("id").
Where("registry_id = ? AND name = ? AND version = ?", registryID, pkg.Name, pkg.Version).
First(model).Error; err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to reload package id after update")
}
} }
// Save metadata if present // Save metadata if present
@@ -415,16 +481,15 @@ func (s *GORMStoreV2) ListPackages(ctx context.Context, opts *metadata.ListOptio
// Convert to metadata.Package // Convert to metadata.Package
packages := make([]*metadata.Package, len(models)) packages := make([]*metadata.Package, len(models))
// Snapshot id->name mapping under read lock once to avoid repeated locking
s.registryCacheMu.RLock()
idToName := make(map[int32]string, len(s.registryCache))
for name, id := range s.registryCache {
idToName[id] = name
}
s.registryCacheMu.RUnlock()
for i, model := range models { for i, model := range models {
// Get registry name from cache packages[i] = s.modelToPackage(&model, idToName[model.RegistryID])
var regName string
for name, id := range s.registryCache {
if id == model.RegistryID {
regName = name
break
}
}
packages[i] = s.modelToPackage(&model, regName)
} }
return packages, nil return packages, nil
@@ -489,6 +554,9 @@ func (s *GORMStoreV2) GetStats(ctx context.Context, registry string) (*metadata.
// Filter out metadata entries (npm metadata pages, pypi pages, etc.) // Filter out metadata entries (npm metadata pages, pypi pages, etc.)
query = query.Where("version NOT IN (?)", []string{"list", "latest", "metadata", "page"}) query = query.Where("version NOT IN (?)", []string{"list", "latest", "metadata", "page"})
// Filter out Go module metadata files (.mod, .info) - only count actual packages (.zip)
query = query.Where("name NOT LIKE ?", "%.mod").Where("name NOT LIKE ?", "%.info")
// Filter by registry if specified // Filter by registry if specified
if registry != "" { if registry != "" {
registryID, err := s.getRegistryID(registry) registryID, err := s.getRegistryID(registry)
+57 -6
View File
@@ -73,7 +73,7 @@ func (s *GORMStoreV2TestSuite) TearDownTest() {
s.store.db.Exec(fmt.Sprintf("DELETE FROM %s", table)) s.store.db.Exec(fmt.Sprintf("DELETE FROM %s", table))
} }
s.store.Close() _ = s.store.Close()
} }
} }
@@ -209,6 +209,57 @@ func (s *GORMStoreV2TestSuite) Test_V2_DeletePackage_Success() {
s.Equal(int64(1), count) // Still in DB, just soft deleted s.Equal(int64(1), count) // Still in DB, just soft deleted
} }
// Test_V2_SavePackage_AfterSoftDelete tests that saving the same
// registry/name/version key after a soft delete (e.g. TTL expiry or
// cache eviction) succeeds instead of colliding on the retained
// storage_key UNIQUE index. Regression for the permanent 502 on
// re-fetch of a once-evicted package.
func (s *GORMStoreV2TestSuite) Test_V2_SavePackage_AfterSoftDelete() {
pkg := &metadata.Package{
Registry: "npm",
Name: "re-add-test",
Version: "1.0.0",
StorageKey: "npm/re-add-test/1.0.0.tgz",
Size: 1000,
CachedAt: time.Now(),
LastAccessed: time.Now(),
}
err := s.store.SavePackage(s.ctx, pkg)
s.NoError(err)
// Soft-delete (simulates TTL expiry / eviction)
err = s.store.DeletePackage(s.ctx, "npm", "re-add-test", "1.0.0")
s.NoError(err)
// Re-save the same key (simulates re-fetch after cache miss).
// Before the fix this failed with:
// UNIQUE constraint failed: packages.storage_key
pkg2 := &metadata.Package{
Registry: "npm",
Name: "re-add-test",
Version: "1.0.0",
StorageKey: "npm/re-add-test/1.0.0.tgz",
Size: 2000,
CachedAt: time.Now(),
LastAccessed: time.Now(),
}
err = s.store.SavePackage(s.ctx, pkg2)
s.NoError(err)
// The re-saved package must be retrievable
retrieved, err := s.store.GetPackage(s.ctx, "npm", "re-add-test", "1.0.0")
s.NoError(err)
s.NotNil(retrieved)
s.Equal(int64(2000), retrieved.Size)
// No duplicate rows retained after the hard purge
var count int64
s.store.db.Unscoped().Model(&PackageModel{}).
Where("name = ?", "re-add-test").
Count(&count)
s.Equal(int64(1), count)
}
// Test_V2_ListPackages_All tests listing all packages // Test_V2_ListPackages_All tests listing all packages
func (s *GORMStoreV2TestSuite) Test_V2_ListPackages_All() { func (s *GORMStoreV2TestSuite) Test_V2_ListPackages_All() {
// Create multiple packages // Create multiple packages
@@ -352,7 +403,7 @@ func (s *GORMStoreV2TestSuite) Test_V2_Count() {
CachedAt: time.Now(), CachedAt: time.Now(),
LastAccessed: time.Now(), LastAccessed: time.Now(),
} }
err := s.store.SavePackage(s.ctx, pkg) err = s.store.SavePackage(s.ctx, pkg)
s.NoError(err) s.NoError(err)
} }
@@ -376,9 +427,9 @@ func (s *GORMStoreV2TestSuite) Test_V2_GetStats() {
} }
// Update download counts // Update download counts
s.store.UpdateDownloadCount(s.ctx, "npm", "pkg1", "1.0.0") _ = s.store.UpdateDownloadCount(s.ctx, "npm", "pkg1", "1.0.0")
s.store.UpdateDownloadCount(s.ctx, "npm", "pkg1", "1.0.0") _ = s.store.UpdateDownloadCount(s.ctx, "npm", "pkg1", "1.0.0")
s.store.UpdateDownloadCount(s.ctx, "npm", "pkg2", "1.0.0") _ = s.store.UpdateDownloadCount(s.ctx, "npm", "pkg2", "1.0.0")
// Get stats for all registries // Get stats for all registries
statsAll, err := s.store.GetStats(s.ctx, "") statsAll, err := s.store.GetStats(s.ctx, "")
@@ -486,7 +537,7 @@ func (s *GORMStoreV2TestSuite) Test_V2_ConcurrentUpdates() {
// SQLite: Sequential updates only (write lock prevents concurrent writes) // SQLite: Sequential updates only (write lock prevents concurrent writes)
updateCount := 5 updateCount := 5
for i := 0; i < updateCount; i++ { for i := 0; i < updateCount; i++ {
err := s.store.UpdateDownloadCount(s.ctx, "npm", "concurrent-test", "1.0.0") err = s.store.UpdateDownloadCount(s.ctx, "npm", "concurrent-test", "1.0.0")
s.NoError(err) s.NoError(err)
} }
+47 -5
View File
@@ -1,6 +1,8 @@
package gormstore package gormstore
import ( import (
"time"
"github.com/go-gormigrate/gormigrate/v2" "github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -30,13 +32,51 @@ func GetMigrations() []*gormigrate.Migration {
// return tx.Exec("ALTER TABLE packages DROP COLUMN new_field").Error // return tx.Exec("ALTER TABLE packages DROP COLUMN new_field").Error
// }, // },
// }, // },
{
// Behavior change: aggregation worker now deletes raw download_events
// in the same transaction as the hourly aggregation, using the same
// cutoff. This prevents double-counting in multi-replica deployments
// where the previous logic kept events for 24h and re-aggregated
// them every run. No schema change required; we purge any stale
// events older than the current aggregation cutoff so the new
// invariant (events ⇔ unaggregated) holds from now on.
ID: "202604280001",
Migrate: func(tx *gorm.DB) error {
// Best-effort cleanup. Safe to run repeatedly.
cutoff := time.Now().Add(-5 * time.Minute)
if err := tx.Exec(
"DELETE FROM download_events WHERE downloaded_at < ?",
cutoff,
).Error; err != nil {
// Table may not exist yet on a fresh install; tolerate.
return nil
}
return nil
},
Rollback: func(tx *gorm.DB) error {
// Behavior change only — nothing to roll back schema-wise.
return nil
},
},
{
// Add api_keys table for persistent authentication keys. Idempotent:
// AutoMigrate is a no-op if the table already exists with the
// expected columns.
ID: "202604280002",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&APIKeyModel{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("api_keys")
},
},
} }
} }
// migrateToV2 creates the complete V2 schema // migrateToV2 creates the complete V2 schema
func migrateToV2(tx *gorm.DB) error { func migrateToV2(tx *gorm.DB) error {
// Get dialect name for database-specific features // Get dialect name for database-specific features
dialectName := tx.Dialector.Name() dialectName := tx.Name()
// Step 1: Create all tables using GORM AutoMigrate // Step 1: Create all tables using GORM AutoMigrate
// This handles cross-database compatibility automatically // This handles cross-database compatibility automatically
@@ -59,11 +99,12 @@ func migrateToV2(tx *gorm.DB) error {
} }
// Step 3: Create database-specific optimizations // Step 3: Create database-specific optimizations
if dialectName == "postgres" { switch dialectName {
case "postgres":
if err := createPostgreSQLOptimizations(tx); err != nil { if err := createPostgreSQLOptimizations(tx); err != nil {
return err return err
} }
} else if dialectName == "mysql" { case "mysql":
if err := createMySQLOptimizations(tx); err != nil { if err := createMySQLOptimizations(tx); err != nil {
return err return err
} }
@@ -192,6 +233,7 @@ func createMySQLOptimizations(tx *gorm.DB) error {
func rollbackFromV2(tx *gorm.DB) error { func rollbackFromV2(tx *gorm.DB) error {
// Drop in reverse order to respect foreign keys // Drop in reverse order to respect foreign keys
tables := []string{ tables := []string{
"api_keys",
"audit_log", "audit_log",
"download_stats_daily", "download_stats_daily",
"download_stats_hourly", "download_stats_hourly",
@@ -206,13 +248,13 @@ func rollbackFromV2(tx *gorm.DB) error {
} }
// Drop PostgreSQL-specific objects // Drop PostgreSQL-specific objects
if tx.Dialector.Name() == "postgres" { if tx.Name() == "postgres" {
tx.Exec("DROP VIEW IF EXISTS v_vulnerable_packages") tx.Exec("DROP VIEW IF EXISTS v_vulnerable_packages")
tx.Exec("DROP FUNCTION IF EXISTS create_next_month_partitions()") tx.Exec("DROP FUNCTION IF EXISTS create_next_month_partitions()")
} }
// Drop MySQL-specific objects // Drop MySQL-specific objects
if tx.Dialector.Name() == "mysql" { if tx.Name() == "mysql" {
tx.Exec("DROP VIEW IF EXISTS v_vulnerable_packages") tx.Exec("DROP VIEW IF EXISTS v_vulnerable_packages")
} }
+130 -101
View File
@@ -18,13 +18,13 @@ type BaseModel struct {
// RegistryModel represents package registries (normalized) // RegistryModel represents package registries (normalized)
// This eliminates repetition of "npm", "pypi", "go" across millions of rows // This eliminates repetition of "npm", "pypi", "go" across millions of rows
type RegistryModel struct { type RegistryModel struct {
ID int32 `gorm:"primaryKey;autoIncrement"` BaseModel
Name string `gorm:"uniqueIndex:idx_registry_name;not null;size:50"` // npm, pypi, go Name string `gorm:"uniqueIndex:idx_registry_name;not null;size:50"` // npm, pypi, go
DisplayName string `gorm:"not null;size:100"` // NPM Registry, PyPI, Go Modules DisplayName string `gorm:"not null;size:100"` // NPM Registry, PyPI, Go Modules
UpstreamURL string `gorm:"not null;size:512"` // https://registry.npmjs.org UpstreamURL string `gorm:"not null;size:512"` // https://registry.npmjs.org
ID int32 `gorm:"primaryKey;autoIncrement"`
Enabled bool `gorm:"not null;default:true;index:idx_registry_enabled"` Enabled bool `gorm:"not null;default:true;index:idx_registry_enabled"`
ScanByDefault bool `gorm:"not null;default:true"` ScanByDefault bool `gorm:"not null;default:true"`
BaseModel
} }
func (RegistryModel) TableName() string { func (RegistryModel) TableName() string {
@@ -33,45 +33,50 @@ func (RegistryModel) TableName() string {
// PackageModel represents the core package data (optimized) // PackageModel represents the core package data (optimized)
type PackageModel struct { type PackageModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
RegistryID int32 `gorm:"not null;index:idx_package_registry_name_version,priority:1"` // Foreign key to registries // Cache management
Name string `gorm:"not null;size:255;index:idx_package_name;index:idx_package_registry_name_version,priority:2"` CachedAt time.Time `gorm:"not null;index:idx_package_cached_at"`
Version string `gorm:"not null;size:100;index:idx_package_registry_name_version,priority:3"` LastAccessed time.Time `gorm:"not null;index:idx_package_last_accessed"` // For LRU eviction
ExpiresAt *time.Time `gorm:"index:idx_package_expires_at"` // For cache invalidation
LastScannedAt *time.Time `gorm:"index:idx_package_last_scanned"`
Metadata *PackageMetadataModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
// Relationships
Registry RegistryModel `gorm:"foreignKey:RegistryID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
BaseModel
Name string `gorm:"not null;size:255;index:idx_package_name;index:idx_package_registry_name_version,priority:2"`
Version string `gorm:"not null;size:100;index:idx_package_registry_name_version,priority:3"`
// Storage information // Storage information
StorageKey string `gorm:"not null;uniqueIndex:idx_package_storage_key;size:512"` StorageKey string `gorm:"not null;uniqueIndex:idx_package_storage_key;size:512"`
Size int64 `gorm:"not null;index:idx_package_size"` // For storage quota queries
ChecksumMD5 string `gorm:"size:32;index:idx_package_md5"` ChecksumMD5 string `gorm:"size:32;index:idx_package_md5"`
ChecksumSHA256 string `gorm:"size:64;index:idx_package_sha256"` ChecksumSHA256 string `gorm:"size:64;index:idx_package_sha256"`
UpstreamURL string `gorm:"size:1024"` UpstreamURL string `gorm:"size:1024"`
// Cache management HighestSeverity string `gorm:"size:20;index:idx_package_severity"` // critical, high, medium, low, none
CachedAt time.Time `gorm:"not null;index:idx_package_cached_at"` AuthProvider string `gorm:"size:50;index:idx_package_auth_provider"` // github, gitlab, custom
LastAccessed time.Time `gorm:"not null;index:idx_package_last_accessed"` // For LRU eviction
ExpiresAt *time.Time `gorm:"index:idx_package_expires_at"` // For cache invalidation
AccessCount int64 `gorm:"not null;default:0;index:idx_package_access_count"` // Total access count (denormalized for performance)
// Security
SecurityScanned bool `gorm:"not null;default:false;index:idx_package_security_scanned"`
LastScannedAt *time.Time `gorm:"index:idx_package_last_scanned"`
VulnerabilityCount int `gorm:"not null;default:0;index:idx_package_vuln_count"` // Denormalized for fast filtering
HighestSeverity string `gorm:"size:20;index:idx_package_severity"` // critical, high, medium, low, none
CriticalCount int `gorm:"not null;default:0"` // Count of critical vulnerabilities
HighCount int `gorm:"not null;default:0"` // Count of high vulnerabilities
ModerateCount int `gorm:"not null;default:0"` // Count of moderate vulnerabilities
LowCount int `gorm:"not null;default:0"` // Count of low vulnerabilities
// Authentication
RequiresAuth bool `gorm:"not null;default:false;index:idx_package_requires_auth"`
AuthProvider string `gorm:"size:50;index:idx_package_auth_provider"` // github, gitlab, custom
BaseModel
// Relationships
Registry RegistryModel `gorm:"foreignKey:RegistryID;constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
Metadata *PackageMetadataModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
ScanResults []ScanResultModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` ScanResults []ScanResultModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Vulnerabilities []PackageVulnerabilityModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Vulnerabilities []PackageVulnerabilityModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
ID int64 `gorm:"primaryKey;autoIncrement"`
Size int64 `gorm:"not null;index:idx_package_size"` // For storage quota queries
AccessCount int64 `gorm:"not null;default:0;index:idx_package_access_count"` // Total access count (denormalized for performance)
VulnerabilityCount int `gorm:"not null;default:0;index:idx_package_vuln_count"` // Denormalized for fast filtering
CriticalCount int `gorm:"not null;default:0"` // Count of critical vulnerabilities
HighCount int `gorm:"not null;default:0"` // Count of high vulnerabilities
ModerateCount int `gorm:"not null;default:0"` // Count of moderate vulnerabilities
LowCount int `gorm:"not null;default:0"` // Count of low vulnerabilities
RegistryID int32 `gorm:"not null;index:idx_package_registry_name_version,priority:1"` // Foreign key to registries
// Security
SecurityScanned bool `gorm:"not null;default:false;index:idx_package_security_scanned"`
// Authentication
RequiresAuth bool `gorm:"not null;default:false;index:idx_package_requires_auth"`
} }
func (PackageModel) TableName() string { func (PackageModel) TableName() string {
@@ -89,15 +94,15 @@ func (p *PackageModel) BeforeCreate(tx *gorm.DB) error {
// PackageMetadataModel stores structured package metadata (1:1 with packages) // PackageMetadataModel stores structured package metadata (1:1 with packages)
// Separated from main table to reduce row size and improve query performance // Separated from main table to reduce row size and improve query performance
type PackageMetadataModel struct { type PackageMetadataModel struct {
PackageID int64 `gorm:"primaryKey;not null"` // 1:1 relationship RawMetadata JSONBField `gorm:"type:jsonb"` // Full metadata as JSONB (PostgreSQL) or JSON
BaseModel
Author string `gorm:"size:255;index:idx_metadata_author"` Author string `gorm:"size:255;index:idx_metadata_author"`
License string `gorm:"size:100;index:idx_metadata_license"` License string `gorm:"size:100;index:idx_metadata_license"`
Homepage string `gorm:"size:512"` Homepage string `gorm:"size:512"`
Repository string `gorm:"size:512"` Repository string `gorm:"size:512"`
Description string `gorm:"type:text"` Description string `gorm:"type:text"`
Keywords PostgresArray `gorm:"type:text"` // JSONB array for PostgreSQL, JSON for MySQL/SQLite Keywords PostgresArray `gorm:"type:text"` // JSONB array for PostgreSQL, JSON for MySQL/SQLite
RawMetadata JSONBField `gorm:"type:jsonb"` // Full metadata as JSONB (PostgreSQL) or JSON PackageID int64 `gorm:"primaryKey;not null"` // 1:1 relationship
BaseModel
} }
func (PackageMetadataModel) TableName() string { func (PackageMetadataModel) TableName() string {
@@ -106,21 +111,22 @@ func (PackageMetadataModel) TableName() string {
// ScanResultModel represents security scan results (optimized) // ScanResultModel represents security scan results (optimized)
type ScanResultModel struct { type ScanResultModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ScannedAt time.Time `gorm:"not null;index:idx_scan_scanned_at"`
PackageID int64 `gorm:"not null;index:idx_scan_package_scanner,priority:1"` // Foreign key Details JSONBField `gorm:"type:jsonb"` // Scanner-specific details
Scanner string `gorm:"not null;size:50;index:idx_scan_scanner;index:idx_scan_package_scanner,priority:2"`
ScannedAt time.Time `gorm:"not null;index:idx_scan_scanned_at"`
Status string `gorm:"not null;size:20;index:idx_scan_status"` // success, failed, pending
VulnCount int `gorm:"not null;default:0;index:idx_scan_vuln_count"`
CriticalCount int `gorm:"not null;default:0"`
HighCount int `gorm:"not null;default:0"`
MediumCount int `gorm:"not null;default:0"`
LowCount int `gorm:"not null;default:0"`
ScanDuration int `gorm:"not null;default:0"` // milliseconds
Details JSONBField `gorm:"type:jsonb"` // Scanner-specific details
BaseModel BaseModel
Package PackageModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Scanner string `gorm:"not null;size:50;index:idx_scan_scanner;index:idx_scan_package_scanner,priority:2"`
Status string `gorm:"not null;size:20;index:idx_scan_status"` // success, failed, pending
Package PackageModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
ID int64 `gorm:"primaryKey;autoIncrement"`
PackageID int64 `gorm:"not null;index:idx_scan_package_scanner,priority:1"` // Foreign key
VulnCount int `gorm:"not null;default:0;index:idx_scan_vuln_count"`
CriticalCount int `gorm:"not null;default:0"`
HighCount int `gorm:"not null;default:0"`
MediumCount int `gorm:"not null;default:0"`
LowCount int `gorm:"not null;default:0"`
ScanDuration int `gorm:"not null;default:0"` // milliseconds
} }
func (ScanResultModel) TableName() string { func (ScanResultModel) TableName() string {
@@ -129,16 +135,16 @@ func (ScanResultModel) TableName() string {
// VulnerabilityModel represents unique vulnerabilities (normalized) // VulnerabilityModel represents unique vulnerabilities (normalized)
type VulnerabilityModel struct { type VulnerabilityModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"` PublishedAt time.Time `gorm:"not null;index:idx_vuln_published"`
BaseModel
CVEID string `gorm:"uniqueIndex:idx_vuln_cve_id;not null;size:50"` // CVE-2021-12345 CVEID string `gorm:"uniqueIndex:idx_vuln_cve_id;not null;size:50"` // CVE-2021-12345
Title string `gorm:"not null;size:512"` Title string `gorm:"not null;size:512"`
Description string `gorm:"type:text"` Description string `gorm:"type:text"`
Severity string `gorm:"not null;size:20;index:idx_vuln_severity"` // critical, high, medium, low Severity string `gorm:"not null;size:20;index:idx_vuln_severity"` // critical, high, medium, low
CVSS float32 `gorm:"index:idx_vuln_cvss"` // CVSS score for sorting FixedVersion string `gorm:"size:100"` // First version where it's fixed
PublishedAt time.Time `gorm:"not null;index:idx_vuln_published"` References PostgresArray `gorm:"type:text"` // URLs to advisories
FixedVersion string `gorm:"size:100"` // First version where it's fixed ID int64 `gorm:"primaryKey;autoIncrement"`
References PostgresArray `gorm:"type:text"` // URLs to advisories CVSS float32 `gorm:"index:idx_vuln_cvss"` // CVSS score for sorting
BaseModel
} }
func (VulnerabilityModel) TableName() string { func (VulnerabilityModel) TableName() string {
@@ -147,17 +153,18 @@ func (VulnerabilityModel) TableName() string {
// PackageVulnerabilityModel is a many-to-many relationship between packages and vulnerabilities // PackageVulnerabilityModel is a many-to-many relationship between packages and vulnerabilities
type PackageVulnerabilityModel struct { type PackageVulnerabilityModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"` DetectedAt time.Time `gorm:"not null;index:idx_pkg_vuln_detected"`
PackageID int64 `gorm:"not null;index:idx_pkg_vuln_package,priority:1;index:idx_pkg_vuln_composite,priority:1"` BypassID *int64 `gorm:"index:idx_pkg_vuln_bypass_id"` // Reference to bypass if applicable
VulnerabilityID int64 `gorm:"not null;index:idx_pkg_vuln_vuln,priority:1;index:idx_pkg_vuln_composite,priority:2"` Vulnerability VulnerabilityModel `gorm:"foreignKey:VulnerabilityID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Scanner string `gorm:"not null;size:50;index:idx_pkg_vuln_scanner"`
DetectedAt time.Time `gorm:"not null;index:idx_pkg_vuln_detected"`
Bypassed bool `gorm:"not null;default:false;index:idx_pkg_vuln_bypassed"`
BypassID *int64 `gorm:"index:idx_pkg_vuln_bypass_id"` // Reference to bypass if applicable
BaseModel BaseModel
Package PackageModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` Scanner string `gorm:"not null;size:50;index:idx_pkg_vuln_scanner"`
Vulnerability VulnerabilityModel `gorm:"foreignKey:VulnerabilityID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Package PackageModel `gorm:"foreignKey:PackageID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
ID int64 `gorm:"primaryKey;autoIncrement"`
PackageID int64 `gorm:"not null;index:idx_pkg_vuln_package,priority:1;index:idx_pkg_vuln_composite,priority:1"`
VulnerabilityID int64 `gorm:"not null;index:idx_pkg_vuln_vuln,priority:1;index:idx_pkg_vuln_composite,priority:2"`
Bypassed bool `gorm:"not null;default:false;index:idx_pkg_vuln_bypassed"`
} }
func (PackageVulnerabilityModel) TableName() string { func (PackageVulnerabilityModel) TableName() string {
@@ -166,22 +173,22 @@ func (PackageVulnerabilityModel) TableName() string {
// CVEBypassModel represents CVE bypass rules (improved) // CVEBypassModel represents CVE bypass rules (improved)
type CVEBypassModel struct { type CVEBypassModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ExpiresAt time.Time `gorm:"not null;index:idx_bypass_expires_at"`
Type string `gorm:"not null;size:20;index:idx_bypass_type"` // cve, package, registry LastUsedAt *time.Time `gorm:"index:idx_bypass_last_used"`
Target string `gorm:"not null;size:512;index:idx_bypass_target"` // CVE-ID, package name, etc.
Reason string `gorm:"not null;type:text"`
CreatedBy string `gorm:"not null;size:255;index:idx_bypass_created_by"`
ExpiresAt time.Time `gorm:"not null;index:idx_bypass_expires_at"`
NotifyOnExpiry bool `gorm:"not null;default:false"`
Active bool `gorm:"not null;default:true;index:idx_bypass_active"`
UsageCount int64 `gorm:"not null;default:0"` // How many times this bypass has been used
LastUsedAt *time.Time `gorm:"index:idx_bypass_last_used"`
// Scope limiting (optional) // Scope limiting (optional)
RegistryID *int32 `gorm:"index:idx_bypass_registry"` // NULL = all registries RegistryID *int32 `gorm:"index:idx_bypass_registry"` // NULL = all registries
PackageID *int64 `gorm:"index:idx_bypass_package"` // NULL = all packages PackageID *int64 `gorm:"index:idx_bypass_package"` // NULL = all packages
BaseModel BaseModel
Type string `gorm:"not null;size:20;index:idx_bypass_type"` // cve, package, registry
Target string `gorm:"not null;size:512;index:idx_bypass_target"` // CVE-ID, package name, etc.
Reason string `gorm:"not null;type:text"`
CreatedBy string `gorm:"not null;size:255;index:idx_bypass_created_by"`
ID int64 `gorm:"primaryKey;autoIncrement"`
UsageCount int64 `gorm:"not null;default:0"` // How many times this bypass has been used
NotifyOnExpiry bool `gorm:"not null;default:false"`
Active bool `gorm:"not null;default:true;index:idx_bypass_active"`
} }
func (CVEBypassModel) TableName() string { func (CVEBypassModel) TableName() string {
@@ -191,17 +198,17 @@ func (CVEBypassModel) TableName() string {
// DownloadEventModel represents raw download events (partitioned by month) // DownloadEventModel represents raw download events (partitioned by month)
// This table should use PostgreSQL partitioning or time-series DB features // This table should use PostgreSQL partitioning or time-series DB features
type DownloadEventModel struct { type DownloadEventModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"` DownloadedAt time.Time `gorm:"not null;index:idx_download_time;index:idx_download_package,priority:2"` // Partition key
PackageID int64 `gorm:"not null;index:idx_download_package,priority:1"` UserAgent string `gorm:"size:512"` // For analytics
RegistryID int32 `gorm:"not null;index:idx_download_registry"` IPAddress string `gorm:"size:45;index:idx_download_ip"` // IPv6 support
DownloadedAt time.Time `gorm:"not null;index:idx_download_time;index:idx_download_package,priority:2"` // Partition key Username string `gorm:"size:255;index:idx_download_user"`
UserAgent string `gorm:"size:512"` // For analytics
IPAddress string `gorm:"size:45;index:idx_download_ip"` // IPv6 support
Authenticated bool `gorm:"not null;default:false"`
Username string `gorm:"size:255;index:idx_download_user"`
// No BaseModel - this is append-only, no updates/deletes on individual rows // No BaseModel - this is append-only, no updates/deletes on individual rows
// Partitioned tables handle cleanup via DROP PARTITION // Partitioned tables handle cleanup via DROP PARTITION
ID int64 `gorm:"primaryKey;autoIncrement"`
PackageID int64 `gorm:"not null;index:idx_download_package,priority:1"`
RegistryID int32 `gorm:"not null;index:idx_download_registry"`
Authenticated bool `gorm:"not null;default:false"`
} }
func (DownloadEventModel) TableName() string { func (DownloadEventModel) TableName() string {
@@ -210,15 +217,16 @@ func (DownloadEventModel) TableName() string {
// DownloadStatsHourlyModel represents pre-aggregated hourly statistics (partitioned) // DownloadStatsHourlyModel represents pre-aggregated hourly statistics (partitioned)
type DownloadStatsHourlyModel struct { type DownloadStatsHourlyModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"` TimeBucket time.Time `gorm:"not null;index:idx_stats_hourly_composite,priority:2"` // Truncated to hour
RegistryID int32 `gorm:"not null;index:idx_stats_hourly_composite,priority:1"` PackageID *int64 `gorm:"index:idx_stats_hourly_package"` // NULL = all packages in registry
PackageID *int64 `gorm:"index:idx_stats_hourly_package"` // NULL = all packages in registry
TimeBucket time.Time `gorm:"not null;index:idx_stats_hourly_composite,priority:2"` // Truncated to hour
DownloadCount int64 `gorm:"not null;default:0"`
UniqueIPs int64 `gorm:"not null;default:0"` // Unique downloaders
AuthDownloads int64 `gorm:"not null;default:0"` // Authenticated downloads
BaseModel BaseModel
ID int64 `gorm:"primaryKey;autoIncrement"`
DownloadCount int64 `gorm:"not null;default:0"`
UniqueIPs int64 `gorm:"not null;default:0"` // Unique downloaders
AuthDownloads int64 `gorm:"not null;default:0"` // Authenticated downloads
RegistryID int32 `gorm:"not null;index:idx_stats_hourly_composite,priority:1"`
} }
func (DownloadStatsHourlyModel) TableName() string { func (DownloadStatsHourlyModel) TableName() string {
@@ -227,16 +235,16 @@ func (DownloadStatsHourlyModel) TableName() string {
// DownloadStatsDailyModel represents pre-aggregated daily statistics // DownloadStatsDailyModel represents pre-aggregated daily statistics
type DownloadStatsDailyModel struct { type DownloadStatsDailyModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
RegistryID int32 `gorm:"not null;index:idx_stats_daily_composite,priority:1"`
PackageID *int64 `gorm:"index:idx_stats_daily_package"` // NULL = all packages in registry
TimeBucket time.Time `gorm:"not null;index:idx_stats_daily_composite,priority:2"` // Truncated to day TimeBucket time.Time `gorm:"not null;index:idx_stats_daily_composite,priority:2"` // Truncated to day
DownloadCount int64 `gorm:"not null;default:0"` PackageID *int64 `gorm:"index:idx_stats_daily_package"` // NULL = all packages in registry
UniqueIPs int64 `gorm:"not null;default:0"` TopUserAgents JSONBField `gorm:"type:jsonb"` // Top 10 user agents
AuthDownloads int64 `gorm:"not null;default:0"`
TopUserAgents JSONBField `gorm:"type:jsonb"` // Top 10 user agents
BaseModel BaseModel
ID int64 `gorm:"primaryKey;autoIncrement"`
DownloadCount int64 `gorm:"not null;default:0"`
UniqueIPs int64 `gorm:"not null;default:0"`
AuthDownloads int64 `gorm:"not null;default:0"`
RegistryID int32 `gorm:"not null;index:idx_stats_daily_composite,priority:1"`
} }
func (DownloadStatsDailyModel) TableName() string { func (DownloadStatsDailyModel) TableName() string {
@@ -245,23 +253,43 @@ func (DownloadStatsDailyModel) TableName() string {
// AuditLogModel tracks all important changes (optional, for compliance) // AuditLogModel tracks all important changes (optional, for compliance)
type AuditLogModel struct { type AuditLogModel struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
EntityType string `gorm:"not null;size:50;index:idx_audit_entity_type"` // package, bypass, registry
EntityID int64 `gorm:"not null;index:idx_audit_entity_id"`
Action string `gorm:"not null;size:20;index:idx_audit_action"` // create, update, delete
Username string `gorm:"not null;size:255;index:idx_audit_username"`
Timestamp time.Time `gorm:"not null;index:idx_audit_timestamp"` Timestamp time.Time `gorm:"not null;index:idx_audit_timestamp"`
Changes JSONBField `gorm:"type:jsonb"` // Before/after values Changes JSONBField `gorm:"type:jsonb"` // Before/after values
EntityType string `gorm:"not null;size:50;index:idx_audit_entity_type"` // package, bypass, registry
Action string `gorm:"not null;size:20;index:idx_audit_action"` // create, update, delete
Username string `gorm:"not null;size:255;index:idx_audit_username"`
IPAddress string `gorm:"size:45"` IPAddress string `gorm:"size:45"`
UserAgent string `gorm:"size:512"` UserAgent string `gorm:"size:512"`
// No BaseModel - append-only audit log // No BaseModel - append-only audit log
ID int64 `gorm:"primaryKey;autoIncrement"`
EntityID int64 `gorm:"not null;index:idx_audit_entity_id"`
} }
func (AuditLogModel) TableName() string { func (AuditLogModel) TableName() string {
return "audit_log" return "audit_log"
} }
// APIKeyModel represents a persisted authentication API key.
// Index on (id, project, revoked) is provided via composite index
// idx_api_key_lookup so the auth manager can quickly enumerate
// non-revoked keys per project at startup.
type APIKeyModel struct {
CreatedAt time.Time `gorm:"not null"`
ExpiresAt *time.Time `gorm:"index:idx_api_key_expires"`
LastUsedAt *time.Time `gorm:"index:idx_api_key_last_used"`
ID string `gorm:"primaryKey;size:64;index:idx_api_key_lookup,priority:1"`
KeyHash string `gorm:"not null;size:255"` // bcrypt hash
Project string `gorm:"not null;size:255;index:idx_api_key_project;index:idx_api_key_lookup,priority:2"`
Role string `gorm:"not null;size:32;index:idx_api_key_role"` // read_only, read_write, admin
Permissions string `gorm:"type:text"` // JSON-encoded list (optional)
Revoked bool `gorm:"not null;default:false;index:idx_api_key_revoked;index:idx_api_key_lookup,priority:3"`
}
func (APIKeyModel) TableName() string {
return "api_keys"
}
// JSONBField is a custom type for JSONB (PostgreSQL) / JSON (MySQL/SQLite) // JSONBField is a custom type for JSONB (PostgreSQL) / JSON (MySQL/SQLite)
type JSONBField map[string]interface{} type JSONBField map[string]interface{}
@@ -324,5 +352,6 @@ func GetAllModels() []interface{} {
&DownloadStatsHourlyModel{}, &DownloadStatsHourlyModel{},
&DownloadStatsDailyModel{}, &DownloadStatsDailyModel{},
&AuditLogModel{}, &AuditLogModel{},
&APIKeyModel{},
} }
} }
+29 -15
View File
@@ -2,12 +2,20 @@ package gormstore
import ( import (
"fmt" "fmt"
"regexp"
"time" "time"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"gorm.io/gorm" "gorm.io/gorm"
) )
// Whitelist regexes for partition table names — defense in depth before
// we interpolate them into raw DDL.
var (
downloadEventPartitionRe = regexp.MustCompile(`^download_events_\d{4}_\d{2}$`)
auditLogPartitionRe = regexp.MustCompile(`^audit_log_\d{4}_\d{2}$`)
)
// PartitionManager handles automatic partition creation and cleanup for PostgreSQL // PartitionManager handles automatic partition creation and cleanup for PostgreSQL
type PartitionManager struct { type PartitionManager struct {
db *gorm.DB db *gorm.DB
@@ -21,7 +29,7 @@ func NewPartitionManager(db *gorm.DB) *PartitionManager {
// EnsurePartitions ensures required partitions exist for current and future months // EnsurePartitions ensures required partitions exist for current and future months
func (pm *PartitionManager) EnsurePartitions() error { func (pm *PartitionManager) EnsurePartitions() error {
// Check if we're using PostgreSQL // Check if we're using PostgreSQL
if pm.db.Dialector.Name() != "postgres" { if pm.db.Name() != "postgres" {
log.Debug().Msg("Partitioning only supported on PostgreSQL, skipping") log.Debug().Msg("Partitioning only supported on PostgreSQL, skipping")
return nil return nil
} }
@@ -286,7 +294,7 @@ func (pm *PartitionManager) createPartitionFunction() error {
// CleanupOldPartitions drops partitions older than the retention period // CleanupOldPartitions drops partitions older than the retention period
func (pm *PartitionManager) CleanupOldPartitions(retentionMonths int) error { func (pm *PartitionManager) CleanupOldPartitions(retentionMonths int) error {
if pm.db.Dialector.Name() != "postgres" { if pm.db.Name() != "postgres" {
return nil return nil
} }
@@ -300,39 +308,45 @@ func (pm *PartitionManager) CleanupOldPartitions(retentionMonths int) error {
// Find and drop old download_events partitions // Find and drop old download_events partitions
var downloadPartitions []string var downloadPartitions []string
err := pm.db.Raw(` if err := pm.db.Raw(`
SELECT tablename FROM pg_tables SELECT tablename FROM pg_tables
WHERE tablename LIKE 'download_events_%' WHERE tablename LIKE 'download_events_%'
AND tablename < 'download_events_' || ? AND tablename < 'download_events_' || ?
`, cutoffPartition).Scan(&downloadPartitions).Error `, cutoffPartition).Scan(&downloadPartitions).Error; err != nil {
if err != nil {
return err return err
} }
for _, partition := range downloadPartitions { for _, partition := range downloadPartitions {
// Defense in depth: even though tablename came from pg_tables, verify it
// matches the expected partition naming scheme before interpolating into DDL.
if !downloadEventPartitionRe.MatchString(partition) {
log.Warn().Str("partition", partition).Msg("Skipping partition with unexpected name")
continue
}
log.Info().Str("partition", partition).Msg("Dropping old partition") log.Info().Str("partition", partition).Msg("Dropping old partition")
if err := pm.db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", partition)).Error; err != nil { if dropErr := pm.db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", partition)).Error; dropErr != nil {
log.Error().Err(err).Str("partition", partition).Msg("Failed to drop partition") log.Error().Err(dropErr).Str("partition", partition).Msg("Failed to drop partition")
} }
} }
// Find and drop old audit_log partitions // Find and drop old audit_log partitions
var auditPartitions []string var auditPartitions []string
err = pm.db.Raw(` if err := pm.db.Raw(`
SELECT tablename FROM pg_tables SELECT tablename FROM pg_tables
WHERE tablename LIKE 'audit_log_%' WHERE tablename LIKE 'audit_log_%'
AND tablename < 'audit_log_' || ? AND tablename < 'audit_log_' || ?
`, cutoffPartition).Scan(&auditPartitions).Error `, cutoffPartition).Scan(&auditPartitions).Error; err != nil {
if err != nil {
return err return err
} }
for _, partition := range auditPartitions { for _, partition := range auditPartitions {
if !auditLogPartitionRe.MatchString(partition) {
log.Warn().Str("partition", partition).Msg("Skipping audit partition with unexpected name")
continue
}
log.Info().Str("partition", partition).Msg("Dropping old audit partition") log.Info().Str("partition", partition).Msg("Dropping old audit partition")
if err := pm.db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", partition)).Error; err != nil { if dropErr := pm.db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", partition)).Error; dropErr != nil {
log.Error().Err(err).Str("partition", partition).Msg("Failed to drop audit partition") log.Error().Err(dropErr).Str("partition", partition).Msg("Failed to drop audit partition")
} }
} }
@@ -341,7 +355,7 @@ func (pm *PartitionManager) CleanupOldPartitions(retentionMonths int) error {
// GetPartitionInfo returns information about current partitions // GetPartitionInfo returns information about current partitions
func (pm *PartitionManager) GetPartitionInfo() (map[string]interface{}, error) { func (pm *PartitionManager) GetPartitionInfo() (map[string]interface{}, error) {
if pm.db.Dialector.Name() != "postgres" { if pm.db.Name() != "postgres" {
return map[string]interface{}{"status": "not_applicable"}, nil return map[string]interface{}{"status": "not_applicable"}, nil
} }
+46
View File
@@ -1,11 +1,19 @@
// Package metadata defines the Store interface and shared model types for
// package metadata persistence backends.
package metadata package metadata
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
) )
// ErrNotImplemented signals that an optional MetadataStore method is not
// supported by the current backend (e.g. a backend may not persist API keys).
// Callers should treat it as a non-fatal capability check.
var ErrNotImplemented = errors.New("metadata: operation not implemented by this backend")
// Store is an alias for MetadataStore for convenience // Store is an alias for MetadataStore for convenience
type Store = MetadataStore type Store = MetadataStore
@@ -62,10 +70,48 @@ type MetadataStore interface {
// AggregateDownloadData aggregates raw download events and cleans up old data // AggregateDownloadData aggregates raw download events and cleans up old data
AggregateDownloadData(ctx context.Context) error AggregateDownloadData(ctx context.Context) error
// SaveAPIKey persists (insert or update) an API key record. Backends that
// do not implement persistent API keys MUST return ErrNotImplemented.
SaveAPIKey(ctx context.Context, key *APIKey) error
// GetAPIKey retrieves a single API key by ID. Returns ErrNotImplemented if
// the backend does not persist API keys. Returns a not-found error wrapped
// from the backend if the key does not exist.
GetAPIKey(ctx context.Context, id string) (*APIKey, error)
// ListAPIKeys returns all API keys (including revoked) so callers can
// decide whether to filter. Returns ErrNotImplemented for backends that
// do not persist API keys.
ListAPIKeys(ctx context.Context) ([]*APIKey, error)
// DeleteAPIKey removes an API key by ID. Returns ErrNotImplemented for
// backends that do not persist API keys.
DeleteAPIKey(ctx context.Context, id string) error
// UpdateAPIKeyLastUsed updates the last-used timestamp without rewriting
// the rest of the row. Implementations should make this cheap; auth.Manager
// may invoke it asynchronously on every successful validation.
UpdateAPIKeyLastUsed(ctx context.Context, id string, t time.Time) error
// Close closes the metadata store // Close closes the metadata store
Close() error Close() error
} }
// APIKey is the storage-layer representation of an authentication API key.
// It is intentionally separate from auth.APIKey to avoid an import cycle:
// the auth package depends on metadata, never the other way around.
type APIKey struct {
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ID string `json:"id"`
KeyHash string `json:"key_hash"`
Project string `json:"project"`
Role string `json:"role"`
Permissions string `json:"permissions,omitempty"` // JSON-encoded list (optional)
Revoked bool `json:"revoked"`
}
// Package represents package metadata // Package represents package metadata
type Package struct { type Package struct {
CachedAt time.Time `json:"cached_at"` CachedAt time.Time `json:"cached_at"`
+2
View File
@@ -1,3 +1,5 @@
// Package metrics exposes Prometheus instrumentation for cache, storage,
// scanner, and HTTP request flows.
package metrics package metrics
import ( import (
+3 -8
View File
@@ -1,3 +1,5 @@
// Package network provides resilient HTTP client primitives with retry,
// timeout, and circuit-breaker support for upstream registry calls.
package network package network
import ( import (
@@ -227,19 +229,12 @@ func (c *Client) do(ctx context.Context, req *http.Request) (*http.Response, err
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
lastErr = err lastErr = err
if c.circuitBreaker != nil {
c.circuitBreaker.RecordFailure()
}
continue continue
} }
// Check if response is retryable // Check if response is retryable
if c.isRetryable(resp.StatusCode) { if c.isRetryable(resp.StatusCode) {
resp.Body.Close() // #nosec G104 -- Cleanup, error not critical _ = resp.Body.Close() // #nosec G104 -- Cleanup, error not critical
lastErr = fmt.Errorf("received retryable status code: %d", resp.StatusCode) lastErr = fmt.Errorf("received retryable status code: %d", resp.StatusCode)
if c.circuitBreaker != nil {
c.circuitBreaker.RecordFailure()
}
continue continue
} }
+35
View File
@@ -405,3 +405,38 @@ func TestConcurrentRequests(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
} }
} }
// TestCircuitBreakerOpens verifies that a configured number of consecutive
// failed requests opens the circuit breaker and that subsequent requests are
// rejected with the circuit-open error until the breaker half-open window
// elapses and a request succeeds again.
func TestCircuitBreakerOpens(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close() // #nosec G104 -- Cleanup, error not critical
client := network.NewClient(network.Config{
Timeout: 5 * time.Second,
MaxRetries: 0,
CircuitBreaker: network.CircuitBreakerConfig{
Enabled: true,
FailureThreshold: 3,
SuccessThreshold: 1,
Timeout: time.Hour,
},
})
ctx := context.Background()
// Three consecutive failed requests open the breaker.
for i := 0; i < 3; i++ {
_, _, err := client.Get(ctx, server.URL, nil)
assert.Error(t, err)
}
// The next request is rejected because the breaker is open.
_, _, err := client.Get(ctx, server.URL, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "circuit breaker is open")
}
+3 -1
View File
@@ -1,3 +1,5 @@
// Package prewarming runs background workers that pre-fetch hot packages
// to reduce first-request latency.
package prewarming package prewarming
import ( import (
@@ -202,7 +204,7 @@ func (w *Worker) prewarmPackage(ctx context.Context, pkg PackageInfo, workerID i
Msg("Failed to fetch package for pre-warming") Msg("Failed to fetch package for pre-warming")
return return
} }
defer body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = body.Close() }() // #nosec G104 -- Cleanup, error not critical
if statusCode != 200 { if statusCode != 200 {
log.Warn(). log.Warn().
+69 -43
View File
@@ -1,3 +1,5 @@
// Package goproxy implements the HTTP handler that proxies Go module
// requests through the GoHoarder cache.
package goproxy package goproxy
import ( import (
@@ -113,7 +115,7 @@ func (h *Handler) handleList(ctx context.Context, w http.ResponseWriter, r *http
// Extract credentials from request // Extract credentials from request
credentials := h.credExtractor.Extract(r) credentials := h.credExtractor.Extract(r)
entry, err := h.cache.Get(ctx, "go", modulePath, "list", func(ctx context.Context) (io.ReadCloser, string, error) { entry, err := h.cache.Get(ctx, "go", modulePath, "list", func(ctx context.Context) (*cache.FetchResult, error) {
// Prepare headers for upstream request // Prepare headers for upstream request
headers := make(map[string]string) headers := make(map[string]string)
if credentials != "" { if credentials != "" {
@@ -122,13 +124,16 @@ func (h *Handler) handleList(ctx context.Context, w http.ResponseWriter, r *http
body, statusCode, err := h.client.Get(ctx, url, headers) body, statusCode, err := h.client.Get(ctx, url, headers)
if err != nil { if err != nil {
return nil, "", err return nil, err
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.ProviderFor(modulePath)}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close() // #nosec G104 -- Cleanup, error not critical
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -136,7 +141,7 @@ func (h *Handler) handleList(ctx context.Context, w http.ResponseWriter, r *http
http.Error(w, "Failed to fetch version list", http.StatusBadGateway) http.Error(w, "Failed to fetch version list", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = entry.Data.Close() }() // #nosec G104 -- Cleanup, error not critical
w.Header().Set("Content-Type", "text/plain; charset=UTF-8") w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
_, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write
@@ -153,7 +158,7 @@ func (h *Handler) handleInfo(ctx context.Context, w http.ResponseWriter, r *http
// Extract credentials from request // Extract credentials from request
credentials := h.credExtractor.Extract(r) credentials := h.credExtractor.Extract(r)
entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (io.ReadCloser, string, error) { entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (*cache.FetchResult, error) {
// Prepare headers for upstream request // Prepare headers for upstream request
headers := make(map[string]string) headers := make(map[string]string)
if credentials != "" { if credentials != "" {
@@ -162,13 +167,16 @@ func (h *Handler) handleInfo(ctx context.Context, w http.ResponseWriter, r *http
body, statusCode, err := h.client.Get(ctx, url, headers) body, statusCode, err := h.client.Get(ctx, url, headers)
if err != nil { if err != nil {
return nil, "", err return nil, err
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.ProviderFor(modulePath)}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close() // #nosec G104 -- Cleanup, error not critical
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -176,7 +184,7 @@ func (h *Handler) handleInfo(ctx context.Context, w http.ResponseWriter, r *http
http.Error(w, "Failed to fetch version info", http.StatusBadGateway) http.Error(w, "Failed to fetch version info", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = entry.Data.Close() }() // #nosec G104 -- Cleanup, error not critical
w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.Header().Set("Content-Type", "application/json; charset=UTF-8")
_, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write
@@ -186,14 +194,14 @@ func (h *Handler) handleInfo(ctx context.Context, w http.ResponseWriter, r *http
func (h *Handler) handleMod(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { func (h *Handler) handleMod(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) {
url := h.upstream + path url := h.upstream + path
modulePath := h.extractModulePath(path) modulePath := h.extractModulePath(path)
// Extract credentials from request
credentials := h.credExtractor.Extract(r)
version := h.extractVersion(path, ".mod") version := h.extractVersion(path, ".mod")
// Use .mod suffix to distinguish from .info and .zip in cache // Use .mod suffix to distinguish from .info and .zip in cache
cacheKey := modulePath + "/@v/" + version + ".mod" cacheKey := modulePath + "/@v/" + version + ".mod"
// Extract credentials from request entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (*cache.FetchResult, error) {
credentials := h.credExtractor.Extract(r)
entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (io.ReadCloser, string, error) {
// Prepare headers for upstream request // Prepare headers for upstream request
headers := make(map[string]string) headers := make(map[string]string)
if credentials != "" { if credentials != "" {
@@ -202,13 +210,16 @@ func (h *Handler) handleMod(ctx context.Context, w http.ResponseWriter, r *http.
body, statusCode, err := h.client.Get(ctx, url, headers) body, statusCode, err := h.client.Get(ctx, url, headers)
if err != nil { if err != nil {
return nil, "", err return nil, err
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.ProviderFor(modulePath)}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close() // #nosec G104 -- Cleanup, error not critical
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -216,7 +227,7 @@ func (h *Handler) handleMod(ctx context.Context, w http.ResponseWriter, r *http.
http.Error(w, "Failed to fetch go.mod", http.StatusBadGateway) http.Error(w, "Failed to fetch go.mod", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = entry.Data.Close() }() // #nosec G104 -- Cleanup, error not critical
w.Header().Set("Content-Type", "text/plain; charset=UTF-8") w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
_, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write
@@ -243,7 +254,7 @@ func (h *Handler) handleZip(ctx context.Context, w http.ResponseWriter, r *http.
Bool("has_credentials", credentials != ""). Bool("has_credentials", credentials != "").
Msg("Handling Go module zip request") Msg("Handling Go module zip request")
entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (io.ReadCloser, string, error) { entry, err := h.cache.Get(ctx, "go", cacheKey, version, func(ctx context.Context) (*cache.FetchResult, error) {
// Prepare headers for upstream request // Prepare headers for upstream request
headers := make(map[string]string) headers := make(map[string]string)
if credentials != "" { if credentials != "" {
@@ -253,13 +264,13 @@ func (h *Handler) handleZip(ctx context.Context, w http.ResponseWriter, r *http.
// Try upstream proxy first (fast path for public modules) // Try upstream proxy first (fast path for public modules)
body, statusCode, err := h.client.Get(ctx, url, headers) body, statusCode, err := h.client.Get(ctx, url, headers)
if err == nil && statusCode == http.StatusOK { if err == nil && statusCode == http.StatusOK {
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
} }
// If upstream failed with 404 or 403, try git fallback (private modules) // If upstream failed with 404 or 403, try git fallback (private modules)
if statusCode == http.StatusNotFound || statusCode == http.StatusForbidden { if statusCode == http.StatusNotFound || statusCode == http.StatusForbidden {
if body != nil { if body != nil {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close() // #nosec G104 -- Cleanup, error not critical
} }
log.Debug(). log.Debug().
@@ -273,12 +284,12 @@ func (h *Handler) handleZip(ctx context.Context, w http.ResponseWriter, r *http.
// Other errors // Other errors
if body != nil { if body != nil {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close() // #nosec G104 -- Cleanup, error not critical
} }
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
}) })
if err != nil { if err != nil {
@@ -294,9 +305,13 @@ func (h *Handler) handleZip(ctx context.Context, w http.ResponseWriter, r *http.
http.Error(w, "Failed to fetch module zip", http.StatusBadGateway) http.Error(w, "Failed to fetch module zip", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = entry.Data.Close() }() // #nosec G104 -- Cleanup, error not critical
// CRITICAL SECURITY CHECK: If module requires auth, validate credentials // CRITICAL SECURITY CHECK: If module requires auth, validate credentials
// The auth validation cache is owned by this handler instance only, so its
// key may use the module path (modulePath) rather than a URL. The module path
// uniquely identifies the private module within this registry's cache; there
// is no cross-registry collision because npm/pypi use their own instances.
if entry.Package != nil && entry.Package.RequiresAuth { if entry.Package != nil && entry.Package.RequiresAuth {
// Check validation cache first // Check validation cache first
allowed, cached, reason := h.validationCache.Get(credHash, modulePath) allowed, cached, reason := h.validationCache.Get(credHash, modulePath)
@@ -350,6 +365,7 @@ func (h *Handler) handleZip(ctx context.Context, w http.ResponseWriter, r *http.
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
_, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write
} }
// handleLatest handles /@latest requests // handleLatest handles /@latest requests
@@ -360,7 +376,7 @@ func (h *Handler) handleLatest(ctx context.Context, w http.ResponseWriter, r *ht
// Extract credentials from request // Extract credentials from request
credentials := h.credExtractor.Extract(r) credentials := h.credExtractor.Extract(r)
entry, err := h.cache.Get(ctx, "go", modulePath, "latest", func(ctx context.Context) (io.ReadCloser, string, error) { entry, err := h.cache.Get(ctx, "go", modulePath, "latest", func(ctx context.Context) (*cache.FetchResult, error) {
// Prepare headers for upstream request // Prepare headers for upstream request
headers := make(map[string]string) headers := make(map[string]string)
if credentials != "" { if credentials != "" {
@@ -369,13 +385,16 @@ func (h *Handler) handleLatest(ctx context.Context, w http.ResponseWriter, r *ht
body, statusCode, err := h.client.Get(ctx, url, headers) body, statusCode, err := h.client.Get(ctx, url, headers)
if err != nil { if err != nil {
return nil, "", err return nil, err
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.ProviderFor(modulePath)}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close() // #nosec G104 -- Cleanup, error not critical
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -383,7 +402,7 @@ func (h *Handler) handleLatest(ctx context.Context, w http.ResponseWriter, r *ht
http.Error(w, "Failed to fetch latest version", http.StatusBadGateway) http.Error(w, "Failed to fetch latest version", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = entry.Data.Close() }() // #nosec G104 -- Cleanup, error not critical
w.Header().Set("Content-Type", "application/json; charset=UTF-8") w.Header().Set("Content-Type", "application/json; charset=UTF-8")
_, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write
@@ -405,7 +424,7 @@ func (h *Handler) handleSumDB(ctx context.Context, w http.ResponseWriter, r *htt
http.Error(w, "Failed to fetch from sumdb", http.StatusBadGateway) http.Error(w, "Failed to fetch from sumdb", http.StatusBadGateway)
return return
} }
defer body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = body.Close() }() // #nosec G104 -- Cleanup, error not critical
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
log.Error().Int("status", statusCode).Str("url", url).Msg("Sumdb returned non-OK status") log.Error().Int("status", statusCode).Str("url", url).Msg("Sumdb returned non-OK status")
@@ -448,7 +467,7 @@ func (h *Handler) extractModulePath(path string) string {
} }
// fetchModuleFromGit fetches a Go module directly from git repository // fetchModuleFromGit fetches a Go module directly from git repository
func (h *Handler) fetchModuleFromGit(ctx context.Context, modulePath, version, credentials string) (io.ReadCloser, string, error) { func (h *Handler) fetchModuleFromGit(ctx context.Context, modulePath, version, credentials string) (*cache.FetchResult, error) {
log.Info(). log.Info().
Str("module", modulePath). Str("module", modulePath).
Str("version", version). Str("version", version).
@@ -457,23 +476,25 @@ func (h *Handler) fetchModuleFromGit(ctx context.Context, modulePath, version, c
// 1. Fetch module source from git // 1. Fetch module source from git
srcPath, err := h.gitFetcher.FetchModule(ctx, modulePath, version, credentials) srcPath, err := h.gitFetcher.FetchModule(ctx, modulePath, version, credentials)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("git fetch failed: %w", err) return nil, fmt.Errorf("git fetch failed: %w", err)
} }
defer h.gitFetcher.Cleanup(srcPath) defer h.gitFetcher.Cleanup(srcPath)
// 2. Validate module // 2. Validate module
if err := h.moduleBuilder.ValidateModule(ctx, srcPath, modulePath); err != nil { if validateErr := h.moduleBuilder.ValidateModule(ctx, srcPath, modulePath); validateErr != nil {
return nil, "", fmt.Errorf("module validation failed: %w", err) return nil, fmt.Errorf("module validation failed: %w", validateErr)
} }
// 3. Build module zip // 3. Build module zip
zipReader, err := h.moduleBuilder.BuildModuleZip(ctx, srcPath, modulePath, version) zipReader, err := h.moduleBuilder.BuildModuleZip(ctx, srcPath, modulePath, version)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("module zip build failed: %w", err) return nil, fmt.Errorf("module zip build failed: %w", err)
} }
// Create source URL for logging // Create source URL for logging. Private modules fetched from git require
sourceURL := fmt.Sprintf("git+https://%s@%s", modulePath, version) // auth, so mark the result accordingly. FIX-L3: strip the git+ scheme so the
// stored UpstreamURL is a fetchable HTTPS URL.
sourceURL := fmt.Sprintf("https://%s@%s", modulePath, version)
log.Info(). log.Info().
Str("module", modulePath). Str("module", modulePath).
@@ -481,5 +502,10 @@ func (h *Handler) fetchModuleFromGit(ctx context.Context, modulePath, version, c
Str("source", sourceURL). Str("source", sourceURL).
Msg("Successfully built module from git") Msg("Successfully built module from git")
return zipReader, sourceURL, nil return &cache.FetchResult{
Data: zipReader,
UpstreamURL: sourceURL,
RequiresAuth: true,
AuthProvider: h.credValidator.ProviderFor(modulePath),
}, nil
} }
+67 -31
View File
@@ -1,3 +1,5 @@
// Package npm implements the HTTP handler that proxies npm registry
// requests through the GoHoarder cache.
package npm package npm
import ( import (
@@ -78,16 +80,19 @@ func (h *Handler) handleMetadata(ctx context.Context, w http.ResponseWriter, r *
url := h.upstream + path url := h.upstream + path
packageName := extractPackageName(path) packageName := extractPackageName(path)
entry, err := h.cache.Get(ctx, "npm", packageName, "metadata", func(ctx context.Context) (io.ReadCloser, string, error) { entry, err := h.cache.Get(ctx, "npm", packageName, "metadata", func(ctx context.Context) (*cache.FetchResult, error) {
body, statusCode, err := h.client.Get(ctx, url, nil) body, statusCode, fetchErr := h.client.Get(ctx, url, nil)
if err != nil { if fetchErr != nil {
return nil, "", err return nil, fetchErr
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.Provider()}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close()
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -95,20 +100,24 @@ func (h *Handler) handleMetadata(ctx context.Context, w http.ResponseWriter, r *
http.Error(w, "Failed to fetch package metadata", http.StatusBadGateway) http.Error(w, "Failed to fetch package metadata", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() {
if cerr := entry.Data.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close NPM metadata body")
}
}()
// Read metadata into memory for URL rewriting // Read metadata into memory for URL rewriting
var buf bytes.Buffer var buf bytes.Buffer
if _, err := io.Copy(&buf, entry.Data); err != nil { if _, copyErr := io.Copy(&buf, entry.Data); copyErr != nil {
log.Error().Err(err).Msg("Failed to read metadata") log.Error().Err(copyErr).Msg("Failed to read metadata")
http.Error(w, "Failed to read metadata", http.StatusInternalServerError) http.Error(w, "Failed to read metadata", http.StatusInternalServerError)
return return
} }
// Parse JSON metadata // Parse JSON metadata
var metadata map[string]interface{} var metadata map[string]interface{}
if err := json.Unmarshal(buf.Bytes(), &metadata); err != nil { if jsonErr := json.Unmarshal(buf.Bytes(), &metadata); jsonErr != nil {
log.Error().Err(err).Msg("Failed to parse metadata JSON") log.Error().Err(jsonErr).Msg("Failed to parse metadata JSON")
http.Error(w, "Failed to parse metadata", http.StatusInternalServerError) http.Error(w, "Failed to parse metadata", http.StatusInternalServerError)
return return
} }
@@ -138,8 +147,10 @@ func (h *Handler) handleTarball(ctx context.Context, w http.ResponseWriter, r *h
credHash := h.credHasher.Hash(credentials) credHash := h.credHasher.Hash(credentials)
// Construct proper upstream URL with /-/ format // Construct proper upstream URL with /-/ format
// Format: https://registry.npmjs.org/package/-/package-version.tgz // Format: https://registry.npmjs.org/<package>/-/<filename>
tarballFilename := strings.ReplaceAll(packageName, "/", "-") + "-" + version + ".tgz" // For unscoped: filename is "<package>-<version>.tgz"
// For scoped @scope/pkg: filename is "<pkg>-<version>.tgz" (no scope, no leading @)
tarballFilename := tarballPrefix(packageName) + version + ".tgz"
url := fmt.Sprintf("%s/%s/-/%s", h.upstream, packageName, tarballFilename) url := fmt.Sprintf("%s/%s/-/%s", h.upstream, packageName, tarballFilename)
log.Debug(). log.Debug().
@@ -150,24 +161,25 @@ func (h *Handler) handleTarball(ctx context.Context, w http.ResponseWriter, r *h
Str("cred_hash", credHash). Str("cred_hash", credHash).
Bool("has_credentials", credentials != ""). Bool("has_credentials", credentials != "").
Msg("Handling tarball request") Msg("Handling tarball request")
entry, err := h.cache.Get(ctx, "npm", packageName, version, func(ctx context.Context) (*cache.FetchResult, error) {
// Try to get from cache first (with credential-aware key)
entry, err := h.cache.Get(ctx, "npm", packageName, version, func(ctx context.Context) (io.ReadCloser, string, error) {
// Prepare headers for upstream request // Prepare headers for upstream request
headers := make(map[string]string) headers := make(map[string]string)
if credentials != "" { if credentials != "" {
headers["Authorization"] = credentials headers["Authorization"] = credentials
} }
body, statusCode, err := h.client.Get(ctx, url, headers) body, statusCode, fetchErr := h.client.Get(ctx, url, headers)
if err != nil { if fetchErr != nil {
return nil, "", err return nil, fetchErr
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.Provider()}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close()
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -183,9 +195,17 @@ func (h *Handler) handleTarball(ctx context.Context, w http.ResponseWriter, r *h
http.Error(w, "Failed to fetch package tarball", http.StatusBadGateway) http.Error(w, "Failed to fetch package tarball", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() {
if cerr := entry.Data.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close NPM tarball body")
}
}()
// CRITICAL SECURITY CHECK: If package requires auth, validate credentials // CRITICAL SECURITY CHECK: If package requires auth, validate credentials
// The validation cache is owned by this handler instance only, so the key
// uses the upstream tarball URL. Uniquely identifies the artifact within
// this registry; no cross-registry collision (goproxy/pypi keep their own
// independent instances).
if entry.Package != nil && entry.Package.RequiresAuth { if entry.Package != nil && entry.Package.RequiresAuth {
// Check validation cache first // Check validation cache first
allowed, cached, reason := h.validationCache.Get(credHash, url) allowed, cached, reason := h.validationCache.Get(credHash, url)
@@ -251,7 +271,11 @@ func (h *Handler) handleSpecial(ctx context.Context, w http.ResponseWriter, r *h
http.Error(w, "Failed to fetch from upstream", http.StatusBadGateway) http.Error(w, "Failed to fetch from upstream", http.StatusBadGateway)
return return
} }
defer body.Close() // #nosec G104 -- Cleanup, error not critical defer func() {
if cerr := body.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close NPM special endpoint body")
}
}()
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
_, _ = io.Copy(w, body) // #nosec G104 -- HTTP response write _, _ = io.Copy(w, body) // #nosec G104 -- HTTP response write
@@ -290,6 +314,19 @@ func extractPackageName(path string) string {
return path return path
} }
// tarballPrefix returns the filename prefix used by npm tarballs for a given
// package. For scoped packages (@scope/pkg) only the bare name is used as a
// prefix — the @scope segment is not part of the filename.
func tarballPrefix(packageName string) string {
bare := packageName
if strings.HasPrefix(bare, "@") {
if idx := strings.Index(bare, "/"); idx >= 0 {
bare = bare[idx+1:]
}
}
return bare + "-"
}
// extractTarballInfo extracts package name and version from tarball path // extractTarballInfo extracts package name and version from tarball path
func extractTarballInfo(path string) (string, string) { func extractTarballInfo(path string) (string, string) {
// Format: /@scope/package/-/package-version.tgz // Format: /@scope/package/-/package-version.tgz
@@ -304,9 +341,9 @@ func extractTarballInfo(path string) (string, string) {
tarballName = strings.TrimSuffix(tarballName, ".tgz") tarballName = strings.TrimSuffix(tarballName, ".tgz")
tarballName = strings.TrimSuffix(tarballName, ".tar.gz") tarballName = strings.TrimSuffix(tarballName, ".tar.gz")
// Remove package name prefix to get version // Remove package name prefix to get version. For scoped packages the
prefix := strings.ReplaceAll(packageName, "/", "-") + "-" // tarball filename uses only the bare package name (no @scope/ prefix).
version := strings.TrimPrefix(tarballName, prefix) version := strings.TrimPrefix(tarballName, tarballPrefix(packageName))
return packageName, version return packageName, version
} }
@@ -334,9 +371,8 @@ func extractTarballInfo(path string) (string, string) {
tarballName = strings.TrimSuffix(tarballName, ".tgz") tarballName = strings.TrimSuffix(tarballName, ".tgz")
tarballName = strings.TrimSuffix(tarballName, ".tar.gz") tarballName = strings.TrimSuffix(tarballName, ".tar.gz")
// Remove package name prefix to get version // Remove package name prefix to get version (scope-aware).
prefix := strings.ReplaceAll(packageName, "/", "-") + "-" version := strings.TrimPrefix(tarballName, tarballPrefix(packageName))
version := strings.TrimPrefix(tarballName, prefix)
return packageName, version return packageName, version
} }
+81
View File
@@ -0,0 +1,81 @@
package npm
import (
"fmt"
"strings"
"testing"
)
// TestScopedTarballFilename verifies that scoped packages produce the correct
// upstream tarball filename. NPM registry expects:
//
// @scope/pkg v1.0.0 -> https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz
//
// The filename portion must NOT include the leading "@scope-" prefix.
func TestScopedTarballFilename(t *testing.T) {
tests := []struct {
name string
packageName string
version string
wantURL string
}{
{
name: "unscoped package",
packageName: "express",
version: "4.18.2",
wantURL: "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
},
{
name: "scoped package",
packageName: "@types/node",
version: "20.0.0",
wantURL: "https://registry.npmjs.org/@types/node/-/node-20.0.0.tgz",
},
{
name: "scoped package with hyphen in name",
packageName: "@babel/preset-env",
version: "7.22.0",
wantURL: "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.0.tgz",
},
}
upstream := "https://registry.npmjs.org"
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bareName := tt.packageName
if strings.HasPrefix(bareName, "@") {
if idx := strings.Index(bareName, "/"); idx >= 0 {
bareName = bareName[idx+1:]
}
}
tarballFilename := bareName + "-" + tt.version + ".tgz"
gotURL := fmt.Sprintf("%s/%s/-/%s", upstream, tt.packageName, tarballFilename)
if gotURL != tt.wantURL {
t.Errorf("got %q, want %q", gotURL, tt.wantURL)
}
})
}
}
func TestExtractTarballInfo_Scoped(t *testing.T) {
// Sanity check that extractTarballInfo correctly parses scoped paths so
// the construction pipeline above is consistent end-to-end.
tests := []struct {
path string
wantPackage string
wantVersion string
}{
{"/@types/node/-/node-20.0.0.tgz", "@types/node", "20.0.0"},
{"/@babel/preset-env/-/preset-env-7.22.0.tgz", "@babel/preset-env", "7.22.0"},
{"/express/-/express-4.18.2.tgz", "express", "4.18.2"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
gotPkg, gotVer := extractTarballInfo(tt.path)
if gotPkg != tt.wantPackage || gotVer != tt.wantVersion {
t.Errorf("extractTarballInfo(%q) = (%q,%q), want (%q,%q)",
tt.path, gotPkg, gotVer, tt.wantPackage, tt.wantVersion)
}
})
}
}
+157 -50
View File
@@ -1,3 +1,5 @@
// Package pypi implements the HTTP handler that proxies PyPI registry
// requests through the GoHoarder cache.
package pypi package pypi
import ( import (
@@ -6,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"regexp" "regexp"
"strings" "strings"
"time" "time"
@@ -17,6 +20,36 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
// defaultAllowedPyPIHosts is the hardcoded SSRF allowlist for hosts that
// original_url query params may target. Subdomains of pythonhosted.org are
// also accepted (handled in isAllowedPyPIHost).
var defaultAllowedPyPIHosts = []string{
"pypi.org",
"files.pythonhosted.org",
"pythonhosted.org",
}
// isAllowedPyPIHost reports whether host is on the allowlist or a subdomain
// of pythonhosted.org. Comparison is case-insensitive on host only.
func isAllowedPyPIHost(host string, allowed []string) bool {
host = strings.ToLower(host)
// Strip optional port
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
for _, a := range allowed {
a = strings.ToLower(a)
if host == a {
return true
}
}
// Allow any subdomain of pythonhosted.org (e.g. files.pythonhosted.org)
if strings.HasSuffix(host, ".pythonhosted.org") {
return true
}
return false
}
// Handler implements the PyPI Simple API (PEP 503) // Handler implements the PyPI Simple API (PEP 503)
type Handler struct { type Handler struct {
cache *cache.Manager cache *cache.Manager
@@ -26,11 +59,15 @@ type Handler struct {
credValidator *auth.PyPIValidator credValidator *auth.PyPIValidator
validationCache *auth.ValidationCache validationCache *auth.ValidationCache
upstream string upstream string
allowedHosts []string
} }
// Config holds PyPI proxy configuration // Config holds PyPI proxy configuration
type Config struct { type Config struct {
Upstream string // Upstream PyPI index (e.g., pypi.org/simple) Upstream string // Upstream PyPI index (e.g., pypi.org/simple)
// AllowedHosts is an SSRF allowlist for hosts that original_url query
// params may target. If empty, defaultAllowedPyPIHosts is used.
AllowedHosts []string
} }
// New creates a new PyPI proxy handler // New creates a new PyPI proxy handler
@@ -39,10 +76,16 @@ func New(cacheManager *cache.Manager, client *network.Client, config Config) *Ha
config.Upstream = "https://pypi.org/simple" config.Upstream = "https://pypi.org/simple"
} }
allowed := config.AllowedHosts
if len(allowed) == 0 {
allowed = defaultAllowedPyPIHosts
}
return &Handler{ return &Handler{
cache: cacheManager, cache: cacheManager,
client: client, client: client,
upstream: config.Upstream, upstream: config.Upstream,
allowedHosts: allowed,
credExtractor: auth.NewCredentialExtractor(), credExtractor: auth.NewCredentialExtractor(),
credHasher: auth.NewCredentialHasher(), credHasher: auth.NewCredentialHasher(),
credValidator: auth.NewPyPIValidator(), credValidator: auth.NewPyPIValidator(),
@@ -81,16 +124,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (h *Handler) handleIndex(ctx context.Context, w http.ResponseWriter, r *http.Request) { func (h *Handler) handleIndex(ctx context.Context, w http.ResponseWriter, r *http.Request) {
url := h.upstream + "/" url := h.upstream + "/"
entry, err := h.cache.Get(ctx, "pypi", "index", "latest", func(ctx context.Context) (io.ReadCloser, string, error) { entry, err := h.cache.Get(ctx, "pypi", "index", "latest", func(ctx context.Context) (*cache.FetchResult, error) {
body, statusCode, err := h.client.Get(ctx, url, nil) body, statusCode, err := h.client.Get(ctx, url, nil)
if err != nil { if err != nil {
return nil, "", err return nil, err
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.Provider()}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close()
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -98,7 +144,11 @@ func (h *Handler) handleIndex(ctx context.Context, w http.ResponseWriter, r *htt
http.Error(w, "Failed to fetch PyPI index", http.StatusBadGateway) http.Error(w, "Failed to fetch PyPI index", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() {
if cerr := entry.Data.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close PyPI index body")
}
}()
w.Header().Set("Content-Type", "text/html; charset=UTF-8") w.Header().Set("Content-Type", "text/html; charset=UTF-8")
_, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write _, _ = io.Copy(w, entry.Data) // #nosec G104 -- HTTP response write
@@ -108,17 +158,19 @@ func (h *Handler) handleIndex(ctx context.Context, w http.ResponseWriter, r *htt
func (h *Handler) handlePackagePage(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { func (h *Handler) handlePackagePage(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) {
url := h.upstream + path url := h.upstream + path
packageName := extractPackageName(path) packageName := extractPackageName(path)
entry, err := h.cache.Get(ctx, "pypi", packageName, "page", func(ctx context.Context) (*cache.FetchResult, error) {
entry, err := h.cache.Get(ctx, "pypi", packageName, "page", func(ctx context.Context) (io.ReadCloser, string, error) {
body, statusCode, err := h.client.Get(ctx, url, nil) body, statusCode, err := h.client.Get(ctx, url, nil)
if err != nil { if err != nil {
return nil, "", err return nil, err
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: url, RequiresAuth: true, AuthProvider: h.credValidator.Provider()}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close()
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, url, nil return &cache.FetchResult{Data: body, UpstreamURL: url}, nil
}) })
if err != nil { if err != nil {
@@ -126,7 +178,11 @@ func (h *Handler) handlePackagePage(ctx context.Context, w http.ResponseWriter,
http.Error(w, "Failed to fetch package page", http.StatusBadGateway) http.Error(w, "Failed to fetch package page", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() {
if cerr := entry.Data.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close PyPI package page body")
}
}()
// Read page into memory for URL rewriting // Read page into memory for URL rewriting
var buf bytes.Buffer var buf bytes.Buffer
@@ -148,16 +204,17 @@ func (h *Handler) handlePackagePage(ctx context.Context, w http.ResponseWriter,
func (h *Handler) handlePackageFile(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) { func (h *Handler) handlePackageFile(ctx context.Context, w http.ResponseWriter, r *http.Request, path string) {
packageName, version := extractPackageFileInfo(path) packageName, version := extractPackageFileInfo(path)
// Make version unique by appending file type to avoid cache collisions // cacheVersion is the cache key's version portion. It must be unique per
// between .whl and .metadata files with same version // distinct artifact. PyPI publishes many files per release (multiple
cacheVersion := version // wheels with different build tags, sdists, zips, .egg files, and
if strings.HasSuffix(path, ".metadata") { // .metadata files for PEP 658), so the bare version is too coarse:
cacheVersion = version + ".metadata" // distinct files of the same version would collide on the same cache
} else if strings.HasSuffix(path, ".whl") { // key. The full unique basename guarantees one cache entry per artifact.
cacheVersion = version + ".whl" filename := path
} else if strings.HasSuffix(path, ".tar.gz") { if i := strings.LastIndex(filename, "/"); i >= 0 {
cacheVersion = version + ".tar.gz" filename = filename[i+1:]
} }
cacheVersion := filename
// Extract credentials from request // Extract credentials from request
credentials := h.credExtractor.Extract(r) credentials := h.credExtractor.Extract(r)
@@ -175,6 +232,23 @@ func (h *Handler) handlePackageFile(ctx context.Context, w http.ResponseWriter,
if !strings.HasPrefix(originalURL, "http://") && !strings.HasPrefix(originalURL, "https://") { if !strings.HasPrefix(originalURL, "http://") && !strings.HasPrefix(originalURL, "https://") {
originalURL = "https://pypi.org" + originalURL originalURL = "https://pypi.org" + originalURL
} }
// SSRF protection: validate parsed host against allowlist before
// fetching. Rejects 169.254.169.254, internal services, etc.
parsed, parseErr := url.Parse(originalURL)
if parseErr != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
log.Warn().Str("original_url", originalURL).Msg("Rejected invalid original_url")
http.Error(w, "Invalid original_url", http.StatusBadRequest)
return
}
if !isAllowedPyPIHost(parsed.Host, h.allowedHosts) {
log.Warn().
Str("original_url", originalURL).
Str("host", parsed.Host).
Msg("Rejected original_url host not on allowlist")
http.Error(w, "original_url host not allowed", http.StatusBadRequest)
return
}
} }
log.Debug(). log.Debug().
@@ -187,7 +261,7 @@ func (h *Handler) handlePackageFile(ctx context.Context, w http.ResponseWriter,
Bool("has_credentials", credentials != ""). Bool("has_credentials", credentials != "").
Msg("Handling PyPI package file request") Msg("Handling PyPI package file request")
entry, err := h.cache.Get(ctx, "pypi", packageName, cacheVersion, func(ctx context.Context) (io.ReadCloser, string, error) { entry, err := h.cache.Get(ctx, "pypi", packageName, cacheVersion, func(ctx context.Context) (*cache.FetchResult, error) {
// Prepare headers for upstream request // Prepare headers for upstream request
headers := make(map[string]string) headers := make(map[string]string)
if credentials != "" { if credentials != "" {
@@ -196,13 +270,16 @@ func (h *Handler) handlePackageFile(ctx context.Context, w http.ResponseWriter,
body, statusCode, err := h.client.Get(ctx, originalURL, headers) body, statusCode, err := h.client.Get(ctx, originalURL, headers)
if err != nil { if err != nil {
return nil, "", err return nil, err
}
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
return &cache.FetchResult{Data: body, UpstreamURL: originalURL, RequiresAuth: true, AuthProvider: h.credValidator.Provider()}, nil
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
body.Close() // #nosec G104 -- Cleanup, error not critical _ = body.Close()
return nil, "", fmt.Errorf("upstream returned status %d", statusCode) return nil, fmt.Errorf("upstream returned status %d", statusCode)
} }
return body, originalURL, nil return &cache.FetchResult{Data: body, UpstreamURL: originalURL}, nil
}) })
if err != nil { if err != nil {
@@ -218,9 +295,17 @@ func (h *Handler) handlePackageFile(ctx context.Context, w http.ResponseWriter,
http.Error(w, "Failed to fetch package file", http.StatusBadGateway) http.Error(w, "Failed to fetch package file", http.StatusBadGateway)
return return
} }
defer entry.Data.Close() // #nosec G104 -- Cleanup, error not critical defer func() {
if cerr := entry.Data.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close PyPI package file body")
}
}()
// CRITICAL SECURITY CHECK: If package requires auth, validate credentials // CRITICAL SECURITY CHECK: If package requires auth, validate credentials
// The validation cache is owned by this handler instance only, so the key
// uses the upstream artifact URL (originalURL). Uniquely identifies the
// artifact within this registry; no cross-registry collision (goproxy/npm
// keep their own independent instances).
if entry.Package != nil && entry.Package.RequiresAuth { if entry.Package != nil && entry.Package.RequiresAuth {
// Check validation cache first // Check validation cache first
allowed, cached, reason := h.validationCache.Get(credHash, originalURL) allowed, cached, reason := h.validationCache.Get(credHash, originalURL)
@@ -323,37 +408,60 @@ func extractPackageName(path string) string {
func extractPackageFileInfo(path string) (string, string) { func extractPackageFileInfo(path string) (string, string) {
// Format: /package-name/package-name-version.whl // Format: /package-name/package-name-version.whl
// or: /package-name/package-name-version.tar.gz // or: /package-name/package-name-version.tar.gz
packageName := extractPackageName(path) packageName := extractPackageName(path)
// Extract filename
parts := strings.Split(path, "/") parts := strings.Split(path, "/")
if len(parts) < 2 { if len(parts) < 2 {
return packageName, "" return packageName, ""
} }
filename := parts[len(parts)-1] base := parts[len(parts)-1]
isWheel := strings.HasSuffix(base, ".whl")
base = strings.TrimSuffix(base, ".whl")
base = strings.TrimSuffix(base, ".tar.gz")
base = strings.TrimSuffix(base, ".zip")
base = strings.TrimSuffix(base, ".egg")
base = strings.TrimSuffix(base, ".metadata")
// Remove extension return packageName, extractVersionFromFilename(base, isWheel)
filename = strings.TrimSuffix(filename, ".whl") }
filename = strings.TrimSuffix(filename, ".tar.gz")
filename = strings.TrimSuffix(filename, ".zip")
filename = strings.TrimSuffix(filename, ".egg")
// Extract version // extractVersionFromFilename extracts the version from a PyPI distribution
// Filename format: package-name-version or package_name-version // filename with the extension already stripped.
// Version typically starts after last dash before build tags //
versionParts := strings.Split(filename, "-") // Wheels (PEP 427) are formatted as
if len(versionParts) >= 2 { //
// Simple heuristic: version is the part that starts with a digit // {distribution}-{version}(-{build})?-{python}-{abi}-{platform}
for i := 1; i < len(versionParts); i++ { //
if len(versionParts[i]) > 0 && versionParts[i][0] >= '0' && versionParts[i][0] <= '9' { // and the distribution is normalized (lowercase, runs of '-', '.', '_'
return packageName, versionParts[i] // collapsed to '_'), so it never contains '-' and the version is always the
} // second dash-separated segment. For sdists/zips/eggs the filename is
// {name}-{version}; PyPI normalizes name separators to '_', so the version
// is always the last dash-separated segment (PEP 440 versions never contain
// '-').
//
// The historical first-digit-segment heuristic is avoided because it mangles
// digit-embedded distribution names (e.g. "2captcha-1.0.0" → "2captcha") and
// intermediate digit segments (e.g. "foo-2019bar-1.0" → "2019bar"). The last
// digit-starting segment is correct for those cases.
func extractVersionFromFilename(base string, isWheel bool) string {
if isWheel {
// The distribution never contains '-', so the version immediately
// follows the first '-'.
if parts := strings.SplitN(base, "-", 3); len(parts) >= 2 && parts[1] != "" && parts[1][0] >= '0' && parts[1][0] <= '9' {
return parts[1]
} }
} }
return packageName, filename // Non-wheel fallback (and defensive wheel edge case): the last
// dash-separated segment that starts with a digit.
parts := strings.Split(base, "-")
for i := len(parts) - 1; i >= 1; i-- {
if len(parts[i]) > 0 && parts[i][0] >= '0' && parts[i][0] <= '9' {
return parts[i]
}
}
return base
} }
// getProxyBaseURL constructs the proxy base URL from the request // getProxyBaseURL constructs the proxy base URL from the request
@@ -396,9 +504,8 @@ func rewritePackagePageURLs(html, packageName, proxyBaseURL string) string {
// This preserves the original CDN URL so we can fetch from the correct location // This preserves the original CDN URL so we can fetch from the correct location
baseURL := strings.TrimSuffix(proxyBaseURL, "/simple") baseURL := strings.TrimSuffix(proxyBaseURL, "/simple")
// URL encode the original URL // URL encode the original URL — covers &, =, ?, #, +, /, etc.
encodedURL := strings.ReplaceAll(originalURL, "&", "%26") encodedURL := url.QueryEscape(originalURL)
encodedURL = strings.ReplaceAll(encodedURL, "=", "%3D")
newURL := fmt.Sprintf(`href="%s/%s/%s?original_url=%s"`, baseURL, packageName, filenameMatch, encodedURL) newURL := fmt.Sprintf(`href="%s/%s/%s?original_url=%s"`, baseURL, packageName, filenameMatch, encodedURL)
return newURL return newURL
+209
View File
@@ -0,0 +1,209 @@
package pypi
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/lukaszraczylo/gohoarder/pkg/auth"
)
func TestIsAllowedPyPIHost(t *testing.T) {
tests := []struct {
name string
host string
allowed []string
want bool
}{
{"pypi.org allowed", "pypi.org", defaultAllowedPyPIHosts, true},
{"files.pythonhosted.org allowed", "files.pythonhosted.org", defaultAllowedPyPIHosts, true},
{"subdomain of pythonhosted.org allowed", "cdn.pythonhosted.org", defaultAllowedPyPIHosts, true},
{"case insensitive", "PyPI.ORG", defaultAllowedPyPIHosts, true},
{"with port", "pypi.org:443", defaultAllowedPyPIHosts, true},
{"AWS metadata blocked", "169.254.169.254", defaultAllowedPyPIHosts, false},
{"GCP metadata blocked", "metadata.google.internal", defaultAllowedPyPIHosts, false},
{"localhost blocked", "localhost", defaultAllowedPyPIHosts, false},
{"loopback blocked", "127.0.0.1", defaultAllowedPyPIHosts, false},
{"private RFC1918 blocked", "10.0.0.1", defaultAllowedPyPIHosts, false},
{"attacker domain blocked", "evil.example.com", defaultAllowedPyPIHosts, false},
{"empty host blocked", "", defaultAllowedPyPIHosts, false},
{"trailing-substring attack blocked", "evilpythonhosted.org", defaultAllowedPyPIHosts, false},
{"prefix attack blocked", "pypi.org.evil.com", defaultAllowedPyPIHosts, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isAllowedPyPIHost(tt.host, tt.allowed)
if got != tt.want {
t.Errorf("isAllowedPyPIHost(%q) = %v, want %v", tt.host, got, tt.want)
}
})
}
}
// newHandlerForSSRFTest builds a Handler with only the fields needed to reach
// the SSRF guard. cache is nil intentionally — the guard rejects before any
// cache call.
func newHandlerForSSRFTest() *Handler {
return &Handler{
credExtractor: auth.NewCredentialExtractor(),
credHasher: auth.NewCredentialHasher(),
upstream: "https://pypi.org/simple",
allowedHosts: defaultAllowedPyPIHosts,
}
}
func TestHandlePackageFile_SSRFRejected(t *testing.T) {
tests := []struct {
name string
originalURL string
}{
{"AWS metadata IP", "http://169.254.169.254/"},
{"GCP metadata host", "http://metadata.google.internal/computeMetadata/v1/"},
{"localhost", "http://localhost:8080/secret"},
{"private network", "http://10.0.0.5/"},
{"file scheme", "file:///etc/passwd"},
{"gopher scheme", "gopher://internal/"},
{"unrelated public host", "https://evil.example.com/payload.whl"},
}
h := newHandlerForSSRFTest()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := "/example/example-1.0.0.whl"
q := url.Values{}
q.Set("original_url", tt.originalURL)
req := httptest.NewRequest(http.MethodGet, path+"?"+q.Encode(), nil)
w := httptest.NewRecorder()
h.handlePackageFile(req.Context(), w, req, path)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for SSRF target %q, got %d (body=%q)", tt.originalURL, w.Code, w.Body.String())
}
})
}
}
func TestHandlePackageFile_AllowedHostNotRejected(t *testing.T) {
// Sanity check: an allowlisted host should NOT be rejected at the SSRF
// guard. We don't have a real cache so the call will fail later with a
// non-400 status — that's fine, we only assert the SSRF guard didn't
// fire.
h := newHandlerForSSRFTest()
path := "/example/example-1.0.0.whl"
q := url.Values{}
q.Set("original_url", "https://files.pythonhosted.org/packages/abc/example-1.0.0.whl")
req := httptest.NewRequest(http.MethodGet, path+"?"+q.Encode(), nil)
w := httptest.NewRecorder()
defer func() {
// nil cache will panic when reached — recover and treat as "guard
// did not block", which is the property we care about.
_ = recover()
}()
h.handlePackageFile(req.Context(), w, req, path)
if w.Code == http.StatusBadRequest {
t.Errorf("allowlisted host wrongly rejected with 400: %s", w.Body.String())
}
}
func TestRewritePackagePageURLs_QueryEscape(t *testing.T) {
// Original URL contains characters that strings.ReplaceAll(&,=) would miss:
// '?', '#', '+'. Verify url.QueryEscape handles them.
html := `<a href="https://files.pythonhosted.org/packages/x/y+z/foo-1.0.whl?token=abc#frag">link</a>`
out := rewritePackagePageURLs(html, "foo", "http://proxy/pypi")
// '+' must be encoded (otherwise PyPI would interpret as space)
if !contains(out, "original_url=https%3A%2F%2Ffiles.pythonhosted.org%2Fpackages%2Fx%2Fy%2Bz%2Ffoo-1.0.whl%3Ftoken%3Dabc%23frag") {
t.Errorf("expected fully URL-encoded original_url, got: %s", out)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func TestExtractPackageFileInfo(t *testing.T) {
tests := []struct {
name string
path string
wantName string
wantVer string
}{
{"numpy wheel", "/numpy/numpy-1.26.0rc1-cp312-cp312-manylinux.whl", "numpy", "1.26.0rc1"},
{"underscore wheel name", "/typing_extensions/typing_extensions-4.8.0-py3-none-any.whl", "typing_extensions", "4.8.0"},
{"hyphenated sdist", "/my-pkg/my-pkg-2.0.1.tar.gz", "my-pkg", "2.0.1"},
{"hyphen name underscore file", "/typed-ast/typed_ast-1.5.4.tar.gz", "typed-ast", "1.5.4"},
{"digit-embedded name", "/2captcha/2captcha-1.0.0.tar.gz", "2captcha", "1.0.0"},
{"digit intermediate segment", "/foo/foo-2019bar-1.0.zip", "foo", "1.0"},
{"egg file", "/foo/foo-1.0.egg", "foo", "1.0"},
{"metadata file", "/foo/foo-1.0.metadata", "foo", "1.0"},
{"no version", "/foo", "foo", "foo"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotVer := extractPackageFileInfo(tt.path)
if gotName != tt.wantName {
t.Errorf("extractPackageFileInfo(%q) name = %q, want %q", tt.path, gotName, tt.wantName)
}
if gotVer != tt.wantVer {
t.Errorf("extractPackageFileInfo(%q) version = %q, want %q", tt.path, gotVer, tt.wantVer)
}
})
}
}
// TestCacheVersionUniquePerArtifact asserts that distinct PyPI artifacts of
// the same logical version produce distinct cache keys, and that the bare
// version returned for logging stays stable.
func TestCacheVersionUniquePerArtifact(t *testing.T) {
// Three wheels of the same version differing only in build/tag, plus a
// .zip and a .metadata file for the same version. The cache key uses the
// full unique basename, so keys must all differ; the extracted version
// must remain the bare stable version for logging/bypass logic.
paths := []string{
"/foo/foo-1.0.0-cp312-cp312-manylinux.whl",
"/foo/foo-1.0.0-cp311-cp311-manylinux.whl",
"/foo/foo-1.0.0-py3-none-any.whl",
"/foo/foo-1.0.0.zip",
"/foo/foo-1.0.0.metadata",
}
seenKeys := make(map[string]bool)
for _, path := range paths {
_, ver := extractPackageFileInfo(path)
if ver != "1.0.0" {
t.Errorf("extractPackageFileInfo(%q) version = %q, want stable %q", path, ver, "1.0.0")
}
filename := path
if i := strings.LastIndex(filename, "/"); i >= 0 {
filename = filename[i+1:]
}
if seenKeys[filename] {
t.Errorf("cache key %q duplicated across artifacts", filename)
}
seenKeys[filename] = true
}
if len(seenKeys) != len(paths) {
t.Errorf("expected %d distinct cache keys, got %d", len(paths), len(seenKeys))
}
}
+198 -11
View File
@@ -1,3 +1,5 @@
// Package ghsa implements a vulnerability scanner backed by the GitHub
// Security Advisory Database.
package ghsa package ghsa
import ( import (
@@ -6,6 +8,8 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
@@ -105,7 +109,7 @@ func (s *Scanner) Health(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("github advisory database not accessible: %w", err) return fmt.Errorf("github advisory database not accessible: %w", err)
} }
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Accept any 2xx or 403 (rate limit) as healthy // Accept any 2xx or 403 (rate limit) as healthy
// Rate limits are expected without a GitHub token and shouldn't fail health checks // Rate limits are expected without a GitHub token and shouldn't fail health checks
@@ -136,9 +140,13 @@ func (s *Scanner) mapRegistryToEcosystem(registry string) string {
// queryAdvisories queries GitHub Advisory Database for a package // queryAdvisories queries GitHub Advisory Database for a package
func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName string) ([]GHSAAdvisory, error) { func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName string) ([]GHSAAdvisory, error) {
url := fmt.Sprintf("https://api.github.com/advisories?ecosystem=%s&affects=%s&per_page=100", ecosystem, packageName) endpoint := fmt.Sprintf(
"https://api.github.com/advisories?ecosystem=%s&affects=%s&per_page=100",
url.QueryEscape(ecosystem),
url.QueryEscape(packageName),
)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
@@ -152,7 +160,7 @@ func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName st
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query advisories: %w", err) return nil, fmt.Errorf("failed to query advisories: %w", err)
} }
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
@@ -167,15 +175,194 @@ func (s *Scanner) queryAdvisories(ctx context.Context, ecosystem, packageName st
return advisories, nil return advisories, nil
} }
// filterAffectedAdvisories filters advisories that affect the given version // filterAffectedAdvisories filters advisories that affect the given version.
// Each advisory may have multiple GHSAVulnerability entries; if any of them
// applies to our installed version (per its vulnerable_version_range), the
// advisory is considered affecting.
//
// Fail-closed: if the version or any range cannot be parsed, the advisory is
// included. We err on the side of reporting a possible vulnerability rather
// than silently dropping it.
func (s *Scanner) filterAffectedAdvisories(advisories []GHSAAdvisory, version string) []GHSAAdvisory { func (s *Scanner) filterAffectedAdvisories(advisories []GHSAAdvisory, version string) []GHSAAdvisory {
// Check if this version is affected if version == "" {
// GitHub API already filters by package, but we need to check version ranges // Without a target version, conservatively include everything.
// For now, we'll include all advisories that match the package return append([]GHSAAdvisory(nil), advisories...)
// A more sophisticated implementation would parse version ranges }
affected := append([]GHSAAdvisory(nil), advisories...)
return affected out := make([]GHSAAdvisory, 0, len(advisories))
for _, adv := range advisories {
if advisoryAffectsVersion(adv, version) {
out = append(out, adv)
}
}
return out
}
// advisoryAffectsVersion reports whether the given installed version falls
// within any of the advisory's vulnerable version ranges.
func advisoryAffectsVersion(adv GHSAAdvisory, version string) bool {
// If the advisory carries no per-vuln range info, conservatively include.
if len(adv.Vulnerabilities) == 0 {
return true
}
for _, v := range adv.Vulnerabilities {
rangeExpr := strings.TrimSpace(v.VulnerableVersions)
if rangeExpr == "" {
// No range — assume affected.
return true
}
matched, ok := versionInRange(version, rangeExpr)
if !ok {
// Parse error → fail-closed: include.
log.Debug().
Str("ghsa_id", adv.GHSAID).
Str("range", rangeExpr).
Str("version", version).
Msg("Could not parse GHSA vulnerable_version_range, including advisory")
return true
}
if matched {
return true
}
}
return false
}
// versionInRange reports whether version satisfies expr. Returns (matched, ok)
// where ok=false signals a parse error. Supported forms (comma separated AND):
//
// "= X"
// "< X"
// "<= X"
// "> X"
// ">= X"
// ">= X, < Y"
//
// All clauses must be satisfied for the range to match.
func versionInRange(version, expr string) (bool, bool) {
clauses := strings.Split(expr, ",")
for _, c := range clauses {
c = strings.TrimSpace(c)
if c == "" {
continue
}
op, bound, ok := splitOpAndVersion(c)
if !ok {
return false, false
}
cmp, ok := compareVersions(version, bound)
if !ok {
return false, false
}
switch op {
case "=", "==":
if cmp != 0 {
return false, true
}
case "<":
if cmp >= 0 {
return false, true
}
case "<=":
if cmp > 0 {
return false, true
}
case ">":
if cmp <= 0 {
return false, true
}
case ">=":
if cmp < 0 {
return false, true
}
default:
return false, false
}
}
return true, true
}
// splitOpAndVersion parses "<op> <version>" pairs (e.g. ">= 1.2.3").
func splitOpAndVersion(clause string) (op, ver string, ok bool) {
clause = strings.TrimSpace(clause)
// Longer operators first to avoid prefix shadowing.
for _, candidate := range []string{">=", "<=", "==", "=", ">", "<"} {
if strings.HasPrefix(clause, candidate) {
rest := strings.TrimSpace(strings.TrimPrefix(clause, candidate))
if rest == "" {
return "", "", false
}
return candidate, rest, true
}
}
return "", "", false
}
// compareVersions compares two dot-separated version strings.
// Returns (cmp, ok). Numeric segments are compared numerically.
// A pre-release suffix (anything after '-' or '+') is treated as lower-priority
// than the same version without one, matching common semver intuition for
// the cases we expect from the GitHub Advisory Database.
func compareVersions(a, b string) (int, bool) {
aBase, aPre := splitPreRelease(a)
bBase, bPre := splitPreRelease(b)
aParts := strings.Split(aBase, ".")
bParts := strings.Split(bBase, ".")
n := len(aParts)
if len(bParts) > n {
n = len(bParts)
}
for i := 0; i < n; i++ {
var av, bv int
var err error
if i < len(aParts) {
av, err = strconv.Atoi(aParts[i])
if err != nil {
return 0, false
}
}
if i < len(bParts) {
bv, err = strconv.Atoi(bParts[i])
if err != nil {
return 0, false
}
}
if av != bv {
if av < bv {
return -1, true
}
return 1, true
}
}
// Bases equal; compare pre-release. No pre-release > has pre-release.
switch {
case aPre == "" && bPre == "":
return 0, true
case aPre == "" && bPre != "":
return 1, true
case aPre != "" && bPre == "":
return -1, true
default:
return strings.Compare(aPre, bPre), true
}
}
// splitPreRelease separates "1.2.3-rc1" into ("1.2.3", "rc1"). Build metadata
// after '+' is stripped (per semver).
func splitPreRelease(v string) (base, pre string) {
v = strings.TrimSpace(v)
v = strings.TrimPrefix(v, "v")
if i := strings.Index(v, "+"); i >= 0 {
v = v[:i]
}
if i := strings.Index(v, "-"); i >= 0 {
return v[:i], v[i+1:]
}
return v, ""
} }
// emptyResult returns an empty scan result // emptyResult returns an empty scan result
+160
View File
@@ -0,0 +1,160 @@
package ghsa
import (
"testing"
)
func TestVersionInRange(t *testing.T) {
// matched: whether the version satisfies the range expression.
// ok: whether the parser/comparator could evaluate the inputs.
cases := []struct {
name string
version string
expr string
matched bool
ok bool
}{
{"single LT match", "1.2.3", "< 2.0.0", true, true},
{"single LT no match", "2.5.0", "< 2.0.0", false, true},
{"single GTE match", "2.5.0", ">= 2.0.0", true, true},
{"single GTE no match", "1.0.0", ">= 2.0.0", false, true},
{"range hit", "1.5.0", ">= 1.0.0, < 2.0.0", true, true},
{"range below", "0.9.0", ">= 1.0.0, < 2.0.0", false, true},
{"range above", "2.0.0", ">= 1.0.0, < 2.0.0", false, true},
{"range upper bound exclusive", "2.0.0", ">= 1.0.0, < 2.0.0", false, true},
{"range lower bound inclusive", "1.0.0", ">= 1.0.0, < 2.0.0", true, true},
{"equality match", "1.2.3", "= 1.2.3", true, true},
{"equality miss", "1.2.4", "= 1.2.3", false, true},
{"with v prefix on bound", "1.2.3", ">= v1.0.0", true, true},
{"shorter version coerces", "1.0", ">= 1.0.0", true, true},
{"pre-release lower than release", "1.0.0-rc1", ">= 1.0.0", false, true},
{"pre-release greater than older", "1.0.0-rc1", ">= 0.9.0", true, true},
{"malformed operator", "1.0.0", "~ 1.0.0", false, false},
{"malformed version", "abc", ">= 1.0.0", false, false},
{"empty bound after op", "1.0.0", ">=", false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
matched, ok := versionInRange(tc.version, tc.expr)
if matched != tc.matched || ok != tc.ok {
t.Fatalf("versionInRange(%q, %q) = (%v, %v), want (%v, %v)",
tc.version, tc.expr, matched, ok, tc.matched, tc.ok)
}
})
}
}
func TestAdvisoryAffectsVersion(t *testing.T) {
cases := []struct {
name string
version string
adv GHSAAdvisory
want bool
}{
{
name: "advisory with no vulnerabilities is conservatively included",
adv: GHSAAdvisory{GHSAID: "GHSA-xxxx", Vulnerabilities: nil},
version: "1.0.0",
want: true,
},
{
name: "matching range marks advisory as affecting",
adv: GHSAAdvisory{
GHSAID: "GHSA-aaaa",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 1.0.0, < 2.0.0"},
},
},
version: "1.5.0",
want: true,
},
{
name: "non-matching range excludes advisory",
adv: GHSAAdvisory{
GHSAID: "GHSA-bbbb",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 2.0.0"},
},
},
version: "1.0.0",
want: false,
},
{
name: "any matching range across multiple vulns is affecting",
adv: GHSAAdvisory{
GHSAID: "GHSA-cccc",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: "< 0.5.0"},
{VulnerableVersions: ">= 1.0.0, < 1.2.0"},
},
},
version: "1.1.0",
want: true,
},
{
name: "empty range falls back to affecting (fail-closed)",
adv: GHSAAdvisory{
GHSAID: "GHSA-dddd",
Vulnerabilities: []GHSAVulnerability{{VulnerableVersions: ""}},
},
version: "1.0.0",
want: true,
},
{
name: "unparseable range falls back to affecting (fail-closed)",
adv: GHSAAdvisory{
GHSAID: "GHSA-eeee",
Vulnerabilities: []GHSAVulnerability{{VulnerableVersions: "~> 1.0"}},
},
version: "1.0.0",
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := advisoryAffectsVersion(tc.adv, tc.version)
if got != tc.want {
t.Fatalf("advisoryAffectsVersion(%q) = %v, want %v",
tc.version, got, tc.want)
}
})
}
}
func TestFilterAffectedAdvisoriesEmptyVersion(t *testing.T) {
// Without a target version we can't compare ranges, so all advisories
// are conservatively included.
s := &Scanner{}
in := []GHSAAdvisory{
{GHSAID: "A", Vulnerabilities: []GHSAVulnerability{{VulnerableVersions: ">= 2.0.0"}}},
{GHSAID: "B"},
}
out := s.filterAffectedAdvisories(in, "")
if len(out) != len(in) {
t.Fatalf("expected all advisories with empty version, got %d/%d", len(out), len(in))
}
}
func TestFilterAffectedAdvisoriesFiltersByRange(t *testing.T) {
s := &Scanner{}
in := []GHSAAdvisory{
{ // matches
GHSAID: "MATCH",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 1.0.0, < 2.0.0"},
},
},
{ // does not match
GHSAID: "MISS",
Vulnerabilities: []GHSAVulnerability{
{VulnerableVersions: ">= 3.0.0"},
},
},
}
out := s.filterAffectedAdvisories(in, "1.5.0")
if len(out) != 1 || out[0].GHSAID != "MATCH" {
t.Fatalf("expected only MATCH, got %+v", out)
}
}
+76 -5
View File
@@ -1,3 +1,5 @@
// Package govulncheck wraps the `govulncheck` CLI to scan Go modules for
// known vulnerabilities.
package govulncheck package govulncheck
import ( import (
@@ -6,6 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -66,15 +69,30 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err) return nil, fmt.Errorf("failed to create temp dir: %w", err)
} }
defer os.RemoveAll(tmpDir) defer func() { _ = os.RemoveAll(tmpDir) }()
// Extract the .zip file // Extract the .zip file
if err := s.extractZip(filePath, tmpDir); err != nil { if extractErr := s.extractZip(filePath, tmpDir); extractErr != nil {
return nil, fmt.Errorf("failed to extract zip: %w", err) return nil, fmt.Errorf("failed to extract zip: %w", extractErr)
} }
// Run govulncheck // Locate the Go module root (directory containing go.mod). Go modules
cmd := exec.CommandContext(ctx, "govulncheck", "-json", "-mode=binary", tmpDir) // #nosec G204 -- govulncheck command with temp directory // in the proxy zip layout are nested under <module>@<version>/.
moduleDir, err := findGoModDir(tmpDir)
if err != nil {
log.Warn().
Err(err).
Str("package", packageName).
Str("version", version).
Msg("Could not locate go.mod in extracted module, skipping govulncheck")
return s.skippedResult(registry, packageName, version, "no go.mod in extracted module"), nil
}
// Run govulncheck in source mode against the module's package set.
// -mode=binary requires a compiled binary which we do not have; the
// default (source) mode wants a Go source tree with a go.mod.
cmd := exec.CommandContext(ctx, "govulncheck", "-json", "./...") // #nosec G204 -- fixed args, cwd is controlled temp dir
cmd.Dir = moduleDir
output, _ := cmd.CombinedOutput() output, _ := cmd.CombinedOutput()
// govulncheck returns non-zero when vulnerabilities are found // govulncheck returns non-zero when vulnerabilities are found
@@ -128,6 +146,59 @@ func (s *Scanner) extractZip(zipPath, destDir string) error {
return cmd.Run() return cmd.Run()
} }
// findGoModDir walks the directory tree under root looking for a directory
// that contains a go.mod file. The Go module proxy ships zips with layout
// "<module>@<version>/...", so the module root is typically one or two
// levels below the extraction directory. Returns an error if none is found.
func findGoModDir(root string) (string, error) {
// Quick check: does the root itself contain go.mod?
if _, err := os.Stat(filepath.Join(root, "go.mod")); err == nil {
return root, nil
}
var found string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil // keep searching
}
if d.IsDir() {
return nil
}
if d.Name() == "go.mod" {
found = filepath.Dir(path)
return filepath.SkipAll
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("go.mod not found under %s", root)
}
return found, nil
}
// skippedResult returns a clean ScanResult marked as skipped with an
// explanation. Using clean (not error) here because the package is simply
// not a Go module we can analyse — not a scanner failure.
func (s *Scanner) skippedResult(registry, packageName, version, reason string) *metadata.ScanResult {
return &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: ScannerName,
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{
"skipped": reason,
},
}
}
// convertResult converts govulncheck findings to our ScanResult format // convertResult converts govulncheck findings to our ScanResult format
func (s *Scanner) convertResult(vulns []GovulncheckVuln, registry, packageName, version string) *metadata.ScanResult { func (s *Scanner) convertResult(vulns []GovulncheckVuln, registry, packageName, version string) *metadata.ScanResult {
vulnerabilities := make([]metadata.Vulnerability, 0) vulnerabilities := make([]metadata.Vulnerability, 0)
+2
View File
@@ -1,3 +1,5 @@
// Package grype wraps the Anchore `grype` CLI to scan packages for
// known vulnerabilities.
package grype package grype
import ( import (
+45 -8
View File
@@ -1,3 +1,5 @@
// Package npmaudit wraps the `npm audit` CLI to surface vulnerability
// findings for npm packages.
package npmaudit package npmaudit
import ( import (
@@ -66,7 +68,7 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err) return nil, fmt.Errorf("failed to create temp dir: %w", err)
} }
defer os.RemoveAll(tmpDir) defer func() { _ = os.RemoveAll(tmpDir) }()
// Extract the .tgz file // Extract the .tgz file
if err := s.extractTgz(filePath, tmpDir); err != nil { if err := s.extractTgz(filePath, tmpDir); err != nil {
@@ -80,8 +82,36 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
packageDir = tmpDir packageDir = tmpDir
} }
// Run npm audit // npm tarballs ship only package.json — there is no lockfile. We must
cmd := exec.CommandContext(ctx, "npm", "audit", "--json", "--package-lock-only") // generate one before `npm audit` can resolve the dependency tree.
// NOTE: this performs network egress (npm registry lookups for
// transitive deps). Acceptable here because the scanner runs server-
// side and the operator already trusts upstream resolution to cache
// the package; we use --ignore-scripts to avoid running install hooks.
log.Info().
Str("scanner", ScannerName).
Str("package", packageName).
Msg("Generating package-lock.json for npm audit (network egress)")
installCmd := exec.CommandContext(ctx, "npm", "install",
"--package-lock-only",
"--omit=dev",
"--ignore-scripts",
"--no-audit",
)
installCmd.Dir = packageDir
if installOut, err := installCmd.CombinedOutput(); err != nil {
log.Warn().
Err(err).
Str("package", packageName).
Str("output", string(installOut)).
Msg("npm install --package-lock-only failed; returning scan-error")
return s.scanErrorResult(registry, packageName, version,
fmt.Sprintf("npm install --package-lock-only failed: %v", err)), nil
}
// Run npm audit against the freshly generated lockfile.
cmd := exec.CommandContext(ctx, "npm", "audit", "--json")
cmd.Dir = packageDir cmd.Dir = packageDir
output, _ := cmd.CombinedOutput() // npm audit returns non-zero when vulns found output, _ := cmd.CombinedOutput() // npm audit returns non-zero when vulns found
@@ -90,8 +120,9 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if len(output) > 0 { if len(output) > 0 {
if err := json.Unmarshal(output, &auditResult); err != nil { if err := json.Unmarshal(output, &auditResult); err != nil {
log.Warn().Err(err).Msg("Failed to parse npm audit output") log.Warn().Err(err).Msg("Failed to parse npm audit output")
// Return clean result on parse error // Parse failure means we couldn't determine vulnerability state — fail closed.
return s.emptyResult(registry, packageName, version), nil return s.scanErrorResult(registry, packageName, version,
fmt.Sprintf("failed to parse npm audit output: %v", err)), nil
} }
} }
@@ -123,7 +154,11 @@ func (s *Scanner) extractTgz(tgzPath, destDir string) error {
} }
// emptyResult returns an empty scan result // emptyResult returns an empty scan result
func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult {
// scanErrorResult returns a result marked as scan-error so the manager merge
// and CheckVulnerabilities can fail closed. Use this when the scan could not
// complete and we therefore have no signal about vulnerabilities.
func (s *Scanner) scanErrorResult(registry, packageName, version, reason string) *metadata.ScanResult {
return &metadata.ScanResult{ return &metadata.ScanResult{
ID: uuid.New().String(), ID: uuid.New().String(),
Registry: registry, Registry: registry,
@@ -131,10 +166,12 @@ func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.S
PackageVersion: version, PackageVersion: version,
Scanner: ScannerName, Scanner: ScannerName,
ScannedAt: time.Now(), ScannedAt: time.Now(),
Status: metadata.ScanStatusClean, Status: metadata.ScanStatusError,
VulnerabilityCount: 0, VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{}, Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{}, Details: map[string]interface{}{
"error": reason,
},
} }
} }
+3 -2
View File
@@ -1,3 +1,4 @@
// Package osv implements a vulnerability scanner backed by the OSV.dev API.
package osv package osv
import ( import (
@@ -158,7 +159,7 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil { if err != nil {
return nil, fmt.Errorf("OSV API request failed: %w", err) return nil, fmt.Errorf("OSV API request failed: %w", err)
} }
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
// Read response // Read response
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@@ -372,7 +373,7 @@ func (s *Scanner) Health(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("OSV API not reachable: %w", err) return fmt.Errorf("OSV API not reachable: %w", err)
} }
defer resp.Body.Close() // #nosec G104 -- Cleanup, error not critical defer func() { _ = resp.Body.Close() }() // #nosec G104 -- Cleanup, error not critical
log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed") log.Debug().Int("status", resp.StatusCode).Msg("OSV health check passed")
return nil return nil
+106 -9
View File
@@ -1,3 +1,5 @@
// Package pipaudit wraps the `pip-audit` CLI to scan Python wheels and
// source distributions for known vulnerabilities.
package pipaudit package pipaudit
import ( import (
@@ -7,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/lukaszraczylo/gohoarder/pkg/config" "github.com/lukaszraczylo/gohoarder/pkg/config"
@@ -66,7 +69,7 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create temp dir: %w", err) return nil, fmt.Errorf("failed to create temp dir: %w", err)
} }
defer os.RemoveAll(tmpDir) defer func() { _ = os.RemoveAll(tmpDir) }()
// Copy the wheel/tar.gz file to temp directory // Copy the wheel/tar.gz file to temp directory
tmpFile := filepath.Join(tmpDir, filepath.Base(filePath)) tmpFile := filepath.Join(tmpDir, filepath.Base(filePath))
@@ -74,16 +77,30 @@ func (s *Scanner) Scan(ctx context.Context, registry, packageName, version strin
return nil, fmt.Errorf("failed to copy file: %w", err) return nil, fmt.Errorf("failed to copy file: %w", err)
} }
// Run pip-audit on the package file // Build the appropriate pip-audit invocation based on artifact type.
cmd := exec.CommandContext(ctx, "pip-audit", "-r", tmpFile, "--format", "json") // #nosec G204 -- pip-audit command with temp file // `-r` expects requirements.txt — passing a wheel/tarball there is wrong.
output, _ := cmd.CombinedOutput() // pip-audit returns non-zero when vulns found // Wheels can be scanned directly via positional arg. Source distributions
// (tarballs) need to be extracted; if they contain a pyproject.toml we
// can scan that, otherwise we fail closed.
cmd, prepErr := s.buildAuditCmd(ctx, tmpDir, tmpFile)
if prepErr != nil {
log.Warn().
Err(prepErr).
Str("package", packageName).
Str("version", version).
Msg("pip-audit could not prepare input artifact, returning scan-error")
return s.scanErrorResult(registry, packageName, version, prepErr.Error()), nil
}
output, _ := cmd.CombinedOutput() // pip-audit returns non-zero when vulns found
// Parse pip-audit output // Parse pip-audit output
var auditResult PipAuditResult var auditResult PipAuditResult
if len(output) > 0 { if len(output) > 0 {
if err := json.Unmarshal(output, &auditResult); err != nil { if err := json.Unmarshal(output, &auditResult); err != nil {
log.Warn().Err(err).Msg("Failed to parse pip-audit output") log.Warn().Err(err).Msg("Failed to parse pip-audit output")
return s.emptyResult(registry, packageName, version), nil // Parse failure → no signal → fail closed.
return s.scanErrorResult(registry, packageName, version,
fmt.Sprintf("failed to parse pip-audit output: %v", err)), nil
} }
} }
@@ -117,8 +134,84 @@ func (s *Scanner) copyFile(src, dst string) error {
return os.WriteFile(dst, input, 0600) return os.WriteFile(dst, input, 0600)
} }
// emptyResult returns an empty scan result // buildAuditCmd constructs the right pip-audit command for the input artifact.
func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.ScanResult { //
// - .whl -> pip-audit <wheel> --format json
// - .tar.gz / .tgz / .zip (sdist) -> extract; if pyproject.toml exists
// run `pip-audit --pyproject <pyproject> --format json`; otherwise error.
//
// extractDir is used as a workspace for sdist extraction.
func (s *Scanner) buildAuditCmd(ctx context.Context, extractDir, artifact string) (*exec.Cmd, error) {
lower := strings.ToLower(artifact)
switch {
case strings.HasSuffix(lower, ".whl"):
// pip-audit can scan a wheel directly via positional argument.
return exec.CommandContext(ctx, "pip-audit", artifact, "--format", "json"), nil // #nosec G204 -- artifact path is in controlled tmp dir
case strings.HasSuffix(lower, ".tar.gz"),
strings.HasSuffix(lower, ".tgz"),
strings.HasSuffix(lower, ".zip"):
// Source distributions must be unpacked first.
sdistDir := filepath.Join(extractDir, "sdist")
if err := os.MkdirAll(sdistDir, 0o750); err != nil {
return nil, fmt.Errorf("create sdist dir: %w", err)
}
if err := s.extractSdist(artifact, sdistDir); err != nil {
return nil, fmt.Errorf("extract sdist: %w", err)
}
pyproject, err := findPyProject(sdistDir)
if err != nil {
return nil, fmt.Errorf("no pyproject.toml in sdist: %w", err)
}
return exec.CommandContext(ctx, "pip-audit", "--pyproject", pyproject, "--format", "json"), nil // #nosec G204 -- pyproject path under controlled tmp dir
default:
return nil, fmt.Errorf("unsupported pip artifact extension: %s", filepath.Base(artifact))
}
}
// extractSdist unpacks a Python source distribution into destDir.
func (s *Scanner) extractSdist(archive, destDir string) error {
lower := strings.ToLower(archive)
switch {
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return exec.Command("tar", "-xzf", archive, "-C", destDir).Run()
case strings.HasSuffix(lower, ".zip"):
return exec.Command("unzip", "-q", archive, "-d", destDir).Run()
default:
return fmt.Errorf("unknown archive type: %s", archive)
}
}
// findPyProject returns the path to a pyproject.toml within root, walking
// one level deep (sdists typically extract to <pkg>-<ver>/).
func findPyProject(root string) (string, error) {
var found string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if d.Name() == "pyproject.toml" {
found = path
return filepath.SkipAll
}
return nil
})
if err != nil {
return "", err
}
if found == "" {
return "", fmt.Errorf("pyproject.toml not found under %s", root)
}
return found, nil
}
// scanErrorResult returns a result marked scan-error so manager merge and
// CheckVulnerabilities can fail closed when this scanner could not run.
func (s *Scanner) scanErrorResult(registry, packageName, version, reason string) *metadata.ScanResult {
return &metadata.ScanResult{ return &metadata.ScanResult{
ID: uuid.New().String(), ID: uuid.New().String(),
Registry: registry, Registry: registry,
@@ -126,13 +219,17 @@ func (s *Scanner) emptyResult(registry, packageName, version string) *metadata.S
PackageVersion: version, PackageVersion: version,
Scanner: ScannerName, Scanner: ScannerName,
ScannedAt: time.Now(), ScannedAt: time.Now(),
Status: metadata.ScanStatusClean, Status: metadata.ScanStatusError,
VulnerabilityCount: 0, VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{}, Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{}, Details: map[string]interface{}{
"error": reason,
},
} }
} }
// emptyResult returns an empty scan result
// convertResult converts pip-audit output to our ScanResult format // convertResult converts pip-audit output to our ScanResult format
func (s *Scanner) convertResult(auditResult *PipAuditResult, registry, packageName, version string) *metadata.ScanResult { func (s *Scanner) convertResult(auditResult *PipAuditResult, registry, packageName, version string) *metadata.ScanResult {
vulnerabilities := make([]metadata.Vulnerability, 0) vulnerabilities := make([]metadata.Vulnerability, 0)
+6 -2
View File
@@ -2,6 +2,7 @@ package scanner
import ( import (
"context" "context"
"sync"
"time" "time"
"github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/metadata"
@@ -15,6 +16,7 @@ type RescanWorker struct {
storage storage.StorageBackend storage storage.StorageBackend
manager *Manager manager *Manager
stopCh chan struct{} stopCh chan struct{}
stopOnce sync.Once
interval time.Duration interval time.Duration
} }
@@ -64,9 +66,11 @@ func (w *RescanWorker) Start(ctx context.Context) {
} }
} }
// Stop stops the rescan worker // Stop stops the rescan worker. Safe to call multiple times.
func (w *RescanWorker) Stop() { func (w *RescanWorker) Stop() {
close(w.stopCh) w.stopOnce.Do(func() {
close(w.stopCh)
})
} }
// rescanPackages re-scans packages that need updating // rescanPackages re-scans packages that need updating
+137 -14
View File
@@ -1,11 +1,18 @@
// Package scanner orchestrates pluggable vulnerability scanners and
// records their results against cached packages.
package scanner package scanner
import ( import (
"context" "context"
stderrors "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/config" "github.com/lukaszraczylo/gohoarder/pkg/config"
hoardererrors "github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/events"
"github.com/lukaszraczylo/gohoarder/pkg/metadata" "github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/ghsa" "github.com/lukaszraczylo/gohoarder/pkg/scanner/ghsa"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/govulncheck" "github.com/lukaszraczylo/gohoarder/pkg/scanner/govulncheck"
@@ -14,6 +21,8 @@ import (
"github.com/lukaszraczylo/gohoarder/pkg/scanner/osv" "github.com/lukaszraczylo/gohoarder/pkg/scanner/osv"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/pipaudit" "github.com/lukaszraczylo/gohoarder/pkg/scanner/pipaudit"
"github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy" "github.com/lukaszraczylo/gohoarder/pkg/scanner/trivy"
"github.com/lukaszraczylo/gohoarder/pkg/uuid"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -37,11 +46,34 @@ type DatabaseUpdater interface {
// Manager manages multiple security scanners // Manager manages multiple security scanners
type Manager struct { type Manager struct {
metadataStore metadata.MetadataStore metadataStore metadata.MetadataStore
config config.SecurityConfig broadcaster events.Broadcaster
scanners []Scanner scanners []Scanner
config config.SecurityConfig
bcMu sync.RWMutex
enabled bool enabled bool
} }
// SetBroadcaster wires an events.Broadcaster onto the scanner so scan
// lifecycle events are published. Pass nil to disable broadcasting.
// Safe for concurrent use.
func (m *Manager) SetBroadcaster(b events.Broadcaster) {
m.bcMu.Lock()
m.broadcaster = b
m.bcMu.Unlock()
}
// emit publishes an event via the configured broadcaster, if any.
// Non-blocking: the underlying transport handles overflow by dropping.
func (m *Manager) emit(eventType string, payload map[string]interface{}) {
m.bcMu.RLock()
b := m.broadcaster
m.bcMu.RUnlock()
if b == nil {
return
}
b.BroadcastEvent(eventType, payload)
}
// New creates a new scanner manager with configured scanners // New creates a new scanner manager with configured scanners
func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Manager, error) { func New(cfg config.SecurityConfig, metadataStore metadata.MetadataStore) (*Manager, error) {
manager := &Manager{ manager := &Manager{
@@ -178,11 +210,42 @@ func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, versio
Msg("Scan completed") Msg("Scan completed")
} }
// If no scanners succeeded, return // If no scanners succeeded, persist a synthetic error result so callers
// fail closed (no scan == blocked) rather than silently leaving
// SecurityScanned=false which would allow the package to be served.
if len(scanResults) == 0 { if len(scanResults) == 0 {
log.Warn(). log.Warn().
Str("package", packageName). Str("package", packageName).
Msg("All scanners failed, no results to save") Msg("All scanners failed, saving scan-error result for fail-closed enforcement")
errResult := &metadata.ScanResult{
ID: uuid.New().String(),
Registry: registry,
PackageName: packageName,
PackageVersion: version,
Scanner: "all",
ScannedAt: time.Now(),
Status: metadata.ScanStatusError,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
Details: map[string]interface{}{
"error": "all configured scanners failed for this package",
},
}
if err := m.metadataStore.SaveScanResult(ctx, errResult); err != nil {
log.Error().
Err(err).
Str("package", packageName).
Msg("Failed to save scan-error result")
return err
}
m.emit(string(websocket.EventScanComplete), map[string]interface{}{
"registry": registry,
"name": packageName,
"version": version,
"status": string(errResult.Status),
"vulnerability_count": errResult.VulnerabilityCount,
})
return nil return nil
} }
@@ -206,6 +269,14 @@ func (m *Manager) ScanPackage(ctx context.Context, registry, packageName, versio
Strs("scanners", scannerNames). Strs("scanners", scannerNames).
Msg("Consolidated scan results saved") Msg("Consolidated scan results saved")
m.emit(string(websocket.EventScanComplete), map[string]interface{}{
"registry": registry,
"name": packageName,
"version": version,
"status": string(mergedResult.Status),
"vulnerability_count": mergedResult.VulnerabilityCount,
})
return nil return nil
} }
@@ -285,18 +356,28 @@ func (m *Manager) mergeResults(results []*metadata.ScanResult, scannerNames []st
} }
} }
// Update status to worst case // Update status to worst case.
if result.Status == metadata.ScanStatusVulnerable { // Order: vulnerable > error > pending > clean. Vulnerable wins because
// the cache layer must surface the actual vulns. Otherwise, if any
// scanner errored, propagate so CheckVulnerabilities can fail closed.
switch result.Status {
case metadata.ScanStatusVulnerable:
merged.Status = metadata.ScanStatusVulnerable merged.Status = metadata.ScanStatusVulnerable
} else if result.Status == metadata.ScanStatusPending && merged.Status != metadata.ScanStatusVulnerable { case metadata.ScanStatusError:
merged.Status = metadata.ScanStatusPending if merged.Status != metadata.ScanStatusVulnerable {
merged.Status = metadata.ScanStatusError
}
case metadata.ScanStatusPending:
if merged.Status != metadata.ScanStatusVulnerable && merged.Status != metadata.ScanStatusError {
merged.Status = metadata.ScanStatusPending
}
} }
} }
// Convert map to slice and count severities // Convert map to slice and count severities
for _, vuln := range vulnMap { for _, vuln := range vulnMap {
merged.Vulnerabilities = append(merged.Vulnerabilities, *vuln) merged.Vulnerabilities = append(merged.Vulnerabilities, *vuln)
severityCounts[strings.ToUpper(vuln.Severity)]++ severityCounts[metadata.NormalizeSeverity(vuln.Severity)]++
} }
// Update counts // Update counts
@@ -328,6 +409,21 @@ func (m *Manager) compareSeverity(s1, s2 string) int {
return v1 - v2 return v1 - v2
} }
// normalizeBypassPackageName reduces a stored package cache key to the
// natural registry/package identifier used in admin bypass targets.
// Go module packages are stored with a cache key of
// "modulePath/@v/version.zip" (and .info/.mod variants); the admin-facing
// target uses the bare module path, e.g. "go/github.com/foo/[email protected]".
// npm/pypi names are stored unchanged, so this is a no-op for them.
func normalizeBypassPackageName(registry, name string) string {
if registry == "go" {
if idx := strings.Index(name, "/@v/"); idx >= 0 {
return name[:idx]
}
}
return name
}
// CheckVulnerabilities checks if a package exceeds vulnerability thresholds // CheckVulnerabilities checks if a package exceeds vulnerability thresholds
func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageName, version string) (bool, string, error) { func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageName, version string) (bool, string, error) {
if !m.enabled { if !m.enabled {
@@ -342,8 +438,9 @@ func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageNam
} }
// Check if entire package is bypassed // Check if entire package is bypassed
packageKey := fmt.Sprintf("%s/%s@%s", registry, packageName, version) bypassName := normalizeBypassPackageName(registry, packageName)
packageKeyNoVersion := fmt.Sprintf("%s/%s", registry, packageName) packageKey := fmt.Sprintf("%s/%s@%s", registry, bypassName, version)
packageKeyNoVersion := fmt.Sprintf("%s/%s", registry, bypassName)
for _, bypass := range bypasses { for _, bypass := range bypasses {
if bypass.Type == metadata.BypassTypePackage && bypass.Active { if bypass.Type == metadata.BypassTypePackage && bypass.Active {
@@ -359,11 +456,37 @@ func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageNam
} }
} }
// Get latest scan result // Get latest scan result.
// SECURITY: Fail closed when no scan exists or backend errors.
// Previously this returned (false, "", nil) which allowed unscanned
// packages through — a fail-open bypass. Cache layer is expected to
// wait for the initial scan via SecurityScanned flag; once that flag
// is set, GetScanResult MUST return a record. A missing record at this
// point indicates either a cleared/lost scan or a transient error;
// either way we block.
result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version) result, err := m.metadataStore.GetScanResult(ctx, registry, packageName, version)
if err != nil { if err != nil {
// No scan result found - allow download (will be scanned after) var hErr *hoardererrors.Error
return false, "", nil if stderrors.As(err, &hErr) && hErr.Code == hoardererrors.ErrCodeNotFound {
return true, "no scan available - fail closed", nil
}
// Real backend error (DB transient, etc.) — also block.
log.Warn().
Err(err).
Str("package", packageName).
Msg("Failed to retrieve scan result, failing closed")
return true, "scan lookup failed - fail closed", nil
}
if result == nil {
// File-backed metadata store returns (nil, nil) on not-found.
return true, "no scan available - fail closed", nil
}
// If the scan itself errored (all scanners failed for this package),
// block. A scan-error record means we don't actually know whether the
// package is safe.
if result.Status == metadata.ScanStatusError {
return true, "scan failed - fail closed", nil
} }
// Build set of bypassed CVEs for fast lookup // Build set of bypassed CVEs for fast lookup
@@ -392,7 +515,7 @@ func (m *Manager) CheckVulnerabilities(ctx context.Context, registry, packageNam
Msg("CVE bypassed by admin") Msg("CVE bypassed by admin")
continue continue
} }
severityCounts[strings.ToUpper(vuln.Severity)]++ severityCounts[metadata.NormalizeSeverity(vuln.Severity)]++
} }
// Check against thresholds // Check against thresholds
+332
View File
@@ -0,0 +1,332 @@
package scanner
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/lukaszraczylo/gohoarder/pkg/config"
hoardererrors "github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/metadata"
"github.com/lukaszraczylo/gohoarder/pkg/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeBroadcaster records BroadcastEvent calls for assertions.
type fakeBroadcaster struct {
events []fakeBroadcastEvent
mu sync.Mutex
}
type fakeBroadcastEvent struct {
Payload any
Type string
}
func (f *fakeBroadcaster) BroadcastEvent(eventType string, payload any) {
f.mu.Lock()
defer f.mu.Unlock()
f.events = append(f.events, fakeBroadcastEvent{Type: eventType, Payload: payload})
}
func (f *fakeBroadcaster) snapshot() []fakeBroadcastEvent {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]fakeBroadcastEvent, len(f.events))
copy(out, f.events)
return out
}
// stubScanner is a minimal Scanner implementation for tests.
type stubScanner struct {
result *metadata.ScanResult
err error
name string
}
func (s *stubScanner) Name() string { return s.name }
func (s *stubScanner) Scan(_ context.Context, registry, packageName, version, _ string) (*metadata.ScanResult, error) {
if s.err != nil {
return nil, s.err
}
r := *s.result
r.Registry = registry
r.PackageName = packageName
r.PackageVersion = version
return &r, nil
}
func (s *stubScanner) Health(context.Context) error { return nil }
// stubMetadataStore is a minimal MetadataStore — only SaveScanResult is exercised.
type stubMetadataStore struct {
saveErr error
savedResults []*metadata.ScanResult
mu sync.Mutex
}
func (m *stubMetadataStore) SaveScanResult(_ context.Context, r *metadata.ScanResult) error {
if m.saveErr != nil {
return m.saveErr
}
m.mu.Lock()
m.savedResults = append(m.savedResults, r)
m.mu.Unlock()
return nil
}
// All other MetadataStore methods are unused by ScanPackage — stub to satisfy interface.
func (m *stubMetadataStore) SavePackage(context.Context, *metadata.Package) error { return nil }
func (m *stubMetadataStore) GetPackage(context.Context, string, string, string) (*metadata.Package, error) {
return nil, hoardererrors.NotFound("not implemented")
}
func (m *stubMetadataStore) DeletePackage(context.Context, string, string, string) error { return nil }
func (m *stubMetadataStore) ListPackages(context.Context, *metadata.ListOptions) ([]*metadata.Package, error) {
return nil, nil
}
func (m *stubMetadataStore) UpdateDownloadCount(context.Context, string, string, string) error {
return nil
}
func (m *stubMetadataStore) GetStats(context.Context, string) (*metadata.Stats, error) {
return nil, nil
}
func (m *stubMetadataStore) GetScanResult(context.Context, string, string, string) (*metadata.ScanResult, error) {
return nil, nil
}
func (m *stubMetadataStore) Count(context.Context) (int, error) { return 0, nil }
func (m *stubMetadataStore) Health(context.Context) error { return nil }
func (m *stubMetadataStore) Close() error { return nil }
func (m *stubMetadataStore) SaveCVEBypass(context.Context, *metadata.CVEBypass) error {
return nil
}
func (m *stubMetadataStore) GetActiveCVEBypasses(context.Context) ([]*metadata.CVEBypass, error) {
return nil, nil
}
func (m *stubMetadataStore) ListCVEBypasses(context.Context, *metadata.BypassListOptions) ([]*metadata.CVEBypass, error) {
return nil, nil
}
func (m *stubMetadataStore) DeleteCVEBypass(context.Context, string) error { return nil }
func (m *stubMetadataStore) CleanupExpiredBypasses(context.Context) (int, error) {
return 0, nil
}
func (m *stubMetadataStore) GetTimeSeriesStats(context.Context, string, string) (*metadata.TimeSeriesStats, error) {
return nil, nil
}
func (m *stubMetadataStore) AggregateDownloadData(context.Context) error { return nil }
func (m *stubMetadataStore) SaveAPIKey(context.Context, *metadata.APIKey) error {
return metadata.ErrNotImplemented
}
func (m *stubMetadataStore) GetAPIKey(context.Context, string) (*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
func (m *stubMetadataStore) ListAPIKeys(context.Context) ([]*metadata.APIKey, error) {
return nil, metadata.ErrNotImplemented
}
func (m *stubMetadataStore) DeleteAPIKey(context.Context, string) error {
return metadata.ErrNotImplemented
}
func (m *stubMetadataStore) UpdateAPIKeyLastUsed(context.Context, string, time.Time) error {
return nil
}
func newTestManager(t *testing.T, store metadata.MetadataStore) *Manager {
t.Helper()
// Enabled=true but all built-in scanners disabled — we'll register
// our own stub via RegisterScanner.
cfg := config.SecurityConfig{Enabled: true}
mgr, err := New(cfg, store)
require.NoError(t, err)
return mgr
}
// TestBroadcaster_ScanCompleteSuccess verifies EventScanComplete fires
// after a successful scan with the expected payload shape.
func TestBroadcaster_ScanCompleteSuccess(t *testing.T) {
store := &stubMetadataStore{}
mgr := newTestManager(t, store)
result := &metadata.ScanResult{
ID: "r1",
Scanner: "stub",
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
VulnerabilityCount: 0,
Vulnerabilities: []metadata.Vulnerability{},
}
mgr.RegisterScanner(&stubScanner{name: "stub", result: result})
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err := mgr.ScanPackage(context.Background(), "npm", "react", "18.2.0", "/tmp/dummy")
require.NoError(t, err)
events := bc.snapshot()
require.Len(t, events, 1)
assert.Equal(t, string(websocket.EventScanComplete), events[0].Type)
payload, ok := events[0].Payload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "npm", payload["registry"])
assert.Equal(t, "react", payload["name"])
assert.Equal(t, "18.2.0", payload["version"])
assert.Equal(t, string(metadata.ScanStatusClean), payload["status"])
assert.Equal(t, 0, payload["vulnerability_count"])
}
// TestBroadcaster_ScanCompleteAllScannersFailed verifies that the
// synthetic-error path still fires EventScanComplete with status=error.
func TestBroadcaster_ScanCompleteAllScannersFailed(t *testing.T) {
store := &stubMetadataStore{}
mgr := newTestManager(t, store)
mgr.RegisterScanner(&stubScanner{name: "broken", err: errors.New("scanner exploded")})
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err := mgr.ScanPackage(context.Background(), "pypi", "requests", "2.31.0", "/tmp/dummy")
require.NoError(t, err)
events := bc.snapshot()
require.Len(t, events, 1)
assert.Equal(t, string(websocket.EventScanComplete), events[0].Type)
payload := events[0].Payload.(map[string]interface{})
assert.Equal(t, "pypi", payload["registry"])
assert.Equal(t, "requests", payload["name"])
assert.Equal(t, "2.31.0", payload["version"])
assert.Equal(t, string(metadata.ScanStatusError), payload["status"])
}
// TestBroadcaster_NoEmitOnSaveError verifies no event is emitted when
// the metadata store fails to persist the scan result.
func TestBroadcaster_NoEmitOnSaveError(t *testing.T) {
store := &stubMetadataStore{saveErr: errors.New("db down")}
mgr := newTestManager(t, store)
result := &metadata.ScanResult{
ID: "r2",
Scanner: "stub",
ScannedAt: time.Now(),
Status: metadata.ScanStatusClean,
Vulnerabilities: []metadata.Vulnerability{},
}
mgr.RegisterScanner(&stubScanner{name: "stub", result: result})
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err := mgr.ScanPackage(context.Background(), "npm", "x", "1", "/tmp/dummy")
require.Error(t, err)
assert.Empty(t, bc.snapshot(), "no event should fire when SaveScanResult fails")
}
// TestBroadcaster_DisabledNoEmit verifies disabled scanner manager
// silently no-ops and emits nothing.
func TestBroadcaster_DisabledNoEmit(t *testing.T) {
store := &stubMetadataStore{}
cfg := config.SecurityConfig{Enabled: false}
mgr, err := New(cfg, store)
require.NoError(t, err)
bc := &fakeBroadcaster{}
mgr.SetBroadcaster(bc)
err = mgr.ScanPackage(context.Background(), "npm", "x", "1", "/tmp/dummy")
require.NoError(t, err)
assert.Empty(t, bc.snapshot())
}
// TestBroadcaster_NilBroadcasterSafe ensures nil broadcaster is safe.
func TestBroadcaster_NilBroadcasterSafe(t *testing.T) {
store := &stubMetadataStore{}
mgr := newTestManager(t, store)
mgr.RegisterScanner(&stubScanner{name: "stub", result: &metadata.ScanResult{
ID: "r", Scanner: "stub", ScannedAt: time.Now(), Status: metadata.ScanStatusClean,
}})
// No SetBroadcaster.
err := mgr.ScanPackage(context.Background(), "npm", "x", "1", "/tmp/dummy")
require.NoError(t, err)
}
// stubBypassStore returns a package bypass and a clean scan result for a
// Go cache-key package, to exercise FIX-H4 bypass-name normalization.
type stubBypassStore struct {
*stubMetadataStore
scan *metadata.ScanResult
bypasses []*metadata.CVEBypass
}
func (m *stubBypassStore) GetActiveCVEBypasses(context.Context) ([]*metadata.CVEBypass, error) {
return m.bypasses, nil
}
func (m *stubBypassStore) GetScanResult(context.Context, string, string, string) (*metadata.ScanResult, error) {
return m.scan, nil
}
// TestCheckVulnerabilities_GoPackageBypassNaturalTarget verifies a package
// bypass targeting the natural Go module path matches a package stored under
// its cache-key name (modulePath/@v/version.zip).
func TestCheckVulnerabilities_GoPackageBypassNaturalTarget(t *testing.T) {
store := &stubBypassStore{
stubMetadataStore: &stubMetadataStore{},
bypasses: []*metadata.CVEBypass{
{
ID: "b1",
Type: metadata.BypassTypePackage,
Target: "go/github.com/foo/[email protected]",
Active: true,
},
},
scan: &metadata.ScanResult{
ID: "r", Scanner: "stub", ScannedAt: time.Now(), Status: metadata.ScanStatusClean,
},
}
mgr := newTestManager(t, store)
// The package is stored under the cache-key name; the version is passed
// separately. The bypass target uses the natural module path.
blocked, reason, err := mgr.CheckVulnerabilities(
context.Background(), "go", "github.com/foo/bar/@v/v1.2.3.zip", "v1.2.3")
require.NoError(t, err)
assert.False(t, blocked, "package bypass should unblock the Go package")
assert.Equal(t, "", reason)
}
// TestCheckVulnerabilities_GoPackageBypassNotFound verifies a package bypass
// targeting a different module does NOT unblock the Go package. Its stored
// scan result is clean, so CheckVulnerabilities returns not-blocked anyway;
// to make the package-block path observable we use a scan result with a
// high-severity vulnerability and zero thresholds (block any non-zero).
func TestCheckVulnerabilities_GoPackageBypassNotFound(t *testing.T) {
store := &stubBypassStore{
stubMetadataStore: &stubMetadataStore{},
bypasses: []*metadata.CVEBypass{
{
ID: "b1",
Type: metadata.BypassTypePackage,
Target: "go/github.com/other/[email protected]",
Active: true,
},
},
scan: &metadata.ScanResult{
ID: "r", Scanner: "stub", ScannedAt: time.Now(), Status: metadata.ScanStatusVulnerable,
Vulnerabilities: []metadata.Vulnerability{
{ID: "CVE-1", Severity: "HIGH"},
},
},
}
mgr := newTestManager(t, store)
blocked, _, err := mgr.CheckVulnerabilities(
context.Background(), "go", "github.com/foo/bar/@v/v1.2.3.zip", "v1.2.3")
require.NoError(t, err)
assert.True(t, blocked, "a non-matching bypass should not unblock the package")
}
+2
View File
@@ -1,3 +1,5 @@
// Package trivy wraps the Aqua Security `trivy` CLI to scan packages for
// known vulnerabilities and license issues.
package trivy package trivy
import ( import (
+124 -53
View File
@@ -1,3 +1,5 @@
// Package filesystem implements the local-disk Storage backend used for
// development and single-node deployments.
package filesystem package filesystem
import ( import (
@@ -55,7 +57,11 @@ func (fs *FilesystemStorage) Get(ctx context.Context, key string) (io.ReadCloser
default: default:
} }
path := fs.keyToPath(key) path, err := fs.keyToPath(key)
if err != nil {
metrics.RecordStorageOperation("filesystem", "get", "error")
return nil, err
}
file, err := os.Open(path) // #nosec G304 -- Path is sanitized storage key file, err := os.Open(path) // #nosec G304 -- Path is sanitized storage key
if err != nil { if err != nil {
@@ -80,18 +86,35 @@ func (fs *FilesystemStorage) Put(ctx context.Context, key string, data io.Reader
default: default:
} }
path := fs.keyToPath(key) path, err := fs.keyToPath(key)
dir := filepath.Dir(path) if err != nil {
metrics.RecordStorageOperation("filesystem", "put", "error")
return err
}
relPath, err := fs.relativeToBase(path)
if err != nil {
metrics.RecordStorageOperation("filesystem", "put", "error")
return err
}
dir := filepath.Dir(relPath)
root, err := os.OpenRoot(fs.basePath)
if err != nil {
metrics.RecordStorageOperation("filesystem", "put", "error")
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to open storage root")
}
defer root.Close() // #nosec G307 -- Best-effort cleanup of opened storage root
// Create directory // Create directory
if err := os.MkdirAll(dir, 0750); err != nil { if dir != "." {
metrics.RecordStorageOperation("filesystem", "put", "error") if mkErr := root.MkdirAll(dir, 0750); mkErr != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create directory") metrics.RecordStorageOperation("filesystem", "put", "error")
return errors.Wrap(mkErr, errors.ErrCodeStorageFailure, "failed to create directory")
}
} }
// Create temp file for atomic write // Create temp file for atomic write
tempPath := path + ".tmp" tempPath := relPath + ".tmp"
tempFile, err := os.Create(tempPath) // #nosec G304 -- Temp path is constructed from sanitized storage key tempFile, err := root.Create(tempPath)
if err != nil { if err != nil {
metrics.RecordStorageOperation("filesystem", "put", "error") metrics.RecordStorageOperation("filesystem", "put", "error")
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create temp file") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create temp file")
@@ -105,62 +128,55 @@ func (fs *FilesystemStorage) Put(ctx context.Context, key string, data io.Reader
written, err := io.Copy(multiWriter, data) written, err := io.Copy(multiWriter, data)
if err != nil { if err != nil {
tempFile.Close() // #nosec G104 -- Cleanup, error not critical _ = tempFile.Close() // #nosec G104 -- Cleanup, error not critical
_ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical _ = root.Remove(tempPath)
metrics.RecordStorageOperation("filesystem", "put", "error") metrics.RecordStorageOperation("filesystem", "put", "error")
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to write data") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to write data")
} }
if err := tempFile.Close(); err != nil { if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical _ = root.Remove(tempPath)
metrics.RecordStorageOperation("filesystem", "put", "error") metrics.RecordStorageOperation("filesystem", "put", "error")
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to close temp file") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to close temp file")
} }
// Check quota
fs.mu.Lock()
if fs.quota > 0 && fs.used+written > fs.quota {
fs.mu.Unlock()
_ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical
metrics.RecordStorageOperation("filesystem", "put", "quota_exceeded")
return errors.QuotaExceeded(fs.quota)
}
fs.used += written
fs.mu.Unlock()
// Verify checksums if provided // Verify checksums if provided
if opts != nil { if opts != nil {
md5Sum := hex.EncodeToString(md5Hash.Sum(nil)) md5Sum := hex.EncodeToString(md5Hash.Sum(nil))
sha256Sum := hex.EncodeToString(sha256Hash.Sum(nil)) sha256Sum := hex.EncodeToString(sha256Hash.Sum(nil))
if opts.ChecksumMD5 != "" && opts.ChecksumMD5 != md5Sum { if opts.ChecksumMD5 != "" && opts.ChecksumMD5 != md5Sum {
_ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical _ = root.Remove(tempPath)
metrics.RecordStorageOperation("filesystem", "put", "checksum_error") metrics.RecordStorageOperation("filesystem", "put", "checksum_error")
return errors.New(errors.ErrCodeChecksumMismatch, "MD5 checksum mismatch") return errors.New(errors.ErrCodeChecksumMismatch, "MD5 checksum mismatch")
} }
if opts.ChecksumSHA256 != "" && opts.ChecksumSHA256 != sha256Sum { if opts.ChecksumSHA256 != "" && opts.ChecksumSHA256 != sha256Sum {
_ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical _ = root.Remove(tempPath)
metrics.RecordStorageOperation("filesystem", "put", "checksum_error") metrics.RecordStorageOperation("filesystem", "put", "checksum_error")
return errors.New(errors.ErrCodeChecksumMismatch, "SHA256 checksum mismatch") return errors.New(errors.ErrCodeChecksumMismatch, "SHA256 checksum mismatch")
} }
} }
// Atomic rename // Atomic rename and quota update under lock so that fs.used reflects
if err := os.Rename(tempPath, path); err != nil { // only successfully renamed files. Quota check happens before increment
_ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical // to avoid transient inflation seen by concurrent Puts.
fs.mu.Lock() fs.mu.Lock()
fs.used -= written if fs.quota > 0 && fs.used+written > fs.quota {
currentUsed := fs.used
fs.mu.Unlock() fs.mu.Unlock()
_ = root.Remove(tempPath)
metrics.RecordStorageOperation("filesystem", "put", "quota_exceeded")
return errors.QuotaExceeded(fs.quota)
}
if err := root.Rename(tempPath, relPath); err != nil {
fs.mu.Unlock()
_ = root.Remove(tempPath)
metrics.RecordStorageOperation("filesystem", "put", "error") metrics.RecordStorageOperation("filesystem", "put", "error")
metrics.UpdateCacheSize("filesystem", currentUsed)
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to rename temp file") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to rename temp file")
} }
fs.used += written
fs.mu.RLock()
currentUsed := fs.used currentUsed := fs.used
fs.mu.RUnlock() fs.mu.Unlock()
metrics.RecordStorageOperation("filesystem", "put", "success") metrics.RecordStorageOperation("filesystem", "put", "success")
metrics.UpdateCacheSize("filesystem", currentUsed) metrics.UpdateCacheSize("filesystem", currentUsed)
@@ -175,7 +191,11 @@ func (fs *FilesystemStorage) Delete(ctx context.Context, key string) error {
default: default:
} }
path := fs.keyToPath(key) path, err := fs.keyToPath(key)
if err != nil {
metrics.RecordStorageOperation("filesystem", "delete", "error")
return err
}
// Get size before deletion // Get size before deletion
info, err := os.Stat(path) info, err := os.Stat(path)
@@ -213,8 +233,11 @@ func (fs *FilesystemStorage) Exists(ctx context.Context, key string) (bool, erro
default: default:
} }
path := fs.keyToPath(key) path, err := fs.keyToPath(key)
_, err := os.Stat(path) if err != nil {
return false, err
}
_, err = os.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return false, nil return false, nil
@@ -232,10 +255,13 @@ func (fs *FilesystemStorage) List(ctx context.Context, prefix string, opts *stor
default: default:
} }
searchPath := fs.keyToPath(prefix) searchPath, err := fs.keyToPath(prefix)
if err != nil {
return nil, err
}
var objects []storage.StorageObject var objects []storage.StorageObject
err := filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error { err = filepath.Walk(searchPath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return nil // Skip errors return nil // Skip errors
} }
@@ -284,7 +310,10 @@ func (fs *FilesystemStorage) Stat(ctx context.Context, key string) (*storage.Sto
default: default:
} }
path := fs.keyToPath(key) path, err := fs.keyToPath(key)
if err != nil {
return nil, err
}
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -331,7 +360,7 @@ func (fs *FilesystemStorage) Health(ctx context.Context) error {
if err != nil { if err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "cannot write to storage") return errors.Wrap(err, errors.ErrCodeStorageFailure, "cannot write to storage")
} }
f.Close() // #nosec G104 -- Cleanup, error not critical _ = f.Close() // #nosec G104 -- Cleanup, error not critical
_ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical _ = os.Remove(tempPath) // #nosec G104 -- Cleanup, error not critical
return nil return nil
@@ -352,7 +381,10 @@ func (fs *FilesystemStorage) GetLocalPath(ctx context.Context, key string) (stri
default: default:
} }
path := fs.keyToPath(key) path, err := fs.keyToPath(key)
if err != nil {
return "", err
}
// Verify file exists // Verify file exists
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
@@ -365,27 +397,66 @@ func (fs *FilesystemStorage) GetLocalPath(ctx context.Context, key string) (stri
return path, nil return path, nil
} }
// keyToPath converts a storage key to filesystem path // keyToPath converts a storage key to filesystem path.
func (fs *FilesystemStorage) keyToPath(key string) string { // It sanitizes the key to prevent path traversal and verifies that the
// resulting absolute path stays within the configured base directory as a
// defense-in-depth check on top of filepath.Clean/Join semantics.
func (fs *FilesystemStorage) keyToPath(key string) (string, error) {
// Sanitize key to prevent path traversal // Sanitize key to prevent path traversal
key = filepath.Clean(key) cleaned := filepath.Clean(key)
// Remove any leading slashes or dots // Remove any leading slashes or dots
key = strings.TrimPrefix(key, "/") cleaned = strings.TrimPrefix(cleaned, "/")
// Keep removing ../ until there are no more // Keep removing ../ until there are no more
for strings.HasPrefix(key, "../") || strings.HasPrefix(key, "..\\") { for strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "..\\") {
key = strings.TrimPrefix(key, "../") cleaned = strings.TrimPrefix(cleaned, "../")
key = strings.TrimPrefix(key, "..\\") cleaned = strings.TrimPrefix(cleaned, "..\\")
} }
// Final clean and ensure it's within base path // Final clean and ensure it's within base path
key = filepath.Clean(key) cleaned = filepath.Clean(cleaned)
if key == ".." || strings.HasPrefix(key, "../") || strings.HasPrefix(key, "..\\") { if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "..\\") {
key = "" cleaned = ""
} }
return filepath.Join(fs.basePath, key) target := filepath.Join(fs.basePath, cleaned)
targetAbs, err := filepath.Abs(target)
if err != nil {
return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to resolve path")
}
if err := fs.ensureWithinBase(targetAbs); err != nil {
return "", errors.New(errors.ErrCodeStorageFailure, fmt.Sprintf("path traversal rejected: %s", key))
}
return targetAbs, nil
}
func (fs *FilesystemStorage) ensureWithinBase(path string) error {
rel, err := fs.relativeToBase(path)
if err != nil {
return err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return errors.New(errors.ErrCodeStorageFailure, fmt.Sprintf("path traversal rejected: %s", path))
}
return nil
}
func (fs *FilesystemStorage) relativeToBase(path string) (string, error) {
baseAbs, err := filepath.Abs(fs.basePath)
if err != nil {
return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to resolve base path")
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to resolve path")
}
rel, err := filepath.Rel(baseAbs, pathAbs)
if err != nil {
return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to compare path")
}
return rel, nil
} }
// calculateUsage calculates current storage usage // calculateUsage calculates current storage usage
+15 -14
View File
@@ -531,8 +531,8 @@ func (s *FilesystemStorageTestSuite) TestConcurrentReadsAndWrites() {
key := fmt.Sprintf("shared/file-%d.txt", j%10) key := fmt.Sprintf("shared/file-%d.txt", j%10)
reader, err := s.fs.Get(ctx, key) reader, err := s.fs.Get(ctx, key)
if err == nil { if err == nil {
io.ReadAll(reader) _, _ = io.ReadAll(reader)
reader.Close() // #nosec G104 -- Cleanup, error not critical _ = reader.Close() // #nosec G104 -- Cleanup, error not critical
} }
} }
}(i) }(i)
@@ -546,7 +546,7 @@ func (s *FilesystemStorageTestSuite) TestConcurrentReadsAndWrites() {
for j := 0; j < numOps; j++ { for j := 0; j < numOps; j++ {
key := fmt.Sprintf("shared/writer-%d-%d.txt", id, j) key := fmt.Sprintf("shared/writer-%d-%d.txt", id, j)
data := fmt.Sprintf("writer-%d-%d", id, j) data := fmt.Sprintf("writer-%d-%d", id, j)
s.fs.Put(ctx, key, strings.NewReader(data), nil) _ = s.fs.Put(ctx, key, strings.NewReader(data), nil)
} }
}(i) }(i)
} }
@@ -608,15 +608,15 @@ func (s *FilesystemStorageTestSuite) TestAtomicWrite() {
case <-stopReading: case <-stopReading:
return return
default: default:
reader, err := s.fs.Get(ctx, key) reader, getErr := s.fs.Get(ctx, key)
if err != nil { if getErr != nil {
readErrors <- err readErrors <- getErr
continue continue
} }
data, err := io.ReadAll(reader) data, readErr := io.ReadAll(reader)
reader.Close() // #nosec G104 -- Cleanup, error not critical reader.Close() // #nosec G104 -- Cleanup, error not critical
if err != nil { if readErr != nil {
readErrors <- err readErrors <- readErr
continue continue
} }
// Data should be either "initial" or "updated", never partial // Data should be either "initial" or "updated", never partial
@@ -663,7 +663,8 @@ func (s *FilesystemStorageTestSuite) TestPathSanitization() {
s.NoError(err) // Should succeed but sanitize path s.NoError(err) // Should succeed but sanitize path
// Verify file is inside base directory // Verify file is inside base directory
sanitized := s.fs.keyToPath(path) sanitized, sanitizeErr := s.fs.keyToPath(path)
s.NoError(sanitizeErr)
s.True(strings.HasPrefix(sanitized, s.tempDir), s.True(strings.HasPrefix(sanitized, s.tempDir),
"Sanitized path %s should be inside %s", sanitized, s.tempDir) "Sanitized path %s should be inside %s", sanitized, s.tempDir)
}) })
@@ -728,7 +729,7 @@ func BenchmarkFilesystemPut(b *testing.B) {
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
key := fmt.Sprintf("bench/file-%d.txt", i) key := fmt.Sprintf("bench/file-%d.txt", i)
fs.Put(ctx, key, strings.NewReader(data), nil) _ = fs.Put(ctx, key, strings.NewReader(data), nil)
} }
} }
@@ -744,14 +745,14 @@ func BenchmarkFilesystemGet(b *testing.B) {
data := strings.Repeat("x", 1024) data := strings.Repeat("x", 1024)
// Setup: Create test file // Setup: Create test file
fs.Put(ctx, "bench/test.txt", strings.NewReader(data), nil) _ = fs.Put(ctx, "bench/test.txt", strings.NewReader(data), nil)
b.ResetTimer() b.ResetTimer()
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
reader, _ := fs.Get(ctx, "bench/test.txt") reader, _ := fs.Get(ctx, "bench/test.txt")
if reader != nil { if reader != nil {
io.ReadAll(reader) _, _ = io.ReadAll(reader)
reader.Close() // #nosec G104 -- Cleanup, error not critical _ = reader.Close() // #nosec G104 -- Cleanup, error not critical
} }
} }
} }
+2
View File
@@ -1,3 +1,5 @@
// Package storage defines the pluggable Storage backend interface used to
// persist cached package payloads (filesystem, S3, NFS, etc.).
package storage package storage
import ( import (
+307
View File
@@ -0,0 +1,307 @@
// Package nfs implements an NFS-backed storage backend.
//
// NFS is, from Go's perspective, an ordinary mounted filesystem. The user is
// expected to mount the export at cfg.Path before starting the application;
// this package does NOT perform mount(8) calls. It wraps the filesystem
// backend and adds NFS-specific safety:
//
// - Best-effort mount-type detection (Linux: /proc/mounts). On non-Linux
// platforms detection is skipped silently. A non-NFS mount is logged at
// Warn level but is NOT a fatal error so tests/CI can run on local
// filesystems.
//
// - Optional per-write fsync (SyncWrites, default true) to flush NFS client
// caches and improve durability across NFS-cached metadata. Stale handles
// and "silent" write losses are common NFS pitfalls.
//
// - A richer Health probe that round-trips a marker file (write, fsync,
// read, delete) to surface stale handles or read-after-write
// inconsistencies the bare filesystem health check would miss.
package nfs
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/storage"
"github.com/lukaszraczylo/gohoarder/pkg/storage/filesystem"
"github.com/rs/zerolog"
)
// Config holds NFS storage configuration. The struct is intentionally
// self-contained so callers can map their own config (e.g.
// pkg/config.StorageConfig) without import cycles.
type Config struct {
// Path is the local mount point of the NFS export. Required.
Path string
// MaxSize is the optional quota in bytes (0 = unlimited). Forwarded to
// the underlying filesystem backend.
MaxSize int64
// SyncWrites, when true (default), forces fsync after every successful
// Put so data is flushed through the NFS client cache to the server.
SyncWrites bool
}
// Storage implements storage.StorageBackend on top of an NFS-mounted path.
type Storage struct {
fs *filesystem.FilesystemStorage
logger zerolog.Logger
path string
syncWrites bool
}
// New constructs an NFS storage backend rooted at cfg.Path.
//
// cfg.Path must already exist and be a directory; the caller is responsible
// for the actual NFS mount. Mount-type detection is best-effort.
func New(cfg Config, logger zerolog.Logger) (*Storage, error) {
if cfg.Path == "" {
return nil, errors.New(errors.ErrCodeStorageFailure, "nfs: path is required")
}
info, err := os.Stat(cfg.Path)
if err != nil {
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "nfs: path does not exist or is inaccessible")
}
if !info.IsDir() {
return nil, errors.New(errors.ErrCodeStorageFailure, fmt.Sprintf("nfs: path is not a directory: %s", cfg.Path))
}
// Best-effort mount-type detection. Non-fatal: warn only.
if mountType, ok := detectMountType(cfg.Path); ok {
if !isNFSMountType(mountType) {
logger.Warn().
Str("path", cfg.Path).
Str("mount_type", mountType).
Msg("nfs: configured path is not on an NFS mount; proceeding anyway")
} else {
logger.Info().
Str("path", cfg.Path).
Str("mount_type", mountType).
Msg("nfs: detected NFS mount")
}
} else {
// Detection unavailable (non-Linux or /proc/mounts unreadable).
logger.Debug().
Str("path", cfg.Path).
Str("os", runtime.GOOS).
Msg("nfs: mount-type detection skipped")
}
fs, err := filesystem.New(cfg.Path, cfg.MaxSize)
if err != nil {
return nil, err
}
return &Storage{
fs: fs,
logger: logger,
path: cfg.Path,
syncWrites: cfg.SyncWrites,
}, nil
}
// Get delegates to the underlying filesystem backend.
func (s *Storage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return s.fs.Get(ctx, key)
}
// Put delegates to the filesystem backend and, when SyncWrites is enabled,
// fsyncs the resulting file to flush the NFS client cache.
func (s *Storage) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error {
if err := s.fs.Put(ctx, key, data, opts); err != nil {
return err
}
if !s.syncWrites {
return nil
}
// Resolve the on-disk path via the LocalPathProvider contract the
// filesystem backend implements. Failure to fsync is logged but not
// returned: the write itself succeeded; durability is best-effort.
path, err := s.fs.GetLocalPath(ctx, key)
if err != nil {
s.logger.Warn().Err(err).Str("key", key).Msg("nfs: post-put path lookup failed; skipping fsync")
return nil
}
relPath, err := relativeToBase(s.path, path)
if err != nil {
s.logger.Warn().Err(err).Str("key", key).Msg("nfs: post-put path escaped configured base; skipping fsync")
return nil
}
root, err := os.OpenRoot(s.path)
if err != nil {
s.logger.Warn().Err(err).Str("path", s.path).Msg("nfs: failed to open configured base; skipping fsync")
return nil
}
defer root.Close() // #nosec G307 -- Best-effort cleanup of opened NFS root
f, err := root.OpenFile(relPath, os.O_RDWR, 0)
if err != nil {
s.logger.Warn().Err(err).Str("key", key).Msg("nfs: post-put open failed; skipping fsync")
return nil
}
if syncErr := f.Sync(); syncErr != nil {
s.logger.Warn().Err(syncErr).Str("key", key).Msg("nfs: post-put fsync failed")
}
_ = f.Close() // #nosec G104 -- close after sync, error not actionable
return nil
}
// Delete delegates to the underlying filesystem backend.
func (s *Storage) Delete(ctx context.Context, key string) error {
return s.fs.Delete(ctx, key)
}
// Exists delegates to the underlying filesystem backend.
func (s *Storage) Exists(ctx context.Context, key string) (bool, error) {
return s.fs.Exists(ctx, key)
}
// List delegates to the underlying filesystem backend.
func (s *Storage) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) {
return s.fs.List(ctx, prefix, opts)
}
// Stat delegates to the underlying filesystem backend.
func (s *Storage) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) {
return s.fs.Stat(ctx, key)
}
// GetQuota delegates to the underlying filesystem backend.
func (s *Storage) GetQuota(ctx context.Context) (*storage.QuotaInfo, error) {
return s.fs.GetQuota(ctx)
}
// Health checks both the underlying filesystem and runs an NFS-specific
// round-trip probe (write, fsync, read, delete) to surface stale handles or
// cache-coherency issues that a bare stat would miss.
func (s *Storage) Health(ctx context.Context) error {
if err := s.fs.Health(ctx); err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
probePath := filepath.Join(s.path, ".nfs_health_probe")
payload := []byte("nfs-health-probe")
f, err := os.Create(probePath) // #nosec G304 -- path under configured base, fixed name
if err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "nfs: cannot create health probe file")
}
if _, writeErr := f.Write(payload); writeErr != nil {
_ = f.Close() // #nosec G104 -- cleanup
_ = os.Remove(probePath) // #nosec G104 -- cleanup
return errors.Wrap(writeErr, errors.ErrCodeStorageFailure, "nfs: cannot write health probe")
}
if syncErr := f.Sync(); syncErr != nil {
_ = f.Close() // #nosec G104 -- cleanup
_ = os.Remove(probePath) // #nosec G104 -- cleanup
return errors.Wrap(syncErr, errors.ErrCodeStorageFailure, "nfs: fsync of health probe failed")
}
if closeErr := f.Close(); closeErr != nil {
_ = os.Remove(probePath) // #nosec G104 -- cleanup
return errors.Wrap(closeErr, errors.ErrCodeStorageFailure, "nfs: close of health probe failed")
}
got, err := os.ReadFile(probePath) // #nosec G304 -- fixed probe path
if err != nil {
_ = os.Remove(probePath) // #nosec G104 -- cleanup
return errors.Wrap(err, errors.ErrCodeStorageFailure, "nfs: read-back of health probe failed (possible stale handle)")
}
if string(got) != string(payload) {
_ = os.Remove(probePath) // #nosec G104 -- cleanup
return errors.New(errors.ErrCodeStorageFailure, "nfs: health probe payload mismatch (cache coherency issue?)")
}
if err := os.Remove(probePath); err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "nfs: cannot remove health probe file")
}
return nil
}
// Close delegates to the underlying filesystem backend.
func (s *Storage) Close() error {
return s.fs.Close()
}
// GetLocalPath exposes direct on-disk paths for scanning (NFS exports look
// like local files to callers). Implements storage.LocalPathProvider.
func (s *Storage) GetLocalPath(ctx context.Context, key string) (string, error) {
return s.fs.GetLocalPath(ctx, key)
}
func relativeToBase(basePath, path string) (string, error) {
baseAbs, err := filepath.Abs(basePath)
if err != nil {
return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "nfs: failed to resolve base path")
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "nfs: failed to resolve path")
}
rel, err := filepath.Rel(baseAbs, pathAbs)
if err != nil {
return "", errors.Wrap(err, errors.ErrCodeStorageFailure, "nfs: failed to compare path")
}
return rel, nil
}
// detectMountType returns the filesystem type backing path. Linux-only: on
// other platforms the second return value is false. Implementation walks
// /proc/mounts and selects the longest matching mount point, which is the
// canonical way to find which mount owns a path.
func detectMountType(path string) (string, bool) {
if runtime.GOOS != "linux" {
return "", false
}
abs, err := filepath.Abs(path)
if err != nil {
return "", false
}
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return "", false
}
var (
bestMount string
bestType string
)
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
mountPoint := fields[1]
fsType := fields[2]
if abs == mountPoint || strings.HasPrefix(abs, strings.TrimRight(mountPoint, "/")+"/") {
if len(mountPoint) > len(bestMount) {
bestMount = mountPoint
bestType = fsType
}
}
}
if bestMount == "" {
return "", false
}
return bestType, true
}
// isNFSMountType returns true for NFS family mount types.
func isNFSMountType(t string) bool {
switch t {
case "nfs", "nfs4":
return true
default:
return false
}
}
+239
View File
@@ -0,0 +1,239 @@
package nfs
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/lukaszraczylo/gohoarder/pkg/storage"
"github.com/rs/zerolog"
)
// newTestStorage builds an NFS Storage rooted at t.TempDir(). It also returns
// a buffer capturing the logger output so detection-related tests can assert
// on log lines without requiring a real NFS mount.
func newTestStorage(t *testing.T, syncWrites bool) (*Storage, *bytes.Buffer) {
t.Helper()
dir := t.TempDir()
logBuf := &bytes.Buffer{}
logger := zerolog.New(logBuf)
s, err := New(Config{
Path: dir,
MaxSize: 1 << 20, // 1 MiB
SyncWrites: syncWrites,
}, logger)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s, logBuf
}
func TestNew_RejectsMissingPath(t *testing.T) {
logger := zerolog.New(io.Discard)
if _, err := New(Config{Path: ""}, logger); err == nil {
t.Fatal("expected error for empty path")
}
}
func TestNew_RejectsNonDirectory(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "not-a-dir")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
logger := zerolog.New(io.Discard)
if _, err := New(Config{Path: file}, logger); err == nil {
t.Fatal("expected error when path is a file")
}
}
func TestNew_RejectsNonexistentPath(t *testing.T) {
logger := zerolog.New(io.Discard)
if _, err := New(Config{Path: "/nonexistent/path/does/not/exist"}, logger); err == nil {
t.Fatal("expected error for missing path")
}
}
// TestNew_LogsWarnOnNonNFSMount: on Linux the temp dir lives on a non-NFS fs,
// so detection should fire and log a warn. On other OSes detection is skipped
// and we just assert New succeeds.
func TestNew_LogsWarnOnNonNFSMount(t *testing.T) {
s, logBuf := newTestStorage(t, true)
if s == nil {
t.Fatal("expected storage")
}
if runtime.GOOS != "linux" {
t.Skipf("mount detection only runs on linux; got %s", runtime.GOOS)
}
out := logBuf.String()
// Either a warn ("not on an NFS mount") or, if /proc/mounts is unreadable
// inside the sandbox, a debug "detection skipped". Both are acceptable;
// what we never want is a hard error.
if !strings.Contains(out, "not on an NFS mount") &&
!strings.Contains(out, "detection skipped") &&
!strings.Contains(out, "detected NFS mount") {
t.Fatalf("expected mount-detection log line, got: %q", out)
}
}
func TestRoundTrip_PutGetStatDelete(t *testing.T) {
s, _ := newTestStorage(t, true)
ctx := context.Background()
const key = "pkgs/example/1.0.0/data.bin"
payload := []byte("hello-nfs-roundtrip")
if err := s.Put(ctx, key, bytes.NewReader(payload), nil); err != nil {
t.Fatalf("Put: %v", err)
}
exists, err := s.Exists(ctx, key)
if err != nil || !exists {
t.Fatalf("Exists: got (%v, %v), want (true, nil)", exists, err)
}
rc, err := s.Get(ctx, key)
if err != nil {
t.Fatalf("Get: %v", err)
}
got, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("payload mismatch: got %q want %q", got, payload)
}
info, err := s.Stat(ctx, key)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if info.Size != int64(len(payload)) {
t.Fatalf("Stat size: got %d want %d", info.Size, len(payload))
}
if delErr := s.Delete(ctx, key); delErr != nil {
t.Fatalf("Delete: %v", delErr)
}
exists, err = s.Exists(ctx, key)
if err != nil {
t.Fatalf("Exists after delete: %v", err)
}
if exists {
t.Fatal("expected key to be gone after delete")
}
}
func TestPut_NoSyncPath(t *testing.T) {
// Same flow as round-trip, but with SyncWrites=false to exercise the
// non-fsync branch.
s, _ := newTestStorage(t, false)
ctx := context.Background()
if err := s.Put(ctx, "no-sync.txt", strings.NewReader("data"), nil); err != nil {
t.Fatalf("Put: %v", err)
}
}
func TestList(t *testing.T) {
s, _ := newTestStorage(t, true)
ctx := context.Background()
keys := []string{"a/one.txt", "a/two.txt", "b/three.txt"}
for _, k := range keys {
if err := s.Put(ctx, k, strings.NewReader(k), nil); err != nil {
t.Fatalf("Put %s: %v", k, err)
}
}
objs, err := s.List(ctx, "a", &storage.ListOptions{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(objs) != 2 {
t.Fatalf("List(a): got %d objs want 2 (%v)", len(objs), objsKeys(objs))
}
}
func TestGetQuota(t *testing.T) {
s, _ := newTestStorage(t, true)
ctx := context.Background()
q, err := s.GetQuota(ctx)
if err != nil {
t.Fatalf("GetQuota: %v", err)
}
if q.Limit != 1<<20 {
t.Fatalf("Limit: got %d want %d", q.Limit, 1<<20)
}
}
func TestHealth_OK(t *testing.T) {
s, _ := newTestStorage(t, true)
if err := s.Health(context.Background()); err != nil {
t.Fatalf("Health: %v", err)
}
}
func TestHealth_LeavesNoProbeFile(t *testing.T) {
s, _ := newTestStorage(t, true)
if err := s.Health(context.Background()); err != nil {
t.Fatalf("Health: %v", err)
}
probe := filepath.Join(s.path, ".nfs_health_probe")
if _, err := os.Stat(probe); !os.IsNotExist(err) {
t.Fatalf("expected probe file removed; stat err=%v", err)
}
}
func TestHealth_FailsWhenPathRemoved(t *testing.T) {
s, _ := newTestStorage(t, true)
// Remove the entire base dir under the backend's feet to simulate a
// missing/stale mount. Health must surface that as an error.
if err := os.RemoveAll(s.path); err != nil {
t.Fatalf("setup: %v", err)
}
if err := s.Health(context.Background()); err == nil {
t.Fatal("expected Health to fail after path removed")
}
}
func TestGetLocalPath(t *testing.T) {
s, _ := newTestStorage(t, true)
ctx := context.Background()
const key = "local/path/test.txt"
if err := s.Put(ctx, key, strings.NewReader("data"), nil); err != nil {
t.Fatalf("Put: %v", err)
}
p, err := s.GetLocalPath(ctx, key)
if err != nil {
t.Fatalf("GetLocalPath: %v", err)
}
if !strings.HasPrefix(p, s.path) {
t.Fatalf("expected path under base; got %s (base %s)", p, s.path)
}
}
func TestStorageBackend_InterfaceConformance(t *testing.T) {
// Compile-time check the wrapper satisfies the public interface.
var _ storage.StorageBackend = (*Storage)(nil)
var _ storage.LocalPathProvider = (*Storage)(nil)
}
// objsKeys is a small helper used in failure messages.
func objsKeys(objs []storage.StorageObject) string {
keys := make([]string, 0, len(objs))
for _, o := range objs {
keys = append(keys, o.Key)
}
b, _ := json.Marshal(keys)
return string(b)
}
+5 -3
View File
@@ -1,3 +1,5 @@
// Package s3 implements the S3-compatible Storage backend (AWS S3,
// MinIO, etc.).
package s3 package s3
import ( import (
@@ -139,9 +141,9 @@ func (s *S3Storage) Put(ctx context.Context, key string, data io.Reader, opts *s
// Check quota if set // Check quota if set
if s.maxSizeBytes > 0 { if s.maxSizeBytes > 0 {
currentUsage, err := s.calculateUsage(ctx) currentUsage, usageErr := s.calculateUsage(ctx)
if err != nil { if usageErr != nil {
log.Warn().Err(err).Msg("Failed to calculate current usage, skipping quota check") log.Warn().Err(usageErr).Msg("Failed to calculate current usage, skipping quota check")
} else if currentUsage+size > s.maxSizeBytes { } else if currentUsage+size > s.maxSizeBytes {
return errors.QuotaExceeded(s.maxSizeBytes) return errors.QuotaExceeded(s.maxSizeBytes)
} }
+2 -2
View File
@@ -17,9 +17,9 @@ func TestS3StorageTestSuite(t *testing.T) {
func (s *S3StorageTestSuite) TestNewS3Storage() { func (s *S3StorageTestSuite) TestNewS3Storage() {
tests := []struct { tests := []struct {
name string name string
errorMsg string
config Config config Config
expectError bool expectError bool
errorMsg string
}{ }{
{ {
name: "valid config with credentials", name: "valid config with credentials",
@@ -175,8 +175,8 @@ func (s *S3StorageTestSuite) TestStripPrefix() {
func (s *S3StorageTestSuite) TestIsNotFoundError() { func (s *S3StorageTestSuite) TestIsNotFoundError() {
tests := []struct { tests := []struct {
name string
err error err error
name string
expected bool expected bool
}{ }{
{ {
+61 -27
View File
@@ -1,3 +1,4 @@
// Package smb implements the SMB/CIFS-backed Storage backend.
package smb package smb
import ( import (
@@ -10,7 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/hirochachacha/go-smb2" "github.com/cloudsoda/go-smb2"
"github.com/lukaszraczylo/gohoarder/pkg/errors" "github.com/lukaszraczylo/gohoarder/pkg/errors"
"github.com/lukaszraczylo/gohoarder/pkg/storage" "github.com/lukaszraczylo/gohoarder/pkg/storage"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -113,7 +114,7 @@ func (s *SMBStorage) createConnection() (*smbConnection, error) {
} }
// Establish SMB session // Establish SMB session
session, err := d.Dial(conn) session, err := d.DialConn(context.Background(), conn, addr)
if err != nil { if err != nil {
_ = conn.Close() _ = conn.Close()
return nil, fmt.Errorf("failed to establish SMB session: %w", err) return nil, fmt.Errorf("failed to establish SMB session: %w", err)
@@ -188,14 +189,17 @@ func (c *smbConnection) close() {
// Get retrieves data from SMB share // Get retrieves data from SMB share
func (s *SMBStorage) Get(ctx context.Context, key string) (io.ReadCloser, error) { func (s *SMBStorage) Get(ctx context.Context, key string) (io.ReadCloser, error) {
path, err := s.keyToPath(key)
if err != nil {
return nil, err
}
conn, err := s.getConnection() conn, err := s.getConnection()
if err != nil { if err != nil {
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection") return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection")
} }
defer s.returnConnection(conn) defer s.returnConnection(conn)
path := s.keyToPath(key)
log.Debug().Str("key", path).Msg("Getting file from SMB") log.Debug().Str("key", path).Msg("Getting file from SMB")
// Open file // Open file
@@ -222,20 +226,23 @@ func (s *SMBStorage) Get(ctx context.Context, key string) (io.ReadCloser, error)
// Put stores data on SMB share // Put stores data on SMB share
func (s *SMBStorage) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error { func (s *SMBStorage) Put(ctx context.Context, key string, data io.Reader, opts *storage.PutOptions) error {
path, err := s.keyToPath(key)
if err != nil {
return err
}
conn, err := s.getConnection() conn, err := s.getConnection()
if err != nil { if err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection")
} }
defer s.returnConnection(conn) defer s.returnConnection(conn)
path := s.keyToPath(key)
log.Debug().Str("key", path).Msg("Putting file to SMB") log.Debug().Str("key", path).Msg("Putting file to SMB")
// Ensure directory exists // Ensure directory exists
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := s.ensureDir(conn, dir); err != nil { if dirErr := s.ensureDir(conn, dir); dirErr != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create SMB directory") return errors.Wrap(dirErr, errors.ErrCodeStorageFailure, "failed to create SMB directory")
} }
// Read data into buffer to check quota // Read data into buffer to check quota
@@ -247,9 +254,9 @@ func (s *SMBStorage) Put(ctx context.Context, key string, data io.Reader, opts *
// Check quota if set // Check quota if set
if s.maxSizeBytes > 0 { if s.maxSizeBytes > 0 {
currentUsage, err := s.calculateUsage(conn) currentUsage, usageErr := s.calculateUsage(conn)
if err != nil { if usageErr != nil {
log.Warn().Err(err).Msg("Failed to calculate current usage, skipping quota check") log.Warn().Err(usageErr).Msg("Failed to calculate current usage, skipping quota check")
} else if currentUsage+size > s.maxSizeBytes { } else if currentUsage+size > s.maxSizeBytes {
return errors.QuotaExceeded(s.maxSizeBytes) return errors.QuotaExceeded(s.maxSizeBytes)
} }
@@ -260,7 +267,11 @@ func (s *SMBStorage) Put(ctx context.Context, key string, data io.Reader, opts *
if err != nil { if err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create SMB file") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to create SMB file")
} }
defer file.Close() defer func() {
if closeErr := file.Close(); closeErr != nil {
log.Warn().Err(closeErr).Str("path", path).Msg("Failed to close SMB file after writing")
}
}()
// Write data // Write data
_, err = file.Write([]byte(buf.String())) _, err = file.Write([]byte(buf.String()))
@@ -286,8 +297,8 @@ func (s *SMBStorage) ensureDir(conn *smbConnection, path string) error {
// Create parent directory first // Create parent directory first
parent := filepath.Dir(path) parent := filepath.Dir(path)
if parent != path && parent != "." && parent != "/" { if parent != path && parent != "." && parent != "/" {
if err := s.ensureDir(conn, parent); err != nil { if parentErr := s.ensureDir(conn, parent); parentErr != nil {
return err return parentErr
} }
} }
@@ -302,14 +313,17 @@ func (s *SMBStorage) ensureDir(conn *smbConnection, path string) error {
// Delete removes data from SMB share // Delete removes data from SMB share
func (s *SMBStorage) Delete(ctx context.Context, key string) error { func (s *SMBStorage) Delete(ctx context.Context, key string) error {
path, err := s.keyToPath(key)
if err != nil {
return err
}
conn, err := s.getConnection() conn, err := s.getConnection()
if err != nil { if err != nil {
return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection") return errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection")
} }
defer s.returnConnection(conn) defer s.returnConnection(conn)
path := s.keyToPath(key)
log.Debug().Str("key", path).Msg("Deleting file from SMB") log.Debug().Str("key", path).Msg("Deleting file from SMB")
err = conn.share.Remove(path) err = conn.share.Remove(path)
@@ -322,14 +336,17 @@ func (s *SMBStorage) Delete(ctx context.Context, key string) error {
// Exists checks if data exists on SMB share // Exists checks if data exists on SMB share
func (s *SMBStorage) Exists(ctx context.Context, key string) (bool, error) { func (s *SMBStorage) Exists(ctx context.Context, key string) (bool, error) {
path, err := s.keyToPath(key)
if err != nil {
return false, err
}
conn, err := s.getConnection() conn, err := s.getConnection()
if err != nil { if err != nil {
return false, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection") return false, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection")
} }
defer s.returnConnection(conn) defer s.returnConnection(conn)
path := s.keyToPath(key)
_, err = conn.share.Stat(path) _, err = conn.share.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -343,14 +360,17 @@ func (s *SMBStorage) Exists(ctx context.Context, key string) (bool, error) {
// List returns a list of objects with the given prefix // List returns a list of objects with the given prefix
func (s *SMBStorage) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) { func (s *SMBStorage) List(ctx context.Context, prefix string, opts *storage.ListOptions) ([]storage.StorageObject, error) {
basePath, err := s.keyToPath(prefix)
if err != nil {
return nil, err
}
conn, err := s.getConnection() conn, err := s.getConnection()
if err != nil { if err != nil {
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection") return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection")
} }
defer s.returnConnection(conn) defer s.returnConnection(conn)
basePath := s.keyToPath(prefix)
log.Debug().Str("prefix", basePath).Msg("Listing files in SMB") log.Debug().Str("prefix", basePath).Msg("Listing files in SMB")
var objects []storage.StorageObject var objects []storage.StorageObject
@@ -422,14 +442,17 @@ func (s *SMBStorage) walkPath(conn *smbConnection, root string, fn func(string,
// Stat returns metadata about stored data // Stat returns metadata about stored data
func (s *SMBStorage) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) { func (s *SMBStorage) Stat(ctx context.Context, key string) (*storage.StorageInfo, error) {
path, err := s.keyToPath(key)
if err != nil {
return nil, err
}
conn, err := s.getConnection() conn, err := s.getConnection()
if err != nil { if err != nil {
return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection") return nil, errors.Wrap(err, errors.ErrCodeStorageFailure, "failed to get SMB connection")
} }
defer s.returnConnection(conn) defer s.returnConnection(conn)
path := s.keyToPath(key)
info, err := conn.share.Stat(path) info, err := conn.share.Stat(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -494,17 +517,28 @@ func (s *SMBStorage) Close() error {
return nil return nil
} }
// keyToPath converts a storage key to SMB path // keyToPath converts a storage key to SMB path. It rejects keys that
func (s *SMBStorage) keyToPath(key string) string { // contain traversal segments ("..") or empty segments to prevent escaping
// the configured base path.
func (s *SMBStorage) keyToPath(key string) (string, error) {
// Reject traversal attempts. Split by both forward and backslash so
// callers can use either separator on input.
normalized := strings.ReplaceAll(key, "\\", "/")
for _, seg := range strings.Split(normalized, "/") {
if seg == ".." || seg == "." {
return "", errors.New(errors.ErrCodeStorageFailure, fmt.Sprintf("invalid key segment %q in %q", seg, key))
}
}
// Normalize separators to backslash for SMB // Normalize separators to backslash for SMB
key = strings.ReplaceAll(key, "/", "\\") winKey := strings.ReplaceAll(key, "/", "\\")
if s.config.Path == "" { if s.config.Path == "" {
return key return winKey, nil
} }
// Use backslash for SMB paths // Use backslash for SMB paths
return s.config.Path + "\\" + key return s.config.Path + "\\" + winKey, nil
} }
// pathToKey converts an SMB path to storage key // pathToKey converts an SMB path to storage key
+2 -1
View File
@@ -141,7 +141,8 @@ func (s *SMBStorageTestSuite) TestKeyToPath() {
}, },
} }
result := storage.keyToPath(tt.key) result, err := storage.keyToPath(tt.key)
s.NoError(err)
s.Equal(tt.expectedWin, result) s.Equal(tt.expectedWin, result)
}) })
} }
+2
View File
@@ -1,3 +1,5 @@
// Package uuid generates RFC 4122 v4 UUIDs used as identifiers across
// the GoHoarder service.
package uuid package uuid
import ( import (

Some files were not shown because too many files have changed in this diff Show More