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:
2026-07-23 17:59:11 +08:00
parent 3d772f748c
commit b5c033426e
11 changed files with 788 additions and 50 deletions

View File

@@ -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 }) {
const pager = useCursorPager(`${runId}:${objectInstanceId}:mft`);
const query = useQuery({
@@ -176,11 +289,42 @@ function AspaView({ 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 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 (
<dl className="meta-grid">
<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="Validity"
value={`${asString(validity?.notBefore) ?? "—"} → ${asString(validity?.notAfter) ?? "—"}`}
value={`${formatUtc(asString(validity?.notBefore)) ?? "—"} → ${formatUtc(asString(validity?.notAfter)) ?? "—"}`}
mono
/>
<MetaRow label="SKI" value={<CopyableValue value={asString(cert.ski)} max={56} label="SKI" />} />
<MetaRow label="AKI" value={<CopyableValue value={asString(cert.aki)} max={56} label="AKI" />} />
<MetaRow label="SIA" value={<CopyableValue value={asString(cert.sia)} max={72} label="SIA" />} />
<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="SKI"
value={<CopyableValue value={asString(cert.ski) ?? asString(ext?.subjectKeyIdentifier)} max={56} label="SKI" />}
/>
<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
label="IP resources"
value={
ipBlocks.length ? (
<span className="chip-list">
{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}
</span>
@@ -217,7 +382,7 @@ function CertificateView({ projection }: { projection: Record<string, unknown> }
asBlocks.length ? (
<span className="chip-list">
{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}
</span>
@@ -258,11 +423,16 @@ export function ProjectionView({
const projection = record.projection;
const type = (record.objectType ?? "").toLowerCase();
if (type === "roa") return <RoaView projection={projection} />;
if (type === "asa" || type === "aspa") return <AspaView projection={projection} />;
// 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 <RoaView projection={typed} />;
if (type === "asa" || type === "aspa") return <AspaView projection={typed} />;
if (type === "mft") {
const manifest = asRecord(projection.manifest);
const manifest = asRecord(typed.manifest);
return (
<>
<dl className="meta-grid" style={{ marginBottom: 16 }}>
@@ -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 (
<>
<dl className="meta-grid" style={{ marginBottom: 16 }}>
<MetaRow label="Issuer" value={asString(crl?.issuer)} mono />
<MetaRow label="thisUpdate" value={formatUtc(asString(crl?.thisUpdate))} />
<MetaRow label="nextUpdate" value={formatUtc(asString(crl?.nextUpdate))} />
<MetaRow label="CRL number" value={asString(crl?.crlNumberHex ?? crl?.crlNumber)} mono />
<MetaRow label="AKI" value={<CopyableValue value={asString(crl?.aki)} max={56} label="AKI" />} />
<MetaRow label="Issuer" value={asString(crl.issuer)} mono />
<MetaRow label="thisUpdate" value={formatUtc(asString(crl.thisUpdate))} />
<MetaRow label="nextUpdate" value={formatUtc(asString(crl.nextUpdate))} />
<MetaRow
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>
<RevokedCerts runId={runId} objectInstanceId={objectInstanceId} />
</>
@@ -294,12 +486,12 @@ export function ProjectionView({
}
if (type === "cer" || type === "ee" || type === "router_cert") {
return <CertificateView projection={projection} />;
return <CertificateView projection={typed} />;
}
return (
<pre className="json-block" data-testid="projection-json">
{JSON.stringify(projection, null, 2)}
{JSON.stringify(typed, null, 2)}
</pre>
);
}

View File

@@ -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",

View File

@@ -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") {

View 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();
});