40 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
26 changed files with 667 additions and 654 deletions

View File

@@ -51,16 +51,14 @@ jobs:
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 || true
continue-on-error: true # findings are reported, do not block the pipeline
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
continue-on-error: true # warn only; known CVEs should not block CI until triaged
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
@@ -72,14 +70,14 @@ jobs:
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
- name: Trivy — filesystem scan (Cargo.lock + source)
if: always()
uses: aquasecurity/trivy-action@0.34.2
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-fs.sarif
severity: HIGH,CRITICAL
exit-code: 0 # warn only; change to 1 to block the pipeline
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
@@ -87,3 +85,93 @@ jobs:
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

@@ -39,12 +39,12 @@ jobs:
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 GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -53,7 +53,7 @@ jobs:
# ── Alpine ───────────────────────────────────────────────────────────────
- name: Extract metadata (alpine)
id: meta-alpine
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
@@ -64,7 +64,7 @@ jobs:
type=raw,value=latest
- name: Build and push (alpine)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -72,13 +72,13 @@ jobs:
push: true
tags: ${{ steps.meta-alpine.outputs.tags }}
labels: ${{ steps.meta-alpine.outputs.labels }}
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine
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@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
@@ -90,7 +90,7 @@ jobs:
type=raw,value=redos
- name: Build and push (redos)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.redos
@@ -98,13 +98,13 @@ jobs:
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
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@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
@@ -116,7 +116,7 @@ jobs:
type=raw,value=astra
- name: Build and push (astra)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.astra
@@ -124,9 +124,21 @@ jobs:
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
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]
@@ -153,7 +165,7 @@ jobs:
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Trivy — image scan (${{ matrix.name }})
uses: aquasecurity/trivy-action@0.34.2
uses: aquasecurity/trivy-action@0.35.0
with:
scan-type: image
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}

3
.gitignore vendored
View File

@@ -6,6 +6,9 @@ data/
*.log
internal config
# Backup files
*.bak
# Internal files
SESSION*.md
TODO.md

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,64 @@ 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 / Добавлено

View File

@@ -1,414 +0,0 @@
# Changelog
All notable changes to NORA will be documented in this file.
---
## [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
#### Configurable Rate Limiting
- Rate limits now configurable via `config.toml` and environment variables
- New config section `[rate_limit]` with parameters: `auth_rps`, `auth_burst`, `upload_rps`, `upload_burst`, `general_rps`, `general_burst`
- Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
#### Secrets Provider Architecture
- Trait-based secrets management (`SecretsProvider` trait)
- ENV provider as default (12-Factor App pattern)
- Protected secrets with `zeroize` (memory zeroed on drop)
- Redacted Debug impl prevents secret leakage in logs
- New config section `[secrets]` with `provider` and `clear_env` options
#### Docker Image Metadata
- Support for image metadata retrieval
#### Documentation
- Bilingual onboarding guide (EN/RU)
---
## [0.2.11] - 2026-01-26
### Added
- Internationalization (i18n) support
- PyPI registry proxy
- UI improvements
---
## [0.2.10] - 2026-01-26
### Changed
- Dark theme applied to all UI pages
---
## [0.2.9] - 2026-01-26
### Changed
- Version bump release
---
## [0.2.8] - 2026-01-26
### Added
- Dashboard endpoint added to OpenAPI documentation
---
## [0.2.7] - 2026-01-26
### Added
- Dynamic version display in UI sidebar
---
## [0.2.6] - 2026-01-26
### Added
#### Dashboard Metrics
- Global stats panel: downloads, uploads, artifacts, cache hit rate, storage
- Extended registry cards with artifact count, size, counters
- Activity log (last 20 events)
#### UI
- Dark theme (bg: #0f172a, cards: #1e293b)
---
## [0.2.5] - 2026-01-26
### Fixed
- Docker push/pull: added PATCH endpoint for chunked uploads
---
## [0.2.4] - 2026-01-26
### Fixed
- Rate limiting: health/metrics endpoints now exempt
- Increased upload rate limits for Docker parallel requests
---
## [0.2.0] - 2026-01-25
### Added
#### UI: SVG Brand Icons
- Replaced emoji icons with proper SVG brand icons (Simple Icons style)
- Docker, Maven, npm, Cargo, PyPI icons now render as scalable vector graphics
- Consistent icon styling across dashboard, sidebar, and detail pages
#### Testing Infrastructure
- Unit tests for LocalStorage (8 tests): put/get, list, stat, health_check
- Unit tests for S3Storage with wiremock HTTP mocking (11 tests)
- Integration tests for auth/htpasswd (7 tests)
- Token lifecycle tests (11 tests)
- Validation tests (21 tests)
- **Total: 75 tests passing**
#### Security: Input Validation (`validation.rs`)
- Path traversal protection: rejects `../`, `..\\`, null bytes, absolute paths
- Docker image name validation per OCI distribution spec
- Content digest validation (`sha256:[64 hex]`, `sha512:[128 hex]`)
- Docker tag/reference validation
- Storage key length limits (max 1024 chars)
#### Security: Rate Limiting (`rate_limit.rs`)
- Auth endpoints: 1 req/sec, burst 5 (brute-force protection)
- Upload endpoints: 10 req/sec, burst 20
- General endpoints: 100 req/sec, burst 200
- Uses `tower_governor` 0.8 with `PeerIpKeyExtractor`
#### Observability: Request ID Tracking (`request_id.rs`)
- `X-Request-ID` header added to all responses
- Accepts upstream request ID or generates UUID v4
- Tracing spans include request_id for log correlation
#### CLI: Migrate Command (`migrate.rs`)
- `nora migrate --from local --to s3` - migrate between storage backends
- `--dry-run` flag for preview without copying
- Progress bar with indicatif
- Skips existing files in destination
- Summary statistics (migrated, skipped, failed, bytes)
#### Error Handling (`error.rs`)
- `AppError` enum with `IntoResponse` for Axum
- Automatic conversion from `StorageError` and `ValidationError`
- JSON error responses with request_id support
### Changed
- `StorageError` now uses `thiserror` derive macro
- `TokenError` now uses `thiserror` derive macro
- Storage wrapper validates keys before delegating to backend
- Docker registry handlers validate name, digest, reference inputs
- Body size limit set to 100MB default via `DefaultBodyLimit`
### Dependencies Added
- `thiserror = "2"` - typed error handling
- `tower_governor = "0.8"` - rate limiting
- `governor = "0.10"` - rate limiting backend
- `tempfile = "3"` (dev) - temporary directories for tests
- `wiremock = "0.6"` (dev) - HTTP mocking for S3 tests
### Files Added
- `src/validation.rs` - input validation module
- `src/migrate.rs` - storage migration module
- `src/error.rs` - application error types
- `src/request_id.rs` - request ID middleware
- `src/rate_limit.rs` - rate limiting configuration
---
## [0.1.0] - 2026-01-24
### Added
- Multi-protocol support: Docker Registry v2, Maven, npm, Cargo, PyPI
- Web UI dashboard
- Swagger UI (`/api-docs`)
- Storage backends: Local filesystem, S3-compatible
- Smart proxy/cache for Maven and npm
- Health checks (`/health`, `/ready`)
- Basic authentication (htpasswd with bcrypt)
- API tokens (revocable, per-user)
- Prometheus metrics (`/metrics`)
- JSON structured logging
- Environment variable configuration
- Graceful shutdown (SIGTERM/SIGINT)
- Backup/restore commands
---
# Журнал изменений (RU)
Все значимые изменения NORA документируются в этом файле.
---
## [0.2.12] - 2026-01-30
### Добавлено
#### Настраиваемый Rate Limiting
- Rate limits настраиваются через `config.toml` и переменные окружения
- Новая секция `[rate_limit]` с параметрами: `auth_rps`, `auth_burst`, `upload_rps`, `upload_burst`, `general_rps`, `general_burst`
- Переменные окружения: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
#### Архитектура Secrets Provider
- Trait-based управление секретами (`SecretsProvider` trait)
- ENV provider по умолчанию (12-Factor App паттерн)
- Защищённые секреты с `zeroize` (память обнуляется при drop)
- Redacted Debug impl предотвращает утечку секретов в логи
- Новая секция `[secrets]` с опциями `provider` и `clear_env`
#### Docker Image Metadata
- Поддержка получения метаданных образов
#### Документация
- Двуязычный onboarding guide (EN/RU)
---
## [0.2.11] - 2026-01-26
### Добавлено
- Поддержка интернационализации (i18n)
- PyPI registry proxy
- Улучшения UI
---
## [0.2.10] - 2026-01-26
### Изменено
- Тёмная тема применена ко всем страницам UI
---
## [0.2.9] - 2026-01-26
### Изменено
- Релиз с обновлением версии
---
## [0.2.8] - 2026-01-26
### Добавлено
- Dashboard endpoint добавлен в OpenAPI документацию
---
## [0.2.7] - 2026-01-26
### Добавлено
- Динамическое отображение версии в сайдбаре UI
---
## [0.2.6] - 2026-01-26
### Добавлено
#### Dashboard Metrics
- Глобальная панель статистики: downloads, uploads, artifacts, cache hit rate, storage
- Расширенные карточки реестров с количеством артефактов, размером, счётчиками
- Лог активности (последние 20 событий)
#### UI
- Тёмная тема (bg: #0f172a, cards: #1e293b)
---
## [0.2.5] - 2026-01-26
### Исправлено
- Docker push/pull: добавлен PATCH endpoint для chunked uploads
---
## [0.2.4] - 2026-01-26
### Исправлено
- Rate limiting: health/metrics endpoints теперь исключены
- Увеличены лимиты upload для параллельных Docker запросов
---
## [0.2.0] - 2026-01-25
### Добавлено
#### UI: SVG иконки брендов
- Эмоджи заменены на SVG иконки брендов (стиль Simple Icons)
- Docker, Maven, npm, Cargo, PyPI теперь отображаются как векторная графика
- Единый стиль иконок на дашборде, сайдбаре и страницах деталей
#### Тестовая инфраструктура
- Unit-тесты для LocalStorage (8 тестов): put/get, list, stat, health_check
- Unit-тесты для S3Storage с HTTP-мокированием wiremock (11 тестов)
- Интеграционные тесты auth/htpasswd (7 тестов)
- Тесты жизненного цикла токенов (11 тестов)
- Тесты валидации (21 тест)
- **Всего: 75 тестов проходят**
#### Безопасность: Валидация ввода (`validation.rs`)
- Защита от path traversal: отклоняет `../`, `..\\`, null-байты, абсолютные пути
- Валидация имён Docker-образов по спецификации OCI distribution
- Валидация дайджестов (`sha256:[64 hex]`, `sha512:[128 hex]`)
- Валидация тегов и ссылок Docker
- Ограничение длины ключей хранилища (макс. 1024 символа)
#### Безопасность: Rate Limiting (`rate_limit.rs`)
- Auth endpoints: 1 req/sec, burst 5 (защита от брутфорса)
- Upload endpoints: 10 req/sec, burst 20
- Общие endpoints: 100 req/sec, burst 200
- Использует `tower_governor` 0.8 с `PeerIpKeyExtractor`
#### Наблюдаемость: Отслеживание Request ID (`request_id.rs`)
- Заголовок `X-Request-ID` добавляется ко всем ответам
- Принимает upstream request ID или генерирует UUID v4
- Tracing spans включают request_id для корреляции логов
#### CLI: Команда миграции (`migrate.rs`)
- `nora migrate --from local --to s3` - миграция между storage backends
- Флаг `--dry-run` для предпросмотра без копирования
- Прогресс-бар с indicatif
- Пропуск существующих файлов в destination
- Итоговая статистика (migrated, skipped, failed, bytes)
#### Обработка ошибок (`error.rs`)
- Enum `AppError` с `IntoResponse` для Axum
- Автоматическая конверсия из `StorageError` и `ValidationError`
- JSON-ответы об ошибках с поддержкой request_id
### Изменено
- `StorageError` теперь использует макрос `thiserror`
- `TokenError` теперь использует макрос `thiserror`
- Storage wrapper валидирует ключи перед делегированием backend
- Docker registry handlers валидируют name, digest, reference
- Лимит размера body установлен в 100MB через `DefaultBodyLimit`
### Добавлены зависимости
- `thiserror = "2"` - типизированная обработка ошибок
- `tower_governor = "0.8"` - rate limiting
- `governor = "0.10"` - backend для rate limiting
- `tempfile = "3"` (dev) - временные директории для тестов
- `wiremock = "0.6"` (dev) - HTTP-мокирование для S3 тестов
### Добавлены файлы
- `src/validation.rs` - модуль валидации ввода
- `src/migrate.rs` - модуль миграции хранилища
- `src/error.rs` - типы ошибок приложения
- `src/request_id.rs` - middleware для request ID
- `src/rate_limit.rs` - конфигурация rate limiting
---
## [0.1.0] - 2026-01-24
### Добавлено
- Мульти-протокольная поддержка: Docker Registry v2, Maven, npm, Cargo, PyPI
- Web UI дашборд
- Swagger UI (`/api-docs`)
- Storage backends: локальная файловая система, S3-совместимое хранилище
- Умный прокси/кэш для Maven и npm
- Health checks (`/health`, `/ready`)
- Базовая аутентификация (htpasswd с bcrypt)
- API токены (отзываемые, per-user)
- Prometheus метрики (`/metrics`)
- JSON структурированное логирование
- Конфигурация через переменные окружения
- Graceful shutdown (SIGTERM/SIGINT)
- Команды backup/restore

36
Cargo.lock generated
View File

@@ -190,13 +190,13 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.18.0"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827"
checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
dependencies = [
"base64",
"blowfish",
"getrandom 0.3.4",
"getrandom 0.4.1",
"subtle",
"zeroize",
]
@@ -473,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]]
@@ -1247,7 +1247,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "nora-cli"
version = "0.2.27"
version = "0.2.29"
dependencies = [
"clap",
"flate2",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]]
name = "nora-registry"
version = "0.2.27"
version = "0.2.29"
dependencies = [
"async-trait",
"axum",
@@ -1299,7 +1299,7 @@ dependencies = [
[[package]]
name = "nora-storage"
version = "0.2.27"
version = "0.2.29"
dependencies = [
"axum",
"base64",
@@ -1542,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",
@@ -1779,7 +1779,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2068,7 +2068,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -2147,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",
@@ -2209,9 +2209,9 @@ dependencies = [
[[package]]
name = "toml"
version = "1.0.3+spec-1.1.0"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap",
"serde_core",
@@ -2533,9 +2533,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.21.0"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
dependencies = [
"getrandom 0.4.1",
"js-sys",
@@ -2741,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]]

View File

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

150
README.md
View File

@@ -1,11 +1,11 @@
<img src="logo.jpg" alt="NORA" height="120" />
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![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/)
[![Telegram](https://img.shields.io/badge/Telegram-DevITWay-blue?logo=telegram)](https://t.me/DevITWay)
[![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**
@@ -36,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
@@ -86,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
@@ -105,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
@@ -133,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 |
@@ -166,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 |
@@ -178,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

View File

@@ -5,7 +5,7 @@
# Vulnerability database (RustSec)
db-urls = ["https://github.com/rustsec/advisory-db"]
ignore = [
"RUSTSEC-2025-0119", # number_prefix unmaintained, transitive via indicatif; no fix available
"RUSTSEC-2025-0119", # number_prefix unmaintained via indicatif; no fix available. Review by 2026-06-15
]
[licenses]

View File

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

View File

@@ -28,7 +28,7 @@ hmac.workspace = true
hex.workspace = true
toml = "1.0"
uuid = { version = "1", features = ["v4"] }
bcrypt = "0.18"
bcrypt = "0.19"
base64 = "0.22"
prometheus = "0.14"
lazy_static = "1.5"

View File

@@ -1,12 +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,
@@ -93,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,
}
@@ -102,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,
}
@@ -110,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,
}
@@ -131,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 {
@@ -177,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,
}
}
@@ -187,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,
}
}
@@ -196,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,
}
}
@@ -309,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
@@ -377,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() {
@@ -397,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) };
@@ -407,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() {

View File

@@ -1,8 +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
@@ -25,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 {
@@ -44,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,7 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
#![allow(dead_code)]
//! Application error handling with HTTP response conversion
//!
//! Provides a unified error type that can be converted to HTTP responses
@@ -18,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 {
@@ -40,6 +40,7 @@ pub enum AppError {
Validation(#[from] ValidationError),
}
#[allow(dead_code)]
/// JSON error response body
#[derive(Serialize)]
struct ErrorResponse {
@@ -74,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 {

View File

@@ -289,6 +289,9 @@ async fn run_server(config: Config, storage: Storage) {
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);
@@ -336,7 +339,7 @@ async fn run_server(config: Config, storage: Storage) {
start_time,
auth,
tokens,
metrics: DashboardMetrics::new(),
metrics: DashboardMetrics::with_persistence(&storage_path),
activity: ActivityLog::new(50),
audit: AuditLog::new(&storage_path),
docker_auth,
@@ -387,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,
@@ -396,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

@@ -5,7 +5,7 @@
//!
//! 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

@@ -3,6 +3,7 @@
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};
@@ -181,6 +182,7 @@ async fn download_blob(
&digest,
&state.docker_auth,
state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
)
.await
{
@@ -392,6 +394,7 @@ async fn get_manifest(
&reference,
&state.docker_auth,
state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
)
.await
{
@@ -733,6 +736,7 @@ async fn fetch_blob_from_upstream(
digest: &str,
docker_auth: &DockerAuth,
timeout: u64,
basic_auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let url = format!(
"{}/v2/{}/blobs/{}",
@@ -741,13 +745,12 @@ async fn fetch_blob_from_upstream(
digest
);
// First try without auth
let response = client
.get(&url)
.timeout(Duration::from_secs(timeout))
.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
@@ -758,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
@@ -790,6 +793,7 @@ async fn fetch_manifest_from_upstream(
reference: &str,
docker_auth: &DockerAuth,
timeout: u64,
basic_auth: Option<&str>,
) -> Result<(Vec<u8>, String), ()> {
let url = format!(
"{}/v2/{}/manifests/{}",
@@ -806,14 +810,15 @@ async fn fetch_manifest_from_upstream(
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)
.timeout(Duration::from_secs(timeout))
.header("Accept", accept_header)
.send()
.await
.map_err(|e| {
.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");
})?;
@@ -830,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");

View File

@@ -1,6 +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};
@@ -36,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);
@@ -51,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)
{
@@ -70,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")?;
@@ -82,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");
@@ -97,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

@@ -3,6 +3,7 @@
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState;
use axum::{
body::Bytes,
@@ -49,10 +50,17 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
return with_content_type(&path, data).into_response();
}
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(&state.http_client, &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();
@@ -124,13 +132,13 @@ async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.map_err(|_| ())?;
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

@@ -3,6 +3,7 @@
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState;
use axum::{
body::Bytes,
@@ -59,8 +60,13 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
if let Some(proxy_url) = &state.config.npm.proxy {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
if let Ok(data) =
fetch_from_proxy(&state.http_client, &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");
@@ -98,13 +104,13 @@ async fn fetch_from_proxy(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.send()
.await
.map_err(|_| ())?;
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

@@ -3,6 +3,7 @@
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState;
use axum::{
extract::{Path, State},
@@ -86,8 +87,13 @@ 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(&state.http_client, &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);
@@ -140,6 +146,7 @@ async fn download_file(
&state.http_client,
&page_url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
@@ -149,6 +156,7 @@ async fn download_file(
&state.http_client,
&file_url,
state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
)
.await
{
@@ -202,14 +210,16 @@ async fn fetch_package_page(
client: &reqwest::Client,
url: &str,
timeout_secs: u64,
auth: Option<&str>,
) -> Result<String, ()> {
let response = client
let mut request = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.header("Accept", "text/html")
.send()
.await
.map_err(|_| ())?;
.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(());
@@ -219,13 +229,17 @@ async fn fetch_package_page(
}
/// Fetch file from upstream
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs))
.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,8 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
#![allow(dead_code)] // Foundational code for future S3/Vault integration
//! Secrets management for NORA
//!
//! Provides a trait-based architecture for secrets providers:
@@ -34,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 {
@@ -56,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

@@ -13,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 {
@@ -68,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 {
@@ -77,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

@@ -141,11 +141,14 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
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;
let total_artifacts = docker_repos.len()
+ maven_repos.len()
+ npm_repos.len()
+ cargo_repos.len()
+ pypi_repos.len();
// 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),
@@ -158,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: docker_repos.len(),
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: maven_repos.len(),
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: npm_repos.len(),
artifact_count: npm_versions,
downloads: state.metrics.get_registry_downloads("npm"),
uploads: 0,
size_bytes: npm_size,
},
RegistryCardStats {
name: "cargo".to_string(),
artifact_count: cargo_repos.len(),
artifact_count: cargo_versions,
downloads: state.metrics.get_registry_downloads("cargo"),
uploads: 0,
size_bytes: cargo_size,
},
RegistryCardStats {
name: "pypi".to_string(),
artifact_count: pypi_repos.len(),
artifact_count: pypi_versions,
downloads: state.metrics.get_registry_downloads("pypi"),
uploads: 0,
size_bytes: pypi_size,
@@ -202,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(),

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
#![allow(dead_code)]
//! Input validation for artifact registry paths and identifiers
//!
//! Provides security validation to prevent path traversal attacks and
@@ -309,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::*;