20260723 新增 rpki-explorer 单容器部署资产并修复 parsed 投影渲染(#128):deploy/rpki-explorer 全套独立 compose 部署资产(对接 231 在跑 soak、retain 10 runs、SSH 转发访问);ProjectionView 按真实 API 结构解包 projection.object、证书走 resourceCertificate+extensions、CRL 读顶层字段并新增 IP/AS 资源格式化;e2e fixtures 对齐真实嵌套结构并新增证书/CRL parsed 覆盖
This commit is contained in:
parent
3d772f748c
commit
b5c033426e
22
deploy/rpki-explorer/.env.example
Normal file
22
deploy/rpki-explorer/.env.example
Normal file
@ -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
|
||||||
68
deploy/rpki-explorer/Dockerfile
Normal file
68
deploy/rpki-explorer/Dockerfile
Normal file
@ -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:<tag> .
|
||||||
|
#
|
||||||
|
# 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"]
|
||||||
76
deploy/rpki-explorer/README.md
Normal file
76
deploy/rpki-explorer/README.md
Normal file
@ -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-<tag>.tar.gz | ssh root@<host> 'gunzip | docker load'
|
||||||
|
|
||||||
|
ssh root@<host>
|
||||||
|
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@<host>
|
||||||
|
# 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.
|
||||||
37
deploy/rpki-explorer/build_image.sh
Executable file
37
deploy/rpki-explorer/build_image.sh
Executable file
@ -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" <<EOF
|
||||||
|
git_rev=$GIT_REV
|
||||||
|
dirty_files=$dirty_count
|
||||||
|
build_time_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
|
image=$IMAGE_NAME:$IMAGE_TAG
|
||||||
|
image_id=$IMAGE_ID
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[build] image tarball: $TARBALL"
|
||||||
|
echo "[build] provenance: $OUT_DIR/rpki-explorer-$IMAGE_TAG.provenance.txt"
|
||||||
|
echo "[build] transfer: cat '$TARBALL' | ssh root@<host> 'gunzip | docker load'"
|
||||||
28
deploy/rpki-explorer/docker-compose.yml
Normal file
28
deploy/rpki-explorer/docker-compose.yml
Normal file
@ -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@<host>
|
||||||
|
- "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"
|
||||||
92
deploy/rpki-explorer/entrypoint.sh
Executable file
92
deploy/rpki-explorer/entrypoint.sh
Executable file
@ -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"
|
||||||
27
deploy/rpki-explorer/nginx.conf
Normal file
27
deploy/rpki-explorer/nginx.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<span>
|
||||||
|
{uris.map((u) => (
|
||||||
|
<span key={u} className="mono" style={{ display: "block", wordBreak: "break-all" }}>
|
||||||
|
{u}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
function ManifestFiles({ runId, objectInstanceId }: { runId: string; objectInstanceId: string }) {
|
||||||
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
@ -176,11 +289,42 @@ function AspaView({ projection }: { projection: Record<string, unknown> }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
function CertificateView({ projection }: { projection: Record<string, unknown> }) {
|
||||||
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 validity = asRecord(cert.validity);
|
||||||
const resources = asRecord(cert.resources);
|
const ext = asRecord(cert.extensions);
|
||||||
const ipBlocks = asArray(resources?.ipResources ?? resources?.ipv4 ?? resources?.addresses);
|
const legacyResources = asRecord(cert.resources);
|
||||||
const asBlocks = asArray(resources?.asResources ?? resources?.asns);
|
|
||||||
|
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 (
|
return (
|
||||||
<dl className="meta-grid">
|
<dl className="meta-grid">
|
||||||
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
|
<MetaRow label="Serial number" value={asString(cert.serialNumberHex ?? cert.serialNumber)} mono />
|
||||||
@ -188,21 +332,42 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
|
|||||||
<MetaRow label="Subject" value={asString(cert.subject)} mono />
|
<MetaRow label="Subject" value={asString(cert.subject)} mono />
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="Validity"
|
label="Validity"
|
||||||
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
|
value={`${formatUtc(asString(validity?.notBefore)) ?? "—"} → ${formatUtc(asString(validity?.notAfter)) ?? "—"}`}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
<MetaRow label="SKI" value={<CopyableValue value={asString(cert.ski)} max={56} label="SKI" />} />
|
<MetaRow
|
||||||
<MetaRow label="AKI" value={<CopyableValue value={asString(cert.aki)} max={56} label="AKI" />} />
|
label="SKI"
|
||||||
<MetaRow label="SIA" value={<CopyableValue value={asString(cert.sia)} max={72} label="SIA" />} />
|
value={<CopyableValue value={asString(cert.ski) ?? asString(ext?.subjectKeyIdentifier)} max={56} label="SKI" />}
|
||||||
<MetaRow label="AIA" value={<CopyableValue value={asString(cert.aia)} max={72} label="AIA" />} />
|
/>
|
||||||
<MetaRow label="CRL DP" value={<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />} />
|
<MetaRow
|
||||||
|
label="AKI"
|
||||||
|
value={<CopyableValue value={asString(cert.aki) ?? asString(ext?.authorityKeyIdentifier)} max={56} label="AKI" />}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="SIA"
|
||||||
|
value={asString(cert.sia) ? <CopyableValue value={asString(cert.sia)} max={72} label="SIA" /> : uriLines(siaUris)}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="AIA"
|
||||||
|
value={asString(cert.aia) ? <CopyableValue value={asString(cert.aia)} max={72} label="AIA" /> : uriLines(aiaUris)}
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="CRL DP"
|
||||||
|
value={
|
||||||
|
asString(cert.crlDistributionPoint ?? cert.crldp) ? (
|
||||||
|
<CopyableValue value={asString(cert.crlDistributionPoint ?? cert.crldp)} max={72} label="CRL distribution point" />
|
||||||
|
) : (
|
||||||
|
uriLines(crlDpUris)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<MetaRow
|
<MetaRow
|
||||||
label="IP resources"
|
label="IP resources"
|
||||||
value={
|
value={
|
||||||
ipBlocks.length ? (
|
ipBlocks.length ? (
|
||||||
<span className="chip-list">
|
<span className="chip-list">
|
||||||
{ipBlocks.slice(0, 12).map((b, i) => (
|
{ipBlocks.slice(0, 12).map((b, i) => (
|
||||||
<span className="chip" key={i}>{typeof b === "string" ? b : JSON.stringify(b)}</span>
|
<span className="chip" key={i}>{b}</span>
|
||||||
))}
|
))}
|
||||||
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
|
{ipBlocks.length > 12 ? <span className="chip">+{ipBlocks.length - 12} more</span> : null}
|
||||||
</span>
|
</span>
|
||||||
@ -217,7 +382,7 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
|
|||||||
asBlocks.length ? (
|
asBlocks.length ? (
|
||||||
<span className="chip-list">
|
<span className="chip-list">
|
||||||
{asBlocks.slice(0, 12).map((b, i) => (
|
{asBlocks.slice(0, 12).map((b, i) => (
|
||||||
<span className="chip" key={i}>{typeof b === "string" || typeof b === "number" ? String(b) : JSON.stringify(b)}</span>
|
<span className="chip" key={i}>{b}</span>
|
||||||
))}
|
))}
|
||||||
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
|
{asBlocks.length > 12 ? <span className="chip">+{asBlocks.length - 12} more</span> : null}
|
||||||
</span>
|
</span>
|
||||||
@ -258,11 +423,16 @@ export function ProjectionView({
|
|||||||
const projection = record.projection;
|
const projection = record.projection;
|
||||||
const type = (record.objectType ?? "").toLowerCase();
|
const type = (record.objectType ?? "").toLowerCase();
|
||||||
|
|
||||||
if (type === "roa") return <RoaView projection={projection} />;
|
// The query service nests the typed payload one level down:
|
||||||
if (type === "asa" || type === "aspa") return <AspaView projection={projection} />;
|
// 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 <RoaView projection={typed} />;
|
||||||
|
if (type === "asa" || type === "aspa") return <AspaView projection={typed} />;
|
||||||
|
|
||||||
if (type === "mft") {
|
if (type === "mft") {
|
||||||
const manifest = asRecord(projection.manifest);
|
const manifest = asRecord(typed.manifest);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||||
@ -278,15 +448,37 @@ export function ProjectionView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "crl") {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
<dl className="meta-grid" style={{ marginBottom: 16 }}>
|
||||||
<MetaRow label="Issuer" value={asString(crl?.issuer)} mono />
|
<MetaRow label="Issuer" value={asString(crl.issuer)} mono />
|
||||||
<MetaRow label="thisUpdate" value={formatUtc(asString(crl?.thisUpdate))} />
|
<MetaRow label="thisUpdate" value={formatUtc(asString(crl.thisUpdate))} />
|
||||||
<MetaRow label="nextUpdate" value={formatUtc(asString(crl?.nextUpdate))} />
|
<MetaRow label="nextUpdate" value={formatUtc(asString(crl.nextUpdate))} />
|
||||||
<MetaRow label="CRL number" value={asString(crl?.crlNumberHex ?? crl?.crlNumber)} mono />
|
<MetaRow
|
||||||
<MetaRow label="AKI" value={<CopyableValue value={asString(crl?.aki)} max={56} label="AKI" />} />
|
label="CRL number"
|
||||||
|
value={
|
||||||
|
asString(crl.crlNumberHex ?? crlExt?.crlNumberHex) ??
|
||||||
|
(asNumber(crl.crlNumber ?? crlExt?.crlNumber) != null
|
||||||
|
? String(asNumber(crl.crlNumber ?? crlExt?.crlNumber))
|
||||||
|
: null)
|
||||||
|
}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<MetaRow
|
||||||
|
label="AKI"
|
||||||
|
value={
|
||||||
|
<CopyableValue
|
||||||
|
value={asString(crl.aki) ?? asString(crlExt?.authorityKeyIdentifier)}
|
||||||
|
max={56}
|
||||||
|
label="AKI"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
|
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
|
||||||
</>
|
</>
|
||||||
@ -294,12 +486,12 @@ export function ProjectionView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "cer" || type === "ee" || type === "router_cert") {
|
if (type === "cer" || type === "ee" || type === "router_cert") {
|
||||||
return <CertificateView projection={projection} />;
|
return <CertificateView projection={typed} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre className="json-block" data-testid="projection-json">
|
<pre className="json-block" data-testid="projection-json">
|
||||||
{JSON.stringify(projection, null, 2)}
|
{JSON.stringify(typed, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -209,7 +209,21 @@ export const OBJ_ROA_REJECTED = {
|
|||||||
rejectReason: "manifest stale: thisUpdate in the past",
|
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_VALIDATION = { ok: 1279, error: 2, skipped: 3 };
|
||||||
export const STATS_OBJECT_TYPES = {
|
export const STATS_OBJECT_TYPES = {
|
||||||
@ -226,11 +240,19 @@ export const STATS_REASONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ROA_PROJECTION = {
|
export const ROA_PROJECTION = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
sha256: SHA("a"),
|
sha256: SHA("a"),
|
||||||
objectType: "roa",
|
objectType: "roa",
|
||||||
parseStatus: "ok",
|
parseStatus: "ok",
|
||||||
|
// Mirrors the live query service shape:
|
||||||
|
// projection = { input, object: { type, roa|manifest|… }, schemaVersion, tool }
|
||||||
projection: {
|
projection: {
|
||||||
|
input: {
|
||||||
|
type: "roa",
|
||||||
|
path: "rsync://repo-a.example/ca1/alice.roa",
|
||||||
|
bytes: { len: 1280, sha256: SHA("a") },
|
||||||
|
},
|
||||||
|
object: {
|
||||||
type: "roa",
|
type: "roa",
|
||||||
roa: {
|
roa: {
|
||||||
asId: 65001,
|
asId: 65001,
|
||||||
@ -249,14 +271,23 @@ export const ROA_PROJECTION = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
schemaVersion: 2,
|
||||||
|
tool: "rpki",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MFT_PROJECTION = {
|
export const MFT_PROJECTION = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
sha256: SHA("b"),
|
sha256: SHA("b"),
|
||||||
objectType: "mft",
|
objectType: "mft",
|
||||||
parseStatus: "ok",
|
parseStatus: "ok",
|
||||||
projection: {
|
projection: {
|
||||||
|
input: {
|
||||||
|
type: "mft",
|
||||||
|
path: "rsync://repo-a.example/ca1/manifest.mft",
|
||||||
|
bytes: { len: 2048, sha256: SHA("b") },
|
||||||
|
},
|
||||||
|
object: {
|
||||||
type: "mft",
|
type: "mft",
|
||||||
manifest: {
|
manifest: {
|
||||||
manifestNumberHex: "0A2F",
|
manifestNumberHex: "0A2F",
|
||||||
@ -266,6 +297,9 @@ export const MFT_PROJECTION = {
|
|||||||
fileCount: 3,
|
fileCount: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
schemaVersion: 2,
|
||||||
|
tool: "rpki",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MFT_FILES = [
|
export const MFT_FILES = [
|
||||||
@ -274,6 +308,125 @@ export const MFT_FILES = [
|
|||||||
{ fileName: "stale.cer", hashHex: SHA("c") },
|
{ 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 = {
|
export const VALIDATION_SUMMARY = {
|
||||||
finalStatus: "valid",
|
finalStatus: "valid",
|
||||||
auditResult: "ok",
|
auditResult: "ok",
|
||||||
|
|||||||
@ -5,7 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
import type { Page, Route } from "@playwright/test";
|
import type { Page, Route } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
|
CER_PROJECTION,
|
||||||
CHAIN_EDGES,
|
CHAIN_EDGES,
|
||||||
|
CRL_PROJECTION,
|
||||||
|
CRL_REVOKED,
|
||||||
EXPLAIN_RECORD,
|
EXPLAIN_RECORD,
|
||||||
HEALTH,
|
HEALTH,
|
||||||
MFT_FILES,
|
MFT_FILES,
|
||||||
@ -303,6 +306,8 @@ export async function installMockApi(
|
|||||||
if (b === "parsed" && !c) {
|
if (b === "parsed" && !c) {
|
||||||
if (obj.objectType === "roa") return fulfillJson(route, ROA_PROJECTION);
|
if (obj.objectType === "roa") return fulfillJson(route, ROA_PROJECTION);
|
||||||
if (obj.objectType === "manifest") return fulfillJson(route, MFT_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);
|
return fulfillJson(route, null);
|
||||||
}
|
}
|
||||||
if (b === "parsed" && c === "manifest-files") {
|
if (b === "parsed" && c === "manifest-files") {
|
||||||
@ -310,7 +315,8 @@ export async function installMockApi(
|
|||||||
return fulfillJson(route, items, next);
|
return fulfillJson(route, items, next);
|
||||||
}
|
}
|
||||||
if (b === "parsed" && c === "revoked-certs") {
|
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) return fulfillJson(route, VALIDATION_SUMMARY);
|
||||||
if (b === "validation" && c === "explain" && request.method() === "POST") {
|
if (b === "validation" && c === "explain" && request.method() === "POST") {
|
||||||
|
|||||||
37
ui/rpki-explorer/tests/e2e/projection-cert-crl.spec.ts
Normal file
37
ui/rpki-explorer/tests/e2e/projection-cert-crl.spec.ts
Normal file
@ -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();
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user