mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-13 07:20:32 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbff337b4c | |||
| a73335c549 | |||
| ad6aba46b2 | |||
| 095270d113 | |||
| 769f5fb01d | |||
| 53884e143b | |||
| 0eb26f24f7 | |||
| fa962b2d6e | |||
| a1da4fff1e | |||
| 868c4feca7 | |||
| 5b4cba1392 | |||
| ad890be56a | |||
| 3b9ea37b0e | |||
| 233b83f902 | |||
| d886426957 | |||
| 52c2443543 | |||
| 26d30b622d | |||
| 272898f43c | |||
| 61de6c6ddd | |||
| b80c7c5160 | |||
| 68089b2bbf | |||
| af411a2bf4 | |||
| 96ccd16879 | |||
| 6582000789 | |||
| 070774ac94 | |||
| 058fc41f1c | |||
| 7f5a3c7c8a | |||
| 5b57cc5913 | |||
| aa844d851d | |||
| 8569de23d5 | |||
|
|
9349b93757 | ||
|
|
69080dfd90 | ||
|
|
ae799aed94 | ||
|
|
95c6e403a8 | ||
|
|
2c886040d7 | ||
|
|
9ab6ccc594 | ||
|
|
679b36b986 | ||
|
|
da8c473e02 | ||
|
|
3dc8b81261 | ||
| 7502c583d0 |
100
.github/workflows/ci.yml
vendored
100
.github/workflows/ci.yml
vendored
@@ -51,16 +51,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
|
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
|
| tar xz -C /usr/local/bin gitleaks
|
||||||
gitleaks detect --source . --exit-code 1 --report-format sarif --report-path gitleaks.sarif || true
|
gitleaks detect --source . --exit-code 1 --report-format sarif --report-path gitleaks.sarif
|
||||||
continue-on-error: true # findings are reported, do not block the pipeline
|
|
||||||
|
|
||||||
# ── CVE in Rust dependencies ────────────────────────────────────────────
|
# ── CVE in Rust dependencies ────────────────────────────────────────────
|
||||||
- name: Install cargo-audit
|
- name: Install cargo-audit
|
||||||
run: cargo install cargo-audit --locked
|
run: cargo install cargo-audit --locked
|
||||||
|
|
||||||
- name: cargo audit — RustSec advisory database
|
- name: cargo audit — RustSec advisory database
|
||||||
run: cargo audit
|
run: cargo audit --ignore RUSTSEC-2025-0119 # known: number_prefix via indicatif
|
||||||
continue-on-error: true # warn only; known CVEs should not block CI until triaged
|
|
||||||
|
|
||||||
# ── Licenses, banned crates, supply chain policy ────────────────────────
|
# ── Licenses, banned crates, supply chain policy ────────────────────────
|
||||||
- name: cargo deny — licenses and banned crates
|
- name: cargo deny — licenses and banned crates
|
||||||
@@ -72,14 +70,14 @@ jobs:
|
|||||||
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
|
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
|
||||||
- name: Trivy — filesystem scan (Cargo.lock + source)
|
- name: Trivy — filesystem scan (Cargo.lock + source)
|
||||||
if: always()
|
if: always()
|
||||||
uses: aquasecurity/trivy-action@0.34.2
|
uses: aquasecurity/trivy-action@0.35.0
|
||||||
with:
|
with:
|
||||||
scan-type: fs
|
scan-type: fs
|
||||||
scan-ref: .
|
scan-ref: .
|
||||||
format: sarif
|
format: sarif
|
||||||
output: trivy-fs.sarif
|
output: trivy-fs.sarif
|
||||||
severity: HIGH,CRITICAL
|
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
|
- name: Upload Trivy fs results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@v4
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
@@ -87,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
|
||||||
|
|||||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -39,12 +39,12 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
driver-opts: network=host
|
driver-opts: network=host
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
# ── Alpine ───────────────────────────────────────────────────────────────
|
# ── Alpine ───────────────────────────────────────────────────────────────
|
||||||
- name: Extract metadata (alpine)
|
- name: Extract metadata (alpine)
|
||||||
id: meta-alpine
|
id: meta-alpine
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
|
|
||||||
- name: Build and push (alpine)
|
- name: Build and push (alpine)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
@@ -72,13 +72,13 @@ 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 ───────────────────────────────────────────────────────────────
|
||||||
- name: Extract metadata (redos)
|
- name: Extract metadata (redos)
|
||||||
id: meta-redos
|
id: meta-redos
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
type=raw,value=redos
|
type=raw,value=redos
|
||||||
|
|
||||||
- name: Build and push (redos)
|
- name: Build and push (redos)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.redos
|
file: Dockerfile.redos
|
||||||
@@ -98,13 +98,13 @@ 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 ───────────────────────────────────────────────────────
|
||||||
- name: Extract metadata (astra)
|
- name: Extract metadata (astra)
|
||||||
id: meta-astra
|
id: meta-astra
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
type=raw,value=astra
|
type=raw,value=astra
|
||||||
|
|
||||||
- name: Build and push (astra)
|
- name: Build and push (astra)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.astra
|
file: Dockerfile.astra
|
||||||
@@ -124,9 +124,21 @@ 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 ──────────────────────────────────────────────────────────
|
||||||
|
- 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:
|
scan:
|
||||||
name: Scan (${{ matrix.name }})
|
name: Scan (${{ matrix.name }})
|
||||||
runs-on: [self-hosted, nora]
|
runs-on: [self-hosted, nora]
|
||||||
@@ -153,7 +165,7 @@ jobs:
|
|||||||
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Trivy — image scan (${{ matrix.name }})
|
- name: Trivy — image scan (${{ matrix.name }})
|
||||||
uses: aquasecurity/trivy-action@0.34.2
|
uses: aquasecurity/trivy-action@0.35.0
|
||||||
with:
|
with:
|
||||||
scan-type: image
|
scan-type: image
|
||||||
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
|
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,9 @@ data/
|
|||||||
*.log
|
*.log
|
||||||
internal config
|
internal config
|
||||||
|
|
||||||
|
# Backup files
|
||||||
|
*.bak
|
||||||
|
|
||||||
# Internal files
|
# Internal files
|
||||||
SESSION*.md
|
SESSION*.md
|
||||||
TODO.md
|
TODO.md
|
||||||
|
|||||||
8
.gitleaks.toml
Normal file
8
.gitleaks.toml
Normal 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"]
|
||||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -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
|
## [0.2.26] - 2026-03-03
|
||||||
|
|
||||||
### Added / Добавлено
|
### Added / Добавлено
|
||||||
|
|||||||
414
CHANGELOG.md.bak
414
CHANGELOG.md.bak
@@ -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
36
Cargo.lock
generated
@@ -190,13 +190,13 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "0.18.0"
|
version = "0.19.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827"
|
checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -473,7 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1247,7 +1247,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-cli"
|
name = "nora-cli"
|
||||||
version = "0.2.27"
|
version = "0.2.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1261,7 +1261,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-registry"
|
name = "nora-registry"
|
||||||
version = "0.2.27"
|
version = "0.2.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1299,7 +1299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nora-storage"
|
name = "nora-storage"
|
||||||
version = "0.2.27"
|
version = "0.2.29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -1542,9 +1542,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.13"
|
version = "0.11.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
@@ -1779,7 +1779,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2068,7 +2068,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.1",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2147,9 +2147,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.49.0"
|
version = "1.50.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -2209,9 +2209,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
|
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
@@ -2533,9 +2533,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.21.0"
|
version = "1.22.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.1",
|
"getrandom 0.4.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -2741,7 +2741,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.27"
|
version = "0.2.29"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["DevITWay <devitway@gmail.com>"]
|
authors = ["DevITWay <devitway@gmail.com>"]
|
||||||
|
|||||||
150
README.md
150
README.md
@@ -1,11 +1,11 @@
|
|||||||
<img src="logo.jpg" alt="NORA" height="120" />
|
|
||||||
|
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/getnora-io/nora/releases)
|
[](https://github.com/getnora-io/nora/releases)
|
||||||
[](https://github.com/getnora-io/nora/actions)
|
[](https://github.com/getnora-io/nora/actions)
|
||||||
|
[](https://github.com/getnora-io/nora/pkgs/container/nora)
|
||||||
|
[](https://github.com/getnora-io/nora/stargazers)
|
||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://t.me/DevITWay)
|
[](https://getnora.dev)
|
||||||
|
[](https://t.me/getnora)
|
||||||
|
|
||||||
> **Your Cloud-Native Artifact Registry**
|
> **Your Cloud-Native Artifact Registry**
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -86,6 +88,39 @@ npm config set registry http://localhost:4000/npm/
|
|||||||
npm publish
|
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
|
## CLI Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -105,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
|
||||||
|
|
||||||
@@ -133,24 +160,10 @@ path = "data/storage"
|
|||||||
enabled = false
|
enabled = false
|
||||||
htpasswd_file = "users.htpasswd"
|
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
|
## Endpoints
|
||||||
|
|
||||||
| URL | Description |
|
| URL | Description |
|
||||||
@@ -166,6 +179,77 @@ clear_env = false
|
|||||||
| `/cargo/` | Cargo |
|
| `/cargo/` | Cargo |
|
||||||
| `/simple/` | PyPI |
|
| `/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
|
## Performance
|
||||||
|
|
||||||
| Metric | NORA | Nexus | JFrog |
|
| Metric | NORA | Nexus | JFrog |
|
||||||
@@ -178,7 +262,7 @@ clear_env = false
|
|||||||
|
|
||||||
**Created and maintained by [DevITWay](https://github.com/devitway)**
|
**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)
|
- 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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# Vulnerability database (RustSec)
|
# Vulnerability database (RustSec)
|
||||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||||
ignore = [
|
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]
|
[licenses]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
nora:
|
nora:
|
||||||
build: .
|
build: .
|
||||||
image: getnora/nora:latest
|
image: ghcr.io/getnora-io/nora:latest
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ hmac.workspace = true
|
|||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
toml = "1.0"
|
toml = "1.0"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
bcrypt = "0.18"
|
bcrypt = "0.19"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
prometheus = "0.14"
|
prometheus = "0.14"
|
||||||
lazy_static = "1.5"
|
lazy_static = "1.5"
|
||||||
|
|||||||
@@ -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,6 +108,8 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -110,6 +118,8 @@ pub struct NpmConfig {
|
|||||||
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 +141,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 {
|
||||||
@@ -177,7 +218,9 @@ fn default_timeout() -> u64 {
|
|||||||
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,6 +230,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +240,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 +354,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 +453,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() {
|
||||||
@@ -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
|
// PyPI config
|
||||||
if let Ok(val) = env::var("NORA_PYPI_PROXY") {
|
if let Ok(val) = env::var("NORA_PYPI_PROXY") {
|
||||||
self.pypi.proxy = if val.is_empty() { None } else { Some(val) };
|
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
|
// 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() {
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::Instant;
|
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
|
/// Dashboard metrics for tracking registry activity
|
||||||
/// Uses atomic counters for thread-safe access without locks
|
/// Uses atomic counters for thread-safe access without locks
|
||||||
@@ -25,6 +46,9 @@ pub struct DashboardMetrics {
|
|||||||
pub raw_uploads: AtomicU64,
|
pub raw_uploads: AtomicU64,
|
||||||
|
|
||||||
pub start_time: Instant,
|
pub start_time: Instant,
|
||||||
|
|
||||||
|
/// Path to metrics.json for persistence
|
||||||
|
persist_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DashboardMetrics {
|
impl DashboardMetrics {
|
||||||
@@ -44,6 +68,75 @@ impl DashboardMetrics {
|
|||||||
raw_downloads: AtomicU64::new(0),
|
raw_downloads: AtomicU64::new(0),
|
||||||
raw_uploads: AtomicU64::new(0),
|
raw_uploads: AtomicU64::new(0),
|
||||||
start_time: Instant::now(),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
//! Application error handling with HTTP response conversion
|
//! Application error handling with HTTP response conversion
|
||||||
//!
|
//!
|
||||||
//! Provides a unified error type that can be converted to HTTP responses
|
//! 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::storage::StorageError;
|
||||||
use crate::validation::ValidationError;
|
use crate::validation::ValidationError;
|
||||||
|
|
||||||
|
#[allow(dead_code)] // Wiring into handlers planned for v0.3
|
||||||
/// Application-level errors with HTTP response conversion
|
/// Application-level errors with HTTP response conversion
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
@@ -40,6 +40,7 @@ pub enum AppError {
|
|||||||
Validation(#[from] ValidationError),
|
Validation(#[from] ValidationError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
/// JSON error response body
|
/// JSON error response body
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ErrorResponse {
|
struct ErrorResponse {
|
||||||
@@ -74,6 +75,7 @@ impl IntoResponse for AppError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl AppError {
|
impl AppError {
|
||||||
/// Create a not found error
|
/// Create a not found error
|
||||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -336,7 +339,7 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
start_time,
|
start_time,
|
||||||
auth,
|
auth,
|
||||||
tokens,
|
tokens,
|
||||||
metrics: DashboardMetrics::new(),
|
metrics: DashboardMetrics::with_persistence(&storage_path),
|
||||||
activity: ActivityLog::new(50),
|
activity: ActivityLog::new(50),
|
||||||
audit: AuditLog::new(&storage_path),
|
audit: AuditLog::new(&storage_path),
|
||||||
docker_auth,
|
docker_auth,
|
||||||
@@ -387,6 +390,16 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
"Available endpoints"
|
"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
|
// Graceful shutdown on SIGTERM/SIGINT
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
@@ -396,6 +409,9 @@ async fn run_server(config: Config, storage: Storage) {
|
|||||||
.await
|
.await
|
||||||
.expect("Server error");
|
.expect("Server error");
|
||||||
|
|
||||||
|
// Save metrics on shutdown
|
||||||
|
state.metrics.save();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
uptime_seconds = state.start_time.elapsed().as_secs(),
|
uptime_seconds = state.start_time.elapsed().as_secs(),
|
||||||
"Nora shutdown complete"
|
"Nora shutdown complete"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//!
|
//!
|
||||||
//! 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)]
|
#![allow(dead_code)] // utoipa doc stubs — not called at runtime, used by derive macros
|
||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -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,14 +810,15 @@ 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| {
|
}
|
||||||
|
let response = request.send().await.map_err(|e| {
|
||||||
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
|
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");
|
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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(());
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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 {
|
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
|
||||||
{
|
{
|
||||||
if is_tarball {
|
if is_tarball {
|
||||||
state.metrics.record_download("npm");
|
state.metrics.record_download("npm");
|
||||||
@@ -98,13 +104,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(());
|
||||||
|
|||||||
@@ -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(());
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
#![allow(dead_code)] // Foundational code for future S3/Vault integration
|
|
||||||
|
|
||||||
//! Secrets management for NORA
|
//! Secrets management for NORA
|
||||||
//!
|
//!
|
||||||
//! Provides a trait-based architecture for secrets providers:
|
//! Provides a trait-based architecture for secrets providers:
|
||||||
@@ -34,6 +32,7 @@ use async_trait::async_trait;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[allow(dead_code)] // Variants used by provider impls; external error handling planned for v0.4
|
||||||
/// Secrets provider error
|
/// Secrets provider error
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum SecretsError {
|
pub enum SecretsError {
|
||||||
@@ -56,9 +55,11 @@ pub enum SecretsError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SecretsProvider: Send + Sync {
|
pub trait SecretsProvider: Send + Sync {
|
||||||
/// Get a secret by key (required)
|
/// Get a secret by key (required)
|
||||||
|
#[allow(dead_code)]
|
||||||
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError>;
|
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError>;
|
||||||
|
|
||||||
/// Get a secret by key (optional, returns None if not found)
|
/// Get a secret by key (optional, returns None if not found)
|
||||||
|
#[allow(dead_code)]
|
||||||
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
|
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
|
||||||
self.get_secret(key).await.ok()
|
self.get_secret(key).await.ok()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ use zeroize::{Zeroize, Zeroizing};
|
|||||||
/// - Implements Zeroize: memory is overwritten with zeros when dropped
|
/// - Implements Zeroize: memory is overwritten with zeros when dropped
|
||||||
/// - Debug shows `***REDACTED***` instead of actual value
|
/// - Debug shows `***REDACTED***` instead of actual value
|
||||||
/// - Clone creates a new protected copy
|
/// - Clone creates a new protected copy
|
||||||
|
#[allow(dead_code)] // Used internally by SecretsProvider impls; external callers planned for v0.4
|
||||||
#[derive(Clone, Zeroize)]
|
#[derive(Clone, Zeroize)]
|
||||||
#[zeroize(drop)]
|
#[zeroize(drop)]
|
||||||
pub struct ProtectedString {
|
pub struct ProtectedString {
|
||||||
inner: String,
|
inner: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl ProtectedString {
|
impl ProtectedString {
|
||||||
/// Create a new protected string
|
/// Create a new protected string
|
||||||
pub fn new(value: String) -> Self {
|
pub fn new(value: String) -> Self {
|
||||||
@@ -68,6 +70,7 @@ impl From<&str> for ProtectedString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// S3 credentials with protected secrets
|
/// S3 credentials with protected secrets
|
||||||
|
#[allow(dead_code)] // S3 storage backend planned for v0.4
|
||||||
#[derive(Clone, Zeroize)]
|
#[derive(Clone, Zeroize)]
|
||||||
#[zeroize(drop)]
|
#[zeroize(drop)]
|
||||||
pub struct S3Credentials {
|
pub struct S3Credentials {
|
||||||
@@ -77,6 +80,7 @@ pub struct S3Credentials {
|
|||||||
pub region: Option<String>,
|
pub region: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl S3Credentials {
|
impl S3Credentials {
|
||||||
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -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 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_storage = docker_size + maven_size + npm_size + cargo_size + pypi_size;
|
||||||
|
|
||||||
let total_artifacts = docker_repos.len()
|
// Count total versions/tags, not just repositories
|
||||||
+ maven_repos.len()
|
let docker_versions: usize = docker_repos.iter().map(|r| r.versions).sum();
|
||||||
+ npm_repos.len()
|
let maven_versions: usize = maven_repos.iter().map(|r| r.versions).sum();
|
||||||
+ cargo_repos.len()
|
let npm_versions: usize = npm_repos.iter().map(|r| r.versions).sum();
|
||||||
+ pypi_repos.len();
|
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 {
|
let global_stats = GlobalStats {
|
||||||
downloads: state.metrics.downloads.load(Ordering::Relaxed),
|
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![
|
let registry_card_stats = vec![
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "docker".to_string(),
|
name: "docker".to_string(),
|
||||||
artifact_count: docker_repos.len(),
|
artifact_count: docker_versions,
|
||||||
downloads: state.metrics.get_registry_downloads("docker"),
|
downloads: state.metrics.get_registry_downloads("docker"),
|
||||||
uploads: state.metrics.get_registry_uploads("docker"),
|
uploads: state.metrics.get_registry_uploads("docker"),
|
||||||
size_bytes: docker_size,
|
size_bytes: docker_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "maven".to_string(),
|
name: "maven".to_string(),
|
||||||
artifact_count: maven_repos.len(),
|
artifact_count: maven_versions,
|
||||||
downloads: state.metrics.get_registry_downloads("maven"),
|
downloads: state.metrics.get_registry_downloads("maven"),
|
||||||
uploads: state.metrics.get_registry_uploads("maven"),
|
uploads: state.metrics.get_registry_uploads("maven"),
|
||||||
size_bytes: maven_size,
|
size_bytes: maven_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "npm".to_string(),
|
name: "npm".to_string(),
|
||||||
artifact_count: npm_repos.len(),
|
artifact_count: npm_versions,
|
||||||
downloads: state.metrics.get_registry_downloads("npm"),
|
downloads: state.metrics.get_registry_downloads("npm"),
|
||||||
uploads: 0,
|
uploads: 0,
|
||||||
size_bytes: npm_size,
|
size_bytes: npm_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "cargo".to_string(),
|
name: "cargo".to_string(),
|
||||||
artifact_count: cargo_repos.len(),
|
artifact_count: cargo_versions,
|
||||||
downloads: state.metrics.get_registry_downloads("cargo"),
|
downloads: state.metrics.get_registry_downloads("cargo"),
|
||||||
uploads: 0,
|
uploads: 0,
|
||||||
size_bytes: cargo_size,
|
size_bytes: cargo_size,
|
||||||
},
|
},
|
||||||
RegistryCardStats {
|
RegistryCardStats {
|
||||||
name: "pypi".to_string(),
|
name: "pypi".to_string(),
|
||||||
artifact_count: pypi_repos.len(),
|
artifact_count: pypi_versions,
|
||||||
downloads: state.metrics.get_registry_downloads("pypi"),
|
downloads: state.metrics.get_registry_downloads("pypi"),
|
||||||
uploads: 0,
|
uploads: 0,
|
||||||
size_bytes: pypi_size,
|
size_bytes: pypi_size,
|
||||||
@@ -202,7 +205,12 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
|||||||
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(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
//! Input validation for artifact registry paths and identifiers
|
//! Input validation for artifact registry paths and identifiers
|
||||||
//!
|
//!
|
||||||
//! Provides security validation to prevent path traversal attacks and
|
//! Provides security validation to prevent path traversal attacks and
|
||||||
@@ -309,63 +308,6 @@ pub fn validate_docker_reference(reference: &str) -> Result<(), ValidationError>
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user