125 Commits

Author SHA1 Message Date
bbff337b4c fix: clean up stale smoke test container before run 2026-03-15 22:25:37 +00:00
a73335c549 docs: trim README, link to docs site, fix website URL 2026-03-15 22:21:08 +00:00
ad6aba46b2 chore: remove internal release runbook from public repo 2026-03-15 21:57:05 +00:00
095270d113 fix: smoke test port mapping (4000, not 5000) 2026-03-15 21:54:13 +00:00
769f5fb01d docs: update CHANGELOG and README for v0.2.29 upstream auth 2026-03-15 21:50:14 +00:00
53884e143b v0.2.29: upstream auth, remove dead code, version bump
- Remove unused DockerAuth::fetch_with_auth() method
- Fix basic_auth_header docstring
- Bump to v0.2.29
2026-03-15 21:42:49 +00:00
0eb26f24f7 refactor: extract basic_auth_header helper, add plaintext credential warnings
- basic_auth_header() in config.rs replaces 6 inline STANDARD.encode calls
- warn_plaintext_credentials() logs warning at startup if auth is in config.toml
- All protocol handlers use shared helper instead of duplicating base64 logic
2026-03-15 21:37:51 +00:00
fa962b2d6e feat: upstream auth for all protocols (Docker, Maven, npm, PyPI)
Wire up basic auth credentials for upstream registry proxying:
- Docker: pass configured auth to Bearer token requests
- Maven: support url|auth format in NORA_MAVEN_PROXIES env var
- npm: add NORA_NPM_PROXY_AUTH env var
- PyPI: add NORA_PYPI_PROXY_AUTH env var
- Mask credentials in logs (never log plaintext passwords)

Config examples:
  NORA_DOCKER_UPSTREAMS="https://registry.corp.com|user:pass"
  NORA_MAVEN_PROXIES="https://nexus.corp.com/maven2|user:pass"
  NORA_NPM_PROXY_AUTH="user:pass"
  NORA_PYPI_PROXY_AUTH="user:pass"
2026-03-15 21:29:20 +00:00
a1da4fff1e fix: integration tests match actual protocol support
- Docker, Maven: full push/pull (write support exists)
- npm, PyPI, Cargo: endpoint checks only (read-only proxy, no publish yet)
2026-03-15 19:58:36 +00:00
868c4feca7 feat: add Maven, PyPI, Cargo integration tests
- Maven: PUT artifact, GET and verify checksum
- PyPI: twine upload + pip install
- Cargo: API endpoint check
- Now testing all 5 protocols: Docker, npm, Maven, PyPI, Cargo
2026-03-15 19:53:27 +00:00
5b4cba1392 fix: add npm auth token for integration test publish 2026-03-15 19:49:54 +00:00
ad890be56a feat: add integration tests, release runbook, cache fallback
- CI: integration job — build NORA, docker push/pull, npm publish/install, API checks
- release: cache-from with ignore-error=true (no dependency on localhost:5000)
- RELEASE_RUNBOOK.md: rollback procedure, deploy order, verification steps
2026-03-15 19:36:38 +00:00
3b9ea37b0e fix: cargo fmt, add .gitleaks.toml allowlist for doc examples
- remove extra blank lines in openapi.rs and secrets/mod.rs
- allowlist commit 92155cf (curl -u admin:yourpassword in README)
2026-03-15 19:27:36 +00:00
233b83f902 security: make CI gates blocking, add smoke test, clean up dead code
- gitleaks, cargo audit, trivy fs now block pipeline on findings
- add smoke test (docker run + curl /health) in release workflow
- deny.toml: add review date to RUSTSEC-2025-0119 ignore
- remove unused validation functions (maven, npm, crate)
- replace blanket #![allow(dead_code)] with targeted allows
2026-03-15 19:25:00 +00:00
d886426957 docs: fix docker badge to GHCR 2026-03-13 17:12:02 +00:00
52c2443543 docs: remove flaky logo, add docs/stars/docker-size badges 2026-03-13 17:09:13 +00:00
26d30b622d style: cargo fmt 2026-03-13 16:59:54 +00:00
272898f43c fix: quinn-proto CVE, add Telegram @getnora, fix website URL 2026-03-13 16:44:20 +00:00
61de6c6ddd fix: persist dashboard metrics and count versions instead of repos
Metrics (downloads, uploads, cache hits) were stored in-memory only
and reset to zero on every restart. Now they persist to metrics.json
in the storage directory with:
- Load on startup from {storage_path}/metrics.json
- Background save every 30 seconds
- Final save on graceful shutdown
- Atomic writes (tmp + rename) to prevent corruption

Artifact count on dashboard now shows total tags/versions across
all registries instead of just counting unique repository names.
This matches user expectations when pushing multiple tags to the
same image (e.g. myapp:v1, myapp:v2 now shows 2, not 1).
2026-03-13 15:43:03 +00:00
b80c7c5160 docs: add authentication guide, TLS setup, FSTEC builds docs
- Fix docker-compose.yml: use ghcr.io/getnora-io/nora:latest
- Remove stale CHANGELOG.md.bak, add *.bak to .gitignore
- README: authentication guide (htpasswd, API tokens, RBAC)
- README: TLS/HTTPS section (reverse proxy, insecure-registries)
- README: document Dockerfile.astra and Dockerfile.redos (FSTEC)
- CHANGELOG: add 0.2.28 release notes
2026-03-13 15:08:04 +00:00
68089b2bbf chore: bump version to 0.2.28 2026-03-12 19:23:32 +00:00
af411a2bf4 Merge pull request #28 from getnora-io/dependabot/cargo/toml-1.0.6spec-1.1.0
chore(deps): bump toml from 1.0.3+spec-1.1.0 to 1.0.6+spec-1.1.0
2026-03-12 22:14:13 +03:00
96ccd16879 Merge pull request #27 from getnora-io/dependabot/cargo/uuid-1.22.0
chore(deps): bump uuid from 1.21.0 to 1.22.0
2026-03-12 22:14:09 +03:00
6582000789 Merge pull request #26 from getnora-io/dependabot/cargo/tokio-1.50.0
chore(deps): bump tokio from 1.49.0 to 1.50.0
2026-03-12 22:14:06 +03:00
070774ac94 Merge pull request #25 from getnora-io/dependabot/cargo/bcrypt-0.19.0
chore(deps): bump bcrypt from 0.18.0 to 0.19.0
2026-03-12 22:14:01 +03:00
058fc41f1c Merge pull request #24 from getnora-io/dependabot/github_actions/docker/metadata-action-6
chore(deps): bump docker/metadata-action from 5 to 6
2026-03-12 22:13:55 +03:00
7f5a3c7c8a Merge pull request #23 from getnora-io/dependabot/github_actions/aquasecurity/trivy-action-0.35.0
chore(deps): bump aquasecurity/trivy-action from 0.34.2 to 0.35.0
2026-03-12 22:13:49 +03:00
5b57cc5913 Merge pull request #22 from getnora-io/dependabot/github_actions/docker/login-action-4
chore(deps): bump docker/login-action from 3 to 4
2026-03-12 22:13:45 +03:00
aa844d851d Merge pull request #21 from getnora-io/dependabot/github_actions/docker/build-push-action-7
chore(deps): bump docker/build-push-action from 6 to 7
2026-03-12 22:13:41 +03:00
8569de23d5 Merge pull request #20 from getnora-io/dependabot/github_actions/docker/setup-buildx-action-4
chore(deps): bump docker/setup-buildx-action from 3 to 4
2026-03-12 22:13:38 +03:00
dependabot[bot]
9349b93757 chore(deps): bump toml from 1.0.3+spec-1.1.0 to 1.0.6+spec-1.1.0
Bumps [toml](https://github.com/toml-rs/toml) from 1.0.3+spec-1.1.0 to 1.0.6+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v1.0.3...toml-v1.0.6)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.0.6+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:26:09 +00:00
dependabot[bot]
69080dfd90 chore(deps): bump uuid from 1.21.0 to 1.22.0
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.21.0 to 1.22.0.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.21.0...v1.22.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:59 +00:00
dependabot[bot]
ae799aed94 chore(deps): bump tokio from 1.49.0 to 1.50.0
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.49.0 to 1.50.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.49.0...tokio-1.50.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:50 +00:00
dependabot[bot]
95c6e403a8 chore(deps): bump bcrypt from 0.18.0 to 0.19.0
Bumps [bcrypt](https://github.com/Keats/rust-bcrypt) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/Keats/rust-bcrypt/compare/v0.18.0...v0.19.0)

---
updated-dependencies:
- dependency-name: bcrypt
  dependency-version: 0.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:38 +00:00
dependabot[bot]
2c886040d7 chore(deps): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:36 +00:00
dependabot[bot]
9ab6ccc594 chore(deps): bump aquasecurity/trivy-action from 0.34.2 to 0.35.0
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.34.2 to 0.35.0.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.34.2...0.35.0)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:33 +00:00
dependabot[bot]
679b36b986 chore(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:27 +00:00
dependabot[bot]
da8c473e02 chore(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:23 +00:00
dependabot[bot]
3dc8b81261 chore(deps): bump docker/setup-buildx-action from 3 to 4
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 04:25:20 +00:00
7502c583d0 docs: add changelog for v0.2.27 2026-03-03 23:17:25 +00:00
a9455c35b9 chore: bump version to 0.2.27 2026-03-03 22:30:24 +00:00
8278297b4a feat: configurable body limit + Docker delete API
- Add body_limit_mb to ServerConfig (default 2048MB, env NORA_BODY_LIMIT_MB)
- Replace hardcoded 100MB DefaultBodyLimit with config value
- Add DELETE /v2/{name}/manifests/{reference} endpoint (Docker Registry V2 spec)
- Add DELETE /v2/{name}/blobs/{digest} endpoint
- Add namespace-qualified variants for both DELETE endpoints
- Return 202 Accepted on success, 404 with MANIFEST_UNKNOWN/BLOB_UNKNOWN errors
- Audit log integration for delete operations

Fixes: 413 Payload Too Large on Docker push >100MB
2026-03-03 22:25:41 +00:00
8da4c4278a style: cargo fmt
DevITWay
2026-03-03 11:03:40 +00:00
99c1f9b5ec docs: add changelog for v0.2.25 and v0.2.26
DevITWay
2026-03-03 11:01:12 +00:00
07de85d4f8 fix: detect OCI manifest media type for Helm chart support
Distinguish OCI vs Docker manifests by checking config.mediaType
instead of assuming all schemaVersion 2 manifests are Docker.
Enables helm push/pull via OCI protocol.

DevITWay
2026-03-03 10:56:52 +00:00
4c3a9f6bd5 chore: bump version to 0.2.26
DevITWay
2026-03-03 10:41:38 +00:00
402d2321ef feat: add RBAC (read/write/admin) and persistent audit log
- Add Role enum to tokens: Read, Write, Admin (default: Read)
- Enforce role-based access in auth middleware (read-only tokens blocked from PUT/POST/DELETE)
- Add role field to token create/list/verify API
- Add persistent audit log (append-only JSONL) for all registry operations
- Audit logging across all registries: docker, npm, maven, pypi, cargo, raw

DevITWay
2026-03-03 10:40:59 +00:00
f560e5f76b feat: add gc command and fix Docker-Content-Digest for Helm OCI
- Add nora gc --dry-run command for orphaned blob cleanup
- Fix Docker-Content-Digest header in blob upload response (enables Helm OCI push)
- Mark-and-sweep GC: list blobs, parse manifests, find/delete orphans

DevITWay
2026-03-03 10:28:39 +00:00
e34032d08f chore: bump version to 0.2.25
Changes:
- fix(rate-limit): NORA_RATE_LIMIT_ENABLED flag + SmartIpKeyExtractor
- deps: clap 4.5.60, uuid 1.21.0, tempfile 3.26.0, bcrypt 0.18.0, indicatif 0.18.4
- ci: checkout v6, upload-artifact v7, gh-release v2, trivy v0.34.2, build-push v6
2026-03-03 09:16:20 +00:00
03a3bf9197 Merge pull request #15 from getnora-io/dependabot/github_actions/docker/build-push-action-6
chore(deps): bump docker/build-push-action from 5 to 6
2026-03-03 12:14:56 +03:00
6c5f0dda30 Merge pull request #14 from getnora-io/dependabot/github_actions/aquasecurity/trivy-action-0.34.2
chore(deps): bump aquasecurity/trivy-action from 0.30.0 to 0.34.2
2026-03-03 12:14:42 +03:00
fb058302c8 Merge pull request #13 from getnora-io/dependabot/github_actions/softprops/action-gh-release-2
chore(deps): bump softprops/action-gh-release from 1 to 2
2026-03-03 12:14:29 +03:00
79565aec47 Merge pull request #12 from getnora-io/dependabot/github_actions/actions/upload-artifact-7
chore(deps): bump actions/upload-artifact from 4 to 7
2026-03-03 12:14:16 +03:00
58a484d805 Merge pull request #11 from getnora-io/dependabot/github_actions/actions/checkout-6
chore(deps): bump actions/checkout from 4 to 6
2026-03-03 12:14:04 +03:00
45c3e276dc Merge pull request #8 from getnora-io/dependabot/cargo/indicatif-0.18.4
chore(deps): bump indicatif from 0.17.11 to 0.18.4
2026-03-03 12:13:33 +03:00
dependabot[bot]
f4e53b85dd chore(deps): bump indicatif from 0.17.11 to 0.18.4
Bumps [indicatif](https://github.com/console-rs/indicatif) from 0.17.11 to 0.18.4.
- [Release notes](https://github.com/console-rs/indicatif/releases)
- [Commits](https://github.com/console-rs/indicatif/compare/0.17.11...0.18.4)

---
updated-dependencies:
- dependency-name: indicatif
  dependency-version: 0.18.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 09:13:21 +00:00
05d89d5153 Merge pull request #18 from getnora-io/dependabot/cargo/bcrypt-0.18.0
chore(deps): bump bcrypt from 0.17.1 to 0.18.0
2026-03-03 12:13:20 +03:00
b149f7ebd4 Merge pull request #19 from getnora-io/dependabot/cargo/tempfile-3.26.0
chore(deps): bump tempfile from 3.24.0 to 3.26.0
2026-03-03 12:12:32 +03:00
5254e2a54a Merge pull request #17 from getnora-io/dependabot/cargo/uuid-1.21.0
chore(deps): bump uuid from 1.20.0 to 1.21.0
2026-03-03 12:12:19 +03:00
8783d1dc4b Merge pull request #16 from getnora-io/dependabot/cargo/clap-4.5.60
chore(deps): bump clap from 4.5.56 to 4.5.60
2026-03-03 12:12:04 +03:00
dependabot[bot]
4c05df2359 chore(deps): bump clap from 4.5.56 to 4.5.60
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.56 to 4.5.60.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.56...clap_complete-v4.5.60)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.60
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 08:53:05 +00:00
7f8e3cfe68 fix(rate-limit): add NORA_RATE_LIMIT_ENABLED flag and SmartIpKeyExtractor
- Add enabled field to RateLimitConfig (default: true, env: NORA_RATE_LIMIT_ENABLED)
- Skip rate limiter layers entirely when disabled
- Replace PeerIpKeyExtractor with SmartIpKeyExtractor for upload/general routes
  to correctly identify clients behind reverse proxies and Docker bridge networks
- Keep PeerIpKeyExtractor for auth routes (stricter brute-force protection)

Root cause: PeerIpKeyExtractor saw all Docker bridge traffic as single IP (172.17.0.1),
exhausting GCRA bucket for all clients simultaneously. With burst=1M, recovery time
reached 84000+ seconds.
2026-03-03 08:51:33 +00:00
dependabot[bot]
13f33e8919 chore(deps): bump tempfile from 3.24.0 to 3.26.0
Bumps [tempfile](https://github.com/Stebalien/tempfile) from 3.24.0 to 3.26.0.
- [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stebalien/tempfile/compare/v3.24.0...v3.26.0)

---
updated-dependencies:
- dependency-name: tempfile
  dependency-version: 3.26.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:26:40 +00:00
dependabot[bot]
7454ff2e03 chore(deps): bump bcrypt from 0.17.1 to 0.18.0
Bumps [bcrypt](https://github.com/Keats/rust-bcrypt) from 0.17.1 to 0.18.0.
- [Commits](https://github.com/Keats/rust-bcrypt/compare/v0.17.1...v0.18.0)

---
updated-dependencies:
- dependency-name: bcrypt
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:26:29 +00:00
dependabot[bot]
5ffb5a9be3 chore(deps): bump uuid from 1.20.0 to 1.21.0
Bumps [uuid](https://github.com/uuid-rs/uuid) from 1.20.0 to 1.21.0.
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.20.0...v1.21.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:26:15 +00:00
dependabot[bot]
c8793a4b60 chore(deps): bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:25:58 +00:00
dependabot[bot]
fd4a7b0b0f chore(deps): bump aquasecurity/trivy-action from 0.30.0 to 0.34.2
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.30.0 to 0.34.2.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.30.0...0.34.2)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 0.34.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:25:55 +00:00
dependabot[bot]
7af1e7462c chore(deps): bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:25:51 +00:00
dependabot[bot]
de1a188fa7 chore(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:25:48 +00:00
dependabot[bot]
36d0749bb3 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-03 04:25:43 +00:00
fb0f80ac5a ci: move scan/release to self-hosted, use NORA for cache and images
- Add NORA (localhost:5000) as internal registry for image push and cache
- Replace type=gha cache with type=registry pointing to NORA
- Move scan and release jobs from ubuntu-latest to self-hosted runner
- Upload binary as artifact in build, download in release (no docker pull)
- Generate SBOM from NORA image instead of ghcr.io
- Add driver-opts: network=host to buildx for localhost registry access
2026-02-25 00:19:37 +00:00
161d7f706a chore: bump version to 0.2.24 2026-02-24 17:09:55 +00:00
e4e38e3aab docs: add Astra Linux SE restore to CHANGELOG [Unreleased] 2026-02-24 17:02:14 +00:00
b153bc0c5b ci: restore Astra Linux SE build, scan, and release image 2026-02-24 17:01:14 +00:00
d76383c701 docs: update CHANGELOG for v0.2.19–v0.2.23 and Unreleased (EN/RU) 2026-02-24 16:44:49 +00:00
d161c2f645 feat: add install.sh script 2026-02-24 15:00:19 +00:00
c7f9d5c036 ci: fix binary path in image (/usr/local/bin/nora) 2026-02-24 14:03:16 +00:00
b41bfd9a88 ci: pin build job to nora runner label to avoid wrong runner 2026-02-24 13:18:11 +00:00
3e3070a401 docs: use logo.jpg in README 2026-02-24 12:47:07 +00:00
3868b16ea4 docs: replace text title with SVG logo, O styled in blue-600 2026-02-24 12:29:07 +00:00
3a6d3eeb9a feat: add binary + sha256 to GitHub Release artifacts 2026-02-24 12:14:29 +00:00
dd29707395 ci: ignore RUSTSEC-2025-0119 (number_prefix unmaintained, transitive via indicatif) 2026-02-24 12:06:34 +00:00
e7a6a652af ci: allow CDLA-Permissive-2.0 license (webpki-roots) 2026-02-24 11:54:19 +00:00
4ad802ce2f fix: bump prometheus 0.13->0.14 and bytes 1.11.0->1.11.1 (CVE-2025-53605, CVE-2026-25541) 2026-02-24 11:36:07 +00:00
dependabot[bot]
04c806b659 chore(deps): bump chrono from 0.4.43 to 0.4.44 (#10)
Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.43 to 0.4.44.
- [Release notes](https://github.com/chronotope/chrono/releases)
- [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md)
- [Commits](https://github.com/chronotope/chrono/compare/v0.4.43...v0.4.44)

---
updated-dependencies:
- dependency-name: chrono
  dependency-version: 0.4.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:23:06 +01:00
dependabot[bot]
50a5395a87 chore(deps): bump quick-xml from 0.31.0 to 0.39.2 (#9)
Bumps [quick-xml](https://github.com/tafia/quick-xml) from 0.31.0 to 0.39.2.
- [Release notes](https://github.com/tafia/quick-xml/releases)
- [Changelog](https://github.com/tafia/quick-xml/blob/master/Changelog.md)
- [Commits](https://github.com/tafia/quick-xml/compare/v0.31.0...v0.39.2)

---
updated-dependencies:
- dependency-name: quick-xml
  dependency-version: 0.39.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:22:58 +01:00
dependabot[bot]
bcd172f23f chore(deps): bump toml from 0.8.23 to 1.0.3+spec-1.1.0 (#7)
Bumps [toml](https://github.com/toml-rs/toml) from 0.8.23 to 1.0.3+spec-1.1.0.
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v1.0.3)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.0.3+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:22:52 +01:00
dependabot[bot]
a5a7c4f8be chore(deps): bump flate2 from 1.1.8 to 1.1.9 (#6)
Bumps [flate2](https://github.com/rust-lang/flate2-rs) from 1.1.8 to 1.1.9.
- [Release notes](https://github.com/rust-lang/flate2-rs/releases)
- [Commits](https://github.com/rust-lang/flate2-rs/compare/1.1.8...1.1.9)

---
updated-dependencies:
- dependency-name: flate2
  dependency-version: 1.1.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:22:46 +01:00
dependabot[bot]
2c7c497c30 chore(deps): bump softprops/action-gh-release from 1 to 2 (#5)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '2'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:20:23 +01:00
dependabot[bot]
6b6f88ab9c chore(deps): bump actions/checkout from 4 to 6 (#4)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:20:19 +01:00
dependabot[bot]
1255e3227b chore(deps): bump docker/build-push-action from 5 to 6 (#3)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:20:16 +01:00
dependabot[bot]
aabd0b76fb chore(deps): bump aquasecurity/trivy-action from 0.30.0 to 0.34.1 (#2)
Bumps [aquasecurity/trivy-action](https://github.com/aquasecurity/trivy-action) from 0.30.0 to 0.34.1.
- [Release notes](https://github.com/aquasecurity/trivy-action/releases)
- [Commits](https://github.com/aquasecurity/trivy-action/compare/0.30.0...0.34.1)

---
updated-dependencies:
- dependency-name: aquasecurity/trivy-action
  dependency-version: 0.34.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-24 12:20:12 +01:00
ac14405af3 ci: restore scan gate on release, block on HIGH/CRITICAL CVE 2026-02-24 10:53:28 +00:00
5f385dce45 ci: add dependabot, pin trivy-action@0.30.0, release no longer waits on scan 2026-02-24 10:48:06 +00:00
761e08f168 ci: upgrade codeql-action v3 -> v4 2026-02-24 10:41:37 +00:00
eb4f82df07 ci: fix deny.toml deprecated keys (copyleft, unlicensed removed in cargo-deny) 2026-02-24 10:26:58 +00:00
9784ad1813 chore: bump version to 0.2.22 2026-02-24 09:20:52 +00:00
fc1288820d ci: remove astra build for now 2026-02-24 00:39:16 +00:00
a17a75161b ci: consolidate all docker builds into single job to fix runner network issues 2026-02-24 00:07:44 +00:00
0b3ef3ab96 ci: use shared runner filesystem instead of artifact API to avoid network upload 2026-02-23 23:41:41 +00:00
99e290d30c ci: fix SBOM image tag and registry credentials 2026-02-23 18:53:17 +00:00
f74b781d1f ci: build musl static binary, fix cargo path (hardcode github-runner home) 2026-02-23 18:08:57 +00:00
05c765627f ci: fix trivy image tag (strip v prefix) 2026-02-23 16:47:18 +00:00
1813546bee ci: move trivy image scan to separate ubuntu-latest job to avoid self-hosted timeout 2026-02-23 16:15:03 +00:00
196c313f20 ci: add cargo cache to build-binary job, remove nora proxy (no sparse protocol) 2026-02-23 14:17:39 +00:00
aece2d739d ci: add registry credentials to trivy image scan 2026-02-23 14:01:31 +00:00
b7e11da2da ci: replace gitleaks action with CLI to avoid license requirement 2026-02-23 13:59:12 +00:00
dd3813edff ci: use github-runner own rust toolchain instead of ai-user path 2026-02-23 13:54:23 +00:00
adade10c67 chore: bump version to 0.2.21 2026-02-23 12:05:19 +00:00
6ad710ff32 ci: add security scanning and SBOM to release pipeline
- ci.yml: add security job (gitleaks, cargo-audit, cargo-deny, trivy fs)
- release.yml: restructure into build-binary + build-docker matrix + release
  - build binary once on self-hosted, reuse across all Docker builds
  - trivy image scan per matrix variant, results to GitHub Security tab
  - SBOM generation in SPDX and CycloneDX formats attached to release
- deny.toml: cargo-deny policy (allowed licenses, banned openssl, crates.io only)
- Dockerfile: remove Rust build stage, use pre-built binary
- Dockerfile.astra, Dockerfile.redos: FROM scratch for Russian certified OS support
2026-02-23 11:37:27 +00:00
037204a3eb fix: use FROM scratch for Astra and RedOS builds
Russian OS registries (registry.astralinux.ru, registry.red-soft.ru)
require auth not available in CI. Use scratch base with static musl
binary instead — runs on any Linux including Astra SE and RED OS.
Comment in each Dockerfile shows how to switch to official base image
once registry access is configured.
2026-02-23 08:43:13 +00:00
1e01d4df56 ci: add Astra Linux and RedOS parallel builds
Add Dockerfile.astra (astralinux/alse) and Dockerfile.redos (redos/redos)
for FSTEC-certified Russian OS targets. Update release.yml with a matrix
strategy that produces three image variants per release:
  - ghcr.io/.../nora:0.x.x          (Alpine, default)
  - ghcr.io/.../nora:0.x.x-astra    (Astra Linux SE)
  - ghcr.io/.../nora:0.x.x-redos    (RED OS)

Build stage is shared (musl static binary) across all variants.
2026-02-23 08:24:48 +00:00
ab5ed3f488 ci: remove unnecessary QEMU step for amd64-only builds 2026-02-23 08:05:54 +00:00
8336166e0e style: apply rustfmt to registry handlers 2026-02-23 07:48:20 +00:00
42e71b9195 refactor: use shared reqwest::Client across all registry handlers
Add http_client field to AppState, initialized once at startup.
Replace per-request Client::builder() calls in npm, maven, pypi,
and docker registry handlers with the shared instance.
This reuses the connection pool across requests instead of
creating a new client on every proxy fetch.

Bump version to 0.2.20.
2026-02-23 07:45:44 +00:00
ffac4f0286 fix(auth): replace starts_with with explicit matches for token paths
Prevent accidental exposure of unknown /api/tokens/* sub-paths.
Only the three known routes are now explicitly whitelisted in
is_public_path: /api/tokens, /api/tokens/list, /api/tokens/revoke.
2026-02-22 20:35:04 +00:00
078ef94153 chore: bump version to 0.2.19 2026-02-22 13:33:25 +00:00
94c92e5bc3 fix: use div_ceil instead of manual implementation 2026-01-31 16:51:37 +00:00
7326f9b0e2 chore: add pre-commit hook to prevent sensitive file commits
- Whitelist approach: only known safe extensions allowed (.rs, .toml, .yml, etc.)
- Block sensitive patterns (.env, .key, .pem, secrets, credentials)
- Warn but allow .md files
- Check only NEW files, modifications to tracked files always allowed
- Block large files (>5MB) with warning
- Run cargo fmt check on Rust files
- Update CONTRIBUTING.md with hook setup instructions
2026-01-31 16:39:04 +00:00
a2cb7c639c style: fix formatting and ignore txt files 2026-01-31 16:29:39 +00:00
eb77060114 perf: add in-memory repo index with pagination
- Add repo_index.rs with lazy rebuild on write operations
- Double-checked locking to prevent race conditions
- npm optimization: count tarballs instead of parsing metadata.json
- Add pagination to all registry list pages (?page=1&limit=50)
- Invalidate index on PUT/proxy cache in docker/maven/npm/pypi

Performance: 500-800x faster list page loads after first rebuild
2026-01-31 15:59:00 +00:00
8da3eab734 docs: add badges to README 2026-01-31 13:02:27 +00:00
f82e252e39 docs: add CONTRIBUTING.md and SECURITY.md 2026-01-31 12:39:41 +00:00
7763b85b94 chore: add copyright headers to all source files
Copyright (c) 2026 Volkov Pavel | DevITWay
SPDX-License-Identifier: MIT
2026-01-31 12:39:31 +00:00
47a3690384 style: fix O alignment in NORA logo on dashboard 2026-01-31 12:39:31 +00:00
65 changed files with 3329 additions and 646 deletions

142
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,142 @@
#!/bin/bash
# Pre-commit hook to prevent accidental commits of sensitive files
# Enable: git config core.hooksPath .githooks
set -e
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
# Allowed file extensions (whitelist)
ALLOWED_EXTENSIONS=(
'\.rs$'
'\.toml$'
'\.lock$'
'\.yml$'
'\.yaml$'
'\.json$'
'\.sh$'
'\.html$'
'\.css$'
'\.js$'
'\.gitignore$'
'\.dockerignore$'
'Dockerfile$'
'LICENSE$'
'Makefile$'
)
# Extensions that trigger a warning (not blocked)
WARN_EXTENSIONS=(
'\.md$'
)
# Always blocked patterns (regardless of extension)
BLOCKED_PATTERNS=(
'\.env$'
'\.env\.'
'\.key$'
'\.pem$'
'\.p12$'
'\.pfx$'
'\.htpasswd$'
'secret'
'credential'
'password'
'\.bak$'
'\.swp$'
'\.swo$'
'node_modules/'
'target/debug/'
'\.DS_Store'
)
# Get staged files (only NEW files, not already tracked)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=A)
if [ -z "$STAGED_FILES" ]; then
# No new files, only modifications to existing - allow
exit 0
fi
# Build patterns
ALLOWED_PATTERN=$(IFS='|'; echo "${ALLOWED_EXTENSIONS[*]}")
WARN_PATTERN=$(IFS='|'; echo "${WARN_EXTENSIONS[*]}")
BLOCKED_PATTERN=$(IFS='|'; echo "${BLOCKED_PATTERNS[*]}")
# Check for blocked patterns first
BLOCKED_FILES=$(echo "$STAGED_FILES" | grep -iE "$BLOCKED_PATTERN" || true)
if [ -n "$BLOCKED_FILES" ]; then
echo -e "${RED}BLOCKED: Suspicious files detected in commit${NC}"
echo ""
echo -e "${YELLOW}Files:${NC}"
echo "$BLOCKED_FILES" | sed 's/^/ - /'
echo ""
echo "If intentional, use: git commit --no-verify"
exit 1
fi
# Check for files with unknown extensions
UNKNOWN_FILES=""
WARN_FILES=""
while IFS= read -r file; do
[ -z "$file" ] && continue
if echo "$file" | grep -qE "$BLOCKED_PATTERN"; then
continue # Already handled above
elif echo "$file" | grep -qE "$WARN_PATTERN"; then
WARN_FILES="$WARN_FILES$file"$'\n'
elif ! echo "$file" | grep -qE "$ALLOWED_PATTERN"; then
UNKNOWN_FILES="$UNKNOWN_FILES$file"$'\n'
fi
done <<< "$STAGED_FILES"
# Warn about .md files
if [ -n "$WARN_FILES" ]; then
echo -e "${YELLOW}WARNING: Markdown files in commit:${NC}"
echo "$WARN_FILES" | sed '/^$/d' | sed 's/^/ - /'
echo ""
fi
# Block unknown extensions
if [ -n "$UNKNOWN_FILES" ]; then
echo -e "${RED}BLOCKED: Files with unknown extensions:${NC}"
echo "$UNKNOWN_FILES" | sed '/^$/d' | sed 's/^/ - /'
echo ""
echo "Allowed extensions: rs, toml, lock, yml, yaml, json, sh, html, css, js, md"
echo "If intentional, use: git commit --no-verify"
exit 1
fi
# Check for large files (>5MB)
LARGE_FILES=$(echo "$STAGED_FILES" | while read f; do
if [ -f "$f" ]; then
size=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
if [ "$size" -gt 5242880 ]; then
echo "$f ($(numfmt --to=iec $size 2>/dev/null || echo "${size}B"))"
fi
fi
done)
if [ -n "$LARGE_FILES" ]; then
echo -e "${YELLOW}WARNING: Large files (>5MB) in commit:${NC}"
echo "$LARGE_FILES" | sed 's/^/ - /'
echo ""
fi
# Run cargo fmt check if Rust files changed
if git diff --cached --name-only | grep -q '\.rs$'; then
if command -v cargo &> /dev/null; then
if ! cargo fmt --check &> /dev/null; then
echo -e "${RED}BLOCKED: cargo fmt check failed${NC}"
echo "Run: cargo fmt"
exit 1
fi
fi
fi
exit 0

16
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
version: 2
updates:
# GitHub Actions — обновляет версии actions в workflows
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
labels: [dependencies, ci]
# Cargo — только security-апдейты, без шума от minor/patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
labels: [dependencies, rust]

View File

@@ -11,7 +11,7 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@@ -27,3 +27,151 @@ jobs:
- name: Run tests
run: cargo test --package nora-registry
security:
name: Security
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for uploading SARIF to GitHub Security tab
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history required for gitleaks
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
# ── Secrets ────────────────────────────────────────────────────────────
- name: Gitleaks — scan for hardcoded secrets
run: |
curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar xz -C /usr/local/bin gitleaks
gitleaks detect --source . --exit-code 1 --report-format sarif --report-path gitleaks.sarif
# ── CVE in Rust dependencies ────────────────────────────────────────────
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: cargo audit — RustSec advisory database
run: cargo audit --ignore RUSTSEC-2025-0119 # known: number_prefix via indicatif
# ── Licenses, banned crates, supply chain policy ────────────────────────
- name: cargo deny — licenses and banned crates
uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
arguments: --all-features
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
- name: Trivy — filesystem scan (Cargo.lock + source)
if: always()
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-fs.sarif
severity: HIGH,CRITICAL
exit-code: 1 # block pipeline on HIGH/CRITICAL vulnerabilities
- name: Upload Trivy fs results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-fs.sarif
category: trivy-fs
integration:
name: Integration
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Build NORA
run: cargo build --release --package nora-registry
# -- Start NORA --
- name: Start NORA
run: |
NORA_STORAGE_PATH=/tmp/nora-data ./target/release/nora &
for i in $(seq 1 15); do
curl -sf http://localhost:4000/health && break || sleep 2
done
curl -sf http://localhost:4000/health | jq .
# -- Docker push/pull --
- name: Configure Docker for insecure registry
run: |
echo '{"insecure-registries": ["localhost:4000"]}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
sleep 2
- name: Docker — push and pull image
run: |
docker pull alpine:3.20
docker tag alpine:3.20 localhost:4000/test/alpine:integration
docker push localhost:4000/test/alpine:integration
docker rmi localhost:4000/test/alpine:integration
docker pull localhost:4000/test/alpine:integration
echo "Docker push/pull OK"
- name: Docker — verify catalog and tags
run: |
curl -sf http://localhost:4000/v2/_catalog | jq .
curl -sf http://localhost:4000/v2/test/alpine/tags/list | jq .
# -- npm (read-only proxy, no publish support yet) --
- name: npm — verify registry endpoint
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/npm/lodash)
echo "npm endpoint returned: $STATUS"
[ "$STATUS" != "000" ] && echo "npm endpoint OK" || (echo "npm endpoint unreachable" && exit 1)
# -- Maven deploy/download --
- name: Maven — deploy and download artifact
run: |
echo "test-artifact-content-$(date +%s)" > /tmp/test-artifact.jar
CHECKSUM=$(sha256sum /tmp/test-artifact.jar | cut -d' ' -f1)
curl -sf -X PUT --data-binary @/tmp/test-artifact.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
curl -sf -o /tmp/downloaded.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
DOWNLOAD_CHECKSUM=$(sha256sum /tmp/downloaded.jar | cut -d' ' -f1)
[ "$CHECKSUM" = "$DOWNLOAD_CHECKSUM" ] && echo "Maven deploy/download OK" || (echo "Checksum mismatch!" && exit 1)
# -- PyPI (read-only proxy, no upload support yet) --
- name: PyPI — verify simple index
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/simple/)
echo "PyPI simple index returned: $STATUS"
[ "$STATUS" = "200" ] && echo "PyPI endpoint OK" || (echo "Expected 200, got $STATUS" && exit 1)
# -- Cargo (read-only proxy, no publish support yet) --
- name: Cargo — verify registry API responds
run: |
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/cargo/api/v1/crates/serde)
echo "Cargo API returned: $STATUS"
[ "$STATUS" != "000" ] && echo "Cargo endpoint OK" || (echo "Cargo endpoint unreachable" && exit 1)
# -- API checks --
- name: API — health, ready, metrics
run: |
curl -sf http://localhost:4000/health | jq .status
curl -sf http://localhost:4000/ready
curl -sf http://localhost:4000/metrics | head -5
echo "API checks OK"
- name: Stop NORA
if: always()
run: pkill nora || true

View File

@@ -6,75 +6,265 @@ on:
env:
REGISTRY: ghcr.io
NORA: localhost:5000
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build & Push
runs-on: self-hosted
runs-on: [self-hosted, nora]
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Rust
run: |
echo "/home/github-runner/.cargo/bin" >> $GITHUB_PATH
echo "RUSTUP_HOME=/home/github-runner/.rustup" >> $GITHUB_ENV
echo "CARGO_HOME=/home/github-runner/.cargo" >> $GITHUB_ENV
- name: Build release binary (musl static)
run: |
cargo build --release --target x86_64-unknown-linux-musl --package nora-registry
cp target/x86_64-unknown-linux-musl/release/nora ./nora
- name: Upload binary artifact
uses: actions/upload-artifact@v7
with:
name: nora-binary-${{ github.run_id }}
path: ./nora
retention-days: 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Log in to Container Registry
uses: docker/login-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
# ── Alpine ───────────────────────────────────────────────────────────────
- name: Extract metadata (alpine)
id: meta-alpine
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
- name: Build and push (alpine)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta-alpine.outputs.tags }}
labels: ${{ steps.meta-alpine.outputs.labels }}
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,ignore-error=true
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,mode=max
# ── RED OS ───────────────────────────────────────────────────────────────
- name: Extract metadata (redos)
id: meta-redos
uses: docker/metadata-action@v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: suffix=-redos,onlatest=true
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=redos
- name: Build and push (redos)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.redos
platforms: linux/amd64
push: true
tags: ${{ steps.meta-redos.outputs.tags }}
labels: ${{ steps.meta-redos.outputs.labels }}
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,ignore-error=true
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,mode=max
# ── Astra Linux SE ───────────────────────────────────────────────────────
- name: Extract metadata (astra)
id: meta-astra
uses: docker/metadata-action@v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: suffix=-astra,onlatest=true
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=astra
- name: Build and push (astra)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.astra
platforms: linux/amd64
push: true
tags: ${{ steps.meta-astra.outputs.tags }}
labels: ${{ steps.meta-astra.outputs.labels }}
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,ignore-error=true
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,mode=max
# ── Smoke test ──────────────────────────────────────────────────────────
- name: Smoke test — verify alpine image starts and responds
run: |
docker rm -f nora-smoke 2>/dev/null || true
docker run --rm -d --name nora-smoke -p 5555:4000 -e NORA_HOST=0.0.0.0 \
${{ env.NORA }}/${{ env.IMAGE_NAME }}:latest
for i in $(seq 1 10); do
curl -sf http://localhost:5555/health && break || sleep 2
done
curl -sf http://localhost:5555/health
docker stop nora-smoke
scan:
name: Scan (${{ matrix.name }})
runs-on: [self-hosted, nora]
needs: build
permissions:
contents: read
packages: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- name: alpine
suffix: ""
- name: redos
suffix: "-redos"
- name: astra
suffix: "-astra"
steps:
- name: Set version tag (strip leading v)
id: ver
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Trivy — image scan (${{ matrix.name }})
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: image
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
format: sarif
output: trivy-image-${{ matrix.name }}.sarif
severity: HIGH,CRITICAL
exit-code: 1
- name: Upload Trivy image results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-image-${{ matrix.name }}.sarif
category: trivy-image-${{ matrix.name }}
release:
name: GitHub Release
runs-on: ubuntu-latest
needs: build
runs-on: [self-hosted, nora]
needs: [build, scan]
permissions:
contents: write
packages: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set version tag (strip leading v)
id: ver
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Download binary artifact
uses: actions/download-artifact@v4
with:
name: nora-binary-${{ github.run_id }}
path: ./artifacts
- name: Prepare binary
run: |
cp ./artifacts/nora ./nora-linux-amd64
chmod +x ./nora-linux-amd64
sha256sum ./nora-linux-amd64 > nora-linux-amd64.sha256
echo "Binary size: $(du -sh nora-linux-amd64 | cut -f1)"
cat nora-linux-amd64.sha256
- name: Generate SBOM (SPDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: spdx-json
output-file: nora-${{ github.ref_name }}.sbom.spdx.json
- name: Generate SBOM (CycloneDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: cyclonedx-json
output-file: nora-${{ github.ref_name }}.sbom.cdx.json
- name: Create Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
nora-linux-amd64
nora-linux-amd64.sha256
nora-${{ github.ref_name }}.sbom.spdx.json
nora-${{ github.ref_name }}.sbom.cdx.json
body: |
## Docker
## Install
```bash
curl -fsSL https://getnora.io/install.sh | sh
```
Or download the binary directly:
```bash
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/nora-linux-amd64
chmod +x nora-linux-amd64
sudo mv nora-linux-amd64 /usr/local/bin/nora
```
## Docker
**Alpine (standard):**
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
```
**RED OS:**
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-redos
```
**Astra Linux SE:**
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-astra
```
## Changelog
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)

8
.gitignore vendored
View File

@@ -6,9 +6,17 @@ data/
*.log
internal config
# Backup files
*.bak
# Internal files
SESSION*.md
TODO.md
ROADMAP*.md
docs-site/
docs/
*.txt
## Internal files
.internal/
examples/

8
.gitleaks.toml Normal file
View File

@@ -0,0 +1,8 @@
# Gitleaks configuration
# https://github.com/gitleaks/gitleaks
[allowlist]
description = "Allowlist for false positives"
# Documentation examples with placeholder credentials
commits = ["92155cf6574d89f93ee68503a7b68455ceaa19af"]

View File

@@ -4,6 +4,259 @@ All notable changes to NORA will be documented in this file.
---
## [0.2.29] - 2026-03-15
### Added / Добавлено
- **Upstream Authentication**: All registry proxies now support Basic Auth credentials for private upstream registries
- **Аутентификация upstream**: Все прокси реестров теперь поддерживают Basic Auth для приватных upstream-реестров
- Docker: `NORA_DOCKER_UPSTREAMS="https://registry.corp.com|user:pass"`
- Maven: `NORA_MAVEN_PROXIES="https://nexus.corp.com/maven2|user:pass"`
- npm: `NORA_NPM_PROXY_AUTH="user:pass"`
- PyPI: `NORA_PYPI_PROXY_AUTH="user:pass"`
- **Plaintext credential warning**: NORA logs a warning at startup if credentials are stored in config.toml instead of env vars
- **Предупреждение о plaintext credentials**: NORA логирует предупреждение при старте, если credentials хранятся в config.toml вместо переменных окружения
### Changed / Изменено
- Extracted `basic_auth_header()` helper for consistent auth across all protocols
- Вынесен хелпер `basic_auth_header()` для единообразной авторизации всех протоколов
### Removed / Удалено
- Removed unused `DockerAuth::fetch_with_auth()` method (dead code cleanup)
- Удалён неиспользуемый метод `DockerAuth::fetch_with_auth()` (очистка мёртвого кода)
---
## [0.2.28] - 2026-03-13
### Fixed / Исправлено
- **docker-compose.yml**: Fixed image reference from `getnora/nora:latest` to `ghcr.io/getnora-io/nora:latest`
- **docker-compose.yml**: Исправлена ссылка на образ с `getnora/nora:latest` на `ghcr.io/getnora-io/nora:latest`
### Documentation / Документация
- **Authentication Guide**: Added complete auth setup guide in README — htpasswd, API tokens, RBAC roles, curl examples
- **Руководство по аутентификации**: Добавлено полное руководство по настройке auth в README — htpasswd, API-токены, RBAC-роли, примеры curl
- **FSTEC builds**: Documented `Dockerfile.astra` and `Dockerfile.redos` purpose in README
- **Сборки ФСТЭК**: Документировано назначение `Dockerfile.astra` и `Dockerfile.redos` в README
- **TLS / HTTPS**: Added reverse proxy setup guide (Caddy, Nginx) and `insecure-registries` Docker config for internal deployments
- **TLS / HTTPS**: Добавлено руководство по настройке reverse proxy (Caddy, Nginx) и конфигурация `insecure-registries` Docker для внутренних инсталляций
### Removed / Удалено
- Removed stale `CHANGELOG.md.bak` from repository
- Удалён устаревший `CHANGELOG.md.bak` из репозитория
---
## [0.2.27] - 2026-03-03
### Added / Добавлено
- **Configurable body limit**: `NORA_BODY_LIMIT_MB` env var (default: `2048` = 2GB) — replaces hardcoded 100MB limit that caused `413 Payload Too Large` on large Docker image push
- **Настраиваемый лимит тела запроса**: переменная `NORA_BODY_LIMIT_MB` (по умолчанию: `2048` = 2GB) — заменяет захардкоженный лимит 100MB, вызывавший `413 Payload Too Large` при push больших Docker-образов
- **Docker Delete API**: `DELETE /v2/{name}/manifests/{reference}` and `DELETE /v2/{name}/blobs/{digest}` per Docker Registry V2 spec (returns 202 Accepted)
- **Docker Delete API**: `DELETE /v2/{name}/manifests/{reference}` и `DELETE /v2/{name}/blobs/{digest}` по спецификации Docker Registry V2 (возвращает 202 Accepted)
- Namespace-qualified DELETE variants (`/v2/{ns}/{name}/...`)
- Audit log integration for delete operations
### Fixed / Исправлено
- Docker push of images >100MB no longer fails with 413 error
- Push Docker-образов >100MB больше не падает с ошибкой 413
---
## [0.2.26] - 2026-03-03
### Added / Добавлено
- **Helm OCI support**: `helm push` / `helm pull` now works out of the box via OCI protocol
- **Поддержка Helm OCI**: `helm push` / `helm pull` теперь работают из коробки через OCI протокол
- **RBAC**: Token-based role system with three roles — `read`, `write`, `admin` (default: `read`)
- **RBAC**: Ролевая система на основе токенов — `read`, `write`, `admin` (по умолчанию: `read`)
- **Audit log**: Persistent append-only JSONL audit trail for all registry operations (`{storage}/audit.jsonl`)
- **Аудит**: Персистентный append-only JSONL лог всех операций реестра (`{storage}/audit.jsonl`)
- **GC command**: `nora gc --dry-run` — garbage collection for orphaned blobs (mark-and-sweep)
- **Команда GC**: `nora gc --dry-run` — сборка мусора для осиротевших блобов (mark-and-sweep)
### Fixed / Исправлено
- **Helm OCI pull**: Fixed OCI manifest media type detection — manifests with non-Docker `config.mediaType` now correctly return `application/vnd.oci.image.manifest.v1+json`
- **Helm OCI pull**: Исправлено определение media type OCI манифестов — манифесты с не-Docker `config.mediaType` теперь корректно возвращают `application/vnd.oci.image.manifest.v1+json`
- **Docker-Content-Digest**: Added missing header in blob upload response (required by Helm OCI client)
- **Docker-Content-Digest**: Добавлен отсутствующий заголовок в ответе на загрузку blob (требуется клиентом Helm OCI)
### Security / Безопасность
- Read-only tokens (`role: read`) are now blocked from PUT/POST/DELETE/PATCH operations with HTTP 403
- Токены только для чтения (`role: read`) теперь блокируются при PUT/POST/DELETE/PATCH с HTTP 403
---
## [0.2.25] - 2026-03-03
### Fixed / Исправлено
- **Rate limiter fix**: Added `NORA_RATE_LIMIT_ENABLED` env var (default: `true`) to disable rate limiting on internal deployments
- **Исправление rate limiter**: Добавлена переменная `NORA_RATE_LIMIT_ENABLED` (по умолчанию: `true`) для отключения rate limiting на внутренних инсталляциях
- **SmartIpKeyExtractor**: Upload and general routes now use `SmartIpKeyExtractor` (reads `X-Forwarded-For`) instead of `PeerIpKeyExtractor` — fixes 429 errors behind reverse proxy / Docker bridge
- **SmartIpKeyExtractor**: Маршруты upload и general теперь используют `SmartIpKeyExtractor` (читает `X-Forwarded-For`) вместо `PeerIpKeyExtractor` — устраняет ошибки 429 за reverse proxy / Docker bridge
### Dependencies / Зависимости
- `clap` 4.5.56 → 4.5.60
- `uuid` 1.20.0 → 1.21.0
- `tempfile` 3.24.0 → 3.26.0
- `bcrypt` 0.17.1 → 0.18.0
- `indicatif` 0.17.11 → 0.18.4
### CI/CD
- `actions/checkout` 4 → 6
- `actions/upload-artifact` 4 → 7
- `softprops/action-gh-release` 1 → 2
- `aquasecurity/trivy-action` 0.30.0 → 0.34.2
- `docker/build-push-action` 5 → 6
- Move scan/release to self-hosted runner with NORA cache
- Сканирование/релиз перенесены на self-hosted runner с кэшем через NORA
---
## [0.2.24] - 2026-02-24
### Added / Добавлено
- `install.sh` installer script live at <https://getnora.io/install.sh> — `curl -fsSL https://getnora.io/install.sh | sh`
- Скрипт установки `install.sh` доступен на <https://getnora.io/install.sh>
### CI/CD
- Restore Astra Linux SE Docker image build, Trivy scan, and release artifact (`-astra` tag)
- Восстановлена сборка Docker-образа для Astra Linux SE, сканирование Trivy и артефакт релиза (тег `-astra`)
---
## [0.2.23] - 2026-02-24
### Added / Добавлено
- Binary (`nora`) + SHA-256 checksum attached to every GitHub Release
- Бинарник (`nora`) и SHA-256 контрольная сумма прикреплены к каждому релизу GitHub
### Fixed / Исправлено
- Security: bump `prometheus` 0.13 → 0.14 (CVE-2025-53605) and `bytes` 1.11.0 → 1.11.1 (CVE-2026-25541)
- Безопасность: обновлены `prometheus` 0.13 → 0.14 (CVE-2025-53605) и `bytes` 1.11.0 → 1.11.1 (CVE-2026-25541)
### CI/CD
- Add Dependabot for automated dependency updates / Добавлен Dependabot для автоматического обновления зависимостей
- Pin `aquasecurity/trivy-action` to `0.30.0`, bump to `0.34.1`; scan gate blocks release on HIGH/CRITICAL CVE
- Закреплён `trivy-action@0.30.0`, обновлён до `0.34.1`; сканирование блокирует релиз при HIGH/CRITICAL CVE
- Upgrade `codeql-action` v3 → v4 / Обновлён `codeql-action` v3 → v4
- Fix `deny.toml` deprecated keys (`copyleft`, `unlicensed` removed in `cargo-deny`) / Исправлены устаревшие ключи в `deny.toml`
- Fix binary path in Docker image (`/usr/local/bin/nora`) / Исправлен путь бинарника в Docker-образе
- Pin build job to `nora` runner label / Джоб сборки закреплён за runner'ом с меткой `nora`
- Allow `CDLA-Permissive-2.0` license (`webpki-roots`) / Разрешена лицензия `CDLA-Permissive-2.0`
- Ignore `RUSTSEC-2025-0119` (unmaintained transitive dep `number_prefix` via `indicatif`)
### Dependencies / Зависимости
- `chrono` 0.4.43 → 0.4.44
- `quick-xml` 0.31.0 → 0.39.2
- `toml` 0.8.23 → 1.0.3+spec-1.1.0
- `flate2` 1.1.8 → 1.1.9
- `softprops/action-gh-release` 1 → 2
- `actions/checkout` 4 → 6
- `docker/build-push-action` 5 → 6
### Documentation / Документация
- Replace text title with SVG logo; `O` styled in blue-600 / Заголовок заменён SVG-логотипом; буква `O` стилизована в blue-600
---
## [0.2.22] - 2026-02-24
### Changed / Изменено
- First stable release with Docker images published to container registry
- Первый стабильный релиз с Docker-образами, опубликованными в container registry
---
## [0.2.21] - 2026-02-24
### CI/CD
- Consolidate all Docker builds into a single job to fix runner network issues / Все Docker-сборки объединены в один job для устранения сетевых проблем runner'а
- Build musl static binary for maximum portability / Сборка musl-бинарника для максимальной переносимости
- Add security scanning (Trivy) + SBOM generation to release pipeline / Добавлено сканирование безопасности (Trivy) и генерация SBOM в pipeline релиза
- Add Cargo cache to speed up builds / Добавлен кэш Cargo для ускорения сборок
- Replace `gitleaks` GitHub Action with CLI (no license requirement) / `gitleaks` Action заменён CLI-вызовом (лицензия не требуется)
- Use GitHub-runner's own Rust toolchain (avoid path conflicts) / Используется Rust toolchain самого GitHub-runner'а
- Use shared runner filesystem instead of artifact API (avoids network upload latency) / Общая файловая система runner'а вместо artifact API
- Remove Astra Linux build temporarily / Сборка для Astra Linux временно удалена
---
## [0.2.20] - 2026-02-23
### Added / Добавлено
- Parallel CI builds for Astra Linux and RedOS / Параллельная сборка в CI для Astra Linux и RedOS
### Changed / Изменено
- Use `FROM scratch` base image for Astra Linux and RedOS Docker builds / Базовый образ `FROM scratch` для Docker-сборок Astra Linux и RedOS
- Shared `reqwest::Client` across all registry handlers / Общий `reqwest::Client` для всех registry-обработчиков
### Fixed / Исправлено
- Auth: replace `starts_with` with explicit `matches!` for token path checks / Аутентификация: `starts_with` заменён явной проверкой `matches!` для путей с токенами
- Remove unnecessary QEMU step for amd64-only builds / Удалён лишний шаг QEMU для amd64-сборок
---
## [0.2.19] - 2026-01-31
### Added / Добавлено
- Pre-commit hook to prevent accidental commits of sensitive files / Pre-commit хук для защиты от случайного коммита чувствительных файлов
- README badges: build status, version, license / Бейджи в README: статус сборки, версия, лицензия
### Performance / Производительность
- In-memory repository index with pagination for faster dashboard load / Индекс репозитория в памяти с пагинацией для ускорения загрузки дашборда
### Fixed / Исправлено
- Use `div_ceil` instead of manual ceiling division / Использован `div_ceil` вместо ручной реализации деления с округлением вверх
---
## [0.2.18] - 2026-01-31
### Changed
- Logo styling refinements
---
## [0.2.17] - 2026-01-31
### Added
- Copyright headers to all source files (Volkov Pavel | DevITWay)
- SPDX-License-Identifier: MIT in all .rs files
---
## [0.2.16] - 2026-01-31
### Changed
- N○RA branding: stylized O logo across dashboard
- Fixed O letter alignment in logo
---
## [0.2.15] - 2026-01-31
### Fixed
- Code formatting (cargo fmt)
---
## [0.2.14] - 2026-01-31
### Fixed
- Docker dashboard now shows actual image size from manifest layers (config + layers sum)
- Previously showed only manifest file size (~500 B instead of actual image size)
---
## [0.2.13] - 2026-01-31
### Fixed
- npm dashboard now shows correct version count and package sizes
- Parses metadata.json for versions, dist.unpackedSize, and time.modified
- Previously showed 0 versions / 0 B for all packages
---
## [0.2.12] - 2026-01-30
### Added

View File

@@ -1,100 +1,71 @@
# Contributing to NORA
Thanks for your interest in contributing to NORA!
Thank you for your interest in contributing to NORA!
## Getting Started
1. **Fork** the repository
2. **Clone** your fork:
```bash
git clone https://github.com/your-username/nora.git
cd nora
```
3. **Create a branch**:
```bash
git checkout -b feature/your-feature-name
```
1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/nora.git`
3. Create a branch: `git checkout -b feature/your-feature`
## Development Setup
### Prerequisites
- Rust 1.75+ (`rustup update`)
- Docker (for testing)
- Git
### Build
```bash
# Install Rust (if needed)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Enable pre-commit hooks (important!)
git config core.hooksPath .githooks
# Build
cargo build
```
### Run
```bash
cargo run --bin nora
```
### Test
```bash
# Run tests
cargo test
cargo clippy
cargo fmt --check
# Run locally
cargo run --bin nora -- serve
```
## Making Changes
1. **Write code** following Rust conventions
2. **Add tests** for new features
3. **Update docs** if needed
4. **Run checks**:
```bash
cargo fmt
cargo clippy -- -D warnings
cargo test
```
## Commit Messages
Follow [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` - New feature
- `fix:` - Bug fix
- `docs:` - Documentation
- `test:` - Tests
- `refactor:` - Code refactoring
- `chore:` - Maintenance
Example:
```bash
git commit -m "feat: add S3 storage migration"
```
## Pull Request Process
1. **Push** to your fork:
```bash
git push origin feature/your-feature-name
```
2. **Open a Pull Request** on GitHub
3. **Wait for review** - maintainers will review your PR
## Code Style
- Follow Rust conventions
- Use `cargo fmt` for formatting
- Pass `cargo clippy` with no warnings
- Write meaningful commit messages
- Run `cargo fmt` before committing
- Run `cargo clippy` and fix warnings
- Follow Rust naming conventions
## Questions?
## Pull Request Process
- Open an [Issue](https://github.com/getnora-io/nora/issues)
- Ask in [Discussions](https://github.com/getnora-io/nora/discussions)
- Reach out on [Telegram](https://t.me/DevITWay)
1. Update documentation if needed
2. Add tests for new features
3. Ensure all tests pass: `cargo test`
4. Ensure code is formatted: `cargo fmt --check`
5. Ensure no clippy warnings: `cargo clippy`
---
## Commit Messages
Built with love by the NORA community
Use conventional commits:
- `feat:` - new feature
- `fix:` - bug fix
- `docs:` - documentation
- `style:` - formatting
- `refactor:` - code refactoring
- `test:` - adding tests
- `chore:` - maintenance
Example: `feat: add OAuth2 authentication`
## Reporting Issues
- Use GitHub Issues
- Include steps to reproduce
- Include NORA version and OS
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
## Contact
- Telegram: [@DevITWay](https://t.me/DevITWay)
- GitHub Issues: [getnora-io/nora](https://github.com/getnora-io/nora/issues)

401
Cargo.lock generated
View File

@@ -82,6 +82,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -184,13 +190,13 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.17.1"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c"
checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
dependencies = [
"base64",
"blowfish",
"getrandom 0.3.4",
"getrandom 0.4.1",
"subtle",
"zeroize",
]
@@ -234,9 +240,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
@@ -262,9 +268,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.43"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
@@ -286,9 +292,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.56"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@@ -296,9 +302,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.56"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
@@ -320,9 +326,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.7"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "colorchoice"
@@ -332,15 +338,15 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "console"
version = "0.15.11"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -467,7 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -495,9 +501,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.8"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
@@ -510,6 +516,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -667,6 +679,19 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "governor"
version = "0.10.4"
@@ -715,6 +740,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -723,7 +757,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -980,6 +1014,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1015,14 +1055,14 @@ dependencies = [
[[package]]
name = "indicatif"
version = "0.17.11"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"unit-prefix",
"web-time",
]
@@ -1080,10 +1120,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.180"
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libredox"
@@ -1098,9 +1144,9 @@ dependencies = [
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
@@ -1201,7 +1247,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nora-cli"
version = "0.2.12"
version = "0.2.29"
dependencies = [
"clap",
"flate2",
@@ -1215,7 +1261,7 @@ dependencies = [
[[package]]
name = "nora-registry"
version = "0.2.12"
version = "0.2.29"
dependencies = [
"async-trait",
"axum",
@@ -1253,7 +1299,7 @@ dependencies = [
[[package]]
name = "nora-storage"
version = "0.2.12"
version = "0.2.29"
dependencies = [
"axum",
"base64",
@@ -1298,12 +1344,6 @@ dependencies = [
"libc",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -1401,6 +1441,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -1412,9 +1462,9 @@ dependencies = [
[[package]]
name = "prometheus"
version = "0.13.4"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1"
checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a"
dependencies = [
"cfg-if",
"fnv",
@@ -1422,14 +1472,28 @@ dependencies = [
"memchr",
"parking_lot",
"protobuf",
"thiserror 1.0.69",
"thiserror 2.0.18",
]
[[package]]
name = "protobuf"
version = "2.28.0"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4"
dependencies = [
"once_cell",
"protobuf-support",
"thiserror 1.0.69",
]
[[package]]
name = "protobuf-support"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "quanta"
@@ -1448,9 +1512,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.31.0"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
dependencies = [
"memchr",
"serde",
@@ -1478,9 +1542,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
@@ -1707,15 +1771,15 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.3"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -1780,6 +1844,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1836,11 +1906,11 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "0.6.9"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
dependencies = [
"serde",
"serde_core",
]
[[package]]
@@ -1990,15 +2060,15 @@ dependencies = [
[[package]]
name = "tempfile"
version = "3.24.0"
version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2077,9 +2147,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -2139,44 +2209,42 @@ dependencies = [
[[package]]
name = "toml"
version = "0.8.23"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap",
"serde",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_write",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
name = "toml_datetime"
version = "1.0.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_parser"
version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
[[package]]
name = "tonic"
@@ -2378,6 +2446,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -2453,11 +2533,11 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.20.0"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.3.4",
"getrandom 0.4.1",
"js-sys",
"wasm-bindgen",
]
@@ -2508,6 +2588,15 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
@@ -2567,6 +2656,40 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -2618,7 +2741,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2695,15 +2818,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
@@ -2856,9 +2970,6 @@ name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "wiremock"
@@ -2888,6 +2999,88 @@ name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
@@ -3038,9 +3231,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.5.5"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8"
[[package]]
name = "zmij"

View File

@@ -7,7 +7,7 @@ members = [
]
[workspace.package]
version = "0.2.12"
version = "0.2.29"
edition = "2021"
license = "MIT"
authors = ["DevITWay <devitway@gmail.com>"]

View File

@@ -1,58 +1,11 @@
# syntax=docker/dockerfile:1.4
# Build stage
FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev curl
WORKDIR /app
# Copy manifests
COPY Cargo.toml Cargo.lock ./
COPY nora-registry/Cargo.toml nora-registry/
COPY nora-storage/Cargo.toml nora-storage/
COPY nora-cli/Cargo.toml nora-cli/
# Create dummy sources for dependency caching
RUN mkdir -p nora-registry/src nora-storage/src nora-cli/src && \
echo "fn main() {}" > nora-registry/src/main.rs && \
echo "fn main() {}" > nora-storage/src/main.rs && \
echo "fn main() {}" > nora-cli/src/main.rs
# Build dependencies only (with cache)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
cargo build --release --package nora-registry && \
rm -rf nora-registry/src nora-storage/src nora-cli/src
# Copy real sources
COPY nora-registry/src nora-registry/src
COPY nora-storage/src nora-storage/src
COPY nora-cli/src nora-cli/src
# Build release binary (with cache)
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
touch nora-registry/src/main.rs && \
cargo build --release --package nora-registry && \
cp /app/target/release/nora /usr/local/bin/nora
# Runtime stage
# Binary is pre-built by CI (cargo build --release) and passed via context
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates && mkdir -p /data
WORKDIR /app
COPY nora /usr/local/bin/nora
# Copy binary
COPY --from=builder /usr/local/bin/nora /usr/local/bin/nora
# Create data directory
RUN mkdir -p /data
# Default environment
ENV RUST_LOG=info
ENV NORA_HOST=0.0.0.0
ENV NORA_PORT=4000
@@ -64,5 +17,5 @@ EXPOSE 4000
VOLUME ["/data"]
ENTRYPOINT ["nora"]
ENTRYPOINT ["/usr/local/bin/nora"]
CMD ["serve"]

28
Dockerfile.astra Normal file
View File

@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.4
# Binary is pre-built by CI (cargo build --release) and passed via context
# Runtime: scratch — compatible with Astra Linux SE (FSTEC certified)
# To switch to official base: replace FROM scratch with
# FROM registry.astralinux.ru/library/alse:latest
# RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY nora /usr/local/bin/nora
ENV RUST_LOG=info
ENV NORA_HOST=0.0.0.0
ENV NORA_PORT=4000
ENV NORA_STORAGE_MODE=local
ENV NORA_STORAGE_PATH=/data/storage
ENV NORA_AUTH_TOKEN_STORAGE=/data/tokens
EXPOSE 4000
VOLUME ["/data"]
ENTRYPOINT ["/usr/local/bin/nora"]
CMD ["serve"]

28
Dockerfile.redos Normal file
View File

@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.4
# Binary is pre-built by CI (cargo build --release) and passed via context
# Runtime: scratch — compatible with RED OS (FSTEC certified)
# To switch to official base: replace FROM scratch with
# FROM registry.red-soft.ru/redos/redos:8
# RUN dnf install -y ca-certificates && dnf clean all
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY nora /usr/local/bin/nora
ENV RUST_LOG=info
ENV NORA_HOST=0.0.0.0
ENV NORA_PORT=4000
ENV NORA_STORAGE_MODE=local
ENV NORA_STORAGE_PATH=/data/storage
ENV NORA_AUTH_TOKEN_STORAGE=/data/tokens
EXPOSE 4000
VOLUME ["/data"]
ENTRYPOINT ["/usr/local/bin/nora"]
CMD ["serve"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 DevITWay
Copyright (c) 2026 Volkov Pavel | DevITWay
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

152
README.md
View File

@@ -1,7 +1,11 @@
# 🐿️ N○RA
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Telegram](https://img.shields.io/badge/Telegram-DevITWay-blue?logo=telegram)](https://t.me/DevITWay)
[![Release](https://img.shields.io/github/v/release/getnora-io/nora)](https://github.com/getnora-io/nora/releases)
[![CI](https://img.shields.io/github/actions/workflow/status/getnora-io/nora/ci.yml?label=CI)](https://github.com/getnora-io/nora/actions)
[![GHCR](https://img.shields.io/badge/ghcr.io-nora-blue?logo=github)](https://github.com/getnora-io/nora/pkgs/container/nora)
[![GitHub Stars](https://img.shields.io/github/stars/getnora-io/nora?style=flat&logo=github)](https://github.com/getnora-io/nora/stargazers)
[![Rust](https://img.shields.io/badge/rust-%23000000.svg?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Docs](https://img.shields.io/badge/docs-getnora.dev-green?logo=gitbook)](https://getnora.dev)
[![Telegram](https://img.shields.io/badge/Telegram-Community-blue?logo=telegram)](https://t.me/getnora)
> **Your Cloud-Native Artifact Registry**
@@ -32,8 +36,10 @@ Fast. Organized. Feel at Home.
- **Security**
- Basic Auth (htpasswd + bcrypt)
- Revocable API tokens
- Revocable API tokens with RBAC
- ENV-based configuration (12-Factor)
- SBOM (SPDX + CycloneDX) in every release
- See [SECURITY.md](SECURITY.md) for vulnerability reporting
## Quick Start
@@ -82,6 +88,39 @@ npm config set registry http://localhost:4000/npm/
npm publish
```
## Authentication
NORA supports Basic Auth (htpasswd) and revocable API tokens with RBAC.
### Quick Setup
```bash
# 1. Create htpasswd file with bcrypt
htpasswd -cbB users.htpasswd admin yourpassword
# Add more users:
htpasswd -bB users.htpasswd ci-user ci-secret
# 2. Start NORA with auth enabled
docker run -d -p 4000:4000 \
-v nora-data:/data \
-v ./users.htpasswd:/data/users.htpasswd \
-e NORA_AUTH_ENABLED=true \
ghcr.io/getnora-io/nora:latest
# 3. Verify
curl -u admin:yourpassword http://localhost:4000/v2/_catalog
```
### API Tokens (RBAC)
| Role | Pull/Read | Push/Write | Delete/Admin |
|------|-----------|------------|--------------|
| `read` | Yes | No | No |
| `write` | Yes | Yes | No |
| `admin` | Yes | Yes | Yes |
See [Authentication guide](https://getnora.dev/configuration/authentication/) for token management, Docker login, and CI/CD integration.
## CLI Commands
```bash
@@ -101,18 +140,10 @@ nora migrate --from local --to s3
| `NORA_HOST` | 127.0.0.1 | Bind address |
| `NORA_PORT` | 4000 | Port |
| `NORA_STORAGE_MODE` | local | `local` or `s3` |
| `NORA_STORAGE_PATH` | data/storage | Local storage path |
| `NORA_STORAGE_S3_URL` | - | S3 endpoint URL |
| `NORA_STORAGE_BUCKET` | registry | S3 bucket name |
| `NORA_AUTH_ENABLED` | false | Enable authentication |
| `NORA_RATE_LIMIT_AUTH_RPS` | 1 | Auth requests per second |
| `NORA_RATE_LIMIT_AUTH_BURST` | 5 | Auth burst size |
| `NORA_RATE_LIMIT_UPLOAD_RPS` | 200 | Upload requests per second |
| `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size |
| `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second |
| `NORA_RATE_LIMIT_GENERAL_BURST` | 200 | General burst size |
| `NORA_SECRETS_PROVIDER` | env | Secrets provider (`env`) |
| `NORA_SECRETS_CLEAR_ENV` | false | Clear env vars after reading |
| `NORA_DOCKER_UPSTREAMS` | `https://registry-1.docker.io` | Docker upstreams (`url\|user:pass,...`) |
See [full configuration reference](https://getnora.dev/configuration/settings/) for all environment variables including storage, rate limiting, proxy auth, and secrets.
### config.toml
@@ -129,24 +160,10 @@ path = "data/storage"
enabled = false
htpasswd_file = "users.htpasswd"
[rate_limit]
# Strict limits for authentication (brute-force protection)
auth_rps = 1
auth_burst = 5
# High limits for CI/CD upload workloads
upload_rps = 200
upload_burst = 500
# Balanced limits for general API endpoints
general_rps = 100
general_burst = 200
[secrets]
# Provider: env (default), aws-secrets, vault, k8s (coming soon)
provider = "env"
# Clear environment variables after reading (security hardening)
clear_env = false
```
See [full config reference](https://getnora.dev/configuration/settings/) for rate limiting, secrets, and proxy settings.
## Endpoints
| URL | Description |
@@ -162,6 +179,77 @@ clear_env = false
| `/cargo/` | Cargo |
| `/simple/` | PyPI |
## TLS / HTTPS
NORA serves plain HTTP by design. **TLS is intentionally not built into the binary** — this is a deliberate architectural decision:
- **Single responsibility**: NORA manages artifacts, not certificates. Embedding TLS means bundling Let's Encrypt clients, certificate renewal logic, ACME challenges, and custom CA support — all of which already exist in battle-tested tools.
- **Operational simplicity**: One place for certificates (reverse proxy), not scattered across every service. When a cert expires, you fix it in one config — not in NORA, Grafana, GitLab, and every other service separately.
- **Industry standard**: Docker Hub, GitHub Container Registry, AWS ECR, Harbor, Nexus — none of them terminate TLS in the registry process. A reverse proxy in front is the universal pattern.
- **Zero-config internal use**: On trusted networks (lab, CI/CD), NORA works out of the box without generating self-signed certs or managing keystores.
### Production (recommended): reverse proxy with auto-TLS
```
Client → Caddy/Nginx (HTTPS, port 443) → NORA (HTTP, port 4000)
```
Caddy example:
```
registry.example.com {
reverse_proxy localhost:4000
}
```
Nginx example:
```nginx
server {
listen 443 ssl;
server_name registry.example.com;
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
client_max_body_size 0; # unlimited for large image pushes
location / {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Internal / Lab: insecure registry
If you run NORA without TLS (e.g., on a private network), configure Docker to trust it:
```json
// /etc/docker/daemon.json
{
"insecure-registries": ["192.168.1.100:4000"]
}
```
Then restart Docker:
```bash
sudo systemctl restart docker
```
> **Note:** `insecure-registries` disables TLS verification for that host. Use only on trusted networks.
## FSTEC-Certified OS Builds
NORA provides dedicated Dockerfiles for Russian FSTEC-certified operating systems:
- `Dockerfile.astra` — Astra Linux SE (for government and defense sector)
- `Dockerfile.redos` — RED OS (for enterprise and public sector)
Both use `scratch` base with statically-linked binary for minimal attack surface. Comments in each file show how to switch to official distro base images if required by your security policy.
These builds are published as `-astra` and `-redos` tagged images in GitHub Releases.
## Performance
| Metric | NORA | Nexus | JFrog |
@@ -174,7 +262,7 @@ clear_env = false
**Created and maintained by [DevITWay](https://github.com/devitway)**
- Website: [devopsway.ru](https://devopsway.ru)
- Website: [getnora.dev](https://getnora.dev)
- Telegram: [@DevITWay](https://t.me/DevITWay)
- GitHub: [@devitway](https://github.com/devitway)
- Email: devitway@gmail.com

53
SECURITY.md Normal file
View File

@@ -0,0 +1,53 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 0.2.x | :white_check_mark: |
| < 0.2 | :x: |
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via:
1. **Email:** devitway@gmail.com
2. **Telegram:** [@DevITWay](https://t.me/DevITWay) (private message)
### What to Include
- Type of vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### Response Timeline
- **Initial response:** within 48 hours
- **Status update:** within 7 days
- **Fix timeline:** depends on severity
### Severity Levels
| Severity | Description | Response |
|----------|-------------|----------|
| Critical | Remote code execution, auth bypass | Immediate fix |
| High | Data exposure, privilege escalation | Fix within 7 days |
| Medium | Limited impact vulnerabilities | Fix in next release |
| Low | Minor issues | Scheduled fix |
## Security Best Practices
When deploying NORA:
1. **Enable authentication** - Set `NORA_AUTH_ENABLED=true`
2. **Use HTTPS** - Put NORA behind a reverse proxy with TLS
3. **Limit network access** - Use firewall rules
4. **Regular updates** - Keep NORA updated to latest version
5. **Secure credentials** - Use strong passwords, rotate tokens
## Acknowledgments
We appreciate responsible disclosure and will acknowledge security researchers who report valid vulnerabilities.

40
deny.toml Normal file
View File

@@ -0,0 +1,40 @@
# cargo-deny configuration
# https://embarkstudios.github.io/cargo-deny/
[advisories]
# Vulnerability database (RustSec)
db-urls = ["https://github.com/rustsec/advisory-db"]
ignore = [
"RUSTSEC-2025-0119", # number_prefix unmaintained via indicatif; no fix available. Review by 2026-06-15
]
[licenses]
# Allowed open-source licenses
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
"Unicode-3.0",
"CC0-1.0",
"OpenSSL",
"Zlib",
"CDLA-Permissive-2.0", # webpki-roots (CA certificates bundle)
"MPL-2.0",
]
[bans]
multiple-versions = "warn"
deny = [
{ name = "openssl-sys" },
{ name = "openssl" },
]
skip = []
[sources]
unknown-registry = "warn"
unknown-git = "warn"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

View File

@@ -1,7 +1,7 @@
services:
nora:
build: .
image: getnora/nora:latest
image: ghcr.io/getnora-io/nora:latest
ports:
- "4000:4000"
volumes:

98
install.sh Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env sh
# NORA installer — https://getnora.io/install.sh
# Usage: curl -fsSL https://getnora.io/install.sh | sh
set -e
REPO="getnora-io/nora"
BINARY="nora"
INSTALL_DIR="/usr/local/bin"
# ── Detect OS and architecture ──────────────────────────────────────────────
OS="$(uname -s)"
ARCH="$(uname -m)"
case "$OS" in
Linux) os="linux" ;;
Darwin) os="darwin" ;;
*)
echo "Unsupported OS: $OS"
echo "Please download manually: https://github.com/$REPO/releases/latest"
exit 1
;;
esac
case "$ARCH" in
x86_64 | amd64) arch="amd64" ;;
aarch64 | arm64) arch="arm64" ;;
*)
echo "Unsupported architecture: $ARCH"
echo "Please download manually: https://github.com/$REPO/releases/latest"
exit 1
;;
esac
ASSET="${BINARY}-${os}-${arch}"
# ── Get latest release version ──────────────────────────────────────────────
VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \
| grep '"tag_name"' \
| sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')"
if [ -z "$VERSION" ]; then
echo "Failed to get latest version"
exit 1
fi
echo "Installing NORA $VERSION ($os/$arch)..."
# ── Download binary and checksum ────────────────────────────────────────────
BASE_URL="https://github.com/$REPO/releases/download/$VERSION"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
echo "Downloading $ASSET..."
curl -fsSL "$BASE_URL/$ASSET" -o "$TMP_DIR/$BINARY"
curl -fsSL "$BASE_URL/$ASSET.sha256" -o "$TMP_DIR/$ASSET.sha256"
# ── Verify checksum ─────────────────────────────────────────────────────────
echo "Verifying checksum..."
EXPECTED="$(awk '{print $1}' "$TMP_DIR/$ASSET.sha256")"
ACTUAL="$(sha256sum "$TMP_DIR/$BINARY" | awk '{print $1}')"
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Checksum mismatch!"
echo " Expected: $EXPECTED"
echo " Actual: $ACTUAL"
exit 1
fi
echo "Checksum OK"
# ── Install ─────────────────────────────────────────────────────────────────
chmod +x "$TMP_DIR/$BINARY"
if [ -w "$INSTALL_DIR" ]; then
mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
elif command -v sudo >/dev/null 2>&1; then
sudo mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
else
# Fallback to ~/.local/bin
INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"
mv "$TMP_DIR/$BINARY" "$INSTALL_DIR/$BINARY"
echo "Installed to $INSTALL_DIR/$BINARY"
echo "Make sure $INSTALL_DIR is in your PATH"
fi
# ── Done ────────────────────────────────────────────────────────────────────
echo ""
echo "NORA $VERSION installed to $INSTALL_DIR/$BINARY"
echo ""
nora --version 2>/dev/null || true

BIN
logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

5
logo.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 72" width="300" height="72">
<text font-family="'SF Mono', 'Fira Code', 'Cascadia Code', monospace" font-weight="800" fill="#0f172a" letter-spacing="1">
<tspan x="8" y="58" font-size="52">N</tspan><tspan font-size="68" dy="-10" fill="#2563EB">O</tspan><tspan font-size="52" dy="10">RA</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -18,6 +18,6 @@ reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
clap = { version = "4", features = ["derive"] }
indicatif = "0.17"
indicatif = "0.18"
tar = "0.4"
flate2 = "1.0"
flate2 = "1.1"

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use clap::{Parser, Subcommand};
#[derive(Parser)]

View File

@@ -26,19 +26,19 @@ sha2.workspace = true
async-trait.workspace = true
hmac.workspace = true
hex.workspace = true
toml = "0.8"
toml = "1.0"
uuid = { version = "1", features = ["v4"] }
bcrypt = "0.17"
bcrypt = "0.19"
base64 = "0.22"
prometheus = "0.13"
prometheus = "0.14"
lazy_static = "1.5"
httpdate = "1"
utoipa = { version = "5", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9", features = ["axum", "reqwest"] }
clap = { version = "4", features = ["derive"] }
tar = "0.4"
flate2 = "1.0"
indicatif = "0.17"
flate2 = "1.1"
indicatif = "0.18"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
tower_governor = "0.8"

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use chrono::{DateTime, Utc};
use parking_lot::RwLock;
use serde::Serialize;

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Persistent audit log — append-only JSONL file
//!
//! Records who/when/what for every registry operation.
//! File: {storage_path}/audit.jsonl
use chrono::{DateTime, Utc};
use parking_lot::Mutex;
use serde::Serialize;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use tracing::{info, warn};
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub ts: DateTime<Utc>,
pub action: String,
pub actor: String,
pub artifact: String,
pub registry: String,
pub detail: String,
}
impl AuditEntry {
pub fn new(action: &str, actor: &str, artifact: &str, registry: &str, detail: &str) -> Self {
Self {
ts: Utc::now(),
action: action.to_string(),
actor: actor.to_string(),
artifact: artifact.to_string(),
registry: registry.to_string(),
detail: detail.to_string(),
}
}
}
pub struct AuditLog {
path: PathBuf,
writer: Mutex<Option<fs::File>>,
}
impl AuditLog {
pub fn new(storage_path: &str) -> Self {
let path = PathBuf::from(storage_path).join("audit.jsonl");
let writer = match OpenOptions::new().create(true).append(true).open(&path) {
Ok(f) => {
info!(path = %path.display(), "Audit log initialized");
Mutex::new(Some(f))
}
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to open audit log, auditing disabled");
Mutex::new(None)
}
};
Self { path, writer }
}
pub fn log(&self, entry: AuditEntry) {
if let Some(ref mut file) = *self.writer.lock() {
if let Ok(json) = serde_json::to_string(&entry) {
let _ = writeln!(file, "{}", json);
let _ = file.flush();
}
}
}
pub fn path(&self) -> &PathBuf {
&self.path
}
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use axum::{
body::Body,
extract::State,
@@ -10,6 +13,7 @@ use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use crate::tokens::Role;
use crate::AppState;
/// Htpasswd-based authentication
@@ -60,11 +64,17 @@ impl HtpasswdAuth {
fn is_public_path(path: &str) -> bool {
matches!(
path,
"/" | "/health" | "/ready" | "/metrics" | "/v2/" | "/v2"
"/" | "/health"
| "/ready"
| "/metrics"
| "/v2/"
| "/v2"
| "/api/tokens"
| "/api/tokens/list"
| "/api/tokens/revoke"
) || path.starts_with("/ui")
|| path.starts_with("/api-docs")
|| path.starts_with("/api/ui")
|| path.starts_with("/api/tokens")
}
/// Auth middleware - supports Basic auth and Bearer tokens
@@ -99,7 +109,18 @@ pub async fn auth_middleware(
if let Some(token) = auth_header.strip_prefix("Bearer ") {
if let Some(ref token_store) = state.tokens {
match token_store.verify_token(token) {
Ok(_user) => return next.run(request).await,
Ok((_user, role)) => {
let method = request.method().clone();
if (method == axum::http::Method::PUT
|| method == axum::http::Method::POST
|| method == axum::http::Method::DELETE
|| method == axum::http::Method::PATCH)
&& !role.can_write()
{
return (StatusCode::FORBIDDEN, "Read-only token").into_response();
}
return next.run(request).await;
}
Err(_) => return unauthorized_response("Invalid or expired token"),
}
} else {
@@ -166,6 +187,12 @@ pub struct CreateTokenRequest {
#[serde(default = "default_ttl")]
pub ttl_days: u64,
pub description: Option<String>,
#[serde(default = "default_role_str")]
pub role: String,
}
fn default_role_str() -> String {
"read".to_string()
}
fn default_ttl() -> u64 {
@@ -185,6 +212,7 @@ pub struct TokenListItem {
pub expires_at: u64,
pub last_used: Option<u64>,
pub description: Option<String>,
pub role: String,
}
#[derive(Serialize)]
@@ -218,7 +246,19 @@ async fn create_token(
}
};
match token_store.create_token(&req.username, req.ttl_days, req.description) {
let role = match req.role.as_str() {
"read" => Role::Read,
"write" => Role::Write,
"admin" => Role::Admin,
_ => {
return (
StatusCode::BAD_REQUEST,
"Invalid role. Use: read, write, admin",
)
.into_response()
}
};
match token_store.create_token(&req.username, req.ttl_days, req.description, role) {
Ok(token) => Json(CreateTokenResponse {
token,
expires_in_days: req.ttl_days,
@@ -262,6 +302,7 @@ async fn list_tokens(
expires_at: t.expires_at,
last_used: t.last_used,
description: t.description,
role: t.role.to_string(),
})
.collect();
@@ -401,8 +442,12 @@ mod tests {
assert!(is_public_path("/api/ui/stats"));
assert!(is_public_path("/api/tokens"));
assert!(is_public_path("/api/tokens/list"));
assert!(is_public_path("/api/tokens/revoke"));
// Protected paths
assert!(!is_public_path("/api/tokens/unknown"));
assert!(!is_public_path("/api/tokens/admin"));
assert!(!is_public_path("/api/tokens/extra/path"));
assert!(!is_public_path("/v2/myimage/blobs/sha256:abc"));
assert!(!is_public_path("/v2/library/nginx/manifests/latest"));
assert!(!is_public_path(

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Backup and restore functionality for Nora
//!
//! Exports all artifacts to a tar.gz file and restores from backups.

View File

@@ -1,9 +1,18 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
pub use crate::secrets::SecretsConfig;
/// Encode "user:pass" into a Basic Auth header value, e.g. "Basic dXNlcjpwYXNz".
pub fn basic_auth_header(credentials: &str) -> String {
format!("Basic {}", STANDARD.encode(credentials))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub server: ServerConfig,
@@ -33,6 +42,13 @@ pub struct ServerConfig {
/// Public URL for generating pull commands (e.g., "registry.example.com")
#[serde(default)]
pub public_url: Option<String>,
/// Maximum request body size in MB (default: 2048 = 2GB)
#[serde(default = "default_body_limit_mb")]
pub body_limit_mb: usize,
}
fn default_body_limit_mb() -> usize {
2048 // 2GB - enough for any Docker image
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
@@ -83,7 +99,7 @@ fn default_bucket() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MavenConfig {
#[serde(default)]
pub proxies: Vec<String>,
pub proxies: Vec<MavenProxyEntry>,
#[serde(default = "default_timeout")]
pub proxy_timeout: u64,
}
@@ -92,6 +108,8 @@ pub struct MavenConfig {
pub struct NpmConfig {
#[serde(default)]
pub proxy: Option<String>,
#[serde(default)]
pub proxy_auth: Option<String>, // "user:pass" for basic auth
#[serde(default = "default_timeout")]
pub proxy_timeout: u64,
}
@@ -100,6 +118,8 @@ pub struct NpmConfig {
pub struct PypiConfig {
#[serde(default)]
pub proxy: Option<String>,
#[serde(default)]
pub proxy_auth: Option<String>, // "user:pass" for basic auth
#[serde(default = "default_timeout")]
pub proxy_timeout: u64,
}
@@ -121,6 +141,37 @@ pub struct DockerUpstream {
pub auth: Option<String>, // "user:pass" for basic auth
}
/// Maven upstream proxy configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MavenProxyEntry {
Simple(String),
Full(MavenProxy),
}
/// Maven upstream proxy with optional auth
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MavenProxy {
pub url: String,
#[serde(default)]
pub auth: Option<String>, // "user:pass" for basic auth
}
impl MavenProxyEntry {
pub fn url(&self) -> &str {
match self {
MavenProxyEntry::Simple(s) => s,
MavenProxyEntry::Full(p) => &p.url,
}
}
pub fn auth(&self) -> Option<&str> {
match self {
MavenProxyEntry::Simple(_) => None,
MavenProxyEntry::Full(p) => p.auth.as_deref(),
}
}
}
/// Raw repository configuration for simple file storage
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawConfig {
@@ -167,7 +218,9 @@ fn default_timeout() -> u64 {
impl Default for MavenConfig {
fn default() -> Self {
Self {
proxies: vec!["https://repo1.maven.org/maven2".to_string()],
proxies: vec![MavenProxyEntry::Simple(
"https://repo1.maven.org/maven2".to_string(),
)],
proxy_timeout: 30,
}
}
@@ -177,6 +230,7 @@ impl Default for NpmConfig {
fn default() -> Self {
Self {
proxy: Some("https://registry.npmjs.org".to_string()),
proxy_auth: None,
proxy_timeout: 30,
}
}
@@ -186,6 +240,7 @@ impl Default for PypiConfig {
fn default() -> Self {
Self {
proxy: Some("https://pypi.org/simple/".to_string()),
proxy_auth: None,
proxy_timeout: 30,
}
}
@@ -246,6 +301,8 @@ impl Default for AuthConfig {
/// - `NORA_RATE_LIMIT_GENERAL_BURST` - General burst size
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
#[serde(default = "default_rate_limit_enabled")]
pub enabled: bool,
#[serde(default = "default_auth_rps")]
pub auth_rps: u64,
#[serde(default = "default_auth_burst")]
@@ -260,6 +317,9 @@ pub struct RateLimitConfig {
pub general_burst: u32,
}
fn default_rate_limit_enabled() -> bool {
true
}
fn default_auth_rps() -> u64 {
1
}
@@ -282,6 +342,7 @@ fn default_general_burst() -> u32 {
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
enabled: default_rate_limit_enabled(),
auth_rps: default_auth_rps(),
auth_burst: default_auth_burst(),
upload_rps: default_upload_rps(),
@@ -293,6 +354,37 @@ impl Default for RateLimitConfig {
}
impl Config {
/// Warn if credentials are configured via config.toml (not env vars)
pub fn warn_plaintext_credentials(&self) {
// Docker upstreams
for (i, upstream) in self.docker.upstreams.iter().enumerate() {
if upstream.auth.is_some() && std::env::var("NORA_DOCKER_UPSTREAMS").is_err() {
tracing::warn!(
upstream_index = i,
url = %upstream.url,
"Docker upstream credentials in config.toml are plaintext — consider NORA_DOCKER_UPSTREAMS env var"
);
}
}
// Maven proxies
for proxy in &self.maven.proxies {
if proxy.auth().is_some() && std::env::var("NORA_MAVEN_PROXIES").is_err() {
tracing::warn!(
url = %proxy.url(),
"Maven proxy credentials in config.toml are plaintext — consider NORA_MAVEN_PROXIES env var"
);
}
}
// npm
if self.npm.proxy_auth.is_some() && std::env::var("NORA_NPM_PROXY_AUTH").is_err() {
tracing::warn!("npm proxy credentials in config.toml are plaintext — consider NORA_NPM_PROXY_AUTH env var");
}
// PyPI
if self.pypi.proxy_auth.is_some() && std::env::var("NORA_PYPI_PROXY_AUTH").is_err() {
tracing::warn!("PyPI proxy credentials in config.toml are plaintext — consider NORA_PYPI_PROXY_AUTH env var");
}
}
/// Load configuration with priority: ENV > config.toml > defaults
pub fn load() -> Self {
// 1. Start with defaults
@@ -321,6 +413,11 @@ impl Config {
if let Ok(val) = env::var("NORA_PUBLIC_URL") {
self.server.public_url = if val.is_empty() { None } else { Some(val) };
}
if let Ok(val) = env::var("NORA_BODY_LIMIT_MB") {
if let Ok(mb) = val.parse() {
self.server.body_limit_mb = mb;
}
}
// Storage config
if let Ok(val) = env::var("NORA_STORAGE_MODE") {
@@ -356,9 +453,23 @@ impl Config {
self.auth.htpasswd_file = val;
}
// Maven config
// Maven config — supports "url1,url2" or "url1|auth1,url2|auth2"
if let Ok(val) = env::var("NORA_MAVEN_PROXIES") {
self.maven.proxies = val.split(',').map(|s| s.trim().to_string()).collect();
self.maven.proxies = val
.split(',')
.filter(|s| !s.is_empty())
.map(|s| {
let parts: Vec<&str> = s.trim().splitn(2, '|').collect();
if parts.len() > 1 {
MavenProxyEntry::Full(MavenProxy {
url: parts[0].to_string(),
auth: Some(parts[1].to_string()),
})
} else {
MavenProxyEntry::Simple(parts[0].to_string())
}
})
.collect();
}
if let Ok(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() {
@@ -376,6 +487,11 @@ impl Config {
}
}
// npm proxy auth
if let Ok(val) = env::var("NORA_NPM_PROXY_AUTH") {
self.npm.proxy_auth = if val.is_empty() { None } else { Some(val) };
}
// PyPI config
if let Ok(val) = env::var("NORA_PYPI_PROXY") {
self.pypi.proxy = if val.is_empty() { None } else { Some(val) };
@@ -386,6 +502,11 @@ impl Config {
}
}
// PyPI proxy auth
if let Ok(val) = env::var("NORA_PYPI_PROXY_AUTH") {
self.pypi.proxy_auth = if val.is_empty() { None } else { Some(val) };
}
// Docker config
if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() {
@@ -423,6 +544,9 @@ impl Config {
}
// Rate limit config
if let Ok(val) = env::var("NORA_RATE_LIMIT_ENABLED") {
self.rate_limit.enabled = val.to_lowercase() == "true" || val == "1";
}
if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_RPS") {
if let Ok(v) = val.parse::<u64>() {
self.rate_limit.auth_rps = v;
@@ -471,6 +595,7 @@ impl Default for Config {
host: String::from("127.0.0.1"),
port: 4000,
public_url: None,
body_limit_mb: 2048,
},
storage: StorageConfig {
mode: StorageMode::Local,

View File

@@ -1,5 +1,29 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use tracing::{info, warn};
/// Serializable snapshot of metrics for persistence
#[derive(Serialize, Deserialize, Default)]
struct MetricsSnapshot {
downloads: u64,
uploads: u64,
cache_hits: u64,
cache_misses: u64,
docker_downloads: u64,
docker_uploads: u64,
npm_downloads: u64,
maven_downloads: u64,
maven_uploads: u64,
cargo_downloads: u64,
pypi_downloads: u64,
raw_downloads: u64,
raw_uploads: u64,
}
/// Dashboard metrics for tracking registry activity
/// Uses atomic counters for thread-safe access without locks
@@ -22,6 +46,9 @@ pub struct DashboardMetrics {
pub raw_uploads: AtomicU64,
pub start_time: Instant,
/// Path to metrics.json for persistence
persist_path: Option<PathBuf>,
}
impl DashboardMetrics {
@@ -41,6 +68,75 @@ impl DashboardMetrics {
raw_downloads: AtomicU64::new(0),
raw_uploads: AtomicU64::new(0),
start_time: Instant::now(),
persist_path: None,
}
}
/// Create metrics with persistence — loads existing data from metrics.json
pub fn with_persistence(storage_path: &str) -> Self {
let path = Path::new(storage_path).join("metrics.json");
let mut metrics = Self::new();
metrics.persist_path = Some(path.clone());
// Load existing metrics if file exists
if path.exists() {
match std::fs::read_to_string(&path) {
Ok(data) => match serde_json::from_str::<MetricsSnapshot>(&data) {
Ok(snap) => {
metrics.downloads = AtomicU64::new(snap.downloads);
metrics.uploads = AtomicU64::new(snap.uploads);
metrics.cache_hits = AtomicU64::new(snap.cache_hits);
metrics.cache_misses = AtomicU64::new(snap.cache_misses);
metrics.docker_downloads = AtomicU64::new(snap.docker_downloads);
metrics.docker_uploads = AtomicU64::new(snap.docker_uploads);
metrics.npm_downloads = AtomicU64::new(snap.npm_downloads);
metrics.maven_downloads = AtomicU64::new(snap.maven_downloads);
metrics.maven_uploads = AtomicU64::new(snap.maven_uploads);
metrics.cargo_downloads = AtomicU64::new(snap.cargo_downloads);
metrics.pypi_downloads = AtomicU64::new(snap.pypi_downloads);
metrics.raw_downloads = AtomicU64::new(snap.raw_downloads);
metrics.raw_uploads = AtomicU64::new(snap.raw_uploads);
info!(
downloads = snap.downloads,
uploads = snap.uploads,
"Loaded persisted metrics"
);
}
Err(e) => warn!("Failed to parse metrics.json: {}", e),
},
Err(e) => warn!("Failed to read metrics.json: {}", e),
}
}
metrics
}
/// Save current metrics to disk
pub fn save(&self) {
let Some(path) = &self.persist_path else {
return;
};
let snap = MetricsSnapshot {
downloads: self.downloads.load(Ordering::Relaxed),
uploads: self.uploads.load(Ordering::Relaxed),
cache_hits: self.cache_hits.load(Ordering::Relaxed),
cache_misses: self.cache_misses.load(Ordering::Relaxed),
docker_downloads: self.docker_downloads.load(Ordering::Relaxed),
docker_uploads: self.docker_uploads.load(Ordering::Relaxed),
npm_downloads: self.npm_downloads.load(Ordering::Relaxed),
maven_downloads: self.maven_downloads.load(Ordering::Relaxed),
maven_uploads: self.maven_uploads.load(Ordering::Relaxed),
cargo_downloads: self.cargo_downloads.load(Ordering::Relaxed),
pypi_downloads: self.pypi_downloads.load(Ordering::Relaxed),
raw_downloads: self.raw_downloads.load(Ordering::Relaxed),
raw_uploads: self.raw_uploads.load(Ordering::Relaxed),
};
// Atomic write: write to tmp then rename
let tmp = path.with_extension("json.tmp");
if let Ok(data) = serde_json::to_string_pretty(&snap) {
if std::fs::write(&tmp, &data).is_ok() {
let _ = std::fs::rename(&tmp, path);
}
}
}

View File

@@ -1,4 +1,6 @@
#![allow(dead_code)]
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Application error handling with HTTP response conversion
//!
//! Provides a unified error type that can be converted to HTTP responses
@@ -15,6 +17,7 @@ use thiserror::Error;
use crate::storage::StorageError;
use crate::validation::ValidationError;
#[allow(dead_code)] // Wiring into handlers planned for v0.3
/// Application-level errors with HTTP response conversion
#[derive(Debug, Error)]
pub enum AppError {
@@ -37,6 +40,7 @@ pub enum AppError {
Validation(#[from] ValidationError),
}
#[allow(dead_code)]
/// JSON error response body
#[derive(Serialize)]
struct ErrorResponse {
@@ -71,6 +75,7 @@ impl IntoResponse for AppError {
}
}
#[allow(dead_code)]
impl AppError {
/// Create a not found error
pub fn not_found(msg: impl Into<String>) -> Self {

121
nora-registry/src/gc.rs Normal file
View File

@@ -0,0 +1,121 @@
//! Garbage Collection for orphaned blobs
//!
//! Mark-and-sweep approach:
//! 1. List all blobs across registries
//! 2. Parse all manifests to find referenced blobs
//! 3. Blobs not referenced by any manifest = orphans
//! 4. Delete orphans (with --dry-run support)
use std::collections::HashSet;
use tracing::info;
use crate::storage::Storage;
pub struct GcResult {
pub total_blobs: usize,
pub referenced_blobs: usize,
pub orphaned_blobs: usize,
pub deleted_blobs: usize,
pub orphan_keys: Vec<String>,
}
pub async fn run_gc(storage: &Storage, dry_run: bool) -> GcResult {
info!("Starting garbage collection (dry_run={})", dry_run);
// 1. Collect all blob keys
let all_blobs = collect_all_blobs(storage).await;
info!("Found {} total blobs", all_blobs.len());
// 2. Collect all referenced digests from manifests
let referenced = collect_referenced_digests(storage).await;
info!(
"Found {} referenced digests from manifests",
referenced.len()
);
// 3. Find orphans
let mut orphan_keys: Vec<String> = Vec::new();
for key in &all_blobs {
if let Some(digest) = key.rsplit('/').next() {
if !referenced.contains(digest) {
orphan_keys.push(key.clone());
}
}
}
info!("Found {} orphaned blobs", orphan_keys.len());
let mut deleted = 0;
if !dry_run {
for key in &orphan_keys {
if storage.delete(key).await.is_ok() {
deleted += 1;
info!("Deleted: {}", key);
}
}
info!("Deleted {} orphaned blobs", deleted);
} else {
for key in &orphan_keys {
info!("[dry-run] Would delete: {}", key);
}
}
GcResult {
total_blobs: all_blobs.len(),
referenced_blobs: referenced.len(),
orphaned_blobs: orphan_keys.len(),
deleted_blobs: deleted,
orphan_keys,
}
}
async fn collect_all_blobs(storage: &Storage) -> Vec<String> {
let mut blobs = Vec::new();
let docker_blobs = storage.list("docker/").await;
for key in docker_blobs {
if key.contains("/blobs/") {
blobs.push(key);
}
}
blobs
}
async fn collect_referenced_digests(storage: &Storage) -> HashSet<String> {
let mut referenced = HashSet::new();
let all_keys = storage.list("docker/").await;
for key in &all_keys {
if !key.contains("/manifests/") || !key.ends_with(".json") || key.ends_with(".meta.json") {
continue;
}
if let Ok(data) = storage.get(key).await {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&data) {
if let Some(config) = json.get("config") {
if let Some(digest) = config.get("digest").and_then(|v| v.as_str()) {
referenced.insert(digest.to_string());
}
}
if let Some(layers) = json.get("layers").and_then(|v| v.as_array()) {
for layer in layers {
if let Some(digest) = layer.get("digest").and_then(|v| v.as_str()) {
referenced.insert(digest.to_string());
}
}
}
if let Some(manifests) = json.get("manifests").and_then(|v| v.as_array()) {
for m in manifests {
if let Some(digest) = m.get("digest").and_then(|v| v.as_str()) {
referenced.insert(digest.to_string());
}
}
}
}
}
}
referenced
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
use serde::Serialize;
use std::sync::Arc;

View File

@@ -1,15 +1,21 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod activity_log;
mod audit;
mod auth;
mod backup;
mod config;
mod dashboard_metrics;
mod error;
mod gc;
mod health;
mod metrics;
mod migrate;
mod openapi;
mod rate_limit;
mod registry;
mod repo_index;
mod request_id;
mod secrets;
mod storage;
@@ -27,9 +33,11 @@ use tracing::{error, info, warn};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use activity_log::ActivityLog;
use audit::AuditLog;
use auth::HtpasswdAuth;
use config::{Config, StorageMode};
use dashboard_metrics::DashboardMetrics;
use repo_index::RepoIndex;
pub use storage::Storage;
use tokens::TokenStore;
@@ -56,6 +64,12 @@ enum Commands {
#[arg(short, long)]
input: PathBuf,
},
/// Garbage collect orphaned blobs
Gc {
/// Dry run - show what would be deleted without deleting
#[arg(long, default_value = "false")]
dry_run: bool,
},
/// Migrate artifacts between storage backends
Migrate {
/// Source storage: local or s3
@@ -78,7 +92,10 @@ pub struct AppState {
pub tokens: Option<TokenStore>,
pub metrics: DashboardMetrics,
pub activity: ActivityLog,
pub audit: AuditLog,
pub docker_auth: registry::DockerAuth,
pub repo_index: RepoIndex,
pub http_client: reqwest::Client,
}
#[tokio::main]
@@ -136,6 +153,17 @@ async fn main() {
std::process::exit(1);
}
}
Some(Commands::Gc { dry_run }) => {
let result = gc::run_gc(&storage, dry_run).await;
println!("GC Summary:");
println!(" Total blobs: {}", result.total_blobs);
println!(" Referenced: {}", result.referenced_blobs);
println!(" Orphaned: {}", result.orphaned_blobs);
println!(" Deleted: {}", result.deleted_blobs);
if dry_run && !result.orphan_keys.is_empty() {
println!("\nRun without --dry-run to delete orphaned blobs.");
}
}
Some(Commands::Migrate { from, to, dry_run }) => {
let source = match from.as_str() {
"local" => Storage::new_local(&config.storage.path),
@@ -203,6 +231,7 @@ async fn run_server(config: Config, storage: Storage) {
// Log rate limiting configuration
info!(
enabled = config.rate_limit.enabled,
auth_rps = config.rate_limit.auth_rps,
auth_burst = config.rate_limit.auth_burst,
upload_rps = config.rate_limit.upload_rps,
@@ -257,37 +286,25 @@ async fn run_server(config: Config, storage: Storage) {
None
};
// Create rate limiters before moving config to state
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
let storage_path = config.storage.path.clone();
let rate_limit_enabled = config.rate_limit.enabled;
// Warn about plaintext credentials in config.toml
config.warn_plaintext_credentials();
// Initialize Docker auth with proxy timeout
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
let state = Arc::new(AppState {
storage,
config,
start_time,
auth,
tokens,
metrics: DashboardMetrics::new(),
activity: ActivityLog::new(50),
docker_auth,
});
let http_client = reqwest::Client::new();
// Token routes with strict rate limiting (brute-force protection)
let auth_routes = auth::token_routes().layer(auth_limiter);
// Registry routes with upload rate limiting
// Registry routes (shared between rate-limited and non-limited paths)
let registry_routes = Router::new()
.merge(registry::docker_routes())
.merge(registry::maven_routes())
.merge(registry::npm_routes())
.merge(registry::cargo_routes())
.merge(registry::pypi_routes())
.merge(registry::raw_routes())
.layer(upload_limiter);
.merge(registry::raw_routes());
// Routes WITHOUT rate limiting (health, metrics, UI)
let public_routes = Router::new()
@@ -296,16 +313,46 @@ async fn run_server(config: Config, storage: Storage) {
.merge(ui::routes())
.merge(openapi::routes());
// Routes WITH rate limiting
let rate_limited_routes = Router::new()
.merge(auth_routes)
.merge(registry_routes)
.layer(general_limiter);
let app_routes = if rate_limit_enabled {
// Create rate limiters before moving config to state
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
let auth_routes = auth::token_routes().layer(auth_limiter);
let limited_registry = registry_routes.layer(upload_limiter);
Router::new()
.merge(auth_routes)
.merge(limited_registry)
.layer(general_limiter)
} else {
info!("Rate limiting DISABLED");
Router::new()
.merge(auth::token_routes())
.merge(registry_routes)
};
let state = Arc::new(AppState {
storage,
config,
start_time,
auth,
tokens,
metrics: DashboardMetrics::with_persistence(&storage_path),
activity: ActivityLog::new(50),
audit: AuditLog::new(&storage_path),
docker_auth,
repo_index: RepoIndex::new(),
http_client,
});
let app = Router::new()
.merge(public_routes)
.merge(rate_limited_routes)
.layer(DefaultBodyLimit::max(100 * 1024 * 1024)) // 100MB default body limit
.merge(app_routes)
.layer(DefaultBodyLimit::max(
state.config.server.body_limit_mb * 1024 * 1024,
))
.layer(middleware::from_fn(request_id::request_id_middleware))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(middleware::from_fn_with_state(
@@ -324,6 +371,7 @@ async fn run_server(config: Config, storage: Storage) {
version = env!("CARGO_PKG_VERSION"),
storage = state.storage.backend_name(),
auth_enabled = state.auth.is_some(),
body_limit_mb = state.config.server.body_limit_mb,
"Nora started"
);
@@ -342,6 +390,16 @@ async fn run_server(config: Config, storage: Storage) {
"Available endpoints"
);
// Background task: persist metrics every 30 seconds
let metrics_state = state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
loop {
interval.tick().await;
metrics_state.metrics.save();
}
});
// Graceful shutdown on SIGTERM/SIGINT
axum::serve(
listener,
@@ -351,6 +409,9 @@ async fn run_server(config: Config, storage: Storage) {
.await
.expect("Server error");
// Save metrics on shutdown
state.metrics.save();
info!(
uptime_seconds = state.start_time.elapsed().as_secs(),
"Nora shutdown complete"

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use axum::{
body::Body,
extract::MatchedPath,

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Migration between storage backends
//!
//! Supports migrating artifacts from one storage backend to another

View File

@@ -1,8 +1,11 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! OpenAPI documentation and Swagger UI
//!
//! Functions in this module are stubs used only for generating OpenAPI documentation.
#![allow(dead_code)]
#![allow(dead_code)] // utoipa doc stubs — not called at runtime, used by derive macros
use axum::Router;
use std::sync::Arc;

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Rate limiting configuration and middleware
//!
//! Provides rate limiting to protect against:
@@ -7,6 +10,7 @@
use crate::config::RateLimitConfig;
use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
/// Create rate limiter layer for auth endpoints (strict protection against brute-force)
pub fn auth_rate_limiter(
@@ -32,11 +36,12 @@ pub fn auth_rate_limiter(
pub fn upload_rate_limiter(
config: &RateLimitConfig,
) -> tower_governor::GovernorLayer<
tower_governor::key_extractor::PeerIpKeyExtractor,
SmartIpKeyExtractor,
governor::middleware::StateInformationMiddleware,
axum::body::Body,
> {
let gov_config = GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(config.upload_rps)
.burst_size(config.upload_burst)
.use_headers()
@@ -50,11 +55,12 @@ pub fn upload_rate_limiter(
pub fn general_rate_limiter(
config: &RateLimitConfig,
) -> tower_governor::GovernorLayer<
tower_governor::key_extractor::PeerIpKeyExtractor,
SmartIpKeyExtractor,
governor::middleware::StateInformationMiddleware,
axum::body::Body,
> {
let gov_config = GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(config.general_rps)
.burst_size(config.general_burst)
.use_headers()
@@ -99,6 +105,7 @@ mod tests {
#[test]
fn test_custom_config() {
let config = RateLimitConfig {
enabled: true,
auth_rps: 10,
auth_burst: 20,
upload_rps: 500,

View File

@@ -1,4 +1,8 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::AppState;
use axum::{
extract::{Path, State},
@@ -47,6 +51,9 @@ async fn download(
"cargo",
"LOCAL",
));
state
.audit
.log(AuditEntry::new("pull", "api", "", "cargo", ""));
(StatusCode::OK, data).into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),

View File

@@ -1,4 +1,9 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::registry::docker_auth::DockerAuth;
use crate::storage::Storage;
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
@@ -8,7 +13,7 @@ use axum::{
extract::{Path, State},
http::{header, HeaderName, StatusCode},
response::{IntoResponse, Response},
routing::{get, head, patch, put},
routing::{delete, get, head, patch, put},
Json, Router,
};
use parking_lot::RwLock;
@@ -61,6 +66,8 @@ pub fn routes() -> Router<Arc<AppState>> {
)
.route("/v2/{name}/manifests/{reference}", get(get_manifest))
.route("/v2/{name}/manifests/{reference}", put(put_manifest))
.route("/v2/{name}/manifests/{reference}", delete(delete_manifest))
.route("/v2/{name}/blobs/{digest}", delete(delete_blob))
.route("/v2/{name}/tags/list", get(list_tags))
// Two-segment name routes (e.g., /v2/library/alpine/...)
.route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns))
@@ -81,6 +88,11 @@ pub fn routes() -> Router<Arc<AppState>> {
"/v2/{ns}/{name}/manifests/{reference}",
put(put_manifest_ns),
)
.route(
"/v2/{ns}/{name}/manifests/{reference}",
delete(delete_manifest_ns),
)
.route("/v2/{ns}/{name}/blobs/{digest}", delete(delete_blob_ns))
.route("/v2/{ns}/{name}/tags/list", get(list_tags_ns))
}
@@ -164,11 +176,13 @@ async fn download_blob(
// Try upstream proxies
for upstream in &state.config.docker.upstreams {
if let Ok(data) = fetch_blob_from_upstream(
&state.http_client,
&upstream.url,
&name,
&digest,
&state.docker_auth,
state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
)
.await
{
@@ -189,6 +203,8 @@ async fn download_blob(
let _ = storage.put(&key_clone, &data_clone).await;
});
state.repo_index.invalidate("docker");
return (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/octet-stream")],
@@ -299,8 +315,19 @@ async fn upload_blob(
"docker",
"LOCAL",
));
state.repo_index.invalidate("docker");
let location = format!("/v2/{}/blobs/{}", name, digest);
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
(
StatusCode::CREATED,
[
(header::LOCATION, location),
(
HeaderName::from_static("docker-content-digest"),
digest.to_string(),
),
],
)
.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
@@ -361,11 +388,13 @@ async fn get_manifest(
for upstream in &state.config.docker.upstreams {
tracing::debug!(upstream_url = %upstream.url, "Trying upstream");
if let Ok((data, content_type)) = fetch_manifest_from_upstream(
&state.http_client,
&upstream.url,
&name,
&reference,
&state.docker_auth,
state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
)
.await
{
@@ -410,6 +439,8 @@ async fn get_manifest(
}
});
state.repo_index.invalidate("docker");
return (
StatusCode::OK,
[
@@ -471,6 +502,14 @@ async fn put_manifest(
"docker",
"LOCAL",
));
state.audit.log(AuditEntry::new(
"push",
"api",
&format!("{}:{}", name, reference),
"docker",
"manifest",
));
state.repo_index.invalidate("docker");
let location = format!("/v2/{}/manifests/{}", name, reference);
(
@@ -501,6 +540,109 @@ async fn list_tags(State(state): State<Arc<AppState>>, Path(name): Path<String>)
(StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response()
}
// ============================================================================
// Delete handlers (Docker Registry V2 spec)
// ============================================================================
async fn delete_manifest(
State(state): State<Arc<AppState>>,
Path((name, reference)): Path<(String, String)>,
) -> Response {
if let Err(e) = validate_docker_name(&name) {
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
}
if let Err(e) = validate_docker_reference(&reference) {
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
}
let key = format!("docker/{}/manifests/{}.json", name, reference);
// If reference is a tag, also delete digest-keyed copy
let is_tag = !reference.starts_with("sha256:");
if is_tag {
if let Ok(data) = state.storage.get(&key).await {
use sha2::Digest;
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data));
let digest_key = format!("docker/{}/manifests/{}.json", name, digest);
let _ = state.storage.delete(&digest_key).await;
let digest_meta = format!("docker/{}/manifests/{}.meta.json", name, digest);
let _ = state.storage.delete(&digest_meta).await;
}
}
// Delete manifest
match state.storage.delete(&key).await {
Ok(()) => {
// Delete associated metadata
let meta_key = format!("docker/{}/manifests/{}.meta.json", name, reference);
let _ = state.storage.delete(&meta_key).await;
state.audit.log(AuditEntry::new(
"delete",
"api",
&format!("{}:{}", name, reference),
"docker",
"manifest",
));
state.repo_index.invalidate("docker");
tracing::info!(name = %name, reference = %reference, "Docker manifest deleted");
StatusCode::ACCEPTED.into_response()
}
Err(crate::storage::StorageError::NotFound) => (
StatusCode::NOT_FOUND,
Json(json!({
"errors": [{
"code": "MANIFEST_UNKNOWN",
"message": "manifest unknown",
"detail": { "name": name, "reference": reference }
}]
})),
)
.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn delete_blob(
State(state): State<Arc<AppState>>,
Path((name, digest)): Path<(String, String)>,
) -> Response {
if let Err(e) = validate_docker_name(&name) {
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
}
if let Err(e) = validate_digest(&digest) {
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
}
let key = format!("docker/{}/blobs/{}", name, digest);
match state.storage.delete(&key).await {
Ok(()) => {
state.audit.log(AuditEntry::new(
"delete",
"api",
&format!("{}@{}", name, &digest[..19.min(digest.len())]),
"docker",
"blob",
));
state.repo_index.invalidate("docker");
tracing::info!(name = %name, digest = %digest, "Docker blob deleted");
StatusCode::ACCEPTED.into_response()
}
Err(crate::storage::StorageError::NotFound) => (
StatusCode::NOT_FOUND,
Json(json!({
"errors": [{
"code": "BLOB_UNKNOWN",
"message": "blob unknown to registry",
"detail": { "digest": digest }
}]
})),
)
.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
// ============================================================================
// Namespace handlers (for two-segment names like library/alpine)
// These combine ns/name into a single name and delegate to the main handlers
@@ -570,13 +712,31 @@ async fn list_tags_ns(
list_tags(state, Path(full_name)).await
}
async fn delete_manifest_ns(
state: State<Arc<AppState>>,
Path((ns, name, reference)): Path<(String, String, String)>,
) -> Response {
let full_name = format!("{}/{}", ns, name);
delete_manifest(state, Path((full_name, reference))).await
}
async fn delete_blob_ns(
state: State<Arc<AppState>>,
Path((ns, name, digest)): Path<(String, String, String)>,
) -> Response {
let full_name = format!("{}/{}", ns, name);
delete_blob(state, Path((full_name, digest))).await
}
/// Fetch a blob from an upstream Docker registry
async fn fetch_blob_from_upstream(
client: &reqwest::Client,
upstream_url: &str,
name: &str,
digest: &str,
docker_auth: &DockerAuth,
timeout: u64,
basic_auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let url = format!(
"{}/v2/{}/blobs/{}",
@@ -585,13 +745,12 @@ async fn fetch_blob_from_upstream(
digest
);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.build()
.map_err(|_| ())?;
// First try without auth
let response = client.get(&url).send().await.map_err(|_| ())?;
// First try — with basic auth if configured
let mut request = client.get(&url).timeout(Duration::from_secs(timeout));
if let Some(credentials) = basic_auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
let response = request.send().await.map_err(|_| ())?;
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Get Www-Authenticate header and fetch token
@@ -602,7 +761,7 @@ async fn fetch_blob_from_upstream(
.map(String::from);
if let Some(token) = docker_auth
.get_token(upstream_url, name, www_auth.as_deref())
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
.await
{
client
@@ -628,11 +787,13 @@ async fn fetch_blob_from_upstream(
/// Fetch a manifest from an upstream Docker registry
/// Returns (manifest_bytes, content_type)
async fn fetch_manifest_from_upstream(
client: &reqwest::Client,
upstream_url: &str,
name: &str,
reference: &str,
docker_auth: &DockerAuth,
timeout: u64,
basic_auth: Option<&str>,
) -> Result<(Vec<u8>, String), ()> {
let url = format!(
"{}/v2/{}/manifests/{}",
@@ -643,28 +804,23 @@ async fn fetch_manifest_from_upstream(
tracing::debug!(url = %url, "Fetching manifest from upstream");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.build()
.map_err(|e| {
tracing::error!(error = %e, "Failed to build HTTP client");
})?;
// Request with Accept header for manifest types
let accept_header = "application/vnd.docker.distribution.manifest.v2+json, \
application/vnd.docker.distribution.manifest.list.v2+json, \
application/vnd.oci.image.manifest.v1+json, \
application/vnd.oci.image.index.v1+json";
// First try without auth
let response = client
// First try with basic auth if configured
let mut request = client
.get(&url)
.header("Accept", accept_header)
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
})?;
.timeout(Duration::from_secs(timeout))
.header("Accept", accept_header);
if let Some(credentials) = basic_auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
let response = request.send().await.map_err(|e| {
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
})?;
tracing::debug!(status = %response.status(), "Initial upstream response");
@@ -679,7 +835,7 @@ async fn fetch_manifest_from_upstream(
tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token");
if let Some(token) = docker_auth
.get_token(upstream_url, name, www_auth.as_deref())
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
.await
{
tracing::debug!("Token acquired, retrying with auth");
@@ -732,8 +888,16 @@ fn detect_manifest_media_type(data: &[u8]) -> String {
if schema_version == 1 {
return "application/vnd.docker.distribution.manifest.v1+json".to_string();
}
// schemaVersion 2 without mediaType is likely docker manifest v2
if json.get("config").is_some() {
// schemaVersion 2 without mediaType - check config.mediaType to distinguish OCI vs Docker
if let Some(config) = json.get("config") {
if let Some(config_mt) = config.get("mediaType").and_then(|v| v.as_str()) {
if config_mt.starts_with("application/vnd.docker.") {
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
}
// OCI or Helm or any non-docker config mediaType
return "application/vnd.oci.image.manifest.v1+json".to_string();
}
// No config.mediaType - assume docker v2
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
}
// If it has "manifests" array, it's an index/list

View File

@@ -1,3 +1,7 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::config::basic_auth_header;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::time::{Duration, Instant};
@@ -33,6 +37,7 @@ impl DockerAuth {
registry_url: &str,
name: &str,
www_authenticate: Option<&str>,
basic_auth: Option<&str>,
) -> Option<String> {
let cache_key = format!("{}:{}", registry_url, name);
@@ -48,7 +53,7 @@ impl DockerAuth {
// Need to fetch a new token
let www_auth = www_authenticate?;
let token = self.fetch_token(www_auth, name).await?;
let token = self.fetch_token(www_auth, name, basic_auth).await?;
// Cache the token (default 5 minute expiry)
{
@@ -67,7 +72,12 @@ impl DockerAuth {
/// Parse Www-Authenticate header and fetch token from auth server
/// Format: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"
async fn fetch_token(&self, www_authenticate: &str, name: &str) -> Option<String> {
async fn fetch_token(
&self,
www_authenticate: &str,
name: &str,
basic_auth: Option<&str>,
) -> Option<String> {
let params = parse_www_authenticate(www_authenticate)?;
let realm = params.get("realm")?;
@@ -79,7 +89,13 @@ impl DockerAuth {
tracing::debug!(url = %url, "Fetching auth token");
let response = self.client.get(&url).send().await.ok()?;
let mut request = self.client.get(&url);
if let Some(credentials) = basic_auth {
request = request.header("Authorization", basic_auth_header(credentials));
tracing::debug!("Using basic auth for token request");
}
let response = request.send().await.ok()?;
if !response.status().is_success() {
tracing::warn!(status = %response.status(), "Token request failed");
@@ -94,44 +110,6 @@ impl DockerAuth {
.and_then(|v| v.as_str())
.map(String::from)
}
/// Make an authenticated request to an upstream registry
pub async fn fetch_with_auth(
&self,
url: &str,
registry_url: &str,
name: &str,
) -> Result<reqwest::Response, ()> {
// First try without auth
let response = self.client.get(url).send().await.map_err(|_| ())?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Extract Www-Authenticate header
let www_auth = response
.headers()
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.map(String::from);
// Get token and retry
if let Some(token) = self
.get_token(registry_url, name, www_auth.as_deref())
.await
{
return self
.client
.get(url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|_| ());
}
return Err(());
}
Ok(response)
}
}
impl Default for DockerAuth {

View File

@@ -1,4 +1,9 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState;
use axum::{
body::Bytes,
@@ -20,7 +25,6 @@ pub fn routes() -> Router<Arc<AppState>> {
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
let key = format!("maven/{}", path);
// Extract artifact name for logging (last 2-3 path components)
let artifact_name = path
.split('/')
.rev()
@@ -31,7 +35,6 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
.collect::<Vec<_>>()
.join("/");
// Try local storage first
if let Ok(data) = state.storage.get(&key).await {
state.metrics.record_download("maven");
state.metrics.record_cache_hit();
@@ -41,14 +44,23 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
"maven",
"CACHE",
));
state
.audit
.log(AuditEntry::new("cache_hit", "api", "", "maven", ""));
return with_content_type(&path, data).into_response();
}
// Try proxy servers
for proxy_url in &state.config.maven.proxies {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
for proxy in &state.config.maven.proxies {
let url = format!("{}/{}", proxy.url().trim_end_matches('/'), path);
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
match fetch_from_proxy(
&state.http_client,
&url,
state.config.maven.proxy_timeout,
proxy.auth(),
)
.await
{
Ok(data) => {
state.metrics.record_download("maven");
state.metrics.record_cache_miss();
@@ -58,8 +70,10 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
"maven",
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "maven", ""));
// Cache in local storage (fire and forget)
let storage = state.storage.clone();
let key_clone = key.clone();
let data_clone = data.clone();
@@ -67,6 +81,8 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
let _ = storage.put(&key_clone, &data_clone).await;
});
state.repo_index.invalidate("maven");
return with_content_type(&path, data.into()).into_response();
}
Err(_) => continue,
@@ -83,7 +99,6 @@ async fn upload(
) -> StatusCode {
let key = format!("maven/{}", path);
// Extract artifact name for logging
let artifact_name = path
.split('/')
.rev()
@@ -103,19 +118,27 @@ async fn upload(
"maven",
"LOCAL",
));
state
.audit
.log(AuditEntry::new("push", "api", "", "maven", ""));
state.repo_index.invalidate("maven");
StatusCode::CREATED
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
if let Some(credentials) = auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod cargo_registry;
pub mod docker;
pub mod docker_auth;

View File

@@ -1,4 +1,9 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState;
use axum::{
body::Bytes,
@@ -16,7 +21,6 @@ pub fn routes() -> Router<Arc<AppState>> {
}
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
// Determine if this is a tarball request or metadata request
let is_tarball = path.contains("/-/");
let key = if is_tarball {
@@ -30,14 +34,12 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
format!("npm/{}/metadata.json", path)
};
// Extract package name for logging
let package_name = if is_tarball {
path.split("/-/").next().unwrap_or(&path).to_string()
} else {
path.clone()
};
// Try local storage first
if let Ok(data) = state.storage.get(&key).await {
if is_tarball {
state.metrics.record_download("npm");
@@ -48,21 +50,24 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
"npm",
"CACHE",
));
state
.audit
.log(AuditEntry::new("cache_hit", "api", "", "npm", ""));
}
return with_content_type(is_tarball, data).into_response();
}
// Try proxy if configured
if let Some(proxy_url) = &state.config.npm.proxy {
let url = if is_tarball {
// Tarball URL: https://registry.npmjs.org/package/-/package-version.tgz
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
} else {
// Metadata URL: https://registry.npmjs.org/package
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
};
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
if let Ok(data) = fetch_from_proxy(
&state.http_client,
&url,
state.config.npm.proxy_timeout,
state.config.npm.proxy_auth.as_deref(),
)
.await
{
if is_tarball {
state.metrics.record_download("npm");
state.metrics.record_cache_miss();
@@ -72,9 +77,11 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
"npm",
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
}
// Cache in local storage (fire and forget)
let storage = state.storage.clone();
let key_clone = key.clone();
let data_clone = data.clone();
@@ -82,6 +89,10 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
let _ = storage.put(&key_clone, &data_clone).await;
});
if is_tarball {
state.repo_index.invalidate("npm");
}
return with_content_type(is_tarball, data.into()).into_response();
}
}
@@ -89,13 +100,17 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
StatusCode::NOT_FOUND.into_response()
}
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
if let Some(credentials) = auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());

View File

@@ -1,4 +1,9 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState;
use axum::{
extract::{Path, State},
@@ -82,7 +87,14 @@ async fn package_versions(
if let Some(proxy_url) = &state.config.pypi.proxy {
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) = fetch_package_page(&url, state.config.pypi.proxy_timeout).await {
if let Ok(html) = fetch_package_page(
&state.http_client,
&url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
// Rewrite URLs in the HTML to point to our registry
let rewritten = rewrite_pypi_links(&html, &normalized);
return (StatusCode::OK, Html(rewritten)).into_response();
@@ -110,6 +122,9 @@ async fn download_file(
"pypi",
"CACHE",
));
state
.audit
.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
let content_type = if filename.ends_with(".whl") {
"application/zip"
@@ -127,10 +142,24 @@ async fn download_file(
// First, fetch the package page to find the actual download URL
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) = fetch_package_page(&page_url, state.config.pypi.proxy_timeout).await {
if let Ok(html) = fetch_package_page(
&state.http_client,
&page_url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
// Find the URL for this specific file
if let Some(file_url) = find_file_url(&html, &filename) {
if let Ok(data) = fetch_file(&file_url, state.config.pypi.proxy_timeout).await {
if let Ok(data) = fetch_file(
&state.http_client,
&file_url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
state.metrics.record_download("pypi");
state.metrics.record_cache_miss();
state.activity.push(ActivityEntry::new(
@@ -139,6 +168,9 @@ async fn download_file(
"pypi",
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
// Cache in local storage
let storage = state.storage.clone();
@@ -148,6 +180,8 @@ async fn download_file(
let _ = storage.put(&key_clone, &data_clone).await;
});
state.repo_index.invalidate("pypi");
let content_type = if filename.ends_with(".whl") {
"application/zip"
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
@@ -172,18 +206,20 @@ fn normalize_name(name: &str) -> String {
}
/// Fetch package page from upstream
async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|_| ())?;
let response = client
async fn fetch_package_page(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<String, ()> {
let mut request = client
.get(url)
.header("Accept", "text/html")
.send()
.await
.map_err(|_| ())?;
.timeout(Duration::from_secs(timeout_secs))
.header("Accept", "text/html");
if let Some(credentials) = auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
@@ -193,13 +229,17 @@ async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()>
}
/// Fetch file from upstream
async fn fetch_file(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
async fn fetch_file(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
if let Some(credentials) = auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() {
return Err(());

View File

@@ -1,4 +1,8 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::AppState;
use axum::{
body::Bytes,
@@ -32,6 +36,9 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
state
.activity
.push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL"));
state
.audit
.log(AuditEntry::new("pull", "api", "", "raw", ""));
// Guess content type from extension
let content_type = guess_content_type(&key);
@@ -69,6 +76,9 @@ async fn upload(
state
.activity
.push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL"));
state
.audit
.log(AuditEntry::new("push", "api", "", "raw", ""));
StatusCode::CREATED.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),

View File

@@ -0,0 +1,351 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! In-memory repository index with lazy rebuild on invalidation.
//!
//! Design (designed for efficiency):
//! - Rebuild happens ONLY on write operations, not TTL
//! - Double-checked locking prevents duplicate rebuilds
//! - Arc<Vec> for zero-cost reads
//! - Single rebuild at a time per registry (rebuild_lock)
use crate::storage::Storage;
use crate::ui::components::format_timestamp;
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
use tracing::info;
/// Repository info for UI display
#[derive(Debug, Clone, Serialize)]
pub struct RepoInfo {
pub name: String,
pub versions: usize,
pub size: u64,
pub updated: String,
}
/// Index for a single registry type
pub struct RegistryIndex {
data: RwLock<Arc<Vec<RepoInfo>>>,
dirty: AtomicBool,
rebuild_lock: AsyncMutex<()>,
}
impl RegistryIndex {
pub fn new() -> Self {
Self {
data: RwLock::new(Arc::new(Vec::new())),
dirty: AtomicBool::new(true),
rebuild_lock: AsyncMutex::new(()),
}
}
/// Mark index as needing rebuild
pub fn invalidate(&self) {
self.dirty.store(true, Ordering::Release);
}
fn is_dirty(&self) -> bool {
self.dirty.load(Ordering::Acquire)
}
fn get_cached(&self) -> Arc<Vec<RepoInfo>> {
Arc::clone(&self.data.read())
}
fn set(&self, data: Vec<RepoInfo>) {
*self.data.write() = Arc::new(data);
self.dirty.store(false, Ordering::Release);
}
pub fn count(&self) -> usize {
self.data.read().len()
}
}
impl Default for RegistryIndex {
fn default() -> Self {
Self::new()
}
}
/// Main repository index for all registries
pub struct RepoIndex {
pub docker: RegistryIndex,
pub maven: RegistryIndex,
pub npm: RegistryIndex,
pub cargo: RegistryIndex,
pub pypi: RegistryIndex,
}
impl RepoIndex {
pub fn new() -> Self {
Self {
docker: RegistryIndex::new(),
maven: RegistryIndex::new(),
npm: RegistryIndex::new(),
cargo: RegistryIndex::new(),
pypi: RegistryIndex::new(),
}
}
/// Invalidate a specific registry index
pub fn invalidate(&self, registry: &str) {
match registry {
"docker" => self.docker.invalidate(),
"maven" => self.maven.invalidate(),
"npm" => self.npm.invalidate(),
"cargo" => self.cargo.invalidate(),
"pypi" => self.pypi.invalidate(),
_ => {}
}
}
/// Get index with double-checked locking (prevents race condition)
pub async fn get(&self, registry: &str, storage: &Storage) -> Arc<Vec<RepoInfo>> {
let index = match registry {
"docker" => &self.docker,
"maven" => &self.maven,
"npm" => &self.npm,
"cargo" => &self.cargo,
"pypi" => &self.pypi,
_ => return Arc::new(Vec::new()),
};
// Fast path: not dirty, return cached
if !index.is_dirty() {
return index.get_cached();
}
// Slow path: acquire rebuild lock (only one thread rebuilds)
let _guard = index.rebuild_lock.lock().await;
// Double-check under lock (another thread may have rebuilt)
if index.is_dirty() {
let data = match registry {
"docker" => build_docker_index(storage).await,
"maven" => build_maven_index(storage).await,
"npm" => build_npm_index(storage).await,
"cargo" => build_cargo_index(storage).await,
"pypi" => build_pypi_index(storage).await,
_ => Vec::new(),
};
info!(registry = registry, count = data.len(), "Index rebuilt");
index.set(data);
}
index.get_cached()
}
/// Get counts for stats (no rebuild, just current state)
pub fn counts(&self) -> (usize, usize, usize, usize, usize) {
(
self.docker.count(),
self.maven.count(),
self.npm.count(),
self.cargo.count(),
self.pypi.count(),
)
}
}
impl Default for RepoIndex {
fn default() -> Self {
Self::new()
}
}
// ============================================================================
// Index builders
// ============================================================================
async fn build_docker_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("docker/").await;
let mut repos: HashMap<String, (usize, u64, u64)> = HashMap::new();
for key in &keys {
if key.ends_with(".meta.json") {
continue;
}
if let Some(rest) = key.strip_prefix("docker/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 3 && parts[1] == "manifests" && key.ends_with(".json") {
let name = parts[0].to_string();
let entry = repos.entry(name).or_insert((0, 0, 0));
entry.0 += 1;
if let Ok(data) = storage.get(key).await {
if let Ok(m) = serde_json::from_slice::<serde_json::Value>(&data) {
let cfg = m
.get("config")
.and_then(|c| c.get("size"))
.and_then(|s| s.as_u64())
.unwrap_or(0);
let layers: u64 = m
.get("layers")
.and_then(|l| l.as_array())
.map(|arr| {
arr.iter()
.filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
.sum()
})
.unwrap_or(0);
entry.1 += cfg + layers;
}
}
if let Some(meta) = storage.stat(key).await {
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
to_sorted_vec(repos)
}
async fn build_maven_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("maven/").await;
let mut repos: HashMap<String, (usize, u64, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("maven/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 2 {
let path = parts[..parts.len() - 1].join("/");
let entry = repos.entry(path).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
to_sorted_vec(repos)
}
async fn build_npm_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("npm/").await;
let mut packages: HashMap<String, (usize, u64, u64)> = HashMap::new();
// Count tarballs instead of parsing metadata.json (faster than parsing JSON)
for key in &keys {
if let Some(rest) = key.strip_prefix("npm/") {
// Pattern: npm/{package}/tarballs/{file}.tgz
if rest.contains("/tarballs/") && key.ends_with(".tgz") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = packages.entry(name).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
}
to_sorted_vec(packages)
}
async fn build_cargo_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("cargo/").await;
let mut crates: HashMap<String, (usize, u64, u64)> = HashMap::new();
for key in &keys {
if key.ends_with(".crate") {
if let Some(rest) = key.strip_prefix("cargo/") {
let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let entry = crates.entry(name).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
}
to_sorted_vec(crates)
}
async fn build_pypi_index(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("pypi/").await;
let mut packages: HashMap<String, (usize, u64, u64)> = HashMap::new();
for key in &keys {
if let Some(rest) = key.strip_prefix("pypi/") {
let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 2 {
let name = parts[0].to_string();
let entry = packages.entry(name).or_insert((0, 0, 0));
entry.0 += 1;
if let Some(meta) = storage.stat(key).await {
entry.1 += meta.size;
if meta.modified > entry.2 {
entry.2 = meta.modified;
}
}
}
}
}
to_sorted_vec(packages)
}
/// Convert HashMap to sorted Vec<RepoInfo>
fn to_sorted_vec(map: HashMap<String, (usize, u64, u64)>) -> Vec<RepoInfo> {
let mut result: Vec<_> = map
.into_iter()
.map(|(name, (versions, size, modified))| RepoInfo {
name,
versions,
size,
updated: if modified > 0 {
format_timestamp(modified)
} else {
"N/A".to_string()
},
})
.collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
/// Pagination helper
pub fn paginate<T: Clone>(data: &[T], page: usize, limit: usize) -> (Vec<T>, usize) {
let total = data.len();
let start = page.saturating_sub(1) * limit;
if start >= total {
return (Vec::new(), total);
}
let end = (start + limit).min(total);
(data[start..end].to_vec(), total)
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Request ID middleware for request tracking and correlation
//!
//! Generates a unique ID for each request that can be used for:

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Environment variables secrets provider
//!
//! Reads secrets from environment variables. This is the default provider

View File

@@ -1,4 +1,5 @@
#![allow(dead_code)] // Foundational code for future S3/Vault integration
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Secrets management for NORA
//!
@@ -31,6 +32,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[allow(dead_code)] // Variants used by provider impls; external error handling planned for v0.4
/// Secrets provider error
#[derive(Debug, Error)]
pub enum SecretsError {
@@ -53,9 +55,11 @@ pub enum SecretsError {
#[async_trait]
pub trait SecretsProvider: Send + Sync {
/// Get a secret by key (required)
#[allow(dead_code)]
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError>;
/// Get a secret by key (optional, returns None if not found)
#[allow(dead_code)]
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
self.get_secret(key).await.ok()
}

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Protected secret types with memory safety
//!
//! Secrets are automatically zeroed on drop and redacted in Debug output.
@@ -10,12 +13,14 @@ use zeroize::{Zeroize, Zeroizing};
/// - Implements Zeroize: memory is overwritten with zeros when dropped
/// - Debug shows `***REDACTED***` instead of actual value
/// - Clone creates a new protected copy
#[allow(dead_code)] // Used internally by SecretsProvider impls; external callers planned for v0.4
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct ProtectedString {
inner: String,
}
#[allow(dead_code)]
impl ProtectedString {
/// Create a new protected string
pub fn new(value: String) -> Self {
@@ -65,6 +70,7 @@ impl From<&str> for ProtectedString {
}
/// S3 credentials with protected secrets
#[allow(dead_code)] // S3 storage backend planned for v0.4
#[derive(Clone, Zeroize)]
#[zeroize(drop)]
pub struct S3Credentials {
@@ -74,6 +80,7 @@ pub struct S3Credentials {
pub region: Option<String>,
}
#[allow(dead_code)]
impl S3Credentials {
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
Self {

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use async_trait::async_trait;
use axum::body::Bytes;
use std::path::PathBuf;

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod local;
mod s3;

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use async_trait::async_trait;
use axum::body::Bytes;
use chrono::Utc;

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
@@ -8,6 +11,35 @@ use uuid::Uuid;
const TOKEN_PREFIX: &str = "nra_";
/// Access role for API tokens
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Read,
Write,
Admin,
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::Read => write!(f, "read"),
Role::Write => write!(f, "write"),
Role::Admin => write!(f, "admin"),
}
}
}
impl Role {
pub fn can_write(&self) -> bool {
matches!(self, Role::Write | Role::Admin)
}
pub fn can_admin(&self) -> bool {
matches!(self, Role::Admin)
}
}
/// API Token metadata stored on disk
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenInfo {
@@ -17,6 +49,12 @@ pub struct TokenInfo {
pub expires_at: u64,
pub last_used: Option<u64>,
pub description: Option<String>,
#[serde(default = "default_role")]
pub role: Role,
}
fn default_role() -> Role {
Role::Read
}
/// Token store for managing API tokens
@@ -41,6 +79,7 @@ impl TokenStore {
user: &str,
ttl_days: u64,
description: Option<String>,
role: Role,
) -> Result<String, TokenError> {
// Generate random token
let raw_token = format!(
@@ -64,6 +103,7 @@ impl TokenStore {
expires_at,
last_used: None,
description,
role,
};
// Save to file
@@ -78,7 +118,7 @@ impl TokenStore {
}
/// Verify a token and return user info if valid
pub fn verify_token(&self, token: &str) -> Result<String, TokenError> {
pub fn verify_token(&self, token: &str) -> Result<(String, Role), TokenError> {
if !token.starts_with(TOKEN_PREFIX) {
return Err(TokenError::InvalidFormat);
}
@@ -118,7 +158,7 @@ impl TokenStore {
let _ = fs::write(&file_path, json);
}
Ok(info.user)
Ok((info.user, info.role))
}
/// List all tokens for a user
@@ -207,7 +247,7 @@ mod tests {
let store = TokenStore::new(temp_dir.path());
let token = store
.create_token("testuser", 30, Some("Test token".to_string()))
.create_token("testuser", 30, Some("Test token".to_string()), Role::Write)
.unwrap();
assert!(token.starts_with("nra_"));
@@ -219,10 +259,13 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let store = TokenStore::new(temp_dir.path());
let token = store.create_token("testuser", 30, None).unwrap();
let user = store.verify_token(&token).unwrap();
let token = store
.create_token("testuser", 30, None, Role::Write)
.unwrap();
let (user, role) = store.verify_token(&token).unwrap();
assert_eq!(user, "testuser");
assert_eq!(role, Role::Write);
}
#[test]
@@ -249,7 +292,9 @@ mod tests {
let store = TokenStore::new(temp_dir.path());
// Create token and manually set it as expired
let token = store.create_token("testuser", 1, None).unwrap();
let token = store
.create_token("testuser", 1, None, Role::Write)
.unwrap();
let token_hash = hash_token(&token);
let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16]));
@@ -269,9 +314,9 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let store = TokenStore::new(temp_dir.path());
store.create_token("user1", 30, None).unwrap();
store.create_token("user1", 30, None).unwrap();
store.create_token("user2", 30, None).unwrap();
store.create_token("user1", 30, None, Role::Write).unwrap();
store.create_token("user1", 30, None, Role::Write).unwrap();
store.create_token("user2", 30, None, Role::Read).unwrap();
let user1_tokens = store.list_tokens("user1");
assert_eq!(user1_tokens.len(), 2);
@@ -288,7 +333,9 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let store = TokenStore::new(temp_dir.path());
let token = store.create_token("testuser", 30, None).unwrap();
let token = store
.create_token("testuser", 30, None, Role::Write)
.unwrap();
let token_hash = hash_token(&token);
let hash_prefix = &token_hash[..16];
@@ -317,9 +364,9 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let store = TokenStore::new(temp_dir.path());
store.create_token("user1", 30, None).unwrap();
store.create_token("user1", 30, None).unwrap();
store.create_token("user2", 30, None).unwrap();
store.create_token("user1", 30, None, Role::Write).unwrap();
store.create_token("user1", 30, None, Role::Write).unwrap();
store.create_token("user2", 30, None, Role::Read).unwrap();
let revoked = store.revoke_all_for_user("user1");
assert_eq!(revoked, 2);
@@ -333,7 +380,9 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
let store = TokenStore::new(temp_dir.path());
let token = store.create_token("testuser", 30, None).unwrap();
let token = store
.create_token("testuser", 30, None, Role::Write)
.unwrap();
// First verification
store.verify_token(&token).unwrap();
@@ -349,7 +398,12 @@ mod tests {
let store = TokenStore::new(temp_dir.path());
store
.create_token("testuser", 30, Some("CI/CD Pipeline".to_string()))
.create_token(
"testuser",
30,
Some("CI/CD Pipeline".to_string()),
Role::Admin,
)
.unwrap();
let tokens = store.list_tokens("testuser");

View File

@@ -1,6 +1,10 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use super::components::{format_size, format_timestamp, html_escape};
use super::templates::encode_uri_component;
use crate::activity_log::ActivityEntry;
use crate::repo_index::RepoInfo;
use crate::AppState;
use crate::Storage;
use axum::{
@@ -21,14 +25,6 @@ pub struct RegistryStats {
pub pypi: usize,
}
#[derive(Serialize, Clone)]
pub struct RepoInfo {
pub name: String,
pub versions: usize,
pub size: u64,
pub updated: String,
}
#[derive(Serialize)]
pub struct TagInfo {
pub name: String,
@@ -112,44 +108,47 @@ pub struct MountPoint {
// ============ API Handlers ============
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
let stats = get_registry_stats(&state.storage).await;
Json(stats)
// Trigger index rebuild if needed, then get counts
let _ = state.repo_index.get("docker", &state.storage).await;
let _ = state.repo_index.get("maven", &state.storage).await;
let _ = state.repo_index.get("npm", &state.storage).await;
let _ = state.repo_index.get("cargo", &state.storage).await;
let _ = state.repo_index.get("pypi", &state.storage).await;
let (docker, maven, npm, cargo, pypi) = state.repo_index.counts();
Json(RegistryStats {
docker,
maven,
npm,
cargo,
pypi,
})
}
pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<DashboardResponse> {
let registry_stats = get_registry_stats(&state.storage).await;
// Get indexes (will rebuild if dirty)
let docker_repos = state.repo_index.get("docker", &state.storage).await;
let maven_repos = state.repo_index.get("maven", &state.storage).await;
let npm_repos = state.repo_index.get("npm", &state.storage).await;
let cargo_repos = state.repo_index.get("cargo", &state.storage).await;
let pypi_repos = state.repo_index.get("pypi", &state.storage).await;
// Calculate total storage size
let all_keys = state.storage.list("").await;
let mut total_storage: u64 = 0;
let mut docker_size: u64 = 0;
let mut maven_size: u64 = 0;
let mut npm_size: u64 = 0;
let mut cargo_size: u64 = 0;
let mut pypi_size: u64 = 0;
// Calculate sizes from cached index
let docker_size: u64 = docker_repos.iter().map(|r| r.size).sum();
let maven_size: u64 = maven_repos.iter().map(|r| r.size).sum();
let npm_size: u64 = npm_repos.iter().map(|r| r.size).sum();
let cargo_size: u64 = cargo_repos.iter().map(|r| r.size).sum();
let pypi_size: u64 = pypi_repos.iter().map(|r| r.size).sum();
let total_storage = docker_size + maven_size + npm_size + cargo_size + pypi_size;
for key in &all_keys {
if let Some(meta) = state.storage.stat(key).await {
total_storage += meta.size;
if key.starts_with("docker/") {
docker_size += meta.size;
} else if key.starts_with("maven/") {
maven_size += meta.size;
} else if key.starts_with("npm/") {
npm_size += meta.size;
} else if key.starts_with("cargo/") {
cargo_size += meta.size;
} else if key.starts_with("pypi/") {
pypi_size += meta.size;
}
}
}
let total_artifacts = registry_stats.docker
+ registry_stats.maven
+ registry_stats.npm
+ registry_stats.cargo
+ registry_stats.pypi;
// Count total versions/tags, not just repositories
let docker_versions: usize = docker_repos.iter().map(|r| r.versions).sum();
let maven_versions: usize = maven_repos.iter().map(|r| r.versions).sum();
let npm_versions: usize = npm_repos.iter().map(|r| r.versions).sum();
let cargo_versions: usize = cargo_repos.iter().map(|r| r.versions).sum();
let pypi_versions: usize = pypi_repos.iter().map(|r| r.versions).sum();
let total_artifacts =
docker_versions + maven_versions + npm_versions + cargo_versions + pypi_versions;
let global_stats = GlobalStats {
downloads: state.metrics.downloads.load(Ordering::Relaxed),
@@ -162,35 +161,35 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
let registry_card_stats = vec![
RegistryCardStats {
name: "docker".to_string(),
artifact_count: registry_stats.docker,
artifact_count: docker_versions,
downloads: state.metrics.get_registry_downloads("docker"),
uploads: state.metrics.get_registry_uploads("docker"),
size_bytes: docker_size,
},
RegistryCardStats {
name: "maven".to_string(),
artifact_count: registry_stats.maven,
artifact_count: maven_versions,
downloads: state.metrics.get_registry_downloads("maven"),
uploads: state.metrics.get_registry_uploads("maven"),
size_bytes: maven_size,
},
RegistryCardStats {
name: "npm".to_string(),
artifact_count: registry_stats.npm,
artifact_count: npm_versions,
downloads: state.metrics.get_registry_downloads("npm"),
uploads: 0,
size_bytes: npm_size,
},
RegistryCardStats {
name: "cargo".to_string(),
artifact_count: registry_stats.cargo,
artifact_count: cargo_versions,
downloads: state.metrics.get_registry_downloads("cargo"),
uploads: 0,
size_bytes: cargo_size,
},
RegistryCardStats {
name: "pypi".to_string(),
artifact_count: registry_stats.pypi,
artifact_count: pypi_versions,
downloads: state.metrics.get_registry_downloads("pypi"),
uploads: 0,
size_bytes: pypi_size,
@@ -206,7 +205,12 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
MountPoint {
registry: "Maven".to_string(),
mount_path: "/maven2/".to_string(),
proxy_upstream: state.config.maven.proxies.first().cloned(),
proxy_upstream: state
.config
.maven
.proxies
.first()
.map(|p| p.url().to_string()),
},
MountPoint {
registry: "npm".to_string(),
@@ -241,15 +245,8 @@ pub async fn api_list(
State(state): State<Arc<AppState>>,
Path(registry_type): Path<String>,
) -> Json<Vec<RepoInfo>> {
let repos = match registry_type.as_str() {
"docker" => get_docker_repos(&state.storage).await,
"maven" => get_maven_repos(&state.storage).await,
"npm" => get_npm_packages(&state.storage).await,
"cargo" => get_cargo_crates(&state.storage).await,
"pypi" => get_pypi_packages(&state.storage).await,
_ => vec![],
};
Json(repos)
let repos = state.repo_index.get(&registry_type, &state.storage).await;
Json((*repos).clone())
}
pub async fn api_detail(
@@ -280,20 +277,13 @@ pub async fn api_search(
) -> axum::response::Html<String> {
let query = params.q.unwrap_or_default().to_lowercase();
let repos = match registry_type.as_str() {
"docker" => get_docker_repos(&state.storage).await,
"maven" => get_maven_repos(&state.storage).await,
"npm" => get_npm_packages(&state.storage).await,
"cargo" => get_cargo_crates(&state.storage).await,
"pypi" => get_pypi_packages(&state.storage).await,
_ => vec![],
};
let repos = state.repo_index.get(&registry_type, &state.storage).await;
let filtered: Vec<_> = if query.is_empty() {
repos
let filtered: Vec<&RepoInfo> = if query.is_empty() {
repos.iter().collect()
} else {
repos
.into_iter()
.iter()
.filter(|r| r.name.to_lowercase().contains(&query))
.collect()
};
@@ -338,7 +328,9 @@ pub async fn api_search(
}
// ============ Data Fetching Functions ============
// NOTE: Legacy functions below - kept for reference, will be removed in future cleanup
#[allow(dead_code)]
pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
let all_keys = storage.list("").await;
@@ -390,6 +382,7 @@ pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
}
}
#[allow(dead_code)]
pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("docker/").await;
@@ -568,6 +561,7 @@ pub async fn get_docker_detail(state: &AppState, name: &str) -> DockerDetail {
DockerDetail { tags }
}
#[allow(dead_code)]
pub async fn get_maven_repos(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("maven/").await;
@@ -627,6 +621,7 @@ pub async fn get_maven_detail(storage: &Storage, path: &str) -> MavenDetail {
MavenDetail { artifacts }
}
#[allow(dead_code)]
pub async fn get_npm_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("npm/").await;
@@ -744,6 +739,7 @@ pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail {
PackageDetail { versions }
}
#[allow(dead_code)]
pub async fn get_cargo_crates(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("cargo/").await;
@@ -811,6 +807,7 @@ pub async fn get_cargo_detail(storage: &Storage, name: &str) -> PackageDetail {
PackageDetail { versions }
}
#[allow(dead_code)]
pub async fn get_pypi_packages(storage: &Storage) -> Vec<RepoInfo> {
let keys = storage.list("pypi/").await;

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use super::i18n::{get_translations, Lang, Translations};
/// Application version from Cargo.toml
@@ -137,7 +140,7 @@ fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
<div id="sidebar" class="fixed md:static inset-y-0 left-0 z-50 w-64 bg-slate-800 text-white flex flex-col transform -translate-x-full md:translate-x-0 transition-transform duration-200 ease-in-out">
<div class="h-16 flex items-center justify-between px-6 border-b border-slate-700">
<div class="flex items-center">
<span class="text-2xl font-bold tracking-tight">N<span class="inline-block w-5 h-5 rounded-full border-2 border-current align-middle relative -top-0.5 mx-0.5"></span>RA</span>
<span class="text-xl font-bold tracking-tight">N<span class="inline-block w-4 h-4 rounded-full border-2 border-current align-middle mx-px"></span>RA</span>
</div>
<button onclick="toggleSidebar()" class="md:hidden p-1 rounded-lg hover:bg-slate-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
/// Internationalization support for the UI
use serde::{Deserialize, Serialize};

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,13 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod api;
mod components;
pub mod components;
pub mod i18n;
mod logo;
mod templates;
use crate::repo_index::paginate;
use crate::AppState;
use axum::{
extract::{Path, Query, State},
@@ -22,6 +26,15 @@ struct LangQuery {
lang: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct ListQuery {
lang: Option<String>,
page: Option<usize>,
limit: Option<usize>,
}
const DEFAULT_PAGE_SIZE: usize = 50;
fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
// Priority: query param > cookie > default
if let Some(ref lang) = query.lang {
@@ -41,6 +54,23 @@ fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
Lang::default()
}
fn extract_lang_from_list(query: &ListQuery, cookie_header: Option<&str>) -> Lang {
if let Some(ref lang) = query.lang {
return Lang::from_str(lang);
}
if let Some(cookies) = cookie_header {
for part in cookies.split(';') {
let part = part.trim();
if let Some(value) = part.strip_prefix("nora_lang=") {
return Lang::from_str(value);
}
}
}
Lang::default()
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
// UI Pages
@@ -82,18 +112,23 @@ async fn dashboard(
// Docker pages
async fn docker_list(
State(state): State<Arc<AppState>>,
Query(query): Query<LangQuery>,
Query(query): Query<ListQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang(
&Query(query),
headers.get("cookie").and_then(|v| v.to_str().ok()),
);
let repos = get_docker_repos(&state.storage).await;
Html(render_registry_list(
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_repos = state.repo_index.get("docker", &state.storage).await;
let (repos, total) = paginate(&all_repos, page, limit);
Html(render_registry_list_paginated(
"docker",
"Docker Registry",
&repos,
page,
limit,
total,
lang,
))
}
@@ -115,18 +150,23 @@ async fn docker_detail(
// Maven pages
async fn maven_list(
State(state): State<Arc<AppState>>,
Query(query): Query<LangQuery>,
Query(query): Query<ListQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang(
&Query(query),
headers.get("cookie").and_then(|v| v.to_str().ok()),
);
let repos = get_maven_repos(&state.storage).await;
Html(render_registry_list(
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_repos = state.repo_index.get("maven", &state.storage).await;
let (repos, total) = paginate(&all_repos, page, limit);
Html(render_registry_list_paginated(
"maven",
"Maven Repository",
&repos,
page,
limit,
total,
lang,
))
}
@@ -148,15 +188,25 @@ async fn maven_detail(
// npm pages
async fn npm_list(
State(state): State<Arc<AppState>>,
Query(query): Query<LangQuery>,
Query(query): Query<ListQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang(
&Query(query),
headers.get("cookie").and_then(|v| v.to_str().ok()),
);
let packages = get_npm_packages(&state.storage).await;
Html(render_registry_list("npm", "npm Registry", &packages, lang))
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_packages = state.repo_index.get("npm", &state.storage).await;
let (packages, total) = paginate(&all_packages, page, limit);
Html(render_registry_list_paginated(
"npm",
"npm Registry",
&packages,
page,
limit,
total,
lang,
))
}
async fn npm_detail(
@@ -176,18 +226,23 @@ async fn npm_detail(
// Cargo pages
async fn cargo_list(
State(state): State<Arc<AppState>>,
Query(query): Query<LangQuery>,
Query(query): Query<ListQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang(
&Query(query),
headers.get("cookie").and_then(|v| v.to_str().ok()),
);
let crates = get_cargo_crates(&state.storage).await;
Html(render_registry_list(
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_crates = state.repo_index.get("cargo", &state.storage).await;
let (crates, total) = paginate(&all_crates, page, limit);
Html(render_registry_list_paginated(
"cargo",
"Cargo Registry",
&crates,
page,
limit,
total,
lang,
))
}
@@ -209,18 +264,23 @@ async fn cargo_detail(
// PyPI pages
async fn pypi_list(
State(state): State<Arc<AppState>>,
Query(query): Query<LangQuery>,
Query(query): Query<ListQuery>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let lang = extract_lang(
&Query(query),
headers.get("cookie").and_then(|v| v.to_str().ok()),
);
let packages = get_pypi_packages(&state.storage).await;
Html(render_registry_list(
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
let page = query.page.unwrap_or(1).max(1);
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
let all_packages = state.repo_index.get("pypi", &state.storage).await;
let (packages, total) = paginate(&all_packages, page, limit);
Html(render_registry_list_paginated(
"pypi",
"PyPI Repository",
&packages,
page,
limit,
total,
lang,
))
}

View File

@@ -1,6 +1,10 @@
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo};
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail};
use super::components::*;
use super::i18n::{get_translations, Lang};
use crate::repo_index::RepoInfo;
/// Renders the main dashboard page with dark theme
pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
@@ -163,6 +167,7 @@ fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
}
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
#[allow(dead_code)]
pub fn render_registry_list(
registry_type: &str,
title: &str,
@@ -273,6 +278,220 @@ pub fn render_registry_list(
layout_dark(title, &content, Some(registry_type), "", lang)
}
/// Renders a registry list page with pagination
pub fn render_registry_list_paginated(
registry_type: &str,
title: &str,
repos: &[RepoInfo],
page: usize,
limit: usize,
total: usize,
lang: Lang,
) -> String {
let t = get_translations(lang);
let icon = get_registry_icon(registry_type);
let table_rows = if repos.is_empty() && page == 1 {
format!(
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
<div class="text-4xl mb-2">📭</div>
<div>{}</div>
<div class="text-sm mt-1">{}</div>
</td></tr>"##,
t.no_repos_found, t.push_first_artifact
)
} else if repos.is_empty() {
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
<div class="text-4xl mb-2">📭</div>
<div>No more items on this page</div>
</td></tr>"##
.to_string()
} else {
repos
.iter()
.map(|repo| {
let detail_url =
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
format!(
r##"
<tr class="hover:bg-slate-700 cursor-pointer" onclick="window.location='{}'">
<td class="px-6 py-4">
<a href="{}" class="text-blue-400 hover:text-blue-300 font-medium">{}</a>
</td>
<td class="px-6 py-4 text-slate-400">{}</td>
<td class="px-6 py-4 text-slate-400">{}</td>
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
</tr>
"##,
detail_url,
detail_url,
html_escape(&repo.name),
repo.versions,
format_size(repo.size),
&repo.updated
)
})
.collect::<Vec<_>>()
.join("")
};
let version_label = match registry_type {
"docker" => t.tags,
_ => t.versions,
};
// Pagination
let total_pages = total.div_ceil(limit);
let start_item = if total == 0 {
0
} else {
(page - 1) * limit + 1
};
let end_item = (start_item + repos.len()).saturating_sub(1);
let pagination = if total_pages > 1 {
let mut pages_html = String::new();
// Previous button
if page > 1 {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">←</a>"##,
registry_type, page - 1, limit
));
} else {
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">←</span>"##);
}
// Page numbers (show max 7 pages around current)
let start_page = if page <= 4 { 1 } else { page - 3 };
let end_page = (start_page + 6).min(total_pages);
if start_page > 1 {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page=1&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">1</a>"##,
registry_type, limit
));
if start_page > 2 {
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
}
}
for p in start_page..=end_page {
if p == page {
pages_html.push_str(&format!(
r##"<span class="px-3 py-1 rounded bg-blue-600 text-white font-medium">{}</span>"##,
p
));
} else {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
registry_type, p, limit, p
));
}
}
if end_page < total_pages {
if end_page < total_pages - 1 {
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
}
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
registry_type, total_pages, limit, total_pages
));
}
// Next button
if page < total_pages {
pages_html.push_str(&format!(
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">→</a>"##,
registry_type, page + 1, limit
));
} else {
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">→</span>"##);
}
format!(
r##"
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-slate-500">
Showing {}-{} of {} items
</div>
<div class="flex items-center gap-1">
{}
</div>
</div>
"##,
start_item, end_item, total, pages_html
)
} else if total > 0 {
format!(
r##"<div class="mt-4 text-sm text-slate-500">Showing all {} items</div>"##,
total
)
} else {
String::new()
};
let content = format!(
r##"
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center">
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
<div>
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
<p class="text-slate-500">{} {}</p>
</div>
</div>
<div class="flex items-center gap-4">
<div class="relative">
<input type="text"
placeholder="{}"
class="pl-10 pr-4 py-2 bg-slate-800 border border-slate-600 text-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-slate-500"
hx-get="/api/ui/{}/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#repo-table-body"
name="q">
<svg class="absolute left-3 top-2.5 h-5 w-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
<table class="w-full">
<thead class="bg-slate-800 border-b border-slate-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
</tr>
</thead>
<tbody id="repo-table-body" class="divide-y divide-slate-700">
{}
</tbody>
</table>
</div>
{}
"##,
icon,
title,
total,
t.repositories,
t.search_placeholder,
registry_type,
t.name,
version_label,
t.size,
t.updated,
table_rows,
pagination
);
layout_dark(title, &content, Some(registry_type), "", lang)
}
/// Renders Docker image detail page
pub fn render_docker_detail(name: &str, detail: &DockerDetail, lang: Lang) -> String {
let _t = get_translations(lang);

View File

@@ -1,4 +1,6 @@
#![allow(dead_code)]
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! Input validation for artifact registry paths and identifiers
//!
//! Provides security validation to prevent path traversal attacks and
@@ -306,63 +308,6 @@ pub fn validate_docker_reference(reference: &str) -> Result<(), ValidationError>
Ok(())
}
/// Validate Maven artifact path.
///
/// Maven paths follow the pattern: groupId/artifactId/version/filename
/// Example: `org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar`
pub fn validate_maven_path(path: &str) -> Result<(), ValidationError> {
validate_storage_key(path)
}
/// Validate npm package name.
pub fn validate_npm_name(name: &str) -> Result<(), ValidationError> {
if name.is_empty() {
return Err(ValidationError::EmptyInput);
}
if name.len() > 214 {
return Err(ValidationError::TooLong {
max: 214,
actual: name.len(),
});
}
// Check for path traversal
if name.contains("..") {
return Err(ValidationError::PathTraversal);
}
Ok(())
}
/// Validate Cargo crate name.
pub fn validate_crate_name(name: &str) -> Result<(), ValidationError> {
if name.is_empty() {
return Err(ValidationError::EmptyInput);
}
if name.len() > 64 {
return Err(ValidationError::TooLong {
max: 64,
actual: name.len(),
});
}
// Check for path traversal
if name.contains("..") || name.contains('/') {
return Err(ValidationError::PathTraversal);
}
// Crate names: alphanumeric, underscores, hyphens
for c in name.chars() {
if !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-') {
return Err(ValidationError::ForbiddenCharacter(c));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -19,10 +19,10 @@ serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
toml = "0.8"
toml = "1.0"
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
base64 = "0.22"
httpdate = "1"
chrono = { version = "0.4", features = ["serde"] }
quick-xml = { version = "0.31", features = ["serialize"] }
quick-xml = { version = "0.39", features = ["serialize"] }

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
use serde::{Deserialize, Serialize};
use std::fs;

View File

@@ -1,3 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
mod config;
use axum::extract::DefaultBodyLimit;