17 Commits

Author SHA1 Message Date
b3b74b8b2d feat: npm full proxy — URL rewriting, scoped packages, publish, integrity cache (v0.2.31)
npm proxy:
- Rewrite tarball URLs in metadata to point to NORA (was broken — tarballs bypassed NORA)
- Scoped packages (@scope/package) full support in handler and repo index
- Metadata cache TTL (NORA_NPM_METADATA_TTL, default 300s) with stale-while-revalidate
- proxy_auth now wired into fetch_from_proxy (was configured but unused)

npm publish:
- PUT /npm/{package} — accepts standard npm publish payload
- Version immutability — 409 Conflict on duplicate version
- Tarball URL rewriting in published metadata

Security:
- SHA256 integrity verification on cached tarballs (immutable cache)
- Attachment filename validation (path traversal protection)
- Package name mismatch detection (URL vs payload)

Config:
- npm.metadata_ttl — configurable cache TTL (env: NORA_NPM_METADATA_TTL)
2026-03-16 12:32:16 +00:00
d41b55fa3a style: cargo fmt 2026-03-16 08:58:27 +00:00
5a68bfd695 fix: dashboard — docker namespaced repos, npm proxy cache, upstream display (v0.2.30) 2026-03-16 08:55:33 +00:00
9c8fee5a5d docs: rewrite README — new slogan, roadmap, trim TLS/FSTEC, fix config example 2026-03-16 07:39:43 +00:00
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
18 changed files with 1257 additions and 232 deletions

View File

@@ -85,3 +85,93 @@ jobs:
with: with:
sarif_file: trivy-fs.sarif sarif_file: trivy-fs.sarif
category: trivy-fs 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

@@ -72,7 +72,7 @@ jobs:
push: true push: true
tags: ${{ steps.meta-alpine.outputs.tags }} tags: ${{ steps.meta-alpine.outputs.tags }}
labels: ${{ steps.meta-alpine.outputs.labels }} 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 cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,mode=max
# ── RED OS ─────────────────────────────────────────────────────────────── # ── RED OS ───────────────────────────────────────────────────────────────
@@ -98,7 +98,7 @@ jobs:
push: true push: true
tags: ${{ steps.meta-redos.outputs.tags }} tags: ${{ steps.meta-redos.outputs.tags }}
labels: ${{ steps.meta-redos.outputs.labels }} 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 cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,mode=max
# ── Astra Linux SE ─────────────────────────────────────────────────────── # ── Astra Linux SE ───────────────────────────────────────────────────────
@@ -124,13 +124,14 @@ jobs:
push: true push: true
tags: ${{ steps.meta-astra.outputs.tags }} tags: ${{ steps.meta-astra.outputs.tags }}
labels: ${{ steps.meta-astra.outputs.labels }} 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 cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,mode=max
# ── Smoke test ────────────────────────────────────────────────────────── # ── Smoke test ──────────────────────────────────────────────────────────
- name: Smoke test — verify alpine image starts and responds - name: Smoke test — verify alpine image starts and responds
run: | run: |
docker run --rm -d --name nora-smoke -p 5555:5000 \ 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 ${{ env.NORA }}/${{ env.IMAGE_NAME }}:latest
for i in $(seq 1 10); do for i in $(seq 1 10); do
curl -sf http://localhost:5555/health && break || sleep 2 curl -sf http://localhost:5555/health && break || sleep 2

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,48 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [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.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.28] - 2026-03-13 ## [0.2.28] - 2026-03-13
### Fixed / Исправлено ### Fixed / Исправлено
@@ -24,6 +66,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.27] - 2026-03-03 ## [0.2.27] - 2026-03-03
### Added / Добавлено ### Added / Добавлено
@@ -40,6 +92,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.26] - 2026-03-03 ## [0.2.26] - 2026-03-03
### Added / Добавлено ### Added / Добавлено
@@ -64,6 +126,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.25] - 2026-03-03 ## [0.2.25] - 2026-03-03
### Fixed / Исправлено ### Fixed / Исправлено
@@ -90,6 +162,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.24] - 2026-02-24 ## [0.2.24] - 2026-02-24
### Added / Добавлено ### Added / Добавлено
@@ -102,6 +184,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.23] - 2026-02-24 ## [0.2.23] - 2026-02-24
### Added / Добавлено ### Added / Добавлено
@@ -137,6 +229,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.22] - 2026-02-24 ## [0.2.22] - 2026-02-24
### Changed / Изменено ### Changed / Изменено
@@ -145,6 +247,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.21] - 2026-02-24 ## [0.2.21] - 2026-02-24
### CI/CD ### CI/CD
@@ -159,6 +271,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.20] - 2026-02-23 ## [0.2.20] - 2026-02-23
### Added / Добавлено ### Added / Добавлено
@@ -174,6 +296,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.19] - 2026-01-31 ## [0.2.19] - 2026-01-31
### Added / Добавлено ### Added / Добавлено
@@ -188,6 +320,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.18] - 2026-01-31 ## [0.2.18] - 2026-01-31
### Changed ### Changed
@@ -195,6 +337,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.17] - 2026-01-31 ## [0.2.17] - 2026-01-31
### Added ### Added
@@ -203,6 +355,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.16] - 2026-01-31 ## [0.2.16] - 2026-01-31
### Changed ### Changed
@@ -211,6 +373,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.15] - 2026-01-31 ## [0.2.15] - 2026-01-31
### Fixed ### Fixed
@@ -218,6 +390,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.14] - 2026-01-31 ## [0.2.14] - 2026-01-31
### Fixed ### Fixed
@@ -226,6 +408,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.13] - 2026-01-31 ## [0.2.13] - 2026-01-31
### Fixed ### Fixed
@@ -235,6 +427,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.12] - 2026-01-30 ## [0.2.12] - 2026-01-30
### Added ### Added
@@ -259,6 +461,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.11] - 2026-01-26 ## [0.2.11] - 2026-01-26
### Added ### Added
@@ -268,6 +480,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.10] - 2026-01-26 ## [0.2.10] - 2026-01-26
### Changed ### Changed
@@ -275,6 +497,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.9] - 2026-01-26 ## [0.2.9] - 2026-01-26
### Changed ### Changed
@@ -282,6 +514,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.8] - 2026-01-26 ## [0.2.8] - 2026-01-26
### Added ### Added
@@ -289,6 +531,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.7] - 2026-01-26 ## [0.2.7] - 2026-01-26
### Added ### Added
@@ -296,6 +548,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.6] - 2026-01-26 ## [0.2.6] - 2026-01-26
### Added ### Added
@@ -310,6 +572,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.5] - 2026-01-26 ## [0.2.5] - 2026-01-26
### Fixed ### Fixed
@@ -317,6 +589,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.4] - 2026-01-26 ## [0.2.4] - 2026-01-26
### Fixed ### Fixed
@@ -325,6 +607,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.0] - 2026-01-25 ## [0.2.0] - 2026-01-25
### Added ### Added
@@ -395,6 +687,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.1.0] - 2026-01-24 ## [0.1.0] - 2026-01-24
### Added ### Added
@@ -414,12 +716,32 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
# Журнал изменений (RU) # Журнал изменений (RU)
Все значимые изменения NORA документируются в этом файле. Все значимые изменения NORA документируются в этом файле.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.12] - 2026-01-30 ## [0.2.12] - 2026-01-30
### Добавлено ### Добавлено
@@ -444,6 +766,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.11] - 2026-01-26 ## [0.2.11] - 2026-01-26
### Добавлено ### Добавлено
@@ -453,6 +785,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.10] - 2026-01-26 ## [0.2.10] - 2026-01-26
### Изменено ### Изменено
@@ -460,6 +802,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.9] - 2026-01-26 ## [0.2.9] - 2026-01-26
### Изменено ### Изменено
@@ -467,6 +819,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.8] - 2026-01-26 ## [0.2.8] - 2026-01-26
### Добавлено ### Добавлено
@@ -474,6 +836,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.7] - 2026-01-26 ## [0.2.7] - 2026-01-26
### Добавлено ### Добавлено
@@ -481,6 +853,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.6] - 2026-01-26 ## [0.2.6] - 2026-01-26
### Добавлено ### Добавлено
@@ -495,6 +877,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.5] - 2026-01-26 ## [0.2.5] - 2026-01-26
### Исправлено ### Исправлено
@@ -502,6 +894,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.4] - 2026-01-26 ## [0.2.4] - 2026-01-26
### Исправлено ### Исправлено
@@ -510,6 +912,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.2.0] - 2026-01-25 ## [0.2.0] - 2026-01-25
### Добавлено ### Добавлено
@@ -580,6 +992,16 @@ All notable changes to NORA will be documented in this file.
--- ---
## [0.2.30] - 2026-03-16
### Fixed / Исправлено
- **Dashboard**: Docker upstream now shown in mount points table (was null)
- **Dashboard**: Docker namespaced repositories (library/alpine, grafana/grafana) now visible in UI
- **Dashboard**: npm proxy-cached packages now appear in package list
- **Dashboard**: Отображение Docker upstream в таблице точек монтирования (было null)
- **Dashboard**: Namespaced Docker-репозитории (library/alpine, grafana/grafana) теперь видны в UI
- **Dashboard**: npm-пакеты из прокси-кеша теперь отображаются в списке пакетов
## [0.1.0] - 2026-01-24 ## [0.1.0] - 2026-01-24
### Добавлено ### Добавлено

6
Cargo.lock generated
View File

@@ -1247,7 +1247,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nora-cli" name = "nora-cli"
version = "0.2.28" version = "0.2.31"
dependencies = [ dependencies = [
"clap", "clap",
"flate2", "flate2",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]] [[package]]
name = "nora-registry" name = "nora-registry"
version = "0.2.28" version = "0.2.31"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -1299,7 +1299,7 @@ dependencies = [
[[package]] [[package]]
name = "nora-storage" name = "nora-storage"
version = "0.2.28" version = "0.2.31"
dependencies = [ dependencies = [
"axum", "axum",
"base64", "base64",

View File

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

140
README.md
View File

@@ -7,11 +7,11 @@
[![Docs](https://img.shields.io/badge/docs-getnora.dev-green?logo=gitbook)](https://getnora.dev) [![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) [![Telegram](https://img.shields.io/badge/Telegram-Community-blue?logo=telegram)](https://t.me/getnora)
> **Your Cloud-Native Artifact Registry** > **Multi-protocol artifact registry that doesn't suck.**
>
> One binary. All protocols. Stupidly fast.
Fast. Organized. Feel at Home. **32 MB** binary | **< 100 MB** RAM | **3s** startup | **5** protocols
**10x faster** than Nexus | **< 100 MB RAM** | **32 MB Docker image**
## Features ## Features
@@ -36,8 +36,10 @@ Fast. Organized. Feel at Home.
- **Security** - **Security**
- Basic Auth (htpasswd + bcrypt) - Basic Auth (htpasswd + bcrypt)
- Revocable API tokens - Revocable API tokens with RBAC
- ENV-based configuration (12-Factor) - ENV-based configuration (12-Factor)
- SBOM (SPDX + CycloneDX) in every release
- See [SECURITY.md](SECURITY.md) for vulnerability reporting
## Quick Start ## Quick Start
@@ -111,37 +113,14 @@ curl -u admin:yourpassword http://localhost:4000/v2/_catalog
### API Tokens (RBAC) ### API Tokens (RBAC)
Tokens support three roles: `read`, `write`, `admin`.
```bash
# Create a write token (30 days TTL)
curl -s -X POST http://localhost:4000/api/tokens \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpassword","role":"write","ttl_days":90,"description":"CI/CD"}'
# Use token with Docker
docker login localhost:4000 -u token -p nra_<token>
# Use token with curl
curl -H "Authorization: Bearer nra_<token>" http://localhost:4000/v2/_catalog
# List tokens
curl -s -X POST http://localhost:4000/api/tokens/list \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpassword"}'
# Revoke token by hash prefix
curl -s -X POST http://localhost:4000/api/tokens/revoke \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpassword","hash_prefix":"<first 16 chars>"}'
```
| Role | Pull/Read | Push/Write | Delete/Admin | | Role | Pull/Read | Push/Write | Delete/Admin |
|------|-----------|------------|--------------| |------|-----------|------------|--------------|
| `read` | Yes | No | No | | `read` | Yes | No | No |
| `write` | Yes | Yes | No | | `write` | Yes | Yes | No |
| `admin` | Yes | Yes | Yes | | `admin` | Yes | Yes | Yes |
See [Authentication guide](https://getnora.dev/configuration/authentication/) for token management, Docker login, and CI/CD integration.
## CLI Commands ## CLI Commands
```bash ```bash
@@ -161,18 +140,10 @@ nora migrate --from local --to s3
| `NORA_HOST` | 127.0.0.1 | Bind address | | `NORA_HOST` | 127.0.0.1 | Bind address |
| `NORA_PORT` | 4000 | Port | | `NORA_PORT` | 4000 | Port |
| `NORA_STORAGE_MODE` | local | `local` or `s3` | | `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_AUTH_ENABLED` | false | Enable authentication |
| `NORA_RATE_LIMIT_AUTH_RPS` | 1 | Auth requests per second | | `NORA_DOCKER_UPSTREAMS` | `https://registry-1.docker.io` | Docker upstreams (`url\|user:pass,...`) |
| `NORA_RATE_LIMIT_AUTH_BURST` | 5 | Auth burst size |
| `NORA_RATE_LIMIT_UPLOAD_RPS` | 200 | Upload requests per second | See [full configuration reference](https://getnora.dev/configuration/settings/) for all environment variables including storage, rate limiting, proxy auth, and secrets.
| `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 |
### config.toml ### config.toml
@@ -189,24 +160,15 @@ path = "data/storage"
enabled = false enabled = false
htpasswd_file = "users.htpasswd" htpasswd_file = "users.htpasswd"
[rate_limit] [docker]
# Strict limits for authentication (brute-force protection) proxy_timeout = 60
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] [[docker.upstreams]]
# Provider: env (default), aws-secrets, vault, k8s (coming soon) url = "https://registry-1.docker.io"
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, proxy auth, and all options.
## Endpoints ## Endpoints
| URL | Description | | URL | Description |
@@ -224,20 +186,7 @@ clear_env = false
## TLS / HTTPS ## TLS / HTTPS
NORA serves plain HTTP by design. **TLS is intentionally not built into the binary** — this is a deliberate architectural decision: NORA serves plain HTTP. Use a reverse proxy for TLS:
- **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 { registry.example.com {
@@ -245,27 +194,7 @@ registry.example.com {
} }
``` ```
Nginx example: For internal networks without TLS, configure Docker:
```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 ```json
// /etc/docker/daemon.json // /etc/docker/daemon.json
@@ -274,24 +203,11 @@ If you run NORA without TLS (e.g., on a private network), configure Docker to tr
} }
``` ```
Then restart Docker: See [TLS / HTTPS guide](https://getnora.dev/configuration/tls/) for Nginx, Traefik, and custom CA setup.
```bash
sudo systemctl restart docker
```
> **Note:** `insecure-registries` disables TLS verification for that host. Use only on trusted networks.
## FSTEC-Certified OS Builds ## FSTEC-Certified OS Builds
NORA provides dedicated Dockerfiles for Russian FSTEC-certified operating systems: Dedicated builds for Astra Linux SE and RED OS are published as `-astra` and `-redos` tagged images in every [GitHub Release](https://github.com/getnora-io/nora/releases). Both use `scratch` base with statically-linked binary.
- `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 ## Performance
@@ -301,11 +217,21 @@ These builds are published as `-astra` and `-redos` tagged images in GitHub Rele
| Memory | < 100 MB | 2-4 GB | 2-4 GB | | Memory | < 100 MB | 2-4 GB | 2-4 GB |
| Image Size | 32 MB | 600+ MB | 1+ GB | | Image Size | 32 MB | 600+ MB | 1+ GB |
## Roadmap
- **OIDC / Workload Identity** — zero-secret auth for GitHub Actions, GitLab CI
- **Online Garbage Collection** — non-blocking cleanup without registry downtime
- **Retention Policies** — declarative rules: keep last N tags, delete older than X days
- **Image Signing** — cosign/notation verification and policy enforcement
- **Replication** — push/pull sync between NORA instances
See [CHANGELOG.md](CHANGELOG.md) for release history.
## Author ## Author
**Created and maintained by [DevITWay](https://github.com/devitway)** **Created and maintained by [DevITWay](https://github.com/devitway)**
- Website: [getnora.io](https://getnora.io) - Website: [getnora.dev](https://getnora.dev)
- Telegram: [@DevITWay](https://t.me/DevITWay) - Telegram: [@DevITWay](https://t.me/DevITWay)
- GitHub: [@devitway](https://github.com/devitway) - GitHub: [@devitway](https://github.com/devitway)
- Email: devitway@gmail.com - Email: devitway@gmail.com

View File

@@ -1,12 +1,18 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay // Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use base64::{engine::general_purpose::STANDARD, Engine};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::env; use std::env;
use std::fs; use std::fs;
pub use crate::secrets::SecretsConfig; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
pub server: ServerConfig, pub server: ServerConfig,
@@ -93,7 +99,7 @@ fn default_bucket() -> String {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MavenConfig { pub struct MavenConfig {
#[serde(default)] #[serde(default)]
pub proxies: Vec<String>, pub proxies: Vec<MavenProxyEntry>,
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub proxy_timeout: u64, pub proxy_timeout: u64,
} }
@@ -102,14 +108,21 @@ pub struct MavenConfig {
pub struct NpmConfig { pub struct NpmConfig {
#[serde(default)] #[serde(default)]
pub proxy: Option<String>, pub proxy: Option<String>,
#[serde(default)]
pub proxy_auth: Option<String>, // "user:pass" for basic auth
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub proxy_timeout: u64, pub proxy_timeout: u64,
/// Metadata cache TTL in seconds (default: 300 = 5 min). Set to 0 to cache forever.
#[serde(default = "default_metadata_ttl")]
pub metadata_ttl: u64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PypiConfig { pub struct PypiConfig {
#[serde(default)] #[serde(default)]
pub proxy: Option<String>, pub proxy: Option<String>,
#[serde(default)]
pub proxy_auth: Option<String>, // "user:pass" for basic auth
#[serde(default = "default_timeout")] #[serde(default = "default_timeout")]
pub proxy_timeout: u64, pub proxy_timeout: u64,
} }
@@ -131,6 +144,37 @@ pub struct DockerUpstream {
pub auth: Option<String>, // "user:pass" for basic auth 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 /// Raw repository configuration for simple file storage
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawConfig { pub struct RawConfig {
@@ -174,10 +218,16 @@ fn default_timeout() -> u64 {
30 30
} }
fn default_metadata_ttl() -> u64 {
300 // 5 minutes
}
impl Default for MavenConfig { impl Default for MavenConfig {
fn default() -> Self { fn default() -> Self {
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, proxy_timeout: 30,
} }
} }
@@ -187,7 +237,9 @@ impl Default for NpmConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
proxy: Some("https://registry.npmjs.org".to_string()), proxy: Some("https://registry.npmjs.org".to_string()),
proxy_auth: None,
proxy_timeout: 30, proxy_timeout: 30,
metadata_ttl: 300,
} }
} }
} }
@@ -196,6 +248,7 @@ impl Default for PypiConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
proxy: Some("https://pypi.org/simple/".to_string()), proxy: Some("https://pypi.org/simple/".to_string()),
proxy_auth: None,
proxy_timeout: 30, proxy_timeout: 30,
} }
} }
@@ -309,6 +362,37 @@ impl Default for RateLimitConfig {
} }
impl Config { 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 /// Load configuration with priority: ENV > config.toml > defaults
pub fn load() -> Self { pub fn load() -> Self {
// 1. Start with defaults // 1. Start with defaults
@@ -377,9 +461,23 @@ impl Config {
self.auth.htpasswd_file = val; 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") { 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(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() { if let Ok(timeout) = val.parse() {
@@ -396,6 +494,16 @@ impl Config {
self.npm.proxy_timeout = timeout; self.npm.proxy_timeout = timeout;
} }
} }
if let Ok(val) = env::var("NORA_NPM_METADATA_TTL") {
if let Ok(ttl) = val.parse() {
self.npm.metadata_ttl = ttl;
}
}
// 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 // PyPI config
if let Ok(val) = env::var("NORA_PYPI_PROXY") { if let Ok(val) = env::var("NORA_PYPI_PROXY") {
@@ -407,6 +515,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 // Docker config
if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") { if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") {
if let Ok(timeout) = val.parse() { if let Ok(timeout) = val.parse() {

View File

@@ -289,6 +289,9 @@ async fn run_server(config: Config, storage: Storage) {
let storage_path = config.storage.path.clone(); let storage_path = config.storage.path.clone();
let rate_limit_enabled = config.rate_limit.enabled; 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 // Initialize Docker auth with proxy timeout
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout); let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);

View File

@@ -5,7 +5,6 @@
//! //!
//! Functions in this module are stubs used only for generating OpenAPI documentation. //! Functions in this module are stubs used only for generating OpenAPI documentation.
#![allow(dead_code)] // utoipa doc stubs — not called at runtime, used by derive macros #![allow(dead_code)] // utoipa doc stubs — not called at runtime, used by derive macros
use axum::Router; use axum::Router;

View File

@@ -3,6 +3,7 @@
use crate::activity_log::{ActionType, ActivityEntry}; use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry; use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::registry::docker_auth::DockerAuth; use crate::registry::docker_auth::DockerAuth;
use crate::storage::Storage; use crate::storage::Storage;
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference}; use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
@@ -181,6 +182,7 @@ async fn download_blob(
&digest, &digest,
&state.docker_auth, &state.docker_auth,
state.config.docker.proxy_timeout, state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
) )
.await .await
{ {
@@ -392,6 +394,7 @@ async fn get_manifest(
&reference, &reference,
&state.docker_auth, &state.docker_auth,
state.config.docker.proxy_timeout, state.config.docker.proxy_timeout,
upstream.auth.as_deref(),
) )
.await .await
{ {
@@ -733,6 +736,7 @@ async fn fetch_blob_from_upstream(
digest: &str, digest: &str,
docker_auth: &DockerAuth, docker_auth: &DockerAuth,
timeout: u64, timeout: u64,
basic_auth: Option<&str>,
) -> Result<Vec<u8>, ()> { ) -> Result<Vec<u8>, ()> {
let url = format!( let url = format!(
"{}/v2/{}/blobs/{}", "{}/v2/{}/blobs/{}",
@@ -741,13 +745,12 @@ async fn fetch_blob_from_upstream(
digest digest
); );
// First try without auth // First try with basic auth if configured
let response = client let mut request = client.get(&url).timeout(Duration::from_secs(timeout));
.get(&url) if let Some(credentials) = basic_auth {
.timeout(Duration::from_secs(timeout)) request = request.header("Authorization", basic_auth_header(credentials));
.send() }
.await let response = request.send().await.map_err(|_| ())?;
.map_err(|_| ())?;
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED { let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Get Www-Authenticate header and fetch token // Get Www-Authenticate header and fetch token
@@ -758,7 +761,7 @@ async fn fetch_blob_from_upstream(
.map(String::from); .map(String::from);
if let Some(token) = docker_auth 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 .await
{ {
client client
@@ -790,6 +793,7 @@ async fn fetch_manifest_from_upstream(
reference: &str, reference: &str,
docker_auth: &DockerAuth, docker_auth: &DockerAuth,
timeout: u64, timeout: u64,
basic_auth: Option<&str>,
) -> Result<(Vec<u8>, String), ()> { ) -> Result<(Vec<u8>, String), ()> {
let url = format!( let url = format!(
"{}/v2/{}/manifests/{}", "{}/v2/{}/manifests/{}",
@@ -806,16 +810,17 @@ async fn fetch_manifest_from_upstream(
application/vnd.oci.image.manifest.v1+json, \ application/vnd.oci.image.manifest.v1+json, \
application/vnd.oci.image.index.v1+json"; application/vnd.oci.image.index.v1+json";
// First try without auth // First try with basic auth if configured
let response = client let mut request = client
.get(&url) .get(&url)
.timeout(Duration::from_secs(timeout)) .timeout(Duration::from_secs(timeout))
.header("Accept", accept_header) .header("Accept", accept_header);
.send() if let Some(credentials) = basic_auth {
.await request = request.header("Authorization", basic_auth_header(credentials));
.map_err(|e| { }
tracing::error!(error = %e, url = %url, "Failed to send request to upstream"); 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"); tracing::debug!(status = %response.status(), "Initial upstream response");
@@ -830,7 +835,7 @@ async fn fetch_manifest_from_upstream(
tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token"); tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token");
if let Some(token) = docker_auth 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 .await
{ {
tracing::debug!("Token acquired, retrying with auth"); tracing::debug!("Token acquired, retrying with auth");

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay // Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use crate::config::basic_auth_header;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -36,6 +37,7 @@ impl DockerAuth {
registry_url: &str, registry_url: &str,
name: &str, name: &str,
www_authenticate: Option<&str>, www_authenticate: Option<&str>,
basic_auth: Option<&str>,
) -> Option<String> { ) -> Option<String> {
let cache_key = format!("{}:{}", registry_url, name); let cache_key = format!("{}:{}", registry_url, name);
@@ -51,7 +53,7 @@ impl DockerAuth {
// Need to fetch a new token // Need to fetch a new token
let www_auth = www_authenticate?; 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) // Cache the token (default 5 minute expiry)
{ {
@@ -70,7 +72,12 @@ impl DockerAuth {
/// Parse Www-Authenticate header and fetch token from auth server /// 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" /// 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 params = parse_www_authenticate(www_authenticate)?;
let realm = params.get("realm")?; let realm = params.get("realm")?;
@@ -82,7 +89,13 @@ impl DockerAuth {
tracing::debug!(url = %url, "Fetching auth token"); 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() { if !response.status().is_success() {
tracing::warn!(status = %response.status(), "Token request failed"); tracing::warn!(status = %response.status(), "Token request failed");
@@ -97,44 +110,6 @@ impl DockerAuth {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(String::from) .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 { impl Default for DockerAuth {

View File

@@ -3,6 +3,7 @@
use crate::activity_log::{ActionType, ActivityEntry}; use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry; use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
body::Bytes, 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(); return with_content_type(&path, data).into_response();
} }
for proxy_url in &state.config.maven.proxies { for proxy in &state.config.maven.proxies {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path); 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) => { Ok(data) => {
state.metrics.record_download("maven"); state.metrics.record_download("maven");
state.metrics.record_cache_miss(); state.metrics.record_cache_miss();
@@ -124,13 +132,13 @@ async fn fetch_from_proxy(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
timeout_secs: u64, timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> { ) -> Result<Vec<u8>, ()> {
let response = client let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
.get(url) if let Some(credentials) = auth {
.timeout(Duration::from_secs(timeout_secs)) request = request.header("Authorization", basic_auth_header(credentials));
.send() }
.await let response = request.send().await.map_err(|_| ())?;
.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());

View File

@@ -3,27 +3,71 @@
use crate::activity_log::{ActionType, ActivityEntry}; use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry; use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{Path, State}, extract::{Path, State},
http::{header, StatusCode}, http::{header, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::{get, put},
Router, Router,
}; };
use base64::Engine;
use sha2::Digest;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
pub fn routes() -> Router<Arc<AppState>> { pub fn routes() -> Router<Arc<AppState>> {
Router::new().route("/npm/{*path}", get(handle_request)) Router::new()
.route("/npm/{*path}", get(handle_request))
.route("/npm/{*path}", put(handle_publish))
}
/// Build NORA base URL from config (for URL rewriting)
fn nora_base_url(state: &AppState) -> String {
state.config.server.public_url.clone().unwrap_or_else(|| {
format!(
"http://{}:{}",
state.config.server.host, state.config.server.port
)
})
}
/// Rewrite tarball URLs in npm metadata to point to NORA.
///
/// Replaces upstream registry URLs (e.g. `https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz`)
/// with NORA URLs (e.g. `http://nora:5000/npm/lodash/-/lodash-4.17.21.tgz`).
fn rewrite_tarball_urls(data: &[u8], nora_base: &str, upstream_url: &str) -> Result<Vec<u8>, ()> {
let mut json: serde_json::Value = serde_json::from_slice(data).map_err(|_| ())?;
let upstream_trimmed = upstream_url.trim_end_matches('/');
let nora_npm_base = format!("{}/npm", nora_base.trim_end_matches('/'));
if let Some(versions) = json.get_mut("versions").and_then(|v| v.as_object_mut()) {
for (_ver, version_data) in versions.iter_mut() {
if let Some(tarball_url) = version_data
.get("dist")
.and_then(|d| d.get("tarball"))
.and_then(|t| t.as_str())
.map(|s| s.to_string())
{
let rewritten = tarball_url.replace(upstream_trimmed, &nora_npm_base);
if let Some(dist) = version_data.get_mut("dist") {
dist["tarball"] = serde_json::Value::String(rewritten);
}
}
}
}
serde_json::to_vec(&json).map_err(|_| ())
} }
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response { async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
let is_tarball = path.contains("/-/"); let is_tarball = path.contains("/-/");
let key = if is_tarball { let key = if is_tarball {
let parts: Vec<&str> = path.split("/-/").collect(); let parts: Vec<&str> = path.splitn(2, "/-/").collect();
if parts.len() == 2 { if parts.len() == 2 {
format!("npm/{}/tarballs/{}", parts[0], parts[1]) format!("npm/{}/tarballs/{}", parts[0], parts[1])
} else { } else {
@@ -39,30 +83,83 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
path.clone() path.clone()
}; };
// --- Cache hit path ---
if let Ok(data) = state.storage.get(&key).await { if let Ok(data) = state.storage.get(&key).await {
if is_tarball { // Metadata TTL: if stale, try to refetch from upstream
state.metrics.record_download("npm"); if !is_tarball {
state.metrics.record_cache_hit(); let ttl = state.config.npm.metadata_ttl;
state.activity.push(ActivityEntry::new( if ttl > 0 {
ActionType::CacheHit, if let Some(meta) = state.storage.stat(&key).await {
package_name, let now = std::time::SystemTime::now()
"npm", .duration_since(std::time::UNIX_EPOCH)
"CACHE", .map(|d| d.as_secs())
)); .unwrap_or(0);
state if now.saturating_sub(meta.modified) > ttl {
.audit if let Some(fresh) = refetch_metadata(&state, &path, &key).await {
.log(AuditEntry::new("cache_hit", "api", "", "npm", "")); return with_content_type(false, fresh.into()).into_response();
}
// Upstream failed — serve stale cache
}
}
}
return with_content_type(false, data).into_response();
} }
return with_content_type(is_tarball, data).into_response();
// Tarball: integrity check if hash exists
let hash_key = format!("{}.sha256", key);
if let Ok(stored_hash) = state.storage.get(&hash_key).await {
let computed = format!("{:x}", sha2::Sha256::digest(&data));
let expected = String::from_utf8_lossy(&stored_hash);
if computed != expected.as_ref() {
tracing::error!(
key = %key,
expected = %expected,
computed = %computed,
"SECURITY: npm tarball integrity check FAILED — possible tampering"
);
return (StatusCode::INTERNAL_SERVER_ERROR, "Integrity check failed")
.into_response();
}
}
state.metrics.record_download("npm");
state.metrics.record_cache_hit();
state.activity.push(ActivityEntry::new(
ActionType::CacheHit,
package_name,
"npm",
"CACHE",
));
state
.audit
.log(AuditEntry::new("cache_hit", "api", "", "npm", ""));
return with_content_type(true, data).into_response();
} }
// --- Proxy fetch path ---
if let Some(proxy_url) = &state.config.npm.proxy { if let Some(proxy_url) = &state.config.npm.proxy {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path); let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
if let Ok(data) = if let Ok(data) = fetch_from_proxy(
fetch_from_proxy(&state.http_client, &url, state.config.npm.proxy_timeout).await &state.http_client,
&url,
state.config.npm.proxy_timeout,
state.config.npm.proxy_auth.as_deref(),
)
.await
{ {
let data_to_cache;
let data_to_serve;
if is_tarball { if is_tarball {
// Compute and store sha256
let hash = format!("{:x}", sha2::Sha256::digest(&data));
let hash_key = format!("{}.sha256", key);
let storage = state.storage.clone();
tokio::spawn(async move {
let _ = storage.put(&hash_key, hash.as_bytes()).await;
});
state.metrics.record_download("npm"); state.metrics.record_download("npm");
state.metrics.record_cache_miss(); state.metrics.record_cache_miss();
state.activity.push(ActivityEntry::new( state.activity.push(ActivityEntry::new(
@@ -74,37 +171,265 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
state state
.audit .audit
.log(AuditEntry::new("proxy_fetch", "api", "", "npm", "")); .log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
data_to_cache = data.clone();
data_to_serve = data;
} else {
// Metadata: rewrite tarball URLs to point to NORA
let nora_base = nora_base_url(&state);
let rewritten = rewrite_tarball_urls(&data, &nora_base, proxy_url)
.unwrap_or_else(|_| data.clone());
data_to_cache = rewritten.clone();
data_to_serve = rewritten;
} }
// Cache in background
let storage = state.storage.clone(); let storage = state.storage.clone();
let key_clone = key.clone(); let key_clone = key.clone();
let data_clone = data.clone();
tokio::spawn(async move { tokio::spawn(async move {
let _ = storage.put(&key_clone, &data_clone).await; let _ = storage.put(&key_clone, &data_to_cache).await;
}); });
if is_tarball { if is_tarball {
state.repo_index.invalidate("npm"); state.repo_index.invalidate("npm");
} }
return with_content_type(is_tarball, data.into()).into_response(); return with_content_type(is_tarball, data_to_serve.into()).into_response();
} }
} }
StatusCode::NOT_FOUND.into_response() StatusCode::NOT_FOUND.into_response()
} }
/// Refetch metadata from upstream, rewrite URLs, update cache.
/// Returns None if upstream is unavailable (caller serves stale cache).
async fn refetch_metadata(state: &Arc<AppState>, path: &str, key: &str) -> Option<Vec<u8>> {
let proxy_url = state.config.npm.proxy.as_ref()?;
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
let data = fetch_from_proxy(
&state.http_client,
&url,
state.config.npm.proxy_timeout,
state.config.npm.proxy_auth.as_deref(),
)
.await
.ok()?;
let nora_base = nora_base_url(state);
let rewritten =
rewrite_tarball_urls(&data, &nora_base, proxy_url).unwrap_or_else(|_| data.clone());
let storage = state.storage.clone();
let key_clone = key.to_string();
let cache_data = rewritten.clone();
tokio::spawn(async move {
let _ = storage.put(&key_clone, &cache_data).await;
});
Some(rewritten)
}
// ============================================================================
// npm publish
// ============================================================================
/// Validate attachment filename: only safe characters, no path traversal.
fn is_valid_attachment_name(name: &str) -> bool {
!name.is_empty()
&& !name.contains("..")
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains('\0')
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@'))
}
async fn handle_publish(
State(state): State<Arc<AppState>>,
Path(path): Path<String>,
body: Bytes,
) -> Response {
let package_name = path;
let payload: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
};
// Security: verify payload name matches URL path
if let Some(payload_name) = payload.get("name").and_then(|n| n.as_str()) {
if payload_name != package_name {
tracing::warn!(
url_name = %package_name,
payload_name = %payload_name,
"SECURITY: npm publish name mismatch — possible spoofing attempt"
);
return (
StatusCode::BAD_REQUEST,
"Package name in URL does not match payload",
)
.into_response();
}
}
let attachments = match payload.get("_attachments").and_then(|a| a.as_object()) {
Some(a) => a,
None => return (StatusCode::BAD_REQUEST, "Missing _attachments").into_response(),
};
let new_versions = match payload.get("versions").and_then(|v| v.as_object()) {
Some(v) => v,
None => return (StatusCode::BAD_REQUEST, "Missing versions").into_response(),
};
// Load or create metadata
let metadata_key = format!("npm/{}/metadata.json", package_name);
let mut metadata = if let Ok(existing) = state.storage.get(&metadata_key).await {
serde_json::from_slice::<serde_json::Value>(&existing)
.unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
// Version immutability
if let Some(existing_versions) = metadata.get("versions").and_then(|v| v.as_object()) {
for ver in new_versions.keys() {
if existing_versions.contains_key(ver) {
return (
StatusCode::CONFLICT,
format!("Version {} already exists", ver),
)
.into_response();
}
}
}
// Store tarballs
for (filename, attachment_data) in attachments {
if !is_valid_attachment_name(filename) {
tracing::warn!(
filename = %filename,
package = %package_name,
"SECURITY: npm publish rejected — invalid attachment filename"
);
return (StatusCode::BAD_REQUEST, "Invalid attachment filename").into_response();
}
let base64_data = match attachment_data.get("data").and_then(|d| d.as_str()) {
Some(d) => d,
None => continue,
};
let tarball_bytes = match base64::engine::general_purpose::STANDARD.decode(base64_data) {
Ok(b) => b,
Err(_) => {
return (StatusCode::BAD_REQUEST, "Invalid base64 in attachment").into_response()
}
};
let tarball_key = format!("npm/{}/tarballs/{}", package_name, filename);
if state
.storage
.put(&tarball_key, &tarball_bytes)
.await
.is_err()
{
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
// Store sha256
let hash = format!("{:x}", sha2::Sha256::digest(&tarball_bytes));
let hash_key = format!("{}.sha256", tarball_key);
let _ = state.storage.put(&hash_key, hash.as_bytes()).await;
}
// Merge versions
let meta_obj = metadata.as_object_mut().unwrap();
let stored_versions = meta_obj.entry("versions").or_insert(serde_json::json!({}));
if let Some(sv) = stored_versions.as_object_mut() {
for (ver, ver_data) in new_versions {
sv.insert(ver.clone(), ver_data.clone());
}
}
// Copy standard fields
for field in &["name", "_id", "description", "readme", "license"] {
if let Some(val) = payload.get(*field) {
meta_obj.insert(field.to_string(), val.clone());
}
}
// Merge dist-tags
if let Some(new_dist_tags) = payload.get("dist-tags").and_then(|d| d.as_object()) {
let stored_dist_tags = meta_obj.entry("dist-tags").or_insert(serde_json::json!({}));
if let Some(sdt) = stored_dist_tags.as_object_mut() {
for (tag, ver) in new_dist_tags {
sdt.insert(tag.clone(), ver.clone());
}
}
}
// Rewrite tarball URLs for published packages
let nora_base = nora_base_url(&state);
if let Some(versions) = metadata.get_mut("versions").and_then(|v| v.as_object_mut()) {
for (ver, ver_data) in versions.iter_mut() {
if let Some(dist) = ver_data.get_mut("dist") {
let short_name = package_name.split('/').next_back().unwrap_or(&package_name);
let tarball_url = format!(
"{}/npm/{}/-/{}-{}.tgz",
nora_base.trim_end_matches('/'),
package_name,
short_name,
ver
);
dist["tarball"] = serde_json::Value::String(tarball_url);
}
}
}
// Store metadata
match serde_json::to_vec(&metadata) {
Ok(bytes) => {
if state.storage.put(&metadata_key, &bytes).await.is_err() {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
}
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
state.metrics.record_upload("npm");
state.activity.push(ActivityEntry::new(
ActionType::Push,
package_name,
"npm",
"LOCAL",
));
state
.audit
.log(AuditEntry::new("push", "api", "", "npm", ""));
state.repo_index.invalidate("npm");
StatusCode::CREATED.into_response()
}
// ============================================================================
// Helpers
// ============================================================================
async fn fetch_from_proxy( async fn fetch_from_proxy(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
timeout_secs: u64, timeout_secs: u64,
auth: Option<&str>,
) -> Result<Vec<u8>, ()> { ) -> Result<Vec<u8>, ()> {
let response = client let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
.get(url) if let Some(credentials) = auth {
.timeout(Duration::from_secs(timeout_secs)) request = request.header("Authorization", basic_auth_header(credentials));
.send() }
.await let response = request.send().await.map_err(|_| ())?;
.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());
@@ -125,3 +450,129 @@ fn with_content_type(
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data) (StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rewrite_tarball_urls_regular_package() {
let metadata = serde_json::json!({
"name": "lodash",
"versions": {
"4.17.21": {
"dist": {
"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"shasum": "abc123"
}
}
}
});
let data = serde_json::to_vec(&metadata).unwrap();
let result =
rewrite_tarball_urls(&data, "http://nora:5000", "https://registry.npmjs.org").unwrap();
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
assert_eq!(
json["versions"]["4.17.21"]["dist"]["tarball"],
"http://nora:5000/npm/lodash/-/lodash-4.17.21.tgz"
);
assert_eq!(json["versions"]["4.17.21"]["dist"]["shasum"], "abc123");
}
#[test]
fn test_rewrite_tarball_urls_scoped_package() {
let metadata = serde_json::json!({
"name": "@babel/core",
"versions": {
"7.26.0": {
"dist": {
"tarball": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
"integrity": "sha512-test"
}
}
}
});
let data = serde_json::to_vec(&metadata).unwrap();
let result =
rewrite_tarball_urls(&data, "http://nora:5000", "https://registry.npmjs.org").unwrap();
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
assert_eq!(
json["versions"]["7.26.0"]["dist"]["tarball"],
"http://nora:5000/npm/@babel/core/-/core-7.26.0.tgz"
);
}
#[test]
fn test_rewrite_tarball_urls_multiple_versions() {
let metadata = serde_json::json!({
"name": "express",
"versions": {
"4.18.2": { "dist": { "tarball": "https://registry.npmjs.org/express/-/express-4.18.2.tgz" } },
"4.19.0": { "dist": { "tarball": "https://registry.npmjs.org/express/-/express-4.19.0.tgz" } }
}
});
let data = serde_json::to_vec(&metadata).unwrap();
let result = rewrite_tarball_urls(
&data,
"https://demo.getnora.io",
"https://registry.npmjs.org",
)
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
assert_eq!(
json["versions"]["4.18.2"]["dist"]["tarball"],
"https://demo.getnora.io/npm/express/-/express-4.18.2.tgz"
);
assert_eq!(
json["versions"]["4.19.0"]["dist"]["tarball"],
"https://demo.getnora.io/npm/express/-/express-4.19.0.tgz"
);
}
#[test]
fn test_rewrite_tarball_urls_no_versions() {
let metadata = serde_json::json!({ "name": "empty-pkg" });
let data = serde_json::to_vec(&metadata).unwrap();
let result =
rewrite_tarball_urls(&data, "http://nora:5000", "https://registry.npmjs.org").unwrap();
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
assert_eq!(json["name"], "empty-pkg");
}
#[test]
fn test_rewrite_invalid_json() {
assert!(rewrite_tarball_urls(
b"not json",
"http://nora:5000",
"https://registry.npmjs.org"
)
.is_err());
}
#[test]
fn test_valid_attachment_names() {
assert!(is_valid_attachment_name("lodash-4.17.21.tgz"));
assert!(is_valid_attachment_name("core-7.26.0.tgz"));
assert!(is_valid_attachment_name("my_package-1.0.0.tgz"));
assert!(is_valid_attachment_name("@scope-pkg-1.0.0.tgz"));
}
#[test]
fn test_path_traversal_attachment_names() {
assert!(!is_valid_attachment_name("../../etc/passwd"));
assert!(!is_valid_attachment_name(
"../docker/nginx/manifests/latest.json"
));
assert!(!is_valid_attachment_name("foo/bar.tgz"));
assert!(!is_valid_attachment_name("foo\\bar.tgz"));
}
#[test]
fn test_empty_and_null_attachment_names() {
assert!(!is_valid_attachment_name(""));
assert!(!is_valid_attachment_name("foo\0bar.tgz"));
}
}

View File

@@ -3,6 +3,7 @@
use crate::activity_log::{ActionType, ActivityEntry}; use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry; use crate::audit::AuditEntry;
use crate::config::basic_auth_header;
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
@@ -86,8 +87,13 @@ async fn package_versions(
if let Some(proxy_url) = &state.config.pypi.proxy { if let Some(proxy_url) = &state.config.pypi.proxy {
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized); let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) = if let Ok(html) = fetch_package_page(
fetch_package_page(&state.http_client, &url, state.config.pypi.proxy_timeout).await &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 // Rewrite URLs in the HTML to point to our registry
let rewritten = rewrite_pypi_links(&html, &normalized); let rewritten = rewrite_pypi_links(&html, &normalized);
@@ -140,6 +146,7 @@ async fn download_file(
&state.http_client, &state.http_client,
&page_url, &page_url,
state.config.pypi.proxy_timeout, state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
) )
.await .await
{ {
@@ -149,6 +156,7 @@ async fn download_file(
&state.http_client, &state.http_client,
&file_url, &file_url,
state.config.pypi.proxy_timeout, state.config.pypi.proxy_timeout,
state.config.pypi.proxy_auth.as_deref(),
) )
.await .await
{ {
@@ -202,14 +210,16 @@ async fn fetch_package_page(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, url: &str,
timeout_secs: u64, timeout_secs: u64,
auth: Option<&str>,
) -> Result<String, ()> { ) -> Result<String, ()> {
let response = client let mut request = client
.get(url) .get(url)
.timeout(Duration::from_secs(timeout_secs)) .timeout(Duration::from_secs(timeout_secs))
.header("Accept", "text/html") .header("Accept", "text/html");
.send() if let Some(credentials) = auth {
.await request = request.header("Authorization", basic_auth_header(credentials));
.map_err(|_| ())?; }
let response = request.send().await.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());
@@ -219,13 +229,17 @@ async fn fetch_package_page(
} }
/// Fetch file from upstream /// Fetch file from upstream
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> { async fn fetch_file(
let response = client client: &reqwest::Client,
.get(url) url: &str,
.timeout(Duration::from_secs(timeout_secs)) timeout_secs: u64,
.send() auth: Option<&str>,
.await ) -> Result<Vec<u8>, ()> {
.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() { if !response.status().is_success() {
return Err(()); return Err(());

View File

@@ -244,10 +244,16 @@ async fn build_npm_index(storage: &Storage) -> Vec<RepoInfo> {
for key in &keys { for key in &keys {
if let Some(rest) = key.strip_prefix("npm/") { if let Some(rest) = key.strip_prefix("npm/") {
// Pattern: npm/{package}/tarballs/{file}.tgz // Pattern: npm/{package}/tarballs/{file}.tgz
// Scoped: npm/@scope/package/tarballs/{file}.tgz
if rest.contains("/tarballs/") && key.ends_with(".tgz") { if rest.contains("/tarballs/") && key.ends_with(".tgz") {
let parts: Vec<_> = rest.split('/').collect(); let parts: Vec<_> = rest.split('/').collect();
if !parts.is_empty() { if !parts.is_empty() {
let name = parts[0].to_string(); // Scoped packages: @scope/package → parts[0]="@scope", parts[1]="package"
let name = if parts[0].starts_with('@') && parts.len() >= 4 {
format!("{}/{}", parts[0], parts[1])
} else {
parts[0].to_string()
};
let entry = packages.entry(name).or_insert((0, 0, 0)); let entry = packages.entry(name).or_insert((0, 0, 0));
entry.0 += 1; entry.0 += 1;

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay // Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//! Secrets management for NORA //! Secrets management for NORA
//! //!
//! Provides a trait-based architecture for secrets providers: //! Provides a trait-based architecture for secrets providers:

View File

@@ -200,12 +200,17 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
MountPoint { MountPoint {
registry: "Docker".to_string(), registry: "Docker".to_string(),
mount_path: "/v2/".to_string(), mount_path: "/v2/".to_string(),
proxy_upstream: None, proxy_upstream: state.config.docker.upstreams.first().map(|u| u.url.clone()),
}, },
MountPoint { MountPoint {
registry: "Maven".to_string(), registry: "Maven".to_string(),
mount_path: "/maven2/".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 { MountPoint {
registry: "npm".to_string(), registry: "npm".to_string(),