diff --git a/deploy/rpki-explorer/.env.example b/deploy/rpki-explorer/.env.example new file mode 100644 index 0000000..fd75ea7 --- /dev/null +++ b/deploy/rpki-explorer/.env.example @@ -0,0 +1,22 @@ +# rpki-explorer standalone stack configuration. +# Copy to .env and adjust paths for the target host. + +# Soak outputs on the host (mounted read-only). +SOAK_RUNS_DIR=/root/rpki096_x86_rtr_installer/data/runs +SOAK_DB_DIR=/root/rpki096_x86_rtr_installer/data/state/db + +# Host directory where the query-db index is persisted (reused across restarts). +QUERY_DB_DIR=/root/rpki128_explorer/query-db + +# Host loopback port the explorer is published on (SSH port-forward access). +LISTEN_PORT=9517 + +# Keep only the latest N indexed runs; first boot backfills the latest N runs. +RETAIN_RUNS=10 + +# Watch poll interval for newly completed runs. +WATCH_INTERVAL_SECS=10 + +# Image coordinates (built locally, transferred via docker save/load). +IMAGE_NAME=rpki-explorer +IMAGE_TAG=local diff --git a/deploy/rpki-explorer/Dockerfile b/deploy/rpki-explorer/Dockerfile new file mode 100644 index 0000000..8422d15 --- /dev/null +++ b/deploy/rpki-explorer/Dockerfile @@ -0,0 +1,68 @@ +# rpki-explorer all-in-one image: +# - rpki_query_service (watch mode) + rpki_query_indexer +# - nginx serving the explorer SPA and proxying /api/v1/ to the query service +# +# Build context must be the repository root (rpki_2/rpki): +# docker build -f deploy/rpki-explorer/Dockerfile -t rpki-explorer: . +# +# Note: no `# syntax=` directive on purpose — the default images below are all +# expected to be present locally, and pulling the dockerfile frontend from +# docker.io may not be possible on offline build hosts. + +ARG RUST_BUILDER_IMAGE=rust:1-bookworm +ARG NODE_BUILDER_IMAGE=node:slim +ARG RUNTIME_IMAGE=nginx:1.27-bookworm + +FROM ${RUST_BUILDER_IMAGE} AS rust-builder + +WORKDIR /src + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + clang \ + cmake \ + git \ + libclang-dev \ + make \ + perl \ + pkg-config \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/src/target \ + cargo build --release --bin rpki_query_service --bin rpki_query_indexer \ + && cp target/release/rpki_query_service target/release/rpki_query_indexer /usr/local/bin/ + +FROM ${NODE_BUILDER_IMAGE} AS ui-builder + +WORKDIR /ui + +COPY ui/rpki-explorer/package.json ui/rpki-explorer/package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY ui/rpki-explorer/ ./ +RUN npm run build + +FROM ${RUNTIME_IMAGE} + +ARG GIT_REV=unknown +LABEL org.opencontainers.image.title="rpki-explorer" \ + org.opencontainers.image.revision="${GIT_REV}" + +COPY --from=rust-builder /usr/local/bin/rpki_query_service /usr/local/bin/rpki_query_service +COPY --from=rust-builder /usr/local/bin/rpki_query_indexer /usr/local/bin/rpki_query_indexer +COPY --from=ui-builder /ui/dist /usr/share/nginx/html +COPY deploy/rpki-explorer/nginx.conf /etc/nginx/conf.d/default.conf +COPY deploy/rpki-explorer/entrypoint.sh /entrypoint.sh + +RUN chmod +x /entrypoint.sh && mkdir -p /data + +EXPOSE 8080 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/deploy/rpki-explorer/README.md b/deploy/rpki-explorer/README.md new file mode 100644 index 0000000..609dbeb --- /dev/null +++ b/deploy/rpki-explorer/README.md @@ -0,0 +1,76 @@ +# rpki-explorer standalone stack + +One container running the RPKI Explorer web UI plus the query backend +(`rpki_query_service` in watch mode, spawning `rpki_query_indexer`), deployed +next to a running ours-RP soak. It mounts the soak outputs **read-only** and +keeps only the latest `RETAIN_RUNS` indexed runs. + +```text +browser ──ssh -L──► 127.0.0.1:9517 ──► container + ├─ nginx :8080 (dist + /api/v1/ proxy) + └─ rpki_query_service :9557 + ├─ /data/query-db (host bind mount, rw, reused on restart) + ├─ /soak/db/repo-bytes.db (ro) + └─ /soak/runs (ro, watched) +``` + +## Prerequisites + +- Docker with the compose plugin (`docker compose version`) on the target host. +- A running soak whose `runs/` directories contain `report.json` (and + `run-summary.json` once finished) — only completed runs get indexed. + +## Build (local machine, same arch as the target) + +```bash +deploy/rpki-explorer/build_image.sh +# tarball + provenance land in target/rpki-explorer-image/ +``` + +The build runs entirely inside Docker (rust release binaries + vite build), +so no local toolchain version requirements apply. + +## Transfer and deploy (remote host) + +```bash +cat target/rpki-explorer-image/rpki-explorer-.tar.gz | ssh root@ 'gunzip | docker load' + +ssh root@ +mkdir -p /root/rpki128_explorer +cd /root/rpki128_explorer +# place docker-compose.yml and .env here (see .env.example) +docker compose up -d +docker logs -f rpki-explorer +``` + +On first boot the entrypoint computes +`--watch-min-run-seq = latest_completed - RETAIN_RUNS + 1` so exactly the +latest `RETAIN_RUNS` completed runs are backfilled. On later boots the +existing query-db drives continuation — the index built earlier is reused, no +re-backfill happens. + +## Access + +```bash +ssh -L 9517:127.0.0.1:9517 root@ +# open http://127.0.0.1:9517 +``` + +The stack binds loopback only; there is no authentication or TLS. + +## Upgrade + +Rebuild the image with a new tag, transfer and `docker load`, update +`IMAGE_TAG` in `.env`, then `docker compose up -d`. The query-db on the host +is preserved. + +## Rollback / removal + +```bash +docker compose down +# keep /root/rpki128_explorer/query-db to reuse the index later, or delete it +# to force a fresh backfill of the latest RETAIN_RUNS runs. +``` + +The soak stack is never touched; removing the explorer stack has no effect on +it. diff --git a/deploy/rpki-explorer/build_image.sh b/deploy/rpki-explorer/build_image.sh new file mode 100755 index 0000000..1652592 --- /dev/null +++ b/deploy/rpki-explorer/build_image.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Build the rpki-explorer all-in-one image locally and export it as a tarball +# ready to transfer to a remote host (docker load on the other side). +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${OUT_DIR:-$REPO_ROOT/target/rpki-explorer-image}" +IMAGE_NAME="${IMAGE_NAME:-rpki-explorer}" +GIT_REV="$(git -C "$REPO_ROOT" rev-parse --short HEAD)" +IMAGE_TAG="${IMAGE_TAG:-$GIT_REV}" + +dirty_count="$(git -C "$REPO_ROOT" status --porcelain | wc -l)" + +echo "[build] repo=$REPO_ROOT rev=$GIT_REV dirty_files=$dirty_count tag=$IMAGE_TAG" + +docker build \ + --file "$REPO_ROOT/deploy/rpki-explorer/Dockerfile" \ + --build-arg "GIT_REV=$GIT_REV" \ + --tag "$IMAGE_NAME:$IMAGE_TAG" \ + "$REPO_ROOT" + +mkdir -p "$OUT_DIR" +TARBALL="$OUT_DIR/rpki-explorer-$IMAGE_TAG.tar.gz" +docker save "$IMAGE_NAME:$IMAGE_TAG" | gzip > "$TARBALL" + +IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$IMAGE_NAME:$IMAGE_TAG")" +cat > "$OUT_DIR/rpki-explorer-$IMAGE_TAG.provenance.txt" < 'gunzip | docker load'" diff --git a/deploy/rpki-explorer/docker-compose.yml b/deploy/rpki-explorer/docker-compose.yml new file mode 100644 index 0000000..a72a298 --- /dev/null +++ b/deploy/rpki-explorer/docker-compose.yml @@ -0,0 +1,28 @@ +name: rpki-explorer + +# Standalone explorer stack: one container with nginx + rpki_query_service +# (+ rpki_query_indexer spawned by the service watcher). It mounts the soak +# run root and state db read-only and persists its query-db on the host so +# container/host restarts reuse the already-built index. + +services: + rpki-explorer: + image: ${IMAGE_NAME:-rpki-explorer}:${IMAGE_TAG:-local} + build: + context: ../.. + dockerfile: deploy/rpki-explorer/Dockerfile + args: + GIT_REV: ${GIT_REV:-unknown} + container_name: rpki-explorer + restart: unless-stopped + ports: + # Loopback only: access via SSH port forwarding, e.g. + # ssh -L 9517:127.0.0.1:9517 root@ + - "127.0.0.1:${LISTEN_PORT:-9517}:8080" + environment: + RETAIN_RUNS: "${RETAIN_RUNS:-10}" + WATCH_INTERVAL_SECS: "${WATCH_INTERVAL_SECS:-10}" + volumes: + - "${SOAK_RUNS_DIR:?set SOAK_RUNS_DIR in .env}:/soak/runs:ro" + - "${SOAK_DB_DIR:?set SOAK_DB_DIR in .env}:/soak/db:ro" + - "${QUERY_DB_DIR:-./query-db}:/data" diff --git a/deploy/rpki-explorer/entrypoint.sh b/deploy/rpki-explorer/entrypoint.sh new file mode 100755 index 0000000..6c856f1 --- /dev/null +++ b/deploy/rpki-explorer/entrypoint.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Container entrypoint: run rpki_query_service (watch mode) and nginx in one +# container. nginx serves the explorer SPA and proxies /api/v1/ to the query +# service. If either process exits, the container exits and the compose +# restart policy brings it back. +set -euo pipefail + +RUNS_DIR="${RUNS_DIR:-/soak/runs}" +REPO_BYTES_DB="${REPO_BYTES_DB:-/soak/db/repo-bytes.db}" +QUERY_DB="${QUERY_DB:-/data/query-db}" +RETAIN_RUNS="${RETAIN_RUNS:-10}" +WATCH_INTERVAL_SECS="${WATCH_INTERVAL_SECS:-10}" +QUERY_LISTEN="${QUERY_LISTEN:-127.0.0.1:9557}" + +log() { echo "[entrypoint] $*"; } + +latest_completed_seq() { + local latest=0 dir base n + shopt -s nullglob + for dir in "$RUNS_DIR"/run_*/; do + [ -f "${dir}report.json" ] || continue + base="${dir%/}" + n="${base##*/run_}" + case "$n" in + *[!0-9]* | "") continue ;; + esac + if [ "$n" -gt "$latest" ]; then + latest="$n" + fi + done + echo "$latest" +} + +mkdir -p "$(dirname "$QUERY_DB")" + +ARGS=( + --query-db "$QUERY_DB" + --listen "$QUERY_LISTEN" + --watch-run-root "$RUNS_DIR" + --watch-interval-secs "$WATCH_INTERVAL_SECS" + --retain-indexed-runs "$RETAIN_RUNS" +) + +if [ -e "$REPO_BYTES_DB" ]; then + ARGS+=(--repo-bytes-db "$REPO_BYTES_DB") +else + log "WARNING: repo-bytes db not found at $REPO_BYTES_DB; parsed/raw/export features will be unavailable" +fi + +# Decide the watcher start point: +# - explicit WATCH_MIN_RUN_SEQ always wins; +# - on first boot (query-db does not exist yet) backfill the latest +# RETAIN_RUNS completed runs; +# - on later boots the existing query-db drives continuation +# (max(min_run_seq, latest_ready+1)), so no flag is needed. +if [ -n "${WATCH_MIN_RUN_SEQ:-}" ]; then + ARGS+=(--watch-min-run-seq "$WATCH_MIN_RUN_SEQ") + log "using explicit WATCH_MIN_RUN_SEQ=$WATCH_MIN_RUN_SEQ" +elif [ ! -e "$QUERY_DB" ]; then + latest="$(latest_completed_seq)" + if [ "$latest" -gt "$RETAIN_RUNS" ]; then + min_seq=$((latest - RETAIN_RUNS + 1)) + else + min_seq=1 + fi + log "first boot: latest completed run seq=$latest -> --watch-min-run-seq $min_seq (backfill $RETAIN_RUNS runs)" + ARGS+=(--watch-min-run-seq "$min_seq") +else + log "existing query-db at $QUERY_DB; resuming from indexed state" +fi + +log "starting rpki_query_service ${ARGS[*]}" +rpki_query_service "${ARGS[@]}" & +QS_PID=$! + +log "starting nginx" +nginx -g 'daemon off;' & +NGINX_PID=$! + +shutdown() { + log "shutting down (qs=$QS_PID nginx=$NGINX_PID)" + kill -TERM "$QS_PID" 2>/dev/null || true + kill -TERM "$NGINX_PID" 2>/dev/null || true +} +trap shutdown TERM INT + +wait -n "$QS_PID" "$NGINX_PID" +EXIT_CODE=$? +log "a process exited (code=$EXIT_CODE); stopping the remaining one" +shutdown +wait "$QS_PID" "$NGINX_PID" 2>/dev/null || true +exit "$EXIT_CODE" diff --git a/deploy/rpki-explorer/nginx.conf b/deploy/rpki-explorer/nginx.conf new file mode 100644 index 0000000..de0918c --- /dev/null +++ b/deploy/rpki-explorer/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 8080; + server_name _; + + access_log /dev/stdout; + error_log /dev/stderr; + + root /usr/share/nginx/html; + index index.html; + + # query service has no CORS headers and no authentication: the API must be + # served same-origin with the UI. + location /api/v1/ { + proxy_pass http://127.0.0.1:9557; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # SPA fallback: routing is client-side. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/ui/rpki-explorer/src/components/ProjectionView.tsx b/ui/rpki-explorer/src/components/ProjectionView.tsx index 4b1e736..0fa460b 100644 --- a/ui/rpki-explorer/src/components/ProjectionView.tsx +++ b/ui/rpki-explorer/src/components/ProjectionView.tsx @@ -19,6 +19,119 @@ function MetaRow({ label, value, mono = false }: { label: string; value: ReactNo ); } +/** Format an IP address from a projection byte array (v4 dotted / v6 compressed hextets). */ +function formatIpAddr(addr: unknown, afi: unknown): string | null { + if (!Array.isArray(addr)) return null; + const bytes = addr.filter((b): b is number => typeof b === "number"); + if (bytes.length === 0) return null; + if (asString(afi) === "Ipv6" || bytes.length > 4) { + const groups: number[] = []; + for (let i = 0; i + 1 < bytes.length; i += 2) { + groups.push((bytes[i] << 8) | bytes[i + 1]); + } + // RFC 5952-style compression of the longest run of zero groups. + let bestStart = -1; + let bestLen = 0; + for (let i = 0; i < groups.length; ) { + if (groups[i] !== 0) { + i += 1; + continue; + } + let j = i; + while (j < groups.length && groups[j] === 0) j += 1; + if (j - i > bestLen) { + bestStart = i; + bestLen = j - i; + } + i = j; + } + const hex = groups.map((g) => g.toString(16)); + if (bestLen >= 2) { + const head = hex.slice(0, bestStart).join(":"); + const tail = hex.slice(bestStart + bestLen).join(":"); + return `${head}::${tail}`; + } + return hex.join(":"); + } + return bytes.join("."); +} + +/** + * Flatten projection ipResources to display strings. + * Real shape (serde of the rpki types): + * { families: [ { afi, choice: { AddressesOrRanges: [ {Prefix:{addr,afi,prefix_len}} | {Range:{min,max}} ] } } ] } + */ +function formatIpResources(resources: unknown): string[] { + const out: string[] = []; + for (const famRaw of asArray(asRecord(resources)?.families)) { + const fam = asRecord(famRaw); + const choice = asRecord(fam?.choice); + if (!choice) continue; + if ("Inherit" in choice) { + out.push("inherit"); + continue; + } + for (const entryRaw of asArray(choice.AddressesOrRanges)) { + const entry = asRecord(entryRaw); + const prefix = asRecord(entry?.Prefix); + if (prefix) { + const base = formatIpAddr(prefix.addr, prefix.afi ?? fam?.afi); + out.push(base ? `${base}/${asNumber(prefix.prefix_len) ?? "?"}` : JSON.stringify(entryRaw)); + continue; + } + const range = asRecord(entry?.Range); + if (range) { + const min = formatIpAddr(asRecord(range.min)?.addr ?? range.min, fam?.afi); + const max = formatIpAddr(asRecord(range.max)?.addr ?? range.max, fam?.afi); + out.push(min && max ? `${min} - ${max}` : JSON.stringify(entryRaw)); + continue; + } + out.push(JSON.stringify(entryRaw)); + } + } + return out; +} + +/** + * Flatten projection asResources to display strings. + * Real shape: { asnum: { AsIdsOrRanges: [ {Id: n} | {Range: {min, max}} ] } } or { "inherit"/"Inherit": … }. + */ +function formatAsResources(resources: unknown): string[] { + const rec = asRecord(resources); + if (!rec) return []; + if ("inherit" in rec || "Inherit" in rec) return ["inherit"]; + const out: string[] = []; + for (const entryRaw of asArray(asRecord(rec.asnum)?.AsIdsOrRanges)) { + const entry = asRecord(entryRaw); + const id = asNumber(entry?.Id); + if (id != null) { + out.push(`AS${id}`); + continue; + } + const range = asRecord(entry?.Range); + if (range) { + out.push(`AS${asNumber(range.min) ?? "?"}-AS${asNumber(range.max) ?? "?"}`); + continue; + } + out.push(JSON.stringify(entryRaw)); + } + return out; +} + +/** Render a list of URIs (one per line) or null when empty. */ +function uriLines(uris: string[]): ReactNode { + if (!uris.length) return null; + return ( + + {uris.map((u) => ( + + {u} + + ))} + + ); +} + function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) { const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`); const query = useQuery({ @@ -176,11 +289,42 @@ function AspaView({ projection }: { projection: Record }) { } function CertificateView({ projection }: { projection: Record }) { - const cert = asRecord(projection.certificate) ?? asRecord(projection.cer) ?? projection; + // Real API shape: the certificate payload lives in `resourceCertificate` + // with extensions nested under `extensions`; keep the older + // certificate/cer + flat-field variants working as fallbacks. + const cert = + asRecord(projection.certificate) ?? + asRecord(projection.cer) ?? + asRecord(projection.resourceCertificate) ?? + projection; const validity = asRecord(cert.validity); - const resources = asRecord(cert.resources); - const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses); - const asBlocks = asArray(resources?.asResources ?? resources?.asns); + const ext = asRecord(cert.extensions); + const legacyResources = asRecord(cert.resources); + + const siaDescriptions = asArray(asRecord(ext?.subjectInfoAccess)?.accessDescriptions); + const siaUris = siaDescriptions + .map((d) => asString(asRecord(d)?.accessLocation)) + .filter((v): v is string => Boolean(v)); + const aiaUris = asArray(ext?.caIssuersUris) + .map((u) => asString(u)) + .filter((v): v is string => Boolean(v)); + const crlDpUris = asArray(ext?.crlDistributionPointsUris) + .map((u) => asString(u)) + .filter((v): v is string => Boolean(v)); + + const extIp = formatIpResources(ext?.ipResources); + const ipBlocks = extIp.length + ? extIp + : asArray(legacyResources?.ipResources ?? legacyResources?.ipv4 ?? legacyResources?.addresses).map((b) => + typeof b === "string" ? b : JSON.stringify(b), + ); + const extAs = formatAsResources(ext?.asResources); + const asBlocks = extAs.length + ? extAs + : asArray(legacyResources?.asResources ?? legacyResources?.asns).map((b) => + typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b), + ); + return (
@@ -188,21 +332,42 @@ function CertificateView({ projection }: { projection: Record } - } /> - } /> - } /> - } /> - } /> + } + /> + } + /> + : uriLines(siaUris)} + /> + : uriLines(aiaUris)} + /> + + ) : ( + uriLines(crlDpUris) + ) + } + /> {ipBlocks.slice(0, 12).map((b, i) => ( - {typeof b === "string" ? b : JSON.stringify(b)} + {b} ))} {ipBlocks.length > 12 ? +{ipBlocks.length - 12} more : null} @@ -217,7 +382,7 @@ function CertificateView({ projection }: { projection: Record } asBlocks.length ? ( {asBlocks.slice(0, 12).map((b, i) => ( - {typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)} + {b} ))} {asBlocks.length > 12 ? +{asBlocks.length - 12} more : null} @@ -258,11 +423,16 @@ export function ProjectionView({ const projection = record.projection; const type = (record.objectType ?? "").toLowerCase(); - if (type === "roa") return ; - if (type === "asa" || type === "aspa") return ; + // The query service nests the typed payload one level down: + // projection = { input, object: { roa | manifest | crl | certificate | aspa, … }, schemaVersion, tool } + // Accept a flat payload too, so older fixtures / shape variants keep working. + const typed = asRecord(projection.object) ?? projection; + + if (type === "roa") return ; + if (type === "asa" || type === "aspa") return ; if (type === "mft") { - const manifest = asRecord(projection.manifest); + const manifest = asRecord(typed.manifest); return ( <>
@@ -278,15 +448,37 @@ export function ProjectionView({ } if (type === "crl") { - const crl = asRecord(projection.crl); + // Real API shape: CRL fields sit directly on the typed payload + // ({ issuer, thisUpdate, nextUpdate, extensions: { crlNumberHex, authorityKeyIdentifier }, … }), + // there is no `crl` wrapper; keep `typed.crl` as a legacy fallback. + const crl = asRecord(typed.crl) ?? typed; + const crlExt = asRecord(crl.extensions); return ( <>
- - - - - } /> + + + + + + } + />
@@ -294,12 +486,12 @@ export function ProjectionView({ } if (type === "cer" || type === "ee" || type === "router_cert") { - return ; + return ; } return (
-      {JSON.stringify(projection, null, 2)}
+      {JSON.stringify(typed, null, 2)}
     
); } diff --git a/ui/rpki-explorer/tests/e2e/fixtures.ts b/ui/rpki-explorer/tests/e2e/fixtures.ts index 39ab90b..f3c2c84 100644 --- a/ui/rpki-explorer/tests/e2e/fixtures.ts +++ b/ui/rpki-explorer/tests/e2e/fixtures.ts @@ -209,7 +209,21 @@ export const OBJ_ROA_REJECTED = { rejectReason: "manifest stale: thisUpdate in the past", }; -export const OBJECTS = [OBJ_ROA, OBJ_MFT, OBJ_CER_REJECTED, OBJ_ASPA, OBJ_ROA_REJECTED]; +export const OBJ_CRL = { + objectInstanceId: "obj-crl-0001", + uri: "rsync://repo-a.example/ca1/main.crl", + sha256: SHA("f"), + objectType: "crl", + result: "ok", + detailSummary: null, + repoId: REPO_A.repoId, + ppId: PP_1.ppId, + sourceSection: "fresh", + rejected: false, + rejectReason: null, +}; + +export const OBJECTS = [OBJ_ROA, OBJ_MFT, OBJ_CER_REJECTED, OBJ_ASPA, OBJ_ROA_REJECTED, OBJ_CRL]; export const STATS_VALIDATION = { ok: 1279, error: 2, skipped: 3 }; export const STATS_OBJECT_TYPES = { @@ -226,45 +240,65 @@ export const STATS_REASONS = { }; export const ROA_PROJECTION = { - schemaVersion: 1, + schemaVersion: 2, sha256: SHA("a"), objectType: "roa", parseStatus: "ok", + // Mirrors the live query service shape: + // projection = { input, object: { type, roa|manifest|… }, schemaVersion, tool } projection: { - type: "roa", - roa: { - asId: 65001, - ipAddressFamilies: [ - { - afi: "ipv4", - addresses: [ - { prefix: "203.0.113.0/24", maxLength: 24 }, - { prefix: "198.51.100.0/25", maxLength: 25 }, - ], - }, - { - afi: "ipv6", - addresses: [{ prefix: "2001:db8::/32", maxLength: 48 }], - }, - ], + input: { + type: "roa", + path: "rsync://repo-a.example/ca1/alice.roa", + bytes: { len: 1280, sha256: SHA("a") }, }, + object: { + type: "roa", + roa: { + asId: 65001, + ipAddressFamilies: [ + { + afi: "ipv4", + addresses: [ + { prefix: "203.0.113.0/24", maxLength: 24 }, + { prefix: "198.51.100.0/25", maxLength: 25 }, + ], + }, + { + afi: "ipv6", + addresses: [{ prefix: "2001:db8::/32", maxLength: 48 }], + }, + ], + }, + }, + schemaVersion: 2, + tool: "rpki", }, }; export const MFT_PROJECTION = { - schemaVersion: 1, + schemaVersion: 2, sha256: SHA("b"), objectType: "mft", parseStatus: "ok", projection: { - type: "mft", - manifest: { - manifestNumberHex: "0A2F", - thisUpdate: "2026-07-16T23:00:00Z", - nextUpdate: "2026-07-17T23:00:00Z", - fileHashAlg: "sha256", - fileCount: 3, + input: { + type: "mft", + path: "rsync://repo-a.example/ca1/manifest.mft", + bytes: { len: 2048, sha256: SHA("b") }, }, + object: { + type: "mft", + manifest: { + manifestNumberHex: "0A2F", + thisUpdate: "2026-07-16T23:00:00Z", + nextUpdate: "2026-07-17T23:00:00Z", + fileHashAlg: "sha256", + fileCount: 3, + }, + }, + schemaVersion: 2, + tool: "rpki", }, }; @@ -274,6 +308,125 @@ export const MFT_FILES = [ { fileName: "stale.cer", hashHex: SHA("c") }, ]; +// Certificate projection in the real nested API shape: +// certificate payload under `object.resourceCertificate`, SKI/AKI/SIA/AIA/CRLDP +// and IP/AS resources under `extensions`. +export const CER_PROJECTION = { + schemaVersion: 2, + sha256: SHA("c"), + objectType: "cer", + parseStatus: "ok", + projection: { + input: { + type: "cer", + path: "rsync://repo-a.example/ca1/stale.cer", + bytes: { len: 1534, sha256: SHA("c") }, + }, + object: { + type: "cer", + decode: { profileValid: true }, + resourceCertificate: { + kind: "Ca", + version: 3, + serialNumberHex: "02C6C7", + issuer: "CN=A90DC5BE, serialNumber=0E65A4F5", + subject: "CN=A9114E750000, serialNumber=5A179648", + validity: { notBefore: "2026-07-23T00:30:06Z", notAfter: "2027-09-30T00:00:00Z" }, + signatureAlgorithm: "1.2.840.113549.1.1.11", + extensions: { + subjectKeyIdentifier: "5a179648b3ef2369dce7bdb58140ff7dc7060abf", + authorityKeyIdentifier: "0e65a4f5fd36b5bd68eb3c923408978c907aa79f", + basicConstraintsCa: true, + subjectInfoAccess: { + accessDescriptions: [ + { accessMethodOid: "1.3.6.1.5.5.7.48.5", accessLocation: "rsync://repo-a.example/ca1/" }, + ], + }, + caIssuersUris: ["rsync://repo-a.example/ca1/issuer.cer"], + crlDistributionPointsUris: ["rsync://repo-a.example/ca1/main.crl"], + ipResources: { + families: [ + { + afi: "Ipv4", + choice: { + AddressesOrRanges: [ + { Prefix: { addr: [103, 152, 34, 0], afi: "Ipv4", prefix_len: 23 } }, + { Range: { min: { addr: [192, 0, 2, 0] }, max: { addr: [192, 0, 2, 255] } } }, + ], + }, + }, + { + afi: "Ipv6", + choice: { + AddressesOrRanges: [ + { + Prefix: { + addr: [38, 6, 104, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + afi: "Ipv6", + prefix_len: 32, + }, + }, + ], + }, + }, + ], + }, + asResources: { + asnum: { + AsIdsOrRanges: [{ Id: 38008 }, { Id: 38023 }, { Range: { min: 65536, max: 65551 } }], + }, + }, + }, + }, + }, + schemaVersion: 2, + tool: "rpki", + }, +}; + +// CRL projection in the real nested API shape: CRL fields sit directly on the +// typed payload (no `crl` wrapper); crlNumber/AKI live under `extensions`. +export const CRL_PROJECTION = { + schemaVersion: 2, + sha256: SHA("f"), + objectType: "crl", + parseStatus: "ok", + projection: { + input: { + type: "crl", + path: "rsync://repo-a.example/ca1/main.crl", + bytes: { len: 900, sha256: SHA("f") }, + }, + object: { + type: "crl", + decode: { profileValid: true }, + issuer: "CN=A9114E750000, serialNumber=5A179648", + thisUpdate: "2026-07-23T02:47:00Z", + nextUpdate: "2026-07-24T02:52:00Z", + signatureAlgorithm: "1.2.840.113549.1.1.11", + extensions: { + authorityKeyIdentifier: "5a179648b3ef2369dce7bdb58140ff7dc7060abf", + crlNumber: 3394, + crlNumberHex: "0D42", + }, + revokedCertificates: { + count: 2, + entries: [ + { serialNumberHex: "1B0B0AD86D78E77D1491B5069E6287D08EEA7C17", revocationDate: "2027-02-13T13:22:01Z" }, + { serialNumberHex: "6A106263F88133615B7E8D3466C192CEC0904F2D", revocationDate: "2027-03-18T19:58:00Z" }, + ], + }, + }, + schemaVersion: 2, + tool: "rpki", + }, +}; + +export const CRL_REVOKED = [ + { serialNumberHex: "1B0B0AD86D78E77D1491B5069E6287D08EEA7C17", revocationDate: "2027-02-13T13:22:01Z" }, + { serialNumberHex: "6A106263F88133615B7E8D3466C192CEC0904F2D", revocationDate: "2027-03-18T19:58:00Z" }, +]; + export const VALIDATION_SUMMARY = { finalStatus: "valid", auditResult: "ok", diff --git a/ui/rpki-explorer/tests/e2e/mockApi.ts b/ui/rpki-explorer/tests/e2e/mockApi.ts index 8cf4997..a50df54 100644 --- a/ui/rpki-explorer/tests/e2e/mockApi.ts +++ b/ui/rpki-explorer/tests/e2e/mockApi.ts @@ -5,7 +5,10 @@ */ import type { Page, Route } from "@playwright/test"; import { + CER_PROJECTION, CHAIN_EDGES, + CRL_PROJECTION, + CRL_REVOKED, EXPLAIN_RECORD, HEALTH, MFT_FILES, @@ -303,6 +306,8 @@ export async function installMockApi( if (b === "parsed" && !c) { if (obj.objectType === "roa") return fulfillJson(route, ROA_PROJECTION); if (obj.objectType === "manifest") return fulfillJson(route, MFT_PROJECTION); + if (obj.objectType === "certificate") return fulfillJson(route, CER_PROJECTION); + if (obj.objectType === "crl") return fulfillJson(route, CRL_PROJECTION); return fulfillJson(route, null); } if (b === "parsed" && c === "manifest-files") { @@ -310,7 +315,8 @@ export async function installMockApi( return fulfillJson(route, items, next); } if (b === "parsed" && c === "revoked-certs") { - return fulfillJson(route, [], null); + const { page: items, next } = paginate(obj.objectType === "crl" ? CRL_REVOKED : [], url, 100); + return fulfillJson(route, items, next); } if (b === "validation" && !c) return fulfillJson(route, VALIDATION_SUMMARY); if (b === "validation" && c === "explain" && request.method() === "POST") { diff --git a/ui/rpki-explorer/tests/e2e/projection-cert-crl.spec.ts b/ui/rpki-explorer/tests/e2e/projection-cert-crl.spec.ts new file mode 100644 index 0000000..a20d123 --- /dev/null +++ b/ui/rpki-explorer/tests/e2e/projection-cert-crl.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { installMockApi } from "./mockApi"; + +test.beforeEach(async ({ page }) => { + await installMockApi(page); +}); + +test("certificate object renders the resourceCertificate projection", async ({ page }) => { + await page.goto("/objects/obj-cer-0001"); + + await expect(page.getByText("Serial number")).toBeVisible(); + await expect(page.getByText("02C6C7")).toBeVisible(); + await expect(page.getByText("CN=A90DC5BE, serialNumber=0E65A4F5")).toBeVisible(); + await expect(page.getByText("CN=A9114E750000, serialNumber=5A179648")).toBeVisible(); + await expect(page.getByText("5a179648b3ef2369dce7bdb58140ff7dc7060abf").first()).toBeVisible(); + await expect(page.getByText("rsync://repo-a.example/ca1/main.crl")).toBeVisible(); + // IP resources rendered from the real serde shape (prefix + range, v4 + v6) + await expect(page.getByText("103.152.34.0/23")).toBeVisible(); + await expect(page.getByText("192.0.2.0 - 192.0.2.255")).toBeVisible(); + await expect(page.getByText("2606:6800::/32")).toBeVisible(); + // AS resources rendered as AS chips (Id + Range) + await expect(page.getByText("AS38008", { exact: true })).toBeVisible(); + await expect(page.getByText("AS65536-AS65551")).toBeVisible(); + await expect(page.getByText("1.2.840.113549.1.1.11")).toBeVisible(); +}); + +test("crl object renders header fields from the top-level payload", async ({ page }) => { + await page.goto("/objects/obj-crl-0001"); + + await expect(page.getByText("CN=A9114E750000, serialNumber=5A179648")).toBeVisible(); + await expect(page.getByText("CRL number")).toBeVisible(); + await expect(page.getByText("0D42")).toBeVisible(); + await expect(page.getByText("5a179648b3ef2369dce7bdb58140ff7dc7060abf")).toBeVisible(); + // Revoked certificates table from the sub-endpoint + await expect(page.getByRole("cell", { name: "1B0B0AD86D78E77D1491B5069E6287D08EEA7C17" })).toBeVisible(); + await expect(page.getByRole("cell", { name: "6A106263F88133615B7E8D3466C192CEC0904F2D" })).toBeVisible(); +});