1 Commits

Author SHA1 Message Date
65fa80dd1b chore: bump version to 0.2.19 2026-01-31 16:54:26 +00:00
22 changed files with 172 additions and 485 deletions

View File

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

View File

@@ -11,7 +11,7 @@ jobs:
name: Test name: Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -27,63 +27,3 @@ jobs:
- name: Run tests - name: Run tests
run: cargo test --package nora-registry run: cargo test --package nora-registry
security:
name: Security
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for uploading SARIF to GitHub Security tab
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history required for gitleaks
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
# ── Secrets ────────────────────────────────────────────────────────────
- name: Gitleaks — scan for hardcoded secrets
run: |
curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
| tar xz -C /usr/local/bin gitleaks
gitleaks detect --source . --exit-code 1 --report-format sarif --report-path gitleaks.sarif || true
continue-on-error: true # findings are reported, do not block the pipeline
# ── CVE in Rust dependencies ────────────────────────────────────────────
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: cargo audit — RustSec advisory database
run: cargo audit
continue-on-error: true # warn only; known CVEs should not block CI until triaged
# ── Licenses, banned crates, supply chain policy ────────────────────────
- name: cargo deny — licenses and banned crates
uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
arguments: --all-features
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
- name: Trivy — filesystem scan (Cargo.lock + source)
if: always()
uses: aquasecurity/trivy-action@0.34.1
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-fs.sarif
severity: HIGH,CRITICAL
exit-code: 0 # warn only; change to 1 to block the pipeline
- name: Upload Trivy fs results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-fs.sarif
category: trivy-fs

View File

@@ -11,7 +11,7 @@ env:
jobs: jobs:
build: build:
name: Build & Push name: Build & Push
runs-on: [self-hosted, nora] runs-on: self-hosted
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -19,16 +19,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Rust - name: Set up QEMU
run: | uses: docker/setup-qemu-action@v3
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: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -40,191 +32,49 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# ── Alpine (standard) ──────────────────────────────────────────────────── - name: Extract metadata
- name: Extract metadata (alpine) id: meta
id: meta-alpine
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest type=raw,value=latest
- name: Build and push (alpine) - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile
platforms: linux/amd64 platforms: linux/amd64
push: true push: true
tags: ${{ steps.meta-alpine.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta-alpine.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=alpine cache-from: type=gha
cache-to: type=gha,mode=max,scope=alpine cache-to: type=gha,mode=max
# ── RED OS ───────────────────────────────────────────────────────────────
- name: Extract metadata (redos)
id: meta-redos
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: suffix=-redos,onlatest=true
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=redos
- name: Build and push (redos)
uses: docker/build-push-action@v5
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=gha,scope=redos
cache-to: type=gha,mode=max,scope=redos
scan:
name: Scan (${{ matrix.name }})
runs-on: ubuntu-latest
needs: build
permissions:
contents: read
packages: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- name: alpine
suffix: ""
- name: redos
suffix: "-redos"
steps:
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set version tag (strip leading v)
id: ver
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# ── CVE scan of the pushed image ────────────────────────────────────────
# Images are FROM scratch — no OS packages, only binary CVE scan
- name: Trivy — image scan (${{ matrix.name }})
uses: aquasecurity/trivy-action@0.30.0
with:
scan-type: image
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
format: sarif
output: trivy-image-${{ matrix.name }}.sarif
severity: HIGH,CRITICAL
exit-code: 1 # block release on HIGH/CRITICAL vulnerabilities
- name: Upload Trivy image results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: trivy-image-${{ matrix.name }}.sarif
category: trivy-image-${{ matrix.name }}
release: release:
name: GitHub Release name: GitHub Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build, scan] needs: build
permissions: permissions:
contents: write contents: write
packages: read # to pull image for SBOM generation
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set version tag (strip leading v)
id: ver
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
# ── Binary — extract from Docker image ──────────────────────────────────
- name: Extract binary from image
run: |
docker create --name nora-extract \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
docker cp nora-extract:/usr/local/bin/nora ./nora-linux-amd64
docker rm nora-extract
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
# ── SBOM — Software Bill of Materials ───────────────────────────────────
- name: Generate SBOM (SPDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: spdx-json
output-file: nora-${{ github.ref_name }}.sbom.spdx.json
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate SBOM (CycloneDX)
uses: anchore/sbom-action@v0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: cyclonedx-json
output-file: nora-${{ github.ref_name }}.sbom.cdx.json
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
generate_release_notes: true generate_release_notes: true
files: |
nora-linux-amd64
nora-linux-amd64.sha256
nora-${{ github.ref_name }}.sbom.spdx.json
nora-${{ github.ref_name }}.sbom.cdx.json
body: | body: |
## 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 ## Docker
**Alpine (standard):**
```bash ```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }} docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
``` ```
**RED OS:**
```bash
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}-redos
```
## Changelog ## Changelog
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md)

4
.gitignore vendored
View File

@@ -13,7 +13,3 @@ ROADMAP*.md
docs-site/ docs-site/
docs/ docs/
*.txt *.txt
## Internal files
.internal/
examples/

93
Cargo.lock generated
View File

@@ -234,9 +234,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]] [[package]]
name = "cc" name = "cc"
@@ -262,9 +262,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
@@ -495,9 +495,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
@@ -1201,7 +1201,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nora-cli" name = "nora-cli"
version = "0.2.22" version = "0.2.18"
dependencies = [ dependencies = [
"clap", "clap",
"flate2", "flate2",
@@ -1215,7 +1215,7 @@ dependencies = [
[[package]] [[package]]
name = "nora-registry" name = "nora-registry"
version = "0.2.22" version = "0.2.18"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
@@ -1253,7 +1253,7 @@ dependencies = [
[[package]] [[package]]
name = "nora-storage" name = "nora-storage"
version = "0.2.22" version = "0.2.18"
dependencies = [ dependencies = [
"axum", "axum",
"base64", "base64",
@@ -1412,9 +1412,9 @@ dependencies = [
[[package]] [[package]]
name = "prometheus" name = "prometheus"
version = "0.14.0" version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"fnv", "fnv",
@@ -1422,28 +1422,14 @@ dependencies = [
"memchr", "memchr",
"parking_lot", "parking_lot",
"protobuf", "protobuf",
"thiserror 2.0.18", "thiserror 1.0.69",
] ]
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "3.7.2" version = "2.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94"
dependencies = [
"once_cell",
"protobuf-support",
"thiserror 1.0.69",
]
[[package]]
name = "protobuf-support"
version = "3.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6"
dependencies = [
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "quanta" name = "quanta"
@@ -1462,9 +1448,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.39.2" version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -1850,11 +1836,11 @@ dependencies = [
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.0.4" version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [ dependencies = [
"serde_core", "serde",
] ]
[[package]] [[package]]
@@ -2153,42 +2139,44 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.0.3+spec-1.1.0" version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"indexmap", "serde",
"serde_core",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
"toml_parser", "toml_edit",
"toml_writer",
"winnow",
] ]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.0.0+spec-1.1.0" version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [ dependencies = [
"serde_core", "serde",
] ]
[[package]] [[package]]
name = "toml_parser" name = "toml_edit"
version = "1.0.9+spec-1.1.0" version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow", "winnow",
] ]
[[package]] [[package]]
name = "toml_writer" name = "toml_write"
version = "1.0.6+spec-1.1.0" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tonic" name = "tonic"
@@ -2868,6 +2856,9 @@ name = "winnow"
version = "0.7.14" version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wiremock" name = "wiremock"
@@ -3047,9 +3038,9 @@ dependencies = [
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.2" version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8" checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
[[package]] [[package]]
name = "zmij" name = "zmij"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
<img src="logo.jpg" alt="NORA" height="120" /> # 🐿️ N○RA
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Release](https://img.shields.io/github/v/release/getnora-io/nora)](https://github.com/getnora-io/nora/releases) [![Release](https://img.shields.io/github/v/release/getnora-io/nora)](https://github.com/getnora-io/nora/releases)

View File

@@ -1,40 +0,0 @@
# 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, transitive via indicatif; no fix available
]
[licenses]
# Allowed open-source licenses
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-DFS-2016",
"Unicode-3.0",
"CC0-1.0",
"OpenSSL",
"Zlib",
"CDLA-Permissive-2.0", # webpki-roots (CA certificates bundle)
"MPL-2.0",
]
[bans]
multiple-versions = "warn"
deny = [
{ name = "openssl-sys" },
{ name = "openssl" },
]
skip = []
[sources]
unknown-registry = "warn"
unknown-git = "warn"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

BIN
logo.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 373 B

View File

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

View File

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

View File

@@ -63,17 +63,11 @@ impl HtpasswdAuth {
fn is_public_path(path: &str) -> bool { fn is_public_path(path: &str) -> bool {
matches!( matches!(
path, path,
"/" | "/health" "/" | "/health" | "/ready" | "/metrics" | "/v2/" | "/v2"
| "/ready"
| "/metrics"
| "/v2/"
| "/v2"
| "/api/tokens"
| "/api/tokens/list"
| "/api/tokens/revoke"
) || path.starts_with("/ui") ) || path.starts_with("/ui")
|| path.starts_with("/api-docs") || path.starts_with("/api-docs")
|| path.starts_with("/api/ui") || path.starts_with("/api/ui")
|| path.starts_with("/api/tokens")
} }
/// Auth middleware - supports Basic auth and Bearer tokens /// Auth middleware - supports Basic auth and Bearer tokens
@@ -410,12 +404,8 @@ mod tests {
assert!(is_public_path("/api/ui/stats")); assert!(is_public_path("/api/ui/stats"));
assert!(is_public_path("/api/tokens")); assert!(is_public_path("/api/tokens"));
assert!(is_public_path("/api/tokens/list")); assert!(is_public_path("/api/tokens/list"));
assert!(is_public_path("/api/tokens/revoke"));
// Protected paths // 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/myimage/blobs/sha256:abc"));
assert!(!is_public_path("/v2/library/nginx/manifests/latest")); assert!(!is_public_path("/v2/library/nginx/manifests/latest"));
assert!(!is_public_path( assert!(!is_public_path(

View File

@@ -85,7 +85,6 @@ pub struct AppState {
pub activity: ActivityLog, pub activity: ActivityLog,
pub docker_auth: registry::DockerAuth, pub docker_auth: registry::DockerAuth,
pub repo_index: RepoIndex, pub repo_index: RepoIndex,
pub http_client: reqwest::Client,
} }
#[tokio::main] #[tokio::main]
@@ -272,8 +271,6 @@ async fn run_server(config: Config, storage: Storage) {
// Initialize Docker auth with proxy timeout // Initialize Docker auth with proxy timeout
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout); let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
let http_client = reqwest::Client::new();
let state = Arc::new(AppState { let state = Arc::new(AppState {
storage, storage,
config, config,
@@ -284,7 +281,6 @@ async fn run_server(config: Config, storage: Storage) {
activity: ActivityLog::new(50), activity: ActivityLog::new(50),
docker_auth, docker_auth,
repo_index: RepoIndex::new(), repo_index: RepoIndex::new(),
http_client,
}); });
// Token routes with strict rate limiting (brute-force protection) // Token routes with strict rate limiting (brute-force protection)

View File

@@ -167,7 +167,6 @@ async fn download_blob(
// Try upstream proxies // Try upstream proxies
for upstream in &state.config.docker.upstreams { for upstream in &state.config.docker.upstreams {
if let Ok(data) = fetch_blob_from_upstream( if let Ok(data) = fetch_blob_from_upstream(
&state.http_client,
&upstream.url, &upstream.url,
&name, &name,
&digest, &digest,
@@ -368,7 +367,6 @@ async fn get_manifest(
for upstream in &state.config.docker.upstreams { for upstream in &state.config.docker.upstreams {
tracing::debug!(upstream_url = %upstream.url, "Trying upstream"); tracing::debug!(upstream_url = %upstream.url, "Trying upstream");
if let Ok((data, content_type)) = fetch_manifest_from_upstream( if let Ok((data, content_type)) = fetch_manifest_from_upstream(
&state.http_client,
&upstream.url, &upstream.url,
&name, &name,
&reference, &reference,
@@ -583,7 +581,6 @@ async fn list_tags_ns(
/// Fetch a blob from an upstream Docker registry /// Fetch a blob from an upstream Docker registry
async fn fetch_blob_from_upstream( async fn fetch_blob_from_upstream(
client: &reqwest::Client,
upstream_url: &str, upstream_url: &str,
name: &str, name: &str,
digest: &str, digest: &str,
@@ -597,14 +594,14 @@ async fn fetch_blob_from_upstream(
digest digest
); );
// First try without auth let client = reqwest::Client::builder()
let response = client
.get(&url)
.timeout(Duration::from_secs(timeout)) .timeout(Duration::from_secs(timeout))
.send() .build()
.await
.map_err(|_| ())?; .map_err(|_| ())?;
// First try without auth
let response = client.get(&url).send().await.map_err(|_| ())?;
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED { let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
// Get Www-Authenticate header and fetch token // Get Www-Authenticate header and fetch token
let www_auth = response let www_auth = response
@@ -640,7 +637,6 @@ async fn fetch_blob_from_upstream(
/// Fetch a manifest from an upstream Docker registry /// Fetch a manifest from an upstream Docker registry
/// Returns (manifest_bytes, content_type) /// Returns (manifest_bytes, content_type)
async fn fetch_manifest_from_upstream( async fn fetch_manifest_from_upstream(
client: &reqwest::Client,
upstream_url: &str, upstream_url: &str,
name: &str, name: &str,
reference: &str, reference: &str,
@@ -656,6 +652,13 @@ async fn fetch_manifest_from_upstream(
tracing::debug!(url = %url, "Fetching manifest from upstream"); tracing::debug!(url = %url, "Fetching manifest from upstream");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.build()
.map_err(|e| {
tracing::error!(error = %e, "Failed to build HTTP client");
})?;
// Request with Accept header for manifest types // Request with Accept header for manifest types
let accept_header = "application/vnd.docker.distribution.manifest.v2+json, \ let accept_header = "application/vnd.docker.distribution.manifest.v2+json, \
application/vnd.docker.distribution.manifest.list.v2+json, \ application/vnd.docker.distribution.manifest.list.v2+json, \
@@ -665,7 +668,6 @@ async fn fetch_manifest_from_upstream(
// First try without auth // First try without auth
let response = client let response = client
.get(&url) .get(&url)
.timeout(Duration::from_secs(timeout))
.header("Accept", accept_header) .header("Accept", accept_header)
.send() .send()
.await .await

View File

@@ -23,6 +23,7 @@ pub fn routes() -> Router<Arc<AppState>> {
async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response { async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
let key = format!("maven/{}", path); let key = format!("maven/{}", path);
// Extract artifact name for logging (last 2-3 path components)
let artifact_name = path let artifact_name = path
.split('/') .split('/')
.rev() .rev()
@@ -33,6 +34,7 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("/"); .join("/");
// Try local storage first
if let Ok(data) = state.storage.get(&key).await { if let Ok(data) = state.storage.get(&key).await {
state.metrics.record_download("maven"); state.metrics.record_download("maven");
state.metrics.record_cache_hit(); state.metrics.record_cache_hit();
@@ -45,10 +47,11 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
return with_content_type(&path, data).into_response(); return with_content_type(&path, data).into_response();
} }
// Try proxy servers
for proxy_url in &state.config.maven.proxies { for proxy_url in &state.config.maven.proxies {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path); let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
match fetch_from_proxy(&state.http_client, &url, state.config.maven.proxy_timeout).await { match fetch_from_proxy(&url, state.config.maven.proxy_timeout).await {
Ok(data) => { Ok(data) => {
state.metrics.record_download("maven"); state.metrics.record_download("maven");
state.metrics.record_cache_miss(); state.metrics.record_cache_miss();
@@ -59,6 +62,7 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
"PROXY", "PROXY",
)); ));
// Cache in local storage (fire and forget)
let storage = state.storage.clone(); let storage = state.storage.clone();
let key_clone = key.clone(); let key_clone = key.clone();
let data_clone = data.clone(); let data_clone = data.clone();
@@ -84,6 +88,7 @@ async fn upload(
) -> StatusCode { ) -> StatusCode {
let key = format!("maven/{}", path); let key = format!("maven/{}", path);
// Extract artifact name for logging
let artifact_name = path let artifact_name = path
.split('/') .split('/')
.rev() .rev()
@@ -110,18 +115,14 @@ async fn upload(
} }
} }
async fn fetch_from_proxy( async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
client: &reqwest::Client, let client = reqwest::Client::builder()
url: &str,
timeout_secs: u64,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs)) .timeout(Duration::from_secs(timeout_secs))
.send() .build()
.await
.map_err(|_| ())?; .map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());
} }

View File

@@ -19,6 +19,7 @@ pub fn routes() -> Router<Arc<AppState>> {
} }
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response { async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
// Determine if this is a tarball request or metadata request
let is_tarball = path.contains("/-/"); let is_tarball = path.contains("/-/");
let key = if is_tarball { let key = if is_tarball {
@@ -32,12 +33,14 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
format!("npm/{}/metadata.json", path) format!("npm/{}/metadata.json", path)
}; };
// Extract package name for logging
let package_name = if is_tarball { let package_name = if is_tarball {
path.split("/-/").next().unwrap_or(&path).to_string() path.split("/-/").next().unwrap_or(&path).to_string()
} else { } else {
path.clone() path.clone()
}; };
// Try local storage first
if let Ok(data) = state.storage.get(&key).await { if let Ok(data) = state.storage.get(&key).await {
if is_tarball { if is_tarball {
state.metrics.record_download("npm"); state.metrics.record_download("npm");
@@ -52,12 +55,17 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
return with_content_type(is_tarball, data).into_response(); return with_content_type(is_tarball, data).into_response();
} }
// Try proxy if configured
if let Some(proxy_url) = &state.config.npm.proxy { if let Some(proxy_url) = &state.config.npm.proxy {
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path); let url = 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)
};
if let Ok(data) = if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
fetch_from_proxy(&state.http_client, &url, state.config.npm.proxy_timeout).await
{
if is_tarball { if is_tarball {
state.metrics.record_download("npm"); state.metrics.record_download("npm");
state.metrics.record_cache_miss(); state.metrics.record_cache_miss();
@@ -69,6 +77,7 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
)); ));
} }
// Cache in local storage (fire and forget)
let storage = state.storage.clone(); let storage = state.storage.clone();
let key_clone = key.clone(); let key_clone = key.clone();
let data_clone = data.clone(); let data_clone = data.clone();
@@ -76,6 +85,7 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
let _ = storage.put(&key_clone, &data_clone).await; let _ = storage.put(&key_clone, &data_clone).await;
}); });
// Invalidate index when caching new tarball
if is_tarball { if is_tarball {
state.repo_index.invalidate("npm"); state.repo_index.invalidate("npm");
} }
@@ -87,18 +97,14 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
StatusCode::NOT_FOUND.into_response() StatusCode::NOT_FOUND.into_response()
} }
async fn fetch_from_proxy( async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
client: &reqwest::Client, let client = reqwest::Client::builder()
url: &str,
timeout_secs: u64,
) -> Result<Vec<u8>, ()> {
let response = client
.get(url)
.timeout(Duration::from_secs(timeout_secs)) .timeout(Duration::from_secs(timeout_secs))
.send() .build()
.await
.map_err(|_| ())?; .map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());
} }

View File

@@ -85,9 +85,7 @@ async fn package_versions(
if let Some(proxy_url) = &state.config.pypi.proxy { if let Some(proxy_url) = &state.config.pypi.proxy {
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized); let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) = if let Ok(html) = fetch_package_page(&url, state.config.pypi.proxy_timeout).await {
fetch_package_page(&state.http_client, &url, state.config.pypi.proxy_timeout).await
{
// Rewrite URLs in the HTML to point to our registry // Rewrite URLs in the HTML to point to our registry
let rewritten = rewrite_pypi_links(&html, &normalized); let rewritten = rewrite_pypi_links(&html, &normalized);
return (StatusCode::OK, Html(rewritten)).into_response(); return (StatusCode::OK, Html(rewritten)).into_response();
@@ -132,22 +130,10 @@ async fn download_file(
// First, fetch the package page to find the actual download URL // First, fetch the package page to find the actual download URL
let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized); let page_url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
if let Ok(html) = fetch_package_page( if let Ok(html) = fetch_package_page(&page_url, state.config.pypi.proxy_timeout).await {
&state.http_client,
&page_url,
state.config.pypi.proxy_timeout,
)
.await
{
// Find the URL for this specific file // Find the URL for this specific file
if let Some(file_url) = find_file_url(&html, &filename) { if let Some(file_url) = find_file_url(&html, &filename) {
if let Ok(data) = fetch_file( if let Ok(data) = fetch_file(&file_url, state.config.pypi.proxy_timeout).await {
&state.http_client,
&file_url,
state.config.pypi.proxy_timeout,
)
.await
{
state.metrics.record_download("pypi"); state.metrics.record_download("pypi");
state.metrics.record_cache_miss(); state.metrics.record_cache_miss();
state.activity.push(ActivityEntry::new( state.activity.push(ActivityEntry::new(
@@ -191,14 +177,14 @@ fn normalize_name(name: &str) -> String {
} }
/// Fetch package page from upstream /// Fetch package page from upstream
async fn fetch_package_page( async fn fetch_package_page(url: &str, timeout_secs: u64) -> Result<String, ()> {
client: &reqwest::Client, let client = reqwest::Client::builder()
url: &str, .timeout(Duration::from_secs(timeout_secs))
timeout_secs: u64, .build()
) -> Result<String, ()> { .map_err(|_| ())?;
let response = client let response = client
.get(url) .get(url)
.timeout(Duration::from_secs(timeout_secs))
.header("Accept", "text/html") .header("Accept", "text/html")
.send() .send()
.await .await
@@ -212,14 +198,14 @@ async fn fetch_package_page(
} }
/// Fetch file from upstream /// Fetch file from upstream
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> { async fn fetch_file(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
let response = client let client = reqwest::Client::builder()
.get(url)
.timeout(Duration::from_secs(timeout_secs)) .timeout(Duration::from_secs(timeout_secs))
.send() .build()
.await
.map_err(|_| ())?; .map_err(|_| ())?;
let response = client.get(url).send().await.map_err(|_| ())?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(()); return Err(());
} }

View File

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