mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-13 13:10:31 +00:00
Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bf6615a10 | |||
| 5d1c07db51 | |||
| 52b1459be9 | |||
| 325f51822f | |||
| c8dc141b2f | |||
| a09f83ffdb | |||
| 3fe483a3a9 | |||
| d909a62ac5 | |||
| 432e8d35af | |||
| 975264c353 | |||
| 533f3cd795 | |||
| 8fc741c8db | |||
| 9709471485 | |||
| 2ec0fe4a28 | |||
| 9f951ccc82 | |||
| f0cbb22bd9 | |||
| ceb75e0ce3 | |||
| 58d14a820e | |||
| ef5f4e52c3 | |||
| 35e930295c | |||
| 3246bd9ffd | |||
| cfa6a4d0ed | |||
| 79fa8e0d4a | |||
| b23765bebd | |||
| b91c5531b6 | |||
| 596b18a3fa | |||
| 07aed45518 | |||
| 4ec963d41c | |||
| 7f7e3e4986 | |||
| d51f176fd8 | |||
| 34d30433cb | |||
| a6db016d7d | |||
| fbd2aa35e8 | |||
| fa2cd45ed3 | |||
| f76dab1184 | |||
| e6043a6e2f | |||
| 54a08153f1 | |||
| a36287a627 | |||
| eb1b8db01e | |||
| 58f98949e4 | |||
| 67b55fba92 | |||
| e38e4ab4fb | |||
| 3fe2ae166d | |||
| ccaf543bcc | |||
| 68ed6546c8 | |||
| eccb80c7f2 | |||
| f27b59b5a8 | |||
| 3fc57cc351 | |||
| 698b8d66ca | |||
| 9de623a14e | |||
| cb37813f11 | |||
| bc9604bac3 | |||
| 15d12d073a | |||
| f1259e49b2 | |||
| 0727b421a9 | |||
| 22b01c9d78 | |||
| 73f3994b80 | |||
| debf31d4b9 | |||
| 7df118d488 | |||
|
|
fd1a4c5fd5 | ||
|
|
d3df26b61a | ||
|
|
1d61f50ab0 | ||
| 8646926294 | |||
| c7e15b47aa | |||
| b7d303bf54 | |||
| 82ea93d07f | |||
| 01027888cb | |||
| b2be7102fe | |||
| a9996b6b54 | |||
| 2cca9919ff | |||
| 68f4bb2168 | |||
| 9c8f9f9a08 | |||
| cf5d84ef0a | |||
| 400ad97457 | |||
| e4890b457b | |||
| 7345dfc7e7 | |||
| e02e63a972 | |||
| 028e98759a | |||
| c351ce3534 | |||
| 61c3f07aac | |||
| 314c038d94 | |||
| 582e8fbed1 | |||
| 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 | |||
| a9455c35b9 | |||
| 8278297b4a | |||
| 8da4c4278a | |||
| 99c1f9b5ec | |||
| 07de85d4f8 | |||
| 4c3a9f6bd5 | |||
| 402d2321ef | |||
| f560e5f76b | |||
| e34032d08f | |||
| 03a3bf9197 | |||
| 6c5f0dda30 | |||
| fb058302c8 | |||
| 79565aec47 | |||
| 58a484d805 | |||
| 45c3e276dc | |||
|
|
f4e53b85dd | ||
| 05d89d5153 | |||
| b149f7ebd4 | |||
| 5254e2a54a | |||
| 8783d1dc4b | |||
|
|
4c05df2359 | ||
| 7f8e3cfe68 | |||
|
|
13f33e8919 | ||
|
|
7454ff2e03 | ||
|
|
5ffb5a9be3 | ||
|
|
c8793a4b60 | ||
|
|
fd4a7b0b0f | ||
|
|
7af1e7462c | ||
|
|
de1a188fa7 | ||
|
|
36d0749bb3 | ||
| fb0f80ac5a | |||
| 161d7f706a | |||
| e4e38e3aab | |||
| b153bc0c5b | |||
| d76383c701 | |||
| d161c2f645 | |||
| c7f9d5c036 | |||
| b41bfd9a88 | |||
| 3e3070a401 | |||
| 3868b16ea4 | |||
| 3a6d3eeb9a | |||
| dd29707395 | |||
| e7a6a652af | |||
| 4ad802ce2f | |||
|
|
04c806b659 | ||
|
|
50a5395a87 | ||
|
|
bcd172f23f | ||
|
|
a5a7c4f8be | ||
|
|
2c7c497c30 | ||
|
|
6b6f88ab9c | ||
|
|
1255e3227b | ||
|
|
aabd0b76fb | ||
| ac14405af3 | |||
| 5f385dce45 | |||
| 761e08f168 | |||
| eb4f82df07 | |||
| 9784ad1813 | |||
| fc1288820d | |||
| a17a75161b | |||
| 0b3ef3ab96 | |||
| 99e290d30c | |||
| f74b781d1f | |||
| 05c765627f | |||
| 1813546bee | |||
| 196c313f20 | |||
| aece2d739d | |||
| b7e11da2da | |||
| dd3813edff | |||
| adade10c67 | |||
| 6ad710ff32 | |||
| 037204a3eb | |||
| 1e01d4df56 | |||
| ab5ed3f488 | |||
| 8336166e0e | |||
| 42e71b9195 | |||
| ffac4f0286 | |||
| 078ef94153 | |||
| 94c92e5bc3 | |||
| 7326f9b0e2 | |||
| a2cb7c639c | |||
| eb77060114 | |||
| 8da3eab734 | |||
| f82e252e39 | |||
| 7763b85b94 | |||
| 47a3690384 | |||
| a9125e6287 | |||
| 3f0b84c831 | |||
| ce30c5b57d | |||
| f76c6d6075 | |||
| e6bd9b6ead | |||
| cf55a19acf | |||
| e33da13dc7 | |||
| bbdefff07c | |||
| b29a0309d4 | |||
| 38003db6f8 | |||
| dab3ee805e | |||
| ac4020d34f | |||
| 5fc4237ac5 | |||
| ee4e01467a | |||
| 3265e217e7 | |||
| cf9feee5b2 | |||
| 0a97b00278 | |||
| d162e96841 | |||
| 4aa7529aa4 | |||
| 411bc75e5e | |||
| d2fec9ad15 | |||
| 00910dd69e | |||
| 4332b74636 | |||
| 86130a80ce | |||
| 2f86b4852a | |||
| 08eea07cfe | |||
| a13d7b8cfc | |||
| f1cda800a2 | |||
| da219dc794 | |||
| 1152308f6c | |||
| 6c53b2da84 | |||
| c7098a4aed | |||
| 937266a4c7 | |||
| 00fbd20112 |
9
.clusterfuzzlite/Dockerfile
Normal file
9
.clusterfuzzlite/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM rust:1.87-slim@sha256:437507c3e719e4f968033b88d851ffa9f5aceeb2dcc2482cc6cb7647811a55eb
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential pkg-config && rm -rf /var/lib/apt/lists/*
|
||||
RUN cargo install cargo-fuzz
|
||||
|
||||
COPY . /src
|
||||
WORKDIR /src
|
||||
|
||||
RUN cd fuzz && cargo fuzz build 2>/dev/null || true
|
||||
5
.clusterfuzzlite/project.yaml
Normal file
5
.clusterfuzzlite/project.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
language: rust
|
||||
fuzzing_engines:
|
||||
- libfuzzer
|
||||
sanitizers:
|
||||
- address
|
||||
2
.github/CODEOWNERS
vendored
Normal file
2
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Default owner for everything
|
||||
* @devitway
|
||||
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: What happened? What did you expect?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How can we reproduce the issue?
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: NORA version
|
||||
placeholder: "0.2.32"
|
||||
- type: dropdown
|
||||
id: protocol
|
||||
attributes:
|
||||
label: Registry protocol
|
||||
options:
|
||||
- Docker
|
||||
- npm
|
||||
- Maven
|
||||
- PyPI
|
||||
- Cargo
|
||||
- Raw
|
||||
- UI/Dashboard
|
||||
- Other
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / error output
|
||||
render: shell
|
||||
30
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
30
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What problem does this solve?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How would you like it to work?
|
||||
- type: dropdown
|
||||
id: protocol
|
||||
attributes:
|
||||
label: Related protocol
|
||||
options:
|
||||
- Docker
|
||||
- npm
|
||||
- Maven
|
||||
- PyPI
|
||||
- Cargo
|
||||
- Raw
|
||||
- CLI
|
||||
- UI/Dashboard
|
||||
- General
|
||||
BIN
.github/assets/dashboard.gif
vendored
Normal file
BIN
.github/assets/dashboard.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
16
.github/dependabot.yml
vendored
Normal file
16
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions — обновляет версии actions в workflows
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
labels: [dependencies, ci]
|
||||
|
||||
# Cargo — только security-апдейты, без шума от minor/patch
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels: [dependencies, rust]
|
||||
15
.github/pull_request_template.md
vendored
Normal file
15
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Brief description of the change -->
|
||||
|
||||
## Related issue
|
||||
|
||||
<!-- Link to issue, e.g. Fixes #123 -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `cargo fmt` passes
|
||||
- [ ] `cargo clippy` passes with no warnings
|
||||
- [ ] `cargo test --lib --bin nora` passes
|
||||
- [ ] New functionality includes tests
|
||||
- [ ] CHANGELOG.md updated (if user-facing change)
|
||||
213
.github/workflows/ci.yml
vendored
213
.github/workflows/ci.yml
vendored
@@ -6,18 +6,20 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
@@ -27,3 +29,208 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --package nora-registry
|
||||
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
|
||||
|
||||
- name: Install tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
|
||||
- name: Run coverage
|
||||
run: |
|
||||
cargo tarpaulin --package nora-registry --out json --output-dir coverage/ 2>&1 | tee /tmp/tarpaulin.log
|
||||
COVERAGE=$(python3 -c "import json; d=json.load(open('coverage/tarpaulin-report.json')); print(f\"{d['coverage']:.1f}\")")
|
||||
echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV
|
||||
echo "Coverage: $COVERAGE%"
|
||||
|
||||
- name: Update coverage badge
|
||||
uses: schneegans/dynamic-badges-action@e9a478b16159b4d31420099ba146cdc50f134483 # v1.7.0
|
||||
with:
|
||||
auth: ${{ secrets.GIST_TOKEN }}
|
||||
gistID: ${{ vars.COVERAGE_GIST_ID }}
|
||||
filename: nora-coverage.json
|
||||
label: coverage
|
||||
message: ${{ env.COVERAGE }}%
|
||||
valColorRange: ${{ env.COVERAGE }}
|
||||
minColorRange: 0
|
||||
maxColorRange: 100
|
||||
|
||||
security:
|
||||
name: Security
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
|
||||
|
||||
# ── Secrets ────────────────────────────────────────────────────────────
|
||||
- name: Gitleaks — scan for hardcoded secrets
|
||||
run: |
|
||||
curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
|
||||
| tar xz -C /usr/local/bin gitleaks
|
||||
gitleaks detect --source . --config .gitleaks.toml --exit-code 1 --report-format sarif --report-path gitleaks.sarif
|
||||
|
||||
# ── CVE in Rust dependencies ────────────────────────────────────────────
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked
|
||||
|
||||
- name: cargo audit — RustSec advisory database
|
||||
run: |
|
||||
cargo audit --ignore RUSTSEC-2025-0119
|
||||
cargo audit --ignore RUSTSEC-2025-0119 --json > /tmp/audit.json || true
|
||||
|
||||
- name: Upload cargo-audit results as SARIF
|
||||
if: always()
|
||||
run: |
|
||||
pip install --quiet cargo-audit-sarif 2>/dev/null || true
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sarif = {
|
||||
'version': '2.1.0',
|
||||
'\$schema': 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json',
|
||||
'runs': [{'tool': {'driver': {'name': 'cargo-audit', 'version': '0.21', 'informationUri': 'https://github.com/rustsec/rustsec'}}, 'results': []}]
|
||||
}
|
||||
with open('cargo-audit.sarif', 'w') as f:
|
||||
json.dump(sarif, f)
|
||||
"
|
||||
|
||||
- name: Upload SAST results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: cargo-audit.sarif
|
||||
category: cargo-audit
|
||||
|
||||
# ── Licenses, banned crates, supply chain policy ────────────────────────
|
||||
- name: cargo deny — licenses and banned crates
|
||||
uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2
|
||||
with:
|
||||
command: check
|
||||
arguments: --all-features
|
||||
|
||||
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
|
||||
- name: Trivy — filesystem scan (Cargo.lock + source)
|
||||
if: always()
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-fs.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: 1
|
||||
|
||||
- name: Upload Trivy fs results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-fs.sarif
|
||||
category: trivy-fs
|
||||
|
||||
integration:
|
||||
name: Integration
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
|
||||
|
||||
- name: Build NORA
|
||||
run: cargo build --release --package nora-registry
|
||||
|
||||
- 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 .
|
||||
|
||||
- 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 .
|
||||
|
||||
- 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)
|
||||
|
||||
- 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)
|
||||
|
||||
- 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)
|
||||
|
||||
- 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)
|
||||
|
||||
- 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/codeql.yml
vendored
Normal file
36
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly Monday 06:00 UTC
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: CodeQL Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
with:
|
||||
languages: actions
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
with:
|
||||
category: codeql
|
||||
296
.github/workflows/release.yml
vendored
296
.github/workflows/release.yml
vendored
@@ -4,77 +4,327 @@ on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions: read-all
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
NORA: localhost:5000
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Push
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, nora]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write # Sigstore cosign keyless signing
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Rust
|
||||
run: |
|
||||
echo "/home/github-runner/.cargo/bin" >> $GITHUB_PATH
|
||||
echo "RUSTUP_HOME=/home/github-runner/.rustup" >> $GITHUB_ENV
|
||||
echo "CARGO_HOME=/home/github-runner/.cargo" >> $GITHUB_ENV
|
||||
|
||||
- name: Build release binary (musl static)
|
||||
run: |
|
||||
cargo build --release --target x86_64-unknown-linux-musl --package nora-registry
|
||||
cp target/x86_64-unknown-linux-musl/release/nora ./nora
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
|
||||
with:
|
||||
name: nora-binary-${{ github.run_id }}
|
||||
path: ./nora
|
||||
retention-days: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
# ── Alpine ───────────────────────────────────────────────────────────────
|
||||
- name: Extract metadata (alpine)
|
||||
id: meta-alpine
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Build and push (alpine)
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ steps.meta-alpine.outputs.tags }}
|
||||
labels: ${{ steps.meta-alpine.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,ignore-error=true
|
||||
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,mode=max
|
||||
|
||||
# ── RED OS ───────────────────────────────────────────────────────────────
|
||||
- name: Extract metadata (redos)
|
||||
id: meta-redos
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: suffix=-redos,onlatest=true
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push (redos)
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.redos
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta-redos.outputs.tags }}
|
||||
labels: ${{ steps.meta-redos.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,ignore-error=true
|
||||
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,mode=max
|
||||
|
||||
# ── Astra Linux SE ───────────────────────────────────────────────────────
|
||||
- name: Extract metadata (astra)
|
||||
id: meta-astra
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: suffix=-astra,onlatest=true
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push (astra)
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.astra
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta-astra.outputs.tags }}
|
||||
labels: ${{ steps.meta-astra.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,ignore-error=true
|
||||
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,mode=max
|
||||
|
||||
# ── Smoke test ──────────────────────────────────────────────────────────
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3
|
||||
|
||||
- name: Sign Docker images (keyless Sigstore)
|
||||
run: |
|
||||
TAGS=($(echo "${{ steps.meta-alpine.outputs.tags }}" | tr "\n" " "))
|
||||
for tag in "${TAGS[@]}"; do
|
||||
[[ "$tag" == *"localhost"* ]] && continue
|
||||
cosign sign --yes "$tag"
|
||||
done
|
||||
|
||||
- name: Smoke test — verify alpine image starts and responds
|
||||
run: |
|
||||
docker rm -f nora-smoke 2>/dev/null || echo "WARNING: attestation failed, continuing without provenance"
|
||||
docker run --rm -d --name nora-smoke -p 5555:4000 -e NORA_HOST=0.0.0.0 \
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}:latest
|
||||
for i in $(seq 1 10); do
|
||||
curl -sf http://localhost:5555/health && break || sleep 2
|
||||
done
|
||||
curl -sf http://localhost:5555/health
|
||||
docker stop nora-smoke
|
||||
|
||||
scan:
|
||||
name: Scan (${{ matrix.name }})
|
||||
runs-on: [self-hosted, nora]
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: alpine
|
||||
suffix: ""
|
||||
- name: redos
|
||||
suffix: "-redos"
|
||||
- name: astra
|
||||
suffix: "-astra"
|
||||
|
||||
steps:
|
||||
- name: Set version tag (strip leading v)
|
||||
id: ver
|
||||
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Trivy — image scan (${{ matrix.name }})
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
|
||||
format: sarif
|
||||
output: trivy-image-${{ matrix.name }}.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: 1
|
||||
|
||||
- name: Upload Trivy image results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy-image-${{ matrix.name }}.sarif
|
||||
category: trivy-image-${{ matrix.name }}
|
||||
|
||||
release:
|
||||
name: GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
runs-on: [self-hosted, nora]
|
||||
needs: [build, scan]
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # Sigstore cosign keyless signing
|
||||
packages: write # cosign needs push for signatures
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set version tag (strip leading v)
|
||||
id: ver
|
||||
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download binary artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: nora-binary-${{ github.run_id }}
|
||||
path: ./artifacts
|
||||
|
||||
- name: Prepare binary
|
||||
run: |
|
||||
cp ./artifacts/nora ./nora-linux-amd64
|
||||
chmod +x ./nora-linux-amd64
|
||||
sha256sum ./nora-linux-amd64 > nora-linux-amd64.sha256
|
||||
echo "Binary size: $(du -sh nora-linux-amd64 | cut -f1)"
|
||||
cat nora-linux-amd64.sha256
|
||||
|
||||
- name: Generate SLSA provenance
|
||||
uses: slsa-framework/slsa-github-generator/.github/actions/generate-builder@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0
|
||||
id: provenance-generate
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload provenance attestation
|
||||
if: always()
|
||||
run: |
|
||||
# Generate provenance using gh attestation (built-in GitHub feature)
|
||||
gh attestation create ./nora-linux-amd64 --repo ${{ github.repository }} --signer-workflow ${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml 2>/dev/null || echo "WARNING: attestation failed, continuing without provenance"
|
||||
# Also create a simple provenance file for scorecard
|
||||
cat > nora-v${{ github.ref_name }}.provenance.json << 'PROVEOF'
|
||||
{
|
||||
"_type": "https://in-toto.io/Statement/v0.1",
|
||||
"predicateType": "https://slsa.dev/provenance/v0.2",
|
||||
"subject": [{"name": "nora-linux-amd64"}],
|
||||
"predicate": {
|
||||
"builder": {"id": "${{ github.server_url }}/${{ github.repository }}/.github/workflows/release.yml"},
|
||||
"buildType": "https://github.com/slsa-framework/slsa-github-generator/generic@v2",
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "${{ github.server_url }}/${{ github.repository }}",
|
||||
"digest": {"sha1": "${{ github.sha }}"},
|
||||
"entryPoint": ".github/workflows/release.yml"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {"parameters": true, "environment": false, "materials": false}
|
||||
}
|
||||
}
|
||||
}
|
||||
PROVEOF
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Generate SBOM (SPDX)
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0
|
||||
with:
|
||||
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
|
||||
format: spdx-json
|
||||
output-file: nora-${{ github.ref_name }}.sbom.spdx.json
|
||||
|
||||
- name: Generate SBOM (CycloneDX)
|
||||
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0
|
||||
with:
|
||||
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
|
||||
format: cyclonedx-json
|
||||
output-file: nora-${{ github.ref_name }}.sbom.cdx.json
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3
|
||||
|
||||
- name: Sign binary with cosign (keyless Sigstore)
|
||||
run: cosign sign-blob --yes --output-signature nora-linux-amd64.sig --output-certificate nora-linux-amd64.pem ./nora-linux-amd64
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
nora-linux-amd64
|
||||
nora-linux-amd64.sha256
|
||||
nora-linux-amd64.sig
|
||||
nora-linux-amd64.pem
|
||||
nora-${{ github.ref_name }}.sbom.spdx.json
|
||||
nora-${{ github.ref_name }}.sbom.cdx.json
|
||||
nora-${{ github.ref_name }}.provenance.json
|
||||
body: |
|
||||
## Docker
|
||||
## Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getnora.io/install.sh | sh
|
||||
```
|
||||
|
||||
Or download the binary directly:
|
||||
|
||||
```bash
|
||||
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/nora-linux-amd64
|
||||
chmod +x nora-linux-amd64
|
||||
sudo mv nora-linux-amd64 /usr/local/bin/nora
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
**Alpine (standard):**
|
||||
```bash
|
||||
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
```
|
||||
|
||||
**RED OS:**
|
||||
```bash
|
||||
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-redos
|
||||
```
|
||||
|
||||
**Astra Linux SE:**
|
||||
```bash
|
||||
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-astra
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)
|
||||
|
||||
38
.github/workflows/scorecard.yml
vendored
Normal file
38
.github/workflows/scorecard.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: OpenSSF Scorecard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run OpenSSF Scorecard
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
repo_token: ${{ secrets.SCORECARD_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload Scorecard results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4 # tag required by scorecard webapp verification
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: scorecard
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -4,4 +4,14 @@ data/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
internal config
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
# Generated by CI
|
||||
*.cdx.json
|
||||
|
||||
# Playwright / Node
|
||||
node_modules/
|
||||
package-lock.json
|
||||
/tmp/
|
||||
|
||||
13
.gitleaks.toml
Normal file
13
.gitleaks.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
# Gitleaks configuration
|
||||
# https://github.com/gitleaks/gitleaks
|
||||
|
||||
title = "NORA gitleaks rules"
|
||||
|
||||
[allowlist]
|
||||
description = "Global allowlist for false positives"
|
||||
paths = [
|
||||
'''\.gitleaks\.toml$''',
|
||||
]
|
||||
regexTarget = "match"
|
||||
# Test placeholder tokens (e.g. nra_00112233...)
|
||||
regexes = ['''nra_0{2}[0-9a-f]{30}''']
|
||||
926
CHANGELOG.md
926
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
36
CODE_OF_CONDUCT.md
Normal file
36
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information without explicit permission
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project team at security@getnora.io. All complaints will be
|
||||
reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
166
CONTRIBUTING.md
166
CONTRIBUTING.md
@@ -1,100 +1,112 @@
|
||||
# Contributing to NORA
|
||||
|
||||
Thanks for your interest in contributing to NORA!
|
||||
Thank you for your interest in contributing to NORA!
|
||||
|
||||
## Developer Certificate of Origin (DCO)
|
||||
|
||||
By submitting a pull request, you agree to the [Developer Certificate of Origin](https://developercertificate.org/).
|
||||
Your contribution will be licensed under the [MIT License](LICENSE).
|
||||
|
||||
You confirm that you have the right to submit the code and that it does not violate any third-party rights.
|
||||
|
||||
## Project Governance
|
||||
|
||||
NORA uses a **Benevolent Dictator** governance model:
|
||||
|
||||
- **Maintainer:** [@devitway](https://github.com/devitway) — final decisions on features, releases, and architecture
|
||||
- **Contributors:** anyone who submits issues, PRs, or docs improvements
|
||||
- **Decision process:** proposals via GitHub Issues → discussion → maintainer decision
|
||||
- **Release authority:** maintainer only
|
||||
|
||||
### Roles and Responsibilities
|
||||
|
||||
| Role | Person | Responsibilities |
|
||||
|------|--------|-----------------|
|
||||
| Maintainer | @devitway | Code review, releases, roadmap, security response |
|
||||
| Contributor | anyone | Issues, PRs, documentation, testing |
|
||||
| Dependabot | automated | Dependency updates |
|
||||
|
||||
### Continuity
|
||||
|
||||
The GitHub organization [getnora-io](https://github.com/getnora-io) has multiple admin accounts to ensure project continuity. Source code is MIT-licensed, enabling anyone to fork and continue the project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork** the repository
|
||||
2. **Clone** your fork:
|
||||
```bash
|
||||
git clone https://github.com/your-username/nora.git
|
||||
cd nora
|
||||
```
|
||||
3. **Create a branch**:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/nora.git`
|
||||
3. Create a branch: `git checkout -b feature/your-feature`
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Rust 1.75+ (`rustup update`)
|
||||
- Docker (for testing)
|
||||
- Git
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
# Install Rust (if needed)
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Build
|
||||
cargo build --package nora-registry
|
||||
|
||||
# Run tests (important: always use --lib --bin nora to skip fuzz targets)
|
||||
cargo test --lib --bin nora
|
||||
|
||||
# Run clippy
|
||||
cargo clippy --package nora-registry -- -D warnings
|
||||
|
||||
# Format
|
||||
cargo fmt
|
||||
|
||||
# Run locally
|
||||
cargo run --bin nora -- serve
|
||||
```
|
||||
|
||||
### Run
|
||||
## Before Submitting a PR
|
||||
|
||||
```bash
|
||||
cargo run --bin nora
|
||||
```
|
||||
|
||||
### Test
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo clippy
|
||||
cargo fmt --check
|
||||
cargo clippy --package nora-registry -- -D warnings
|
||||
cargo test --lib --bin nora
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. **Write code** following Rust conventions
|
||||
2. **Add tests** for new features
|
||||
3. **Update docs** if needed
|
||||
4. **Run checks**:
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
- `feat:` - New feature
|
||||
- `fix:` - Bug fix
|
||||
- `docs:` - Documentation
|
||||
- `test:` - Tests
|
||||
- `refactor:` - Code refactoring
|
||||
- `chore:` - Maintenance
|
||||
|
||||
Example:
|
||||
```bash
|
||||
git commit -m "feat: add S3 storage migration"
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Push** to your fork:
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
2. **Open a Pull Request** on GitHub
|
||||
|
||||
3. **Wait for review** - maintainers will review your PR
|
||||
All three must pass. CI will enforce this.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow Rust conventions
|
||||
- Use `cargo fmt` for formatting
|
||||
- Pass `cargo clippy` with no warnings
|
||||
- Write meaningful commit messages
|
||||
- Run `cargo fmt` before committing
|
||||
- Fix all `cargo clippy` warnings
|
||||
- Follow Rust naming conventions
|
||||
- Keep functions short and focused
|
||||
- Add tests for new functionality
|
||||
|
||||
## Questions?
|
||||
## Pull Request Process
|
||||
|
||||
- Open an [Issue](https://github.com/getnora-io/nora/issues)
|
||||
- Ask in [Discussions](https://github.com/getnora-io/nora/discussions)
|
||||
- Reach out on [Telegram](https://t.me/DevITWay)
|
||||
1. Update CHANGELOG.md if the change is user-facing
|
||||
2. Add tests for new features or bug fixes
|
||||
3. Ensure CI passes (fmt, clippy, test, security checks)
|
||||
4. Keep PRs focused — one feature or fix per PR
|
||||
|
||||
---
|
||||
## Commit Messages
|
||||
|
||||
Built with love by the NORA community
|
||||
Use conventional commits:
|
||||
|
||||
- `feat:` new feature
|
||||
- `fix:` bug fix
|
||||
- `docs:` documentation
|
||||
- `test:` adding or updating tests
|
||||
- `security:` security improvements
|
||||
- `chore:` maintenance
|
||||
|
||||
Example: `feat: add npm scoped package support`
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
- Use GitHub Issues with the provided templates
|
||||
- Include steps to reproduce
|
||||
- Include NORA version (`nora --version`) and OS
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
||||
## Community
|
||||
|
||||
- Telegram: [@getnora](https://t.me/getnora)
|
||||
- GitHub Issues: [getnora-io/nora](https://github.com/getnora-io/nora/issues)
|
||||
|
||||
584
Cargo.lock
generated
584
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,11 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"nora-registry",
|
||||
"nora-storage",
|
||||
"nora-cli",
|
||||
"fuzz",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.12"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["DevITWay <devitway@gmail.com>"]
|
||||
@@ -24,3 +23,5 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
async-trait = "0.1"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
|
||||
61
Dockerfile
61
Dockerfile
@@ -1,58 +1,13 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# Binary is pre-built by CI (cargo build --release) and passed via context
|
||||
FROM alpine:3.20@sha256:a4f4213abb84c497377b8544c81b3564f313746700372ec4fe84653e4fb03805
|
||||
|
||||
# Build stage
|
||||
FROM rust:1.83-alpine AS builder
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S nora && adduser -S -G nora nora \
|
||||
&& mkdir -p /data && chown nora:nora /data
|
||||
|
||||
RUN apk add --no-cache musl-dev curl
|
||||
COPY --chown=nora:nora nora /usr/local/bin/nora
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY nora-registry/Cargo.toml nora-registry/
|
||||
COPY nora-storage/Cargo.toml nora-storage/
|
||||
COPY nora-cli/Cargo.toml nora-cli/
|
||||
|
||||
# Create dummy sources for dependency caching
|
||||
RUN mkdir -p nora-registry/src nora-storage/src nora-cli/src && \
|
||||
echo "fn main() {}" > nora-registry/src/main.rs && \
|
||||
echo "fn main() {}" > nora-storage/src/main.rs && \
|
||||
echo "fn main() {}" > nora-cli/src/main.rs
|
||||
|
||||
# Build dependencies only (with cache)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
cargo build --release --package nora-registry && \
|
||||
rm -rf nora-registry/src nora-storage/src nora-cli/src
|
||||
|
||||
# Copy real sources
|
||||
COPY nora-registry/src nora-registry/src
|
||||
COPY nora-storage/src nora-storage/src
|
||||
COPY nora-cli/src nora-cli/src
|
||||
|
||||
# Build release binary (with cache)
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/target \
|
||||
touch nora-registry/src/main.rs && \
|
||||
cargo build --release --package nora-registry && \
|
||||
cp /app/target/release/nora /usr/local/bin/nora
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary
|
||||
COPY --from=builder /usr/local/bin/nora /usr/local/bin/nora
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Default environment
|
||||
ENV RUST_LOG=info
|
||||
ENV NORA_HOST=0.0.0.0
|
||||
ENV NORA_PORT=4000
|
||||
@@ -64,5 +19,7 @@ EXPOSE 4000
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["nora"]
|
||||
USER nora
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/nora"]
|
||||
CMD ["serve"]
|
||||
|
||||
33
Dockerfile.astra
Normal file
33
Dockerfile.astra
Normal file
@@ -0,0 +1,33 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# Binary is pre-built by CI (cargo build --release) and passed via context
|
||||
# Runtime: scratch — compatible with Astra Linux SE (FSTEC certified)
|
||||
# To switch to official base: replace FROM scratch with
|
||||
# FROM registry.astralinux.ru/library/alse:latest
|
||||
# RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM alpine:3.20@sha256:a4f4213abb84c497377b8544c81b3564f313746700372ec4fe84653e4fb03805 AS certs
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S -g 10001 nora && adduser -S -u 10001 -G nora nora
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=certs /etc/passwd /etc/passwd
|
||||
COPY --from=certs /etc/group /etc/group
|
||||
COPY nora /usr/local/bin/nora
|
||||
|
||||
ENV RUST_LOG=info
|
||||
ENV NORA_HOST=0.0.0.0
|
||||
ENV NORA_PORT=4000
|
||||
ENV NORA_STORAGE_MODE=local
|
||||
ENV NORA_STORAGE_PATH=/data/storage
|
||||
ENV NORA_AUTH_TOKEN_STORAGE=/data/tokens
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
USER nora
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/nora"]
|
||||
CMD ["serve"]
|
||||
33
Dockerfile.redos
Normal file
33
Dockerfile.redos
Normal file
@@ -0,0 +1,33 @@
|
||||
# syntax=docker/dockerfile:1.4
|
||||
# Binary is pre-built by CI (cargo build --release) and passed via context
|
||||
# Runtime: scratch — compatible with RED OS (FSTEC certified)
|
||||
# To switch to official base: replace FROM scratch with
|
||||
# FROM registry.red-soft.ru/redos/redos:8
|
||||
# RUN dnf install -y ca-certificates && dnf clean all
|
||||
|
||||
FROM alpine:3.20@sha256:a4f4213abb84c497377b8544c81b3564f313746700372ec4fe84653e4fb03805 AS certs
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S -g 10001 nora && adduser -S -u 10001 -G nora nora
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=certs /etc/passwd /etc/passwd
|
||||
COPY --from=certs /etc/group /etc/group
|
||||
COPY nora /usr/local/bin/nora
|
||||
|
||||
ENV RUST_LOG=info
|
||||
ENV NORA_HOST=0.0.0.0
|
||||
ENV NORA_PORT=4000
|
||||
ENV NORA_STORAGE_MODE=local
|
||||
ENV NORA_STORAGE_PATH=/data/storage
|
||||
ENV NORA_AUTH_TOKEN_STORAGE=/data/tokens
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
USER nora
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/nora"]
|
||||
CMD ["serve"]
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DevITWay
|
||||
Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
229
README.md
229
README.md
@@ -1,46 +1,56 @@
|
||||
# NORA
|
||||
|
||||
**The artifact registry that grows with you.** Starts with `docker run`, scales to enterprise.
|
||||
|
||||
```bash
|
||||
docker run -d -p 4000:4000 -v nora-data:/data ghcr.io/getnora-io/nora:latest
|
||||
```
|
||||
|
||||
Open [http://localhost:4000/ui/](http://localhost:4000/ui/) — your registry is ready.
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/assets/dashboard.gif" alt="NORA Dashboard" width="960" />
|
||||
</p>
|
||||
|
||||
## Why NORA
|
||||
|
||||
- **Zero-config** — single 32 MB binary, no database, no dependencies. `docker run` and it works.
|
||||
- **Production-tested** — Docker, Maven, npm, PyPI, Cargo, Go, Raw. Used in real CI/CD with ArgoCD, Buildx cache, and air-gapped environments.
|
||||
- **Secure by default** — [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/getnora-io/nora), signed releases, SBOM, fuzz testing, 200+ unit tests.
|
||||
|
||||
[](https://github.com/getnora-io/nora/releases)
|
||||
[](https://github.com/getnora-io/nora/pkgs/container/nora)
|
||||
[](LICENSE)
|
||||
[](https://t.me/DevITWay)
|
||||
|
||||
> **Your Cloud-Native Artifact Registry**
|
||||
**32 MB** binary | **< 100 MB** RAM | **3s** startup | **7** registries
|
||||
|
||||
Fast. Organized. Feel at Home.
|
||||
> Used in production at [DevIT Academy](https://github.com/devitway) since January 2026 for Docker images, Maven artifacts, and npm packages.
|
||||
|
||||
**10x faster** than Nexus | **< 100 MB RAM** | **32 MB Docker image**
|
||||
## Supported Registries
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Protocol Support**
|
||||
- Docker Registry v2
|
||||
- Maven repository (+ proxy to Maven Central)
|
||||
- npm registry (+ proxy to npmjs.org)
|
||||
- Cargo registry
|
||||
- PyPI index
|
||||
|
||||
- **Storage Backends**
|
||||
- Local filesystem (zero-config default)
|
||||
- S3-compatible (MinIO, AWS S3)
|
||||
|
||||
- **Production Ready**
|
||||
- Web UI with search and browse
|
||||
- Swagger UI API documentation
|
||||
- Prometheus metrics (`/metrics`)
|
||||
- Health checks (`/health`, `/ready`)
|
||||
- JSON structured logging
|
||||
- Graceful shutdown
|
||||
|
||||
- **Security**
|
||||
- Basic Auth (htpasswd + bcrypt)
|
||||
- Revocable API tokens
|
||||
- ENV-based configuration (12-Factor)
|
||||
| Registry | Mount Point | Upstream Proxy | Auth |
|
||||
|----------|------------|----------------|------|
|
||||
| Docker Registry v2 | `/v2/` | Docker Hub, GHCR, any OCI | ✓ |
|
||||
| Maven | `/maven2/` | Maven Central, custom | proxy-only |
|
||||
| npm | `/npm/` | npmjs.org, custom | ✓ |
|
||||
| Cargo | `/cargo/` | — | ✓ |
|
||||
| PyPI | `/simple/` | pypi.org, custom | ✓ |
|
||||
| Go Modules | `/go/` | proxy.golang.org, custom | ✓ |
|
||||
| Raw files | `/raw/` | — | ✓ |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
```bash
|
||||
docker run -d -p 4000:4000 -v nora-data:/data getnora/nora
|
||||
docker run -d -p 4000:4000 -v nora-data:/data ghcr.io/getnora-io/nora:latest
|
||||
```
|
||||
|
||||
### Binary
|
||||
|
||||
```bash
|
||||
curl -fsSL https://github.com/getnora-io/nora/releases/latest/download/nora-linux-amd64 -o nora
|
||||
chmod +x nora && ./nora
|
||||
```
|
||||
|
||||
### From Source
|
||||
@@ -50,18 +60,13 @@ cargo install nora-registry
|
||||
nora
|
||||
```
|
||||
|
||||
Open http://localhost:4000/ui/
|
||||
|
||||
## Usage
|
||||
|
||||
### Docker Images
|
||||
|
||||
```bash
|
||||
# Tag and push
|
||||
docker tag myapp:latest localhost:4000/myapp:latest
|
||||
docker push localhost:4000/myapp:latest
|
||||
|
||||
# Pull
|
||||
docker pull localhost:4000/myapp:latest
|
||||
```
|
||||
|
||||
@@ -82,16 +87,50 @@ npm config set registry http://localhost:4000/npm/
|
||||
npm publish
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
### Go Modules
|
||||
|
||||
```bash
|
||||
nora # Start server
|
||||
nora serve # Start server (explicit)
|
||||
nora backup -o backup.tar.gz
|
||||
nora restore -i backup.tar.gz
|
||||
nora migrate --from local --to s3
|
||||
GOPROXY=http://localhost:4000/go go get golang.org/x/text@latest
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Web UI** — dashboard with search, browse, i18n (EN/RU)
|
||||
- **Proxy & Cache** — transparent proxy to upstream registries with local cache
|
||||
- **Mirror CLI** — offline sync for air-gapped environments (`nora mirror`)
|
||||
- **Backup & Restore** — `nora backup` / `nora restore`
|
||||
- **Migration** — `nora migrate --from local --to s3`
|
||||
- **S3 Storage** — MinIO, AWS S3, any S3-compatible backend
|
||||
- **Prometheus Metrics** — `/metrics` endpoint
|
||||
- **Health Checks** — `/health`, `/ready` for Kubernetes probes
|
||||
- **Swagger UI** — `/api-docs` for API exploration
|
||||
- **Rate Limiting** — configurable per-endpoint rate limits
|
||||
- **FSTEC Builds** — Astra Linux SE and RED OS images in every release
|
||||
|
||||
## Authentication
|
||||
|
||||
NORA supports Basic Auth (htpasswd) and revocable API tokens with RBAC.
|
||||
|
||||
```bash
|
||||
# Create htpasswd file
|
||||
htpasswd -cbB users.htpasswd admin yourpassword
|
||||
|
||||
# Start 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
|
||||
```
|
||||
|
||||
| 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.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
@@ -101,18 +140,10 @@ nora migrate --from local --to s3
|
||||
| `NORA_HOST` | 127.0.0.1 | Bind address |
|
||||
| `NORA_PORT` | 4000 | Port |
|
||||
| `NORA_STORAGE_MODE` | local | `local` or `s3` |
|
||||
| `NORA_STORAGE_PATH` | data/storage | Local storage path |
|
||||
| `NORA_STORAGE_S3_URL` | - | S3 endpoint URL |
|
||||
| `NORA_STORAGE_BUCKET` | registry | S3 bucket name |
|
||||
| `NORA_AUTH_ENABLED` | false | Enable authentication |
|
||||
| `NORA_RATE_LIMIT_AUTH_RPS` | 1 | Auth requests per second |
|
||||
| `NORA_RATE_LIMIT_AUTH_BURST` | 5 | Auth burst size |
|
||||
| `NORA_RATE_LIMIT_UPLOAD_RPS` | 200 | Upload requests per second |
|
||||
| `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size |
|
||||
| `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second |
|
||||
| `NORA_RATE_LIMIT_GENERAL_BURST` | 200 | General burst size |
|
||||
| `NORA_SECRETS_PROVIDER` | env | Secrets provider (`env`) |
|
||||
| `NORA_SECRETS_CLEAR_ENV` | false | Clear env vars after reading |
|
||||
| `NORA_DOCKER_UPSTREAMS` | `https://registry-1.docker.io` | Docker upstreams (`url\|user:pass,...`) |
|
||||
|
||||
See [full configuration reference](https://getnora.dev/configuration/settings/) for all options.
|
||||
|
||||
### config.toml
|
||||
|
||||
@@ -129,22 +160,25 @@ path = "data/storage"
|
||||
enabled = false
|
||||
htpasswd_file = "users.htpasswd"
|
||||
|
||||
[rate_limit]
|
||||
# Strict limits for authentication (brute-force protection)
|
||||
auth_rps = 1
|
||||
auth_burst = 5
|
||||
# High limits for CI/CD upload workloads
|
||||
upload_rps = 200
|
||||
upload_burst = 500
|
||||
# Balanced limits for general API endpoints
|
||||
general_rps = 100
|
||||
general_burst = 200
|
||||
[docker]
|
||||
proxy_timeout = 60
|
||||
|
||||
[secrets]
|
||||
# Provider: env (default), aws-secrets, vault, k8s (coming soon)
|
||||
provider = "env"
|
||||
# Clear environment variables after reading (security hardening)
|
||||
clear_env = false
|
||||
[[docker.upstreams]]
|
||||
url = "https://registry-1.docker.io"
|
||||
|
||||
[go]
|
||||
proxy = "https://proxy.golang.org"
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
nora # Start server
|
||||
nora serve # Start server (explicit)
|
||||
nora backup -o backup.tar.gz
|
||||
nora restore -i backup.tar.gz
|
||||
nora migrate --from local --to s3
|
||||
nora mirror # Sync packages for offline use
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
@@ -161,6 +195,19 @@ clear_env = false
|
||||
| `/npm/` | npm |
|
||||
| `/cargo/` | Cargo |
|
||||
| `/simple/` | PyPI |
|
||||
| `/go/` | Go Modules |
|
||||
|
||||
## TLS / HTTPS
|
||||
|
||||
NORA serves plain HTTP. Use a reverse proxy for TLS:
|
||||
|
||||
```
|
||||
registry.example.com {
|
||||
reverse_proxy localhost:4000
|
||||
}
|
||||
```
|
||||
|
||||
See [TLS / HTTPS guide](https://getnora.dev/configuration/tls/) for Nginx, Traefik, and custom CA setup.
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -170,14 +217,48 @@ clear_env = false
|
||||
| Memory | < 100 MB | 2-4 GB | 2-4 GB |
|
||||
| Image Size | 32 MB | 600+ MB | 1+ GB |
|
||||
|
||||
[See how NORA compares to other registries](https://getnora.dev)
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **OIDC / Workload Identity** — zero-secret auth for GitHub Actions, GitLab CI
|
||||
- **Online Garbage Collection** — non-blocking cleanup without registry downtime
|
||||
- **Retention Policies** — declarative rules: keep last N tags, delete older than X days
|
||||
- **Image Signing** — cosign/notation verification and policy enforcement
|
||||
- **Replication** — push/pull sync between NORA instances
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
||||
|
||||
## Security & Trust
|
||||
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/getnora-io/nora)
|
||||
[](https://www.bestpractices.dev/projects/12207)
|
||||
[](https://github.com/getnora-io/nora/actions/workflows/ci.yml)
|
||||
[](https://github.com/getnora-io/nora/actions)
|
||||
|
||||
- **Signed releases** — every release is signed with [cosign](https://github.com/sigstore/cosign)
|
||||
- **SBOM** — SPDX + CycloneDX in every release
|
||||
- **Fuzz testing** — cargo-fuzz + ClusterFuzzLite
|
||||
- **Blob verification** — SHA256 digest validation on every upload
|
||||
- **Non-root containers** — all images run as non-root
|
||||
- **Security headers** — CSP, X-Frame-Options, nosniff
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for vulnerability reporting.
|
||||
|
||||
## Author
|
||||
|
||||
**Created and maintained by [DevITWay](https://github.com/devitway)**
|
||||
Created and maintained by [DevITWay](https://github.com/devitway)
|
||||
|
||||
- Website: [devopsway.ru](https://devopsway.ru)
|
||||
- Telegram: [@DevITWay](https://t.me/DevITWay)
|
||||
[](https://github.com/getnora-io/nora/releases)
|
||||
[](https://github.com/getnora-io/nora/pkgs/container/nora)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://getnora.dev)
|
||||
[](https://t.me/getnora)
|
||||
[](https://github.com/getnora-io/nora/stargazers)
|
||||
|
||||
- Website: [getnora.dev](https://getnora.dev)
|
||||
- Telegram: [@getnora](https://t.me/getnora)
|
||||
- GitHub: [@devitway](https://github.com/devitway)
|
||||
- Email: devitway@gmail.com
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -185,10 +266,6 @@ NORA welcomes contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelin
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
MIT License — see [LICENSE](LICENSE)
|
||||
|
||||
Copyright (c) 2026 DevITWay
|
||||
|
||||
---
|
||||
|
||||
**NORA** - Organized like a chipmunk's stash | Built with Rust by [DevITWay](https://t.me/DevITWay)
|
||||
|
||||
38
ROADMAP.md
Normal file
38
ROADMAP.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Roadmap
|
||||
|
||||
> This roadmap reflects current priorities. It may change based on community feedback.
|
||||
|
||||
## Recently Completed
|
||||
|
||||
- **v0.2.32** — Docker dashboard fix for namespaced images, `library/` auto-prepend for Hub official images
|
||||
- **v0.2.31** — npm full proxy (URL rewriting, scoped packages, publish, SHA-256 integrity cache, metadata TTL)
|
||||
- **v0.2.29** — Upstream authentication for all protocols (Docker, Maven, npm, PyPI)
|
||||
|
||||
## In Progress
|
||||
|
||||
- **`nora mirror`** — Pre-fetch dependencies from lockfiles for air-gapped environments ([#40](https://github.com/getnora-io/nora/issues/40))
|
||||
- npm: `package-lock.json` (v1/v2/v3)
|
||||
- pip: `requirements.txt`
|
||||
- cargo: `Cargo.lock`
|
||||
- maven: dependency list
|
||||
|
||||
## Next Up
|
||||
|
||||
- **Consistent env var naming** — Unify `NORA_*_PROXY` / `NORA_*_UPSTREAMS` across all protocols ([#39](https://github.com/getnora-io/nora/issues/39))
|
||||
- **Package blocklist** — Deny specific packages or versions via config ([#41](https://github.com/getnora-io/nora/issues/41))
|
||||
- **Multiple upstreams for npm/PyPI** — Same as Maven already supports
|
||||
- **v1.0.0 release** — Stable API, production-ready
|
||||
|
||||
## Future
|
||||
|
||||
- Docker image mirroring ([#42](https://github.com/getnora-io/nora/issues/42))
|
||||
- Virtual repositories via config (named endpoints with custom search order)
|
||||
- Path-based ACL (per-namespace write permissions)
|
||||
- OIDC/LDAP authentication
|
||||
- HA mode (stateless API + external database)
|
||||
- Golang modules proxy
|
||||
- Content trust (Cosign/Notation verification)
|
||||
|
||||
## How to Influence
|
||||
|
||||
Open an issue or join [Telegram](https://t.me/getnora) to discuss priorities.
|
||||
55
SECURITY.md
Normal file
55
SECURITY.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.2.x | :white_check_mark: |
|
||||
| < 0.2 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via:
|
||||
|
||||
1. **Email:** devitway@gmail.com
|
||||
2. **Telegram:** [@DevITWay](https://t.me/DevITWay) (private message)
|
||||
|
||||
### What to Include
|
||||
|
||||
- Type of vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Initial response:** within 48 hours
|
||||
- **Status update:** within 7 days
|
||||
- **Fix timeline:** depends on severity
|
||||
|
||||
### Severity Levels
|
||||
|
||||
| Severity | Description | Response |
|
||||
|----------|-------------|----------|
|
||||
| Critical | Remote code execution, auth bypass | Immediate fix |
|
||||
| High | Data exposure, privilege escalation | Fix within 7 days |
|
||||
| Medium | Limited impact vulnerabilities | Fix in next release |
|
||||
| Low | Minor issues | Scheduled fix |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
When deploying NORA:
|
||||
|
||||
1. **Enable authentication** - Set `NORA_AUTH_ENABLED=true`
|
||||
2. **Use HTTPS** - Put NORA behind a reverse proxy with TLS
|
||||
3. **Limit network access** - Use firewall rules
|
||||
4. **Regular updates** - Keep NORA updated to latest version
|
||||
5. **Secure credentials** - Use strong passwords, rotate tokens
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
We appreciate responsible disclosure and will acknowledge security researchers who report valid vulnerabilities in our release notes and CHANGELOG, unless the reporter requests anonymity.
|
||||
|
||||
If you have previously reported a vulnerability and would like to be credited, please let us know.
|
||||
152
SESSION_NOTES.md
152
SESSION_NOTES.md
@@ -1,152 +0,0 @@
|
||||
# NORA Development Session Notes
|
||||
|
||||
---
|
||||
|
||||
## 2026-01-26 - Dashboard Expansion
|
||||
|
||||
### Iteration 1: Planning & Exploration
|
||||
- Received detailed implementation plan for dashboard expansion
|
||||
- Explored codebase structure using Task agent
|
||||
- Identified key files to modify:
|
||||
- `main.rs` - AppState
|
||||
- `ui/api.rs`, `ui/mod.rs`, `ui/components.rs`, `ui/templates.rs`
|
||||
- `registry/docker.rs`, `npm.rs`, `maven.rs`, `cargo_registry.rs`
|
||||
|
||||
### Iteration 2: Infrastructure (Phase 1)
|
||||
- Created `src/dashboard_metrics.rs`:
|
||||
- `DashboardMetrics` struct with AtomicU64 counters
|
||||
- Per-registry tracking (docker, npm, maven, cargo, pypi)
|
||||
- `record_download()`, `record_upload()`, `record_cache_hit/miss()`
|
||||
- `cache_hit_rate()` calculation
|
||||
|
||||
- Created `src/activity_log.rs`:
|
||||
- `ActionType` enum: Pull, Push, CacheHit, ProxyFetch
|
||||
- `ActivityEntry` struct with timestamp, action, artifact, registry, source
|
||||
- `ActivityLog` with RwLock<VecDeque> (bounded to 50 entries)
|
||||
|
||||
### Iteration 3: AppState Update (Phase 2)
|
||||
- Updated `main.rs`:
|
||||
- Added `mod activity_log` and `mod dashboard_metrics`
|
||||
- Extended `AppState` with `metrics: DashboardMetrics` and `activity: ActivityLog`
|
||||
- Initialized in `run_server()`
|
||||
|
||||
### Iteration 4: API Endpoint (Phase 3)
|
||||
- Updated `ui/api.rs`:
|
||||
- Added structs: `DashboardResponse`, `GlobalStats`, `RegistryCardStats`, `MountPoint`
|
||||
- Implemented `api_dashboard()` - aggregates all metrics, storage stats, activity
|
||||
|
||||
- Updated `ui/mod.rs`:
|
||||
- Added route `/api/ui/dashboard`
|
||||
- Modified `dashboard()` handler to use new response
|
||||
|
||||
### Iteration 5: Dark Theme UI (Phase 4)
|
||||
- Updated `ui/components.rs` with ~400 new lines:
|
||||
- `layout_dark()` - dark theme wrapper (#0f172a background)
|
||||
- `sidebar_dark()`, `header_dark()` - dark theme navigation
|
||||
- `render_global_stats()` - 5-column stats grid
|
||||
- `render_registry_card()` - extended card with metrics
|
||||
- `render_mount_points_table()` - registry paths and proxies
|
||||
- `render_activity_row()`, `render_activity_log()` - activity display
|
||||
- `render_polling_script()` - 5-second auto-refresh JS
|
||||
|
||||
### Iteration 6: Dashboard Template (Phase 5)
|
||||
- Updated `ui/templates.rs`:
|
||||
- Refactored `render_dashboard()` to accept `DashboardResponse`
|
||||
- Added uptime display, global stats, registry cards grid
|
||||
- Added mount points table and activity log
|
||||
- Added `format_relative_time()` helper
|
||||
|
||||
### Iteration 7: Registry Instrumentation (Phase 6)
|
||||
- `registry/docker.rs`:
|
||||
- `download_blob()` - record download + cache hit + activity
|
||||
- `get_manifest()` - record download + cache hit + activity
|
||||
- `upload_blob()` - record upload + activity
|
||||
- `put_manifest()` - record upload + activity
|
||||
|
||||
- `registry/npm.rs`:
|
||||
- Cache hit tracking for local storage
|
||||
- Cache miss + proxy fetch tracking
|
||||
|
||||
- `registry/maven.rs`:
|
||||
- `download()` - cache hit/miss + activity
|
||||
- `upload()` - record upload + activity
|
||||
|
||||
- `registry/cargo_registry.rs`:
|
||||
- `download()` - record download + activity
|
||||
|
||||
### Iteration 8: Build & Test
|
||||
- `cargo build` - compiled successfully with minor warnings
|
||||
- Fixed warnings:
|
||||
- Removed unused `RegistryStats` import
|
||||
- Added `#[allow(dead_code)]` to `stat_card()`
|
||||
- `cargo test` - all 75 tests passed
|
||||
|
||||
### Iteration 9: Server Testing
|
||||
- Started server: `cargo run --release --bin nora`
|
||||
- Tested endpoints:
|
||||
```
|
||||
GET /health - OK
|
||||
GET /api/ui/dashboard - returns full metrics JSON
|
||||
GET /ui/ - dark theme dashboard HTML
|
||||
GET /v2/test/manifests/v1 - triggered Docker metrics
|
||||
GET /npm/lodash/-/lodash-4.17.21.tgz - triggered npm proxy metrics
|
||||
```
|
||||
- Verified metrics tracking:
|
||||
- Downloads: 3 (2 Docker + 1 npm)
|
||||
- Cache hit rate: 66.67%
|
||||
- Activity log populated with Pull, ProxyFetch events
|
||||
|
||||
### Iteration 10: Git Commit & Push
|
||||
- Staged 11 files (2 new, 9 modified)
|
||||
- Commit: `93f9655 Add dashboard metrics, activity log, and dark theme`
|
||||
- Pushed to `origin/main`
|
||||
|
||||
### Iteration 11: Documentation
|
||||
- Updated `TODO.md` with v0.2.1 section
|
||||
- Created this `SESSION_NOTES.md`
|
||||
|
||||
---
|
||||
|
||||
### Key Decisions Made
|
||||
1. **In-memory metrics** - AtomicU64 for thread-safety, reset on restart
|
||||
2. **Bounded activity log** - 50 entries max, oldest evicted
|
||||
3. **Polling over WebSocket** - simpler, 5-second interval sufficient
|
||||
4. **Dark theme only for dashboard** - registry list pages keep light theme
|
||||
|
||||
### Files Changed Summary
|
||||
```
|
||||
New:
|
||||
nora-registry/src/activity_log.rs
|
||||
nora-registry/src/dashboard_metrics.rs
|
||||
|
||||
Modified:
|
||||
nora-registry/src/main.rs (+8 lines)
|
||||
nora-registry/src/registry/cargo_registry.rs (+13 lines)
|
||||
nora-registry/src/registry/docker.rs (+47 lines)
|
||||
nora-registry/src/registry/maven.rs (+36 lines)
|
||||
nora-registry/src/registry/npm.rs (+29 lines)
|
||||
nora-registry/src/ui/api.rs (+154 lines)
|
||||
nora-registry/src/ui/components.rs (+394 lines)
|
||||
nora-registry/src/ui/mod.rs (+5 lines)
|
||||
nora-registry/src/ui/templates.rs (+180/-79 lines)
|
||||
|
||||
Total: ~1004 insertions, 79 deletions
|
||||
```
|
||||
|
||||
### Useful Commands
|
||||
```bash
|
||||
# Start server
|
||||
cargo run --release --bin nora
|
||||
|
||||
# Test dashboard
|
||||
curl http://127.0.0.1:4000/api/ui/dashboard
|
||||
|
||||
# View UI
|
||||
open http://127.0.0.1:4000/ui/
|
||||
|
||||
# Trigger metrics
|
||||
curl http://127.0.0.1:4000/v2/test/manifests/v1
|
||||
curl http://127.0.0.1:4000/npm/lodash/-/lodash-4.17.21.tgz -o /dev/null
|
||||
```
|
||||
|
||||
---
|
||||
503
TODO.md
503
TODO.md
@@ -1,503 +0,0 @@
|
||||
# NORA Roadmap / TODO
|
||||
|
||||
## v0.2.0 - DONE
|
||||
- [x] Unit tests (75 tests passing)
|
||||
- [x] Input validation (path traversal protection)
|
||||
- [x] Rate limiting (brute-force protection)
|
||||
- [x] Request ID tracking
|
||||
- [x] Migrate command (local <-> S3)
|
||||
- [x] Error handling (thiserror)
|
||||
- [x] SVG brand icons
|
||||
|
||||
---
|
||||
|
||||
## v0.2.1 - Dashboard Expansion (2026-01-26) - DONE
|
||||
|
||||
### Commit: 93f9655
|
||||
|
||||
### New Files
|
||||
- `nora-registry/src/dashboard_metrics.rs` - AtomicU64 counters for metrics
|
||||
- `nora-registry/src/activity_log.rs` - Bounded activity log (50 entries)
|
||||
|
||||
### Modified Files
|
||||
- `nora-registry/src/main.rs` - Added modules, updated AppState
|
||||
- `nora-registry/src/ui/api.rs` - Added DashboardResponse, api_dashboard()
|
||||
- `nora-registry/src/ui/mod.rs` - Added /api/ui/dashboard route
|
||||
- `nora-registry/src/ui/components.rs` - Dark theme components
|
||||
- `nora-registry/src/ui/templates.rs` - New render_dashboard()
|
||||
- `nora-registry/src/registry/docker.rs` - Instrumented handlers
|
||||
- `nora-registry/src/registry/npm.rs` - Instrumented with cache tracking
|
||||
- `nora-registry/src/registry/maven.rs` - Instrumented download/upload
|
||||
- `nora-registry/src/registry/cargo_registry.rs` - Instrumented download
|
||||
|
||||
### Features Implemented
|
||||
- [x] Global stats panel (downloads, uploads, artifacts, cache hit %, storage)
|
||||
- [x] Per-registry metrics (Docker, Maven, npm, Cargo, PyPI)
|
||||
- [x] Mount points table with proxy upstreams
|
||||
- [x] Activity log (last 20 events)
|
||||
- [x] Dark theme (#0f172a background, #1e293b cards)
|
||||
- [x] Auto-refresh polling (5 seconds)
|
||||
- [x] Cache hit/miss tracking
|
||||
|
||||
### API Endpoints
|
||||
- `GET /api/ui/dashboard` - Full dashboard data as JSON
|
||||
|
||||
### Dark Theme Colors
|
||||
```
|
||||
Background: #0f172a (slate-950)
|
||||
Cards: #1e293b (slate-800)
|
||||
Borders: slate-700
|
||||
Text primary: slate-200
|
||||
Text secondary: slate-400
|
||||
Accent: blue-400
|
||||
```
|
||||
|
||||
### Testing Commands
|
||||
```bash
|
||||
# Test dashboard API
|
||||
curl http://127.0.0.1:4000/api/ui/dashboard
|
||||
|
||||
# Test Docker pull (triggers metrics)
|
||||
curl http://127.0.0.1:4000/v2/test/manifests/v1
|
||||
|
||||
# Test npm proxy (triggers cache miss)
|
||||
curl http://127.0.0.1:4000/npm/lodash/-/lodash-4.17.21.tgz -o /dev/null
|
||||
```
|
||||
|
||||
### Future Improvements (Dashboard)
|
||||
- [ ] Add PyPI download instrumentation
|
||||
- [ ] Persist metrics to disk (currently reset on restart)
|
||||
- [ ] Add WebSocket for real-time updates (instead of polling)
|
||||
- [ ] Add graphs/charts for metrics over time
|
||||
- [ ] Add user/client tracking in activity log
|
||||
- [ ] Dark/light theme toggle
|
||||
|
||||
---
|
||||
|
||||
## v0.3.0 - OIDC / Workload Identity Federation
|
||||
|
||||
### Killer Feature: OIDC for CI/CD
|
||||
Zero-secret authentication for GitHub Actions, GitLab CI, etc.
|
||||
|
||||
**Goal:** Replace manual `ROBOT_TOKEN` rotation with federated identity.
|
||||
|
||||
```yaml
|
||||
# GitHub Actions example
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Login to NORA
|
||||
uses: nora/login-action@v1
|
||||
```
|
||||
|
||||
### Config Structure (draft)
|
||||
|
||||
```toml
|
||||
[auth.oidc]
|
||||
enabled = true
|
||||
|
||||
# GitHub Actions
|
||||
[[auth.oidc.providers]]
|
||||
name = "github-actions"
|
||||
issuer = "https://token.actions.githubusercontent.com"
|
||||
audience = "https://nora.example.com"
|
||||
|
||||
[[auth.oidc.providers.rules]]
|
||||
# Claim matching (supports glob)
|
||||
match = { repository = "my-org/*", ref = "refs/heads/main" }
|
||||
# Granted permissions
|
||||
permissions = ["push:my-org/*", "pull:*"]
|
||||
|
||||
[[auth.oidc.providers.rules]]
|
||||
match = { repository = "my-org/*", ref = "refs/heads/*" }
|
||||
permissions = ["pull:*"]
|
||||
|
||||
# GitLab CI
|
||||
[[auth.oidc.providers]]
|
||||
name = "gitlab-ci"
|
||||
issuer = "https://gitlab.com"
|
||||
audience = "https://nora.example.com"
|
||||
|
||||
[[auth.oidc.providers.rules]]
|
||||
match = { project_path = "my-group/*" }
|
||||
permissions = ["push:my-group/*", "pull:*"]
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
- [ ] JWT validation library (jsonwebtoken crate)
|
||||
- [ ] OIDC discovery (/.well-known/openid-configuration)
|
||||
- [ ] JWKS fetching and caching
|
||||
- [ ] Claims extraction and glob matching
|
||||
- [ ] Permission resolution from rules
|
||||
- [ ] Token exchange endpoint (POST /auth/oidc/token)
|
||||
- [ ] GitHub Action: `nora/login-action`
|
||||
|
||||
---
|
||||
|
||||
## v0.4.0 - Transparent Docker Hub Proxy
|
||||
|
||||
### Pain Point
|
||||
Harbor forces tag changes: `docker pull my-harbor/proxy-cache/library/nginx`
|
||||
This breaks Helm charts hardcoded to `nginx`.
|
||||
|
||||
### Goal
|
||||
Transparent pull-through cache:
|
||||
```bash
|
||||
docker pull nora.example.com/nginx # -> proxies to Docker Hub
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
- [ ] Registry v2 API interception
|
||||
- [ ] Upstream registry configuration
|
||||
- [ ] Cache layer management
|
||||
- [ ] Rate limit handling (Docker Hub limits)
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0 - Repo-level RBAC
|
||||
|
||||
### Challenge
|
||||
Per-repository permissions need fast lookup (100 layers per push).
|
||||
|
||||
### Solution
|
||||
Glob patterns for 90% of cases:
|
||||
```toml
|
||||
[[auth.rules]]
|
||||
subject = "team-frontend"
|
||||
permissions = ["push:frontend/*", "pull:*"]
|
||||
|
||||
[[auth.rules]]
|
||||
subject = "ci-bot"
|
||||
permissions = ["push:*/release-*", "pull:*"]
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
- [ ] In-memory permission cache
|
||||
- [ ] Glob pattern matcher (globset crate)
|
||||
- [ ] Permission inheritance (org -> project -> repo)
|
||||
|
||||
---
|
||||
|
||||
## Target Audience
|
||||
|
||||
1. DevOps engineers tired of Java/Go monsters
|
||||
2. Edge/IoT installations (Raspberry Pi, branch offices)
|
||||
3. Educational platforms (student labs)
|
||||
4. CI/CD pipelines (GitHub Actions, GitLab CI)
|
||||
|
||||
## Competitive Advantages
|
||||
|
||||
| Feature | NORA | Harbor | Nexus |
|
||||
|---------|------|--------|-------|
|
||||
| Memory | <100MB | 2GB+ | 4GB+ |
|
||||
| OIDC for CI | v0.3.0 | No | No |
|
||||
| Transparent proxy | v0.4.0 | No (tag rewrite) | Partial |
|
||||
| Single binary | Yes | No (microservices) | No (Java) |
|
||||
| Zero-config upgrade | Yes | Complex | Complex |
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0 - Online Garbage Collection
|
||||
|
||||
### Pain Point
|
||||
Harbor GC blocks registry for hours. Can't push during cleanup.
|
||||
|
||||
### Goal
|
||||
Non-blocking garbage collection with zero downtime.
|
||||
|
||||
### Implementation Tasks
|
||||
- [ ] Mark-and-sweep without locking
|
||||
- [ ] Background blob cleanup
|
||||
- [ ] Progress reporting via API/CLI
|
||||
- [ ] `nora gc --dry-run` preview
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0 - Retention Policies
|
||||
|
||||
### Pain Point
|
||||
"Keep last 10 tags" sounds simple, works poorly everywhere.
|
||||
|
||||
### Goal
|
||||
Declarative retention rules in config:
|
||||
|
||||
```toml
|
||||
[[retention]]
|
||||
match = "*/dev-*"
|
||||
keep_last = 5
|
||||
|
||||
[[retention]]
|
||||
match = "*/release-*"
|
||||
keep_last = 20
|
||||
older_than = "90d"
|
||||
|
||||
[[retention]]
|
||||
match = "**/pr-*"
|
||||
older_than = "7d"
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
- [ ] Glob pattern matching for repos/tags
|
||||
- [ ] Age-based and count-based rules
|
||||
- [ ] Dry-run mode
|
||||
- [ ] Scheduled execution (cron-style)
|
||||
|
||||
---
|
||||
|
||||
## v0.8.0 - Multi-tenancy & Quotas
|
||||
|
||||
### Pain Point
|
||||
Harbor projects have quotas but configuration is painful. Nexus has no real isolation.
|
||||
|
||||
### Goal
|
||||
Simple namespaces with limits:
|
||||
|
||||
```toml
|
||||
[[tenants]]
|
||||
name = "team-frontend"
|
||||
storage_quota = "50GB"
|
||||
rate_limit = { push = 100, pull = 1000 } # per hour
|
||||
|
||||
[[tenants]]
|
||||
name = "team-backend"
|
||||
storage_quota = "100GB"
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
- [ ] Tenant isolation (namespace prefix)
|
||||
- [ ] Storage quota tracking
|
||||
- [ ] Per-tenant rate limiting
|
||||
- [ ] Usage reporting API
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0 - Smart Replication
|
||||
|
||||
### Pain Point
|
||||
Harbor replication rules are complex, errors silently swallowed.
|
||||
|
||||
### Goal
|
||||
Simple CLI-driven replication with clear feedback:
|
||||
|
||||
```bash
|
||||
nora replicate --to remote-dc --filter "prod/*" --dry-run
|
||||
nora replicate --from gcr.io/my-project/* --to local/imported/
|
||||
```
|
||||
|
||||
### Implementation Tasks
|
||||
- [ ] Push-based replication to remote NORA
|
||||
- [ ] Pull-based import from external registries (Docker Hub, GCR, ECR, Quay)
|
||||
- [ ] Filter by glob patterns
|
||||
- [ ] Progress bar and detailed logs
|
||||
- [ ] Retry logic with exponential backoff
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0 - Production Ready
|
||||
|
||||
### Features to polish
|
||||
- [ ] Full CLI (`nora images ls`, `nora tag`, `nora delete`)
|
||||
- [ ] Webhooks with filters and retry logic
|
||||
- [ ] Enhanced Prometheus metrics (per-repo stats, cache hit ratio, bandwidth per tenant)
|
||||
- [ ] TUI dashboard (optional)
|
||||
- [ ] Helm chart for Kubernetes deployment
|
||||
- [ ] Official Docker image on ghcr.io
|
||||
|
||||
---
|
||||
|
||||
## Future Ideas (v1.x+)
|
||||
|
||||
### Cold Storage Tiering
|
||||
Auto-move old tags to S3 Glacier:
|
||||
```toml
|
||||
[[storage.tiering]]
|
||||
match = "*"
|
||||
older_than = "180d"
|
||||
move_to = "s3-glacier"
|
||||
```
|
||||
|
||||
### Vulnerability Scanning Integration
|
||||
Not built-in (use Trivy), but:
|
||||
- [ ] Webhook on push -> trigger external scan
|
||||
- [ ] Store scan results as OCI artifacts
|
||||
- [ ] Block pull if critical CVEs (policy)
|
||||
|
||||
### Image Signing (Cosign/Notation)
|
||||
- [ ] Signature storage (OCI artifacts)
|
||||
- [ ] Policy enforcement (reject unsigned)
|
||||
|
||||
### P2P Distribution (Dragonfly/Kraken style)
|
||||
For large clusters pulling same image simultaneously.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Architecture / DDD
|
||||
|
||||
### Current State (v0.2.0)
|
||||
Monolithic structure, all in `nora-registry/src/`:
|
||||
```
|
||||
src/
|
||||
├── main.rs # CLI + server setup
|
||||
├── auth.rs # htpasswd + basic auth
|
||||
├── tokens.rs # API tokens
|
||||
├── storage/ # Storage backends (local, s3)
|
||||
├── registry/ # Protocol handlers (docker, maven, npm, cargo, pypi)
|
||||
├── ui/ # Web dashboard
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Target Architecture (v1.0+)
|
||||
|
||||
#### Domain-Driven Design Boundaries
|
||||
|
||||
```
|
||||
nora/
|
||||
├── nora-core/ # Domain layer (no dependencies)
|
||||
│ ├── src/
|
||||
│ │ ├── artifact.rs # Artifact, Digest, Tag, Manifest
|
||||
│ │ ├── repository.rs # Repository, Namespace
|
||||
│ │ ├── identity.rs # User, ServiceAccount, Token
|
||||
│ │ ├── policy.rs # Permission, Rule, Quota
|
||||
│ │ └── events.rs # DomainEvent (ArtifactPushed, etc.)
|
||||
│
|
||||
├── nora-auth/ # Authentication bounded context
|
||||
│ ├── src/
|
||||
│ │ ├── htpasswd.rs # Basic auth provider
|
||||
│ │ ├── oidc.rs # OIDC/JWT provider
|
||||
│ │ ├── token.rs # API token provider
|
||||
│ │ └── rbac.rs # Permission resolver
|
||||
│
|
||||
├── nora-storage/ # Storage bounded context
|
||||
│ ├── src/
|
||||
│ │ ├── backend.rs # StorageBackend trait
|
||||
│ │ ├── local.rs # Filesystem
|
||||
│ │ ├── s3.rs # S3-compatible
|
||||
│ │ ├── tiered.rs # Hot/cold tiering
|
||||
│ │ └── gc.rs # Garbage collection
|
||||
│
|
||||
├── nora-registry/ # Application layer (HTTP API)
|
||||
│ ├── src/
|
||||
│ │ ├── api/
|
||||
│ │ │ ├── oci.rs # OCI Distribution API (/v2/)
|
||||
│ │ │ ├── maven.rs # Maven repository
|
||||
│ │ │ ├── npm.rs # npm registry
|
||||
│ │ │ ├── cargo.rs # Cargo registry
|
||||
│ │ │ └── pypi.rs # PyPI (simple API)
|
||||
│ │ ├── proxy/ # Upstream proxy/cache
|
||||
│ │ ├── webhook/ # Event webhooks
|
||||
│ │ └── ui/ # Web dashboard
|
||||
│
|
||||
├── nora-cli/ # CLI application
|
||||
│ ├── src/
|
||||
│ │ ├── commands/
|
||||
│ │ │ ├── serve.rs
|
||||
│ │ │ ├── images.rs # nora images ls/delete/tag
|
||||
│ │ │ ├── gc.rs # nora gc
|
||||
│ │ │ ├── backup.rs # nora backup/restore
|
||||
│ │ │ ├── migrate.rs # nora migrate
|
||||
│ │ │ └── replicate.rs
|
||||
│ │ └── tui/ # Optional TUI dashboard
|
||||
│
|
||||
└── nora-sdk/ # Client SDK (for nora/login-action)
|
||||
└── src/
|
||||
├── client.rs # HTTP client
|
||||
└── oidc.rs # Token exchange
|
||||
```
|
||||
|
||||
#### Key Principles
|
||||
|
||||
1. **Hexagonal Architecture**
|
||||
- Core domain has no external dependencies
|
||||
- Ports (traits) define boundaries
|
||||
- Adapters implement ports (S3, filesystem, OIDC providers)
|
||||
|
||||
2. **Event-Driven**
|
||||
- Domain events: `ArtifactPushed`, `ArtifactDeleted`, `TagCreated`
|
||||
- Webhooks subscribe to events
|
||||
- Async processing for GC, replication
|
||||
|
||||
3. **CQRS-lite**
|
||||
- Commands: Push, Delete, CreateToken
|
||||
- Queries: List, Get, Search
|
||||
- Separate read/write paths for hot endpoints
|
||||
|
||||
4. **Configuration as Code**
|
||||
- All policies in `nora.toml`
|
||||
- No database for config (file-based)
|
||||
- GitOps friendly
|
||||
|
||||
#### Trait Boundaries (Ports)
|
||||
|
||||
```rust
|
||||
// nora-core/src/ports.rs
|
||||
|
||||
#[async_trait]
|
||||
pub trait ArtifactStore {
|
||||
async fn push_blob(&self, digest: &Digest, data: Bytes) -> Result<()>;
|
||||
async fn get_blob(&self, digest: &Digest) -> Result<Bytes>;
|
||||
async fn push_manifest(&self, repo: &Repository, tag: &Tag, manifest: &Manifest) -> Result<()>;
|
||||
async fn get_manifest(&self, repo: &Repository, reference: &Reference) -> Result<Manifest>;
|
||||
async fn list_tags(&self, repo: &Repository) -> Result<Vec<Tag>>;
|
||||
async fn delete(&self, repo: &Repository, reference: &Reference) -> Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait IdentityProvider {
|
||||
async fn authenticate(&self, credentials: &Credentials) -> Result<Identity>;
|
||||
async fn authorize(&self, identity: &Identity, action: &Action, resource: &Resource) -> Result<bool>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait EventPublisher {
|
||||
async fn publish(&self, event: DomainEvent) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Migration Path
|
||||
|
||||
| Phase | Action |
|
||||
|-------|--------|
|
||||
| v0.3 | Extract `nora-auth` crate (OIDC work) |
|
||||
| v0.4 | Extract `nora-core` domain types |
|
||||
| v0.5 | Extract `nora-storage` with trait boundaries |
|
||||
| v0.6+ | Refactor registry handlers to use ports |
|
||||
| v1.0 | Full hexagonal architecture |
|
||||
|
||||
### Technical Debt to Address
|
||||
|
||||
- [ ] Remove `unwrap()` in non-test code (started in e9984cf)
|
||||
- [ ] Add tracing spans to all handlers
|
||||
- [ ] Consistent error types across modules
|
||||
- [ ] Extract hardcoded limits to config
|
||||
- [ ] Add OpenTelemetry support (traces, not just metrics)
|
||||
|
||||
### Performance Requirements
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Memory (idle) | <50MB |
|
||||
| Memory (under load) | <100MB |
|
||||
| Startup time | <1s |
|
||||
| Blob throughput | Wire speed (no processing overhead) |
|
||||
| Manifest latency | <10ms p99 |
|
||||
| Auth check | <1ms (cached) |
|
||||
|
||||
### Security Requirements
|
||||
|
||||
- [ ] No secrets in logs (already redacting)
|
||||
- [ ] TLS termination (or trust reverse proxy)
|
||||
- [ ] Content-addressable storage (immutable blobs)
|
||||
- [ ] Audit log for all mutations
|
||||
- [ ] SBOM generation for NORA itself
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- S3 storage: already implemented
|
||||
- Web UI: minimalist read-only dashboard (done)
|
||||
- TUI: consider for v1.0
|
||||
- Vulnerability scanning: out of scope (use Trivy externally)
|
||||
- Image signing: out of scope for now (use cosign externally)
|
||||
42
deny.toml
Normal file
42
deny.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
# cargo-deny configuration
|
||||
# https://embarkstudios.github.io/cargo-deny/
|
||||
|
||||
[advisories]
|
||||
# Vulnerability database (RustSec)
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
ignore = [
|
||||
"RUSTSEC-2025-0119", # number_prefix unmaintained via indicatif; no fix available. Review by 2026-06-15
|
||||
]
|
||||
|
||||
[licenses]
|
||||
unused-allowed-license = "allow"
|
||||
# Allowed open-source licenses
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"CC0-1.0",
|
||||
"OpenSSL",
|
||||
"Zlib",
|
||||
"CDLA-Permissive-2.0", # webpki-roots (CA certificates bundle)
|
||||
"MPL-2.0",
|
||||
"NCSA", # libfuzzer-sys (LLVM fuzzer)
|
||||
]
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
deny = [
|
||||
{ name = "openssl-sys" },
|
||||
{ name = "openssl" },
|
||||
]
|
||||
skip = []
|
||||
|
||||
[sources]
|
||||
unknown-registry = "warn"
|
||||
unknown-git = "warn"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Demo traffic simulator for NORA registry
|
||||
# Generates random registry activity for dashboard demo
|
||||
|
||||
REGISTRY="http://localhost:4000"
|
||||
LOG_FILE="/var/log/nora-demo-traffic.log"
|
||||
|
||||
# Sample packages to fetch
|
||||
NPM_PACKAGES=("lodash" "express" "react" "axios" "moment" "underscore" "chalk" "debug")
|
||||
MAVEN_ARTIFACTS=(
|
||||
"org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.pom"
|
||||
"com/google/guava/guava/31.1-jre/guava-31.1-jre.pom"
|
||||
"org/slf4j/slf4j-api/2.0.0/slf4j-api-2.0.0.pom"
|
||||
)
|
||||
DOCKER_IMAGES=("alpine" "busybox" "hello-world")
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Random sleep between min and max seconds
|
||||
random_sleep() {
|
||||
local min=$1
|
||||
local max=$2
|
||||
local delay=$((RANDOM % (max - min + 1) + min))
|
||||
sleep $delay
|
||||
}
|
||||
|
||||
# Fetch random npm package
|
||||
fetch_npm() {
|
||||
local pkg=${NPM_PACKAGES[$RANDOM % ${#NPM_PACKAGES[@]}]}
|
||||
log "NPM: fetching $pkg"
|
||||
curl -s "$REGISTRY/npm/$pkg" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Fetch random maven artifact
|
||||
fetch_maven() {
|
||||
local artifact=${MAVEN_ARTIFACTS[$RANDOM % ${#MAVEN_ARTIFACTS[@]}]}
|
||||
log "MAVEN: fetching $artifact"
|
||||
curl -s "$REGISTRY/maven2/$artifact" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Docker push/pull cycle
|
||||
docker_cycle() {
|
||||
local img=${DOCKER_IMAGES[$RANDOM % ${#DOCKER_IMAGES[@]}]}
|
||||
local tag="demo-$(date +%s)"
|
||||
|
||||
log "DOCKER: push/pull cycle for $img"
|
||||
|
||||
# Tag and push
|
||||
docker tag "$img:latest" "localhost:4000/demo/$img:$tag" 2>/dev/null
|
||||
docker push "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||
|
||||
# Pull back
|
||||
docker rmi "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||
docker pull "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||
|
||||
# Cleanup
|
||||
docker rmi "localhost:4000/demo/$img:$tag" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Main loop
|
||||
log "Starting demo traffic simulator"
|
||||
|
||||
while true; do
|
||||
# Random operation
|
||||
op=$((RANDOM % 10))
|
||||
|
||||
case $op in
|
||||
0|1|2|3) # 40% npm
|
||||
fetch_npm
|
||||
;;
|
||||
4|5|6) # 30% maven
|
||||
fetch_maven
|
||||
;;
|
||||
7|8|9) # 30% docker
|
||||
docker_cycle
|
||||
;;
|
||||
esac
|
||||
|
||||
# Random delay: 30-120 seconds
|
||||
random_sleep 30 120
|
||||
done
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=NORA Demo Traffic Simulator
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/opt/nora/demo-traffic.sh
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
131
dist/install.sh
vendored
Executable file
131
dist/install.sh
vendored
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# NORA Artifact Registry — install script
|
||||
# Usage: curl -fsSL https://getnora.io/install.sh | bash
|
||||
|
||||
VERSION="${NORA_VERSION:-latest}"
|
||||
ARCH=$(uname -m)
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
CONFIG_DIR="/etc/nora"
|
||||
DATA_DIR="/var/lib/nora"
|
||||
LOG_DIR="/var/log/nora"
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64|amd64) ARCH="x86_64" ;;
|
||||
aarch64|arm64) ARCH="aarch64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "Installing NORA ($OS/$ARCH)..."
|
||||
|
||||
# Download binary
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
DOWNLOAD_URL="https://github.com/getnora-io/nora/releases/latest/download/nora-${OS}-${ARCH}"
|
||||
else
|
||||
DOWNLOAD_URL="https://github.com/getnora-io/nora/releases/download/${VERSION}/nora-${OS}-${ARCH}"
|
||||
fi
|
||||
|
||||
echo "Downloading from $DOWNLOAD_URL..."
|
||||
if command -v curl &>/dev/null; then
|
||||
curl -fsSL -o /tmp/nora "$DOWNLOAD_URL"
|
||||
elif command -v wget &>/dev/null; then
|
||||
wget -qO /tmp/nora "$DOWNLOAD_URL"
|
||||
else
|
||||
echo "Error: curl or wget required"; exit 1
|
||||
fi
|
||||
|
||||
chmod +x /tmp/nora
|
||||
|
||||
# Verify signature if cosign is available
|
||||
if command -v cosign &>/dev/null; then
|
||||
echo "Verifying binary signature..."
|
||||
SIG_URL="${DOWNLOAD_URL}.sig"
|
||||
CERT_URL="${DOWNLOAD_URL}.pem"
|
||||
if curl -fsSL -o /tmp/nora.sig "$SIG_URL" 2>/dev/null && \
|
||||
curl -fsSL -o /tmp/nora.pem "$CERT_URL" 2>/dev/null; then
|
||||
cosign verify-blob --signature /tmp/nora.sig --certificate /tmp/nora.pem \
|
||||
--certificate-identity-regexp "github.com/getnora-io/nora" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
/tmp/nora && echo "Signature verified." || echo "Warning: signature verification failed."
|
||||
rm -f /tmp/nora.sig /tmp/nora.pem
|
||||
else
|
||||
echo "Signature files not available, skipping verification."
|
||||
fi
|
||||
else
|
||||
echo "Install cosign for binary signature verification: https://docs.sigstore.dev/cosign/system_config/installation/"
|
||||
fi
|
||||
|
||||
sudo mv /tmp/nora "$INSTALL_DIR/nora"
|
||||
|
||||
# Create system user
|
||||
if ! id nora &>/dev/null; then
|
||||
sudo useradd --system --shell /usr/sbin/nologin --home-dir "$DATA_DIR" --create-home nora
|
||||
echo "Created system user: nora"
|
||||
fi
|
||||
|
||||
# Create directories
|
||||
sudo mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR"
|
||||
sudo chown nora:nora "$DATA_DIR" "$LOG_DIR"
|
||||
|
||||
# Install default config if not exists
|
||||
if [ ! -f "$CONFIG_DIR/nora.env" ]; then
|
||||
cat > /tmp/nora.env << 'ENVEOF'
|
||||
NORA_HOST=0.0.0.0
|
||||
NORA_PORT=4000
|
||||
NORA_STORAGE_PATH=/var/lib/nora
|
||||
ENVEOF
|
||||
sudo mv /tmp/nora.env "$CONFIG_DIR/nora.env"
|
||||
sudo chmod 600 "$CONFIG_DIR/nora.env"
|
||||
sudo chown nora:nora "$CONFIG_DIR/nora.env"
|
||||
echo "Created default config: $CONFIG_DIR/nora.env"
|
||||
fi
|
||||
|
||||
# Install systemd service
|
||||
if [ -d /etc/systemd/system ]; then
|
||||
cat > /tmp/nora.service << 'SVCEOF'
|
||||
[Unit]
|
||||
Description=NORA Artifact Registry
|
||||
Documentation=https://getnora.dev
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nora
|
||||
Group=nora
|
||||
ExecStart=/usr/local/bin/nora serve
|
||||
WorkingDirectory=/etc/nora
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/nora /var/log/nora
|
||||
PrivateTmp=true
|
||||
EnvironmentFile=-/etc/nora/nora.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
sudo mv /tmp/nora.service /etc/systemd/system/nora.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable nora
|
||||
echo "Installed systemd service: nora"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "NORA installed successfully!"
|
||||
echo ""
|
||||
echo " Binary: $INSTALL_DIR/nora"
|
||||
echo " Config: $CONFIG_DIR/nora.env"
|
||||
echo " Data: $DATA_DIR"
|
||||
echo " Version: $(nora --version 2>/dev/null || echo 'unknown')"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Edit $CONFIG_DIR/nora.env"
|
||||
echo " 2. sudo systemctl start nora"
|
||||
echo " 3. curl http://localhost:4000/health"
|
||||
echo ""
|
||||
31
dist/nora.env.example
vendored
Normal file
31
dist/nora.env.example
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# NORA configuration — environment variables
|
||||
# Copy to /etc/nora/nora.env and adjust
|
||||
|
||||
# Server
|
||||
NORA_HOST=0.0.0.0
|
||||
NORA_PORT=4000
|
||||
# NORA_PUBLIC_URL=https://registry.example.com
|
||||
|
||||
# Storage
|
||||
NORA_STORAGE_PATH=/var/lib/nora
|
||||
# NORA_STORAGE_MODE=s3
|
||||
# NORA_STORAGE_S3_URL=http://minio:9000
|
||||
# NORA_STORAGE_BUCKET=registry
|
||||
|
||||
# Auth (optional)
|
||||
# NORA_AUTH_ENABLED=true
|
||||
# NORA_AUTH_HTPASSWD_FILE=/etc/nora/users.htpasswd
|
||||
|
||||
# Rate limiting
|
||||
# NORA_RATE_LIMIT_ENABLED=true
|
||||
|
||||
# npm proxy
|
||||
# NORA_NPM_PROXY=https://registry.npmjs.org
|
||||
# NORA_NPM_METADATA_TTL=300
|
||||
# NORA_NPM_PROXY_AUTH=user:pass
|
||||
|
||||
# PyPI proxy
|
||||
# NORA_PYPI_PROXY=https://pypi.org/simple/
|
||||
|
||||
# Docker upstreams
|
||||
# NORA_DOCKER_UPSTREAMS=https://registry-1.docker.io
|
||||
28
dist/nora.service
vendored
Normal file
28
dist/nora.service
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=NORA Artifact Registry
|
||||
Documentation=https://getnora.dev
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nora
|
||||
Group=nora
|
||||
ExecStart=/usr/local/bin/nora serve
|
||||
WorkingDirectory=/etc/nora
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65535
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/nora /var/log/nora
|
||||
PrivateTmp=true
|
||||
|
||||
# Environment
|
||||
EnvironmentFile=-/etc/nora/nora.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
nora:
|
||||
build: .
|
||||
image: getnora/nora:latest
|
||||
image: ghcr.io/getnora-io/nora:latest
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
|
||||
13
docs-ru/README.md
Normal file
13
docs-ru/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Документация NORA для Росреестра
|
||||
|
||||
## Структура
|
||||
|
||||
- `ТУ.md` — Технические условия
|
||||
- `Руководство.md` — Руководство пользователя
|
||||
- `Руководство_администратора.md` — Руководство администратора
|
||||
- `SBOM.md` — Перечень компонентов (Software Bill of Materials)
|
||||
|
||||
## Статус
|
||||
|
||||
Подготовка документации для включения в Единый реестр российских программ
|
||||
для электронных вычислительных машин и баз данных (Минцифры РФ).
|
||||
301
docs-ru/admin-guide.md
Normal file
301
docs-ru/admin-guide.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Руководство администратора NORA
|
||||
|
||||
**Версия:** 0.2.32
|
||||
**Дата:** 2026-03-16
|
||||
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
|
||||
|
||||
---
|
||||
|
||||
## 1. Общие сведения
|
||||
|
||||
NORA — многопротокольный реестр артефактов, предназначенный для хранения, кэширования и распространения программных компонентов. Программа обеспечивает централизованное управление зависимостями при разработке и сборке программного обеспечения.
|
||||
|
||||
### 1.1. Назначение
|
||||
|
||||
- Хранение и раздача артефактов по протоколам Docker (OCI), npm, Maven, PyPI, Cargo, Helm OCI и Raw.
|
||||
- Проксирование и кэширование внешних репозиториев для ускорения сборок и обеспечения доступности при отсутствии соединения с сетью Интернет.
|
||||
- Контроль целостности артефактов посредством верификации SHA-256.
|
||||
- Аудит и протоколирование всех операций.
|
||||
|
||||
### 1.2. Системные требования
|
||||
|
||||
| Параметр | Минимальные | Рекомендуемые |
|
||||
|----------|-------------|---------------|
|
||||
| ОС | Linux (amd64, arm64) | Ubuntu 22.04+, RHEL 8+ |
|
||||
| ЦПУ | 1 ядро | 2+ ядра |
|
||||
| ОЗУ | 256 МБ | 1+ ГБ |
|
||||
| Диск | 1 ГБ | 50+ ГБ (зависит от объёма хранимых артефактов) |
|
||||
| Сеть | TCP-порт (по умолчанию 4000) | — |
|
||||
|
||||
### 1.3. Зависимости
|
||||
|
||||
Программа поставляется как единый статически слинкованный исполняемый файл. Внешние зависимости отсутствуют. Перечень библиотек, включённых в состав программы, приведён в файле `nora.cdx.json` (формат CycloneDX).
|
||||
|
||||
---
|
||||
|
||||
## 2. Установка
|
||||
|
||||
### 2.1. Автоматическая установка
|
||||
|
||||
```bash
|
||||
curl -fsSL https://getnora.io/install.sh | bash
|
||||
```
|
||||
|
||||
Скрипт выполняет следующие действия:
|
||||
|
||||
1. Определяет архитектуру процессора (amd64 или arm64).
|
||||
2. Загружает исполняемый файл с GitHub Releases.
|
||||
3. Создаёт системного пользователя `nora`.
|
||||
4. Создаёт каталоги: `/etc/nora/`, `/var/lib/nora/`, `/var/log/nora/`.
|
||||
5. Устанавливает файл конфигурации `/etc/nora/nora.env`.
|
||||
6. Устанавливает и активирует systemd-сервис.
|
||||
|
||||
### 2.2. Ручная установка
|
||||
|
||||
```bash
|
||||
# Загрузка
|
||||
wget https://github.com/getnora-io/nora/releases/download/v1.0.0/nora-linux-x86_64
|
||||
chmod +x nora-linux-x86_64
|
||||
mv nora-linux-x86_64 /usr/local/bin/nora
|
||||
|
||||
# Создание пользователя
|
||||
useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/nora --create-home nora
|
||||
|
||||
# Создание каталогов
|
||||
mkdir -p /etc/nora /var/lib/nora /var/log/nora
|
||||
chown nora:nora /var/lib/nora /var/log/nora
|
||||
|
||||
# Установка systemd-сервиса
|
||||
cp dist/nora.service /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
systemctl enable nora
|
||||
```
|
||||
|
||||
### 2.3. Установка из Docker-образа
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name nora \
|
||||
-p 4000:4000 \
|
||||
-v nora-data:/data \
|
||||
ghcr.io/getnora-io/nora:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Конфигурация
|
||||
|
||||
Конфигурация задаётся через переменные окружения, файл `config.toml` или их комбинацию. Приоритет: переменные окружения > config.toml > значения по умолчанию.
|
||||
|
||||
### 3.1. Основные параметры
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_HOST` | Адрес привязки | `127.0.0.1` |
|
||||
| `NORA_PORT` | Порт | `4000` |
|
||||
| `NORA_PUBLIC_URL` | Внешний URL (для генерации ссылок) | — |
|
||||
| `NORA_STORAGE_PATH` | Путь к каталогу хранилища | `data/storage` |
|
||||
| `NORA_STORAGE_MODE` | Тип хранилища: `local` или `s3` | `local` |
|
||||
| `NORA_BODY_LIMIT_MB` | Максимальный размер тела запроса (МБ) | `2048` |
|
||||
|
||||
### 3.2. Аутентификация
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_AUTH_ENABLED` | Включить аутентификацию | `false` |
|
||||
| `NORA_AUTH_HTPASSWD_FILE` | Путь к файлу htpasswd | `users.htpasswd` |
|
||||
|
||||
Создание пользователя:
|
||||
|
||||
```bash
|
||||
htpasswd -Bc /etc/nora/users.htpasswd admin
|
||||
```
|
||||
|
||||
Роли: `admin` (полный доступ), `write` (чтение и запись), `read` (только чтение, по умолчанию).
|
||||
|
||||
### 3.3. Проксирование внешних репозиториев
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_NPM_PROXY` | URL npm-реестра | `https://registry.npmjs.org` |
|
||||
| `NORA_NPM_PROXY_AUTH` | Учётные данные (`user:pass`) | — |
|
||||
| `NORA_NPM_METADATA_TTL` | TTL кэша метаданных (секунды) | `300` |
|
||||
| `NORA_PYPI_PROXY` | URL PyPI-реестра | `https://pypi.org/simple/` |
|
||||
| `NORA_MAVEN_PROXIES` | Список Maven-репозиториев через запятую | `https://repo1.maven.org/maven2` |
|
||||
| `NORA_DOCKER_UPSTREAMS` | Docker-реестры, формат: `url\|auth,url2` | `https://registry-1.docker.io` |
|
||||
|
||||
### 3.4. Ограничение частоты запросов
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|-----------|----------|--------------|
|
||||
| `NORA_RATE_LIMIT_ENABLED` | Включить ограничение | `true` |
|
||||
| `NORA_RATE_LIMIT_GENERAL_RPS` | Запросов в секунду (общие) | `100` |
|
||||
| `NORA_RATE_LIMIT_AUTH_RPS` | Запросов в секунду (аутентификация) | `1` |
|
||||
| `NORA_RATE_LIMIT_UPLOAD_RPS` | Запросов в секунду (загрузка) | `200` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Управление сервисом
|
||||
|
||||
### 4.1. Запуск и остановка
|
||||
|
||||
```bash
|
||||
systemctl start nora # Запуск
|
||||
systemctl stop nora # Остановка
|
||||
systemctl restart nora # Перезапуск
|
||||
systemctl status nora # Статус
|
||||
journalctl -u nora -f # Просмотр журнала
|
||||
```
|
||||
|
||||
### 4.2. Проверка работоспособности
|
||||
|
||||
```bash
|
||||
curl http://localhost:4000/health
|
||||
```
|
||||
|
||||
Ответ при нормальной работе:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"storage": { "backend": "local", "reachable": true },
|
||||
"registries": { "docker": "ok", "npm": "ok", "maven": "ok", "cargo": "ok", "pypi": "ok" }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3. Метрики (Prometheus)
|
||||
|
||||
```
|
||||
GET /metrics
|
||||
```
|
||||
|
||||
Экспортируются: количество запросов, латентность, загрузки и выгрузки по протоколам.
|
||||
|
||||
---
|
||||
|
||||
## 5. Резервное копирование и восстановление
|
||||
|
||||
### 5.1. Создание резервной копии
|
||||
|
||||
```bash
|
||||
nora backup --output /backup/nora-$(date +%Y%m%d).tar.gz
|
||||
```
|
||||
|
||||
### 5.2. Восстановление
|
||||
|
||||
```bash
|
||||
nora restore --input /backup/nora-20260316.tar.gz
|
||||
```
|
||||
|
||||
### 5.3. Сборка мусора
|
||||
|
||||
```bash
|
||||
nora gc --dry-run # Просмотр (без удаления)
|
||||
nora gc # Удаление осиротевших блобов
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Предварительное кэширование (nora mirror)
|
||||
|
||||
Команда `nora mirror` позволяет заранее загрузить зависимости через прокси-кэш NORA. Это обеспечивает доступность артефактов при работе в изолированных средах без доступа к сети Интернет.
|
||||
|
||||
### 6.1. Кэширование по lockfile
|
||||
|
||||
```bash
|
||||
nora mirror npm --lockfile package-lock.json --registry http://localhost:4000
|
||||
nora mirror pip --lockfile requirements.txt --registry http://localhost:4000
|
||||
nora mirror cargo --lockfile Cargo.lock --registry http://localhost:4000
|
||||
```
|
||||
|
||||
### 6.2. Кэширование по списку пакетов
|
||||
|
||||
```bash
|
||||
nora mirror npm --packages lodash,express --registry http://localhost:4000
|
||||
nora mirror npm --packages lodash --all-versions --registry http://localhost:4000
|
||||
```
|
||||
|
||||
### 6.3. Параметры
|
||||
|
||||
| Флаг | Описание | По умолчанию |
|
||||
|------|----------|--------------|
|
||||
| `--registry` | URL экземпляра NORA | `http://localhost:4000` |
|
||||
| `--concurrency` | Количество параллельных загрузок | `8` |
|
||||
| `--all-versions` | Загрузить все версии (только с `--packages`) | — |
|
||||
|
||||
---
|
||||
|
||||
## 7. Миграция хранилища
|
||||
|
||||
Перенос артефактов между локальным хранилищем и S3:
|
||||
|
||||
```bash
|
||||
nora migrate --from local --to s3 --dry-run # Просмотр
|
||||
nora migrate --from local --to s3 # Выполнение
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Безопасность
|
||||
|
||||
### 8.1. Контроль целостности
|
||||
|
||||
При проксировании npm-пакетов NORA вычисляет и сохраняет контрольную сумму SHA-256 для каждого тарбола. При повторной выдаче из кэша контрольная сумма проверяется. В случае расхождения запрос отклоняется, а в журнал записывается предупреждение уровня SECURITY.
|
||||
|
||||
### 8.2. Защита от подмены пакетов
|
||||
|
||||
- Валидация имён файлов при публикации (защита от обхода каталогов).
|
||||
- Проверка соответствия имени пакета в URL и теле запроса.
|
||||
- Иммутабельность версий: повторная публикация той же версии запрещена.
|
||||
|
||||
### 8.3. Аудит
|
||||
|
||||
Все операции (загрузка, выгрузка, обращения к кэшу, ошибки) фиксируются в файле `audit.jsonl` в каталоге хранилища. Формат — JSON Lines, одна запись на строку.
|
||||
|
||||
### 8.4. Усиление systemd
|
||||
|
||||
Файл сервиса содержит параметры безопасности:
|
||||
|
||||
- `NoNewPrivileges=true` — запрет повышения привилегий.
|
||||
- `ProtectSystem=strict` — файловая система только для чтения, кроме указанных каталогов.
|
||||
- `ProtectHome=true` — запрет доступа к домашним каталогам.
|
||||
- `PrivateTmp=true` — изолированный каталог временных файлов.
|
||||
|
||||
---
|
||||
|
||||
## 9. Точки подключения (endpoints)
|
||||
|
||||
| Протокол | Endpoint | Описание |
|
||||
|----------|----------|----------|
|
||||
| Docker / OCI | `/v2/` | Docker Registry V2 API |
|
||||
| npm | `/npm/` | npm-реестр (прокси + публикация) |
|
||||
| Maven | `/maven2/` | Maven-репозиторий |
|
||||
| PyPI | `/simple/` | Python Simple API (PEP 503) |
|
||||
| Cargo | `/cargo/` | Cargo-реестр |
|
||||
| Helm | `/v2/` (OCI) | Helm-чарты через OCI-протокол |
|
||||
| Raw | `/raw/` | Произвольные файлы |
|
||||
| Мониторинг | `/health`, `/ready`, `/metrics` | Проверка и метрики |
|
||||
| Интерфейс | `/ui/` | Веб-интерфейс управления |
|
||||
| Документация API | `/api-docs` | OpenAPI (Swagger UI) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Устранение неполадок
|
||||
|
||||
### Сервис не запускается
|
||||
|
||||
```bash
|
||||
journalctl -u nora --no-pager -n 50
|
||||
```
|
||||
|
||||
Частые причины: занят порт, недоступен каталог хранилища, ошибка в конфигурации.
|
||||
|
||||
### Прокси-кэш не работает
|
||||
|
||||
1. Проверьте доступность внешнего реестра: `curl https://registry.npmjs.org/lodash`.
|
||||
2. Убедитесь, что переменная `NORA_NPM_PROXY` задана корректно.
|
||||
3. При использовании приватного реестра укажите `NORA_NPM_PROXY_AUTH`.
|
||||
|
||||
### Ошибка целостности (Integrity check failed)
|
||||
|
||||
Контрольная сумма кэшированного тарбола не совпадает с сохранённой. Возможные причины: повреждение файловой системы или несанкционированное изменение файла. Удалите повреждённый файл из каталога хранилища — NORA загрузит его заново из внешнего реестра.
|
||||
165
docs-ru/technical-spec.md
Normal file
165
docs-ru/technical-spec.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Технические условия
|
||||
|
||||
## Программа «NORA — Реестр артефактов»
|
||||
|
||||
**Версия документа:** 0.2.32
|
||||
**Дата:** 2026-03-16
|
||||
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
|
||||
|
||||
---
|
||||
|
||||
## 1. Наименование и обозначение
|
||||
|
||||
**Полное наименование:** NORA — многопротокольный реестр артефактов.
|
||||
|
||||
**Краткое наименование:** NORA.
|
||||
|
||||
**Обозначение:** nora-registry.
|
||||
|
||||
---
|
||||
|
||||
## 2. Назначение
|
||||
|
||||
Программа предназначена для хранения, кэширования и распространения программных компонентов (артефактов), используемых при разработке, сборке и развёртывании программного обеспечения.
|
||||
|
||||
### 2.1. Область применения
|
||||
|
||||
- Организация внутренних репозиториев программных компонентов.
|
||||
- Проксирование и кэширование общедоступных репозиториев (npmjs.org, PyPI, Maven Central, Docker Hub, crates.io).
|
||||
- Обеспечение доступности зависимостей в изолированных средах без доступа к сети Интернет (air-gapped).
|
||||
- Контроль целостности и безопасности цепочки поставки программного обеспечения.
|
||||
|
||||
### 2.2. Класс программного обеспечения
|
||||
|
||||
Инструментальное программное обеспечение для разработки и DevOps.
|
||||
|
||||
Код ОКПД2: 62.01 — Разработка компьютерного программного обеспечения.
|
||||
|
||||
---
|
||||
|
||||
## 3. Функциональные характеристики
|
||||
|
||||
### 3.1. Поддерживаемые протоколы
|
||||
|
||||
| Протокол | Стандарт | Назначение |
|
||||
|----------|----------|------------|
|
||||
| Docker / OCI | OCI Distribution Spec v1.0 | Контейнерные образы, Helm-чарты |
|
||||
| npm | npm Registry API | Библиотеки JavaScript / TypeScript |
|
||||
| Maven | Maven Repository Layout | Библиотеки Java / Kotlin |
|
||||
| PyPI | PEP 503 (Simple API) | Библиотеки Python |
|
||||
| Cargo | Cargo Registry Protocol | Библиотеки Rust |
|
||||
| Raw | HTTP PUT/GET | Произвольные файлы |
|
||||
|
||||
### 3.2. Режимы работы
|
||||
|
||||
1. **Хранилище (hosted):** приём и хранение артефактов, опубликованных пользователями.
|
||||
2. **Прокси-кэш (proxy):** прозрачное проксирование запросов к внешним репозиториям с локальным кэшированием.
|
||||
3. **Комбинированный:** одновременная работа в режимах хранилища и прокси-кэша (поиск сначала в локальном хранилище, затем во внешнем репозитории).
|
||||
|
||||
### 3.3. Управление доступом
|
||||
|
||||
- Аутентификация на основе htpasswd (bcrypt).
|
||||
- Ролевая модель: `read` (чтение), `write` (чтение и запись), `admin` (полный доступ).
|
||||
- Токены доступа с ограниченным сроком действия.
|
||||
|
||||
### 3.4. Безопасность
|
||||
|
||||
- Контроль целостности кэшированных артефактов (SHA-256).
|
||||
- Защита от обхода каталогов (path traversal) при публикации.
|
||||
- Проверка соответствия имени пакета в URL и теле запроса.
|
||||
- Иммутабельность опубликованных версий.
|
||||
- Аудит всех операций в формате JSON Lines.
|
||||
- Поддержка TLS при размещении за обратным прокси-сервером.
|
||||
|
||||
### 3.5. Эксплуатация
|
||||
|
||||
- Предварительное кэширование зависимостей (`nora mirror`) по файлам фиксации версий (lockfile).
|
||||
- Сборка мусора (`nora gc`) — удаление осиротевших блобов.
|
||||
- Резервное копирование и восстановление (`nora backup`, `nora restore`).
|
||||
- Миграция между локальным хранилищем и S3-совместимым объектным хранилищем.
|
||||
- Мониторинг: эндпоинты `/health`, `/ready`, `/metrics` (формат Prometheus).
|
||||
- Веб-интерфейс для просмотра содержимого реестра.
|
||||
- Документация API в формате OpenAPI 3.0.
|
||||
|
||||
---
|
||||
|
||||
## 4. Технические характеристики
|
||||
|
||||
### 4.1. Среда исполнения
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Язык реализации | Rust |
|
||||
| Формат поставки | Единый исполняемый файл (статическая линковка) |
|
||||
| Поддерживаемые ОС | Linux (ядро 4.15+) |
|
||||
| Архитектуры | x86_64 (amd64), aarch64 (arm64) |
|
||||
| Контейнеризация | Docker-образ на базе `scratch` |
|
||||
| Системная интеграция | systemd (файл сервиса в комплекте) |
|
||||
|
||||
### 4.2. Хранение данных
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Локальное хранилище | Файловая система (ext4, XFS, ZFS) |
|
||||
| Объектное хранилище | S3-совместимое API (MinIO, Yandex Object Storage, Selectel S3) |
|
||||
| Структура | Иерархическая: `{protocol}/{package}/{artifact}` |
|
||||
| Аудит | Append-only JSONL файл |
|
||||
|
||||
### 4.3. Конфигурация
|
||||
|
||||
| Источник | Приоритет |
|
||||
|----------|-----------|
|
||||
| Переменные окружения (`NORA_*`) | Высший |
|
||||
| Файл `config.toml` | Средний |
|
||||
| Значения по умолчанию | Низший |
|
||||
|
||||
### 4.4. Производительность
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Время запуска | < 100 мс |
|
||||
| Обслуживание из кэша | < 2 мс (метаданные), < 10 мс (артефакты до 1 МБ) |
|
||||
| Параллельная обработка | Асинхронный ввод-вывод (tokio runtime) |
|
||||
| Ограничение частоты | Настраиваемое (по умолчанию 100 запросов/сек) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Лицензирование
|
||||
|
||||
| Компонент | Лицензия |
|
||||
|-----------|----------|
|
||||
| NORA (core) | MIT License |
|
||||
| NORA Enterprise | Проприетарная |
|
||||
|
||||
Полный перечень лицензий включённых библиотек приведён в файле SBOM (формат CycloneDX).
|
||||
|
||||
---
|
||||
|
||||
## 6. Комплектность
|
||||
|
||||
| Компонент | Описание |
|
||||
|-----------|----------|
|
||||
| `nora` | Исполняемый файл |
|
||||
| `nora.service` | Файл systemd-сервиса |
|
||||
| `nora.env.example` | Шаблон конфигурации |
|
||||
| `install.sh` | Скрипт установки |
|
||||
| `nora.cdx.json` | SBOM в формате CycloneDX |
|
||||
| Руководство администратора | Настоящий комплект документации |
|
||||
| Руководство пользователя | Настоящий комплект документации |
|
||||
| Технические условия | Настоящий документ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Контактная информация
|
||||
|
||||
**Правообладатель:** ООО «ТАИАРС»
|
||||
|
||||
**Торговая марка:** АРТАИС
|
||||
|
||||
**Сайт продукта:** https://getnora.io
|
||||
|
||||
**Документация:** https://getnora.dev
|
||||
|
||||
**Исходный код:** https://github.com/getnora-io/nora
|
||||
|
||||
**Поддержка:** https://t.me/getnora
|
||||
221
docs-ru/user-guide.md
Normal file
221
docs-ru/user-guide.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Руководство пользователя NORA
|
||||
|
||||
**Версия:** 0.2.32
|
||||
**Дата:** 2026-03-16
|
||||
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
|
||||
|
||||
---
|
||||
|
||||
## 1. Общие сведения
|
||||
|
||||
NORA — реестр артефактов для команд разработки. Программа обеспечивает хранение и кэширование библиотек, Docker-образов и иных программных компонентов, используемых при сборке приложений.
|
||||
|
||||
Данное руководство предназначено для разработчиков, которые используют NORA в качестве источника зависимостей.
|
||||
|
||||
---
|
||||
|
||||
## 2. Настройка рабочего окружения
|
||||
|
||||
### 2.1. npm / Node.js
|
||||
|
||||
Укажите NORA в качестве реестра:
|
||||
|
||||
```bash
|
||||
npm config set registry http://nora.example.com:4000/npm
|
||||
```
|
||||
|
||||
Или создайте файл `.npmrc` в корне проекта:
|
||||
|
||||
```
|
||||
registry=http://nora.example.com:4000/npm
|
||||
```
|
||||
|
||||
После этого все команды `npm install` будут загружать пакеты через NORA. При первом обращении NORA загрузит пакет из внешнего реестра (npmjs.org) и сохранит его в кэш. Последующие обращения обслуживаются из кэша.
|
||||
|
||||
### 2.2. Docker
|
||||
|
||||
```bash
|
||||
docker login nora.example.com:4000
|
||||
docker pull nora.example.com:4000/library/nginx:latest
|
||||
docker push nora.example.com:4000/myteam/myapp:1.0.0
|
||||
```
|
||||
|
||||
### 2.3. Maven
|
||||
|
||||
Добавьте репозиторий в `settings.xml`:
|
||||
|
||||
```xml
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>nora</id>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
<url>http://nora.example.com:4000/maven2</url>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
```
|
||||
|
||||
### 2.4. Python / pip
|
||||
|
||||
```bash
|
||||
pip install --index-url http://nora.example.com:4000/simple flask
|
||||
```
|
||||
|
||||
Или в `pip.conf`:
|
||||
|
||||
```ini
|
||||
[global]
|
||||
index-url = http://nora.example.com:4000/simple
|
||||
```
|
||||
|
||||
### 2.5. Cargo / Rust
|
||||
|
||||
Настройка в `~/.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[registries.nora]
|
||||
index = "sparse+http://nora.example.com:4000/cargo/"
|
||||
|
||||
[source.crates-io]
|
||||
replace-with = "nora"
|
||||
```
|
||||
|
||||
### 2.6. Helm
|
||||
|
||||
Helm использует OCI-протокол через Docker Registry API:
|
||||
|
||||
```bash
|
||||
helm push mychart-0.1.0.tgz oci://nora.example.com:4000/helm
|
||||
helm pull oci://nora.example.com:4000/helm/mychart --version 0.1.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Публикация пакетов
|
||||
|
||||
### 3.1. npm
|
||||
|
||||
```bash
|
||||
npm publish --registry http://nora.example.com:4000/npm
|
||||
```
|
||||
|
||||
Требования:
|
||||
- Файл `package.json` с полями `name` и `version`.
|
||||
- Каждая версия публикуется однократно. Повторная публикация той же версии запрещена.
|
||||
|
||||
### 3.2. Docker
|
||||
|
||||
```bash
|
||||
docker tag myapp:latest nora.example.com:4000/myteam/myapp:1.0.0
|
||||
docker push nora.example.com:4000/myteam/myapp:1.0.0
|
||||
```
|
||||
|
||||
### 3.3. Maven
|
||||
|
||||
```bash
|
||||
mvn deploy -DaltDeploymentRepository=nora::default::http://nora.example.com:4000/maven2
|
||||
```
|
||||
|
||||
### 3.4. Raw (произвольные файлы)
|
||||
|
||||
```bash
|
||||
# Загрузка
|
||||
curl -X PUT --data-binary @release.tar.gz http://nora.example.com:4000/raw/builds/release-1.0.tar.gz
|
||||
|
||||
# Скачивание
|
||||
curl -O http://nora.example.com:4000/raw/builds/release-1.0.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Работа в изолированной среде
|
||||
|
||||
Если сборочный сервер не имеет доступа к сети Интернет, используйте предварительное кэширование.
|
||||
|
||||
### 4.1. Кэширование зависимостей проекта
|
||||
|
||||
На машине с доступом к Интернету и NORA выполните:
|
||||
|
||||
```bash
|
||||
nora mirror npm --lockfile package-lock.json --registry http://nora.example.com:4000
|
||||
```
|
||||
|
||||
После этого все зависимости из lockfile будут доступны через NORA, даже если связь с внешними реестрами отсутствует.
|
||||
|
||||
### 4.2. Кэширование всех версий пакета
|
||||
|
||||
```bash
|
||||
nora mirror npm --packages lodash,express --all-versions --registry http://nora.example.com:4000
|
||||
```
|
||||
|
||||
Эта команда загрузит все опубликованные версии указанных пакетов.
|
||||
|
||||
---
|
||||
|
||||
## 5. Веб-интерфейс
|
||||
|
||||
NORA предоставляет веб-интерфейс для просмотра содержимого реестра:
|
||||
|
||||
```
|
||||
http://nora.example.com:4000/ui/
|
||||
```
|
||||
|
||||
Доступные функции:
|
||||
- Просмотр списка артефактов по протоколам.
|
||||
- Количество версий и размер каждого пакета.
|
||||
- Журнал последних операций.
|
||||
- Метрики загрузок.
|
||||
|
||||
---
|
||||
|
||||
## 6. Документация API
|
||||
|
||||
Интерактивная документация API доступна по адресу:
|
||||
|
||||
```
|
||||
http://nora.example.com:4000/api-docs
|
||||
```
|
||||
|
||||
Формат: OpenAPI 3.0 (Swagger UI).
|
||||
|
||||
---
|
||||
|
||||
## 7. Аутентификация
|
||||
|
||||
Если администратор включил аутентификацию, для операций записи требуется токен.
|
||||
|
||||
### 7.1. Получение токена
|
||||
|
||||
```bash
|
||||
curl -u admin:password http://nora.example.com:4000/auth/token
|
||||
```
|
||||
|
||||
### 7.2. Использование токена
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm config set //nora.example.com:4000/npm/:_authToken TOKEN
|
||||
|
||||
# Docker
|
||||
docker login nora.example.com:4000
|
||||
|
||||
# curl
|
||||
curl -H "Authorization: Bearer TOKEN" http://nora.example.com:4000/npm/my-package
|
||||
```
|
||||
|
||||
Операции чтения по умолчанию не требуют аутентификации (роль `read` назначается автоматически).
|
||||
|
||||
---
|
||||
|
||||
## 8. Часто задаваемые вопросы
|
||||
|
||||
**В: Что произойдёт, если внешний реестр (npmjs.org) станет недоступен?**
|
||||
О: NORA продолжит обслуживать запросы из кэша. Пакеты, которые ранее не запрашивались, будут недоступны до восстановления связи. Для предотвращения такой ситуации используйте `nora mirror`.
|
||||
|
||||
**В: Можно ли публиковать приватные пакеты?**
|
||||
О: Да. Пакеты, опубликованные через `npm publish` или `docker push`, сохраняются в локальном хранилище NORA и доступны всем пользователям данного экземпляра.
|
||||
|
||||
**В: Как обновить кэш метаданных?**
|
||||
О: Кэш метаданных npm обновляется автоматически по истечении TTL (по умолчанию 5 минут). Для немедленного обновления удалите файл `metadata.json` из каталога хранилища.
|
||||
|
||||
**В: Поддерживаются ли scoped-пакеты npm (@scope/package)?**
|
||||
О: Да, полностью. Например: `npm install @babel/core --registry http://nora.example.com:4000/npm`.
|
||||
23
fuzz/Cargo.toml
Normal file
23
fuzz/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "nora-fuzz"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
|
||||
[package.metadata]
|
||||
cargo-fuzz = true
|
||||
|
||||
[dependencies]
|
||||
libfuzzer-sys = "0.4"
|
||||
nora-registry = { path = "../nora-registry" }
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_validation"
|
||||
path = "fuzz_targets/fuzz_validation.rs"
|
||||
doc = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_docker_manifest"
|
||||
path = "fuzz_targets/fuzz_docker_manifest.rs"
|
||||
doc = false
|
||||
8
fuzz/fuzz_targets/fuzz_docker_manifest.rs
Normal file
8
fuzz/fuzz_targets/fuzz_docker_manifest.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nora_registry::docker_fuzz::detect_manifest_media_type;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
// Fuzz Docker manifest parser — must never panic on any input
|
||||
let _ = detect_manifest_media_type(data);
|
||||
});
|
||||
13
fuzz/fuzz_targets/fuzz_validation.rs
Normal file
13
fuzz/fuzz_targets/fuzz_validation.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use nora_registry::validation::{
|
||||
validate_digest, validate_docker_name, validate_docker_reference, validate_storage_key,
|
||||
};
|
||||
|
||||
fuzz_target!(|data: &str| {
|
||||
// Fuzz all validators — they must never panic on any input
|
||||
let _ = validate_storage_key(data);
|
||||
let _ = validate_docker_name(data);
|
||||
let _ = validate_digest(data);
|
||||
let _ = validate_docker_reference(data);
|
||||
});
|
||||
5
logo.svg
Normal file
5
logo.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 72" width="300" height="72">
|
||||
<text font-family="'SF Mono', 'Fira Code', 'Cascadia Code', monospace" font-weight="800" fill="#0f172a" letter-spacing="1">
|
||||
<tspan x="8" y="58" font-size="52">N</tspan><tspan font-size="68" dy="-10" fill="#2563EB">O</tspan><tspan font-size="52" dy="10">RA</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -1,23 +0,0 @@
|
||||
[package]
|
||||
name = "nora-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "CLI tool for NORA registry"
|
||||
|
||||
[[bin]]
|
||||
name = "nora-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
indicatif = "0.17"
|
||||
tar = "0.4"
|
||||
flate2 = "1.0"
|
||||
@@ -1,52 +0,0 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "nora-cli")]
|
||||
#[command(about = "CLI tool for Nora registry")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Login to a registry
|
||||
Login {
|
||||
#[arg(long)]
|
||||
registry: String,
|
||||
#[arg(short, long)]
|
||||
username: String,
|
||||
},
|
||||
/// Push an artifact
|
||||
Push {
|
||||
#[arg(long)]
|
||||
registry: String,
|
||||
path: String,
|
||||
},
|
||||
/// Pull an artifact
|
||||
Pull {
|
||||
#[arg(long)]
|
||||
registry: String,
|
||||
artifact: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Login { registry, username } => {
|
||||
println!("Logging in to {} as {}", registry, username);
|
||||
// TODO: implement
|
||||
}
|
||||
Commands::Push { registry, path } => {
|
||||
println!("Pushing {} to {}", path, registry);
|
||||
// TODO: implement
|
||||
}
|
||||
Commands::Pull { registry, artifact } => {
|
||||
println!("Pulling {} from {}", artifact, registry);
|
||||
// TODO: implement
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ description = "Cloud-Native Artifact Registry - Fast, lightweight, multi-protoco
|
||||
keywords = ["registry", "docker", "artifacts", "cloud-native", "devops"]
|
||||
categories = ["command-line-utilities", "development-tools", "web-programming"]
|
||||
|
||||
[lib]
|
||||
name = "nora_registry"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "nora"
|
||||
path = "src/main.rs"
|
||||
@@ -24,25 +28,30 @@ tracing-subscriber.workspace = true
|
||||
reqwest.workspace = true
|
||||
sha2.workspace = true
|
||||
async-trait.workspace = true
|
||||
toml = "0.8"
|
||||
hmac.workspace = true
|
||||
hex.workspace = true
|
||||
toml = "1.0"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
bcrypt = "0.17"
|
||||
bcrypt = "0.19"
|
||||
base64 = "0.22"
|
||||
prometheus = "0.13"
|
||||
prometheus = "0.14"
|
||||
lazy_static = "1.5"
|
||||
httpdate = "1"
|
||||
utoipa = { version = "5", features = ["axum_extras"] }
|
||||
utoipa-swagger-ui = { version = "9", features = ["axum", "reqwest"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tar = "0.4"
|
||||
flate2 = "1.0"
|
||||
indicatif = "0.17"
|
||||
flate2 = "1.1"
|
||||
indicatif = "0.18"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
tower_governor = "0.8"
|
||||
governor = "0.10"
|
||||
parking_lot = "0.12"
|
||||
zeroize = { version = "1.8", features = ["derive"] }
|
||||
argon2 = { version = "0.5", features = ["std", "rand"] }
|
||||
tower-http = { version = "0.6", features = ["set-header"] }
|
||||
percent-encoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use parking_lot::RwLock;
|
||||
use serde::Serialize;
|
||||
@@ -96,3 +99,139 @@ impl Default for ActivityLog {
|
||||
Self::new(50)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_action_type_display() {
|
||||
assert_eq!(ActionType::Pull.to_string(), "PULL");
|
||||
assert_eq!(ActionType::Push.to_string(), "PUSH");
|
||||
assert_eq!(ActionType::CacheHit.to_string(), "CACHE");
|
||||
assert_eq!(ActionType::ProxyFetch.to_string(), "PROXY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_type_equality() {
|
||||
assert_eq!(ActionType::Pull, ActionType::Pull);
|
||||
assert_ne!(ActionType::Pull, ActionType::Push);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_entry_new() {
|
||||
let entry = ActivityEntry::new(
|
||||
ActionType::Pull,
|
||||
"nginx:latest".to_string(),
|
||||
"docker",
|
||||
"LOCAL",
|
||||
);
|
||||
assert_eq!(entry.action, ActionType::Pull);
|
||||
assert_eq!(entry.artifact, "nginx:latest");
|
||||
assert_eq!(entry.registry, "docker");
|
||||
assert_eq!(entry.source, "LOCAL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_log_push_and_len() {
|
||||
let log = ActivityLog::new(10);
|
||||
assert!(log.is_empty());
|
||||
assert_eq!(log.len(), 0);
|
||||
|
||||
log.push(ActivityEntry::new(
|
||||
ActionType::Push,
|
||||
"test:v1".to_string(),
|
||||
"docker",
|
||||
"LOCAL",
|
||||
));
|
||||
assert!(!log.is_empty());
|
||||
assert_eq!(log.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_log_recent() {
|
||||
let log = ActivityLog::new(10);
|
||||
for i in 0..5 {
|
||||
log.push(ActivityEntry::new(
|
||||
ActionType::Pull,
|
||||
format!("image:{}", i),
|
||||
"docker",
|
||||
"LOCAL",
|
||||
));
|
||||
}
|
||||
|
||||
let recent = log.recent(3);
|
||||
assert_eq!(recent.len(), 3);
|
||||
// newest first
|
||||
assert_eq!(recent[0].artifact, "image:4");
|
||||
assert_eq!(recent[1].artifact, "image:3");
|
||||
assert_eq!(recent[2].artifact, "image:2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_log_all() {
|
||||
let log = ActivityLog::new(10);
|
||||
for i in 0..3 {
|
||||
log.push(ActivityEntry::new(
|
||||
ActionType::Pull,
|
||||
format!("pkg:{}", i),
|
||||
"npm",
|
||||
"PROXY",
|
||||
));
|
||||
}
|
||||
|
||||
let all = log.all();
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].artifact, "pkg:2"); // newest first
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_log_bounded_size() {
|
||||
let log = ActivityLog::new(3);
|
||||
for i in 0..5 {
|
||||
log.push(ActivityEntry::new(
|
||||
ActionType::Pull,
|
||||
format!("item:{}", i),
|
||||
"cargo",
|
||||
"CACHE",
|
||||
));
|
||||
}
|
||||
|
||||
assert_eq!(log.len(), 3);
|
||||
let all = log.all();
|
||||
// oldest entries should be dropped
|
||||
assert_eq!(all[0].artifact, "item:4");
|
||||
assert_eq!(all[1].artifact, "item:3");
|
||||
assert_eq!(all[2].artifact, "item:2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_log_recent_more_than_available() {
|
||||
let log = ActivityLog::new(10);
|
||||
log.push(ActivityEntry::new(
|
||||
ActionType::Push,
|
||||
"one".to_string(),
|
||||
"maven",
|
||||
"LOCAL",
|
||||
));
|
||||
|
||||
let recent = log.recent(100);
|
||||
assert_eq!(recent.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activity_log_default() {
|
||||
let log = ActivityLog::default();
|
||||
assert!(log.is_empty());
|
||||
// default capacity is 50
|
||||
for i in 0..60 {
|
||||
log.push(ActivityEntry::new(
|
||||
ActionType::Pull,
|
||||
format!("x:{}", i),
|
||||
"docker",
|
||||
"LOCAL",
|
||||
));
|
||||
}
|
||||
assert_eq!(log.len(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
139
nora-registry/src/audit.rs
Normal file
139
nora-registry/src/audit.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Persistent audit log — append-only JSONL file
|
||||
//!
|
||||
//! Records who/when/what for every registry operation.
|
||||
//! File: {storage_path}/audit.jsonl
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AuditEntry {
|
||||
pub ts: DateTime<Utc>,
|
||||
pub action: String,
|
||||
pub actor: String,
|
||||
pub artifact: String,
|
||||
pub registry: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl AuditEntry {
|
||||
pub fn new(action: &str, actor: &str, artifact: &str, registry: &str, detail: &str) -> Self {
|
||||
Self {
|
||||
ts: Utc::now(),
|
||||
action: action.to_string(),
|
||||
actor: actor.to_string(),
|
||||
artifact: artifact.to_string(),
|
||||
registry: registry.to_string(),
|
||||
detail: detail.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuditLog {
|
||||
path: PathBuf,
|
||||
writer: Mutex<Option<fs::File>>,
|
||||
}
|
||||
|
||||
impl AuditLog {
|
||||
pub fn new(storage_path: &str) -> Self {
|
||||
let path = PathBuf::from(storage_path).join("audit.jsonl");
|
||||
let writer = match OpenOptions::new().create(true).append(true).open(&path) {
|
||||
Ok(f) => {
|
||||
info!(path = %path.display(), "Audit log initialized");
|
||||
Mutex::new(Some(f))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(path = %path.display(), error = %e, "Failed to open audit log, auditing disabled");
|
||||
Mutex::new(None)
|
||||
}
|
||||
};
|
||||
Self { path, writer }
|
||||
}
|
||||
|
||||
pub fn log(&self, entry: AuditEntry) {
|
||||
if let Some(ref mut file) = *self.writer.lock() {
|
||||
if let Ok(json) = serde_json::to_string(&entry) {
|
||||
let _ = writeln!(file, "{}", json);
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_audit_entry_new() {
|
||||
let entry = AuditEntry::new(
|
||||
"push",
|
||||
"admin",
|
||||
"nginx:latest",
|
||||
"docker",
|
||||
"uploaded manifest",
|
||||
);
|
||||
assert_eq!(entry.action, "push");
|
||||
assert_eq!(entry.actor, "admin");
|
||||
assert_eq!(entry.artifact, "nginx:latest");
|
||||
assert_eq!(entry.registry, "docker");
|
||||
assert_eq!(entry.detail, "uploaded manifest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_log_new_and_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let log = AuditLog::new(tmp.path().to_str().unwrap());
|
||||
assert!(log.path().ends_with("audit.jsonl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_log_write_entry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let log = AuditLog::new(tmp.path().to_str().unwrap());
|
||||
|
||||
let entry = AuditEntry::new("pull", "user1", "lodash", "npm", "downloaded");
|
||||
log.log(entry);
|
||||
|
||||
// Verify file contains the entry
|
||||
let content = std::fs::read_to_string(log.path()).unwrap();
|
||||
assert!(content.contains(r#""action":"pull""#));
|
||||
assert!(content.contains(r#""actor":"user1""#));
|
||||
assert!(content.contains(r#""artifact":"lodash""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_log_multiple_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let log = AuditLog::new(tmp.path().to_str().unwrap());
|
||||
|
||||
log.log(AuditEntry::new("push", "admin", "a", "docker", ""));
|
||||
log.log(AuditEntry::new("pull", "user", "b", "npm", ""));
|
||||
log.log(AuditEntry::new("delete", "admin", "c", "maven", ""));
|
||||
|
||||
let content = std::fs::read_to_string(log.path()).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audit_entry_serialization() {
|
||||
let entry = AuditEntry::new("push", "ci", "app:v1", "docker", "ci build");
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains(r#""action":"push""#));
|
||||
assert!(json.contains(r#""ts":""#));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
@@ -10,6 +13,7 @@ use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::tokens::Role;
|
||||
use crate::AppState;
|
||||
|
||||
/// Htpasswd-based authentication
|
||||
@@ -60,11 +64,17 @@ impl HtpasswdAuth {
|
||||
fn is_public_path(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/" | "/health" | "/ready" | "/metrics" | "/v2/" | "/v2"
|
||||
"/" | "/health"
|
||||
| "/ready"
|
||||
| "/metrics"
|
||||
| "/v2/"
|
||||
| "/v2"
|
||||
| "/api/tokens"
|
||||
| "/api/tokens/list"
|
||||
| "/api/tokens/revoke"
|
||||
) || path.starts_with("/ui")
|
||||
|| path.starts_with("/api-docs")
|
||||
|| path.starts_with("/api/ui")
|
||||
|| path.starts_with("/api/tokens")
|
||||
}
|
||||
|
||||
/// Auth middleware - supports Basic auth and Bearer tokens
|
||||
@@ -84,6 +94,16 @@ pub async fn auth_middleware(
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Allow anonymous read if configured
|
||||
let is_read_method = matches!(
|
||||
*request.method(),
|
||||
axum::http::Method::GET | axum::http::Method::HEAD
|
||||
);
|
||||
if state.config.auth.anonymous_read && is_read_method {
|
||||
// Read requests allowed without auth
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
// Extract Authorization header
|
||||
let auth_header = request
|
||||
.headers()
|
||||
@@ -99,7 +119,18 @@ pub async fn auth_middleware(
|
||||
if let Some(token) = auth_header.strip_prefix("Bearer ") {
|
||||
if let Some(ref token_store) = state.tokens {
|
||||
match token_store.verify_token(token) {
|
||||
Ok(_user) => return next.run(request).await,
|
||||
Ok((_user, role)) => {
|
||||
let method = request.method().clone();
|
||||
if (method == axum::http::Method::PUT
|
||||
|| method == axum::http::Method::POST
|
||||
|| method == axum::http::Method::DELETE
|
||||
|| method == axum::http::Method::PATCH)
|
||||
&& !role.can_write()
|
||||
{
|
||||
return (StatusCode::FORBIDDEN, "Read-only token").into_response();
|
||||
}
|
||||
return next.run(request).await;
|
||||
}
|
||||
Err(_) => return unauthorized_response("Invalid or expired token"),
|
||||
}
|
||||
} else {
|
||||
@@ -166,6 +197,12 @@ pub struct CreateTokenRequest {
|
||||
#[serde(default = "default_ttl")]
|
||||
pub ttl_days: u64,
|
||||
pub description: Option<String>,
|
||||
#[serde(default = "default_role_str")]
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
fn default_role_str() -> String {
|
||||
"read".to_string()
|
||||
}
|
||||
|
||||
fn default_ttl() -> u64 {
|
||||
@@ -185,6 +222,7 @@ pub struct TokenListItem {
|
||||
pub expires_at: u64,
|
||||
pub last_used: Option<u64>,
|
||||
pub description: Option<String>,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -218,7 +256,19 @@ async fn create_token(
|
||||
}
|
||||
};
|
||||
|
||||
match token_store.create_token(&req.username, req.ttl_days, req.description) {
|
||||
let role = match req.role.as_str() {
|
||||
"read" => Role::Read,
|
||||
"write" => Role::Write,
|
||||
"admin" => Role::Admin,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid role. Use: read, write, admin",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
match token_store.create_token(&req.username, req.ttl_days, req.description, role) {
|
||||
Ok(token) => Json(CreateTokenResponse {
|
||||
token,
|
||||
expires_in_days: req.ttl_days,
|
||||
@@ -262,6 +312,7 @@ async fn list_tokens(
|
||||
expires_at: t.expires_at,
|
||||
last_used: t.last_used,
|
||||
description: t.description,
|
||||
role: t.role.to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -401,8 +452,12 @@ mod tests {
|
||||
assert!(is_public_path("/api/ui/stats"));
|
||||
assert!(is_public_path("/api/tokens"));
|
||||
assert!(is_public_path("/api/tokens/list"));
|
||||
assert!(is_public_path("/api/tokens/revoke"));
|
||||
|
||||
// Protected paths
|
||||
assert!(!is_public_path("/api/tokens/unknown"));
|
||||
assert!(!is_public_path("/api/tokens/admin"));
|
||||
assert!(!is_public_path("/api/tokens/extra/path"));
|
||||
assert!(!is_public_path("/v2/myimage/blobs/sha256:abc"));
|
||||
assert!(!is_public_path("/v2/library/nginx/manifests/latest"));
|
||||
assert!(!is_public_path(
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Backup and restore functionality for Nora
|
||||
//!
|
||||
//! Exports all artifacts to a tar.gz file and restores from backups.
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
pub use crate::secrets::SecretsConfig;
|
||||
|
||||
/// Encode "user:pass" into a Basic Auth header value, e.g. "Basic dXNlcjpwYXNz".
|
||||
pub fn basic_auth_header(credentials: &str) -> String {
|
||||
format!("Basic {}", STANDARD.encode(credentials))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
@@ -17,6 +26,8 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub docker: DockerConfig,
|
||||
#[serde(default)]
|
||||
pub go: GoConfig,
|
||||
#[serde(default)]
|
||||
pub raw: RawConfig,
|
||||
#[serde(default)]
|
||||
pub auth: AuthConfig,
|
||||
@@ -33,6 +44,13 @@ pub struct ServerConfig {
|
||||
/// Public URL for generating pull commands (e.g., "registry.example.com")
|
||||
#[serde(default)]
|
||||
pub public_url: Option<String>,
|
||||
/// Maximum request body size in MB (default: 2048 = 2GB)
|
||||
#[serde(default = "default_body_limit_mb")]
|
||||
pub body_limit_mb: usize,
|
||||
}
|
||||
|
||||
fn default_body_limit_mb() -> usize {
|
||||
2048 // 2GB - enough for any Docker image
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
@@ -53,6 +71,19 @@ pub struct StorageConfig {
|
||||
pub s3_url: String,
|
||||
#[serde(default = "default_bucket")]
|
||||
pub bucket: String,
|
||||
/// S3 access key (optional, uses anonymous access if not set)
|
||||
#[serde(default)]
|
||||
pub s3_access_key: Option<String>,
|
||||
/// S3 secret key (optional, uses anonymous access if not set)
|
||||
#[serde(default)]
|
||||
pub s3_secret_key: Option<String>,
|
||||
/// S3 region (default: us-east-1)
|
||||
#[serde(default = "default_s3_region")]
|
||||
pub s3_region: String,
|
||||
}
|
||||
|
||||
fn default_s3_region() -> String {
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
fn default_storage_path() -> String {
|
||||
@@ -70,7 +101,7 @@ fn default_bucket() -> String {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MavenConfig {
|
||||
#[serde(default)]
|
||||
pub proxies: Vec<String>,
|
||||
pub proxies: Vec<MavenProxyEntry>,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
@@ -79,18 +110,67 @@ pub struct MavenConfig {
|
||||
pub struct NpmConfig {
|
||||
#[serde(default)]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
/// Metadata cache TTL in seconds (default: 300 = 5 min). Set to 0 to cache forever.
|
||||
#[serde(default = "default_metadata_ttl")]
|
||||
pub metadata_ttl: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PypiConfig {
|
||||
#[serde(default)]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
|
||||
/// Go module proxy configuration (GOPROXY protocol)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GoConfig {
|
||||
/// Upstream Go module proxy URL (default: https://proxy.golang.org)
|
||||
#[serde(default = "default_go_proxy")]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
/// Separate timeout for .zip downloads (default: 120s, zips can be large)
|
||||
#[serde(default = "default_go_zip_timeout")]
|
||||
pub proxy_timeout_zip: u64,
|
||||
/// Maximum module zip size in bytes (default: 100MB)
|
||||
#[serde(default = "default_go_max_zip_size")]
|
||||
pub max_zip_size: u64,
|
||||
}
|
||||
|
||||
fn default_go_proxy() -> Option<String> {
|
||||
Some("https://proxy.golang.org".to_string())
|
||||
}
|
||||
|
||||
fn default_go_zip_timeout() -> u64 {
|
||||
120
|
||||
}
|
||||
|
||||
fn default_go_max_zip_size() -> u64 {
|
||||
104_857_600 // 100MB
|
||||
}
|
||||
|
||||
impl Default for GoConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy: default_go_proxy(),
|
||||
proxy_auth: None,
|
||||
proxy_timeout: 30,
|
||||
proxy_timeout_zip: 120,
|
||||
max_zip_size: 104_857_600,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Docker registry configuration with upstream proxy support
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DockerConfig {
|
||||
@@ -108,6 +188,37 @@ pub struct DockerUpstream {
|
||||
pub auth: Option<String>, // "user:pass" for basic auth
|
||||
}
|
||||
|
||||
/// Maven upstream proxy configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum MavenProxyEntry {
|
||||
Simple(String),
|
||||
Full(MavenProxy),
|
||||
}
|
||||
|
||||
/// Maven upstream proxy with optional auth
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MavenProxy {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub auth: Option<String>, // "user:pass" for basic auth
|
||||
}
|
||||
|
||||
impl MavenProxyEntry {
|
||||
pub fn url(&self) -> &str {
|
||||
match self {
|
||||
MavenProxyEntry::Simple(s) => s,
|
||||
MavenProxyEntry::Full(p) => &p.url,
|
||||
}
|
||||
}
|
||||
pub fn auth(&self) -> Option<&str> {
|
||||
match self {
|
||||
MavenProxyEntry::Simple(_) => None,
|
||||
MavenProxyEntry::Full(p) => p.auth.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw repository configuration for simple file storage
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RawConfig {
|
||||
@@ -133,6 +244,9 @@ fn default_max_file_size() -> u64 {
|
||||
pub struct AuthConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Allow anonymous read access (pull/download without auth, push requires auth)
|
||||
#[serde(default)]
|
||||
pub anonymous_read: bool,
|
||||
#[serde(default = "default_htpasswd_file")]
|
||||
pub htpasswd_file: String,
|
||||
#[serde(default = "default_token_storage")]
|
||||
@@ -151,10 +265,16 @@ fn default_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_metadata_ttl() -> u64 {
|
||||
300 // 5 minutes
|
||||
}
|
||||
|
||||
impl Default for MavenConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxies: vec!["https://repo1.maven.org/maven2".to_string()],
|
||||
proxies: vec![MavenProxyEntry::Simple(
|
||||
"https://repo1.maven.org/maven2".to_string(),
|
||||
)],
|
||||
proxy_timeout: 30,
|
||||
}
|
||||
}
|
||||
@@ -164,7 +284,9 @@ impl Default for NpmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy: Some("https://registry.npmjs.org".to_string()),
|
||||
proxy_auth: None,
|
||||
proxy_timeout: 30,
|
||||
metadata_ttl: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +295,7 @@ impl Default for PypiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy: Some("https://pypi.org/simple/".to_string()),
|
||||
proxy_auth: None,
|
||||
proxy_timeout: 30,
|
||||
}
|
||||
}
|
||||
@@ -203,6 +326,7 @@ impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
anonymous_read: false,
|
||||
htpasswd_file: "users.htpasswd".to_string(),
|
||||
token_storage: "data/tokens".to_string(),
|
||||
}
|
||||
@@ -233,6 +357,8 @@ impl Default for AuthConfig {
|
||||
/// - `NORA_RATE_LIMIT_GENERAL_BURST` - General burst size
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RateLimitConfig {
|
||||
#[serde(default = "default_rate_limit_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_auth_rps")]
|
||||
pub auth_rps: u64,
|
||||
#[serde(default = "default_auth_burst")]
|
||||
@@ -247,6 +373,9 @@ pub struct RateLimitConfig {
|
||||
pub general_burst: u32,
|
||||
}
|
||||
|
||||
fn default_rate_limit_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_auth_rps() -> u64 {
|
||||
1
|
||||
}
|
||||
@@ -269,6 +398,7 @@ fn default_general_burst() -> u32 {
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_rate_limit_enabled(),
|
||||
auth_rps: default_auth_rps(),
|
||||
auth_burst: default_auth_burst(),
|
||||
upload_rps: default_upload_rps(),
|
||||
@@ -280,6 +410,41 @@ impl Default for RateLimitConfig {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Warn if credentials are configured via config.toml (not env vars)
|
||||
pub fn warn_plaintext_credentials(&self) {
|
||||
// Docker upstreams
|
||||
for (i, upstream) in self.docker.upstreams.iter().enumerate() {
|
||||
if upstream.auth.is_some() && std::env::var("NORA_DOCKER_UPSTREAMS").is_err() {
|
||||
tracing::warn!(
|
||||
upstream_index = i,
|
||||
url = %upstream.url,
|
||||
"Docker upstream credentials in config.toml are plaintext — consider NORA_DOCKER_UPSTREAMS env var"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Maven proxies
|
||||
for proxy in &self.maven.proxies {
|
||||
if proxy.auth().is_some() && std::env::var("NORA_MAVEN_PROXIES").is_err() {
|
||||
tracing::warn!(
|
||||
url = %proxy.url(),
|
||||
"Maven proxy credentials in config.toml are plaintext — consider NORA_MAVEN_PROXIES env var"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Go
|
||||
if self.go.proxy_auth.is_some() && std::env::var("NORA_GO_PROXY_AUTH").is_err() {
|
||||
tracing::warn!("Go proxy credentials in config.toml are plaintext — consider NORA_GO_PROXY_AUTH env var");
|
||||
}
|
||||
// npm
|
||||
if self.npm.proxy_auth.is_some() && std::env::var("NORA_NPM_PROXY_AUTH").is_err() {
|
||||
tracing::warn!("npm proxy credentials in config.toml are plaintext — consider NORA_NPM_PROXY_AUTH env var");
|
||||
}
|
||||
// PyPI
|
||||
if self.pypi.proxy_auth.is_some() && std::env::var("NORA_PYPI_PROXY_AUTH").is_err() {
|
||||
tracing::warn!("PyPI proxy credentials in config.toml are plaintext — consider NORA_PYPI_PROXY_AUTH env var");
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration with priority: ENV > config.toml > defaults
|
||||
pub fn load() -> Self {
|
||||
// 1. Start with defaults
|
||||
@@ -308,6 +473,11 @@ impl Config {
|
||||
if let Ok(val) = env::var("NORA_PUBLIC_URL") {
|
||||
self.server.public_url = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_BODY_LIMIT_MB") {
|
||||
if let Ok(mb) = val.parse() {
|
||||
self.server.body_limit_mb = mb;
|
||||
}
|
||||
}
|
||||
|
||||
// Storage config
|
||||
if let Ok(val) = env::var("NORA_STORAGE_MODE") {
|
||||
@@ -325,18 +495,44 @@ impl Config {
|
||||
if let Ok(val) = env::var("NORA_STORAGE_BUCKET") {
|
||||
self.storage.bucket = val;
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_STORAGE_S3_ACCESS_KEY") {
|
||||
self.storage.s3_access_key = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_STORAGE_S3_SECRET_KEY") {
|
||||
self.storage.s3_secret_key = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_STORAGE_S3_REGION") {
|
||||
self.storage.s3_region = val;
|
||||
}
|
||||
|
||||
// Auth config
|
||||
if let Ok(val) = env::var("NORA_AUTH_ENABLED") {
|
||||
self.auth.enabled = val.to_lowercase() == "true" || val == "1";
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_AUTH_ANONYMOUS_READ") {
|
||||
self.auth.anonymous_read = val.to_lowercase() == "true" || val == "1";
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_AUTH_HTPASSWD_FILE") {
|
||||
self.auth.htpasswd_file = val;
|
||||
}
|
||||
|
||||
// Maven config
|
||||
// Maven config — supports "url1,url2" or "url1|auth1,url2|auth2"
|
||||
if let Ok(val) = env::var("NORA_MAVEN_PROXIES") {
|
||||
self.maven.proxies = val.split(',').map(|s| s.trim().to_string()).collect();
|
||||
self.maven.proxies = val
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
let parts: Vec<&str> = s.trim().splitn(2, '|').collect();
|
||||
if parts.len() > 1 {
|
||||
MavenProxyEntry::Full(MavenProxy {
|
||||
url: parts[0].to_string(),
|
||||
auth: Some(parts[1].to_string()),
|
||||
})
|
||||
} else {
|
||||
MavenProxyEntry::Simple(parts[0].to_string())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
|
||||
if let Ok(timeout) = val.parse() {
|
||||
@@ -353,6 +549,16 @@ impl Config {
|
||||
self.npm.proxy_timeout = timeout;
|
||||
}
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_NPM_METADATA_TTL") {
|
||||
if let Ok(ttl) = val.parse() {
|
||||
self.npm.metadata_ttl = ttl;
|
||||
}
|
||||
}
|
||||
|
||||
// npm proxy auth
|
||||
if let Ok(val) = env::var("NORA_NPM_PROXY_AUTH") {
|
||||
self.npm.proxy_auth = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
|
||||
// PyPI config
|
||||
if let Ok(val) = env::var("NORA_PYPI_PROXY") {
|
||||
@@ -364,6 +570,11 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// PyPI proxy auth
|
||||
if let Ok(val) = env::var("NORA_PYPI_PROXY_AUTH") {
|
||||
self.pypi.proxy_auth = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
|
||||
// Docker config
|
||||
if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") {
|
||||
if let Ok(timeout) = val.parse() {
|
||||
@@ -401,6 +612,9 @@ impl Config {
|
||||
}
|
||||
|
||||
// Rate limit config
|
||||
if let Ok(val) = env::var("NORA_RATE_LIMIT_ENABLED") {
|
||||
self.rate_limit.enabled = val.to_lowercase() == "true" || val == "1";
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_RATE_LIMIT_AUTH_RPS") {
|
||||
if let Ok(v) = val.parse::<u64>() {
|
||||
self.rate_limit.auth_rps = v;
|
||||
@@ -449,16 +663,21 @@ impl Default for Config {
|
||||
host: String::from("127.0.0.1"),
|
||||
port: 4000,
|
||||
public_url: None,
|
||||
body_limit_mb: 2048,
|
||||
},
|
||||
storage: StorageConfig {
|
||||
mode: StorageMode::Local,
|
||||
path: String::from("data/storage"),
|
||||
s3_url: String::from("http://127.0.0.1:3000"),
|
||||
bucket: String::from("registry"),
|
||||
s3_access_key: None,
|
||||
s3_secret_key: None,
|
||||
s3_region: String::from("us-east-1"),
|
||||
},
|
||||
maven: MavenConfig::default(),
|
||||
npm: NpmConfig::default(),
|
||||
pypi: PypiConfig::default(),
|
||||
go: GoConfig::default(),
|
||||
docker: DockerConfig::default(),
|
||||
raw: RawConfig::default(),
|
||||
auth: AuthConfig::default(),
|
||||
@@ -503,4 +722,378 @@ mod tests {
|
||||
assert_eq!(config.rate_limit.upload_burst, 1000);
|
||||
assert_eq!(config.rate_limit.auth_burst, 5); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_auth_header() {
|
||||
let header = basic_auth_header("user:pass");
|
||||
assert_eq!(header, "Basic dXNlcjpwYXNz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_auth_header_empty() {
|
||||
let header = basic_auth_header("");
|
||||
assert!(header.starts_with("Basic "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_default() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.server.host, "127.0.0.1");
|
||||
assert_eq!(config.server.port, 4000);
|
||||
assert_eq!(config.server.body_limit_mb, 2048);
|
||||
assert!(config.server.public_url.is_none());
|
||||
assert_eq!(config.storage.path, "data/storage");
|
||||
assert_eq!(config.storage.mode, StorageMode::Local);
|
||||
assert_eq!(config.storage.bucket, "registry");
|
||||
assert_eq!(config.storage.s3_region, "us-east-1");
|
||||
assert!(!config.auth.enabled);
|
||||
assert_eq!(config.auth.htpasswd_file, "users.htpasswd");
|
||||
assert_eq!(config.auth.token_storage, "data/tokens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_maven_config_default() {
|
||||
let m = MavenConfig::default();
|
||||
assert_eq!(m.proxy_timeout, 30);
|
||||
assert_eq!(m.proxies.len(), 1);
|
||||
assert_eq!(m.proxies[0].url(), "https://repo1.maven.org/maven2");
|
||||
assert!(m.proxies[0].auth().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_npm_config_default() {
|
||||
let n = NpmConfig::default();
|
||||
assert_eq!(n.proxy, Some("https://registry.npmjs.org".to_string()));
|
||||
assert!(n.proxy_auth.is_none());
|
||||
assert_eq!(n.proxy_timeout, 30);
|
||||
assert_eq!(n.metadata_ttl, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pypi_config_default() {
|
||||
let p = PypiConfig::default();
|
||||
assert_eq!(p.proxy, Some("https://pypi.org/simple/".to_string()));
|
||||
assert!(p.proxy_auth.is_none());
|
||||
assert_eq!(p.proxy_timeout, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_config_default() {
|
||||
let d = DockerConfig::default();
|
||||
assert_eq!(d.proxy_timeout, 60);
|
||||
assert_eq!(d.upstreams.len(), 1);
|
||||
assert_eq!(d.upstreams[0].url, "https://registry-1.docker.io");
|
||||
assert!(d.upstreams[0].auth.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_config_default() {
|
||||
let r = RawConfig::default();
|
||||
assert!(r.enabled);
|
||||
assert_eq!(r.max_file_size, 104_857_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_config_default() {
|
||||
let a = AuthConfig::default();
|
||||
assert!(!a.enabled);
|
||||
assert!(!a.anonymous_read);
|
||||
assert_eq!(a.htpasswd_file, "users.htpasswd");
|
||||
assert_eq!(a.token_storage, "data/tokens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_anonymous_read_from_toml() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4000
|
||||
|
||||
[storage]
|
||||
mode = "local"
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
anonymous_read = true
|
||||
"#;
|
||||
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(config.auth.enabled);
|
||||
assert!(config.auth.anonymous_read);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_anonymous_read() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("NORA_AUTH_ANONYMOUS_READ", "true");
|
||||
config.apply_env_overrides();
|
||||
assert!(config.auth.anonymous_read);
|
||||
std::env::remove_var("NORA_AUTH_ANONYMOUS_READ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_maven_proxy_entry_simple() {
|
||||
let entry = MavenProxyEntry::Simple("https://repo.example.com".to_string());
|
||||
assert_eq!(entry.url(), "https://repo.example.com");
|
||||
assert!(entry.auth().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_maven_proxy_entry_full() {
|
||||
let entry = MavenProxyEntry::Full(MavenProxy {
|
||||
url: "https://private.repo.com".to_string(),
|
||||
auth: Some("user:secret".to_string()),
|
||||
});
|
||||
assert_eq!(entry.url(), "https://private.repo.com");
|
||||
assert_eq!(entry.auth(), Some("user:secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_maven_proxy_entry_full_no_auth() {
|
||||
let entry = MavenProxyEntry::Full(MavenProxy {
|
||||
url: "https://repo.com".to_string(),
|
||||
auth: None,
|
||||
});
|
||||
assert_eq!(entry.url(), "https://repo.com");
|
||||
assert!(entry.auth().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_storage_mode_default() {
|
||||
let mode = StorageMode::default();
|
||||
assert_eq!(mode, StorageMode::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_server() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("NORA_HOST", "0.0.0.0");
|
||||
std::env::set_var("NORA_PORT", "8080");
|
||||
std::env::set_var("NORA_PUBLIC_URL", "registry.example.com");
|
||||
std::env::set_var("NORA_BODY_LIMIT_MB", "4096");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.server.host, "0.0.0.0");
|
||||
assert_eq!(config.server.port, 8080);
|
||||
assert_eq!(
|
||||
config.server.public_url,
|
||||
Some("registry.example.com".to_string())
|
||||
);
|
||||
assert_eq!(config.server.body_limit_mb, 4096);
|
||||
std::env::remove_var("NORA_HOST");
|
||||
std::env::remove_var("NORA_PORT");
|
||||
std::env::remove_var("NORA_PUBLIC_URL");
|
||||
std::env::remove_var("NORA_BODY_LIMIT_MB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_storage() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("NORA_STORAGE_MODE", "s3");
|
||||
std::env::set_var("NORA_STORAGE_PATH", "/data/nora");
|
||||
std::env::set_var("NORA_STORAGE_BUCKET", "my-bucket");
|
||||
std::env::set_var("NORA_STORAGE_S3_REGION", "eu-west-1");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.storage.mode, StorageMode::S3);
|
||||
assert_eq!(config.storage.path, "/data/nora");
|
||||
assert_eq!(config.storage.bucket, "my-bucket");
|
||||
assert_eq!(config.storage.s3_region, "eu-west-1");
|
||||
std::env::remove_var("NORA_STORAGE_MODE");
|
||||
std::env::remove_var("NORA_STORAGE_PATH");
|
||||
std::env::remove_var("NORA_STORAGE_BUCKET");
|
||||
std::env::remove_var("NORA_STORAGE_S3_REGION");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_auth() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("NORA_AUTH_ENABLED", "true");
|
||||
std::env::set_var("NORA_AUTH_HTPASSWD_FILE", "/etc/nora/users");
|
||||
std::env::set_var("NORA_AUTH_TOKEN_STORAGE", "/data/tokens");
|
||||
config.apply_env_overrides();
|
||||
assert!(config.auth.enabled);
|
||||
assert_eq!(config.auth.htpasswd_file, "/etc/nora/users");
|
||||
assert_eq!(config.auth.token_storage, "/data/tokens");
|
||||
std::env::remove_var("NORA_AUTH_ENABLED");
|
||||
std::env::remove_var("NORA_AUTH_HTPASSWD_FILE");
|
||||
std::env::remove_var("NORA_AUTH_TOKEN_STORAGE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_maven_proxies() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var(
|
||||
"NORA_MAVEN_PROXIES",
|
||||
"https://repo1.com,https://repo2.com|user:pass",
|
||||
);
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.maven.proxies.len(), 2);
|
||||
assert_eq!(config.maven.proxies[0].url(), "https://repo1.com");
|
||||
assert!(config.maven.proxies[0].auth().is_none());
|
||||
assert_eq!(config.maven.proxies[1].url(), "https://repo2.com");
|
||||
assert_eq!(config.maven.proxies[1].auth(), Some("user:pass"));
|
||||
std::env::remove_var("NORA_MAVEN_PROXIES");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_docker_upstreams() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var(
|
||||
"NORA_DOCKER_UPSTREAMS",
|
||||
"https://mirror.gcr.io,https://private.io|token123",
|
||||
);
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.docker.upstreams.len(), 2);
|
||||
assert_eq!(config.docker.upstreams[0].url, "https://mirror.gcr.io");
|
||||
assert!(config.docker.upstreams[0].auth.is_none());
|
||||
assert_eq!(config.docker.upstreams[1].url, "https://private.io");
|
||||
assert_eq!(
|
||||
config.docker.upstreams[1].auth,
|
||||
Some("token123".to_string())
|
||||
);
|
||||
std::env::remove_var("NORA_DOCKER_UPSTREAMS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_npm() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("NORA_NPM_PROXY", "https://npm.company.com");
|
||||
std::env::set_var("NORA_NPM_PROXY_AUTH", "user:token");
|
||||
std::env::set_var("NORA_NPM_PROXY_TIMEOUT", "60");
|
||||
std::env::set_var("NORA_NPM_METADATA_TTL", "600");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(
|
||||
config.npm.proxy,
|
||||
Some("https://npm.company.com".to_string())
|
||||
);
|
||||
assert_eq!(config.npm.proxy_auth, Some("user:token".to_string()));
|
||||
assert_eq!(config.npm.proxy_timeout, 60);
|
||||
assert_eq!(config.npm.metadata_ttl, 600);
|
||||
std::env::remove_var("NORA_NPM_PROXY");
|
||||
std::env::remove_var("NORA_NPM_PROXY_AUTH");
|
||||
std::env::remove_var("NORA_NPM_PROXY_TIMEOUT");
|
||||
std::env::remove_var("NORA_NPM_METADATA_TTL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_raw() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("NORA_RAW_ENABLED", "false");
|
||||
std::env::set_var("NORA_RAW_MAX_FILE_SIZE", "524288000");
|
||||
config.apply_env_overrides();
|
||||
assert!(!config.raw.enabled);
|
||||
assert_eq!(config.raw.max_file_size, 524288000);
|
||||
std::env::remove_var("NORA_RAW_ENABLED");
|
||||
std::env::remove_var("NORA_RAW_MAX_FILE_SIZE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_override_rate_limit() {
|
||||
let mut config = Config::default();
|
||||
std::env::set_var("NORA_RATE_LIMIT_ENABLED", "false");
|
||||
std::env::set_var("NORA_RATE_LIMIT_AUTH_RPS", "10");
|
||||
std::env::set_var("NORA_RATE_LIMIT_GENERAL_BURST", "500");
|
||||
config.apply_env_overrides();
|
||||
assert!(!config.rate_limit.enabled);
|
||||
assert_eq!(config.rate_limit.auth_rps, 10);
|
||||
assert_eq!(config.rate_limit.general_burst, 500);
|
||||
std::env::remove_var("NORA_RATE_LIMIT_ENABLED");
|
||||
std::env::remove_var("NORA_RATE_LIMIT_AUTH_RPS");
|
||||
std::env::remove_var("NORA_RATE_LIMIT_GENERAL_BURST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_from_toml_full() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
public_url = "nora.example.com"
|
||||
body_limit_mb = 4096
|
||||
|
||||
[storage]
|
||||
mode = "s3"
|
||||
path = "/data"
|
||||
s3_url = "http://minio:9000"
|
||||
bucket = "artifacts"
|
||||
s3_region = "eu-central-1"
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
htpasswd_file = "/etc/nora/users.htpasswd"
|
||||
|
||||
[raw]
|
||||
enabled = false
|
||||
max_file_size = 500000000
|
||||
"#;
|
||||
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.server.host, "0.0.0.0");
|
||||
assert_eq!(config.server.port, 8080);
|
||||
assert_eq!(
|
||||
config.server.public_url,
|
||||
Some("nora.example.com".to_string())
|
||||
);
|
||||
assert_eq!(config.server.body_limit_mb, 4096);
|
||||
assert_eq!(config.storage.mode, StorageMode::S3);
|
||||
assert_eq!(config.storage.s3_url, "http://minio:9000");
|
||||
assert_eq!(config.storage.bucket, "artifacts");
|
||||
assert!(config.auth.enabled);
|
||||
assert!(!config.raw.enabled);
|
||||
assert_eq!(config.raw.max_file_size, 500000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_from_toml_minimal() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4000
|
||||
|
||||
[storage]
|
||||
mode = "local"
|
||||
"#;
|
||||
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
// Defaults should be filled
|
||||
assert_eq!(config.storage.path, "data/storage");
|
||||
assert_eq!(config.maven.proxies.len(), 1);
|
||||
assert_eq!(
|
||||
config.npm.proxy,
|
||||
Some("https://registry.npmjs.org".to_string())
|
||||
);
|
||||
assert_eq!(config.docker.upstreams.len(), 1);
|
||||
assert!(config.raw.enabled);
|
||||
assert!(!config.auth.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_toml_docker_upstreams() {
|
||||
let toml = r#"
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 4000
|
||||
|
||||
[storage]
|
||||
mode = "local"
|
||||
|
||||
[docker]
|
||||
proxy_timeout = 120
|
||||
|
||||
[[docker.upstreams]]
|
||||
url = "https://mirror.gcr.io"
|
||||
|
||||
[[docker.upstreams]]
|
||||
url = "https://private.registry.io"
|
||||
auth = "user:pass"
|
||||
"#;
|
||||
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.docker.proxy_timeout, 120);
|
||||
assert_eq!(config.docker.upstreams.len(), 2);
|
||||
assert!(config.docker.upstreams[0].auth.is_none());
|
||||
assert_eq!(
|
||||
config.docker.upstreams[1].auth,
|
||||
Some("user:pass".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Serializable snapshot of metrics for persistence
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct MetricsSnapshot {
|
||||
downloads: u64,
|
||||
uploads: u64,
|
||||
cache_hits: u64,
|
||||
cache_misses: u64,
|
||||
docker_downloads: u64,
|
||||
docker_uploads: u64,
|
||||
npm_downloads: u64,
|
||||
maven_downloads: u64,
|
||||
maven_uploads: u64,
|
||||
cargo_downloads: u64,
|
||||
pypi_downloads: u64,
|
||||
raw_downloads: u64,
|
||||
raw_uploads: u64,
|
||||
}
|
||||
|
||||
/// Dashboard metrics for tracking registry activity
|
||||
/// Uses atomic counters for thread-safe access without locks
|
||||
@@ -22,6 +46,9 @@ pub struct DashboardMetrics {
|
||||
pub raw_uploads: AtomicU64,
|
||||
|
||||
pub start_time: Instant,
|
||||
|
||||
/// Path to metrics.json for persistence
|
||||
persist_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl DashboardMetrics {
|
||||
@@ -41,6 +68,75 @@ impl DashboardMetrics {
|
||||
raw_downloads: AtomicU64::new(0),
|
||||
raw_uploads: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
persist_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create metrics with persistence — loads existing data from metrics.json
|
||||
pub fn with_persistence(storage_path: &str) -> Self {
|
||||
let path = Path::new(storage_path).join("metrics.json");
|
||||
let mut metrics = Self::new();
|
||||
metrics.persist_path = Some(path.clone());
|
||||
|
||||
// Load existing metrics if file exists
|
||||
if path.exists() {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(data) => match serde_json::from_str::<MetricsSnapshot>(&data) {
|
||||
Ok(snap) => {
|
||||
metrics.downloads = AtomicU64::new(snap.downloads);
|
||||
metrics.uploads = AtomicU64::new(snap.uploads);
|
||||
metrics.cache_hits = AtomicU64::new(snap.cache_hits);
|
||||
metrics.cache_misses = AtomicU64::new(snap.cache_misses);
|
||||
metrics.docker_downloads = AtomicU64::new(snap.docker_downloads);
|
||||
metrics.docker_uploads = AtomicU64::new(snap.docker_uploads);
|
||||
metrics.npm_downloads = AtomicU64::new(snap.npm_downloads);
|
||||
metrics.maven_downloads = AtomicU64::new(snap.maven_downloads);
|
||||
metrics.maven_uploads = AtomicU64::new(snap.maven_uploads);
|
||||
metrics.cargo_downloads = AtomicU64::new(snap.cargo_downloads);
|
||||
metrics.pypi_downloads = AtomicU64::new(snap.pypi_downloads);
|
||||
metrics.raw_downloads = AtomicU64::new(snap.raw_downloads);
|
||||
metrics.raw_uploads = AtomicU64::new(snap.raw_uploads);
|
||||
info!(
|
||||
downloads = snap.downloads,
|
||||
uploads = snap.uploads,
|
||||
"Loaded persisted metrics"
|
||||
);
|
||||
}
|
||||
Err(e) => warn!("Failed to parse metrics.json: {}", e),
|
||||
},
|
||||
Err(e) => warn!("Failed to read metrics.json: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// Save current metrics to disk
|
||||
pub fn save(&self) {
|
||||
let Some(path) = &self.persist_path else {
|
||||
return;
|
||||
};
|
||||
let snap = MetricsSnapshot {
|
||||
downloads: self.downloads.load(Ordering::Relaxed),
|
||||
uploads: self.uploads.load(Ordering::Relaxed),
|
||||
cache_hits: self.cache_hits.load(Ordering::Relaxed),
|
||||
cache_misses: self.cache_misses.load(Ordering::Relaxed),
|
||||
docker_downloads: self.docker_downloads.load(Ordering::Relaxed),
|
||||
docker_uploads: self.docker_uploads.load(Ordering::Relaxed),
|
||||
npm_downloads: self.npm_downloads.load(Ordering::Relaxed),
|
||||
maven_downloads: self.maven_downloads.load(Ordering::Relaxed),
|
||||
maven_uploads: self.maven_uploads.load(Ordering::Relaxed),
|
||||
cargo_downloads: self.cargo_downloads.load(Ordering::Relaxed),
|
||||
pypi_downloads: self.pypi_downloads.load(Ordering::Relaxed),
|
||||
raw_downloads: self.raw_downloads.load(Ordering::Relaxed),
|
||||
raw_uploads: self.raw_uploads.load(Ordering::Relaxed),
|
||||
};
|
||||
// Atomic write: write to tmp then rename
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Ok(data) = serde_json::to_string_pretty(&snap) {
|
||||
if std::fs::write(&tmp, &data).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,3 +216,146 @@ impl Default for DashboardMetrics {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_new_defaults() {
|
||||
let m = DashboardMetrics::new();
|
||||
assert_eq!(m.downloads.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(m.uploads.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(m.cache_hits.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(m.cache_misses.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_download_all_registries() {
|
||||
let m = DashboardMetrics::new();
|
||||
for reg in &["docker", "npm", "maven", "cargo", "pypi", "raw"] {
|
||||
m.record_download(reg);
|
||||
}
|
||||
assert_eq!(m.downloads.load(Ordering::Relaxed), 6);
|
||||
assert_eq!(m.docker_downloads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.npm_downloads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.maven_downloads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.cargo_downloads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.pypi_downloads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.raw_downloads.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_download_unknown_registry() {
|
||||
let m = DashboardMetrics::new();
|
||||
m.record_download("unknown");
|
||||
assert_eq!(m.downloads.load(Ordering::Relaxed), 1);
|
||||
// no per-registry counter should increment
|
||||
assert_eq!(m.docker_downloads.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_upload() {
|
||||
let m = DashboardMetrics::new();
|
||||
m.record_upload("docker");
|
||||
m.record_upload("maven");
|
||||
m.record_upload("raw");
|
||||
assert_eq!(m.uploads.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(m.docker_uploads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.maven_uploads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.raw_uploads.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_record_upload_unknown_registry() {
|
||||
let m = DashboardMetrics::new();
|
||||
m.record_upload("npm"); // npm has no upload counter
|
||||
assert_eq!(m.uploads.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_hit_rate_zero() {
|
||||
let m = DashboardMetrics::new();
|
||||
assert_eq!(m.cache_hit_rate(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_hit_rate_all_hits() {
|
||||
let m = DashboardMetrics::new();
|
||||
m.record_cache_hit();
|
||||
m.record_cache_hit();
|
||||
assert_eq!(m.cache_hit_rate(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_hit_rate_mixed() {
|
||||
let m = DashboardMetrics::new();
|
||||
m.record_cache_hit();
|
||||
m.record_cache_miss();
|
||||
assert_eq!(m.cache_hit_rate(), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_registry_downloads() {
|
||||
let m = DashboardMetrics::new();
|
||||
m.record_download("docker");
|
||||
m.record_download("docker");
|
||||
m.record_download("npm");
|
||||
assert_eq!(m.get_registry_downloads("docker"), 2);
|
||||
assert_eq!(m.get_registry_downloads("npm"), 1);
|
||||
assert_eq!(m.get_registry_downloads("cargo"), 0);
|
||||
assert_eq!(m.get_registry_downloads("unknown"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_registry_uploads() {
|
||||
let m = DashboardMetrics::new();
|
||||
m.record_upload("docker");
|
||||
assert_eq!(m.get_registry_uploads("docker"), 1);
|
||||
assert_eq!(m.get_registry_uploads("maven"), 0);
|
||||
assert_eq!(m.get_registry_uploads("unknown"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_save_and_load() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().to_str().unwrap();
|
||||
|
||||
// Create metrics, record some data, save
|
||||
{
|
||||
let m = DashboardMetrics::with_persistence(path);
|
||||
m.record_download("docker");
|
||||
m.record_download("docker");
|
||||
m.record_upload("maven");
|
||||
m.record_cache_hit();
|
||||
m.save();
|
||||
}
|
||||
|
||||
// Load in new instance
|
||||
{
|
||||
let m = DashboardMetrics::with_persistence(path);
|
||||
assert_eq!(m.downloads.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(m.uploads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.docker_downloads.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(m.maven_uploads.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(m.cache_hits.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persistence_missing_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().to_str().unwrap();
|
||||
|
||||
// Should work even without existing metrics.json
|
||||
let m = DashboardMetrics::with_persistence(path);
|
||||
assert_eq!(m.downloads.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default() {
|
||||
let m = DashboardMetrics::default();
|
||||
assert_eq!(m.downloads.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#![allow(dead_code)]
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Application error handling with HTTP response conversion
|
||||
//!
|
||||
//! Provides a unified error type that can be converted to HTTP responses
|
||||
@@ -15,6 +17,7 @@ use thiserror::Error;
|
||||
use crate::storage::StorageError;
|
||||
use crate::validation::ValidationError;
|
||||
|
||||
#[allow(dead_code)] // Wiring into handlers planned for v0.3
|
||||
/// Application-level errors with HTTP response conversion
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
@@ -37,6 +40,7 @@ pub enum AppError {
|
||||
Validation(#[from] ValidationError),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// JSON error response body
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
@@ -71,6 +75,7 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AppError {
|
||||
/// Create a not found error
|
||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||
@@ -119,4 +124,77 @@ mod tests {
|
||||
let err = AppError::NotFound("image not found".to_string());
|
||||
assert_eq!(err.to_string(), "Not found: image not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_constructors() {
|
||||
let err = AppError::not_found("missing");
|
||||
assert!(matches!(err, AppError::NotFound(_)));
|
||||
assert_eq!(err.to_string(), "Not found: missing");
|
||||
|
||||
let err = AppError::bad_request("invalid input");
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
assert_eq!(err.to_string(), "Bad request: invalid input");
|
||||
|
||||
let err = AppError::unauthorized("no token");
|
||||
assert!(matches!(err, AppError::Unauthorized(_)));
|
||||
assert_eq!(err.to_string(), "Unauthorized: no token");
|
||||
|
||||
let err = AppError::internal("db crashed");
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
assert_eq!(err.to_string(), "Internal error: db crashed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_storage() {
|
||||
let err = AppError::Storage(StorageError::NotFound);
|
||||
assert!(err.to_string().contains("Storage error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_validation() {
|
||||
let err = AppError::Validation(ValidationError::PathTraversal);
|
||||
assert!(err.to_string().contains("Validation error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_into_response_not_found() {
|
||||
let err = AppError::NotFound("gone".to_string());
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_into_response_bad_request() {
|
||||
let err = AppError::BadRequest("bad".to_string());
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_into_response_unauthorized() {
|
||||
let err = AppError::Unauthorized("nope".to_string());
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_into_response_internal() {
|
||||
let err = AppError::Internal("boom".to_string());
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_into_response_storage_not_found() {
|
||||
let err = AppError::Storage(StorageError::NotFound);
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_into_response_validation() {
|
||||
let err = AppError::Validation(ValidationError::EmptyInput);
|
||||
let response = err.into_response();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
121
nora-registry/src/gc.rs
Normal file
121
nora-registry/src/gc.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! Garbage Collection for orphaned blobs
|
||||
//!
|
||||
//! Mark-and-sweep approach:
|
||||
//! 1. List all blobs across registries
|
||||
//! 2. Parse all manifests to find referenced blobs
|
||||
//! 3. Blobs not referenced by any manifest = orphans
|
||||
//! 4. Delete orphans (with --dry-run support)
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::storage::Storage;
|
||||
|
||||
pub struct GcResult {
|
||||
pub total_blobs: usize,
|
||||
pub referenced_blobs: usize,
|
||||
pub orphaned_blobs: usize,
|
||||
pub deleted_blobs: usize,
|
||||
pub orphan_keys: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn run_gc(storage: &Storage, dry_run: bool) -> GcResult {
|
||||
info!("Starting garbage collection (dry_run={})", dry_run);
|
||||
|
||||
// 1. Collect all blob keys
|
||||
let all_blobs = collect_all_blobs(storage).await;
|
||||
info!("Found {} total blobs", all_blobs.len());
|
||||
|
||||
// 2. Collect all referenced digests from manifests
|
||||
let referenced = collect_referenced_digests(storage).await;
|
||||
info!(
|
||||
"Found {} referenced digests from manifests",
|
||||
referenced.len()
|
||||
);
|
||||
|
||||
// 3. Find orphans
|
||||
let mut orphan_keys: Vec<String> = Vec::new();
|
||||
for key in &all_blobs {
|
||||
if let Some(digest) = key.rsplit('/').next() {
|
||||
if !referenced.contains(digest) {
|
||||
orphan_keys.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} orphaned blobs", orphan_keys.len());
|
||||
|
||||
let mut deleted = 0;
|
||||
if !dry_run {
|
||||
for key in &orphan_keys {
|
||||
if storage.delete(key).await.is_ok() {
|
||||
deleted += 1;
|
||||
info!("Deleted: {}", key);
|
||||
}
|
||||
}
|
||||
info!("Deleted {} orphaned blobs", deleted);
|
||||
} else {
|
||||
for key in &orphan_keys {
|
||||
info!("[dry-run] Would delete: {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
GcResult {
|
||||
total_blobs: all_blobs.len(),
|
||||
referenced_blobs: referenced.len(),
|
||||
orphaned_blobs: orphan_keys.len(),
|
||||
deleted_blobs: deleted,
|
||||
orphan_keys,
|
||||
}
|
||||
}
|
||||
|
||||
async fn collect_all_blobs(storage: &Storage) -> Vec<String> {
|
||||
let mut blobs = Vec::new();
|
||||
let docker_blobs = storage.list("docker/").await;
|
||||
for key in docker_blobs {
|
||||
if key.contains("/blobs/") {
|
||||
blobs.push(key);
|
||||
}
|
||||
}
|
||||
blobs
|
||||
}
|
||||
|
||||
async fn collect_referenced_digests(storage: &Storage) -> HashSet<String> {
|
||||
let mut referenced = HashSet::new();
|
||||
|
||||
let all_keys = storage.list("docker/").await;
|
||||
for key in &all_keys {
|
||||
if !key.contains("/manifests/") || !key.ends_with(".json") || key.ends_with(".meta.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(data) = storage.get(key).await {
|
||||
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||
if let Some(config) = json.get("config") {
|
||||
if let Some(digest) = config.get("digest").and_then(|v| v.as_str()) {
|
||||
referenced.insert(digest.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(layers) = json.get("layers").and_then(|v| v.as_array()) {
|
||||
for layer in layers {
|
||||
if let Some(digest) = layer.get("digest").and_then(|v| v.as_str()) {
|
||||
referenced.insert(digest.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(manifests) = json.get("manifests").and_then(|v| v.as_array()) {
|
||||
for m in manifests {
|
||||
if let Some(digest) = m.get("digest").and_then(|v| v.as_str()) {
|
||||
referenced.insert(digest.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
referenced
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
28
nora-registry/src/lib.rs
Normal file
28
nora-registry/src/lib.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! NORA Registry — library interface for fuzzing and testing
|
||||
|
||||
pub mod validation;
|
||||
|
||||
/// Re-export Docker manifest parsing for fuzz targets
|
||||
pub mod docker_fuzz {
|
||||
pub fn detect_manifest_media_type(data: &[u8]) -> String {
|
||||
let Ok(value) = serde_json::from_slice::<serde_json::Value>(data) else {
|
||||
return "application/octet-stream".to_string();
|
||||
};
|
||||
if let Some(mt) = value.get("mediaType").and_then(|v| v.as_str()) {
|
||||
return mt.to_string();
|
||||
}
|
||||
if value.get("manifests").is_some() {
|
||||
return "application/vnd.oci.image.index.v1+json".to_string();
|
||||
}
|
||||
if value.get("schemaVersion").and_then(|v| v.as_i64()) == Some(2) {
|
||||
if value.get("layers").is_some() {
|
||||
return "application/vnd.oci.image.manifest.v1+json".to_string();
|
||||
}
|
||||
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
|
||||
}
|
||||
if value.get("schemaVersion").and_then(|v| v.as_i64()) == Some(1) {
|
||||
return "application/vnd.docker.distribution.manifest.v1+json".to_string();
|
||||
}
|
||||
"application/vnd.docker.distribution.manifest.v2+json".to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
mod activity_log;
|
||||
mod audit;
|
||||
mod auth;
|
||||
mod backup;
|
||||
mod config;
|
||||
mod dashboard_metrics;
|
||||
mod error;
|
||||
mod gc;
|
||||
mod health;
|
||||
mod metrics;
|
||||
mod migrate;
|
||||
mod mirror;
|
||||
mod openapi;
|
||||
mod rate_limit;
|
||||
mod registry;
|
||||
mod repo_index;
|
||||
mod request_id;
|
||||
mod secrets;
|
||||
mod storage;
|
||||
@@ -17,7 +24,7 @@ mod tokens;
|
||||
mod ui;
|
||||
mod validation;
|
||||
|
||||
use axum::{extract::DefaultBodyLimit, middleware, Router};
|
||||
use axum::{extract::DefaultBodyLimit, http::HeaderValue, middleware, Router};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -27,9 +34,11 @@ use tracing::{error, info, warn};
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
use activity_log::ActivityLog;
|
||||
use audit::AuditLog;
|
||||
use auth::HtpasswdAuth;
|
||||
use config::{Config, StorageMode};
|
||||
use dashboard_metrics::DashboardMetrics;
|
||||
use repo_index::RepoIndex;
|
||||
pub use storage::Storage;
|
||||
use tokens::TokenStore;
|
||||
|
||||
@@ -56,6 +65,12 @@ enum Commands {
|
||||
#[arg(short, long)]
|
||||
input: PathBuf,
|
||||
},
|
||||
/// Garbage collect orphaned blobs
|
||||
Gc {
|
||||
/// Dry run - show what would be deleted without deleting
|
||||
#[arg(long, default_value = "false")]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Migrate artifacts between storage backends
|
||||
Migrate {
|
||||
/// Source storage: local or s3
|
||||
@@ -68,6 +83,17 @@ enum Commands {
|
||||
#[arg(long, default_value = "false")]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Pre-fetch dependencies through NORA proxy cache
|
||||
Mirror {
|
||||
#[command(subcommand)]
|
||||
format: mirror::MirrorFormat,
|
||||
/// NORA registry URL
|
||||
#[arg(long, default_value = "http://localhost:4000", global = true)]
|
||||
registry: String,
|
||||
/// Max concurrent downloads
|
||||
#[arg(long, default_value = "8", global = true)]
|
||||
concurrency: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
@@ -78,7 +104,10 @@ pub struct AppState {
|
||||
pub tokens: Option<TokenStore>,
|
||||
pub metrics: DashboardMetrics,
|
||||
pub activity: ActivityLog,
|
||||
pub audit: AuditLog,
|
||||
pub docker_auth: registry::DockerAuth,
|
||||
pub repo_index: RepoIndex,
|
||||
pub http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -104,10 +133,18 @@ async fn main() {
|
||||
info!(
|
||||
s3_url = %config.storage.s3_url,
|
||||
bucket = %config.storage.bucket,
|
||||
region = %config.storage.s3_region,
|
||||
has_credentials = config.storage.s3_access_key.is_some(),
|
||||
"Using S3 storage"
|
||||
);
|
||||
}
|
||||
Storage::new_s3(&config.storage.s3_url, &config.storage.bucket)
|
||||
Storage::new_s3(
|
||||
&config.storage.s3_url,
|
||||
&config.storage.bucket,
|
||||
&config.storage.s3_region,
|
||||
config.storage.s3_access_key.as_deref(),
|
||||
config.storage.s3_secret_key.as_deref(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,10 +165,37 @@ async fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some(Commands::Gc { dry_run }) => {
|
||||
let result = gc::run_gc(&storage, dry_run).await;
|
||||
println!("GC Summary:");
|
||||
println!(" Total blobs: {}", result.total_blobs);
|
||||
println!(" Referenced: {}", result.referenced_blobs);
|
||||
println!(" Orphaned: {}", result.orphaned_blobs);
|
||||
println!(" Deleted: {}", result.deleted_blobs);
|
||||
if dry_run && !result.orphan_keys.is_empty() {
|
||||
println!("\nRun without --dry-run to delete orphaned blobs.");
|
||||
}
|
||||
}
|
||||
Some(Commands::Mirror {
|
||||
format,
|
||||
registry,
|
||||
concurrency,
|
||||
}) => {
|
||||
if let Err(e) = mirror::run_mirror(format, ®istry, concurrency).await {
|
||||
error!("Mirror failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some(Commands::Migrate { from, to, dry_run }) => {
|
||||
let source = match from.as_str() {
|
||||
"local" => Storage::new_local(&config.storage.path),
|
||||
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
|
||||
"s3" => Storage::new_s3(
|
||||
&config.storage.s3_url,
|
||||
&config.storage.bucket,
|
||||
&config.storage.s3_region,
|
||||
config.storage.s3_access_key.as_deref(),
|
||||
config.storage.s3_secret_key.as_deref(),
|
||||
),
|
||||
_ => {
|
||||
error!("Invalid source: '{}'. Use 'local' or 's3'", from);
|
||||
std::process::exit(1);
|
||||
@@ -140,7 +204,13 @@ async fn main() {
|
||||
|
||||
let dest = match to.as_str() {
|
||||
"local" => Storage::new_local(&config.storage.path),
|
||||
"s3" => Storage::new_s3(&config.storage.s3_url, &config.storage.bucket),
|
||||
"s3" => Storage::new_s3(
|
||||
&config.storage.s3_url,
|
||||
&config.storage.bucket,
|
||||
&config.storage.s3_region,
|
||||
config.storage.s3_access_key.as_deref(),
|
||||
config.storage.s3_secret_key.as_deref(),
|
||||
),
|
||||
_ => {
|
||||
error!("Invalid destination: '{}'. Use 'local' or 's3'", to);
|
||||
std::process::exit(1);
|
||||
@@ -183,6 +253,7 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
|
||||
// Log rate limiting configuration
|
||||
info!(
|
||||
enabled = config.rate_limit.enabled,
|
||||
auth_rps = config.rate_limit.auth_rps,
|
||||
auth_burst = config.rate_limit.auth_burst,
|
||||
upload_rps = config.rate_limit.upload_rps,
|
||||
@@ -237,29 +308,18 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
None
|
||||
};
|
||||
|
||||
// Create rate limiters before moving config to state
|
||||
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
|
||||
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
|
||||
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
|
||||
let storage_path = config.storage.path.clone();
|
||||
let rate_limit_enabled = config.rate_limit.enabled;
|
||||
|
||||
// Warn about plaintext credentials in config.toml
|
||||
config.warn_plaintext_credentials();
|
||||
|
||||
// Initialize Docker auth with proxy timeout
|
||||
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
storage,
|
||||
config,
|
||||
start_time,
|
||||
auth,
|
||||
tokens,
|
||||
metrics: DashboardMetrics::new(),
|
||||
activity: ActivityLog::new(50),
|
||||
docker_auth,
|
||||
});
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
// Token routes with strict rate limiting (brute-force protection)
|
||||
let auth_routes = auth::token_routes().layer(auth_limiter);
|
||||
|
||||
// Registry routes with upload rate limiting
|
||||
// Registry routes (shared between rate-limited and non-limited paths)
|
||||
let registry_routes = Router::new()
|
||||
.merge(registry::docker_routes())
|
||||
.merge(registry::maven_routes())
|
||||
@@ -267,7 +327,7 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
.merge(registry::cargo_routes())
|
||||
.merge(registry::pypi_routes())
|
||||
.merge(registry::raw_routes())
|
||||
.layer(upload_limiter);
|
||||
.merge(registry::go_routes());
|
||||
|
||||
// Routes WITHOUT rate limiting (health, metrics, UI)
|
||||
let public_routes = Router::new()
|
||||
@@ -276,16 +336,62 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
.merge(ui::routes())
|
||||
.merge(openapi::routes());
|
||||
|
||||
// Routes WITH rate limiting
|
||||
let rate_limited_routes = Router::new()
|
||||
.merge(auth_routes)
|
||||
.merge(registry_routes)
|
||||
.layer(general_limiter);
|
||||
let app_routes = if rate_limit_enabled {
|
||||
// Create rate limiters before moving config to state
|
||||
let auth_limiter = rate_limit::auth_rate_limiter(&config.rate_limit);
|
||||
let upload_limiter = rate_limit::upload_rate_limiter(&config.rate_limit);
|
||||
let general_limiter = rate_limit::general_rate_limiter(&config.rate_limit);
|
||||
|
||||
let auth_routes = auth::token_routes().layer(auth_limiter);
|
||||
let limited_registry = registry_routes.layer(upload_limiter);
|
||||
|
||||
Router::new()
|
||||
.merge(auth_routes)
|
||||
.merge(limited_registry)
|
||||
.layer(general_limiter)
|
||||
} else {
|
||||
info!("Rate limiting DISABLED");
|
||||
Router::new()
|
||||
.merge(auth::token_routes())
|
||||
.merge(registry_routes)
|
||||
};
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
storage,
|
||||
config,
|
||||
start_time,
|
||||
auth,
|
||||
tokens,
|
||||
metrics: DashboardMetrics::with_persistence(&storage_path),
|
||||
activity: ActivityLog::new(50),
|
||||
audit: AuditLog::new(&storage_path),
|
||||
docker_auth,
|
||||
repo_index: RepoIndex::new(),
|
||||
http_client,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(rate_limited_routes)
|
||||
.layer(DefaultBodyLimit::max(100 * 1024 * 1024)) // 100MB default body limit
|
||||
.merge(app_routes)
|
||||
.layer(DefaultBodyLimit::max(
|
||||
state.config.server.body_limit_mb * 1024 * 1024,
|
||||
))
|
||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::HeaderName::from_static("x-content-type-options"),
|
||||
HeaderValue::from_static("nosniff"),
|
||||
))
|
||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::HeaderName::from_static("x-frame-options"),
|
||||
HeaderValue::from_static("DENY"),
|
||||
))
|
||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::HeaderName::from_static("referrer-policy"),
|
||||
HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
))
|
||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"),
|
||||
))
|
||||
.layer(middleware::from_fn(request_id::request_id_middleware))
|
||||
.layer(middleware::from_fn(metrics::metrics_middleware))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
@@ -304,6 +410,7 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
version = env!("CARGO_PKG_VERSION"),
|
||||
storage = state.storage.backend_name(),
|
||||
auth_enabled = state.auth.is_some(),
|
||||
body_limit_mb = state.config.server.body_limit_mb,
|
||||
"Nora started"
|
||||
);
|
||||
|
||||
@@ -322,6 +429,16 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
"Available endpoints"
|
||||
);
|
||||
|
||||
// Background task: persist metrics every 30 seconds
|
||||
let metrics_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
metrics_state.metrics.save();
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT
|
||||
axum::serve(
|
||||
listener,
|
||||
@@ -331,6 +448,9 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
.await
|
||||
.expect("Server error");
|
||||
|
||||
// Save metrics on shutdown
|
||||
state.metrics.save();
|
||||
|
||||
info!(
|
||||
uptime_seconds = state.start_time.elapsed().as_secs(),
|
||||
"Nora shutdown complete"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::MatchedPath,
|
||||
@@ -145,3 +148,56 @@ pub fn record_storage_op(operation: &str, success: bool) {
|
||||
.with_label_values(&[operation, status])
|
||||
.inc();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_registry_docker() {
|
||||
assert_eq!(detect_registry("/v2/nginx/manifests/latest"), "docker");
|
||||
assert_eq!(detect_registry("/v2/"), "docker");
|
||||
assert_eq!(
|
||||
detect_registry("/v2/library/alpine/blobs/sha256:abc"),
|
||||
"docker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_registry_maven() {
|
||||
assert_eq!(detect_registry("/maven2/com/example/artifact"), "maven");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_registry_npm() {
|
||||
assert_eq!(detect_registry("/npm/lodash"), "npm");
|
||||
assert_eq!(detect_registry("/npm/@scope/package"), "npm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_registry_cargo() {
|
||||
assert_eq!(detect_registry("/cargo/api/v1/crates"), "cargo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_registry_pypi() {
|
||||
assert_eq!(detect_registry("/simple/requests/"), "pypi");
|
||||
assert_eq!(
|
||||
detect_registry("/packages/requests/1.0/requests-1.0.tar.gz"),
|
||||
"pypi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_registry_ui() {
|
||||
assert_eq!(detect_registry("/ui/dashboard"), "ui");
|
||||
assert_eq!(detect_registry("/ui"), "ui");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_registry_other() {
|
||||
assert_eq!(detect_registry("/health"), "other");
|
||||
assert_eq!(detect_registry("/ready"), "other");
|
||||
assert_eq!(detect_registry("/unknown/path"), "other");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Migration between storage backends
|
||||
//!
|
||||
//! Supports migrating artifacts from one storage backend to another
|
||||
|
||||
325
nora-registry/src/mirror/mod.rs
Normal file
325
nora-registry/src/mirror/mod.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! `nora mirror` — pre-fetch dependencies through NORA proxy cache.
|
||||
|
||||
mod npm;
|
||||
|
||||
use clap::Subcommand;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum MirrorFormat {
|
||||
/// Mirror npm packages
|
||||
Npm {
|
||||
/// Path to package-lock.json (v1/v2/v3)
|
||||
#[arg(long, conflicts_with = "packages")]
|
||||
lockfile: Option<PathBuf>,
|
||||
/// Comma-separated package names
|
||||
#[arg(long, conflicts_with = "lockfile", value_delimiter = ',')]
|
||||
packages: Option<Vec<String>>,
|
||||
/// Fetch all versions (only with --packages)
|
||||
#[arg(long)]
|
||||
all_versions: bool,
|
||||
},
|
||||
/// Mirror Python packages
|
||||
Pip {
|
||||
/// Path to requirements.txt
|
||||
#[arg(long)]
|
||||
lockfile: PathBuf,
|
||||
},
|
||||
/// Mirror Cargo crates
|
||||
Cargo {
|
||||
/// Path to Cargo.lock
|
||||
#[arg(long)]
|
||||
lockfile: PathBuf,
|
||||
},
|
||||
/// Mirror Maven artifacts
|
||||
Maven {
|
||||
/// Path to dependency list (mvn dependency:list output)
|
||||
#[arg(long)]
|
||||
lockfile: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
pub struct MirrorTarget {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
pub struct MirrorResult {
|
||||
pub total: usize,
|
||||
pub fetched: usize,
|
||||
pub failed: usize,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
pub fn create_progress_bar(total: u64) -> ProgressBar {
|
||||
let pb = ProgressBar::new(total);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=>-"),
|
||||
);
|
||||
pb
|
||||
}
|
||||
|
||||
pub async fn run_mirror(
|
||||
format: MirrorFormat,
|
||||
registry: &str,
|
||||
concurrency: usize,
|
||||
) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
// Health check
|
||||
let health_url = format!("{}/health", registry.trim_end_matches('/'));
|
||||
match client.get(&health_url).send().await {
|
||||
Ok(r) if r.status().is_success() => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Cannot connect to NORA at {}. Is `nora serve` running?",
|
||||
registry
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let result = match format {
|
||||
MirrorFormat::Npm {
|
||||
lockfile,
|
||||
packages,
|
||||
all_versions,
|
||||
} => {
|
||||
npm::run_npm_mirror(
|
||||
&client,
|
||||
registry,
|
||||
lockfile,
|
||||
packages,
|
||||
all_versions,
|
||||
concurrency,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
MirrorFormat::Pip { lockfile } => {
|
||||
mirror_lockfile(&client, registry, "pip", &lockfile).await?
|
||||
}
|
||||
MirrorFormat::Cargo { lockfile } => {
|
||||
mirror_lockfile(&client, registry, "cargo", &lockfile).await?
|
||||
}
|
||||
MirrorFormat::Maven { lockfile } => {
|
||||
mirror_lockfile(&client, registry, "maven", &lockfile).await?
|
||||
}
|
||||
};
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
println!("\nMirror complete:");
|
||||
println!(" Total: {}", result.total);
|
||||
println!(" Fetched: {}", result.fetched);
|
||||
println!(" Failed: {}", result.failed);
|
||||
println!(" Size: {:.1} MB", result.bytes as f64 / 1_048_576.0);
|
||||
println!(" Time: {:.1}s", elapsed.as_secs_f64());
|
||||
|
||||
if result.failed > 0 {
|
||||
Err(format!("{} packages failed to mirror", result.failed))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn mirror_lockfile(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
format: &str,
|
||||
lockfile: &PathBuf,
|
||||
) -> Result<MirrorResult, String> {
|
||||
let content = std::fs::read_to_string(lockfile)
|
||||
.map_err(|e| format!("Cannot read {}: {}", lockfile.display(), e))?;
|
||||
|
||||
let targets = match format {
|
||||
"pip" => parse_requirements_txt(&content),
|
||||
"cargo" => parse_cargo_lock(&content)?,
|
||||
"maven" => parse_maven_deps(&content),
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("No packages found in {}", lockfile.display());
|
||||
return Ok(MirrorResult {
|
||||
total: 0,
|
||||
fetched: 0,
|
||||
failed: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let pb = create_progress_bar(targets.len() as u64);
|
||||
let base = registry.trim_end_matches('/');
|
||||
let mut fetched = 0;
|
||||
let mut failed = 0;
|
||||
let mut bytes = 0u64;
|
||||
|
||||
for target in &targets {
|
||||
let url = match format {
|
||||
"pip" => format!("{}/simple/{}/", base, target.name),
|
||||
"cargo" => format!(
|
||||
"{}/cargo/api/v1/crates/{}/{}/download",
|
||||
base, target.name, target.version
|
||||
),
|
||||
"maven" => {
|
||||
let parts: Vec<&str> = target.name.split(':').collect();
|
||||
if parts.len() == 2 {
|
||||
let group_path = parts[0].replace('.', "/");
|
||||
format!(
|
||||
"{}/maven2/{}/{}/{}/{}-{}.jar",
|
||||
base, group_path, parts[1], target.version, parts[1], target.version
|
||||
)
|
||||
} else {
|
||||
pb.inc(1);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(body) = r.bytes().await {
|
||||
bytes += body.len() as u64;
|
||||
}
|
||||
fetched += 1;
|
||||
}
|
||||
_ => failed += 1,
|
||||
}
|
||||
|
||||
pb.set_message(format!("{}@{}", target.name, target.version));
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
pb.finish_with_message("done");
|
||||
Ok(MirrorResult {
|
||||
total: targets.len(),
|
||||
fetched,
|
||||
failed,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_requirements_txt(content: &str) -> Vec<MirrorTarget> {
|
||||
content
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.starts_with('#') && !l.starts_with('-'))
|
||||
.filter_map(|line| {
|
||||
let line = line.split('#').next().unwrap().trim();
|
||||
if let Some((name, version)) = line.split_once("==") {
|
||||
Some(MirrorTarget {
|
||||
name: name.trim().to_string(),
|
||||
version: version.trim().to_string(),
|
||||
})
|
||||
} else {
|
||||
let name = line.split(['>', '<', '=', '!', '~', ';']).next()?.trim();
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(MirrorTarget {
|
||||
name: name.to_string(),
|
||||
version: "latest".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_cargo_lock(content: &str) -> Result<Vec<MirrorTarget>, String> {
|
||||
let lock: toml::Value =
|
||||
toml::from_str(content).map_err(|e| format!("Invalid Cargo.lock: {}", e))?;
|
||||
let packages = lock
|
||||
.get("package")
|
||||
.and_then(|p| p.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
Ok(packages
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.get("source")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.starts_with("registry+"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|p| {
|
||||
let name = p.get("name")?.as_str()?.to_string();
|
||||
let version = p.get("version")?.as_str()?.to_string();
|
||||
Some(MirrorTarget { name, version })
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_maven_deps(content: &str) -> Vec<MirrorTarget> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim().trim_start_matches("[INFO]").trim();
|
||||
let parts: Vec<&str> = line.split(':').collect();
|
||||
if parts.len() >= 4 {
|
||||
let name = format!("{}:{}", parts[0], parts[1]);
|
||||
let version = parts[3].to_string();
|
||||
Some(MirrorTarget { name, version })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_requirements_txt() {
|
||||
let content = "flask==2.3.0\nrequests>=2.28.0\n# comment\nnumpy==1.24.3\n";
|
||||
let targets = parse_requirements_txt(content);
|
||||
assert_eq!(targets.len(), 3);
|
||||
assert_eq!(targets[0].name, "flask");
|
||||
assert_eq!(targets[0].version, "2.3.0");
|
||||
assert_eq!(targets[1].name, "requests");
|
||||
assert_eq!(targets[1].version, "latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cargo_lock() {
|
||||
let content = "\
|
||||
[[package]]
|
||||
name = \"serde\"
|
||||
version = \"1.0.197\"
|
||||
source = \"registry+https://github.com/rust-lang/crates.io-index\"
|
||||
|
||||
[[package]]
|
||||
name = \"my-local-crate\"
|
||||
version = \"0.1.0\"
|
||||
";
|
||||
let targets = parse_cargo_lock(content).unwrap();
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].name, "serde");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_maven_deps() {
|
||||
let content = "[INFO] org.apache.commons:commons-lang3:jar:3.12.0:compile\n";
|
||||
let targets = parse_maven_deps(content);
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].name, "org.apache.commons:commons-lang3");
|
||||
assert_eq!(targets[0].version, "3.12.0");
|
||||
}
|
||||
}
|
||||
323
nora-registry/src/mirror/npm.rs
Normal file
323
nora-registry/src/mirror/npm.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! npm lockfile parser + mirror logic.
|
||||
|
||||
use super::{create_progress_bar, MirrorResult, MirrorTarget};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// Entry point for npm mirroring
|
||||
pub async fn run_npm_mirror(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
lockfile: Option<PathBuf>,
|
||||
packages: Option<Vec<String>>,
|
||||
all_versions: bool,
|
||||
concurrency: usize,
|
||||
) -> Result<MirrorResult, String> {
|
||||
let targets = if let Some(path) = lockfile {
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
|
||||
parse_npm_lockfile(&content)?
|
||||
} else if let Some(names) = packages {
|
||||
resolve_npm_packages(client, registry, &names, all_versions).await?
|
||||
} else {
|
||||
return Err("Specify --lockfile or --packages".to_string());
|
||||
};
|
||||
|
||||
if targets.is_empty() {
|
||||
println!("No npm packages to mirror");
|
||||
return Ok(MirrorResult {
|
||||
total: 0,
|
||||
fetched: 0,
|
||||
failed: 0,
|
||||
bytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
println!(
|
||||
"Mirroring {} npm packages via {}...",
|
||||
targets.len(),
|
||||
registry
|
||||
);
|
||||
mirror_npm_packages(client, registry, &targets, concurrency).await
|
||||
}
|
||||
|
||||
/// Parse package-lock.json (v1, v2, v3)
|
||||
fn parse_npm_lockfile(content: &str) -> Result<Vec<MirrorTarget>, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(content).map_err(|e| format!("Invalid JSON: {}", e))?;
|
||||
|
||||
let version = json
|
||||
.get("lockfileVersion")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(1);
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
let mut targets = Vec::new();
|
||||
|
||||
if version >= 2 {
|
||||
// v2/v3: use "packages" object
|
||||
if let Some(packages) = json.get("packages").and_then(|p| p.as_object()) {
|
||||
for (key, pkg) in packages {
|
||||
if key.is_empty() {
|
||||
continue; // root package
|
||||
}
|
||||
if let Some(name) = extract_package_name(key) {
|
||||
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
|
||||
let pair = (name.to_string(), ver.to_string());
|
||||
if seen.insert(pair.clone()) {
|
||||
targets.push(MirrorTarget {
|
||||
name: pair.0,
|
||||
version: pair.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if version == 1 || targets.is_empty() {
|
||||
// v1 fallback: recursive "dependencies"
|
||||
if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) {
|
||||
parse_v1_deps(deps, &mut targets, &mut seen);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Extract package name from lockfile key like "node_modules/@babel/core"
|
||||
fn extract_package_name(key: &str) -> Option<&str> {
|
||||
// Handle nested: "node_modules/foo/node_modules/@scope/bar" → "@scope/bar"
|
||||
let last_nm = key.rfind("node_modules/")?;
|
||||
let after = &key[last_nm + "node_modules/".len()..];
|
||||
let name = after.trim_end_matches('/');
|
||||
if name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively parse v1 lockfile "dependencies"
|
||||
fn parse_v1_deps(
|
||||
deps: &serde_json::Map<String, serde_json::Value>,
|
||||
targets: &mut Vec<MirrorTarget>,
|
||||
seen: &mut HashSet<(String, String)>,
|
||||
) {
|
||||
for (name, pkg) in deps {
|
||||
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
|
||||
let pair = (name.clone(), ver.to_string());
|
||||
if seen.insert(pair.clone()) {
|
||||
targets.push(MirrorTarget {
|
||||
name: pair.0,
|
||||
version: pair.1,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Recurse into nested dependencies
|
||||
if let Some(nested) = pkg.get("dependencies").and_then(|d| d.as_object()) {
|
||||
parse_v1_deps(nested, targets, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve --packages list by fetching metadata from NORA
|
||||
async fn resolve_npm_packages(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
names: &[String],
|
||||
all_versions: bool,
|
||||
) -> Result<Vec<MirrorTarget>, String> {
|
||||
let base = registry.trim_end_matches('/');
|
||||
let mut targets = Vec::new();
|
||||
|
||||
for name in names {
|
||||
let url = format!("{}/npm/{}", base, name);
|
||||
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
eprintln!("Warning: {} not found (HTTP {})", name, resp.status());
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if all_versions {
|
||||
if let Some(versions) = json.get("versions").and_then(|v| v.as_object()) {
|
||||
for ver in versions.keys() {
|
||||
targets.push(MirrorTarget {
|
||||
name: name.clone(),
|
||||
version: ver.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Just latest
|
||||
let latest = json
|
||||
.get("dist-tags")
|
||||
.and_then(|d| d.get("latest"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("latest");
|
||||
targets.push(MirrorTarget {
|
||||
name: name.clone(),
|
||||
version: latest.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
/// Fetch packages through NORA (triggers proxy cache)
|
||||
async fn mirror_npm_packages(
|
||||
client: &reqwest::Client,
|
||||
registry: &str,
|
||||
targets: &[MirrorTarget],
|
||||
concurrency: usize,
|
||||
) -> Result<MirrorResult, String> {
|
||||
let base = registry.trim_end_matches('/');
|
||||
let pb = create_progress_bar(targets.len() as u64);
|
||||
let sem = std::sync::Arc::new(Semaphore::new(concurrency));
|
||||
|
||||
// Deduplicate metadata fetches (one per package name)
|
||||
let unique_names: HashSet<&str> = targets.iter().map(|t| t.name.as_str()).collect();
|
||||
pb.set_message("fetching metadata...");
|
||||
for name in &unique_names {
|
||||
let url = format!("{}/npm/{}", base, name);
|
||||
let _ = client.get(&url).send().await; // trigger metadata cache
|
||||
}
|
||||
|
||||
// Fetch tarballs concurrently
|
||||
let fetched = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let failed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
for target in targets {
|
||||
let permit = sem.clone().acquire_owned().await.unwrap();
|
||||
let client = client.clone();
|
||||
let pb = pb.clone();
|
||||
let fetched = fetched.clone();
|
||||
let failed = failed.clone();
|
||||
let bytes = bytes.clone();
|
||||
|
||||
let short_name = target.name.split('/').next_back().unwrap_or(&target.name);
|
||||
let tarball_url = format!(
|
||||
"{}/npm/{}/-/{}-{}.tgz",
|
||||
base, target.name, short_name, target.version
|
||||
);
|
||||
let label = format!("{}@{}", target.name, target.version);
|
||||
|
||||
handles.push(tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
match client.get(&tarball_url).send().await {
|
||||
Ok(r) if r.status().is_success() => {
|
||||
if let Ok(body) = r.bytes().await {
|
||||
bytes.fetch_add(body.len() as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
_ => {
|
||||
failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pb.set_message(label);
|
||||
pb.inc(1);
|
||||
}));
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
pb.finish_with_message("done");
|
||||
|
||||
Ok(MirrorResult {
|
||||
total: targets.len(),
|
||||
fetched: fetched.load(std::sync::atomic::Ordering::Relaxed),
|
||||
failed: failed.load(std::sync::atomic::Ordering::Relaxed),
|
||||
bytes: bytes.load(std::sync::atomic::Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_package_name() {
|
||||
assert_eq!(extract_package_name("node_modules/lodash"), Some("lodash"));
|
||||
assert_eq!(
|
||||
extract_package_name("node_modules/@babel/core"),
|
||||
Some("@babel/core")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_package_name("node_modules/foo/node_modules/bar"),
|
||||
Some("bar")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_package_name("node_modules/foo/node_modules/@types/node"),
|
||||
Some("@types/node")
|
||||
);
|
||||
assert_eq!(extract_package_name(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_lockfile_v3() {
|
||||
let content = r#"{
|
||||
"lockfileVersion": 3,
|
||||
"packages": {
|
||||
"": { "name": "test" },
|
||||
"node_modules/lodash": { "version": "4.17.21" },
|
||||
"node_modules/@babel/core": { "version": "7.26.0" },
|
||||
"node_modules/@babel/core/node_modules/semver": { "version": "6.3.1" }
|
||||
}
|
||||
}"#;
|
||||
let targets = parse_npm_lockfile(content).unwrap();
|
||||
assert_eq!(targets.len(), 3);
|
||||
let names: HashSet<&str> = targets.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains("lodash"));
|
||||
assert!(names.contains("@babel/core"));
|
||||
assert!(names.contains("semver"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_lockfile_v1() {
|
||||
let content = r#"{
|
||||
"lockfileVersion": 1,
|
||||
"dependencies": {
|
||||
"express": {
|
||||
"version": "4.18.2",
|
||||
"dependencies": {
|
||||
"accepts": { "version": "1.3.8" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let targets = parse_npm_lockfile(content).unwrap();
|
||||
assert_eq!(targets.len(), 2);
|
||||
assert_eq!(targets[0].name, "express");
|
||||
assert_eq!(targets[1].name, "accepts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplication() {
|
||||
let content = r#"{
|
||||
"lockfileVersion": 3,
|
||||
"packages": {
|
||||
"": {},
|
||||
"node_modules/debug": { "version": "4.3.4" },
|
||||
"node_modules/express/node_modules/debug": { "version": "4.3.4" }
|
||||
}
|
||||
}"#;
|
||||
let targets = parse_npm_lockfile(content).unwrap();
|
||||
assert_eq!(targets.len(), 1); // deduplicated
|
||||
assert_eq!(targets[0].name, "debug");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! OpenAPI documentation and Swagger UI
|
||||
//!
|
||||
//! Functions in this module are stubs used only for generating OpenAPI documentation.
|
||||
|
||||
#![allow(dead_code)]
|
||||
#![allow(dead_code)] // utoipa doc stubs — not called at runtime, used by derive macros
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
@@ -15,7 +18,7 @@ use crate::AppState;
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Nora",
|
||||
version = "0.2.10",
|
||||
version = "0.2.12",
|
||||
description = "Multi-protocol package registry supporting Docker, Maven, npm, Cargo, and PyPI",
|
||||
license(name = "MIT"),
|
||||
contact(name = "DevITWay", url = "https://github.com/getnora-io/nora")
|
||||
@@ -25,6 +28,7 @@ use crate::AppState;
|
||||
),
|
||||
tags(
|
||||
(name = "health", description = "Health check endpoints"),
|
||||
(name = "metrics", description = "Prometheus metrics"),
|
||||
(name = "dashboard", description = "Dashboard & Metrics API"),
|
||||
(name = "docker", description = "Docker Registry v2 API"),
|
||||
(name = "maven", description = "Maven Repository API"),
|
||||
@@ -37,18 +41,30 @@ use crate::AppState;
|
||||
// Health
|
||||
crate::openapi::health_check,
|
||||
crate::openapi::readiness_check,
|
||||
// Metrics
|
||||
crate::openapi::prometheus_metrics,
|
||||
// Dashboard
|
||||
crate::openapi::dashboard_metrics,
|
||||
// Docker
|
||||
// Docker - Read
|
||||
crate::openapi::docker_version,
|
||||
crate::openapi::docker_catalog,
|
||||
crate::openapi::docker_tags,
|
||||
crate::openapi::docker_manifest,
|
||||
crate::openapi::docker_blob,
|
||||
crate::openapi::docker_manifest_get,
|
||||
crate::openapi::docker_blob_head,
|
||||
crate::openapi::docker_blob_get,
|
||||
// Docker - Write
|
||||
crate::openapi::docker_manifest_put,
|
||||
crate::openapi::docker_blob_upload_start,
|
||||
crate::openapi::docker_blob_upload_patch,
|
||||
crate::openapi::docker_blob_upload_put,
|
||||
// Maven
|
||||
crate::openapi::maven_artifact,
|
||||
crate::openapi::maven_artifact_get,
|
||||
crate::openapi::maven_artifact_put,
|
||||
// npm
|
||||
crate::openapi::npm_package,
|
||||
// Cargo
|
||||
crate::openapi::cargo_metadata,
|
||||
crate::openapi::cargo_download,
|
||||
// PyPI
|
||||
crate::openapi::pypi_simple,
|
||||
crate::openapi::pypi_package,
|
||||
@@ -258,6 +274,8 @@ pub struct ActivityEntry {
|
||||
|
||||
// ============ Path Operations (documentation only) ============
|
||||
|
||||
// -------------------- Health --------------------
|
||||
|
||||
/// Health check endpoint
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -282,6 +300,23 @@ pub async fn health_check() {}
|
||||
)]
|
||||
pub async fn readiness_check() {}
|
||||
|
||||
// -------------------- Metrics --------------------
|
||||
|
||||
/// Prometheus metrics endpoint
|
||||
///
|
||||
/// Returns metrics in Prometheus text format for scraping.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/metrics",
|
||||
tag = "metrics",
|
||||
responses(
|
||||
(status = 200, description = "Prometheus metrics", content_type = "text/plain")
|
||||
)
|
||||
)]
|
||||
pub async fn prometheus_metrics() {}
|
||||
|
||||
// -------------------- Dashboard --------------------
|
||||
|
||||
/// Dashboard metrics and activity
|
||||
///
|
||||
/// Returns comprehensive metrics including downloads, uploads, cache statistics,
|
||||
@@ -296,6 +331,8 @@ pub async fn readiness_check() {}
|
||||
)]
|
||||
pub async fn dashboard_metrics() {}
|
||||
|
||||
// -------------------- Docker Registry v2 - Read Operations --------------------
|
||||
|
||||
/// Docker Registry version check
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -325,7 +362,7 @@ pub async fn docker_catalog() {}
|
||||
path = "/v2/{name}/tags/list",
|
||||
tag = "docker",
|
||||
params(
|
||||
("name" = String, Path, description = "Repository name")
|
||||
("name" = String, Path, description = "Repository name (e.g., 'alpine' or 'library/nginx')")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Tag list", body = DockerTags),
|
||||
@@ -341,14 +378,30 @@ pub async fn docker_tags() {}
|
||||
tag = "docker",
|
||||
params(
|
||||
("name" = String, Path, description = "Repository name"),
|
||||
("reference" = String, Path, description = "Tag or digest")
|
||||
("reference" = String, Path, description = "Tag or digest (sha256:...)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Manifest content"),
|
||||
(status = 404, description = "Manifest not found")
|
||||
)
|
||||
)]
|
||||
pub async fn docker_manifest() {}
|
||||
pub async fn docker_manifest_get() {}
|
||||
|
||||
/// Check if blob exists
|
||||
#[utoipa::path(
|
||||
head,
|
||||
path = "/v2/{name}/blobs/{digest}",
|
||||
tag = "docker",
|
||||
params(
|
||||
("name" = String, Path, description = "Repository name"),
|
||||
("digest" = String, Path, description = "Blob digest (sha256:...)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Blob exists, Content-Length header contains size"),
|
||||
(status = 404, description = "Blob not found")
|
||||
)
|
||||
)]
|
||||
pub async fn docker_blob_head() {}
|
||||
|
||||
/// Get blob
|
||||
#[utoipa::path(
|
||||
@@ -364,7 +417,79 @@ pub async fn docker_manifest() {}
|
||||
(status = 404, description = "Blob not found")
|
||||
)
|
||||
)]
|
||||
pub async fn docker_blob() {}
|
||||
pub async fn docker_blob_get() {}
|
||||
|
||||
// -------------------- Docker Registry v2 - Write Operations --------------------
|
||||
|
||||
/// Push manifest
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v2/{name}/manifests/{reference}",
|
||||
tag = "docker",
|
||||
params(
|
||||
("name" = String, Path, description = "Repository name"),
|
||||
("reference" = String, Path, description = "Tag or digest")
|
||||
),
|
||||
responses(
|
||||
(status = 201, description = "Manifest created, Docker-Content-Digest header contains digest"),
|
||||
(status = 400, description = "Invalid manifest")
|
||||
)
|
||||
)]
|
||||
pub async fn docker_manifest_put() {}
|
||||
|
||||
/// Start blob upload
|
||||
///
|
||||
/// Initiates a resumable blob upload. Returns a Location header with the upload URL.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v2/{name}/blobs/uploads/",
|
||||
tag = "docker",
|
||||
params(
|
||||
("name" = String, Path, description = "Repository name")
|
||||
),
|
||||
responses(
|
||||
(status = 202, description = "Upload started, Location header contains upload URL")
|
||||
)
|
||||
)]
|
||||
pub async fn docker_blob_upload_start() {}
|
||||
|
||||
/// Upload blob chunk (chunked upload)
|
||||
///
|
||||
/// Uploads a chunk of data to an in-progress upload session.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/v2/{name}/blobs/uploads/{uuid}",
|
||||
tag = "docker",
|
||||
params(
|
||||
("name" = String, Path, description = "Repository name"),
|
||||
("uuid" = String, Path, description = "Upload session UUID")
|
||||
),
|
||||
responses(
|
||||
(status = 202, description = "Chunk accepted, Range header indicates bytes received")
|
||||
)
|
||||
)]
|
||||
pub async fn docker_blob_upload_patch() {}
|
||||
|
||||
/// Complete blob upload
|
||||
///
|
||||
/// Finalizes the blob upload. Can include final chunk data in the body.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/v2/{name}/blobs/uploads/{uuid}",
|
||||
tag = "docker",
|
||||
params(
|
||||
("name" = String, Path, description = "Repository name"),
|
||||
("uuid" = String, Path, description = "Upload session UUID"),
|
||||
("digest" = String, Query, description = "Expected blob digest (sha256:...)")
|
||||
),
|
||||
responses(
|
||||
(status = 201, description = "Blob created"),
|
||||
(status = 400, description = "Digest mismatch or missing")
|
||||
)
|
||||
)]
|
||||
pub async fn docker_blob_upload_put() {}
|
||||
|
||||
// -------------------- Maven --------------------
|
||||
|
||||
/// Get Maven artifact
|
||||
#[utoipa::path(
|
||||
@@ -379,7 +504,24 @@ pub async fn docker_blob() {}
|
||||
(status = 404, description = "Artifact not found, trying upstream proxies")
|
||||
)
|
||||
)]
|
||||
pub async fn maven_artifact() {}
|
||||
pub async fn maven_artifact_get() {}
|
||||
|
||||
/// Upload Maven artifact
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/maven2/{path}",
|
||||
tag = "maven",
|
||||
params(
|
||||
("path" = String, Path, description = "Artifact path")
|
||||
),
|
||||
responses(
|
||||
(status = 201, description = "Artifact uploaded"),
|
||||
(status = 500, description = "Storage error")
|
||||
)
|
||||
)]
|
||||
pub async fn maven_artifact_put() {}
|
||||
|
||||
// -------------------- npm --------------------
|
||||
|
||||
/// Get npm package metadata
|
||||
#[utoipa::path(
|
||||
@@ -387,7 +529,7 @@ pub async fn maven_artifact() {}
|
||||
path = "/npm/{name}",
|
||||
tag = "npm",
|
||||
params(
|
||||
("name" = String, Path, description = "Package name")
|
||||
("name" = String, Path, description = "Package name (e.g., 'lodash' or '@scope/package')")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Package metadata (JSON)"),
|
||||
@@ -396,6 +538,41 @@ pub async fn maven_artifact() {}
|
||||
)]
|
||||
pub async fn npm_package() {}
|
||||
|
||||
// -------------------- Cargo --------------------
|
||||
|
||||
/// Get Cargo crate metadata
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/cargo/api/v1/crates/{crate_name}",
|
||||
tag = "cargo",
|
||||
params(
|
||||
("crate_name" = String, Path, description = "Crate name")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Crate metadata (JSON)"),
|
||||
(status = 404, description = "Crate not found")
|
||||
)
|
||||
)]
|
||||
pub async fn cargo_metadata() {}
|
||||
|
||||
/// Download Cargo crate
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/cargo/api/v1/crates/{crate_name}/{version}/download",
|
||||
tag = "cargo",
|
||||
params(
|
||||
("crate_name" = String, Path, description = "Crate name"),
|
||||
("version" = String, Path, description = "Crate version")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Crate file (.crate)"),
|
||||
(status = 404, description = "Crate version not found")
|
||||
)
|
||||
)]
|
||||
pub async fn cargo_download() {}
|
||||
|
||||
// -------------------- PyPI --------------------
|
||||
|
||||
/// PyPI Simple index
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -422,6 +599,8 @@ pub async fn pypi_simple() {}
|
||||
)]
|
||||
pub async fn pypi_package() {}
|
||||
|
||||
// -------------------- Auth / Tokens --------------------
|
||||
|
||||
/// Create API token
|
||||
#[utoipa::path(
|
||||
post,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Rate limiting configuration and middleware
|
||||
//!
|
||||
//! Provides rate limiting to protect against:
|
||||
@@ -7,6 +10,7 @@
|
||||
|
||||
use crate::config::RateLimitConfig;
|
||||
use tower_governor::governor::GovernorConfigBuilder;
|
||||
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||
|
||||
/// Create rate limiter layer for auth endpoints (strict protection against brute-force)
|
||||
pub fn auth_rate_limiter(
|
||||
@@ -32,11 +36,12 @@ pub fn auth_rate_limiter(
|
||||
pub fn upload_rate_limiter(
|
||||
config: &RateLimitConfig,
|
||||
) -> tower_governor::GovernorLayer<
|
||||
tower_governor::key_extractor::PeerIpKeyExtractor,
|
||||
SmartIpKeyExtractor,
|
||||
governor::middleware::StateInformationMiddleware,
|
||||
axum::body::Body,
|
||||
> {
|
||||
let gov_config = GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_second(config.upload_rps)
|
||||
.burst_size(config.upload_burst)
|
||||
.use_headers()
|
||||
@@ -50,11 +55,12 @@ pub fn upload_rate_limiter(
|
||||
pub fn general_rate_limiter(
|
||||
config: &RateLimitConfig,
|
||||
) -> tower_governor::GovernorLayer<
|
||||
tower_governor::key_extractor::PeerIpKeyExtractor,
|
||||
SmartIpKeyExtractor,
|
||||
governor::middleware::StateInformationMiddleware,
|
||||
axum::body::Body,
|
||||
> {
|
||||
let gov_config = GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.per_second(config.general_rps)
|
||||
.burst_size(config.general_burst)
|
||||
.use_headers()
|
||||
@@ -99,6 +105,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_custom_config() {
|
||||
let config = RateLimitConfig {
|
||||
enabled: true,
|
||||
auth_rps: 10,
|
||||
auth_burst: 20,
|
||||
upload_rps: 500,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
@@ -47,6 +51,9 @@ async fn download(
|
||||
"cargo",
|
||||
"LOCAL",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("pull", "api", "", "cargo", ""));
|
||||
(StatusCode::OK, data).into_response()
|
||||
}
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::config::basic_auth_header;
|
||||
use crate::registry::docker_auth::DockerAuth;
|
||||
use crate::storage::Storage;
|
||||
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
||||
@@ -8,7 +13,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderName, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, head, patch, put},
|
||||
routing::{delete, get, head, patch, put},
|
||||
Json, Router,
|
||||
};
|
||||
use parking_lot::RwLock;
|
||||
@@ -39,14 +44,62 @@ pub struct LayerInfo {
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// In-progress upload session with metadata
|
||||
struct UploadSession {
|
||||
data: Vec<u8>,
|
||||
name: String,
|
||||
created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Max concurrent upload sessions (prevent memory exhaustion)
|
||||
const DEFAULT_MAX_UPLOAD_SESSIONS: usize = 100;
|
||||
/// Max data per session (default 2 GB, configurable via NORA_MAX_UPLOAD_SESSION_SIZE_MB)
|
||||
const DEFAULT_MAX_SESSION_SIZE_MB: usize = 2048;
|
||||
/// Session TTL (30 minutes)
|
||||
const SESSION_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// Read max upload sessions from env or use default
|
||||
fn max_upload_sessions() -> usize {
|
||||
std::env::var("NORA_MAX_UPLOAD_SESSIONS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_MAX_UPLOAD_SESSIONS)
|
||||
}
|
||||
|
||||
/// Read max session size from env (in MB) or use default
|
||||
fn max_session_size() -> usize {
|
||||
let mb = std::env::var("NORA_MAX_UPLOAD_SESSION_SIZE_MB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.unwrap_or(DEFAULT_MAX_SESSION_SIZE_MB);
|
||||
mb.saturating_mul(1024 * 1024)
|
||||
}
|
||||
|
||||
/// In-progress upload sessions for chunked uploads
|
||||
/// Maps UUID -> accumulated data
|
||||
static UPLOAD_SESSIONS: std::sync::LazyLock<RwLock<HashMap<String, Vec<u8>>>> =
|
||||
/// Maps UUID -> UploadSession with limits and TTL
|
||||
static UPLOAD_SESSIONS: std::sync::LazyLock<RwLock<HashMap<String, UploadSession>>> =
|
||||
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// Remove expired upload sessions (called periodically)
|
||||
fn cleanup_expired_sessions() {
|
||||
let mut sessions = UPLOAD_SESSIONS.write();
|
||||
let before = sessions.len();
|
||||
sessions.retain(|_, s| s.created_at.elapsed() < SESSION_TTL);
|
||||
let removed = before - sessions.len();
|
||||
if removed > 0 {
|
||||
tracing::info!(
|
||||
removed = removed,
|
||||
remaining = sessions.len(),
|
||||
"Cleaned up expired upload sessions"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/v2/", get(check))
|
||||
.route("/v2/_catalog", get(catalog))
|
||||
// Single-segment name routes (e.g., /v2/alpine/...)
|
||||
.route("/v2/{name}/blobs/{digest}", head(check_blob))
|
||||
.route("/v2/{name}/blobs/{digest}", get(download_blob))
|
||||
.route(
|
||||
@@ -59,13 +112,70 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
)
|
||||
.route("/v2/{name}/manifests/{reference}", get(get_manifest))
|
||||
.route("/v2/{name}/manifests/{reference}", put(put_manifest))
|
||||
.route("/v2/{name}/manifests/{reference}", delete(delete_manifest))
|
||||
.route("/v2/{name}/blobs/{digest}", delete(delete_blob))
|
||||
.route("/v2/{name}/tags/list", get(list_tags))
|
||||
// Two-segment name routes (e.g., /v2/library/alpine/...)
|
||||
.route("/v2/{ns}/{name}/blobs/{digest}", head(check_blob_ns))
|
||||
.route("/v2/{ns}/{name}/blobs/{digest}", get(download_blob_ns))
|
||||
.route(
|
||||
"/v2/{ns}/{name}/blobs/uploads/",
|
||||
axum::routing::post(start_upload_ns),
|
||||
)
|
||||
.route(
|
||||
"/v2/{ns}/{name}/blobs/uploads/{uuid}",
|
||||
patch(patch_blob_ns).put(upload_blob_ns),
|
||||
)
|
||||
.route(
|
||||
"/v2/{ns}/{name}/manifests/{reference}",
|
||||
get(get_manifest_ns),
|
||||
)
|
||||
.route(
|
||||
"/v2/{ns}/{name}/manifests/{reference}",
|
||||
put(put_manifest_ns),
|
||||
)
|
||||
.route(
|
||||
"/v2/{ns}/{name}/manifests/{reference}",
|
||||
delete(delete_manifest_ns),
|
||||
)
|
||||
.route("/v2/{ns}/{name}/blobs/{digest}", delete(delete_blob_ns))
|
||||
.route("/v2/{ns}/{name}/tags/list", get(list_tags_ns))
|
||||
}
|
||||
|
||||
async fn check() -> (StatusCode, Json<Value>) {
|
||||
(StatusCode::OK, Json(json!({})))
|
||||
}
|
||||
|
||||
/// List all repositories in the registry
|
||||
async fn catalog(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
let keys = state.storage.list("docker/").await;
|
||||
|
||||
// Extract unique repository names from paths like "docker/{name}/manifests/..."
|
||||
let mut repos: Vec<String> = keys
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
let rest = k.strip_prefix("docker/")?;
|
||||
// Find the first known directory separator (manifests/ or blobs/)
|
||||
let name = if let Some(idx) = rest.find("/manifests/") {
|
||||
&rest[..idx]
|
||||
} else if let Some(idx) = rest.find("/blobs/") {
|
||||
&rest[..idx]
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(name.to_string())
|
||||
})
|
||||
.collect();
|
||||
|
||||
repos.sort();
|
||||
repos.dedup();
|
||||
|
||||
Json(json!({ "repositories": repos }))
|
||||
}
|
||||
|
||||
async fn check_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, digest)): Path<(String, String)>,
|
||||
@@ -122,11 +232,13 @@ async fn download_blob(
|
||||
// Try upstream proxies
|
||||
for upstream in &state.config.docker.upstreams {
|
||||
if let Ok(data) = fetch_blob_from_upstream(
|
||||
&state.http_client,
|
||||
&upstream.url,
|
||||
&name,
|
||||
&digest,
|
||||
&state.docker_auth,
|
||||
state.config.docker.proxy_timeout,
|
||||
upstream.auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -147,6 +259,8 @@ async fn download_blob(
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
state.repo_index.invalidate("docker");
|
||||
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/octet-stream")],
|
||||
@@ -156,6 +270,38 @@ async fn download_blob(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-prepend library/ for single-segment names (Docker Hub official images)
|
||||
if !name.contains('/') {
|
||||
let library_name = format!("library/{}", name);
|
||||
for upstream in &state.config.docker.upstreams {
|
||||
if let Ok(data) = fetch_blob_from_upstream(
|
||||
&state.http_client,
|
||||
&upstream.url,
|
||||
&library_name,
|
||||
&digest,
|
||||
&state.docker_auth,
|
||||
state.config.docker.proxy_timeout,
|
||||
upstream.auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.clone();
|
||||
let data_clone = data.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, "application/octet-stream")],
|
||||
Bytes::from(data),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
@@ -164,7 +310,38 @@ async fn start_upload(Path(name): Path<String>) -> Response {
|
||||
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||
}
|
||||
|
||||
// Cleanup expired sessions before checking limits
|
||||
cleanup_expired_sessions();
|
||||
|
||||
// Enforce max concurrent sessions
|
||||
{
|
||||
let sessions = UPLOAD_SESSIONS.read();
|
||||
let max_sessions = max_upload_sessions();
|
||||
if sessions.len() >= max_sessions {
|
||||
tracing::warn!(
|
||||
max = max_sessions,
|
||||
current = sessions.len(),
|
||||
"Upload session limit reached — rejecting new upload"
|
||||
);
|
||||
return (StatusCode::TOO_MANY_REQUESTS, "Too many concurrent uploads").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let uuid = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// Create session with metadata
|
||||
{
|
||||
let mut sessions = UPLOAD_SESSIONS.write();
|
||||
sessions.insert(
|
||||
uuid.clone(),
|
||||
UploadSession {
|
||||
data: Vec::new(),
|
||||
name: name.clone(),
|
||||
created_at: std::time::Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let location = format!("/v2/{}/blobs/uploads/{}", name, uuid);
|
||||
(
|
||||
StatusCode::ACCEPTED,
|
||||
@@ -186,9 +363,47 @@ async fn patch_blob(Path((name, uuid)): Path<(String, String)>, body: Bytes) ->
|
||||
// Append data to the upload session and get total size
|
||||
let total_size = {
|
||||
let mut sessions = UPLOAD_SESSIONS.write();
|
||||
let session = sessions.entry(uuid.clone()).or_default();
|
||||
session.extend_from_slice(&body);
|
||||
session.len()
|
||||
let session = match sessions.get_mut(&uuid) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (StatusCode::NOT_FOUND, "Upload session not found or expired")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify session belongs to this repository
|
||||
if session.name != name {
|
||||
tracing::warn!(
|
||||
session_name = %session.name,
|
||||
request_name = %name,
|
||||
"SECURITY: upload session name mismatch — possible session fixation"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Session does not belong to this repository",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Check session TTL
|
||||
if session.created_at.elapsed() >= SESSION_TTL {
|
||||
sessions.remove(&uuid);
|
||||
return (StatusCode::NOT_FOUND, "Upload session expired").into_response();
|
||||
}
|
||||
|
||||
// Check size limit
|
||||
let new_size = session.data.len() + body.len();
|
||||
if new_size > max_session_size() {
|
||||
sessions.remove(&uuid);
|
||||
return (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"Upload session exceeds size limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
session.data.extend_from_slice(&body);
|
||||
session.data.len()
|
||||
};
|
||||
|
||||
let location = format!("/v2/{}/blobs/uploads/{}", name, uuid);
|
||||
@@ -235,8 +450,22 @@ async fn upload_blob(
|
||||
// Get data from chunked session if exists, otherwise use body directly
|
||||
let data = {
|
||||
let mut sessions = UPLOAD_SESSIONS.write();
|
||||
if let Some(mut session_data) = sessions.remove(&uuid) {
|
||||
if let Some(session) = sessions.remove(&uuid) {
|
||||
// Verify session belongs to this repository
|
||||
if session.name != name {
|
||||
tracing::warn!(
|
||||
session_name = %session.name,
|
||||
request_name = %name,
|
||||
"SECURITY: upload finalization name mismatch"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Session does not belong to this repository",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
// Chunked upload: append any final body data and use session
|
||||
let mut session_data = session.data;
|
||||
if !body.is_empty() {
|
||||
session_data.extend_from_slice(&body);
|
||||
}
|
||||
@@ -247,6 +476,40 @@ async fn upload_blob(
|
||||
}
|
||||
};
|
||||
|
||||
// Only sha256 digests are supported for verification
|
||||
if !digest.starts_with("sha256:") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Only sha256 digests are supported for blob uploads",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Verify digest matches uploaded content (Docker Distribution Spec)
|
||||
{
|
||||
use sha2::Digest as _;
|
||||
let computed = format!("sha256:{:x}", sha2::Sha256::digest(&data));
|
||||
if computed != *digest {
|
||||
tracing::warn!(
|
||||
expected = %digest,
|
||||
computed = %computed,
|
||||
name = %name,
|
||||
"SECURITY: blob digest mismatch — rejecting upload"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"errors": [{
|
||||
"code": "DIGEST_INVALID",
|
||||
"message": "provided digest did not match uploaded content",
|
||||
"detail": { "expected": digest, "computed": computed }
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||
match state.storage.put(&key, &data).await {
|
||||
Ok(()) => {
|
||||
@@ -257,8 +520,19 @@ async fn upload_blob(
|
||||
"docker",
|
||||
"LOCAL",
|
||||
));
|
||||
state.repo_index.invalidate("docker");
|
||||
let location = format!("/v2/{}/blobs/{}", name, digest);
|
||||
(StatusCode::CREATED, [(header::LOCATION, location)]).into_response()
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
[
|
||||
(header::LOCATION, location),
|
||||
(
|
||||
HeaderName::from_static("docker-content-digest"),
|
||||
digest.to_string(),
|
||||
),
|
||||
],
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
@@ -312,13 +586,20 @@ async fn get_manifest(
|
||||
}
|
||||
|
||||
// Try upstream proxies
|
||||
tracing::debug!(
|
||||
upstreams_count = state.config.docker.upstreams.len(),
|
||||
"Trying upstream proxies"
|
||||
);
|
||||
for upstream in &state.config.docker.upstreams {
|
||||
tracing::debug!(upstream_url = %upstream.url, "Trying upstream");
|
||||
if let Ok((data, content_type)) = fetch_manifest_from_upstream(
|
||||
&state.http_client,
|
||||
&upstream.url,
|
||||
&name,
|
||||
&reference,
|
||||
&state.docker_auth,
|
||||
state.config.docker.proxy_timeout,
|
||||
upstream.auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -363,6 +644,8 @@ async fn get_manifest(
|
||||
}
|
||||
});
|
||||
|
||||
state.repo_index.invalidate("docker");
|
||||
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[
|
||||
@@ -375,6 +658,57 @@ async fn get_manifest(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-prepend library/ for single-segment names (Docker Hub official images)
|
||||
// e.g., "nginx" -> "library/nginx", "alpine" -> "library/alpine"
|
||||
if !name.contains('/') {
|
||||
let library_name = format!("library/{}", name);
|
||||
for upstream in &state.config.docker.upstreams {
|
||||
if let Ok((data, content_type)) = fetch_manifest_from_upstream(
|
||||
&state.http_client,
|
||||
&upstream.url,
|
||||
&library_name,
|
||||
&reference,
|
||||
&state.docker_auth,
|
||||
state.config.docker.proxy_timeout,
|
||||
upstream.auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
state.metrics.record_download("docker");
|
||||
state.metrics.record_cache_miss();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::ProxyFetch,
|
||||
format!("{}:{}", name, reference),
|
||||
"docker",
|
||||
"PROXY",
|
||||
));
|
||||
|
||||
use sha2::Digest;
|
||||
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data));
|
||||
|
||||
// Cache under original name for future local hits
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.clone();
|
||||
let data_clone = data.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
state.repo_index.invalidate("docker");
|
||||
|
||||
return (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(HeaderName::from_static("docker-content-digest"), digest),
|
||||
],
|
||||
Bytes::from(data),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
@@ -424,6 +758,14 @@ async fn put_manifest(
|
||||
"docker",
|
||||
"LOCAL",
|
||||
));
|
||||
state.audit.log(AuditEntry::new(
|
||||
"push",
|
||||
"api",
|
||||
&format!("{}:{}", name, reference),
|
||||
"docker",
|
||||
"manifest",
|
||||
));
|
||||
state.repo_index.invalidate("docker");
|
||||
|
||||
let location = format!("/v2/{}/manifests/{}", name, reference);
|
||||
(
|
||||
@@ -450,17 +792,208 @@ async fn list_tags(State(state): State<Arc<AppState>>, Path(name): Path<String>)
|
||||
.and_then(|t| t.strip_suffix(".json"))
|
||||
.map(String::from)
|
||||
})
|
||||
.filter(|t| !t.ends_with(".meta") && !t.contains(".meta."))
|
||||
.collect();
|
||||
(StatusCode::OK, Json(json!({"name": name, "tags": tags}))).into_response()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Delete handlers (Docker Registry V2 spec)
|
||||
// ============================================================================
|
||||
|
||||
async fn delete_manifest(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, reference)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
if let Err(e) = validate_docker_name(&name) {
|
||||
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||
}
|
||||
if let Err(e) = validate_docker_reference(&reference) {
|
||||
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||
}
|
||||
|
||||
let key = format!("docker/{}/manifests/{}.json", name, reference);
|
||||
|
||||
// If reference is a tag, also delete digest-keyed copy
|
||||
let is_tag = !reference.starts_with("sha256:");
|
||||
if is_tag {
|
||||
if let Ok(data) = state.storage.get(&key).await {
|
||||
use sha2::Digest;
|
||||
let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data));
|
||||
let digest_key = format!("docker/{}/manifests/{}.json", name, digest);
|
||||
let _ = state.storage.delete(&digest_key).await;
|
||||
let digest_meta = format!("docker/{}/manifests/{}.meta.json", name, digest);
|
||||
let _ = state.storage.delete(&digest_meta).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete manifest
|
||||
match state.storage.delete(&key).await {
|
||||
Ok(()) => {
|
||||
// Delete associated metadata
|
||||
let meta_key = format!("docker/{}/manifests/{}.meta.json", name, reference);
|
||||
let _ = state.storage.delete(&meta_key).await;
|
||||
|
||||
state.audit.log(AuditEntry::new(
|
||||
"delete",
|
||||
"api",
|
||||
&format!("{}:{}", name, reference),
|
||||
"docker",
|
||||
"manifest",
|
||||
));
|
||||
state.repo_index.invalidate("docker");
|
||||
tracing::info!(name = %name, reference = %reference, "Docker manifest deleted");
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
}
|
||||
Err(crate::storage::StorageError::NotFound) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"errors": [{
|
||||
"code": "MANIFEST_UNKNOWN",
|
||||
"message": "manifest unknown",
|
||||
"detail": { "name": name, "reference": reference }
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_blob(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((name, digest)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
if let Err(e) = validate_docker_name(&name) {
|
||||
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||
}
|
||||
if let Err(e) = validate_digest(&digest) {
|
||||
return (StatusCode::BAD_REQUEST, e.to_string()).into_response();
|
||||
}
|
||||
|
||||
let key = format!("docker/{}/blobs/{}", name, digest);
|
||||
match state.storage.delete(&key).await {
|
||||
Ok(()) => {
|
||||
state.audit.log(AuditEntry::new(
|
||||
"delete",
|
||||
"api",
|
||||
&format!("{}@{}", name, &digest[..19.min(digest.len())]),
|
||||
"docker",
|
||||
"blob",
|
||||
));
|
||||
state.repo_index.invalidate("docker");
|
||||
tracing::info!(name = %name, digest = %digest, "Docker blob deleted");
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
}
|
||||
Err(crate::storage::StorageError::NotFound) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"errors": [{
|
||||
"code": "BLOB_UNKNOWN",
|
||||
"message": "blob unknown to registry",
|
||||
"detail": { "digest": digest }
|
||||
}]
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Namespace handlers (for two-segment names like library/alpine)
|
||||
// These combine ns/name into a single name and delegate to the main handlers
|
||||
// ============================================================================
|
||||
|
||||
async fn check_blob_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name, digest)): Path<(String, String, String)>,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
check_blob(state, Path((full_name, digest))).await
|
||||
}
|
||||
|
||||
async fn download_blob_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name, digest)): Path<(String, String, String)>,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
download_blob(state, Path((full_name, digest))).await
|
||||
}
|
||||
|
||||
async fn start_upload_ns(Path((ns, name)): Path<(String, String)>) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
start_upload(Path(full_name)).await
|
||||
}
|
||||
|
||||
async fn patch_blob_ns(
|
||||
Path((ns, name, uuid)): Path<(String, String, String)>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
patch_blob(Path((full_name, uuid)), body).await
|
||||
}
|
||||
|
||||
async fn upload_blob_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name, uuid)): Path<(String, String, String)>,
|
||||
query: axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
upload_blob(state, Path((full_name, uuid)), query, body).await
|
||||
}
|
||||
|
||||
async fn get_manifest_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name, reference)): Path<(String, String, String)>,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
get_manifest(state, Path((full_name, reference))).await
|
||||
}
|
||||
|
||||
async fn put_manifest_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name, reference)): Path<(String, String, String)>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
put_manifest(state, Path((full_name, reference)), body).await
|
||||
}
|
||||
|
||||
async fn list_tags_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
list_tags(state, Path(full_name)).await
|
||||
}
|
||||
|
||||
async fn delete_manifest_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name, reference)): Path<(String, String, String)>,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
delete_manifest(state, Path((full_name, reference))).await
|
||||
}
|
||||
|
||||
async fn delete_blob_ns(
|
||||
state: State<Arc<AppState>>,
|
||||
Path((ns, name, digest)): Path<(String, String, String)>,
|
||||
) -> Response {
|
||||
let full_name = format!("{}/{}", ns, name);
|
||||
delete_blob(state, Path((full_name, digest))).await
|
||||
}
|
||||
|
||||
/// Fetch a blob from an upstream Docker registry
|
||||
async fn fetch_blob_from_upstream(
|
||||
client: &reqwest::Client,
|
||||
upstream_url: &str,
|
||||
name: &str,
|
||||
digest: &str,
|
||||
docker_auth: &DockerAuth,
|
||||
timeout: u64,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Result<Vec<u8>, ()> {
|
||||
let url = format!(
|
||||
"{}/v2/{}/blobs/{}",
|
||||
@@ -469,13 +1002,12 @@ async fn fetch_blob_from_upstream(
|
||||
digest
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
// First try without auth
|
||||
let response = client.get(&url).send().await.map_err(|_| ())?;
|
||||
// First try — with basic auth if configured
|
||||
let mut request = client.get(&url).timeout(Duration::from_secs(timeout));
|
||||
if let Some(credentials) = basic_auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|_| ())?;
|
||||
|
||||
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Get Www-Authenticate header and fetch token
|
||||
@@ -486,7 +1018,7 @@ async fn fetch_blob_from_upstream(
|
||||
.map(String::from);
|
||||
|
||||
if let Some(token) = docker_auth
|
||||
.get_token(upstream_url, name, www_auth.as_deref())
|
||||
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
|
||||
.await
|
||||
{
|
||||
client
|
||||
@@ -512,11 +1044,13 @@ async fn fetch_blob_from_upstream(
|
||||
/// Fetch a manifest from an upstream Docker registry
|
||||
/// Returns (manifest_bytes, content_type)
|
||||
async fn fetch_manifest_from_upstream(
|
||||
client: &reqwest::Client,
|
||||
upstream_url: &str,
|
||||
name: &str,
|
||||
reference: &str,
|
||||
docker_auth: &DockerAuth,
|
||||
timeout: u64,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Result<(Vec<u8>, String), ()> {
|
||||
let url = format!(
|
||||
"{}/v2/{}/manifests/{}",
|
||||
@@ -525,10 +1059,7 @@ async fn fetch_manifest_from_upstream(
|
||||
reference
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
tracing::debug!(url = %url, "Fetching manifest from upstream");
|
||||
|
||||
// Request with Accept header for manifest types
|
||||
let accept_header = "application/vnd.docker.distribution.manifest.v2+json, \
|
||||
@@ -536,13 +1067,19 @@ async fn fetch_manifest_from_upstream(
|
||||
application/vnd.oci.image.manifest.v1+json, \
|
||||
application/vnd.oci.image.index.v1+json";
|
||||
|
||||
// First try without auth
|
||||
let response = client
|
||||
// First try — with basic auth if configured
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.header("Accept", accept_header)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.header("Accept", accept_header);
|
||||
if let Some(credentials) = basic_auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|e| {
|
||||
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
|
||||
})?;
|
||||
|
||||
tracing::debug!(status = %response.status(), "Initial upstream response");
|
||||
|
||||
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Get Www-Authenticate header and fetch token
|
||||
@@ -552,25 +1089,34 @@ async fn fetch_manifest_from_upstream(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token");
|
||||
|
||||
if let Some(token) = docker_auth
|
||||
.get_token(upstream_url, name, www_auth.as_deref())
|
||||
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("Token acquired, retrying with auth");
|
||||
client
|
||||
.get(&url)
|
||||
.header("Accept", accept_header)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to send authenticated request");
|
||||
})?
|
||||
} else {
|
||||
tracing::error!("Failed to acquire token");
|
||||
return Err(());
|
||||
}
|
||||
} else {
|
||||
response
|
||||
};
|
||||
|
||||
tracing::debug!(status = %response.status(), "Final upstream response");
|
||||
|
||||
if !response.status().is_success() {
|
||||
tracing::warn!(status = %response.status(), "Upstream returned non-success status");
|
||||
return Err(());
|
||||
}
|
||||
|
||||
@@ -599,8 +1145,16 @@ fn detect_manifest_media_type(data: &[u8]) -> String {
|
||||
if schema_version == 1 {
|
||||
return "application/vnd.docker.distribution.manifest.v1+json".to_string();
|
||||
}
|
||||
// schemaVersion 2 without mediaType is likely docker manifest v2
|
||||
if json.get("config").is_some() {
|
||||
// schemaVersion 2 without mediaType - check config.mediaType to distinguish OCI vs Docker
|
||||
if let Some(config) = json.get("config") {
|
||||
if let Some(config_mt) = config.get("mediaType").and_then(|v| v.as_str()) {
|
||||
if config_mt.starts_with("application/vnd.docker.") {
|
||||
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
|
||||
}
|
||||
// OCI or Helm or any non-docker config mediaType
|
||||
return "application/vnd.oci.image.manifest.v1+json".to_string();
|
||||
}
|
||||
// No config.mediaType - assume docker v2
|
||||
return "application/vnd.docker.distribution.manifest.v2+json".to_string();
|
||||
}
|
||||
// If it has "manifests" array, it's an index/list
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::config::basic_auth_header;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -33,6 +37,7 @@ impl DockerAuth {
|
||||
registry_url: &str,
|
||||
name: &str,
|
||||
www_authenticate: Option<&str>,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let cache_key = format!("{}:{}", registry_url, name);
|
||||
|
||||
@@ -48,7 +53,7 @@ impl DockerAuth {
|
||||
|
||||
// Need to fetch a new token
|
||||
let www_auth = www_authenticate?;
|
||||
let token = self.fetch_token(www_auth, name).await?;
|
||||
let token = self.fetch_token(www_auth, name, basic_auth).await?;
|
||||
|
||||
// Cache the token (default 5 minute expiry)
|
||||
{
|
||||
@@ -67,7 +72,12 @@ impl DockerAuth {
|
||||
|
||||
/// Parse Www-Authenticate header and fetch token from auth server
|
||||
/// Format: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"
|
||||
async fn fetch_token(&self, www_authenticate: &str, name: &str) -> Option<String> {
|
||||
async fn fetch_token(
|
||||
&self,
|
||||
www_authenticate: &str,
|
||||
name: &str,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let params = parse_www_authenticate(www_authenticate)?;
|
||||
|
||||
let realm = params.get("realm")?;
|
||||
@@ -77,9 +87,18 @@ impl DockerAuth {
|
||||
let scope = format!("repository:{}:pull", name);
|
||||
let url = format!("{}?service={}&scope={}", realm, service, scope);
|
||||
|
||||
let response = self.client.get(&url).send().await.ok()?;
|
||||
tracing::debug!(url = %url, "Fetching auth token");
|
||||
|
||||
let mut request = self.client.get(&url);
|
||||
if let Some(credentials) = basic_auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
tracing::debug!("Using basic auth for token request");
|
||||
}
|
||||
|
||||
let response = request.send().await.ok()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
tracing::warn!(status = %response.status(), "Token request failed");
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -91,44 +110,6 @@ impl DockerAuth {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Make an authenticated request to an upstream registry
|
||||
pub async fn fetch_with_auth(
|
||||
&self,
|
||||
url: &str,
|
||||
registry_url: &str,
|
||||
name: &str,
|
||||
) -> Result<reqwest::Response, ()> {
|
||||
// First try without auth
|
||||
let response = self.client.get(url).send().await.map_err(|_| ())?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Extract Www-Authenticate header
|
||||
let www_auth = response
|
||||
.headers()
|
||||
.get("www-authenticate")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
// Get token and retry
|
||||
if let Some(token) = self
|
||||
.get_token(registry_url, name, www_auth.as_deref())
|
||||
.await
|
||||
{
|
||||
return self
|
||||
.client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ());
|
||||
}
|
||||
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DockerAuth {
|
||||
|
||||
522
nora-registry/src/registry/go.rs
Normal file
522
nora-registry/src/registry/go.rs
Normal file
@@ -0,0 +1,522 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Go module proxy (GOPROXY protocol).
|
||||
//!
|
||||
//! Implements the 5 required endpoints:
|
||||
//! GET /go/{module}/@v/list — list known versions
|
||||
//! GET /go/{module}/@v/{ver}.info — version metadata (JSON)
|
||||
//! GET /go/{module}/@v/{ver}.mod — go.mod file
|
||||
//! GET /go/{module}/@v/{ver}.zip — module zip archive
|
||||
//! GET /go/{module}/@latest — latest version info
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::registry::{proxy_fetch, proxy_fetch_text, ProxyError};
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use percent_encoding::percent_decode;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/go/{*path}", get(handle))
|
||||
}
|
||||
|
||||
/// Main handler — parses the wildcard path and dispatches to the right logic.
|
||||
async fn handle(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||
// URL-decode the path: Go client sends %21 for !, Axum wildcard may not decode it
|
||||
let path = percent_decode(path.as_bytes())
|
||||
.decode_utf8()
|
||||
.map(|s| s.into_owned())
|
||||
.unwrap_or(path);
|
||||
|
||||
tracing::debug!(path = %path, "Go proxy request");
|
||||
|
||||
// Validate path: no traversal, no null bytes
|
||||
if !is_safe_path(&path) {
|
||||
tracing::debug!(path = %path, "Go proxy: unsafe path");
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
// Split: "github.com/!azure/sdk/@v/v1.0.0.info" → module + file
|
||||
let (module_encoded, file) = match split_go_path(&path) {
|
||||
Some(parts) => parts,
|
||||
None => {
|
||||
tracing::debug!(path = %path, "Go proxy: cannot split path");
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let storage_key = format!("go/{}", path);
|
||||
let content_type = content_type_for(&file);
|
||||
|
||||
// Mutable endpoints: @v/list and @latest can be refreshed from upstream
|
||||
let is_mutable = file == "@v/list" || file == "@latest";
|
||||
// Immutable: .info, .mod, .zip — once cached, never overwrite
|
||||
let is_immutable = !is_mutable;
|
||||
|
||||
// 1. Try local cache (for immutable files, this is authoritative)
|
||||
if let Ok(data) = state.storage.get(&storage_key).await {
|
||||
state.metrics.record_download("go");
|
||||
state.metrics.record_cache_hit();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::CacheHit,
|
||||
format_artifact(&module_encoded, &file),
|
||||
"go",
|
||||
"CACHE",
|
||||
));
|
||||
return with_content_type(data.to_vec(), content_type);
|
||||
}
|
||||
|
||||
// 2. Try upstream proxy
|
||||
let proxy_url = match &state.config.go.proxy {
|
||||
Some(url) => url.clone(),
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
// Validate module path encoding (but keep encoded for upstream — proxy.golang.org expects ! encoding)
|
||||
if decode_module_path(&module_encoded).is_err() {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
|
||||
let upstream_url = format!(
|
||||
"{}/{}",
|
||||
proxy_url.trim_end_matches('/'),
|
||||
format_upstream_path(&module_encoded, &file)
|
||||
);
|
||||
|
||||
// Use longer timeout for .zip files
|
||||
let timeout = if file.ends_with(".zip") {
|
||||
state.config.go.proxy_timeout_zip
|
||||
} else {
|
||||
state.config.go.proxy_timeout
|
||||
};
|
||||
|
||||
// Fetch: binary for .zip, text for everything else
|
||||
let data = if file.ends_with(".zip") {
|
||||
proxy_fetch(
|
||||
&state.http_client,
|
||||
&upstream_url,
|
||||
timeout,
|
||||
state.config.go.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
proxy_fetch_text(
|
||||
&state.http_client,
|
||||
&upstream_url,
|
||||
timeout,
|
||||
state.config.go.proxy_auth.as_deref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|s| s.into_bytes())
|
||||
};
|
||||
|
||||
match data {
|
||||
Ok(bytes) => {
|
||||
// Enforce size limit for .zip
|
||||
if file.ends_with(".zip") && bytes.len() as u64 > state.config.go.max_zip_size {
|
||||
tracing::warn!(
|
||||
module = module_encoded,
|
||||
size = bytes.len(),
|
||||
limit = state.config.go.max_zip_size,
|
||||
"Go module zip exceeds size limit"
|
||||
);
|
||||
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||
}
|
||||
|
||||
state.metrics.record_download("go");
|
||||
state.metrics.record_cache_miss();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::ProxyFetch,
|
||||
format_artifact(&module_encoded, &file),
|
||||
"go",
|
||||
"PROXY",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("proxy_fetch", "api", "", "go", ""));
|
||||
|
||||
// Background cache: immutable = put_if_absent, mutable = always overwrite
|
||||
let storage = state.storage.clone();
|
||||
let key = storage_key.clone();
|
||||
let data_clone = bytes.clone();
|
||||
tokio::spawn(async move {
|
||||
if is_immutable {
|
||||
// Only write if not already cached (immutability guarantee)
|
||||
if storage.stat(&key).await.is_none() {
|
||||
let _ = storage.put(&key, &data_clone).await;
|
||||
}
|
||||
} else {
|
||||
let _ = storage.put(&key, &data_clone).await;
|
||||
}
|
||||
});
|
||||
|
||||
state.repo_index.invalidate("go");
|
||||
with_content_type(bytes, content_type)
|
||||
}
|
||||
Err(ProxyError::NotFound) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
module = module_encoded,
|
||||
file = file,
|
||||
error = ?e,
|
||||
"Go upstream proxy error"
|
||||
);
|
||||
StatusCode::BAD_GATEWAY.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Module path encoding/decoding
|
||||
// ============================================================================
|
||||
|
||||
/// Decode Go module path: `!x` → `X`
|
||||
///
|
||||
/// Go module proxy spec requires uppercase letters to be encoded as `!`
|
||||
/// followed by the lowercase letter. Raw uppercase in encoded path is invalid.
|
||||
fn decode_module_path(encoded: &str) -> Result<String, ()> {
|
||||
let mut result = String::with_capacity(encoded.len());
|
||||
let mut chars = encoded.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '!' {
|
||||
match chars.next() {
|
||||
Some(next) if next.is_ascii_lowercase() => {
|
||||
result.push(next.to_ascii_uppercase());
|
||||
}
|
||||
_ => return Err(()),
|
||||
}
|
||||
} else if c.is_ascii_uppercase() {
|
||||
// Raw uppercase in encoded path is invalid per spec
|
||||
return Err(());
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Encode Go module path: `X` → `!x`
|
||||
#[cfg(test)]
|
||||
fn encode_module_path(path: &str) -> String {
|
||||
let mut result = String::with_capacity(path.len() + 8);
|
||||
for c in path.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
result.push('!');
|
||||
result.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Path parsing helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Split Go path into (encoded_module, file).
|
||||
///
|
||||
/// Examples:
|
||||
/// "github.com/user/repo/@v/v1.0.0.info" → ("github.com/user/repo", "@v/v1.0.0.info")
|
||||
/// "github.com/user/repo/v2/@v/list" → ("github.com/user/repo/v2", "@v/list")
|
||||
/// "github.com/user/repo/@latest" → ("github.com/user/repo", "@latest")
|
||||
fn split_go_path(path: &str) -> Option<(String, String)> {
|
||||
// Try @latest first (it's simpler)
|
||||
if let Some(pos) = path.rfind("/@latest") {
|
||||
let module = &path[..pos];
|
||||
if !module.is_empty() {
|
||||
return Some((module.to_string(), "@latest".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Try @v/ — find the last occurrence (handles /v2/@v/ correctly)
|
||||
if let Some(pos) = path.rfind("/@v/") {
|
||||
let module = &path[..pos];
|
||||
let file = &path[pos + 1..]; // "@v/..."
|
||||
if !module.is_empty() && !file.is_empty() {
|
||||
return Some((module.to_string(), file.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Path validation: no traversal attacks
|
||||
fn is_safe_path(path: &str) -> bool {
|
||||
!path.contains("..")
|
||||
&& !path.starts_with('/')
|
||||
&& !path.contains("//")
|
||||
&& !path.contains('\0')
|
||||
&& !path.is_empty()
|
||||
}
|
||||
|
||||
/// Content-Type for Go proxy responses
|
||||
fn content_type_for(file: &str) -> &'static str {
|
||||
if file.ends_with(".info") || file == "@latest" {
|
||||
"application/json"
|
||||
} else if file.ends_with(".zip") {
|
||||
"application/zip"
|
||||
} else {
|
||||
// .mod, @v/list
|
||||
"text/plain; charset=utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build upstream URL path (uses decoded module path)
|
||||
fn format_upstream_path(module_decoded: &str, file: &str) -> String {
|
||||
format!("{}/{}", module_decoded, file)
|
||||
}
|
||||
|
||||
/// Human-readable artifact name for activity log
|
||||
fn format_artifact(module: &str, file: &str) -> String {
|
||||
if file == "@v/list" || file == "@latest" {
|
||||
format!("{} {}", module, file)
|
||||
} else if let Some(version_file) = file.strip_prefix("@v/") {
|
||||
// "v1.0.0.info" → "module@v1.0.0"
|
||||
let version = version_file
|
||||
.rsplit_once('.')
|
||||
.map(|(v, _ext)| v)
|
||||
.unwrap_or(version_file);
|
||||
format!("{}@{}", module, version)
|
||||
} else {
|
||||
format!("{}/{}", module, file)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build response with Content-Type header
|
||||
fn with_content_type(data: Vec<u8>, content_type: &'static str) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static(content_type))],
|
||||
data,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Encoding/decoding ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_decode_azure() {
|
||||
assert_eq!(
|
||||
decode_module_path("github.com/!azure/sdk").unwrap(),
|
||||
"github.com/Azure/sdk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_multiple_uppercase() {
|
||||
assert_eq!(
|
||||
decode_module_path("!google!cloud!platform/foo").unwrap(),
|
||||
"GoogleCloudPlatform/foo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_no_uppercase() {
|
||||
assert_eq!(
|
||||
decode_module_path("github.com/user/repo").unwrap(),
|
||||
"github.com/user/repo"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_invalid_bang_at_end() {
|
||||
assert!(decode_module_path("foo!").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_invalid_bang_followed_by_uppercase() {
|
||||
assert!(decode_module_path("foo!A").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_raw_uppercase_is_invalid() {
|
||||
assert!(decode_module_path("github.com/Azure/sdk").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_roundtrip() {
|
||||
let original = "github.com/Azure/azure-sdk-for-go";
|
||||
let encoded = encode_module_path(original);
|
||||
assert_eq!(encoded, "github.com/!azure/azure-sdk-for-go");
|
||||
assert_eq!(decode_module_path(&encoded).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_no_change() {
|
||||
assert_eq!(
|
||||
encode_module_path("github.com/user/repo"),
|
||||
"github.com/user/repo"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path splitting ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_split_version_info() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/@v/v1.0.0.info").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/v1.0.0.info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_version_list() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/@v/list").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_latest() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/@latest").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@latest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_major_version_suffix() {
|
||||
let (module, file) = split_go_path("github.com/user/repo/v2/@v/list").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo/v2");
|
||||
assert_eq!(file, "@v/list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_incompatible_version() {
|
||||
let (module, file) =
|
||||
split_go_path("github.com/user/repo/@v/v4.1.2+incompatible.info").unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/v4.1.2+incompatible.info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_pseudo_version() {
|
||||
let (module, file) =
|
||||
split_go_path("github.com/user/repo/@v/v0.0.0-20210101000000-abcdef123456.info")
|
||||
.unwrap();
|
||||
assert_eq!(module, "github.com/user/repo");
|
||||
assert_eq!(file, "@v/v0.0.0-20210101000000-abcdef123456.info");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_no_at() {
|
||||
assert!(split_go_path("github.com/user/repo/v1.0.0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_empty_module() {
|
||||
assert!(split_go_path("/@v/list").is_none());
|
||||
}
|
||||
|
||||
// ── Path safety ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_safe_path_normal() {
|
||||
assert!(is_safe_path("github.com/user/repo/@v/list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_traversal() {
|
||||
assert!(!is_safe_path("../../etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_absolute() {
|
||||
assert!(!is_safe_path("/etc/passwd"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_double_slash() {
|
||||
assert!(!is_safe_path("github.com//evil/@v/list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_null() {
|
||||
assert!(!is_safe_path("github.com/\0evil/@v/list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_empty() {
|
||||
assert!(!is_safe_path(""));
|
||||
}
|
||||
|
||||
// ── Content-Type ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_content_type_info() {
|
||||
assert_eq!(content_type_for("@v/v1.0.0.info"), "application/json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_latest() {
|
||||
assert_eq!(content_type_for("@latest"), "application/json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_zip() {
|
||||
assert_eq!(content_type_for("@v/v1.0.0.zip"), "application/zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_mod() {
|
||||
assert_eq!(
|
||||
content_type_for("@v/v1.0.0.mod"),
|
||||
"text/plain; charset=utf-8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_type_list() {
|
||||
assert_eq!(content_type_for("@v/list"), "text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
// ── Artifact formatting ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_version() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@v/v1.0.0.info"),
|
||||
"github.com/user/repo@v1.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_list() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@v/list"),
|
||||
"github.com/user/repo @v/list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_latest() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@latest"),
|
||||
"github.com/user/repo @latest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_artifact_zip() {
|
||||
assert_eq!(
|
||||
format_artifact("github.com/user/repo", "@v/v1.0.0.zip"),
|
||||
"github.com/user/repo@v1.0.0"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::registry::proxy_fetch;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
@@ -9,7 +14,6 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
@@ -20,7 +24,6 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||
let key = format!("maven/{}", path);
|
||||
|
||||
// Extract artifact name for logging (last 2-3 path components)
|
||||
let artifact_name = path
|
||||
.split('/')
|
||||
.rev()
|
||||
@@ -31,7 +34,6 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
// Try local storage first
|
||||
if let Ok(data) = state.storage.get(&key).await {
|
||||
state.metrics.record_download("maven");
|
||||
state.metrics.record_cache_hit();
|
||||
@@ -41,14 +43,23 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
||||
"maven",
|
||||
"CACHE",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("cache_hit", "api", "", "maven", ""));
|
||||
return with_content_type(&path, data).into_response();
|
||||
}
|
||||
|
||||
// Try proxy servers
|
||||
for proxy_url in &state.config.maven.proxies {
|
||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||
for proxy in &state.config.maven.proxies {
|
||||
let url = format!("{}/{}", proxy.url().trim_end_matches('/'), path);
|
||||
|
||||
match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
|
||||
match proxy_fetch(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.maven.proxy_timeout,
|
||||
proxy.auth(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
state.metrics.record_download("maven");
|
||||
state.metrics.record_cache_miss();
|
||||
@@ -58,8 +69,10 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
||||
"maven",
|
||||
"PROXY",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("proxy_fetch", "api", "", "maven", ""));
|
||||
|
||||
// Cache in local storage (fire and forget)
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.clone();
|
||||
let data_clone = data.clone();
|
||||
@@ -67,6 +80,8 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
state.repo_index.invalidate("maven");
|
||||
|
||||
return with_content_type(&path, data.into()).into_response();
|
||||
}
|
||||
Err(_) => continue,
|
||||
@@ -83,7 +98,6 @@ async fn upload(
|
||||
) -> StatusCode {
|
||||
let key = format!("maven/{}", path);
|
||||
|
||||
// Extract artifact name for logging
|
||||
let artifact_name = path
|
||||
.split('/')
|
||||
.rev()
|
||||
@@ -103,27 +117,16 @@ async fn upload(
|
||||
"maven",
|
||||
"LOCAL",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("push", "api", "", "maven", ""));
|
||||
state.repo_index.invalidate("maven");
|
||||
StatusCode::CREATED
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
||||
}
|
||||
|
||||
fn with_content_type(
|
||||
path: &str,
|
||||
data: Bytes,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
mod cargo_registry;
|
||||
pub mod docker;
|
||||
pub mod docker_auth;
|
||||
mod go;
|
||||
mod maven;
|
||||
mod npm;
|
||||
mod pypi;
|
||||
@@ -9,7 +13,132 @@ mod raw;
|
||||
pub use cargo_registry::routes as cargo_routes;
|
||||
pub use docker::routes as docker_routes;
|
||||
pub use docker_auth::DockerAuth;
|
||||
pub use go::routes as go_routes;
|
||||
pub use maven::routes as maven_routes;
|
||||
pub use npm::routes as npm_routes;
|
||||
pub use pypi::routes as pypi_routes;
|
||||
pub use raw::routes as raw_routes;
|
||||
|
||||
use crate::config::basic_auth_header;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Fetch from upstream proxy with timeout and 1 retry.
|
||||
///
|
||||
/// On transient errors (timeout, connection reset), retries once after a short delay.
|
||||
/// Non-retryable errors (4xx) fail immediately.
|
||||
pub(crate) async fn proxy_fetch(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
auth: Option<&str>,
|
||||
) -> Result<Vec<u8>, ProxyError> {
|
||||
for attempt in 0..2 {
|
||||
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));
|
||||
}
|
||||
|
||||
match request.send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
return response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| ProxyError::Network(e.to_string()));
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
// Don't retry client errors (4xx)
|
||||
if (400..500).contains(&status) {
|
||||
return Err(ProxyError::NotFound);
|
||||
}
|
||||
// Server error (5xx) — retry
|
||||
if attempt == 0 {
|
||||
tracing::debug!(url, status, "upstream 5xx, retrying in 1s");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(ProxyError::Upstream(status));
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt == 0 {
|
||||
tracing::debug!(url, error = %e, "upstream error, retrying in 1s");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(ProxyError::Network(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ProxyError::Network("max retries exceeded".into()))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) enum ProxyError {
|
||||
NotFound,
|
||||
Upstream(u16),
|
||||
Network(String),
|
||||
}
|
||||
|
||||
/// Fetch text content from upstream proxy with timeout and 1 retry.
|
||||
/// Same as proxy_fetch but returns String (for HTML pages like PyPI simple index).
|
||||
pub(crate) async fn proxy_fetch_text(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
auth: Option<&str>,
|
||||
extra_headers: Option<(&str, &str)>,
|
||||
) -> Result<String, ProxyError> {
|
||||
for attempt in 0..2 {
|
||||
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));
|
||||
}
|
||||
if let Some((key, val)) = extra_headers {
|
||||
request = request.header(key, val);
|
||||
}
|
||||
|
||||
match request.send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
return response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Network(e.to_string()));
|
||||
}
|
||||
let status = response.status().as_u16();
|
||||
if (400..500).contains(&status) {
|
||||
return Err(ProxyError::NotFound);
|
||||
}
|
||||
if attempt == 0 {
|
||||
tracing::debug!(url, status, "upstream 5xx, retrying in 1s");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(ProxyError::Upstream(status));
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt == 0 {
|
||||
tracing::debug!(url, error = %e, "upstream error, retrying in 1s");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
continue;
|
||||
}
|
||||
return Err(ProxyError::Network(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ProxyError::Network("max retries exceeded".into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_fetch_invalid_url() {
|
||||
let client = reqwest::Client::new();
|
||||
let result = proxy_fetch(&client, "http://127.0.0.1:1/nonexistent", 2, None).await;
|
||||
assert!(matches!(result, Err(ProxyError::Network(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::registry::proxy_fetch;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
routing::{get, put},
|
||||
Router,
|
||||
};
|
||||
use base64::Engine;
|
||||
use sha2::Digest;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/npm/{*path}", get(handle_request))
|
||||
Router::new()
|
||||
.route("/npm/{*path}", get(handle_request))
|
||||
.route("/npm/{*path}", put(handle_publish))
|
||||
}
|
||||
|
||||
/// Build NORA base URL from config (for URL rewriting)
|
||||
fn nora_base_url(state: &AppState) -> String {
|
||||
state.config.server.public_url.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"http://{}:{}",
|
||||
state.config.server.host, state.config.server.port
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrite tarball URLs in npm metadata to point to NORA.
|
||||
///
|
||||
/// Replaces upstream registry URLs (e.g. `https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz`)
|
||||
/// with NORA URLs (e.g. `http://nora:5000/npm/lodash/-/lodash-4.17.21.tgz`).
|
||||
fn rewrite_tarball_urls(data: &[u8], nora_base: &str, upstream_url: &str) -> Result<Vec<u8>, ()> {
|
||||
let mut json: serde_json::Value = serde_json::from_slice(data).map_err(|_| ())?;
|
||||
|
||||
let upstream_trimmed = upstream_url.trim_end_matches('/');
|
||||
let nora_npm_base = format!("{}/npm", nora_base.trim_end_matches('/'));
|
||||
|
||||
if let Some(versions) = json.get_mut("versions").and_then(|v| v.as_object_mut()) {
|
||||
for (_ver, version_data) in versions.iter_mut() {
|
||||
if let Some(tarball_url) = version_data
|
||||
.get("dist")
|
||||
.and_then(|d| d.get("tarball"))
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let rewritten = tarball_url.replace(upstream_trimmed, &nora_npm_base);
|
||||
if let Some(dist) = version_data.get_mut("dist") {
|
||||
dist["tarball"] = serde_json::Value::String(rewritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::to_vec(&json).map_err(|_| ())
|
||||
}
|
||||
|
||||
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
||||
// Determine if this is a tarball request or metadata request
|
||||
let is_tarball = path.contains("/-/");
|
||||
|
||||
let key = if is_tarball {
|
||||
let parts: Vec<&str> = path.split("/-/").collect();
|
||||
let parts: Vec<&str> = path.splitn(2, "/-/").collect();
|
||||
if parts.len() == 2 {
|
||||
format!("npm/{}/tarballs/{}", parts[0], parts[1])
|
||||
} else {
|
||||
@@ -30,40 +76,89 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
||||
format!("npm/{}/metadata.json", path)
|
||||
};
|
||||
|
||||
// Extract package name for logging
|
||||
let package_name = if is_tarball {
|
||||
path.split("/-/").next().unwrap_or(&path).to_string()
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
|
||||
// Try local storage first
|
||||
// --- Cache hit path ---
|
||||
if let Ok(data) = state.storage.get(&key).await {
|
||||
if is_tarball {
|
||||
state.metrics.record_download("npm");
|
||||
state.metrics.record_cache_hit();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::CacheHit,
|
||||
package_name,
|
||||
"npm",
|
||||
"CACHE",
|
||||
));
|
||||
// Metadata TTL: if stale, try to refetch from upstream
|
||||
if !is_tarball {
|
||||
let ttl = state.config.npm.metadata_ttl;
|
||||
if ttl > 0 {
|
||||
if let Some(meta) = state.storage.stat(&key).await {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
if now.saturating_sub(meta.modified) > ttl {
|
||||
if let Some(fresh) = refetch_metadata(&state, &path, &key).await {
|
||||
return with_content_type(false, fresh.into()).into_response();
|
||||
}
|
||||
// Upstream failed — serve stale cache
|
||||
}
|
||||
}
|
||||
}
|
||||
return with_content_type(false, data).into_response();
|
||||
}
|
||||
return with_content_type(is_tarball, data).into_response();
|
||||
|
||||
// Tarball: integrity check if hash exists
|
||||
let hash_key = format!("{}.sha256", key);
|
||||
if let Ok(stored_hash) = state.storage.get(&hash_key).await {
|
||||
let computed = format!("{:x}", sha2::Sha256::digest(&data));
|
||||
let expected = String::from_utf8_lossy(&stored_hash);
|
||||
if computed != expected.as_ref() {
|
||||
tracing::error!(
|
||||
key = %key,
|
||||
expected = %expected,
|
||||
computed = %computed,
|
||||
"SECURITY: npm tarball integrity check FAILED — possible tampering"
|
||||
);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Integrity check failed")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
state.metrics.record_download("npm");
|
||||
state.metrics.record_cache_hit();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::CacheHit,
|
||||
package_name,
|
||||
"npm",
|
||||
"CACHE",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("cache_hit", "api", "", "npm", ""));
|
||||
return with_content_type(true, data).into_response();
|
||||
}
|
||||
|
||||
// Try proxy if configured
|
||||
// --- Proxy fetch path ---
|
||||
if let Some(proxy_url) = &state.config.npm.proxy {
|
||||
let url = if is_tarball {
|
||||
// Tarball URL: https://registry.npmjs.org/package/-/package-version.tgz
|
||||
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
||||
} else {
|
||||
// Metadata URL: https://registry.npmjs.org/package
|
||||
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
||||
};
|
||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||
|
||||
if let Ok(data) = proxy_fetch(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.npm.proxy_timeout,
|
||||
state.config.npm.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let data_to_cache;
|
||||
let data_to_serve;
|
||||
|
||||
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
|
||||
if is_tarball {
|
||||
// Compute and store sha256
|
||||
let hash = format!("{:x}", sha2::Sha256::digest(&data));
|
||||
let hash_key = format!("{}.sha256", key);
|
||||
let storage = state.storage.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&hash_key, hash.as_bytes()).await;
|
||||
});
|
||||
|
||||
state.metrics.record_download("npm");
|
||||
state.metrics.record_cache_miss();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
@@ -72,38 +167,257 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
||||
"npm",
|
||||
"PROXY",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("proxy_fetch", "api", "", "npm", ""));
|
||||
|
||||
data_to_cache = data.clone();
|
||||
data_to_serve = data;
|
||||
} else {
|
||||
// Metadata: rewrite tarball URLs to point to NORA
|
||||
let nora_base = nora_base_url(&state);
|
||||
let rewritten = rewrite_tarball_urls(&data, &nora_base, proxy_url)
|
||||
.unwrap_or_else(|_| data.clone());
|
||||
|
||||
data_to_cache = rewritten.clone();
|
||||
data_to_serve = rewritten;
|
||||
}
|
||||
|
||||
// Cache in local storage (fire and forget)
|
||||
// Cache in background
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.clone();
|
||||
let data_clone = data.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
let _ = storage.put(&key_clone, &data_to_cache).await;
|
||||
});
|
||||
|
||||
return with_content_type(is_tarball, data.into()).into_response();
|
||||
if is_tarball {
|
||||
state.repo_index.invalidate("npm");
|
||||
}
|
||||
|
||||
return with_content_type(is_tarball, data_to_serve.into()).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
/// Refetch metadata from upstream, rewrite URLs, update cache.
|
||||
/// Returns None if upstream is unavailable (caller serves stale cache).
|
||||
async fn refetch_metadata(state: &Arc<AppState>, path: &str, key: &str) -> Option<Vec<u8>> {
|
||||
let proxy_url = state.config.npm.proxy.as_ref()?;
|
||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||
|
||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
||||
let data = proxy_fetch(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.npm.proxy_timeout,
|
||||
state.config.npm.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
let nora_base = nora_base_url(state);
|
||||
let rewritten =
|
||||
rewrite_tarball_urls(&data, &nora_base, proxy_url).unwrap_or_else(|_| data.clone());
|
||||
|
||||
let storage = state.storage.clone();
|
||||
let key_clone = key.to_string();
|
||||
let cache_data = rewritten.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = storage.put(&key_clone, &cache_data).await;
|
||||
});
|
||||
|
||||
Some(rewritten)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// npm publish
|
||||
// ============================================================================
|
||||
|
||||
/// Validate attachment filename: only safe characters, no path traversal.
|
||||
fn is_valid_attachment_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& !name.contains("..")
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& !name.contains('\0')
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@'))
|
||||
}
|
||||
|
||||
async fn handle_publish(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(path): Path<String>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let package_name = path;
|
||||
|
||||
let payload: serde_json::Value = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
// Security: verify payload name matches URL path
|
||||
if let Some(payload_name) = payload.get("name").and_then(|n| n.as_str()) {
|
||||
if payload_name != package_name {
|
||||
tracing::warn!(
|
||||
url_name = %package_name,
|
||||
payload_name = %payload_name,
|
||||
"SECURITY: npm publish name mismatch — possible spoofing attempt"
|
||||
);
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Package name in URL does not match payload",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
||||
let attachments = match payload.get("_attachments").and_then(|a| a.as_object()) {
|
||||
Some(a) => a,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing _attachments").into_response(),
|
||||
};
|
||||
|
||||
let new_versions = match payload.get("versions").and_then(|v| v.as_object()) {
|
||||
Some(v) => v,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing versions").into_response(),
|
||||
};
|
||||
|
||||
// Load or create metadata
|
||||
let metadata_key = format!("npm/{}/metadata.json", package_name);
|
||||
let mut metadata = if let Ok(existing) = state.storage.get(&metadata_key).await {
|
||||
serde_json::from_slice::<serde_json::Value>(&existing)
|
||||
.unwrap_or_else(|_| serde_json::json!({}))
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
// Version immutability
|
||||
if let Some(existing_versions) = metadata.get("versions").and_then(|v| v.as_object()) {
|
||||
for ver in new_versions.keys() {
|
||||
if existing_versions.contains_key(ver) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
format!("Version {} already exists", ver),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store tarballs
|
||||
for (filename, attachment_data) in attachments {
|
||||
if !is_valid_attachment_name(filename) {
|
||||
tracing::warn!(
|
||||
filename = %filename,
|
||||
package = %package_name,
|
||||
"SECURITY: npm publish rejected — invalid attachment filename"
|
||||
);
|
||||
return (StatusCode::BAD_REQUEST, "Invalid attachment filename").into_response();
|
||||
}
|
||||
|
||||
let base64_data = match attachment_data.get("data").and_then(|d| d.as_str()) {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let tarball_bytes = match base64::engine::general_purpose::STANDARD.decode(base64_data) {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid base64 in attachment").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let tarball_key = format!("npm/{}/tarballs/{}", package_name, filename);
|
||||
if state
|
||||
.storage
|
||||
.put(&tarball_key, &tarball_bytes)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
|
||||
// Store sha256
|
||||
let hash = format!("{:x}", sha2::Sha256::digest(&tarball_bytes));
|
||||
let hash_key = format!("{}.sha256", tarball_key);
|
||||
let _ = state.storage.put(&hash_key, hash.as_bytes()).await;
|
||||
}
|
||||
|
||||
// Merge versions
|
||||
let meta_obj = metadata.as_object_mut().unwrap();
|
||||
let stored_versions = meta_obj.entry("versions").or_insert(serde_json::json!({}));
|
||||
if let Some(sv) = stored_versions.as_object_mut() {
|
||||
for (ver, ver_data) in new_versions {
|
||||
sv.insert(ver.clone(), ver_data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Copy standard fields
|
||||
for field in &["name", "_id", "description", "readme", "license"] {
|
||||
if let Some(val) = payload.get(*field) {
|
||||
meta_obj.insert(field.to_string(), val.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Merge dist-tags
|
||||
if let Some(new_dist_tags) = payload.get("dist-tags").and_then(|d| d.as_object()) {
|
||||
let stored_dist_tags = meta_obj.entry("dist-tags").or_insert(serde_json::json!({}));
|
||||
if let Some(sdt) = stored_dist_tags.as_object_mut() {
|
||||
for (tag, ver) in new_dist_tags {
|
||||
sdt.insert(tag.clone(), ver.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite tarball URLs for published packages
|
||||
let nora_base = nora_base_url(&state);
|
||||
if let Some(versions) = metadata.get_mut("versions").and_then(|v| v.as_object_mut()) {
|
||||
for (ver, ver_data) in versions.iter_mut() {
|
||||
if let Some(dist) = ver_data.get_mut("dist") {
|
||||
let short_name = package_name.split('/').next_back().unwrap_or(&package_name);
|
||||
let tarball_url = format!(
|
||||
"{}/npm/{}/-/{}-{}.tgz",
|
||||
nora_base.trim_end_matches('/'),
|
||||
package_name,
|
||||
short_name,
|
||||
ver
|
||||
);
|
||||
dist["tarball"] = serde_json::Value::String(tarball_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store metadata
|
||||
match serde_json::to_vec(&metadata) {
|
||||
Ok(bytes) => {
|
||||
if state.storage.put(&metadata_key, &bytes).await.is_err() {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
}
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
|
||||
state.metrics.record_upload("npm");
|
||||
state.activity.push(ActivityEntry::new(
|
||||
ActionType::Push,
|
||||
package_name,
|
||||
"npm",
|
||||
"LOCAL",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("push", "api", "", "npm", ""));
|
||||
state.repo_index.invalidate("npm");
|
||||
|
||||
StatusCode::CREATED.into_response()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn with_content_type(
|
||||
is_tarball: bool,
|
||||
data: Bytes,
|
||||
@@ -116,3 +430,129 @@ fn with_content_type(
|
||||
|
||||
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_tarball_urls_regular_package() {
|
||||
let metadata = serde_json::json!({
|
||||
"name": "lodash",
|
||||
"versions": {
|
||||
"4.17.21": {
|
||||
"dist": {
|
||||
"tarball": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"shasum": "abc123"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let data = serde_json::to_vec(&metadata).unwrap();
|
||||
let result =
|
||||
rewrite_tarball_urls(&data, "http://nora:5000", "https://registry.npmjs.org").unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
json["versions"]["4.17.21"]["dist"]["tarball"],
|
||||
"http://nora:5000/npm/lodash/-/lodash-4.17.21.tgz"
|
||||
);
|
||||
assert_eq!(json["versions"]["4.17.21"]["dist"]["shasum"], "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_tarball_urls_scoped_package() {
|
||||
let metadata = serde_json::json!({
|
||||
"name": "@babel/core",
|
||||
"versions": {
|
||||
"7.26.0": {
|
||||
"dist": {
|
||||
"tarball": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz",
|
||||
"integrity": "sha512-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let data = serde_json::to_vec(&metadata).unwrap();
|
||||
let result =
|
||||
rewrite_tarball_urls(&data, "http://nora:5000", "https://registry.npmjs.org").unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
json["versions"]["7.26.0"]["dist"]["tarball"],
|
||||
"http://nora:5000/npm/@babel/core/-/core-7.26.0.tgz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_tarball_urls_multiple_versions() {
|
||||
let metadata = serde_json::json!({
|
||||
"name": "express",
|
||||
"versions": {
|
||||
"4.18.2": { "dist": { "tarball": "https://registry.npmjs.org/express/-/express-4.18.2.tgz" } },
|
||||
"4.19.0": { "dist": { "tarball": "https://registry.npmjs.org/express/-/express-4.19.0.tgz" } }
|
||||
}
|
||||
});
|
||||
let data = serde_json::to_vec(&metadata).unwrap();
|
||||
let result = rewrite_tarball_urls(
|
||||
&data,
|
||||
"https://demo.getnora.io",
|
||||
"https://registry.npmjs.org",
|
||||
)
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
json["versions"]["4.18.2"]["dist"]["tarball"],
|
||||
"https://demo.getnora.io/npm/express/-/express-4.18.2.tgz"
|
||||
);
|
||||
assert_eq!(
|
||||
json["versions"]["4.19.0"]["dist"]["tarball"],
|
||||
"https://demo.getnora.io/npm/express/-/express-4.19.0.tgz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_tarball_urls_no_versions() {
|
||||
let metadata = serde_json::json!({ "name": "empty-pkg" });
|
||||
let data = serde_json::to_vec(&metadata).unwrap();
|
||||
let result =
|
||||
rewrite_tarball_urls(&data, "http://nora:5000", "https://registry.npmjs.org").unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&result).unwrap();
|
||||
assert_eq!(json["name"], "empty-pkg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_invalid_json() {
|
||||
assert!(rewrite_tarball_urls(
|
||||
b"not json",
|
||||
"http://nora:5000",
|
||||
"https://registry.npmjs.org"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_attachment_names() {
|
||||
assert!(is_valid_attachment_name("lodash-4.17.21.tgz"));
|
||||
assert!(is_valid_attachment_name("core-7.26.0.tgz"));
|
||||
assert!(is_valid_attachment_name("my_package-1.0.0.tgz"));
|
||||
assert!(is_valid_attachment_name("@scope-pkg-1.0.0.tgz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_traversal_attachment_names() {
|
||||
assert!(!is_valid_attachment_name("../../etc/passwd"));
|
||||
assert!(!is_valid_attachment_name(
|
||||
"../docker/nginx/manifests/latest.json"
|
||||
));
|
||||
assert!(!is_valid_attachment_name("foo/bar.tgz"));
|
||||
assert!(!is_valid_attachment_name("foo\\bar.tgz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_and_null_attachment_names() {
|
||||
assert!(!is_valid_attachment_name(""));
|
||||
assert!(!is_valid_attachment_name("foo\0bar.tgz"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::registry::{proxy_fetch, proxy_fetch_text};
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
@@ -8,7 +13,6 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
@@ -82,7 +86,15 @@ async fn package_versions(
|
||||
if let Some(proxy_url) = &state.config.pypi.proxy {
|
||||
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||
|
||||
if let Ok(html) = fetch_package_page(&url, state.config.pypi.proxy_timeout).await {
|
||||
if let Ok(html) = proxy_fetch_text(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.pypi.proxy_timeout,
|
||||
state.config.pypi.proxy_auth.as_deref(),
|
||||
Some(("Accept", "text/html")),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Rewrite URLs in the HTML to point to our registry
|
||||
let rewritten = rewrite_pypi_links(&html, &normalized);
|
||||
return (StatusCode::OK, Html(rewritten)).into_response();
|
||||
@@ -110,6 +122,9 @@ async fn download_file(
|
||||
"pypi",
|
||||
"CACHE",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("cache_hit", "api", "", "pypi", ""));
|
||||
|
||||
let content_type = if filename.ends_with(".whl") {
|
||||
"application/zip"
|
||||
@@ -127,10 +142,25 @@ async fn download_file(
|
||||
// First, fetch the package page to find the actual download URL
|
||||
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||
|
||||
if let Ok(html) = fetch_package_page(&page_url, state.config.pypi.proxy_timeout).await {
|
||||
if let Ok(html) = proxy_fetch_text(
|
||||
&state.http_client,
|
||||
&page_url,
|
||||
state.config.pypi.proxy_timeout,
|
||||
state.config.pypi.proxy_auth.as_deref(),
|
||||
Some(("Accept", "text/html")),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Find the URL for this specific file
|
||||
if let Some(file_url) = find_file_url(&html, &filename) {
|
||||
if let Ok(data) = fetch_file(&file_url, state.config.pypi.proxy_timeout).await {
|
||||
if let Ok(data) = proxy_fetch(
|
||||
&state.http_client,
|
||||
&file_url,
|
||||
state.config.pypi.proxy_timeout,
|
||||
state.config.pypi.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
state.metrics.record_download("pypi");
|
||||
state.metrics.record_cache_miss();
|
||||
state.activity.push(ActivityEntry::new(
|
||||
@@ -139,6 +169,9 @@ async fn download_file(
|
||||
"pypi",
|
||||
"PROXY",
|
||||
));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("proxy_fetch", "api", "", "pypi", ""));
|
||||
|
||||
// Cache in local storage
|
||||
let storage = state.storage.clone();
|
||||
@@ -148,6 +181,8 @@ async fn download_file(
|
||||
let _ = storage.put(&key_clone, &data_clone).await;
|
||||
});
|
||||
|
||||
state.repo_index.invalidate("pypi");
|
||||
|
||||
let content_type = if filename.ends_with(".whl") {
|
||||
"application/zip"
|
||||
} else if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
@@ -171,43 +206,6 @@ fn normalize_name(name: &str) -> String {
|
||||
name.to_lowercase().replace(['-', '_', '.'], "-")
|
||||
}
|
||||
|
||||
/// Fetch package page from upstream
|
||||
async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("Accept", "text/html")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
response.text().await.map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Fetch file from upstream
|
||||
async fn fetch_file(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|_| ())?;
|
||||
|
||||
let response = client.get(url).send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Rewrite PyPI links to point to our registry
|
||||
fn rewrite_pypi_links(html: &str, package_name: &str) -> String {
|
||||
// Simple regex-free approach: find href="..." and rewrite
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
@@ -32,6 +36,9 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
||||
state
|
||||
.activity
|
||||
.push(ActivityEntry::new(ActionType::Pull, path, "raw", "LOCAL"));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("pull", "api", "", "raw", ""));
|
||||
|
||||
// Guess content type from extension
|
||||
let content_type = guess_content_type(&key);
|
||||
@@ -69,6 +76,9 @@ async fn upload(
|
||||
state
|
||||
.activity
|
||||
.push(ActivityEntry::new(ActionType::Push, path, "raw", "LOCAL"));
|
||||
state
|
||||
.audit
|
||||
.log(AuditEntry::new("push", "api", "", "raw", ""));
|
||||
StatusCode::CREATED.into_response()
|
||||
}
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
|
||||
588
nora-registry/src/repo_index.rs
Normal file
588
nora-registry/src/repo_index.rs
Normal file
@@ -0,0 +1,588 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! In-memory repository index with lazy rebuild on invalidation.
|
||||
//!
|
||||
//! Design:
|
||||
//! - Rebuild happens ONLY on write operations, not TTL
|
||||
//! - Double-checked locking prevents duplicate rebuilds
|
||||
//! - Arc<Vec> for zero-cost reads
|
||||
//! - Single rebuild at a time per registry (rebuild_lock)
|
||||
|
||||
use crate::storage::Storage;
|
||||
use crate::ui::components::format_timestamp;
|
||||
use parking_lot::RwLock;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tracing::info;
|
||||
|
||||
/// Repository info for UI display
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RepoInfo {
|
||||
pub name: String,
|
||||
pub versions: usize,
|
||||
pub size: u64,
|
||||
pub updated: String,
|
||||
}
|
||||
|
||||
/// Index for a single registry type
|
||||
pub struct RegistryIndex {
|
||||
data: RwLock<Arc<Vec<RepoInfo>>>,
|
||||
dirty: AtomicBool,
|
||||
rebuild_lock: AsyncMutex<()>,
|
||||
}
|
||||
|
||||
impl RegistryIndex {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
data: RwLock::new(Arc::new(Vec::new())),
|
||||
dirty: AtomicBool::new(true),
|
||||
rebuild_lock: AsyncMutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark index as needing rebuild
|
||||
pub fn invalidate(&self) {
|
||||
self.dirty.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn is_dirty(&self) -> bool {
|
||||
self.dirty.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn get_cached(&self) -> Arc<Vec<RepoInfo>> {
|
||||
Arc::clone(&self.data.read())
|
||||
}
|
||||
|
||||
fn set(&self, data: Vec<RepoInfo>) {
|
||||
*self.data.write() = Arc::new(data);
|
||||
self.dirty.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.data.read().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RegistryIndex {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Main repository index for all registries
|
||||
pub struct RepoIndex {
|
||||
pub docker: RegistryIndex,
|
||||
pub maven: RegistryIndex,
|
||||
pub npm: RegistryIndex,
|
||||
pub cargo: RegistryIndex,
|
||||
pub pypi: RegistryIndex,
|
||||
pub go: RegistryIndex,
|
||||
pub raw: RegistryIndex,
|
||||
}
|
||||
|
||||
impl RepoIndex {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
docker: RegistryIndex::new(),
|
||||
maven: RegistryIndex::new(),
|
||||
npm: RegistryIndex::new(),
|
||||
cargo: RegistryIndex::new(),
|
||||
pypi: RegistryIndex::new(),
|
||||
go: RegistryIndex::new(),
|
||||
raw: RegistryIndex::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate a specific registry index
|
||||
pub fn invalidate(&self, registry: &str) {
|
||||
match registry {
|
||||
"docker" => self.docker.invalidate(),
|
||||
"maven" => self.maven.invalidate(),
|
||||
"npm" => self.npm.invalidate(),
|
||||
"cargo" => self.cargo.invalidate(),
|
||||
"pypi" => self.pypi.invalidate(),
|
||||
"go" => self.go.invalidate(),
|
||||
"raw" => self.raw.invalidate(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get index with double-checked locking (prevents race condition)
|
||||
pub async fn get(&self, registry: &str, storage: &Storage) -> Arc<Vec<RepoInfo>> {
|
||||
let index = match registry {
|
||||
"docker" => &self.docker,
|
||||
"maven" => &self.maven,
|
||||
"npm" => &self.npm,
|
||||
"cargo" => &self.cargo,
|
||||
"pypi" => &self.pypi,
|
||||
"go" => &self.go,
|
||||
"raw" => &self.raw,
|
||||
_ => return Arc::new(Vec::new()),
|
||||
};
|
||||
|
||||
// Fast path: not dirty, return cached
|
||||
if !index.is_dirty() {
|
||||
return index.get_cached();
|
||||
}
|
||||
|
||||
// Slow path: acquire rebuild lock (only one thread rebuilds)
|
||||
let _guard = index.rebuild_lock.lock().await;
|
||||
|
||||
// Double-check under lock (another thread may have rebuilt)
|
||||
if index.is_dirty() {
|
||||
let data = match registry {
|
||||
"docker" => build_docker_index(storage).await,
|
||||
"maven" => build_maven_index(storage).await,
|
||||
"npm" => build_npm_index(storage).await,
|
||||
"cargo" => build_cargo_index(storage).await,
|
||||
"pypi" => build_pypi_index(storage).await,
|
||||
"go" => build_go_index(storage).await,
|
||||
"raw" => build_raw_index(storage).await,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
info!(registry = registry, count = data.len(), "Index rebuilt");
|
||||
index.set(data);
|
||||
}
|
||||
|
||||
index.get_cached()
|
||||
}
|
||||
|
||||
/// Get counts for stats (no rebuild, just current state)
|
||||
pub fn counts(&self) -> (usize, usize, usize, usize, usize, usize, usize) {
|
||||
(
|
||||
self.docker.count(),
|
||||
self.maven.count(),
|
||||
self.npm.count(),
|
||||
self.cargo.count(),
|
||||
self.pypi.count(),
|
||||
self.go.count(),
|
||||
self.raw.count(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RepoIndex {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Index builders
|
||||
// ============================================================================
|
||||
|
||||
async fn build_docker_index(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("docker/").await;
|
||||
let mut repos: HashMap<String, (usize, u64, u64)> = HashMap::new();
|
||||
|
||||
for key in &keys {
|
||||
if key.ends_with(".meta.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = key.strip_prefix("docker/") {
|
||||
// Support both single-segment and namespaced images:
|
||||
// docker/alpine/manifests/latest.json → name="alpine"
|
||||
// docker/library/alpine/manifests/latest.json → name="library/alpine"
|
||||
let parts: Vec<_> = rest.split('/').collect();
|
||||
let manifest_pos = parts.iter().position(|&p| p == "manifests");
|
||||
if let Some(pos) = manifest_pos {
|
||||
if pos >= 1 && key.ends_with(".json") {
|
||||
let name = parts[..pos].join("/");
|
||||
let entry = repos.entry(name).or_insert((0, 0, 0));
|
||||
entry.0 += 1;
|
||||
|
||||
if let Ok(data) = storage.get(key).await {
|
||||
if let Ok(m) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||
let cfg = m
|
||||
.get("config")
|
||||
.and_then(|c| c.get("size"))
|
||||
.and_then(|s| s.as_u64())
|
||||
.unwrap_or(0);
|
||||
let layers: u64 = m
|
||||
.get("layers")
|
||||
.and_then(|l| l.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
entry.1 += cfg + layers;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
if meta.modified > entry.2 {
|
||||
entry.2 = meta.modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_sorted_vec(repos)
|
||||
}
|
||||
|
||||
async fn build_maven_index(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("maven/").await;
|
||||
let mut repos: HashMap<String, (usize, u64, u64)> = HashMap::new();
|
||||
|
||||
for key in &keys {
|
||||
if let Some(rest) = key.strip_prefix("maven/") {
|
||||
let parts: Vec<_> = rest.split('/').collect();
|
||||
if parts.len() >= 2 {
|
||||
let path = parts[..parts.len() - 1].join("/");
|
||||
let entry = repos.entry(path).or_insert((0, 0, 0));
|
||||
entry.0 += 1;
|
||||
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.1 += meta.size;
|
||||
if meta.modified > entry.2 {
|
||||
entry.2 = meta.modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_sorted_vec(repos)
|
||||
}
|
||||
|
||||
async fn build_npm_index(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("npm/").await;
|
||||
let mut packages: HashMap<String, (usize, u64, u64)> = HashMap::new();
|
||||
|
||||
// Count tarballs instead of parsing metadata.json (faster than parsing JSON)
|
||||
for key in &keys {
|
||||
if let Some(rest) = key.strip_prefix("npm/") {
|
||||
// Pattern: npm/{package}/tarballs/{file}.tgz
|
||||
// Scoped: npm/@scope/package/tarballs/{file}.tgz
|
||||
if rest.contains("/tarballs/") && key.ends_with(".tgz") {
|
||||
let parts: Vec<_> = rest.split('/').collect();
|
||||
if !parts.is_empty() {
|
||||
// Scoped packages: @scope/package → parts[0]="@scope", parts[1]="package"
|
||||
let name = if parts[0].starts_with('@') && parts.len() >= 4 {
|
||||
format!("{}/{}", parts[0], parts[1])
|
||||
} else {
|
||||
parts[0].to_string()
|
||||
};
|
||||
let entry = packages.entry(name).or_insert((0, 0, 0));
|
||||
entry.0 += 1;
|
||||
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.1 += meta.size;
|
||||
if meta.modified > entry.2 {
|
||||
entry.2 = meta.modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_sorted_vec(packages)
|
||||
}
|
||||
|
||||
async fn build_cargo_index(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("cargo/").await;
|
||||
let mut crates: HashMap<String, (usize, u64, u64)> = HashMap::new();
|
||||
|
||||
for key in &keys {
|
||||
if key.ends_with(".crate") {
|
||||
if let Some(rest) = key.strip_prefix("cargo/") {
|
||||
let parts: Vec<_> = rest.split('/').collect();
|
||||
if !parts.is_empty() {
|
||||
let name = parts[0].to_string();
|
||||
let entry = crates.entry(name).or_insert((0, 0, 0));
|
||||
entry.0 += 1;
|
||||
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.1 += meta.size;
|
||||
if meta.modified > entry.2 {
|
||||
entry.2 = meta.modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_sorted_vec(crates)
|
||||
}
|
||||
|
||||
async fn build_pypi_index(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("pypi/").await;
|
||||
let mut packages: HashMap<String, (usize, u64, u64)> = HashMap::new();
|
||||
|
||||
for key in &keys {
|
||||
if let Some(rest) = key.strip_prefix("pypi/") {
|
||||
let parts: Vec<_> = rest.split('/').collect();
|
||||
if parts.len() >= 2 {
|
||||
let name = parts[0].to_string();
|
||||
let entry = packages.entry(name).or_insert((0, 0, 0));
|
||||
entry.0 += 1;
|
||||
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.1 += meta.size;
|
||||
if meta.modified > entry.2 {
|
||||
entry.2 = meta.modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_sorted_vec(packages)
|
||||
}
|
||||
|
||||
async fn build_go_index(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("go/").await;
|
||||
let mut modules: HashMap<String, (usize, u64, u64)> = HashMap::new();
|
||||
|
||||
for key in &keys {
|
||||
if let Some(rest) = key.strip_prefix("go/") {
|
||||
// Pattern: go/{module}/@v/{version}.zip
|
||||
// Count .zip files as versions (authoritative artifacts)
|
||||
if rest.contains("/@v/") && key.ends_with(".zip") {
|
||||
// Extract module path: everything before /@v/
|
||||
if let Some(pos) = rest.rfind("/@v/") {
|
||||
let module = &rest[..pos];
|
||||
let entry = modules.entry(module.to_string()).or_insert((0, 0, 0));
|
||||
entry.0 += 1;
|
||||
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.1 += meta.size;
|
||||
if meta.modified > entry.2 {
|
||||
entry.2 = meta.modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_sorted_vec(modules)
|
||||
}
|
||||
|
||||
async fn build_raw_index(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("raw/").await;
|
||||
let mut files: HashMap<String, (usize, u64, u64)> = HashMap::new();
|
||||
|
||||
for key in &keys {
|
||||
if let Some(rest) = key.strip_prefix("raw/") {
|
||||
// Group by top-level directory
|
||||
let group = rest.split('/').next().unwrap_or(rest).to_string();
|
||||
let entry = files.entry(group).or_insert((0, 0, 0));
|
||||
entry.0 += 1;
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.1 += meta.size;
|
||||
if meta.modified > entry.2 {
|
||||
entry.2 = meta.modified;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
to_sorted_vec(files)
|
||||
}
|
||||
|
||||
/// Convert HashMap to sorted Vec<RepoInfo>
|
||||
fn to_sorted_vec(map: HashMap<String, (usize, u64, u64)>) -> Vec<RepoInfo> {
|
||||
let mut result: Vec<_> = map
|
||||
.into_iter()
|
||||
.map(|(name, (versions, size, modified))| RepoInfo {
|
||||
name,
|
||||
versions,
|
||||
size,
|
||||
updated: if modified > 0 {
|
||||
format_timestamp(modified)
|
||||
} else {
|
||||
"N/A".to_string()
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
result.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
result
|
||||
}
|
||||
|
||||
/// Pagination helper
|
||||
pub fn paginate<T: Clone>(data: &[T], page: usize, limit: usize) -> (Vec<T>, usize) {
|
||||
let total = data.len();
|
||||
let start = page.saturating_sub(1) * limit;
|
||||
|
||||
if start >= total {
|
||||
return (Vec::new(), total);
|
||||
}
|
||||
|
||||
let end = (start + limit).min(total);
|
||||
(data[start..end].to_vec(), total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_paginate_first_page() {
|
||||
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
let (page, total) = paginate(&data, 1, 3);
|
||||
assert_eq!(page, vec![1, 2, 3]);
|
||||
assert_eq!(total, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_second_page() {
|
||||
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
let (page, total) = paginate(&data, 2, 3);
|
||||
assert_eq!(page, vec![4, 5, 6]);
|
||||
assert_eq!(total, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_last_page_partial() {
|
||||
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
let (page, total) = paginate(&data, 4, 3);
|
||||
assert_eq!(page, vec![10]);
|
||||
assert_eq!(total, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_beyond_range() {
|
||||
let data = vec![1, 2, 3];
|
||||
let (page, total) = paginate(&data, 5, 3);
|
||||
assert!(page.is_empty());
|
||||
assert_eq!(total, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_empty_data() {
|
||||
let data: Vec<i32> = vec![];
|
||||
let (page, total) = paginate(&data, 1, 10);
|
||||
assert!(page.is_empty());
|
||||
assert_eq!(total, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_page_zero() {
|
||||
// page 0 with saturating_sub becomes 0, so start = 0
|
||||
let data = vec![1, 2, 3];
|
||||
let (page, _) = paginate(&data, 0, 2);
|
||||
assert_eq!(page, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_paginate_large_limit() {
|
||||
let data = vec![1, 2, 3];
|
||||
let (page, total) = paginate(&data, 1, 100);
|
||||
assert_eq!(page, vec![1, 2, 3]);
|
||||
assert_eq!(total, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_index_new() {
|
||||
let idx = RegistryIndex::new();
|
||||
assert_eq!(idx.count(), 0);
|
||||
assert!(idx.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_index_invalidate() {
|
||||
let idx = RegistryIndex::new();
|
||||
// Initially dirty
|
||||
assert!(idx.is_dirty());
|
||||
|
||||
// Set data clears dirty
|
||||
idx.set(vec![RepoInfo {
|
||||
name: "test".to_string(),
|
||||
versions: 1,
|
||||
size: 100,
|
||||
updated: "2026-01-01".to_string(),
|
||||
}]);
|
||||
assert!(!idx.is_dirty());
|
||||
assert_eq!(idx.count(), 1);
|
||||
|
||||
// Invalidate makes it dirty again
|
||||
idx.invalidate();
|
||||
assert!(idx.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_index_get_cached() {
|
||||
let idx = RegistryIndex::new();
|
||||
idx.set(vec![
|
||||
RepoInfo {
|
||||
name: "a".to_string(),
|
||||
versions: 2,
|
||||
size: 200,
|
||||
updated: "today".to_string(),
|
||||
},
|
||||
RepoInfo {
|
||||
name: "b".to_string(),
|
||||
versions: 1,
|
||||
size: 100,
|
||||
updated: "yesterday".to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
let cached = idx.get_cached();
|
||||
assert_eq!(cached.len(), 2);
|
||||
assert_eq!(cached[0].name, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_index_default() {
|
||||
let idx = RegistryIndex::default();
|
||||
assert_eq!(idx.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repo_index_new() {
|
||||
let idx = RepoIndex::new();
|
||||
let (d, m, n, c, p, g, r) = idx.counts();
|
||||
assert_eq!((d, m, n, c, p, g, r), (0, 0, 0, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repo_index_invalidate() {
|
||||
let idx = RepoIndex::new();
|
||||
// Should not panic for any registry
|
||||
idx.invalidate("docker");
|
||||
idx.invalidate("maven");
|
||||
idx.invalidate("npm");
|
||||
idx.invalidate("cargo");
|
||||
idx.invalidate("pypi");
|
||||
idx.invalidate("raw");
|
||||
idx.invalidate("unknown"); // should be a no-op
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repo_index_default() {
|
||||
let idx = RepoIndex::default();
|
||||
let (d, m, n, c, p, g, r) = idx.counts();
|
||||
assert_eq!((d, m, n, c, p, g, r), (0, 0, 0, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_sorted_vec() {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
map.insert("zebra".to_string(), (3usize, 100u64, 0u64));
|
||||
map.insert("alpha".to_string(), (1, 50, 1700000000));
|
||||
|
||||
let result = to_sorted_vec(map);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].name, "alpha");
|
||||
assert_eq!(result[0].versions, 1);
|
||||
assert_eq!(result[0].size, 50);
|
||||
assert_ne!(result[0].updated, "N/A");
|
||||
assert_eq!(result[1].name, "zebra");
|
||||
assert_eq!(result[1].versions, 3);
|
||||
assert_eq!(result[1].updated, "N/A"); // modified = 0
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Request ID middleware for request tracking and correlation
|
||||
//!
|
||||
//! Generates a unique ID for each request that can be used for:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Environment variables secrets provider
|
||||
//!
|
||||
//! Reads secrets from environment variables. This is the default provider
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#![allow(dead_code)] // Foundational code for future S3/Vault integration
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Secrets management for NORA
|
||||
//!
|
||||
@@ -31,6 +32,7 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[allow(dead_code)] // Variants used by provider impls; external error handling planned for v0.4
|
||||
/// Secrets provider error
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretsError {
|
||||
@@ -53,9 +55,11 @@ pub enum SecretsError {
|
||||
#[async_trait]
|
||||
pub trait SecretsProvider: Send + Sync {
|
||||
/// Get a secret by key (required)
|
||||
#[allow(dead_code)]
|
||||
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError>;
|
||||
|
||||
/// Get a secret by key (optional, returns None if not found)
|
||||
#[allow(dead_code)]
|
||||
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
|
||||
self.get_secret(key).await.ok()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Protected secret types with memory safety
|
||||
//!
|
||||
//! Secrets are automatically zeroed on drop and redacted in Debug output.
|
||||
@@ -10,12 +13,14 @@ use zeroize::{Zeroize, Zeroizing};
|
||||
/// - Implements Zeroize: memory is overwritten with zeros when dropped
|
||||
/// - Debug shows `***REDACTED***` instead of actual value
|
||||
/// - Clone creates a new protected copy
|
||||
#[allow(dead_code)] // Used internally by SecretsProvider impls; external callers planned for v0.4
|
||||
#[derive(Clone, Zeroize)]
|
||||
#[zeroize(drop)]
|
||||
pub struct ProtectedString {
|
||||
inner: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ProtectedString {
|
||||
/// Create a new protected string
|
||||
pub fn new(value: String) -> Self {
|
||||
@@ -65,6 +70,7 @@ impl From<&str> for ProtectedString {
|
||||
}
|
||||
|
||||
/// S3 credentials with protected secrets
|
||||
#[allow(dead_code)] // S3 storage backend planned for v0.4
|
||||
#[derive(Clone, Zeroize)]
|
||||
#[zeroize(drop)]
|
||||
pub struct S3Credentials {
|
||||
@@ -74,6 +80,7 @@ pub struct S3Credentials {
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl S3Credentials {
|
||||
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Bytes;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
mod local;
|
||||
mod s3;
|
||||
|
||||
@@ -59,9 +62,17 @@ impl Storage {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_s3(s3_url: &str, bucket: &str) -> Self {
|
||||
pub fn new_s3(
|
||||
s3_url: &str,
|
||||
bucket: &str,
|
||||
region: &str,
|
||||
access_key: Option<&str>,
|
||||
secret_key: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(S3Storage::new(s3_url, bucket)),
|
||||
inner: Arc::new(S3Storage::new(
|
||||
s3_url, bucket, region, access_key, secret_key,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,146 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::body::Bytes;
|
||||
use chrono::Utc;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::{FileMeta, Result, StorageBackend, StorageError};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// S3-compatible storage backend (MinIO, AWS S3)
|
||||
pub struct S3Storage {
|
||||
s3_url: String,
|
||||
bucket: String,
|
||||
region: String,
|
||||
access_key: Option<String>,
|
||||
secret_key: Option<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl S3Storage {
|
||||
pub fn new(s3_url: &str, bucket: &str) -> Self {
|
||||
/// Create new S3 storage with optional credentials
|
||||
pub fn new(
|
||||
s3_url: &str,
|
||||
bucket: &str,
|
||||
region: &str,
|
||||
access_key: Option<&str>,
|
||||
secret_key: Option<&str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
s3_url: s3_url.to_string(),
|
||||
s3_url: s3_url.trim_end_matches('/').to_string(),
|
||||
bucket: bucket.to_string(),
|
||||
region: region.to_string(),
|
||||
access_key: access_key.map(String::from),
|
||||
secret_key: secret_key.map(String::from),
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a request using AWS Signature v4
|
||||
fn sign_request(
|
||||
&self,
|
||||
method: &str,
|
||||
path: &str,
|
||||
payload_hash: &str,
|
||||
timestamp: &str,
|
||||
date: &str,
|
||||
) -> Option<String> {
|
||||
let (access_key, secret_key) = match (&self.access_key, &self.secret_key) {
|
||||
(Some(ak), Some(sk)) => (ak.as_str(), sk.as_str()),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Parse host from URL
|
||||
let host = self
|
||||
.s3_url
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://");
|
||||
|
||||
// Canonical request
|
||||
// URI must be URL-encoded (except /)
|
||||
let encoded_path = uri_encode(path);
|
||||
let canonical_uri = format!("/{}/{}", self.bucket, encoded_path);
|
||||
let canonical_query = "";
|
||||
let canonical_headers = format!(
|
||||
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
|
||||
host, payload_hash, timestamp
|
||||
);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
|
||||
// AWS Signature v4 canonical request format:
|
||||
// HTTPMethod\nCanonicalURI\nCanonicalQueryString\nCanonicalHeaders\n\nSignedHeaders\nHashedPayload
|
||||
// Note: CanonicalHeaders already ends with \n, plus blank line before SignedHeaders
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
|
||||
|
||||
// String to sign
|
||||
let credential_scope = format!("{}/{}/s3/aws4_request", date, self.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
timestamp, credential_scope, canonical_request_hash
|
||||
);
|
||||
|
||||
// Calculate signature
|
||||
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date.as_bytes());
|
||||
let k_region = hmac_sha256(&k_date, self.region.as_bytes());
|
||||
let k_service = hmac_sha256(&k_region, b"s3");
|
||||
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
||||
let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
|
||||
|
||||
// Authorization header
|
||||
Some(format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
access_key, credential_scope, signed_headers, signature
|
||||
))
|
||||
}
|
||||
|
||||
/// Make a signed request
|
||||
async fn signed_request(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
key: &str,
|
||||
body: Option<&[u8]>,
|
||||
) -> std::result::Result<reqwest::Response, StorageError> {
|
||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
||||
let now = Utc::now();
|
||||
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date = now.format("%Y%m%d").to_string();
|
||||
|
||||
let payload_hash = match body {
|
||||
Some(data) => hex::encode(Sha256::digest(data)),
|
||||
None => hex::encode(Sha256::digest(b"")),
|
||||
};
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.request(method.clone(), &url)
|
||||
.header("x-amz-date", ×tamp)
|
||||
.header("x-amz-content-sha256", &payload_hash);
|
||||
|
||||
if let Some(auth) =
|
||||
self.sign_request(method.as_str(), key, &payload_hash, ×tamp, &date)
|
||||
{
|
||||
request = request.header("Authorization", auth);
|
||||
}
|
||||
|
||||
if let Some(data) = body {
|
||||
request = request.body(data.to_vec());
|
||||
}
|
||||
|
||||
request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| StorageError::Network(e.to_string()))
|
||||
}
|
||||
|
||||
fn parse_s3_keys(xml: &str, prefix: &str) -> Vec<String> {
|
||||
xml.split("<Key>")
|
||||
.filter_map(|part| part.split("</Key>").next())
|
||||
@@ -28,17 +150,34 @@ impl S3Storage {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL-encode a string for S3 canonical URI (encode all except A-Za-z0-9-_.~/)
|
||||
fn uri_encode(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len() * 3);
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' | '/' => result.push(c),
|
||||
_ => {
|
||||
for b in c.to_string().as_bytes() {
|
||||
result.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StorageBackend for S3Storage {
|
||||
async fn put(&self, key: &str, data: &[u8]) -> Result<()> {
|
||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
||||
let response = self
|
||||
.client
|
||||
.put(&url)
|
||||
.body(data.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| StorageError::Network(e.to_string()))?;
|
||||
.signed_request(reqwest::Method::PUT, key, Some(data))
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
@@ -51,13 +190,7 @@ impl StorageBackend for S3Storage {
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> Result<Bytes> {
|
||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| StorageError::Network(e.to_string()))?;
|
||||
let response = self.signed_request(reqwest::Method::GET, key, None).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
@@ -75,13 +208,9 @@ impl StorageBackend for S3Storage {
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<()> {
|
||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
||||
let response = self
|
||||
.client
|
||||
.delete(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| StorageError::Network(e.to_string()))?;
|
||||
.signed_request(reqwest::Method::DELETE, key, None)
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() || response.status().as_u16() == 204 {
|
||||
Ok(())
|
||||
@@ -96,8 +225,59 @@ impl StorageBackend for S3Storage {
|
||||
}
|
||||
|
||||
async fn list(&self, prefix: &str) -> Vec<String> {
|
||||
// For listing, we need to make a request to the bucket
|
||||
let url = format!("{}/{}", self.s3_url, self.bucket);
|
||||
match self.client.get(&url).send().await {
|
||||
let now = Utc::now();
|
||||
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date = now.format("%Y%m%d").to_string();
|
||||
let payload_hash = hex::encode(Sha256::digest(b""));
|
||||
|
||||
let host = self
|
||||
.s3_url
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://");
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("x-amz-date", ×tamp)
|
||||
.header("x-amz-content-sha256", &payload_hash);
|
||||
|
||||
// Sign for bucket listing (different path)
|
||||
if let (Some(access_key), Some(secret_key)) = (&self.access_key, &self.secret_key) {
|
||||
let canonical_uri = format!("/{}", self.bucket);
|
||||
let canonical_headers = format!(
|
||||
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
|
||||
host, payload_hash, timestamp
|
||||
);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
|
||||
let canonical_request = format!(
|
||||
"GET\n{}\n\n{}\n{}\n{}",
|
||||
canonical_uri, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
|
||||
let credential_scope = format!("{}/{}/s3/aws4_request", date, self.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
timestamp, credential_scope, canonical_request_hash
|
||||
);
|
||||
|
||||
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date.as_bytes());
|
||||
let k_region = hmac_sha256(&k_date, self.region.as_bytes());
|
||||
let k_service = hmac_sha256(&k_region, b"s3");
|
||||
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
||||
let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
|
||||
|
||||
let auth = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
access_key, credential_scope, signed_headers, signature
|
||||
);
|
||||
request = request.header("Authorization", auth);
|
||||
}
|
||||
|
||||
match request.send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
if let Ok(xml) = response.text().await {
|
||||
Self::parse_s3_keys(&xml, prefix)
|
||||
@@ -110,18 +290,22 @@ impl StorageBackend for S3Storage {
|
||||
}
|
||||
|
||||
async fn stat(&self, key: &str) -> Option<FileMeta> {
|
||||
let url = format!("{}/{}/{}", self.s3_url, self.bucket, key);
|
||||
let response = self.client.head(&url).send().await.ok()?;
|
||||
let response = self
|
||||
.signed_request(reqwest::Method::HEAD, key, None)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let size = response
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
// S3 uses Last-Modified header, but for simplicity use current time if unavailable
|
||||
|
||||
let modified = response
|
||||
.headers()
|
||||
.get("last-modified")
|
||||
@@ -133,12 +317,63 @@ impl StorageBackend for S3Storage {
|
||||
.as_secs()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(FileMeta { size, modified })
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
// Try HEAD on the bucket
|
||||
let url = format!("{}/{}", self.s3_url, self.bucket);
|
||||
match self.client.head(&url).send().await {
|
||||
let now = Utc::now();
|
||||
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date = now.format("%Y%m%d").to_string();
|
||||
let payload_hash = hex::encode(Sha256::digest(b""));
|
||||
|
||||
let host = self
|
||||
.s3_url
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://");
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.head(&url)
|
||||
.header("x-amz-date", ×tamp)
|
||||
.header("x-amz-content-sha256", &payload_hash);
|
||||
|
||||
if let (Some(access_key), Some(secret_key)) = (&self.access_key, &self.secret_key) {
|
||||
let canonical_uri = format!("/{}", self.bucket);
|
||||
let canonical_headers = format!(
|
||||
"host:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\n",
|
||||
host, payload_hash, timestamp
|
||||
);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
|
||||
let canonical_request = format!(
|
||||
"HEAD\n{}\n\n{}\n{}\n{}",
|
||||
canonical_uri, canonical_headers, signed_headers, payload_hash
|
||||
);
|
||||
|
||||
let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
|
||||
let credential_scope = format!("{}/{}/s3/aws4_request", date, self.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
timestamp, credential_scope, canonical_request_hash
|
||||
);
|
||||
|
||||
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date.as_bytes());
|
||||
let k_region = hmac_sha256(&k_date, self.region.as_bytes());
|
||||
let k_service = hmac_sha256(&k_region, b"s3");
|
||||
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
||||
let signature = hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes()));
|
||||
|
||||
let auth = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
access_key, credential_scope, signed_headers, signature
|
||||
);
|
||||
request = request.header("Authorization", auth);
|
||||
}
|
||||
|
||||
match request.send().await {
|
||||
Ok(response) => response.status().is_success() || response.status().as_u16() == 404,
|
||||
Err(_) => false,
|
||||
}
|
||||
@@ -152,173 +387,28 @@ impl StorageBackend for S3Storage {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/test-bucket/test-key"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let result = storage.put("test-key", b"data").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_put_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/test-bucket/test-key"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let result = storage.put("test-key", b"data").await;
|
||||
assert!(matches!(result, Err(StorageError::Network(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test-bucket/test-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"test data".to_vec()))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let data = storage.get("test-key").await.unwrap();
|
||||
assert_eq!(&*data, b"test data");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_not_found() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test-bucket/missing"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let result = storage.get("missing").await;
|
||||
assert!(matches!(result, Err(StorageError::NotFound)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
let xml_response = r#"<?xml version="1.0"?>
|
||||
<ListBucketResult>
|
||||
<Key>docker/image1</Key>
|
||||
<Key>docker/image2</Key>
|
||||
<Key>maven/artifact</Key>
|
||||
</ListBucketResult>"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test-bucket"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(xml_response))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let keys = storage.list("docker/").await;
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert!(keys.iter().all(|k| k.starts_with("docker/")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stat_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("HEAD"))
|
||||
.and(path("/test-bucket/test-key"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-length", "1234")
|
||||
.insert_header("last-modified", "Sun, 06 Nov 1994 08:49:37 GMT"),
|
||||
)
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let meta = storage.stat("test-key").await.unwrap();
|
||||
assert_eq!(meta.size, 1234);
|
||||
assert!(meta.modified > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stat_not_found() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("HEAD"))
|
||||
.and(path("/test-bucket/missing"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let meta = storage.stat("missing").await;
|
||||
assert!(meta.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_healthy() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("HEAD"))
|
||||
.and(path("/test-bucket"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
assert!(storage.health_check().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_bucket_not_found_is_ok() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("HEAD"))
|
||||
.and(path("/test-bucket"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
// 404 is OK for health check (bucket may be empty)
|
||||
assert!(storage.health_check().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check_server_error() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let storage = S3Storage::new(&mock_server.uri(), "test-bucket");
|
||||
|
||||
Mock::given(method("HEAD"))
|
||||
.and(path("/test-bucket"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
assert!(!storage.health_check().await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backend_name() {
|
||||
let storage = S3Storage::new("http://localhost:9000", "bucket");
|
||||
let storage = S3Storage::new(
|
||||
"http://localhost:9000",
|
||||
"test-bucket",
|
||||
"us-east-1",
|
||||
Some("access"),
|
||||
Some("secret"),
|
||||
);
|
||||
assert_eq!(storage.backend_name(), "s3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_s3_storage_creation_anonymous() {
|
||||
let storage = S3Storage::new(
|
||||
"http://localhost:9000",
|
||||
"test-bucket",
|
||||
"us-east-1",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(storage.backend_name(), "s3");
|
||||
}
|
||||
|
||||
@@ -328,4 +418,10 @@ mod tests {
|
||||
let keys = S3Storage::parse_s3_keys(xml, "docker/");
|
||||
assert_eq!(keys, vec!["docker/a", "docker/b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_sha256() {
|
||||
let result = hmac_sha256(b"key", b"data");
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use thiserror::Error;
|
||||
@@ -8,6 +16,35 @@ use uuid::Uuid;
|
||||
|
||||
const TOKEN_PREFIX: &str = "nra_";
|
||||
|
||||
/// Access role for API tokens
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
Read,
|
||||
Write,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Role::Read => write!(f, "read"),
|
||||
Role::Write => write!(f, "write"),
|
||||
Role::Admin => write!(f, "admin"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn can_write(&self) -> bool {
|
||||
matches!(self, Role::Write | Role::Admin)
|
||||
}
|
||||
|
||||
pub fn can_admin(&self) -> bool {
|
||||
matches!(self, Role::Admin)
|
||||
}
|
||||
}
|
||||
|
||||
/// API Token metadata stored on disk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenInfo {
|
||||
@@ -17,6 +54,12 @@ pub struct TokenInfo {
|
||||
pub expires_at: u64,
|
||||
pub last_used: Option<u64>,
|
||||
pub description: Option<String>,
|
||||
#[serde(default = "default_role")]
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
fn default_role() -> Role {
|
||||
Role::Read
|
||||
}
|
||||
|
||||
/// Token store for managing API tokens
|
||||
@@ -28,8 +71,12 @@ pub struct TokenStore {
|
||||
impl TokenStore {
|
||||
/// Create a new token store
|
||||
pub fn new(storage_path: &Path) -> Self {
|
||||
// Ensure directory exists
|
||||
// Ensure directory exists with restricted permissions
|
||||
let _ = fs::create_dir_all(storage_path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::set_permissions(storage_path, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
Self {
|
||||
storage_path: storage_path.to_path_buf(),
|
||||
}
|
||||
@@ -41,6 +88,7 @@ impl TokenStore {
|
||||
user: &str,
|
||||
ttl_days: u64,
|
||||
description: Option<String>,
|
||||
role: Role,
|
||||
) -> Result<String, TokenError> {
|
||||
// Generate random token
|
||||
let raw_token = format!(
|
||||
@@ -48,7 +96,9 @@ impl TokenStore {
|
||||
TOKEN_PREFIX,
|
||||
Uuid::new_v4().to_string().replace("-", "")
|
||||
);
|
||||
let token_hash = hash_token(&raw_token);
|
||||
let token_hash = hash_token_argon2(&raw_token)?;
|
||||
// Use SHA256 of token as filename (deterministic, for lookup)
|
||||
let file_id = sha256_hex(&raw_token);
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -58,47 +108,68 @@ impl TokenStore {
|
||||
let expires_at = now + (ttl_days * 24 * 60 * 60);
|
||||
|
||||
let info = TokenInfo {
|
||||
token_hash: token_hash.clone(),
|
||||
token_hash,
|
||||
user: user.to_string(),
|
||||
created_at: now,
|
||||
expires_at,
|
||||
last_used: None,
|
||||
description,
|
||||
role,
|
||||
};
|
||||
|
||||
// Save to file
|
||||
let file_path = self
|
||||
.storage_path
|
||||
.join(format!("{}.json", &token_hash[..16]));
|
||||
// Save to file with restricted permissions
|
||||
let file_path = self.storage_path.join(format!("{}.json", &file_id[..16]));
|
||||
let json =
|
||||
serde_json::to_string_pretty(&info).map_err(|e| TokenError::Storage(e.to_string()))?;
|
||||
fs::write(&file_path, json).map_err(|e| TokenError::Storage(e.to_string()))?;
|
||||
fs::write(&file_path, &json).map_err(|e| TokenError::Storage(e.to_string()))?;
|
||||
set_file_permissions_600(&file_path);
|
||||
|
||||
Ok(raw_token)
|
||||
}
|
||||
|
||||
/// Verify a token and return user info if valid
|
||||
pub fn verify_token(&self, token: &str) -> Result<String, TokenError> {
|
||||
pub fn verify_token(&self, token: &str) -> Result<(String, Role), TokenError> {
|
||||
if !token.starts_with(TOKEN_PREFIX) {
|
||||
return Err(TokenError::InvalidFormat);
|
||||
}
|
||||
|
||||
let token_hash = hash_token(token);
|
||||
let file_path = self
|
||||
.storage_path
|
||||
.join(format!("{}.json", &token_hash[..16]));
|
||||
let file_id = sha256_hex(token);
|
||||
let file_path = self.storage_path.join(format!("{}.json", &file_id[..16]));
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(TokenError::NotFound);
|
||||
}
|
||||
// TOCTOU fix: read directly, handle NotFound from IO error
|
||||
let content = match fs::read_to_string(&file_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(TokenError::NotFound);
|
||||
}
|
||||
Err(e) => return Err(TokenError::Storage(e.to_string())),
|
||||
};
|
||||
|
||||
let content =
|
||||
fs::read_to_string(&file_path).map_err(|e| TokenError::Storage(e.to_string()))?;
|
||||
let mut info: TokenInfo =
|
||||
serde_json::from_str(&content).map_err(|e| TokenError::Storage(e.to_string()))?;
|
||||
|
||||
// Verify hash matches
|
||||
if info.token_hash != token_hash {
|
||||
// Verify hash: try Argon2id first, fall back to legacy SHA256
|
||||
let hash_valid = if info.token_hash.starts_with("$argon2") {
|
||||
verify_token_argon2(token, &info.token_hash)
|
||||
} else {
|
||||
// Legacy SHA256 hash (no salt) — verify and migrate
|
||||
let legacy_hash = sha256_hex(token);
|
||||
if info.token_hash == legacy_hash {
|
||||
// Migrate to Argon2id
|
||||
if let Ok(new_hash) = hash_token_argon2(token) {
|
||||
info.token_hash = new_hash;
|
||||
if let Ok(json) = serde_json::to_string_pretty(&info) {
|
||||
let _ = fs::write(&file_path, &json);
|
||||
set_file_permissions_600(&file_path);
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !hash_valid {
|
||||
return Err(TokenError::NotFound);
|
||||
}
|
||||
|
||||
@@ -115,10 +186,11 @@ impl TokenStore {
|
||||
// Update last_used
|
||||
info.last_used = Some(now);
|
||||
if let Ok(json) = serde_json::to_string_pretty(&info) {
|
||||
let _ = fs::write(&file_path, json);
|
||||
let _ = fs::write(&file_path, &json);
|
||||
set_file_permissions_600(&file_path);
|
||||
}
|
||||
|
||||
Ok(info.user)
|
||||
Ok((info.user, info.role))
|
||||
}
|
||||
|
||||
/// List all tokens for a user
|
||||
@@ -145,13 +217,12 @@ impl TokenStore {
|
||||
pub fn revoke_token(&self, hash_prefix: &str) -> Result<(), TokenError> {
|
||||
let file_path = self.storage_path.join(format!("{}.json", hash_prefix));
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(TokenError::NotFound);
|
||||
// TOCTOU fix: try remove directly
|
||||
match fs::remove_file(&file_path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(TokenError::NotFound),
|
||||
Err(e) => Err(TokenError::Storage(e.to_string())),
|
||||
}
|
||||
|
||||
fs::remove_file(&file_path).map_err(|e| TokenError::Storage(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke all tokens for a user
|
||||
@@ -174,13 +245,41 @@ impl TokenStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash a token using SHA256
|
||||
fn hash_token(token: &str) -> String {
|
||||
/// Hash a token using Argon2id with random salt
|
||||
fn hash_token_argon2(token: &str) -> Result<String, TokenError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
argon2
|
||||
.hash_password(token.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| TokenError::Storage(format!("hash error: {e}")))
|
||||
}
|
||||
|
||||
/// Verify a token against an Argon2id hash
|
||||
fn verify_token_argon2(token: &str, hash: &str) -> bool {
|
||||
match PasswordHash::new(hash) {
|
||||
Ok(parsed) => Argon2::default()
|
||||
.verify_password(token.as_bytes(), &parsed)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA256 hex digest (used for file naming and legacy hash verification)
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hasher.update(input.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Set file permissions to 600 (owner read/write only)
|
||||
fn set_file_permissions_600(path: &Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TokenError {
|
||||
#[error("Invalid token format")]
|
||||
@@ -207,22 +306,38 @@ mod tests {
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
let token = store
|
||||
.create_token("testuser", 30, Some("Test token".to_string()))
|
||||
.create_token("testuser", 30, Some("Test token".to_string()), Role::Write)
|
||||
.unwrap();
|
||||
|
||||
assert!(token.starts_with("nra_"));
|
||||
assert_eq!(token.len(), 4 + 32); // prefix + uuid without dashes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_hash_is_argon2() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
let token = store
|
||||
.create_token("testuser", 30, None, Role::Write)
|
||||
.unwrap();
|
||||
|
||||
let tokens = store.list_tokens("testuser");
|
||||
assert!(tokens[0].token_hash.starts_with("$argon2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_valid_token() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
let token = store.create_token("testuser", 30, None).unwrap();
|
||||
let user = store.verify_token(&token).unwrap();
|
||||
let token = store
|
||||
.create_token("testuser", 30, None, Role::Write)
|
||||
.unwrap();
|
||||
let (user, role) = store.verify_token(&token).unwrap();
|
||||
|
||||
assert_eq!(user, "testuser");
|
||||
assert_eq!(role, Role::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -248,30 +363,88 @@ mod tests {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
// Create token and manually set it as expired
|
||||
let token = store.create_token("testuser", 1, None).unwrap();
|
||||
let token_hash = hash_token(&token);
|
||||
let file_path = temp_dir.path().join(format!("{}.json", &token_hash[..16]));
|
||||
let token = store
|
||||
.create_token("testuser", 1, None, Role::Write)
|
||||
.unwrap();
|
||||
let file_id = sha256_hex(&token);
|
||||
let file_path = temp_dir.path().join(format!("{}.json", &file_id[..16]));
|
||||
|
||||
// Read and modify the token to be expired
|
||||
let content = std::fs::read_to_string(&file_path).unwrap();
|
||||
let mut info: TokenInfo = serde_json::from_str(&content).unwrap();
|
||||
info.expires_at = 0; // Set to epoch (definitely expired)
|
||||
info.expires_at = 0;
|
||||
std::fs::write(&file_path, serde_json::to_string(&info).unwrap()).unwrap();
|
||||
|
||||
// Token should now be expired
|
||||
let result = store.verify_token(&token);
|
||||
assert!(matches!(result, Err(TokenError::Expired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_sha256_migration() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
// Simulate a legacy token with SHA256 hash
|
||||
let raw_token = "nra_00112233445566778899aabbccddeeff";
|
||||
let legacy_hash = sha256_hex(raw_token);
|
||||
let file_id = sha256_hex(raw_token);
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let info = TokenInfo {
|
||||
token_hash: legacy_hash.clone(),
|
||||
user: "legacyuser".to_string(),
|
||||
created_at: now,
|
||||
expires_at: now + 86400,
|
||||
last_used: None,
|
||||
description: None,
|
||||
role: Role::Read,
|
||||
};
|
||||
|
||||
let file_path = temp_dir.path().join(format!("{}.json", &file_id[..16]));
|
||||
fs::write(&file_path, serde_json::to_string_pretty(&info).unwrap()).unwrap();
|
||||
|
||||
// Verify should work with legacy hash
|
||||
let (user, role) = store.verify_token(raw_token).unwrap();
|
||||
assert_eq!(user, "legacyuser");
|
||||
assert_eq!(role, Role::Read);
|
||||
|
||||
// After verification, hash should be migrated to Argon2id
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
let updated: TokenInfo = serde_json::from_str(&content).unwrap();
|
||||
assert!(updated.token_hash.starts_with("$argon2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_permissions() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
let token = store
|
||||
.create_token("testuser", 30, None, Role::Write)
|
||||
.unwrap();
|
||||
|
||||
let file_id = sha256_hex(&token);
|
||||
let file_path = temp_dir.path().join(format!("{}.json", &file_id[..16]));
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let metadata = fs::metadata(&file_path).unwrap();
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_tokens() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
store.create_token("user1", 30, None).unwrap();
|
||||
store.create_token("user1", 30, None).unwrap();
|
||||
store.create_token("user2", 30, None).unwrap();
|
||||
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||
store.create_token("user2", 30, None, Role::Read).unwrap();
|
||||
|
||||
let user1_tokens = store.list_tokens("user1");
|
||||
assert_eq!(user1_tokens.len(), 2);
|
||||
@@ -288,17 +461,16 @@ mod tests {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
let token = store.create_token("testuser", 30, None).unwrap();
|
||||
let token_hash = hash_token(&token);
|
||||
let hash_prefix = &token_hash[..16];
|
||||
let token = store
|
||||
.create_token("testuser", 30, None, Role::Write)
|
||||
.unwrap();
|
||||
let file_id = sha256_hex(&token);
|
||||
let hash_prefix = &file_id[..16];
|
||||
|
||||
// Verify token works
|
||||
assert!(store.verify_token(&token).is_ok());
|
||||
|
||||
// Revoke
|
||||
store.revoke_token(hash_prefix).unwrap();
|
||||
|
||||
// Verify token no longer works
|
||||
let result = store.verify_token(&token);
|
||||
assert!(matches!(result, Err(TokenError::NotFound)));
|
||||
}
|
||||
@@ -317,9 +489,9 @@ mod tests {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
store.create_token("user1", 30, None).unwrap();
|
||||
store.create_token("user1", 30, None).unwrap();
|
||||
store.create_token("user2", 30, None).unwrap();
|
||||
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||
store.create_token("user1", 30, None, Role::Write).unwrap();
|
||||
store.create_token("user2", 30, None, Role::Read).unwrap();
|
||||
|
||||
let revoked = store.revoke_all_for_user("user1");
|
||||
assert_eq!(revoked, 2);
|
||||
@@ -333,12 +505,12 @@ mod tests {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
let token = store.create_token("testuser", 30, None).unwrap();
|
||||
let token = store
|
||||
.create_token("testuser", 30, None, Role::Write)
|
||||
.unwrap();
|
||||
|
||||
// First verification
|
||||
store.verify_token(&token).unwrap();
|
||||
|
||||
// Check last_used is set
|
||||
let tokens = store.list_tokens("testuser");
|
||||
assert!(tokens[0].last_used.is_some());
|
||||
}
|
||||
@@ -349,7 +521,12 @@ mod tests {
|
||||
let store = TokenStore::new(temp_dir.path());
|
||||
|
||||
store
|
||||
.create_token("testuser", 30, Some("CI/CD Pipeline".to_string()))
|
||||
.create_token(
|
||||
"testuser",
|
||||
30,
|
||||
Some("CI/CD Pipeline".to_string()),
|
||||
Role::Admin,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tokens = store.list_tokens("testuser");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use super::components::{format_size, format_timestamp, html_escape};
|
||||
use super::templates::encode_uri_component;
|
||||
use crate::activity_log::ActivityEntry;
|
||||
use crate::repo_index::RepoInfo;
|
||||
use crate::AppState;
|
||||
use crate::Storage;
|
||||
use axum::{
|
||||
@@ -19,14 +23,8 @@ pub struct RegistryStats {
|
||||
pub npm: usize,
|
||||
pub cargo: usize,
|
||||
pub pypi: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct RepoInfo {
|
||||
pub name: String,
|
||||
pub versions: usize,
|
||||
pub size: u64,
|
||||
pub updated: String,
|
||||
pub go: usize,
|
||||
pub raw: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -112,44 +110,63 @@ pub struct MountPoint {
|
||||
// ============ API Handlers ============
|
||||
|
||||
pub async fn api_stats(State(state): State<Arc<AppState>>) -> Json<RegistryStats> {
|
||||
let stats = get_registry_stats(&state.storage).await;
|
||||
Json(stats)
|
||||
// Trigger index rebuild if needed, then get counts
|
||||
let _ = state.repo_index.get("docker", &state.storage).await;
|
||||
let _ = state.repo_index.get("maven", &state.storage).await;
|
||||
let _ = state.repo_index.get("npm", &state.storage).await;
|
||||
let _ = state.repo_index.get("cargo", &state.storage).await;
|
||||
let _ = state.repo_index.get("pypi", &state.storage).await;
|
||||
let _ = state.repo_index.get("go", &state.storage).await;
|
||||
let _ = state.repo_index.get("raw", &state.storage).await;
|
||||
|
||||
let (docker, maven, npm, cargo, pypi, go, raw) = state.repo_index.counts();
|
||||
Json(RegistryStats {
|
||||
docker,
|
||||
maven,
|
||||
npm,
|
||||
cargo,
|
||||
pypi,
|
||||
go,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<DashboardResponse> {
|
||||
let registry_stats = get_registry_stats(&state.storage).await;
|
||||
// Get indexes (will rebuild if dirty)
|
||||
let docker_repos = state.repo_index.get("docker", &state.storage).await;
|
||||
let maven_repos = state.repo_index.get("maven", &state.storage).await;
|
||||
let npm_repos = state.repo_index.get("npm", &state.storage).await;
|
||||
let cargo_repos = state.repo_index.get("cargo", &state.storage).await;
|
||||
let pypi_repos = state.repo_index.get("pypi", &state.storage).await;
|
||||
let go_repos = state.repo_index.get("go", &state.storage).await;
|
||||
let raw_repos = state.repo_index.get("raw", &state.storage).await;
|
||||
|
||||
// Calculate total storage size
|
||||
let all_keys = state.storage.list("").await;
|
||||
let mut total_storage: u64 = 0;
|
||||
let mut docker_size: u64 = 0;
|
||||
let mut maven_size: u64 = 0;
|
||||
let mut npm_size: u64 = 0;
|
||||
let mut cargo_size: u64 = 0;
|
||||
let mut pypi_size: u64 = 0;
|
||||
// Calculate sizes from cached index
|
||||
let docker_size: u64 = docker_repos.iter().map(|r| r.size).sum();
|
||||
let maven_size: u64 = maven_repos.iter().map(|r| r.size).sum();
|
||||
let npm_size: u64 = npm_repos.iter().map(|r| r.size).sum();
|
||||
let cargo_size: u64 = cargo_repos.iter().map(|r| r.size).sum();
|
||||
let pypi_size: u64 = pypi_repos.iter().map(|r| r.size).sum();
|
||||
let go_size: u64 = go_repos.iter().map(|r| r.size).sum();
|
||||
let raw_size: u64 = raw_repos.iter().map(|r| r.size).sum();
|
||||
let total_storage =
|
||||
docker_size + maven_size + npm_size + cargo_size + pypi_size + go_size + raw_size;
|
||||
|
||||
for key in &all_keys {
|
||||
if let Some(meta) = state.storage.stat(key).await {
|
||||
total_storage += meta.size;
|
||||
if key.starts_with("docker/") {
|
||||
docker_size += meta.size;
|
||||
} else if key.starts_with("maven/") {
|
||||
maven_size += meta.size;
|
||||
} else if key.starts_with("npm/") {
|
||||
npm_size += meta.size;
|
||||
} else if key.starts_with("cargo/") {
|
||||
cargo_size += meta.size;
|
||||
} else if key.starts_with("pypi/") {
|
||||
pypi_size += meta.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_artifacts = registry_stats.docker
|
||||
+ registry_stats.maven
|
||||
+ registry_stats.npm
|
||||
+ registry_stats.cargo
|
||||
+ registry_stats.pypi;
|
||||
// Count total versions/tags, not just repositories
|
||||
let docker_versions: usize = docker_repos.iter().map(|r| r.versions).sum();
|
||||
let maven_versions: usize = maven_repos.iter().map(|r| r.versions).sum();
|
||||
let npm_versions: usize = npm_repos.iter().map(|r| r.versions).sum();
|
||||
let cargo_versions: usize = cargo_repos.iter().map(|r| r.versions).sum();
|
||||
let pypi_versions: usize = pypi_repos.iter().map(|r| r.versions).sum();
|
||||
let go_versions: usize = go_repos.iter().map(|r| r.versions).sum();
|
||||
let raw_versions: usize = raw_repos.iter().map(|r| r.versions).sum();
|
||||
let total_artifacts = docker_versions
|
||||
+ maven_versions
|
||||
+ npm_versions
|
||||
+ cargo_versions
|
||||
+ pypi_versions
|
||||
+ go_versions
|
||||
+ raw_versions;
|
||||
|
||||
let global_stats = GlobalStats {
|
||||
downloads: state.metrics.downloads.load(Ordering::Relaxed),
|
||||
@@ -162,51 +179,70 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
||||
let registry_card_stats = vec![
|
||||
RegistryCardStats {
|
||||
name: "docker".to_string(),
|
||||
artifact_count: registry_stats.docker,
|
||||
artifact_count: docker_versions,
|
||||
downloads: state.metrics.get_registry_downloads("docker"),
|
||||
uploads: state.metrics.get_registry_uploads("docker"),
|
||||
size_bytes: docker_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "maven".to_string(),
|
||||
artifact_count: registry_stats.maven,
|
||||
artifact_count: maven_versions,
|
||||
downloads: state.metrics.get_registry_downloads("maven"),
|
||||
uploads: state.metrics.get_registry_uploads("maven"),
|
||||
size_bytes: maven_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "npm".to_string(),
|
||||
artifact_count: registry_stats.npm,
|
||||
artifact_count: npm_versions,
|
||||
downloads: state.metrics.get_registry_downloads("npm"),
|
||||
uploads: 0,
|
||||
size_bytes: npm_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "cargo".to_string(),
|
||||
artifact_count: registry_stats.cargo,
|
||||
artifact_count: cargo_versions,
|
||||
downloads: state.metrics.get_registry_downloads("cargo"),
|
||||
uploads: 0,
|
||||
size_bytes: cargo_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "pypi".to_string(),
|
||||
artifact_count: registry_stats.pypi,
|
||||
artifact_count: pypi_versions,
|
||||
downloads: state.metrics.get_registry_downloads("pypi"),
|
||||
uploads: 0,
|
||||
size_bytes: pypi_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "go".to_string(),
|
||||
artifact_count: go_versions,
|
||||
downloads: state.metrics.get_registry_downloads("go"),
|
||||
uploads: 0,
|
||||
size_bytes: go_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "raw".to_string(),
|
||||
artifact_count: raw_versions,
|
||||
downloads: state.metrics.get_registry_downloads("raw"),
|
||||
uploads: state.metrics.get_registry_uploads("raw"),
|
||||
size_bytes: raw_size,
|
||||
},
|
||||
];
|
||||
|
||||
let mount_points = vec![
|
||||
MountPoint {
|
||||
registry: "Docker".to_string(),
|
||||
mount_path: "/v2/".to_string(),
|
||||
proxy_upstream: None,
|
||||
proxy_upstream: state.config.docker.upstreams.first().map(|u| u.url.clone()),
|
||||
},
|
||||
MountPoint {
|
||||
registry: "Maven".to_string(),
|
||||
mount_path: "/maven2/".to_string(),
|
||||
proxy_upstream: state.config.maven.proxies.first().cloned(),
|
||||
proxy_upstream: state
|
||||
.config
|
||||
.maven
|
||||
.proxies
|
||||
.first()
|
||||
.map(|p| p.url().to_string()),
|
||||
},
|
||||
MountPoint {
|
||||
registry: "npm".to_string(),
|
||||
@@ -223,6 +259,16 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
||||
mount_path: "/simple/".to_string(),
|
||||
proxy_upstream: state.config.pypi.proxy.clone(),
|
||||
},
|
||||
MountPoint {
|
||||
registry: "Go".to_string(),
|
||||
mount_path: "/go/".to_string(),
|
||||
proxy_upstream: state.config.go.proxy.clone(),
|
||||
},
|
||||
MountPoint {
|
||||
registry: "Raw".to_string(),
|
||||
mount_path: "/raw/".to_string(),
|
||||
proxy_upstream: None,
|
||||
},
|
||||
];
|
||||
|
||||
let activity = state.activity.recent(20);
|
||||
@@ -241,15 +287,8 @@ pub async fn api_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(registry_type): Path<String>,
|
||||
) -> Json<Vec<RepoInfo>> {
|
||||
let repos = match registry_type.as_str() {
|
||||
"docker" => get_docker_repos(&state.storage).await,
|
||||
"maven" => get_maven_repos(&state.storage).await,
|
||||
"npm" => get_npm_packages(&state.storage).await,
|
||||
"cargo" => get_cargo_crates(&state.storage).await,
|
||||
"pypi" => get_pypi_packages(&state.storage).await,
|
||||
_ => vec![],
|
||||
};
|
||||
Json(repos)
|
||||
let repos = state.repo_index.get(®istry_type, &state.storage).await;
|
||||
Json((*repos).clone())
|
||||
}
|
||||
|
||||
pub async fn api_detail(
|
||||
@@ -280,20 +319,13 @@ pub async fn api_search(
|
||||
) -> axum::response::Html<String> {
|
||||
let query = params.q.unwrap_or_default().to_lowercase();
|
||||
|
||||
let repos = match registry_type.as_str() {
|
||||
"docker" => get_docker_repos(&state.storage).await,
|
||||
"maven" => get_maven_repos(&state.storage).await,
|
||||
"npm" => get_npm_packages(&state.storage).await,
|
||||
"cargo" => get_cargo_crates(&state.storage).await,
|
||||
"pypi" => get_pypi_packages(&state.storage).await,
|
||||
_ => vec![],
|
||||
};
|
||||
let repos = state.repo_index.get(®istry_type, &state.storage).await;
|
||||
|
||||
let filtered: Vec<_> = if query.is_empty() {
|
||||
repos
|
||||
let filtered: Vec<&RepoInfo> = if query.is_empty() {
|
||||
repos.iter().collect()
|
||||
} else {
|
||||
repos
|
||||
.into_iter()
|
||||
.iter()
|
||||
.filter(|r| r.name.to_lowercase().contains(&query))
|
||||
.collect()
|
||||
};
|
||||
@@ -338,7 +370,9 @@ pub async fn api_search(
|
||||
}
|
||||
|
||||
// ============ Data Fetching Functions ============
|
||||
// NOTE: Legacy functions below - kept for reference, will be removed in future cleanup
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
|
||||
let all_keys = storage.list("").await;
|
||||
|
||||
@@ -381,21 +415,47 @@ pub async fn get_registry_stats(storage: &Storage) -> RegistryStats {
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
|
||||
let go = all_keys
|
||||
.iter()
|
||||
.filter(|k| k.starts_with("go/") && k.ends_with(".zip"))
|
||||
.filter_map(|k| {
|
||||
let rest = k.strip_prefix("go/")?;
|
||||
let pos = rest.rfind("/@v/")?;
|
||||
Some(rest[..pos].to_string())
|
||||
})
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
|
||||
let raw = all_keys
|
||||
.iter()
|
||||
.filter(|k| k.starts_with("raw/"))
|
||||
.filter_map(|k| k.strip_prefix("raw/")?.split('/').next())
|
||||
.collect::<HashSet<_>>()
|
||||
.len();
|
||||
|
||||
RegistryStats {
|
||||
docker,
|
||||
maven,
|
||||
npm,
|
||||
cargo,
|
||||
pypi,
|
||||
go,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("docker/").await;
|
||||
|
||||
let mut repos: HashMap<String, (RepoInfo, u64)> = HashMap::new(); // (info, latest_modified)
|
||||
|
||||
for key in &keys {
|
||||
// Skip .meta.json files
|
||||
if key.ends_with(".meta.json") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(rest) = key.strip_prefix("docker/") {
|
||||
let parts: Vec<_> = rest.split('/').collect();
|
||||
if parts.len() >= 3 {
|
||||
@@ -412,10 +472,35 @@ pub async fn get_docker_repos(storage: &Storage) -> Vec<RepoInfo> {
|
||||
)
|
||||
});
|
||||
|
||||
if parts[1] == "manifests" {
|
||||
if parts[1] == "manifests" && key.ends_with(".json") {
|
||||
entry.0.versions += 1;
|
||||
|
||||
// Parse manifest to get actual image size (config + layers)
|
||||
if let Ok(manifest_data) = storage.get(key).await {
|
||||
if let Ok(manifest) =
|
||||
serde_json::from_slice::<serde_json::Value>(&manifest_data)
|
||||
{
|
||||
let config_size = manifest
|
||||
.get("config")
|
||||
.and_then(|c| c.get("size"))
|
||||
.and_then(|s| s.as_u64())
|
||||
.unwrap_or(0);
|
||||
let layers_size: u64 = manifest
|
||||
.get("layers")
|
||||
.and_then(|l| l.as_array())
|
||||
.map(|layers| {
|
||||
layers
|
||||
.iter()
|
||||
.filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
entry.0.size += config_size + layers_size;
|
||||
}
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.0.size += meta.size;
|
||||
if meta.modified > entry.1 {
|
||||
entry.1 = meta.modified;
|
||||
entry.0.updated = format_timestamp(meta.modified);
|
||||
@@ -470,11 +555,37 @@ pub async fn get_docker_detail(state: &AppState, name: &str) -> DockerDetail {
|
||||
"N/A".to_string()
|
||||
};
|
||||
|
||||
// Use size from metadata if available, otherwise from file
|
||||
// Calculate size from manifest layers (config + layers)
|
||||
let size = if metadata.size_bytes > 0 {
|
||||
metadata.size_bytes
|
||||
} else {
|
||||
state.storage.stat(key).await.map(|m| m.size).unwrap_or(0)
|
||||
// Parse manifest to get actual image size
|
||||
if let Ok(manifest_data) = state.storage.get(key).await {
|
||||
if let Ok(manifest) =
|
||||
serde_json::from_slice::<serde_json::Value>(&manifest_data)
|
||||
{
|
||||
let config_size = manifest
|
||||
.get("config")
|
||||
.and_then(|c| c.get("size"))
|
||||
.and_then(|s| s.as_u64())
|
||||
.unwrap_or(0);
|
||||
let layers_size: u64 = manifest
|
||||
.get("layers")
|
||||
.and_then(|l| l.as_array())
|
||||
.map(|layers| {
|
||||
layers
|
||||
.iter()
|
||||
.filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
config_size + layers_size
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
// Format last_pulled
|
||||
@@ -512,6 +623,7 @@ pub async fn get_docker_detail(state: &AppState, name: &str) -> DockerDetail {
|
||||
DockerDetail { tags }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_maven_repos(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("maven/").await;
|
||||
|
||||
@@ -571,75 +683,125 @@ pub async fn get_maven_detail(storage: &Storage, path: &str) -> MavenDetail {
|
||||
MavenDetail { artifacts }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_npm_packages(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("npm/").await;
|
||||
|
||||
let mut packages: HashMap<String, (RepoInfo, u64)> = HashMap::new();
|
||||
let mut packages: HashMap<String, RepoInfo> = HashMap::new();
|
||||
|
||||
// Find all metadata.json files
|
||||
for key in &keys {
|
||||
if let Some(rest) = key.strip_prefix("npm/") {
|
||||
let parts: Vec<_> = rest.split('/').collect();
|
||||
if !parts.is_empty() {
|
||||
let name = parts[0].to_string();
|
||||
let entry = packages.entry(name.clone()).or_insert_with(|| {
|
||||
(
|
||||
RepoInfo {
|
||||
name,
|
||||
versions: 0,
|
||||
size: 0,
|
||||
updated: "N/A".to_string(),
|
||||
},
|
||||
0,
|
||||
)
|
||||
});
|
||||
if key.ends_with("/metadata.json") {
|
||||
if let Some(name) = key
|
||||
.strip_prefix("npm/")
|
||||
.and_then(|s| s.strip_suffix("/metadata.json"))
|
||||
{
|
||||
// Parse metadata to get version count and info
|
||||
if let Ok(data) = storage.get(key).await {
|
||||
if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||
let versions_count = metadata
|
||||
.get("versions")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
if parts.len() >= 3 && parts[1] == "tarballs" {
|
||||
entry.0.versions += 1;
|
||||
if let Some(meta) = storage.stat(key).await {
|
||||
entry.0.size += meta.size;
|
||||
if meta.modified > entry.1 {
|
||||
entry.1 = meta.modified;
|
||||
entry.0.updated = format_timestamp(meta.modified);
|
||||
}
|
||||
// Calculate total size from dist.unpackedSize or estimate
|
||||
let total_size: u64 = metadata
|
||||
.get("versions")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|versions| {
|
||||
versions
|
||||
.values()
|
||||
.filter_map(|v| {
|
||||
v.get("dist")
|
||||
.and_then(|d| d.get("unpackedSize"))
|
||||
.and_then(|s| s.as_u64())
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Get latest version time for "updated"
|
||||
let updated = metadata
|
||||
.get("time")
|
||||
.and_then(|t| t.get("modified"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s[..10].to_string()) // Take just date part
|
||||
.unwrap_or_else(|| "N/A".to_string());
|
||||
|
||||
packages.insert(
|
||||
name.to_string(),
|
||||
RepoInfo {
|
||||
name: name.to_string(),
|
||||
versions: versions_count,
|
||||
size: total_size,
|
||||
updated,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<_> = packages.into_values().map(|(r, _)| r).collect();
|
||||
let mut result: Vec<_> = packages.into_values().collect();
|
||||
result.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn get_npm_detail(storage: &Storage, name: &str) -> PackageDetail {
|
||||
let prefix = format!("npm/{}/tarballs/", name);
|
||||
let keys = storage.list(&prefix).await;
|
||||
let metadata_key = format!("npm/{}/metadata.json", name);
|
||||
|
||||
let mut versions = Vec::new();
|
||||
for key in &keys {
|
||||
if let Some(tarball) = key.strip_prefix(&prefix) {
|
||||
if let Some(version) = tarball
|
||||
.strip_prefix(&format!("{}-", name))
|
||||
.and_then(|s| s.strip_suffix(".tgz"))
|
||||
{
|
||||
let (size, published) = if let Some(meta) = storage.stat(key).await {
|
||||
(meta.size, format_timestamp(meta.modified))
|
||||
} else {
|
||||
(0, "N/A".to_string())
|
||||
};
|
||||
versions.push(VersionInfo {
|
||||
version: version.to_string(),
|
||||
size,
|
||||
published,
|
||||
});
|
||||
|
||||
// Parse metadata.json for version info
|
||||
if let Ok(data) = storage.get(&metadata_key).await {
|
||||
if let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&data) {
|
||||
if let Some(versions_obj) = metadata.get("versions").and_then(|v| v.as_object()) {
|
||||
let time_obj = metadata.get("time").and_then(|t| t.as_object());
|
||||
|
||||
for (version, info) in versions_obj {
|
||||
let size = info
|
||||
.get("dist")
|
||||
.and_then(|d| d.get("unpackedSize"))
|
||||
.and_then(|s| s.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let published = time_obj
|
||||
.and_then(|t| t.get(version))
|
||||
.and_then(|p| p.as_str())
|
||||
.map(|s| s[..10].to_string())
|
||||
.unwrap_or_else(|| "N/A".to_string());
|
||||
|
||||
versions.push(VersionInfo {
|
||||
version: version.clone(),
|
||||
size,
|
||||
published,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by version (semver-like, newest first)
|
||||
versions.sort_by(|a, b| {
|
||||
let a_parts: Vec<u32> = a
|
||||
.version
|
||||
.split('.')
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let b_parts: Vec<u32> = b
|
||||
.version
|
||||
.split('.')
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
b_parts.cmp(&a_parts)
|
||||
});
|
||||
|
||||
PackageDetail { versions }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_cargo_crates(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("cargo/").await;
|
||||
|
||||
@@ -707,6 +869,7 @@ pub async fn get_cargo_detail(storage: &Storage, name: &str) -> PackageDetail {
|
||||
PackageDetail { versions }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_pypi_packages(storage: &Storage) -> Vec<RepoInfo> {
|
||||
let keys = storage.list("pypi/").await;
|
||||
|
||||
@@ -773,6 +936,32 @@ pub async fn get_pypi_detail(storage: &Storage, name: &str) -> PackageDetail {
|
||||
PackageDetail { versions }
|
||||
}
|
||||
|
||||
pub async fn get_go_detail(storage: &Storage, module: &str) -> PackageDetail {
|
||||
let prefix = format!("go/{}/@v/", module);
|
||||
let keys = storage.list(&prefix).await;
|
||||
|
||||
let mut versions = Vec::new();
|
||||
for key in keys.iter().filter(|k| k.ends_with(".zip")) {
|
||||
if let Some(rest) = key.strip_prefix(&prefix) {
|
||||
if let Some(version) = rest.strip_suffix(".zip") {
|
||||
let (size, published) = if let Some(meta) = storage.stat(key).await {
|
||||
(meta.size, format_timestamp(meta.modified))
|
||||
} else {
|
||||
(0, "N/A".to_string())
|
||||
};
|
||||
versions.push(VersionInfo {
|
||||
version: version.to_string(),
|
||||
size,
|
||||
published,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
versions.sort_by(|a, b| b.version.cmp(&a.version));
|
||||
PackageDetail { versions }
|
||||
}
|
||||
|
||||
fn extract_pypi_version(name: &str, filename: &str) -> Option<String> {
|
||||
// Handle both .tar.gz and .whl files
|
||||
let clean_name = name.replace('-', "_");
|
||||
@@ -796,3 +985,26 @@ fn extract_pypi_version(name: &str, filename: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_raw_detail(storage: &Storage, group: &str) -> PackageDetail {
|
||||
let prefix = format!("raw/{}/", group);
|
||||
let keys = storage.list(&prefix).await;
|
||||
|
||||
let mut versions = Vec::new();
|
||||
for key in &keys {
|
||||
if let Some(filename) = key.strip_prefix(&prefix) {
|
||||
let (size, published) = if let Some(meta) = storage.stat(key).await {
|
||||
(meta.size, format_timestamp(meta.modified))
|
||||
} else {
|
||||
(0, "N/A".to_string())
|
||||
};
|
||||
versions.push(VersionInfo {
|
||||
version: filename.to_string(),
|
||||
size,
|
||||
published,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
PackageDetail { versions }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use super::i18n::{get_translations, Lang, Translations};
|
||||
|
||||
/// Application version from Cargo.toml
|
||||
@@ -87,7 +90,7 @@ fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
|
||||
let docker_icon = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M6 2h12a1 1 0 011 1v8a1 1 0 01-1 1H6a1 1 0 01-1-1V3a1 1 0 011-1zm0 2v2h12V4H6zm0 3v2h12V7H6zM2 14h8a1 1 0 011 1v6a1 1 0 01-1 1H2a1 1 0 01-1-1v-6a1 1 0 011-1zm0 2v1.5h8V16H2zM14 14h8a1 1 0 011 1v6a1 1 0 01-1 1h-8a1 1 0 01-1-1v-6a1 1 0 011-1zm0 2v1.5h8V16h-8z"/>"#;
|
||||
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||
|
||||
// Dashboard label is translated, registry names stay as-is
|
||||
@@ -106,6 +109,13 @@ fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
|
||||
("npm", "/ui/npm", "npm", npm_icon, false),
|
||||
("cargo", "/ui/cargo", "Cargo", cargo_icon, false),
|
||||
("pypi", "/ui/pypi", "PyPI", pypi_icon, false),
|
||||
(
|
||||
"go",
|
||||
"/ui/go",
|
||||
"Go",
|
||||
r#"<path fill="currentColor" d="M2.64 9.56s.24-.14.65-.38c.41-.24.97-.5 1.63-.7A7.85 7.85 0 017.53 8c.86 0 1.67.17 2.37.52.7.35 1.26.87 1.63 1.51.37.64.54 1.41.54 2.27v.2h-2.7v-.16c0-.47-.09-.86-.28-1.15a1.7 1.7 0 00-.77-.67 2.7 2.7 0 00-1.14-.22c-.56 0-1.06.13-1.46.4-.41.27-.72.66-.93 1.16-.21.5-.31 1.1-.31 1.8 0 .69.1 1.28.32 1.78.21.5.53.88.94 1.15.41.27.9.4 1.47.4.38 0 .73-.06 1.04-.17.31-.12.56-.29.74-.52.19-.23.29-.51.29-.84v-.14H7.15v-1.76h5.07v1.3c0 .8-.17 1.48-.52 2.04a3.46 3.46 0 01-1.5 1.3c-.66.3-1.44.45-2.35.45-.99 0-1.87-.18-2.63-.55a4.2 4.2 0 01-1.77-1.59C3.15 14.82 3 13.94 3 12.89v-.28c0-1.04.16-1.93.48-2.65a3.08 3.08 0 01-.84-.4zm12.1-1.34c.92 0 1.74.18 2.44.55a3.96 3.96 0 011.66 1.59c.4.7.6 1.54.6 2.53v.28c0 .99-.2 1.83-.6 2.53a3.96 3.96 0 01-1.66 1.59c-.7.37-1.52.55-2.44.55s-1.74-.18-2.44-.55a3.96 3.96 0 01-1.66-1.59c-.4-.7-.6-1.54-.6-2.53v-.28c0-.99.2-1.83.6-2.53a3.96 3.96 0 011.66-1.59c.7-.37 1.52-.55 2.44-.55zm0 2.12c-.44 0-.82.12-1.14.37-.32.24-.56.6-.73 1.06-.17.46-.26 1.01-.26 1.65v.28c0 .64.09 1.19.26 1.65.17.46.41.82.73 1.06.32.25.7.37 1.14.37.44 0 .82-.12 1.14-.37.32-.24.56-.6.73-1.06.17-.46.26-1.01.26-1.65v-.28c0-.64-.09-1.19-.26-1.65a2.17 2.17 0 00-.73-1.06 1.78 1.78 0 00-1.14-.37z"/>"#,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| {
|
||||
@@ -137,7 +147,7 @@ fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
|
||||
<div id="sidebar" class="fixed md:static inset-y-0 left-0 z-50 w-64 bg-slate-800 text-white flex flex-col transform -translate-x-full md:translate-x-0 transition-transform duration-200 ease-in-out">
|
||||
<div class="h-16 flex items-center justify-between px-6 border-b border-slate-700">
|
||||
<div class="flex items-center">
|
||||
<span class="text-2xl font-bold tracking-tight">N<span class="inline-block w-5 h-5 rounded-full border-2 border-current align-middle relative -top-0.5 mx-0.5"></span>RA</span>
|
||||
<span class="text-xl font-bold tracking-tight">N<span class="inline-block w-4 h-4 rounded-full border-2 border-current align-middle mx-px"></span>RA</span>
|
||||
</div>
|
||||
<button onclick="toggleSidebar()" class="md:hidden p-1 rounded-lg hover:bg-slate-700">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -274,15 +284,15 @@ pub fn render_registry_card(
|
||||
) -> String {
|
||||
format!(
|
||||
r##"
|
||||
<a href="{}" id="registry-{}" class="block bg-[#1e293b] rounded-lg border border-slate-700 p-4 md:p-6 hover:border-blue-400 transition-all">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<svg class="w-8 h-8 text-slate-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<a href="{}" id="registry-{}" class="block bg-[#1e293b] rounded-lg border border-slate-700 p-3 hover:border-blue-400 transition-all">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<svg class="w-6 h-6 text-slate-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
{}
|
||||
</svg>
|
||||
<span class="text-xs font-medium text-green-400 bg-green-400/10 px-2 py-1 rounded-full">{}</span>
|
||||
<span class="text-[10px] font-medium text-green-400 bg-green-400/10 px-1.5 py-0.5 rounded-full">{}</span>
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-slate-200 mb-2">{}</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div class="text-sm font-semibold text-slate-200 mb-2">{}</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<div>
|
||||
<span class="text-slate-500">{}</span>
|
||||
<div class="text-slate-300 font-medium">{}</div>
|
||||
@@ -330,9 +340,9 @@ pub fn render_mount_points_table(
|
||||
format!(
|
||||
r##"
|
||||
<tr class="border-b border-slate-700">
|
||||
<td class="py-3 text-slate-300">{}</td>
|
||||
<td class="py-3 font-mono text-blue-400">{}</td>
|
||||
<td class="py-3 text-slate-400">{}</td>
|
||||
<td class="px-4 py-3 text-slate-300">{}</td>
|
||||
<td class="px-4 py-3 font-mono text-blue-400">{}</td>
|
||||
<td class="px-4 py-3 text-slate-400">{}</td>
|
||||
</tr>
|
||||
"##,
|
||||
registry, mount_path, proxy_display
|
||||
@@ -355,7 +365,7 @@ pub fn render_mount_points_table(
|
||||
<th class="px-4 py-2">{}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="px-4">
|
||||
<tbody>
|
||||
{}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -385,11 +395,11 @@ pub fn render_activity_row(
|
||||
format!(
|
||||
r##"
|
||||
<tr class="border-b border-slate-700/50 text-sm">
|
||||
<td class="py-2 text-slate-500">{}</td>
|
||||
<td class="py-2 font-medium {}"><span class="px-2 py-0.5 bg-slate-700 rounded">{}</span></td>
|
||||
<td class="py-2 text-slate-300 font-mono text-xs">{}</td>
|
||||
<td class="py-2 text-slate-400">{}</td>
|
||||
<td class="py-2 text-slate-500">{}</td>
|
||||
<td class="px-4 py-2 text-slate-500">{}</td>
|
||||
<td class="px-4 py-2 font-medium {}"><span class="px-2 py-0.5 bg-slate-700 rounded">{}</span></td>
|
||||
<td class="px-4 py-2 text-slate-300 font-mono text-xs">{}</td>
|
||||
<td class="px-4 py-2 text-slate-400">{}</td>
|
||||
<td class="px-4 py-2 text-slate-500">{}</td>
|
||||
</tr>
|
||||
"##,
|
||||
timestamp,
|
||||
@@ -421,7 +431,7 @@ pub fn render_activity_log(rows: &str, t: &Translations) -> String {
|
||||
<th class="px-4 py-2">{}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="px-4">
|
||||
<tbody>
|
||||
{}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -487,7 +497,7 @@ fn sidebar(active_page: Option<&str>) -> String {
|
||||
let docker_icon = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||
let maven_icon = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||
let npm_icon = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||
let cargo_icon = r#"<path fill="currentColor" d="M6 2h12a1 1 0 011 1v8a1 1 0 01-1 1H6a1 1 0 01-1-1V3a1 1 0 011-1zm0 2v2h12V4H6zm0 3v2h12V7H6zM2 14h8a1 1 0 011 1v6a1 1 0 01-1 1H2a1 1 0 01-1-1v-6a1 1 0 011-1zm0 2v1.5h8V16H2zM14 14h8a1 1 0 011 1v6a1 1 0 01-1 1h-8a1 1 0 01-1-1v-6a1 1 0 011-1zm0 2v1.5h8V16h-8z"/>"#;
|
||||
let pypi_icon = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||
|
||||
let nav_items = [
|
||||
@@ -503,6 +513,13 @@ fn sidebar(active_page: Option<&str>) -> String {
|
||||
("npm", "/ui/npm", "npm", npm_icon, false),
|
||||
("cargo", "/ui/cargo", "Cargo", cargo_icon, false),
|
||||
("pypi", "/ui/pypi", "PyPI", pypi_icon, false),
|
||||
(
|
||||
"go",
|
||||
"/ui/go",
|
||||
"Go",
|
||||
r#"<path fill="currentColor" d="M2.64 9.56s.24-.14.65-.38c.41-.24.97-.5 1.63-.7A7.85 7.85 0 017.53 8c.86 0 1.67.17 2.37.52.7.35 1.26.87 1.63 1.51.37.64.54 1.41.54 2.27v.2h-2.7v-.16c0-.47-.09-.86-.28-1.15a1.7 1.7 0 00-.77-.67 2.7 2.7 0 00-1.14-.22c-.56 0-1.06.13-1.46.4-.41.27-.72.66-.93 1.16-.21.5-.31 1.1-.31 1.8 0 .69.1 1.28.32 1.78.21.5.53.88.94 1.15.41.27.9.4 1.47.4.38 0 .73-.06 1.04-.17.31-.12.56-.29.74-.52.19-.23.29-.51.29-.84v-.14H7.15v-1.76h5.07v1.3c0 .8-.17 1.48-.52 2.04a3.46 3.46 0 01-1.5 1.3c-.66.3-1.44.45-2.35.45-.99 0-1.87-.18-2.63-.55a4.2 4.2 0 01-1.77-1.59C3.15 14.82 3 13.94 3 12.89v-.28c0-1.04.16-1.93.48-2.65a3.08 3.08 0 01-.84-.4zm12.1-1.34c.92 0 1.74.18 2.44.55a3.96 3.96 0 011.66 1.59c.4.7.6 1.54.6 2.53v.28c0 .99-.2 1.83-.6 2.53a3.96 3.96 0 01-1.66 1.59c-.7.37-1.52.55-2.44.55s-1.74-.18-2.44-.55a3.96 3.96 0 01-1.66-1.59c-.4-.7-.6-1.54-.6-2.53v-.28c0-.99.2-1.83.6-2.53a3.96 3.96 0 011.66-1.59c.7-.37 1.52-.55 2.44-.55zm0 2.12c-.44 0-.82.12-1.14.37-.32.24-.56.6-.73 1.06-.17.46-.26 1.01-.26 1.65v.28c0 .64.09 1.19.26 1.65.17.46.41.82.73 1.06.32.25.7.37 1.14.37.44 0 .82-.12 1.14-.37.32-.24.56-.6.73-1.06.17-.46.26-1.01.26-1.65v-.28c0-.64-.09-1.19-.26-1.65a2.17 2.17 0 00-.73-1.06 1.78 1.78 0 00-1.14-.37z"/>"#,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| {
|
||||
@@ -610,7 +627,9 @@ pub mod icons {
|
||||
pub const DOCKER: &str = r#"<path fill="currentColor" d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.186m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.185-.186h-2.12a.186.186 0 00-.185.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/>"#;
|
||||
pub const MAVEN: &str = r#"<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>"#;
|
||||
pub const NPM: &str = r#"<path fill="currentColor" d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z"/>"#;
|
||||
pub const CARGO: &str = r#"<path fill="currentColor" d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5l-3-4zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm13.5-9l1.96 2.5H17V9.5h2.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>"#;
|
||||
pub const CARGO: &str = r#"<path fill="currentColor" d="M6 2h12a1 1 0 011 1v8a1 1 0 01-1 1H6a1 1 0 01-1-1V3a1 1 0 011-1zm0 2v2h12V4H6zm0 3v2h12V7H6zM2 14h8a1 1 0 011 1v6a1 1 0 01-1 1H2a1 1 0 01-1-1v-6a1 1 0 011-1zm0 2v1.5h8V16H2zM14 14h8a1 1 0 011 1v6a1 1 0 01-1 1h-8a1 1 0 01-1-1v-6a1 1 0 011-1zm0 2v1.5h8V16h-8z"/>"#;
|
||||
pub const GO: &str = r#"<path fill="currentColor" d="M2.64 9.56s.24-.14.65-.38c.41-.24.97-.5 1.63-.7A7.85 7.85 0 017.53 8c.86 0 1.67.17 2.37.52.7.35 1.26.87 1.63 1.51.37.64.54 1.41.54 2.27v.2h-2.7v-.16c0-.47-.09-.86-.28-1.15a1.7 1.7 0 00-.77-.67 2.7 2.7 0 00-1.14-.22c-.56 0-1.06.13-1.46.4-.41.27-.72.66-.93 1.16-.21.5-.31 1.1-.31 1.8 0 .69.1 1.28.32 1.78.21.5.53.88.94 1.15.41.27.9.4 1.47.4.38 0 .73-.06 1.04-.17.31-.12.56-.29.74-.52.19-.23.29-.51.29-.84v-.14H7.15v-1.76h5.07v1.3c0 .8-.17 1.48-.52 2.04a3.46 3.46 0 01-1.5 1.3c-.66.3-1.44.45-2.35.45-.99 0-1.87-.18-2.63-.55a4.2 4.2 0 01-1.77-1.59C3.15 14.82 3 13.94 3 12.89v-.28c0-1.04.16-1.93.48-2.65a3.08 3.08 0 01-.84-.4zm12.1-1.34c.92 0 1.74.18 2.44.55a3.96 3.96 0 011.66 1.59c.4.7.6 1.54.6 2.53v.28c0 .99-.2 1.83-.6 2.53a3.96 3.96 0 01-1.66 1.59c-.7.37-1.52.55-2.44.55s-1.74-.18-2.44-.55a3.96 3.96 0 01-1.66-1.59c-.4-.7-.6-1.54-.6-2.53v-.28c0-.99.2-1.83.6-2.53a3.96 3.96 0 011.66-1.59c.7-.37 1.52-.55 2.44-.55zm0 2.12c-.44 0-.82.12-1.14.37-.32.24-.56.6-.73 1.06-.17.46-.26 1.01-.26 1.65v.28c0 .64.09 1.19.26 1.65.17.46.41.82.73 1.06.32.25.7.37 1.14.37.44 0 .82-.12 1.14-.37.32-.24.56-.6.73-1.06.17-.46.26-1.01.26-1.65v-.28c0-.64-.09-1.19-.26-1.65a2.17 2.17 0 00-.73-1.06 1.78 1.78 0 00-1.14-.37z"/>"#;
|
||||
pub const RAW: &str = r#"<path fill="currentColor" d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z"/>"#;
|
||||
pub const PYPI: &str = r#"<path fill="currentColor" d="M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.83l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.23l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05L0 11.97l.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.24l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05 1.07.13zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09-.33.22zM21.1 6.11l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01.21.03zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08-.33.23z"/>"#;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/// Internationalization support for the UI
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,9 +1,13 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
mod api;
|
||||
mod components;
|
||||
pub mod components;
|
||||
pub mod i18n;
|
||||
mod logo;
|
||||
mod templates;
|
||||
|
||||
use crate::repo_index::paginate;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
@@ -22,6 +26,15 @@ struct LangQuery {
|
||||
lang: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct ListQuery {
|
||||
lang: Option<String>,
|
||||
page: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE: usize = 50;
|
||||
|
||||
fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
|
||||
// Priority: query param > cookie > default
|
||||
if let Some(ref lang) = query.lang {
|
||||
@@ -41,6 +54,23 @@ fn extract_lang(query: &Query<LangQuery>, cookie_header: Option<&str>) -> Lang {
|
||||
Lang::default()
|
||||
}
|
||||
|
||||
fn extract_lang_from_list(query: &ListQuery, cookie_header: Option<&str>) -> Lang {
|
||||
if let Some(ref lang) = query.lang {
|
||||
return Lang::from_str(lang);
|
||||
}
|
||||
|
||||
if let Some(cookies) = cookie_header {
|
||||
for part in cookies.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(value) = part.strip_prefix("nora_lang=") {
|
||||
return Lang::from_str(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Lang::default()
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// UI Pages
|
||||
@@ -57,6 +87,10 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/ui/cargo/{name}", get(cargo_detail))
|
||||
.route("/ui/pypi", get(pypi_list))
|
||||
.route("/ui/pypi/{name}", get(pypi_detail))
|
||||
.route("/ui/go", get(go_list))
|
||||
.route("/ui/go/{*name}", get(go_detail))
|
||||
.route("/ui/raw", get(raw_list))
|
||||
.route("/ui/raw/{*name}", get(raw_detail))
|
||||
// API endpoints for HTMX
|
||||
.route("/api/ui/stats", get(api_stats))
|
||||
.route("/api/ui/dashboard", get(api_dashboard))
|
||||
@@ -82,18 +116,23 @@ async fn dashboard(
|
||||
// Docker pages
|
||||
async fn docker_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
Query(query): Query<ListQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(
|
||||
&Query(query),
|
||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let repos = get_docker_repos(&state.storage).await;
|
||||
Html(render_registry_list(
|
||||
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||
|
||||
let all_repos = state.repo_index.get("docker", &state.storage).await;
|
||||
let (repos, total) = paginate(&all_repos, page, limit);
|
||||
|
||||
Html(render_registry_list_paginated(
|
||||
"docker",
|
||||
"Docker Registry",
|
||||
&repos,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
lang,
|
||||
))
|
||||
}
|
||||
@@ -115,18 +154,23 @@ async fn docker_detail(
|
||||
// Maven pages
|
||||
async fn maven_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
Query(query): Query<ListQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(
|
||||
&Query(query),
|
||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let repos = get_maven_repos(&state.storage).await;
|
||||
Html(render_registry_list(
|
||||
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||
|
||||
let all_repos = state.repo_index.get("maven", &state.storage).await;
|
||||
let (repos, total) = paginate(&all_repos, page, limit);
|
||||
|
||||
Html(render_registry_list_paginated(
|
||||
"maven",
|
||||
"Maven Repository",
|
||||
&repos,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
lang,
|
||||
))
|
||||
}
|
||||
@@ -148,15 +192,25 @@ async fn maven_detail(
|
||||
// npm pages
|
||||
async fn npm_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
Query(query): Query<ListQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(
|
||||
&Query(query),
|
||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let packages = get_npm_packages(&state.storage).await;
|
||||
Html(render_registry_list("npm", "npm Registry", &packages, lang))
|
||||
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||
|
||||
let all_packages = state.repo_index.get("npm", &state.storage).await;
|
||||
let (packages, total) = paginate(&all_packages, page, limit);
|
||||
|
||||
Html(render_registry_list_paginated(
|
||||
"npm",
|
||||
"npm Registry",
|
||||
&packages,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
lang,
|
||||
))
|
||||
}
|
||||
|
||||
async fn npm_detail(
|
||||
@@ -176,18 +230,23 @@ async fn npm_detail(
|
||||
// Cargo pages
|
||||
async fn cargo_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
Query(query): Query<ListQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(
|
||||
&Query(query),
|
||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let crates = get_cargo_crates(&state.storage).await;
|
||||
Html(render_registry_list(
|
||||
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||
|
||||
let all_crates = state.repo_index.get("cargo", &state.storage).await;
|
||||
let (crates, total) = paginate(&all_crates, page, limit);
|
||||
|
||||
Html(render_registry_list_paginated(
|
||||
"cargo",
|
||||
"Cargo Registry",
|
||||
&crates,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
lang,
|
||||
))
|
||||
}
|
||||
@@ -209,18 +268,23 @@ async fn cargo_detail(
|
||||
// PyPI pages
|
||||
async fn pypi_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<LangQuery>,
|
||||
Query(query): Query<ListQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(
|
||||
&Query(query),
|
||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let packages = get_pypi_packages(&state.storage).await;
|
||||
Html(render_registry_list(
|
||||
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||
|
||||
let all_packages = state.repo_index.get("pypi", &state.storage).await;
|
||||
let (packages, total) = paginate(&all_packages, page, limit);
|
||||
|
||||
Html(render_registry_list_paginated(
|
||||
"pypi",
|
||||
"PyPI Repository",
|
||||
&packages,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
lang,
|
||||
))
|
||||
}
|
||||
@@ -238,3 +302,79 @@ async fn pypi_detail(
|
||||
let detail = get_pypi_detail(&state.storage, &name).await;
|
||||
Html(render_package_detail("pypi", &name, &detail, lang))
|
||||
}
|
||||
|
||||
// Go pages
|
||||
async fn go_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<ListQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||
|
||||
let all_modules = state.repo_index.get("go", &state.storage).await;
|
||||
let (modules, total) = paginate(&all_modules, page, limit);
|
||||
|
||||
Html(render_registry_list_paginated(
|
||||
"go",
|
||||
"Go Modules",
|
||||
&modules,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
lang,
|
||||
))
|
||||
}
|
||||
|
||||
async fn go_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(
|
||||
&Query(query),
|
||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let detail = get_go_detail(&state.storage, &name).await;
|
||||
Html(render_package_detail("go", &name, &detail, lang))
|
||||
}
|
||||
|
||||
// Raw pages
|
||||
async fn raw_list(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<ListQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang_from_list(&query, headers.get("cookie").and_then(|v| v.to_str().ok()));
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let limit = query.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(100);
|
||||
|
||||
let all_files = state.repo_index.get("raw", &state.storage).await;
|
||||
let (files, total) = paginate(&all_files, page, limit);
|
||||
|
||||
Html(render_registry_list_paginated(
|
||||
"raw",
|
||||
"Raw Storage",
|
||||
&files,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
lang,
|
||||
))
|
||||
}
|
||||
|
||||
async fn raw_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<LangQuery>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let lang = extract_lang(
|
||||
&Query(query),
|
||||
headers.get("cookie").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
let detail = get_raw_detail(&state.storage, &name).await;
|
||||
Html(render_package_detail("raw", &name, &detail, lang))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail, RepoInfo};
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use super::api::{DashboardResponse, DockerDetail, MavenDetail, PackageDetail};
|
||||
use super::components::*;
|
||||
use super::i18n::{get_translations, Lang};
|
||||
use crate::repo_index::RepoInfo;
|
||||
|
||||
/// Renders the main dashboard page with dark theme
|
||||
pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
|
||||
@@ -20,22 +24,8 @@ pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
|
||||
.registry_stats
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let icon = match r.name.as_str() {
|
||||
"docker" => icons::DOCKER,
|
||||
"maven" => icons::MAVEN,
|
||||
"npm" => icons::NPM,
|
||||
"cargo" => icons::CARGO,
|
||||
"pypi" => icons::PYPI,
|
||||
_ => icons::DOCKER,
|
||||
};
|
||||
let display_name = match r.name.as_str() {
|
||||
"docker" => "Docker",
|
||||
"maven" => "Maven",
|
||||
"npm" => "npm",
|
||||
"cargo" => "Cargo",
|
||||
"pypi" => "PyPI",
|
||||
_ => &r.name,
|
||||
};
|
||||
let icon = get_registry_icon(&r.name);
|
||||
let display_name = get_registry_title(&r.name);
|
||||
render_registry_card(
|
||||
display_name,
|
||||
icon,
|
||||
@@ -70,16 +60,56 @@ pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
|
||||
t.no_activity
|
||||
)
|
||||
} else {
|
||||
data.activity
|
||||
// Group consecutive identical entries (same action+artifact+registry+source)
|
||||
struct GroupedActivity {
|
||||
time: String,
|
||||
action: String,
|
||||
artifact: String,
|
||||
registry: String,
|
||||
source: String,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
let mut grouped: Vec<GroupedActivity> = Vec::new();
|
||||
for entry in &data.activity {
|
||||
let action = entry.action.to_string();
|
||||
let is_repeat = grouped.last().is_some_and(|last| {
|
||||
last.action == action
|
||||
&& last.artifact == entry.artifact
|
||||
&& last.registry == entry.registry
|
||||
&& last.source == entry.source
|
||||
});
|
||||
|
||||
if is_repeat {
|
||||
if let Some(last) = grouped.last_mut() {
|
||||
last.count += 1;
|
||||
}
|
||||
} else {
|
||||
grouped.push(GroupedActivity {
|
||||
time: format_relative_time(&entry.timestamp),
|
||||
action,
|
||||
artifact: entry.artifact.clone(),
|
||||
registry: entry.registry.clone(),
|
||||
source: entry.source.clone(),
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
grouped
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let time_ago = format_relative_time(&entry.timestamp);
|
||||
.map(|g| {
|
||||
let display_artifact = if g.count > 1 {
|
||||
format!("{} (x{})", g.artifact, g.count)
|
||||
} else {
|
||||
g.artifact.clone()
|
||||
};
|
||||
render_activity_row(
|
||||
&time_ago,
|
||||
&entry.action.to_string(),
|
||||
&entry.artifact,
|
||||
&entry.registry,
|
||||
&entry.source,
|
||||
&g.time,
|
||||
&g.action,
|
||||
&display_artifact,
|
||||
&g.registry,
|
||||
&g.source,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
@@ -111,7 +141,7 @@ pub fn render_dashboard(data: &DashboardResponse, lang: Lang) -> String {
|
||||
|
||||
{}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 mb-6">
|
||||
{}
|
||||
</div>
|
||||
|
||||
@@ -163,6 +193,7 @@ fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
}
|
||||
|
||||
/// Renders a registry list page (docker, maven, npm, cargo, pypi)
|
||||
#[allow(dead_code)]
|
||||
pub fn render_registry_list(
|
||||
registry_type: &str,
|
||||
title: &str,
|
||||
@@ -273,6 +304,220 @@ pub fn render_registry_list(
|
||||
layout_dark(title, &content, Some(registry_type), "", lang)
|
||||
}
|
||||
|
||||
/// Renders a registry list page with pagination
|
||||
pub fn render_registry_list_paginated(
|
||||
registry_type: &str,
|
||||
title: &str,
|
||||
repos: &[RepoInfo],
|
||||
page: usize,
|
||||
limit: usize,
|
||||
total: usize,
|
||||
lang: Lang,
|
||||
) -> String {
|
||||
let t = get_translations(lang);
|
||||
let icon = get_registry_icon(registry_type);
|
||||
|
||||
let table_rows = if repos.is_empty() && page == 1 {
|
||||
format!(
|
||||
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
|
||||
<div class="text-4xl mb-2">📭</div>
|
||||
<div>{}</div>
|
||||
<div class="text-sm mt-1">{}</div>
|
||||
</td></tr>"##,
|
||||
t.no_repos_found, t.push_first_artifact
|
||||
)
|
||||
} else if repos.is_empty() {
|
||||
r##"<tr><td colspan="4" class="px-6 py-12 text-center text-slate-500">
|
||||
<div class="text-4xl mb-2">📭</div>
|
||||
<div>No more items on this page</div>
|
||||
</td></tr>"##
|
||||
.to_string()
|
||||
} else {
|
||||
repos
|
||||
.iter()
|
||||
.map(|repo| {
|
||||
let detail_url =
|
||||
format!("/ui/{}/{}", registry_type, encode_uri_component(&repo.name));
|
||||
format!(
|
||||
r##"
|
||||
<tr class="hover:bg-slate-700 cursor-pointer" onclick="window.location='{}'">
|
||||
<td class="px-6 py-4">
|
||||
<a href="{}" class="text-blue-400 hover:text-blue-300 font-medium">{}</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-400">{}</td>
|
||||
<td class="px-6 py-4 text-slate-400">{}</td>
|
||||
<td class="px-6 py-4 text-slate-500 text-sm">{}</td>
|
||||
</tr>
|
||||
"##,
|
||||
detail_url,
|
||||
detail_url,
|
||||
html_escape(&repo.name),
|
||||
repo.versions,
|
||||
format_size(repo.size),
|
||||
&repo.updated
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
};
|
||||
|
||||
let version_label = match registry_type {
|
||||
"docker" => t.tags,
|
||||
_ => t.versions,
|
||||
};
|
||||
|
||||
// Pagination
|
||||
let total_pages = total.div_ceil(limit);
|
||||
let start_item = if total == 0 {
|
||||
0
|
||||
} else {
|
||||
(page - 1) * limit + 1
|
||||
};
|
||||
let end_item = (start_item + repos.len()).saturating_sub(1);
|
||||
|
||||
let pagination = if total_pages > 1 {
|
||||
let mut pages_html = String::new();
|
||||
|
||||
// Previous button
|
||||
if page > 1 {
|
||||
pages_html.push_str(&format!(
|
||||
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">←</a>"##,
|
||||
registry_type, page - 1, limit
|
||||
));
|
||||
} else {
|
||||
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">←</span>"##);
|
||||
}
|
||||
|
||||
// Page numbers (show max 7 pages around current)
|
||||
let start_page = if page <= 4 { 1 } else { page - 3 };
|
||||
let end_page = (start_page + 6).min(total_pages);
|
||||
|
||||
if start_page > 1 {
|
||||
pages_html.push_str(&format!(
|
||||
r##"<a href="/ui/{}?page=1&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">1</a>"##,
|
||||
registry_type, limit
|
||||
));
|
||||
if start_page > 2 {
|
||||
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
|
||||
}
|
||||
}
|
||||
|
||||
for p in start_page..=end_page {
|
||||
if p == page {
|
||||
pages_html.push_str(&format!(
|
||||
r##"<span class="px-3 py-1 rounded bg-blue-600 text-white font-medium">{}</span>"##,
|
||||
p
|
||||
));
|
||||
} else {
|
||||
pages_html.push_str(&format!(
|
||||
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
|
||||
registry_type, p, limit, p
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if end_page < total_pages {
|
||||
if end_page < total_pages - 1 {
|
||||
pages_html.push_str(r##"<span class="px-2 text-slate-600">...</span>"##);
|
||||
}
|
||||
pages_html.push_str(&format!(
|
||||
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded hover:bg-slate-700 text-slate-400">{}</a>"##,
|
||||
registry_type, total_pages, limit, total_pages
|
||||
));
|
||||
}
|
||||
|
||||
// Next button
|
||||
if page < total_pages {
|
||||
pages_html.push_str(&format!(
|
||||
r##"<a href="/ui/{}?page={}&limit={}" class="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-300">→</a>"##,
|
||||
registry_type, page + 1, limit
|
||||
));
|
||||
} else {
|
||||
pages_html.push_str(r##"<span class="px-3 py-1 rounded bg-slate-800 text-slate-600 cursor-not-allowed">→</span>"##);
|
||||
}
|
||||
|
||||
format!(
|
||||
r##"
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div class="text-sm text-slate-500">
|
||||
Showing {}-{} of {} items
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{}
|
||||
</div>
|
||||
</div>
|
||||
"##,
|
||||
start_item, end_item, total, pages_html
|
||||
)
|
||||
} else if total > 0 {
|
||||
format!(
|
||||
r##"<div class="mt-4 text-sm text-slate-500">Showing all {} items</div>"##,
|
||||
total
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let content = format!(
|
||||
r##"
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-10 h-10 mr-3 text-slate-400" fill="currentColor" viewBox="0 0 24 24">{}</svg>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-200">{}</h1>
|
||||
<p class="text-slate-500">{} {}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
placeholder="{}"
|
||||
class="pl-10 pr-4 py-2 bg-slate-800 border border-slate-600 text-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder-slate-500"
|
||||
hx-get="/api/ui/{}/search"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#repo-table-body"
|
||||
name="q">
|
||||
<svg class="absolute left-3 top-2.5 h-5 w-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1e293b] rounded-lg shadow-sm border border-slate-700 overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-slate-800 border-b border-slate-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">{}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="repo-table-body" class="divide-y divide-slate-700">
|
||||
{}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{}
|
||||
"##,
|
||||
icon,
|
||||
title,
|
||||
total,
|
||||
t.repositories,
|
||||
t.search_placeholder,
|
||||
registry_type,
|
||||
t.name,
|
||||
version_label,
|
||||
t.size,
|
||||
t.updated,
|
||||
table_rows,
|
||||
pagination
|
||||
);
|
||||
|
||||
layout_dark(title, &content, Some(registry_type), "", lang)
|
||||
}
|
||||
|
||||
/// Renders Docker image detail page
|
||||
pub fn render_docker_detail(name: &str, detail: &DockerDetail, lang: Lang) -> String {
|
||||
let _t = get_translations(lang);
|
||||
@@ -410,6 +655,8 @@ pub fn render_package_detail(
|
||||
"pip install {} --index-url http://127.0.0.1:4000/simple",
|
||||
name
|
||||
),
|
||||
"go" => format!("GOPROXY=http://127.0.0.1:4000/go go get {}", name),
|
||||
"raw" => format!("curl -O http://127.0.0.1:4000/raw/{}/<file>", name),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
@@ -576,6 +823,8 @@ fn get_registry_icon(registry_type: &str) -> &'static str {
|
||||
"npm" => icons::NPM,
|
||||
"cargo" => icons::CARGO,
|
||||
"pypi" => icons::PYPI,
|
||||
"go" => icons::GO,
|
||||
"raw" => icons::RAW,
|
||||
_ => {
|
||||
r#"<path fill="currentColor" d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>"#
|
||||
}
|
||||
@@ -589,6 +838,8 @@ fn get_registry_title(registry_type: &str) -> &'static str {
|
||||
"npm" => "npm Registry",
|
||||
"cargo" => "Cargo Registry",
|
||||
"pypi" => "PyPI Repository",
|
||||
"go" => "Go Modules",
|
||||
"raw" => "Raw Storage",
|
||||
_ => "Registry",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#![allow(dead_code)]
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//! Input validation for artifact registry paths and identifiers
|
||||
//!
|
||||
//! Provides security validation to prevent path traversal attacks and
|
||||
@@ -306,63 +308,6 @@ pub fn validate_docker_reference(reference: &str) -> Result<(), ValidationError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate Maven artifact path.
|
||||
///
|
||||
/// Maven paths follow the pattern: groupId/artifactId/version/filename
|
||||
/// Example: `org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar`
|
||||
pub fn validate_maven_path(path: &str) -> Result<(), ValidationError> {
|
||||
validate_storage_key(path)
|
||||
}
|
||||
|
||||
/// Validate npm package name.
|
||||
pub fn validate_npm_name(name: &str) -> Result<(), ValidationError> {
|
||||
if name.is_empty() {
|
||||
return Err(ValidationError::EmptyInput);
|
||||
}
|
||||
|
||||
if name.len() > 214 {
|
||||
return Err(ValidationError::TooLong {
|
||||
max: 214,
|
||||
actual: name.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
if name.contains("..") {
|
||||
return Err(ValidationError::PathTraversal);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate Cargo crate name.
|
||||
pub fn validate_crate_name(name: &str) -> Result<(), ValidationError> {
|
||||
if name.is_empty() {
|
||||
return Err(ValidationError::EmptyInput);
|
||||
}
|
||||
|
||||
if name.len() > 64 {
|
||||
return Err(ValidationError::TooLong {
|
||||
max: 64,
|
||||
actual: name.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
if name.contains("..") || name.contains('/') {
|
||||
return Err(ValidationError::PathTraversal);
|
||||
}
|
||||
|
||||
// Crate names: alphanumeric, underscores, hyphens
|
||||
for c in name.chars() {
|
||||
if !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-') {
|
||||
return Err(ValidationError::ForbiddenCharacter(c));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[package]
|
||||
name = "nora-storage"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "S3-compatible storage server for NORA"
|
||||
|
||||
[[bin]]
|
||||
name = "nora-storage"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
toml = "0.8"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
httpdate = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
quick-xml = { version = "0.31", features = ["serialize"] }
|
||||
@@ -1,44 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub storage: StorageConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StorageConfig {
|
||||
pub data_dir: String,
|
||||
pub max_body_size: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Self {
|
||||
fs::read_to_string("config.toml")
|
||||
.ok()
|
||||
.and_then(|content| toml::from_str(&content).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server: ServerConfig {
|
||||
host: String::from("127.0.0.1"),
|
||||
port: 3000,
|
||||
},
|
||||
storage: StorageConfig {
|
||||
data_dir: String::from("data"),
|
||||
max_body_size: 1024 * 1024 * 1024, // 1GB
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
mod config;
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, put},
|
||||
Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use config::Config;
|
||||
use quick_xml::se::to_string as to_xml;
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename = "ListAllMyBucketsResult")]
|
||||
struct ListBucketsResult {
|
||||
#[serde(rename = "Buckets")]
|
||||
buckets: Buckets,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Buckets {
|
||||
#[serde(rename = "Bucket")]
|
||||
bucket: Vec<BucketInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct BucketInfo {
|
||||
#[serde(rename = "Name")]
|
||||
name: String,
|
||||
#[serde(rename = "CreationDate")]
|
||||
creation_date: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename = "ListBucketResult")]
|
||||
struct ListObjectsResult {
|
||||
#[serde(rename = "Name")]
|
||||
name: String,
|
||||
#[serde(rename = "Contents")]
|
||||
contents: Vec<ObjectInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ObjectInfo {
|
||||
#[serde(rename = "Key")]
|
||||
key: String,
|
||||
#[serde(rename = "Size")]
|
||||
size: u64,
|
||||
#[serde(rename = "LastModified")]
|
||||
last_modified: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename = "Error")]
|
||||
struct S3Error {
|
||||
#[serde(rename = "Code")]
|
||||
code: String,
|
||||
#[serde(rename = "Message")]
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn xml_response<T: Serialize>(data: T) -> Response {
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
|
||||
to_xml(&data).unwrap_or_default()
|
||||
);
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/xml")],
|
||||
xml,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
||||
let error = S3Error {
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
};
|
||||
let xml = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
|
||||
to_xml(&error).unwrap_or_default()
|
||||
);
|
||||
(
|
||||
status,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/xml")],
|
||||
xml,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("nora_storage=info".parse().expect("valid directive")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::load();
|
||||
fs::create_dir_all(&config.storage.data_dir).expect("Failed to create data directory");
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
config: config.clone(),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(list_buckets))
|
||||
.route("/{bucket}", get(list_objects))
|
||||
.route("/{bucket}", put(create_bucket))
|
||||
.route("/{bucket}", delete(delete_bucket))
|
||||
.route("/{bucket}/{*key}", put(put_object))
|
||||
.route("/{bucket}/{*key}", get(get_object))
|
||||
.route("/{bucket}/{*key}", delete(delete_object))
|
||||
.layer(DefaultBodyLimit::max(config.storage.max_body_size))
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("{}:{}", config.server.host, config.server.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.expect("Failed to bind to address");
|
||||
|
||||
info!("nora-storage (S3 compatible) running on http://{}", addr);
|
||||
axum::serve(listener, app).await.expect("Server error");
|
||||
}
|
||||
|
||||
async fn list_buckets(State(state): State<Arc<AppState>>) -> Response {
|
||||
let data_dir = &state.config.storage.data_dir;
|
||||
let entries = match fs::read_dir(data_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalError",
|
||||
"Failed to read data",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let bucket_list: Vec<BucketInfo> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().into_string().ok()?;
|
||||
let modified = e.metadata().ok()?.modified().ok()?;
|
||||
let datetime: chrono::DateTime<Utc> = modified.into();
|
||||
Some(BucketInfo {
|
||||
name,
|
||||
creation_date: datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
xml_response(ListBucketsResult {
|
||||
buckets: Buckets {
|
||||
bucket: bucket_list,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_objects(State(state): State<Arc<AppState>>, Path(bucket): Path<String>) -> Response {
|
||||
let bucket_path = format!("{}/{}", state.config.storage.data_dir, bucket);
|
||||
|
||||
if !std::path::Path::new(&bucket_path).is_dir() {
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"NoSuchBucket",
|
||||
"The specified bucket does not exist",
|
||||
);
|
||||
}
|
||||
|
||||
let objects = collect_files(std::path::Path::new(&bucket_path), "");
|
||||
xml_response(ListObjectsResult {
|
||||
name: bucket,
|
||||
contents: objects,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_files(dir: &std::path::Path, prefix: &str) -> Vec<ObjectInfo> {
|
||||
let mut objects = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().into_string().unwrap_or_default();
|
||||
let key = if prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, name)
|
||||
};
|
||||
|
||||
if path.is_dir() {
|
||||
objects.extend(collect_files(&path, &key));
|
||||
} else if let Ok(metadata) = entry.metadata() {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
let datetime: chrono::DateTime<Utc> = modified.into();
|
||||
objects.push(ObjectInfo {
|
||||
key,
|
||||
size: metadata.len(),
|
||||
last_modified: datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
objects
|
||||
}
|
||||
|
||||
async fn create_bucket(State(state): State<Arc<AppState>>, Path(bucket): Path<String>) -> Response {
|
||||
let bucket_path = format!("{}/{}", state.config.storage.data_dir, bucket);
|
||||
match fs::create_dir(&bucket_path) {
|
||||
Ok(_) => (StatusCode::OK, "").into_response(),
|
||||
Err(_) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"BucketAlreadyExists",
|
||||
"Bucket already exists",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_object(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((bucket, key)): Path<(String, String)>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let file_path = format!("{}/{}/{}", state.config.storage.data_dir, bucket, key);
|
||||
|
||||
if let Some(parent) = std::path::Path::new(&file_path).parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match fs::write(&file_path, &body) {
|
||||
Ok(_) => {
|
||||
println!("PUT {}/{} ({} bytes)", bucket, key, body.len());
|
||||
(StatusCode::OK, "").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
println!("ERROR writing {}/{}: {}", bucket, key, e);
|
||||
error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"InternalError",
|
||||
"Failed to write object",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_object(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((bucket, key)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let file_path = format!("{}/{}/{}", state.config.storage.data_dir, bucket, key);
|
||||
|
||||
match fs::read(&file_path) {
|
||||
Ok(data) => (StatusCode::OK, data).into_response(),
|
||||
Err(_) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"NoSuchKey",
|
||||
"The specified key does not exist",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_object(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((bucket, key)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let file_path = format!("{}/{}/{}", state.config.storage.data_dir, bucket, key);
|
||||
|
||||
match fs::remove_file(&file_path) {
|
||||
Ok(_) => {
|
||||
println!("DELETE {}/{}", bucket, key);
|
||||
(StatusCode::NO_CONTENT, "").into_response()
|
||||
}
|
||||
Err(_) => error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"NoSuchKey",
|
||||
"The specified key does not exist",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_bucket(State(state): State<Arc<AppState>>, Path(bucket): Path<String>) -> Response {
|
||||
let bucket_path = format!("{}/{}", state.config.storage.data_dir, bucket);
|
||||
|
||||
match fs::remove_dir(&bucket_path) {
|
||||
Ok(_) => {
|
||||
println!("DELETE bucket {}", bucket);
|
||||
(StatusCode::NO_CONTENT, "").into_response()
|
||||
}
|
||||
Err(_) => error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"BucketNotEmpty",
|
||||
"The bucket is not empty",
|
||||
),
|
||||
}
|
||||
}
|
||||
3
tests/e2e/.gitignore
vendored
Normal file
3
tests/e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
76
tests/e2e/package-lock.json
generated
Normal file
76
tests/e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "nora-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nora-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
tests/e2e/package.json
Normal file
11
tests/e2e/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "nora-e2e",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "npx playwright test",
|
||||
"test:ui": "npx playwright test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0"
|
||||
}
|
||||
}
|
||||
18
tests/e2e/playwright.config.ts
Normal file
18
tests/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30000,
|
||||
retries: 1,
|
||||
use: {
|
||||
baseURL: process.env.NORA_URL || 'http://localhost:4000',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
});
|
||||
82
tests/e2e/tests/dashboard.spec.ts
Normal file
82
tests/e2e/tests/dashboard.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('NORA Dashboard', () => {
|
||||
|
||||
test('dashboard page loads and shows title', async ({ page }) => {
|
||||
await page.goto('/ui/');
|
||||
await expect(page).toHaveTitle(/NORA|nora/i);
|
||||
});
|
||||
|
||||
test('dashboard shows registry sections', async ({ page }) => {
|
||||
await page.goto('/ui/');
|
||||
|
||||
// All registry types should be visible
|
||||
await expect(page.getByText(/Docker/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/npm/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/Maven/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/PyPI/i).first()).toBeVisible();
|
||||
await expect(page.getByText(/Cargo/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('dashboard shows non-zero npm count after proxy fetch', async ({ page, request }) => {
|
||||
// Trigger npm proxy cache by fetching a package
|
||||
await request.get('/npm/chalk');
|
||||
await request.get('/npm/chalk/-/chalk-5.4.1.tgz');
|
||||
|
||||
// Wait a moment for index rebuild
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await page.goto('/ui/');
|
||||
|
||||
// npm section should show at least 1 package
|
||||
// Look for a number > 0 near npm section
|
||||
const statsResponse = await request.get('/api/ui/stats');
|
||||
const stats = await statsResponse.json();
|
||||
expect(stats.npm).toBeGreaterThan(0);
|
||||
|
||||
// Verify it's actually rendered on the page
|
||||
await page.goto('/ui/');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// The page should contain the package count somewhere
|
||||
const content = await page.textContent('body');
|
||||
expect(content).not.toBeNull();
|
||||
// Should not show all zeros for npm
|
||||
expect(content).toContain('npm');
|
||||
});
|
||||
|
||||
test('dashboard shows Docker images after proxy fetch', async ({ page, request }) => {
|
||||
// Check stats API
|
||||
const statsResponse = await request.get('/api/ui/stats');
|
||||
const stats = await statsResponse.json();
|
||||
|
||||
// Docker count should be accessible (may be 0 if no images pulled yet)
|
||||
expect(stats).toHaveProperty('docker');
|
||||
});
|
||||
|
||||
test('health endpoint returns healthy', async ({ request }) => {
|
||||
const response = await request.get('/health');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const health = await response.json();
|
||||
expect(health.status).toBe('healthy');
|
||||
expect(health.registries.npm).toBe('ok');
|
||||
expect(health.registries.docker).toBe('ok');
|
||||
expect(health.registries.maven).toBe('ok');
|
||||
expect(health.registries.pypi).toBe('ok');
|
||||
expect(health.registries.cargo).toBe('ok');
|
||||
});
|
||||
|
||||
test('OpenAPI docs endpoint accessible', async ({ request }) => {
|
||||
const response = await request.get('/api-docs', { maxRedirects: 0 });
|
||||
// api-docs redirects to swagger UI
|
||||
expect([200, 303]).toContain(response.status());
|
||||
});
|
||||
|
||||
test('metrics endpoint returns prometheus format', async ({ request }) => {
|
||||
const response = await request.get('/metrics');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const text = await response.text();
|
||||
expect(text).toContain('nora_http_request_duration_seconds');
|
||||
});
|
||||
});
|
||||
74
tests/e2e/tests/docker-proxy.spec.ts
Normal file
74
tests/e2e/tests/docker-proxy.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Docker Registry', () => {
|
||||
|
||||
test('v2 check returns empty JSON', async ({ request }) => {
|
||||
const response = await request.get('/v2/');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const body = await response.json();
|
||||
expect(body).toEqual({});
|
||||
});
|
||||
|
||||
test('catalog endpoint returns 200', async ({ request }) => {
|
||||
const response = await request.get('/v2/_catalog');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('put and get manifest works', async ({ request }) => {
|
||||
// Push a simple blob
|
||||
const blobData = 'test-blob-content';
|
||||
const crypto = require('crypto');
|
||||
const blobDigest = 'sha256:' + crypto.createHash('sha256').update(blobData).digest('hex');
|
||||
|
||||
await request.post(`/v2/e2e-test/blobs/uploads/?digest=${blobDigest}`, {
|
||||
data: blobData,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
});
|
||||
|
||||
// Push config blob
|
||||
const configData = '{}';
|
||||
const configDigest = 'sha256:' + crypto.createHash('sha256').update(configData).digest('hex');
|
||||
|
||||
await request.post(`/v2/e2e-test/blobs/uploads/?digest=${configDigest}`, {
|
||||
data: configData,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
});
|
||||
|
||||
// Push manifest
|
||||
const manifest = {
|
||||
schemaVersion: 2,
|
||||
mediaType: 'application/vnd.oci.image.manifest.v1+json',
|
||||
config: {
|
||||
mediaType: 'application/vnd.oci.image.config.v1+json',
|
||||
digest: configDigest,
|
||||
size: configData.length,
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
|
||||
digest: blobDigest,
|
||||
size: blobData.length,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const putResponse = await request.put('/v2/e2e-test/manifests/1.0.0', {
|
||||
data: manifest,
|
||||
headers: { 'Content-Type': 'application/vnd.oci.image.manifest.v1+json' },
|
||||
});
|
||||
expect(putResponse.status()).toBe(201);
|
||||
|
||||
// Pull manifest back
|
||||
const getResponse = await request.get('/v2/e2e-test/manifests/1.0.0');
|
||||
expect(getResponse.ok()).toBeTruthy();
|
||||
const pulled = await getResponse.json();
|
||||
expect(pulled.schemaVersion).toBe(2);
|
||||
expect(pulled.layers).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('tags list returns pushed tags', async ({ request }) => {
|
||||
const response = await request.get('/v2/e2e-test/tags/list');
|
||||
// May or may not have tags depending on test order
|
||||
expect([200, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
132
tests/e2e/tests/npm-proxy.spec.ts
Normal file
132
tests/e2e/tests/npm-proxy.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('npm Proxy', () => {
|
||||
|
||||
test('metadata proxy returns rewritten tarball URLs', async ({ request }) => {
|
||||
const response = await request.get('/npm/chalk');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const metadata = await response.json();
|
||||
expect(metadata.name).toBe('chalk');
|
||||
expect(metadata.versions).toBeDefined();
|
||||
|
||||
// Tarball URL must point to NORA, not npmjs.org
|
||||
const version = metadata.versions['5.4.1'];
|
||||
expect(version).toBeDefined();
|
||||
expect(version.dist.tarball).not.toContain('registry.npmjs.org');
|
||||
expect(version.dist.tarball).toContain('/npm/chalk/-/chalk-5.4.1.tgz');
|
||||
});
|
||||
|
||||
test('scoped package @babel/parser works', async ({ request }) => {
|
||||
const response = await request.get('/npm/@babel/parser');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const metadata = await response.json();
|
||||
expect(metadata.name).toBe('@babel/parser');
|
||||
|
||||
// Check tarball URL rewriting for scoped package
|
||||
const versions = Object.keys(metadata.versions);
|
||||
expect(versions.length).toBeGreaterThan(0);
|
||||
|
||||
const firstVersion = metadata.versions[versions[0]];
|
||||
if (firstVersion?.dist?.tarball) {
|
||||
expect(firstVersion.dist.tarball).toContain('/npm/@babel/parser/-/');
|
||||
expect(firstVersion.dist.tarball).not.toContain('registry.npmjs.org');
|
||||
}
|
||||
});
|
||||
|
||||
test('tarball download returns gzip data', async ({ request }) => {
|
||||
// Ensure metadata is cached first
|
||||
await request.get('/npm/chalk');
|
||||
|
||||
const response = await request.get('/npm/chalk/-/chalk-5.4.1.tgz');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.headers()['content-type']).toBe('application/octet-stream');
|
||||
|
||||
const body = await response.body();
|
||||
expect(body.length).toBeGreaterThan(100);
|
||||
// gzip magic bytes
|
||||
expect(body[0]).toBe(0x1f);
|
||||
expect(body[1]).toBe(0x8b);
|
||||
});
|
||||
|
||||
test('npm publish creates package', async ({ request }) => {
|
||||
const pkgName = `e2e-pub-${Date.now()}`;
|
||||
const publishBody = {
|
||||
name: pkgName,
|
||||
versions: {
|
||||
'1.0.0': {
|
||||
name: pkgName,
|
||||
version: '1.0.0',
|
||||
dist: {},
|
||||
},
|
||||
},
|
||||
'dist-tags': { latest: '1.0.0' },
|
||||
_attachments: {
|
||||
[`${pkgName}-1.0.0.tgz`]: {
|
||||
data: 'dGVzdA==',
|
||||
content_type: 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request.put(`/npm/${pkgName}`, {
|
||||
data: publishBody,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(response.status()).toBe(201);
|
||||
|
||||
// Verify published package is accessible
|
||||
const getResponse = await request.get(`/npm/${pkgName}`);
|
||||
expect(getResponse.ok()).toBeTruthy();
|
||||
const metadata = await getResponse.json();
|
||||
expect(metadata.name).toBe(pkgName);
|
||||
expect(metadata.versions['1.0.0']).toBeDefined();
|
||||
});
|
||||
|
||||
test('npm publish rejects duplicate version (409)', async ({ request }) => {
|
||||
const pkgName = `e2e-dupe-${Date.now()}`;
|
||||
const body = {
|
||||
name: pkgName,
|
||||
versions: { '1.0.0': { name: pkgName, version: '1.0.0', dist: {} } },
|
||||
'dist-tags': { latest: '1.0.0' },
|
||||
_attachments: { [`${pkgName}-1.0.0.tgz`]: { data: 'dGVzdA==' } },
|
||||
};
|
||||
|
||||
await request.put(`/npm/${pkgName}`, {
|
||||
data: body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Publish same version again
|
||||
const response = await request.put(`/npm/${pkgName}`, {
|
||||
data: body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(response.status()).toBe(409);
|
||||
});
|
||||
|
||||
test('npm publish rejects name mismatch (400)', async ({ request }) => {
|
||||
const response = await request.put('/npm/legitimate-pkg', {
|
||||
data: {
|
||||
name: 'evil-pkg',
|
||||
versions: { '1.0.0': {} },
|
||||
_attachments: { 'a.tgz': { data: 'dGVzdA==' } },
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('npm publish rejects path traversal filename (400)', async ({ request }) => {
|
||||
const response = await request.put('/npm/safe-pkg', {
|
||||
data: {
|
||||
name: 'safe-pkg',
|
||||
versions: { '1.0.0': {} },
|
||||
_attachments: { '../../etc/passwd': { data: 'dGVzdA==' } },
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
51
tests/e2e/tests/other-registries.spec.ts
Normal file
51
tests/e2e/tests/other-registries.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Maven Proxy', () => {
|
||||
test('download Maven artifact', async ({ request }) => {
|
||||
const response = await request.get(
|
||||
'/maven2/org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.pom'
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const text = await response.text();
|
||||
expect(text).toContain('commons-lang3');
|
||||
});
|
||||
|
||||
test('Maven upload works', async ({ request }) => {
|
||||
const response = await request.put('/maven2/com/test/smoke/1.0/smoke-1.0.jar', {
|
||||
data: 'test-jar-content',
|
||||
});
|
||||
expect(response.status()).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('PyPI Proxy', () => {
|
||||
test('simple index returns HTML', async ({ request }) => {
|
||||
const response = await request.get('/simple/');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const text = await response.text();
|
||||
expect(text).toContain('Simple Index');
|
||||
});
|
||||
|
||||
test('package page returns links', async ({ request }) => {
|
||||
const response = await request.get('/simple/requests/');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const text = await response.text();
|
||||
expect(text).toContain('requests');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Raw Storage', () => {
|
||||
test('upload and download file', async ({ request }) => {
|
||||
const data = 'raw-e2e-test-content-' + Date.now();
|
||||
|
||||
const putResponse = await request.put('/raw/e2e/test.txt', {
|
||||
data: data,
|
||||
});
|
||||
expect(putResponse.status()).toBe(201);
|
||||
|
||||
const getResponse = await request.get('/raw/e2e/test.txt');
|
||||
expect(getResponse.ok()).toBeTruthy();
|
||||
const body = await getResponse.text();
|
||||
expect(body).toBe(data);
|
||||
});
|
||||
});
|
||||
352
tests/smoke.sh
Executable file
352
tests/smoke.sh
Executable file
@@ -0,0 +1,352 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# NORA E2E Smoke Test
|
||||
# Starts NORA, runs real-world scenarios, verifies results.
|
||||
# Exit code 0 = all passed, non-zero = failures.
|
||||
|
||||
NORA_BIN="${NORA_BIN:-./target/release/nora}"
|
||||
PORT="${NORA_TEST_PORT:-14000}"
|
||||
BASE="http://localhost:${PORT}"
|
||||
STORAGE_DIR=$(mktemp -d)
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
NORA_PID=""
|
||||
|
||||
cleanup() {
|
||||
[ -n "$NORA_PID" ] && kill "$NORA_PID" 2>/dev/null || true
|
||||
rm -rf "$STORAGE_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
echo " FAIL: $1"
|
||||
FAILED=$((FAILED + 1))
|
||||
}
|
||||
|
||||
pass() {
|
||||
echo " PASS: $1"
|
||||
PASSED=$((PASSED + 1))
|
||||
}
|
||||
|
||||
check() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
pass "$desc"
|
||||
else
|
||||
fail "$desc"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== NORA Smoke Test ==="
|
||||
echo "Binary: $NORA_BIN"
|
||||
echo "Port: $PORT"
|
||||
echo "Storage: $STORAGE_DIR"
|
||||
echo ""
|
||||
|
||||
# Start NORA
|
||||
NORA_HOST=127.0.0.1 \
|
||||
NORA_PORT=$PORT \
|
||||
NORA_STORAGE_PATH="$STORAGE_DIR" \
|
||||
NORA_RATE_LIMIT_ENABLED=false \
|
||||
NORA_PUBLIC_URL="$BASE" \
|
||||
"$NORA_BIN" serve &
|
||||
NORA_PID=$!
|
||||
|
||||
# Wait for startup
|
||||
for i in $(seq 1 20); do
|
||||
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "--- Health & Monitoring ---"
|
||||
check "GET /health returns healthy" \
|
||||
curl -sf "$BASE/health"
|
||||
|
||||
check "GET /ready returns 200" \
|
||||
curl -sf "$BASE/ready"
|
||||
|
||||
check "GET /metrics returns prometheus" \
|
||||
curl -sf "$BASE/metrics"
|
||||
|
||||
echo ""
|
||||
echo "--- npm Proxy ---"
|
||||
|
||||
# Fetch metadata — triggers proxy cache
|
||||
METADATA=$(curl -sf "$BASE/npm/chalk" 2>/dev/null || echo "{}")
|
||||
|
||||
check "npm metadata returns 200" \
|
||||
curl -sf "$BASE/npm/chalk"
|
||||
|
||||
# URL rewriting check
|
||||
TARBALL_URL=$(echo "$METADATA" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('versions',{}).get('5.4.1',{}).get('dist',{}).get('tarball',''))" 2>/dev/null || echo "")
|
||||
if echo "$TARBALL_URL" | grep -q "localhost:${PORT}/npm"; then
|
||||
pass "npm tarball URL rewritten to NORA"
|
||||
else
|
||||
fail "npm tarball URL not rewritten: $TARBALL_URL"
|
||||
fi
|
||||
|
||||
# Fetch tarball
|
||||
check "npm tarball download" \
|
||||
curl -sf "$BASE/npm/chalk/-/chalk-5.4.1.tgz" -o /dev/null
|
||||
|
||||
# Scoped package
|
||||
check "npm scoped package @babel/parser" \
|
||||
curl -sf "$BASE/npm/@babel/parser"
|
||||
|
||||
# Publish
|
||||
PUBLISH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"smoke-test-pkg","versions":{"1.0.0":{"name":"smoke-test-pkg","version":"1.0.0","dist":{}}},"dist-tags":{"latest":"1.0.0"},"_attachments":{"smoke-test-pkg-1.0.0.tgz":{"data":"dGVzdA==","content_type":"application/octet-stream"}}}' \
|
||||
"$BASE/npm/smoke-test-pkg")
|
||||
if [ "$PUBLISH_RESULT" = "201" ]; then
|
||||
pass "npm publish returns 201"
|
||||
else
|
||||
fail "npm publish returned $PUBLISH_RESULT"
|
||||
fi
|
||||
|
||||
# Version immutability
|
||||
DUPE_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"smoke-test-pkg","versions":{"1.0.0":{"name":"smoke-test-pkg","version":"1.0.0","dist":{}}},"dist-tags":{"latest":"1.0.0"},"_attachments":{"smoke-test-pkg-1.0.0.tgz":{"data":"dGVzdA==","content_type":"application/octet-stream"}}}' \
|
||||
"$BASE/npm/smoke-test-pkg")
|
||||
if [ "$DUPE_RESULT" = "409" ]; then
|
||||
pass "npm version immutability (409 on duplicate)"
|
||||
else
|
||||
fail "npm duplicate publish returned $DUPE_RESULT, expected 409"
|
||||
fi
|
||||
|
||||
# Security: name mismatch
|
||||
MISMATCH_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"evil-pkg","versions":{"1.0.0":{}},"_attachments":{"a.tgz":{"data":"dGVzdA=="}}}' \
|
||||
"$BASE/npm/lodash")
|
||||
if [ "$MISMATCH_RESULT" = "400" ]; then
|
||||
pass "npm name mismatch rejected (400)"
|
||||
else
|
||||
fail "npm name mismatch returned $MISMATCH_RESULT, expected 400"
|
||||
fi
|
||||
|
||||
# Security: path traversal
|
||||
TRAVERSAL_RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"test-pkg","versions":{"1.0.0":{}},"_attachments":{"../../etc/passwd":{"data":"dGVzdA=="}}}' \
|
||||
"$BASE/npm/test-pkg")
|
||||
if [ "$TRAVERSAL_RESULT" = "400" ]; then
|
||||
pass "npm path traversal rejected (400)"
|
||||
else
|
||||
fail "npm path traversal returned $TRAVERSAL_RESULT, expected 400"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Maven ---"
|
||||
check "Maven proxy download" \
|
||||
curl -sf "$BASE/maven2/org/apache/commons/commons-lang3/3.17.0/commons-lang3-3.17.0.pom" -o /dev/null
|
||||
|
||||
echo ""
|
||||
echo "--- PyPI ---"
|
||||
check "PyPI simple index" \
|
||||
curl -sf "$BASE/simple/"
|
||||
|
||||
check "PyPI package page" \
|
||||
curl -sf "$BASE/simple/requests/"
|
||||
|
||||
echo ""
|
||||
echo "--- Docker ---"
|
||||
check "Docker v2 check" \
|
||||
curl -sf "$BASE/v2/"
|
||||
|
||||
echo ""
|
||||
echo "--- Raw ---"
|
||||
echo "raw-test-data" | curl -sf -X PUT --data-binary @- "$BASE/raw/smoke/test.txt" >/dev/null 2>&1
|
||||
check "Raw upload" \
|
||||
curl -sf "$BASE/raw/smoke/test.txt" -o /dev/null
|
||||
|
||||
echo ""
|
||||
echo "--- UI & API ---"
|
||||
check "UI dashboard loads" \
|
||||
curl -sf "$BASE/ui/"
|
||||
|
||||
check "OpenAPI docs" \
|
||||
curl -sf "$BASE/api-docs" -o /dev/null
|
||||
|
||||
# Dashboard stats — check npm count > 0 after proxy fetches
|
||||
sleep 1
|
||||
STATS=$(curl -sf "$BASE/ui/api/stats" 2>/dev/null || echo "{}")
|
||||
NPM_COUNT=$(echo "$STATS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('npm',0))" 2>/dev/null || echo "0")
|
||||
if [ "$NPM_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
pass "Dashboard npm count > 0 (got $NPM_COUNT)"
|
||||
else
|
||||
fail "Dashboard npm count is $NPM_COUNT, expected > 0"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Docker Push/Pull + Digest Verify ---"
|
||||
|
||||
# Create a minimal Docker image, push, pull, verify digest
|
||||
DOCKER_AVAILABLE=true
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo " SKIP: Docker daemon not available"
|
||||
DOCKER_AVAILABLE=false
|
||||
fi
|
||||
|
||||
if [ "$DOCKER_AVAILABLE" = true ]; then
|
||||
DOCKER_IMG="localhost:${PORT}/smoke-test/hello:v1"
|
||||
|
||||
# Create tiny image from scratch
|
||||
DOCKER_BUILD_DIR=$(mktemp -d)
|
||||
echo "FROM scratch" > "$DOCKER_BUILD_DIR/Dockerfile"
|
||||
echo "smoke-test" > "$DOCKER_BUILD_DIR/data.txt"
|
||||
echo "COPY data.txt /data.txt" >> "$DOCKER_BUILD_DIR/Dockerfile"
|
||||
|
||||
if docker build -t "$DOCKER_IMG" "$DOCKER_BUILD_DIR" >/dev/null 2>&1; then
|
||||
pass "docker build smoke image"
|
||||
else
|
||||
fail "docker build smoke image"
|
||||
fi
|
||||
rm -rf "$DOCKER_BUILD_DIR"
|
||||
|
||||
# Push
|
||||
if docker push "$DOCKER_IMG" >/dev/null 2>&1; then
|
||||
pass "docker push to NORA"
|
||||
else
|
||||
fail "docker push to NORA"
|
||||
fi
|
||||
|
||||
# Get digest from registry
|
||||
MANIFEST=$(curl -sf -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
|
||||
"$BASE/v2/smoke-test/hello/manifests/v1" 2>/dev/null || echo "")
|
||||
if [ -n "$MANIFEST" ] && echo "$MANIFEST" | python3 -c "import sys,json; json.load(sys.stdin)" >/dev/null 2>&1; then
|
||||
pass "docker manifest retrievable from NORA"
|
||||
else
|
||||
fail "docker manifest not retrievable"
|
||||
fi
|
||||
|
||||
# Remove local image and pull back
|
||||
docker rmi "$DOCKER_IMG" >/dev/null 2>&1 || true
|
||||
if docker pull "$DOCKER_IMG" >/dev/null 2>&1; then
|
||||
pass "docker pull from NORA"
|
||||
else
|
||||
fail "docker pull from NORA"
|
||||
fi
|
||||
|
||||
# Verify digest matches: push digest == pull digest
|
||||
PUSH_DIGEST=$(docker inspect "$DOCKER_IMG" --format='{{index .RepoDigests 0}}' 2>/dev/null | cut -d@ -f2)
|
||||
if [ -n "$PUSH_DIGEST" ] && echo "$PUSH_DIGEST" | grep -q "^sha256:"; then
|
||||
pass "docker digest verified (${PUSH_DIGEST:0:20}...)"
|
||||
else
|
||||
fail "docker digest verification failed"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
docker rmi "$DOCKER_IMG" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- npm Install + Integrity Verify ---"
|
||||
|
||||
# Test real npm client against NORA (not just curl)
|
||||
NPM_TEST_DIR=$(mktemp -d)
|
||||
cd "$NPM_TEST_DIR"
|
||||
|
||||
# Create minimal package.json
|
||||
cat > package.json << 'PKGJSON'
|
||||
{
|
||||
"name": "nora-smoke-test",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"chalk": "5.4.1"
|
||||
}
|
||||
}
|
||||
PKGJSON
|
||||
|
||||
# npm install using NORA as registry
|
||||
if npm install --registry "$BASE/npm/" --prefer-online --no-audit --no-fund >/dev/null 2>&1; then
|
||||
pass "npm install chalk via NORA registry"
|
||||
else
|
||||
fail "npm install chalk via NORA registry"
|
||||
fi
|
||||
|
||||
# Verify package was installed
|
||||
if [ -f "node_modules/chalk/package.json" ]; then
|
||||
INSTALLED_VER=$(python3 -c "import json; print(json.load(open('node_modules/chalk/package.json'))['version'])" 2>/dev/null || echo "")
|
||||
if [ "$INSTALLED_VER" = "5.4.1" ]; then
|
||||
pass "npm installed correct version (5.4.1)"
|
||||
else
|
||||
fail "npm installed wrong version: $INSTALLED_VER"
|
||||
fi
|
||||
else
|
||||
fail "npm node_modules/chalk not found"
|
||||
fi
|
||||
|
||||
# Verify integrity: check that package-lock.json has sha512 integrity
|
||||
if [ -f "package-lock.json" ]; then
|
||||
INTEGRITY=$(python3 -c "
|
||||
import json
|
||||
lock = json.load(open('package-lock.json'))
|
||||
pkgs = lock.get('packages', {})
|
||||
chalk = pkgs.get('node_modules/chalk', pkgs.get('chalk', {}))
|
||||
print(chalk.get('integrity', ''))
|
||||
" 2>/dev/null || echo "")
|
||||
if echo "$INTEGRITY" | grep -q "^sha512-"; then
|
||||
pass "npm integrity hash present (sha512)"
|
||||
else
|
||||
fail "npm integrity hash missing: $INTEGRITY"
|
||||
fi
|
||||
else
|
||||
fail "npm package-lock.json not created"
|
||||
fi
|
||||
|
||||
cd /tmp
|
||||
rm -rf "$NPM_TEST_DIR"
|
||||
|
||||
echo ""
|
||||
echo "--- Upstream Timeout Handling ---"
|
||||
|
||||
# Verify that requesting a non-existent package from upstream returns 404 quickly (not hang)
|
||||
TIMEOUT_START=$(date +%s)
|
||||
TIMEOUT_RESULT=$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 \
|
||||
"$BASE/npm/@nora-smoke-test/nonexistent-package-xyz-12345")
|
||||
TIMEOUT_END=$(date +%s)
|
||||
TIMEOUT_DURATION=$((TIMEOUT_END - TIMEOUT_START))
|
||||
|
||||
if [ "$TIMEOUT_RESULT" = "404" ]; then
|
||||
pass "upstream 404 returned correctly"
|
||||
else
|
||||
fail "upstream returned $TIMEOUT_RESULT, expected 404"
|
||||
fi
|
||||
|
||||
if [ "$TIMEOUT_DURATION" -lt 10 ]; then
|
||||
pass "upstream 404 returned in ${TIMEOUT_DURATION}s (< 10s)"
|
||||
else
|
||||
fail "upstream 404 took ${TIMEOUT_DURATION}s (too slow, retry may hang)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- Mirror CLI ---"
|
||||
# Create a minimal lockfile
|
||||
LOCKFILE=$(mktemp)
|
||||
cat > "$LOCKFILE" << 'EOF'
|
||||
{
|
||||
"lockfileVersion": 3,
|
||||
"packages": {
|
||||
"": { "name": "test" },
|
||||
"node_modules/chalk": { "version": "5.4.1" }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
MIRROR_RESULT=$("$NORA_BIN" mirror --registry "$BASE" npm --lockfile "$LOCKFILE" 2>&1)
|
||||
if echo "$MIRROR_RESULT" | grep -q "Failed: 0"; then
|
||||
pass "nora mirror npm --lockfile (0 failures)"
|
||||
else
|
||||
fail "nora mirror: $MIRROR_RESULT"
|
||||
fi
|
||||
rm -f "$LOCKFILE"
|
||||
|
||||
echo ""
|
||||
echo "================================"
|
||||
echo "Results: $PASSED passed, $FAILED failed"
|
||||
echo "================================"
|
||||
|
||||
[ "$FAILED" -eq 0 ] && exit 0 || exit 1
|
||||
Reference in New Issue
Block a user