11 Commits

56 changed files with 2777 additions and 258 deletions

View File

@@ -37,12 +37,27 @@ RETAIN_RUNS=100
# TAL/TA input mode: # TAL/TA input mode:
# file-with-ta: use packaged fixture TAL + TA only. # file-with-ta: use packaged fixture TAL + TA only.
# file-live-ta: use packaged fixture TAL; snapshot waits for live TA refresh, delta refreshes TA in background. # file-live-ta: use packaged fixture TAL; snapshot waits for live TA refresh, delta refreshes TA in background.
# custom-file-with-ta: use one custom TAL + TA from the read-only custom fixture mount.
# url: pass TAL URL to child process. # url: pass TAL URL to child process.
TAL_INPUT_MODE=file-live-ta TAL_INPUT_MODE=file-live-ta
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1 LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15 LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
LIVE_TA_REFRESH_MAX_TIME_SECS=120 LIVE_TA_REFRESH_MAX_TIME_SECS=120
# Custom TAL patch mode. Keep TAL_INPUT_MODE=file-live-ta and the five-RIR RIRS
# list above for the original package behavior. For a private fixture, set:
# RIRS=custom
# TAL_INPUT_MODE=custom-file-with-ta
# The paths below are container paths; CUSTOM_FIXTURE_HOST_DIR is a host path
# resolved relative to the compose directory when left at its default.
CUSTOM_FIXTURE_HOST_DIR=../custom-fixtures
CUSTOM_TAL_PATH=/opt/ours-rp/custom-fixtures/tal/custom.tal
CUSTOM_TA_PATH=/opt/ours-rp/custom-fixtures/ta/custom-ta.cer
CUSTOM_TAL_URI=https://host.docker.internal:18443/tal/custom.tal
# Space-separated container paths; paths must not contain spaces. Set this in
# custom mode when the RRDP server uses a private/self-signed CA.
HTTP_ROOT_CERT_PATHS=
# Sync and runtime behavior. # Sync and runtime behavior.
RSYNC_SCOPE=module-root RSYNC_SCOPE=module-root
DISABLE_COMPETING_RPS=0 DISABLE_COMPETING_RPS=0

View File

@@ -11,8 +11,15 @@ services:
RUN_ROOT: /var/lib/ours-rp RUN_ROOT: /var/lib/ours-rp
BIN_DIR: /opt/ours-rp/bin BIN_DIR: /opt/ours-rp/bin
FIXTURE_DIR: /opt/ours-rp/fixtures FIXTURE_DIR: /opt/ours-rp/fixtures
CUSTOM_FIXTURE_DIR: /opt/ours-rp/custom-fixtures
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- ../.env:/opt/ours-rp/.env:ro - ../.env:/opt/ours-rp/.env:ro
# Overlay the patched runner while keeping the original runtime image
# and its exact rpki binaries unchanged.
- ../scripts/soak/run_soak.sh:/opt/ours-rp/run_soak.sh:ro
- ${CUSTOM_FIXTURE_HOST_DIR:-../custom-fixtures}:/opt/ours-rp/custom-fixtures:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/state:/var/lib/ours-rp/state - ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/state:/var/lib/ours-rp/state
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/runs:/var/lib/ours-rp/runs - ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/runs:/var/lib/ours-rp/runs
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/logs:/var/lib/ours-rp/logs - ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/logs:/var/lib/ours-rp/logs

View File

@@ -45,7 +45,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "ours_rp_cir_trust_anchors", "expr": "max(ours_rp_cir_trust_anchors and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "RIRs", "legendFormat": "RIRs",
"refId": "A" "refId": "A"
} }
@@ -89,7 +89,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "ours_rp_run_duration_seconds", "expr": "max(ours_rp_run_duration_seconds and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "wall", "legendFormat": "wall",
"refId": "A" "refId": "A"
} }
@@ -133,7 +133,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "ours_rp_run_max_rss_bytes", "expr": "max(ours_rp_run_max_rss_bytes and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "rss", "legendFormat": "rss",
"refId": "A" "refId": "A"
} }
@@ -178,7 +178,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "ours_rp_publication_points", "expr": "max(ours_rp_publication_points and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "publication points", "legendFormat": "publication points",
"refId": "A" "refId": "A"
} }
@@ -223,7 +223,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "ours_rp_run_sequence", "expr": "max(ours_rp_run_sequence)",
"legendFormat": "seq", "legendFormat": "seq",
"refId": "A" "refId": "A"
} }
@@ -287,7 +287,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "100 * sum by (job, instance, exported_instance) (ours_rp_repo_terminal_state_count{terminal_state=\"publication_point_cache\"}) / sum by (job, instance, exported_instance) (ours_rp_publication_points)", "expr": "max((100 * sum by (exported_instance) (ours_rp_repo_terminal_state_count{terminal_state=\"publication_point_cache\"}) / sum by (exported_instance) (ours_rp_publication_points)) and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "PP cache hit ratio", "legendFormat": "PP cache hit ratio",
"refId": "A" "refId": "A"
} }
@@ -332,7 +332,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "ours_rp_vrps{kind=\"total\"}", "expr": "max(ours_rp_vrps{kind=\"total\"} and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "VRPs raw", "legendFormat": "VRPs raw",
"refId": "A" "refId": "A"
} }
@@ -377,7 +377,7 @@
"pluginVersion": "11.3.1", "pluginVersion": "11.3.1",
"targets": [ "targets": [
{ {
"expr": "ours_rp_vaps", "expr": "max(ours_rp_vaps and on (exported_instance) topk(1, ours_rp_run_sequence))",
"legendFormat": "VAPs", "legendFormat": "VAPs",
"refId": "A" "refId": "A"
} }

View File

@@ -0,0 +1,14 @@
# Custom TAL fixture mount
This directory is intentionally shipped as a mount point, not as a repository
of private keys. Run the patch tool `tools/generate_custom_fixture.py` to create
the test-only TAL, TA, RPKI objects, HTTPS CA/server certificates, RRDP files,
and rsync module data below it.
The generated fixture contains `baseline-v1`, `sync-hash-mismatch` and
`baseline-v2` for snapshot / hash / delta tests, plus
`validation-expired`, `validation-max-length`,
`validation-max-length-invalid`, `validation-roa-prefix-outside`,
`validation-out-of-resource`, `validation-ca-over-resource`, and
`validation-nonstandard` for validation cases. Generated keys are test-only
and must not be used in a production RPKI hierarchy.

View File

@@ -0,0 +1,30 @@
# 自建 TAL/TA 补丁
本目录描述并生成一个叠加到客户 Arm64 Ours RP 组件包上的测试补丁。补丁基线是源码 commit `9f4f4cf069e9fa8f6f97ed0f1065adac38abe82f`,不会替换客户包中的 runtime image 或 `bin/rpki`、`bin/rpki_daemon` 二进制。
补丁做三件事:
1. 在 runner 中增加 `RIRS=custom`、`TAL_INPUT_MODE=custom-file-with-ta`、自定义 TAL/TA 路径和 `HTTP_ROOT_CERT_PATHS`;
2. 通过 Compose 只读挂载 patched runner、`custom-fixtures/` 和 `host.docker.internal`,连接固定 HTTPS RRDP 与 rsync 服务;
3. 提供固定证书链、RPKI 对象生成器和服务启动脚本,不依赖 Barry、Rapport 或公网 RIR。
先停止 Ours RP,再执行:
```bash
./apply_custom_tal_patch.sh \
--component-root /opt/ours-rp-rtr-stack/components/ours-rp \
--enable-custom
```
`--enable-custom` 会修改组件 `.env` 的 custom 模式入口;不加该选项只安装 overlay,不改变当前 `.env`。回滚:
```bash
./rollback_custom_tal_patch.sh \
--component-root /opt/ours-rp-rtr-stack/components/ours-rp
```
补丁应用脚本会校验 `PACKAGE-MANIFEST.env` 的源码 commit 和 `arm64` 架构,并留下 `CUSTOM-TAL-PATCH-MANIFEST.env` 与 `.custom-tal-patch-state/` 备份。升级组件后需要重新检查 patch marker;不要把该 overlay 误当成客户原始安装包。
生成器默认把容器访问地址写成 `host.docker.internal`,固定服务端口为 HTTPS RRDP `18443`、rsync `1873`,rsync module 为 `custom`。可用 `--host localhost` 生成给宿主机直接运行 `bin/rpki` 的本地变体。
用例包括:`baseline-v1`、`sync-hash-mismatch`、`baseline-v2`、`validation-expired`、`validation-max-length`、`validation-max-length-invalid`、`validation-roa-prefix-outside`、`validation-out-of-resource`、`validation-ca-over-resource`、`validation-nonstandard`。其中 `validation-ca-over-resource` 在 `RESOURCE_VALIDATION_MODE=rfc6487` 下验证严格拒绝;`validation-update-03` 是原包默认的兼容处理模式。

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PATCH_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PAYLOAD_ROOT="$SCRIPT_DIR/payload"
STATE_DIR_NAME=".custom-tal-patch-state"
EXPECTED_SOURCE_COMMIT="9f4f4cf069e9fa8f6f97ed0f1065adac38abe82f"
usage() {
cat <<'USAGE'
Usage:
./apply_custom_tal_patch.sh --component-root <installed-ours-rp-root>
./apply_custom_tal_patch.sh --component-root <root> --enable-custom
The default operation installs the overlay but does not change the existing
.env. --enable-custom writes the custom TAL/TA/root-cert example values into
the component .env so the next run uses the fixed local fixture.
Stop the ours RP service before applying or rolling back the overlay.
USAGE
}
die() {
echo "error: $*" >&2
exit 2
}
COMPONENT_ROOT=""
ENABLE_CUSTOM=0
while [[ $# -gt 0 ]]; do
case "$1" in
--component-root)
COMPONENT_ROOT="${2:-}"
shift 2
;;
--enable-custom)
ENABLE_CUSTOM=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
[[ -n "$COMPONENT_ROOT" ]] || die "--component-root is required"
COMPONENT_ROOT="$(cd "$COMPONENT_ROOT" 2>/dev/null && pwd)" \
|| die "component root does not exist: $COMPONENT_ROOT"
[[ -f "$COMPONENT_ROOT/PACKAGE-MANIFEST.env" ]] \
|| die "missing component PACKAGE-MANIFEST.env: $COMPONENT_ROOT"
[[ -d "$PAYLOAD_ROOT" ]] || die "missing patch payload: $PAYLOAD_ROOT"
# shellcheck disable=SC1091
source "$COMPONENT_ROOT/PACKAGE-MANIFEST.env"
[[ "${source_commit:-}" == "$EXPECTED_SOURCE_COMMIT" ]] \
|| die "base source commit mismatch: ${source_commit:-missing} != $EXPECTED_SOURCE_COMMIT"
[[ "${package_arch:-${PACKAGE_ARCH:-}}" == "arm64" ]] \
|| die "this patch is for the customer Arm64 component, got ${package_arch:-${PACKAGE_ARCH:-missing}}"
STATE_ROOT="$COMPONENT_ROOT/$STATE_DIR_NAME"
BACKUP_ROOT="$STATE_ROOT/original"
marker="$COMPONENT_ROOT/CUSTOM-TAL-PATCH-MANIFEST.env"
set_env_value() {
local env_path="$1"
local key="$2"
local value="$3"
local tmp_path="${env_path}.custom-tal-patch.tmp"
[[ -f "$env_path" ]] || die "missing component .env: $env_path"
awk -v key="$key" -v value="$value" '
BEGIN { found = 0 }
$0 ~ "^" key "=" { print key "=" value; found = 1; next }
{ print }
END { if (!found) print key "=" value }
' "$env_path" > "$tmp_path"
mv "$tmp_path" "$env_path"
}
copy_payload_file() {
local source_path="$1"
local target_path="$2"
mkdir -p "$(dirname "$target_path")"
cp "$source_path" "$target_path"
chmod --reference="$source_path" "$target_path" 2>/dev/null || true
}
append_custom_env_example() {
local target_path="$1"
if ! grep -Eq '^CUSTOM_TAL_URI=' "$target_path"; then
{
printf '\n# #142 custom TAL patch settings; original package values above are preserved.\n'
cat "$SCRIPT_DIR/custom-tal.env.example"
} >> "$target_path"
fi
}
if [[ -e "$STATE_ROOT" ]]; then
die "patch is already applied or an incomplete state exists: $STATE_ROOT"
fi
mkdir -p "$BACKUP_ROOT"
for relative_path in compose/docker-compose.yml .env.example; do
[[ -f "$COMPONENT_ROOT/$relative_path" ]] \
|| die "missing base file: $COMPONENT_ROOT/$relative_path"
mkdir -p "$BACKUP_ROOT/$(dirname "$relative_path")"
cp -a "$COMPONENT_ROOT/$relative_path" "$BACKUP_ROOT/$relative_path"
done
runner_path="$COMPONENT_ROOT/scripts/soak/run_soak.sh"
if [[ -e "$runner_path" ]]; then
mkdir -p "$BACKUP_ROOT/scripts/soak"
cp -a "$runner_path" "$BACKUP_ROOT/scripts/soak/run_soak.sh"
printf 'runner_was_present=1\n' > "$STATE_ROOT/state.env"
else
printf 'runner_was_present=0\n' > "$STATE_ROOT/state.env"
fi
if [[ -d "$COMPONENT_ROOT/custom-fixtures" ]]; then
cp -a "$COMPONENT_ROOT/custom-fixtures" "$BACKUP_ROOT/custom-fixtures"
printf 'custom_fixtures_were_present=1\n' >> "$STATE_ROOT/state.env"
else
printf 'custom_fixtures_were_present=0\n' >> "$STATE_ROOT/state.env"
fi
if [[ -d "$COMPONENT_ROOT/custom-tal-patch" ]]; then
cp -a "$COMPONENT_ROOT/custom-tal-patch" "$BACKUP_ROOT/custom-tal-patch"
printf 'patch_tools_were_present=1\n' >> "$STATE_ROOT/state.env"
else
printf 'patch_tools_were_present=0\n' >> "$STATE_ROOT/state.env"
fi
if [[ -f "$COMPONENT_ROOT/custom-tal.env.example" ]]; then
cp -a "$COMPONENT_ROOT/custom-tal.env.example" "$BACKUP_ROOT/custom-tal.env.example"
printf 'custom_env_example_was_present=1\n' >> "$STATE_ROOT/state.env"
else
printf 'custom_env_example_was_present=0\n' >> "$STATE_ROOT/state.env"
fi
if [[ "$ENABLE_CUSTOM" == "1" ]]; then
[[ -f "$COMPONENT_ROOT/.env" ]] || die "missing component .env: $COMPONENT_ROOT/.env"
cp -a "$COMPONENT_ROOT/.env" "$BACKUP_ROOT/.env"
fi
copy_payload_file "$PAYLOAD_ROOT/compose/docker-compose.yml" "$COMPONENT_ROOT/compose/docker-compose.yml"
append_custom_env_example "$COMPONENT_ROOT/.env.example"
copy_payload_file "$PAYLOAD_ROOT/scripts/soak/run_soak.sh" "$runner_path"
mkdir -p "$COMPONENT_ROOT/custom-fixtures/tal" \
"$COMPONENT_ROOT/custom-fixtures/ta" \
"$COMPONENT_ROOT/custom-fixtures/certs" \
"$COMPONENT_ROOT/custom-fixtures/services"
if [[ -d "$PAYLOAD_ROOT/custom-fixtures" ]]; then
cp -a "$PAYLOAD_ROOT/custom-fixtures/." "$COMPONENT_ROOT/custom-fixtures/"
fi
mkdir -p "$COMPONENT_ROOT/custom-tal-patch/tools"
cp -a "$PAYLOAD_ROOT/tools/." "$COMPONENT_ROOT/custom-tal-patch/tools/"
copy_payload_file "$SCRIPT_DIR/custom-tal.env.example" \
"$COMPONENT_ROOT/custom-tal.env.example"
if [[ "$ENABLE_CUSTOM" == "1" ]]; then
set_env_value "$COMPONENT_ROOT/.env" RIRS custom
set_env_value "$COMPONENT_ROOT/.env" TAL_INPUT_MODE custom-file-with-ta
set_env_value "$COMPONENT_ROOT/.env" CUSTOM_FIXTURE_HOST_DIR ../custom-fixtures
set_env_value "$COMPONENT_ROOT/.env" CUSTOM_TAL_PATH /opt/ours-rp/custom-fixtures/tal/custom.tal
set_env_value "$COMPONENT_ROOT/.env" CUSTOM_TA_PATH /opt/ours-rp/custom-fixtures/ta/custom-ta.cer
set_env_value "$COMPONENT_ROOT/.env" CUSTOM_TAL_URI https://host.docker.internal:18443/tal/custom.tal
set_env_value "$COMPONENT_ROOT/.env" HTTP_ROOT_CERT_PATHS /opt/ours-rp/custom-fixtures/certs/rrdp-ca.pem
printf 'env_was_enabled=1\n' >> "$STATE_ROOT/state.env"
else
printf 'env_was_enabled=0\n' >> "$STATE_ROOT/state.env"
fi
[[ -f "$SCRIPT_DIR/PATCH-MANIFEST.env" ]] \
|| die "missing PATCH-MANIFEST.env"
cp "$SCRIPT_DIR/PATCH-MANIFEST.env" "$marker"
printf 'patch_root=%s\n' "$PATCH_ROOT" >> "$STATE_ROOT/state.env"
printf 'applied_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$STATE_ROOT/state.env"
echo "custom TAL patch applied to $COMPONENT_ROOT"
if [[ "$ENABLE_CUSTOM" == "1" ]]; then
echo "custom mode enabled in $COMPONENT_ROOT/.env"
else
echo "custom mode remains disabled; review custom-tal.env.example before enabling it"
fi

View File

@@ -0,0 +1,15 @@
# Copy these values into the component .env after applying the patch.
# The patch's --enable-custom option writes the same values automatically.
RIRS=custom
TAL_INPUT_MODE=custom-file-with-ta
CUSTOM_FIXTURE_HOST_DIR=../custom-fixtures
CUSTOM_TAL_PATH=/opt/ours-rp/custom-fixtures/tal/custom.tal
CUSTOM_TA_PATH=/opt/ours-rp/custom-fixtures/ta/custom-ta.cer
CUSTOM_TAL_URI=https://host.docker.internal:18443/tal/custom.tal
HTTP_ROOT_CERT_PATHS=/opt/ours-rp/custom-fixtures/certs/rrdp-ca.pem
# Recommended for a finite acceptance run; keep the customer default for a
# long-running test only after the fixture service is supervised.
MAX_RUNS=1
INTERVAL_SECS=0
SOAK_RESTART_POLICY=no

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
./rollback_custom_tal_patch.sh --component-root <installed-ours-rp-root>
Stop the ours RP service before rolling back the overlay.
USAGE
}
die() {
echo "error: $*" >&2
exit 2
}
COMPONENT_ROOT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--component-root)
COMPONENT_ROOT="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
[[ -n "$COMPONENT_ROOT" ]] || die "--component-root is required"
COMPONENT_ROOT="$(cd "$COMPONENT_ROOT" 2>/dev/null && pwd)" \
|| die "component root does not exist: $COMPONENT_ROOT"
STATE_ROOT="$COMPONENT_ROOT/.custom-tal-patch-state"
BACKUP_ROOT="$STATE_ROOT/original"
[[ -d "$STATE_ROOT" ]] || die "patch state not found: $STATE_ROOT"
[[ -f "$BACKUP_ROOT/compose/docker-compose.yml" ]] \
|| die "patch backup is incomplete: $BACKUP_ROOT"
cp -a "$BACKUP_ROOT/compose/docker-compose.yml" "$COMPONENT_ROOT/compose/docker-compose.yml"
cp -a "$BACKUP_ROOT/.env.example" "$COMPONENT_ROOT/.env.example"
if [[ -f "$BACKUP_ROOT/scripts/soak/run_soak.sh" ]]; then
mkdir -p "$COMPONENT_ROOT/scripts/soak"
cp -a "$BACKUP_ROOT/scripts/soak/run_soak.sh" "$COMPONENT_ROOT/scripts/soak/run_soak.sh"
else
rm -f "$COMPONENT_ROOT/scripts/soak/run_soak.sh"
rmdir "$COMPONENT_ROOT/scripts/soak" 2>/dev/null || true
fi
if [[ -d "$BACKUP_ROOT/custom-fixtures" ]]; then
rm -rf "$COMPONENT_ROOT/custom-fixtures"
cp -a "$BACKUP_ROOT/custom-fixtures" "$COMPONENT_ROOT/custom-fixtures"
else
rm -rf "$COMPONENT_ROOT/custom-fixtures"
fi
if [[ -d "$BACKUP_ROOT/custom-tal-patch" ]]; then
rm -rf "$COMPONENT_ROOT/custom-tal-patch"
cp -a "$BACKUP_ROOT/custom-tal-patch" "$COMPONENT_ROOT/custom-tal-patch"
else
rm -rf "$COMPONENT_ROOT/custom-tal-patch"
fi
if [[ -f "$BACKUP_ROOT/custom-tal.env.example" ]]; then
cp -a "$BACKUP_ROOT/custom-tal.env.example" "$COMPONENT_ROOT/custom-tal.env.example"
else
rm -f "$COMPONENT_ROOT/custom-tal.env.example"
fi
if [[ -f "$BACKUP_ROOT/.env" ]]; then
cp -a "$BACKUP_ROOT/.env" "$COMPONENT_ROOT/.env"
fi
rm -f "$COMPONENT_ROOT/CUSTOM-TAL-PATCH-MANIFEST.env"
rm -rf "$STATE_ROOT"
echo "custom TAL patch rolled back from $COMPONENT_ROOT"

View File

@@ -0,0 +1,636 @@
#!/usr/bin/env python3
"""Generate a small self-contained RPKI repository for installer tests.
The generator uses Python cryptography and the OpenSSL CLI for CMS signing.
It does not invoke Barry, Rapport, or a public RIR service.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import ipaddress
import shutil
import subprocess
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
from cryptography.x509 import AccessDescription, AuthorityInformationAccess, DistributionPoint, UniformResourceIdentifier
from cryptography.x509.oid import AuthorityInformationAccessOID, NameOID, ObjectIdentifier
RRDP_HOST = "host.docker.internal"
RRDP_PORT = 18443
RSYNC_PORT = 1873
TAL_URI = f"rsync://{RRDP_HOST}:{RSYNC_PORT}/custom/ta.cer"
TAL_HTTPS_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/ta/custom-ta.cer"
RRDP_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/rrdp/notification.xml"
ROOT_REPO_URI = f"rsync://{RRDP_HOST}:{RSYNC_PORT}/custom/root/"
ROOT_MFT_URI = f"{ROOT_REPO_URI}root.mft"
CHILD_REPO_URI = f"rsync://{RRDP_HOST}:{RSYNC_PORT}/custom/child/"
CHILD_MFT_URI = f"{CHILD_REPO_URI}child.mft"
OID_SIA = ObjectIdentifier("1.3.6.1.5.5.7.1.11")
OID_RESOURCES_IP = ObjectIdentifier("1.3.6.1.5.5.7.1.7")
OID_RESOURCES_AS = ObjectIdentifier("1.3.6.1.5.5.7.1.8")
OID_POLICY_IP_AS = ObjectIdentifier("1.3.6.1.5.5.7.14.2")
OID_ROA_ECONTENT = "1.2.840.113549.1.9.16.1.24"
OID_MANIFEST_ECONTENT = "1.2.840.113549.1.9.16.1.26"
OID_SHA256 = "2.16.840.1.101.3.4.2.1"
SIA_CA_REPOSITORY = "1.3.6.1.5.5.7.48.5"
SIA_RPKI_MANIFEST = "1.3.6.1.5.5.7.48.10"
SIA_SIGNED_OBJECT = "1.3.6.1.5.5.7.48.11"
SIA_RPKI_NOTIFY = "1.3.6.1.5.5.7.48.13"
UTC = timezone.utc
VALID_FROM = datetime(2026, 1, 1, tzinfo=UTC)
VALID_TO = datetime(2027, 12, 31, 23, 59, 59, tzinfo=UTC)
CHILD_PREFIX = ipaddress.ip_network("203.0.113.0/24")
SECOND_PREFIX = ipaddress.ip_network("203.0.113.128/25")
OUTSIDE_PREFIX = ipaddress.ip_network("203.0.114.0/24")
TEST_ASN = 64496
SESSION_ID = "11111111-2222-4333-8444-555555555555"
def tlv(tag: int, value: bytes) -> bytes:
length = len(value)
if length < 128:
encoded_length = bytes([length])
else:
raw = length.to_bytes((length.bit_length() + 7) // 8, "big")
encoded_length = bytes([0x80 | len(raw)]) + raw
return bytes([tag]) + encoded_length + value
def seq(*values: bytes) -> bytes:
return tlv(0x30, b"".join(values))
def integer(value: int) -> bytes:
raw = value.to_bytes(max(1, (value.bit_length() + 7) // 8), "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return tlv(0x02, raw)
def oid(oid_value: str) -> bytes:
parts = [int(part) for part in oid_value.split(".")]
encoded = bytearray([40 * parts[0] + parts[1]])
for part in parts[2:]:
chunks = [part & 0x7F]
part >>= 7
while part:
chunks.append(0x80 | (part & 0x7F))
part >>= 7
encoded.extend(reversed(chunks))
return tlv(0x06, bytes(encoded))
def octet(value: bytes) -> bytes:
return tlv(0x04, value)
def uri(value: str) -> bytes:
return tlv(0x86, value.encode("ascii"))
def bit_string(value: bytes, unused: int = 0) -> bytes:
return tlv(0x03, bytes([unused]) + value)
def generalized_time(value: datetime) -> bytes:
return tlv(0x18, value.astimezone(UTC).strftime("%Y%m%d%H%M%SZ").encode("ascii"))
def context_zero(value: bytes) -> bytes:
return tlv(0xA0, value)
def ip_choice(networks: list[ipaddress._BaseNetwork]) -> bytes:
entries = []
for network in networks:
length = (network.prefixlen + 7) // 8
raw = network.network_address.packed[:length]
entries.append(bit_string(raw, length * 8 - network.prefixlen))
return seq(*entries)
def ip_resources(networks: list[ipaddress._BaseNetwork]) -> bytes:
families: dict[int, list[ipaddress._BaseNetwork]] = {1: [], 2: []}
for network in networks:
families[1 if network.version == 4 else 2].append(network)
return seq(
*(
seq(octet(afi.to_bytes(2, "big")), ip_choice(items))
for afi, items in families.items()
if items
)
)
def ip_inherit() -> bytes:
return seq(
seq(octet(b"\x00\x01"), tlv(0x05, b"")),
seq(octet(b"\x00\x02"), tlv(0x05, b"")),
)
def as_resources(asns: list[int] | None) -> bytes:
if asns is None:
return seq(context_zero(tlv(0x05, b"")))
return seq(context_zero(seq(*(integer(asn) for asn in asns))))
def sia(entries: list[tuple[str, str]]) -> bytes:
return seq(*(seq(oid(method), uri(location)) for method, location in entries))
def write(path: Path, data: bytes, mode: int | None = None) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
if mode is not None:
path.chmod(mode)
def pem_cert(cert: x509.Certificate) -> bytes:
return cert.public_bytes(Encoding.PEM)
def pem_key(key: rsa.RSAPrivateKey) -> bytes:
return key.private_bytes(Encoding.PEM, PrivateFormat.TraditionalOpenSSL, NoEncryption())
def write_key(path: Path, key: rsa.RSAPrivateKey) -> None:
write(path, pem_key(key), 0o600)
def sign_cert(
subject: x509.Name,
issuer: x509.Name,
public_key,
signing_key: rsa.RSAPrivateKey,
serial: int,
is_ca: bool,
ski_key: rsa.RSAPrivateKey,
aki_key: rsa.RSAPrivateKey | None = None,
ip_ext: bytes | None = None,
as_ext: bytes | None = None,
ca_repository: str | None = None,
manifest_uri: str | None = None,
notify_uri: str | None = None,
signed_object_uri: str | None = None,
crl_uri: str | None = None,
ca_issuer_uri: str | None = None,
not_before: datetime = VALID_FROM,
not_after: datetime = VALID_TO,
) -> x509.Certificate:
builder = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(public_key)
.serial_number(serial)
.not_valid_before(not_before)
.not_valid_after(not_after)
)
if is_ca:
builder = builder.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
if is_ca:
usage = x509.KeyUsage(False, False, False, False, False, True, True, False, False)
else:
usage = x509.KeyUsage(True, False, False, False, False, False, False, False, False)
builder = builder.add_extension(usage, critical=True)
builder = builder.add_extension(x509.SubjectKeyIdentifier.from_public_key(ski_key.public_key()), critical=False)
if aki_key is not None:
builder = builder.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(aki_key.public_key()), critical=False)
builder = builder.add_extension(
x509.CertificatePolicies([x509.PolicyInformation(OID_POLICY_IP_AS, None)]),
critical=True,
)
if crl_uri is not None:
builder = builder.add_extension(
x509.CRLDistributionPoints(
[DistributionPoint([UniformResourceIdentifier(crl_uri)], None, None, None)]
),
critical=False,
)
if ca_issuer_uri is not None:
builder = builder.add_extension(
AuthorityInformationAccess(
[AccessDescription(AuthorityInformationAccessOID.CA_ISSUERS, UniformResourceIdentifier(ca_issuer_uri))]
),
critical=False,
)
sia_entries: list[tuple[str, str]] = []
if ca_repository is not None:
sia_entries.append((SIA_CA_REPOSITORY, ca_repository))
if manifest_uri is not None:
sia_entries.append((SIA_RPKI_MANIFEST, manifest_uri))
if notify_uri is not None:
sia_entries.append((SIA_RPKI_NOTIFY, notify_uri))
if signed_object_uri is not None:
sia_entries.append((SIA_SIGNED_OBJECT, signed_object_uri))
if sia_entries:
builder = builder.add_extension(x509.UnrecognizedExtension(OID_SIA, sia(sia_entries)), critical=False)
if ip_ext is not None:
builder = builder.add_extension(x509.UnrecognizedExtension(OID_RESOURCES_IP, ip_ext), critical=True)
if as_ext is not None:
builder = builder.add_extension(x509.UnrecognizedExtension(OID_RESOURCES_AS, as_ext), critical=True)
return builder.sign(signing_key, hashes.SHA256())
def make_crl(subject: x509.Name, key: rsa.RSAPrivateKey, number: int) -> x509.CertificateRevocationList:
return (
x509.CertificateRevocationListBuilder()
.issuer_name(subject)
.last_update(VALID_FROM + timedelta(days=30))
.next_update(VALID_TO - timedelta(days=30))
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False)
.add_extension(x509.CRLNumber(number), critical=False)
.sign(key, hashes.SHA256())
)
def roa_content(prefix: ipaddress._BaseNetwork, max_length: int) -> bytes:
length = (prefix.prefixlen + 7) // 8
raw = prefix.network_address.packed[:length]
address = seq(bit_string(raw, length * 8 - prefix.prefixlen), integer(max_length))
family = seq(octet((1 if prefix.version == 4 else 2).to_bytes(2, "big")), seq(address))
return seq(integer(TEST_ASN), seq(family))
def manifest_content(number: int, files: list[tuple[str, bytes]]) -> bytes:
entries = [
seq(tlv(0x16, name.encode("ascii")), bit_string(hashlib.sha256(content).digest()))
for name, content in files
]
return seq(
integer(number),
generalized_time(VALID_FROM + timedelta(days=30)),
generalized_time(VALID_TO - timedelta(days=30)),
oid(OID_SHA256),
seq(*entries),
)
def write_cms(content: bytes, content_type: str, signer_cert: x509.Certificate, signer_key: rsa.RSAPrivateKey, output: Path, work: Path) -> None:
content_path = work / f"{output.name}.content.der"
signer_path = work / f"{output.name}.signer.pem"
key_path = work / f"{output.name}.key.pem"
write(content_path, content)
write(signer_path, pem_cert(signer_cert))
write_key(key_path, signer_key)
subprocess.run(
[
"openssl", "cms", "-sign", "-binary", "-in", str(content_path),
"-signer", str(signer_path), "-inkey", str(key_path),
"-outform", "DER", "-nodetach", "-nosmimecap", "-keyid", "-md", "sha256",
"-econtent_type", content_type, "-out", str(output),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
def make_roa(
name: str,
prefix,
max_length: int,
not_before: datetime,
not_after: datetime,
child_name,
child_key,
common: Path,
work: Path,
certificate_prefix=None,
) -> None:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = sign_cert(
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"#142 EE {name}")]),
child_name,
key.public_key(),
child_key,
x509.random_serial_number(),
False,
key,
child_key,
ip_ext=ip_resources([certificate_prefix or prefix]),
signed_object_uri=f"{CHILD_REPO_URI}{name}",
crl_uri=f"{CHILD_REPO_URI}child.crl",
ca_issuer_uri=f"{CHILD_REPO_URI}child.cer",
not_before=not_before,
not_after=not_after,
)
write_cms(roa_content(prefix, max_length), OID_ROA_ECONTENT, cert, key, common / name, work)
def make_manifest(name: str, number: int, files: list[tuple[str, bytes]], child_name, child_key, common: Path, work: Path) -> bytes:
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cert = sign_cert(
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"#142 Manifest EE {name}")]),
child_name,
key.public_key(),
child_key,
x509.random_serial_number(),
False,
key,
child_key,
ip_ext=ip_inherit(),
as_ext=as_resources(None),
signed_object_uri=CHILD_MFT_URI if name != "root" else ROOT_MFT_URI,
crl_uri=f"{CHILD_REPO_URI}child.crl" if name != "root" else f"{ROOT_REPO_URI}root.crl",
ca_issuer_uri=f"{CHILD_REPO_URI}child.cer" if name != "root" else f"{ROOT_REPO_URI}ta.cer",
)
output = common / f"{name}.mft"
write_cms(manifest_content(number, files), OID_MANIFEST_ECONTENT, cert, key, output, work)
return output.read_bytes()
def publish(uri: str, path: Path) -> str:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f' <publish uri="{uri}">{encoded}</publish>\n'
def snapshot(case_root: Path, names: list[str], serial: int, extra_root_names: list[str] | None = None) -> bytes:
repository = case_root / "repository"
objects = [
(f"{ROOT_REPO_URI}root.crl", repository / "root/root.crl"),
(f"{ROOT_REPO_URI}child.cer", repository / "root/child.cer"),
*[
(f"{ROOT_REPO_URI}{name}", repository / f"root/{name}")
for name in extra_root_names or []
],
(f"{CHILD_REPO_URI}child.crl", repository / "child/child.crl"),
*[(f"{CHILD_REPO_URI}{name}", repository / f"child/{name}") for name in names],
(ROOT_MFT_URI, repository / "root/root.mft"),
(CHILD_MFT_URI, repository / "child/child.mft"),
]
body = "".join(publish(uri, path) for uri, path in objects)
return (
f'<snapshot xmlns="http://www.ripe.net/rpki/rrdp" session_id="{SESSION_ID}" serial="{serial}">\n'
f"{body}</snapshot>\n"
).encode()
def notification(serial: int, snap: bytes, delta: bytes | None) -> bytes:
lines = [
f'<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{SESSION_ID}" serial="{serial}">',
f' <snapshot uri="https://{RRDP_HOST}:{RRDP_PORT}/rrdp/snapshot.xml" hash="{hashlib.sha256(snap).hexdigest()}"/>',
]
if delta is not None:
lines.append(
f' <delta serial="{serial}" uri="https://{RRDP_HOST}:{RRDP_PORT}/rrdp/delta-{serial}.xml" hash="{hashlib.sha256(delta).hexdigest()}"/>'
)
return ("\n".join(lines) + "\n</notification>\n").encode()
def corrupt_first_hash(xml: bytes) -> bytes:
marker = b'hash="'
start = xml.index(marker) + len(marker)
end = xml.index(b'"', start)
value = bytearray(xml[start:end])
value[0] = ord("0") if value[0] != ord("0") else ord("1")
return xml[:start] + bytes(value) + xml[end:]
def delta(add_name: str, add_path: Path, new_mft: Path) -> bytes:
body = publish(CHILD_MFT_URI, new_mft)
body += publish(f"{CHILD_REPO_URI}{add_name}", add_path)
return f'<delta xmlns="http://www.ripe.net/rpki/rrdp" session_id="{SESSION_ID}" serial="2">\n{body}</delta>\n'.encode()
def build_case(
output: Path,
common: Path,
tal: bytes,
ta: bytes,
name: str,
number: int,
names: list[str],
child_mft: bytes,
change: bytes | None = None,
root_mft: bytes | None = None,
extra_root_objects: list[tuple[str, bytes]] | None = None,
notification_hash_mismatch: bool = False,
) -> None:
case_root = output / "cases" / name
repo = case_root / "repository"
(repo / "root").mkdir(parents=True, exist_ok=True)
(repo / "child").mkdir(parents=True, exist_ok=True)
shutil.copy2(common / "ta.cer", repo / "ta.cer")
for item in ("root.crl", "child.cer"):
shutil.copy2(common / item, repo / "root" / item)
(repo / "root/root.mft").write_bytes(root_mft if root_mft is not None else (common / "root.mft").read_bytes())
for item, content in extra_root_objects or []:
(repo / "root" / item).write_bytes(content)
for item in (
"child.crl", "valid.roa", "valid2.roa", "expired.roa", "max-length.roa",
"max-length-invalid.roa", "roa-prefix-outside.roa", "out-of-resource.roa", "bad-format.roa",
):
if (common / item).exists():
shutil.copy2(common / item, repo / "child" / item)
(repo / "child/child.mft").write_bytes(child_mft)
snap = snapshot(case_root, names, number, [item for item, _ in extra_root_objects or []])
(case_root / "http/rrdp").mkdir(parents=True, exist_ok=True)
(case_root / "http/tal").mkdir(parents=True, exist_ok=True)
(case_root / "http/ta").mkdir(parents=True, exist_ok=True)
(case_root / "http/rrdp/snapshot.xml").write_bytes(snap)
notification_xml = notification(number, snap, change)
if notification_hash_mismatch:
notification_xml = corrupt_first_hash(notification_xml)
(case_root / "http/rrdp/notification.xml").write_bytes(notification_xml)
if change is not None:
(case_root / "http/rrdp/delta-2.xml").write_bytes(change)
(case_root / "http/tal/custom.tal").write_bytes(tal)
(case_root / "http/ta/custom-ta.cer").write_bytes(ta)
(case_root / "CASE-MANIFEST.txt").write_text(
f"case={name}\nserial={number}\nobjects={','.join(names)}\nsnapshot_sha256={hashlib.sha256(snap).hexdigest()}\n",
encoding="utf-8",
)
def make_https_certificates(output: Path) -> None:
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "#142 fixed RRDP test CA")])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name).issuer_name(ca_name).public_key(ca_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(VALID_FROM).not_valid_after(VALID_TO)
.add_extension(x509.BasicConstraints(ca=True, path_length=1), critical=True)
.add_extension(x509.KeyUsage(False, False, False, False, False, True, True, False, False), critical=True)
.sign(ca_key, hashes.SHA256())
)
server_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, RRDP_HOST)])
try:
server_host = x509.IPAddress(ipaddress.ip_address(RRDP_HOST))
except ValueError:
server_host = x509.DNSName(RRDP_HOST)
san_entries = [server_host]
if RRDP_HOST != "localhost":
san_entries.append(x509.DNSName("localhost"))
server_cert = (
x509.CertificateBuilder()
.subject_name(server_name).issuer_name(ca_cert.subject).public_key(server_key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(VALID_FROM).not_valid_after(VALID_TO)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, False, False, False, False, False, False, False, False), critical=True)
.add_extension(x509.SubjectAlternativeName(san_entries), critical=False)
.sign(ca_key, hashes.SHA256())
)
write(output / "certs/rrdp-ca.pem", pem_cert(ca_cert))
write_key(output / "certs/rrdp-ca.key", ca_key)
write(output / "certs/rrdp-server.pem", pem_cert(server_cert))
write_key(output / "certs/rrdp-server.key", server_key)
def main() -> None:
global RRDP_HOST, TAL_URI, TAL_HTTPS_URI, RRDP_URI
global ROOT_REPO_URI, ROOT_MFT_URI, CHILD_REPO_URI, CHILD_MFT_URI
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
parser.add_argument(
"--host",
default=RRDP_HOST,
help="host embedded in TAL, certificate SIA and RRDP data (default: host.docker.internal)",
)
args = parser.parse_args()
RRDP_HOST = args.host
TAL_URI = f"rsync://{RRDP_HOST}:{RSYNC_PORT}/custom/ta.cer"
TAL_HTTPS_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/ta/custom-ta.cer"
RRDP_URI = f"https://{RRDP_HOST}:{RRDP_PORT}/rrdp/notification.xml"
ROOT_REPO_URI = f"rsync://{RRDP_HOST}:{RSYNC_PORT}/custom/root/"
ROOT_MFT_URI = f"{ROOT_REPO_URI}root.mft"
CHILD_REPO_URI = f"rsync://{RRDP_HOST}:{RSYNC_PORT}/custom/child/"
CHILD_MFT_URI = f"{CHILD_REPO_URI}child.mft"
output = args.output.resolve()
if output.exists():
shutil.rmtree(output)
for directory in ("tal", "ta", "certs", "services", "keys"):
(output / directory).mkdir(parents=True)
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
child_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
root_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "#142 Custom RPKI Root")])
child_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "#142 Custom RPKI Child")])
root_cert = sign_cert(
root_name, root_name, root_key.public_key(), root_key, 0x14200001, True, root_key,
ip_ext=ip_resources([CHILD_PREFIX]),
as_ext=as_resources([TEST_ASN]), ca_repository=ROOT_REPO_URI, manifest_uri=ROOT_MFT_URI,
notify_uri=RRDP_URI,
)
child_cert = sign_cert(
child_name, root_name, child_key.public_key(), root_key, 0x14200002, True, child_key, root_key,
ip_ext=ip_resources([CHILD_PREFIX]), as_ext=as_resources([TEST_ASN]),
ca_repository=CHILD_REPO_URI, manifest_uri=CHILD_MFT_URI, notify_uri=RRDP_URI,
crl_uri=f"{ROOT_REPO_URI}root.crl", ca_issuer_uri=f"{ROOT_REPO_URI}child.cer",
)
bad_child_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "#142 Custom RPKI Overclaiming Child")])
bad_child_repo_uri = f"rsync://{RRDP_HOST}:{RSYNC_PORT}/custom/bad-child/"
bad_child_cert = sign_cert(
bad_child_name, root_name, child_key.public_key(), root_key, 0x14200003, True, child_key, root_key,
ip_ext=ip_resources([OUTSIDE_PREFIX]), as_ext=as_resources([TEST_ASN]),
ca_repository=bad_child_repo_uri, manifest_uri=f"{bad_child_repo_uri}bad-child.mft", notify_uri=RRDP_URI,
crl_uri=f"{ROOT_REPO_URI}root.crl", ca_issuer_uri=f"{ROOT_REPO_URI}child.cer",
)
root_crl = make_crl(root_name, root_key, 1)
child_crl = make_crl(child_name, child_key, 1)
tal = (
f"{TAL_URI}\n{TAL_HTTPS_URI}\n\n"
+ base64.b64encode(root_key.public_key().public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)).decode()
+ "\n"
).encode()
write(output / "tal/custom.tal", tal)
write(output / "ta/custom-ta.cer", root_cert.public_bytes(Encoding.DER))
with tempfile.TemporaryDirectory(prefix="custom-rpki-cms-") as temp:
work = Path(temp)
common = output / ".common"
common.mkdir()
write(common / "ta.cer", root_cert.public_bytes(Encoding.DER))
write(common / "child.cer", child_cert.public_bytes(Encoding.DER))
write(common / "bad-child.cer", bad_child_cert.public_bytes(Encoding.DER))
write(common / "root.crl", root_crl.public_bytes(Encoding.DER))
write(common / "child.crl", child_crl.public_bytes(Encoding.DER))
for name, prefix, max_length, before, after, certificate_prefix in (
("valid.roa", CHILD_PREFIX, 24, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("valid2.roa", SECOND_PREFIX, 25, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("expired.roa", CHILD_PREFIX, 24, VALID_FROM - timedelta(days=30), VALID_FROM - timedelta(days=1), None),
("max-length.roa", CHILD_PREFIX, 25, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("max-length-invalid.roa", CHILD_PREFIX, 33, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
("roa-prefix-outside.roa", OUTSIDE_PREFIX, 24, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), CHILD_PREFIX),
("out-of-resource.roa", OUTSIDE_PREFIX, 24, VALID_FROM + timedelta(days=31), VALID_TO - timedelta(days=31), None),
):
make_roa(
name, prefix, max_length, before, after, child_name, child_key, common, work,
certificate_prefix=certificate_prefix,
)
bad = bytearray((common / "valid.roa").read_bytes())
bad[-1] ^= 1
write(common / "bad-format.roa", bytes(bad))
root_mft = make_manifest(
"root", 1, [
("root.crl", (common / "root.crl").read_bytes()),
("child.cer", (common / "child.cer").read_bytes()),
], root_name, root_key, common, work,
)
write(common / "root.mft", root_mft)
bad_root_mft = make_manifest(
"root", 1, [
("root.crl", (common / "root.crl").read_bytes()),
("child.cer", (common / "child.cer").read_bytes()),
("bad-child.cer", (common / "bad-child.cer").read_bytes()),
], root_name, root_key, common, work,
)
write(common / "root.mft", root_mft)
case_specs = (
("baseline-v1", 1, ["child.crl", "valid.roa"]),
("sync-hash-mismatch", 1, ["child.crl", "valid.roa"]),
("baseline-v2", 2, ["child.crl", "valid.roa", "valid2.roa"]),
("validation-expired", 1, ["child.crl", "expired.roa"]),
("validation-max-length", 1, ["child.crl", "max-length.roa"]),
("validation-max-length-invalid", 1, ["child.crl", "max-length-invalid.roa"]),
("validation-roa-prefix-outside", 1, ["child.crl", "roa-prefix-outside.roa"]),
("validation-out-of-resource", 1, ["child.crl", "out-of-resource.roa"]),
("validation-ca-over-resource", 1, ["child.crl", "valid.roa"]),
("validation-nonstandard", 1, ["child.crl", "bad-format.roa"]),
)
child_mfts = {}
for name, number, names in case_specs:
child_mfts[name] = make_manifest(
name, number, [(item, (common / item).read_bytes()) for item in names],
child_name, child_key, common, work,
)
for name, number, names in case_specs:
change = None
if name == "baseline-v2":
change = delta("valid2.roa", common / "valid2.roa", common / "baseline-v2.mft")
build_case(
output, common, tal, root_cert.public_bytes(Encoding.DER), name, number, names,
child_mfts[name], change,
root_mft=bad_root_mft if name == "validation-ca-over-resource" else None,
extra_root_objects=[("bad-child.cer", (common / "bad-child.cer").read_bytes())]
if name == "validation-ca-over-resource" else None,
notification_hash_mismatch=name == "sync-hash-mismatch",
)
write_key(output / "keys/root.key", root_key)
write_key(output / "keys/child.key", child_key)
make_https_certificates(output)
shutil.rmtree(output / ".common")
(output / "FIXTURE-MANIFEST.txt").write_text(
"fixture_schema_version=1\nfixture_kind=custom-tal-fixed-local-rpki\n"
f"tal_uri={TAL_URI}\nrrdp_uri={RRDP_URI}\nrsync_module=custom\n"
f"rrdp_port={RRDP_PORT}\nrsync_port={RSYNC_PORT}\n"
"cases=baseline-v1,sync-hash-mismatch,baseline-v2,validation-expired,validation-max-length,validation-max-length-invalid,"
"validation-roa-prefix-outside,validation-out-of-resource,validation-ca-over-resource,validation-nonstandard\n",
encoding="utf-8",
)
print(f"generated custom fixture: {output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Serve a fixture directory over HTTPS with request logging."""
from __future__ import annotations
import argparse
import http.server
import ssl
from pathlib import Path
class RequestHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format: str, *args) -> None:
print("%s %s" % (self.log_date_time_string(), format % args), flush=True)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--directory", type=Path, required=True)
parser.add_argument("--certfile", type=Path, required=True)
parser.add_argument("--keyfile", type=Path, required=True)
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=18443)
args = parser.parse_args()
handler = lambda *handler_args, directory=str(args.directory): RequestHandler(
*handler_args, directory=directory
)
server = http.server.ThreadingHTTPServer((args.host, args.port), handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=args.certfile, keyfile=args.keyfile)
server.socket = context.wrap_socket(server.socket, server_side=True)
print(f"https fixture server listening on {args.host}:{args.port} root={args.directory}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$BASH_SOURCE")" && pwd)"
FIXTURE_ROOT=""
CASE_NAME="baseline-v1"
RRDP_PORT="$(printenv RRDP_PORT 2>/dev/null || true)"
RSYNC_PORT="$(printenv RSYNC_PORT 2>/dev/null || true)"
PID_ROOT=""
[[ -n "$RRDP_PORT" ]] || RRDP_PORT=18443
[[ -n "$RSYNC_PORT" ]] || RSYNC_PORT=1873
usage() {
cat <<'USAGE'
Usage:
./start_fixed_services.sh --fixture-root <generated-fixtures> [--case NAME]
Cases: baseline-v1, sync-hash-mismatch, baseline-v2, validation-expired,
validation-max-length, validation-max-length-invalid,
validation-roa-prefix-outside, validation-out-of-resource,
validation-ca-over-resource, validation-nonstandard
Environment: RRDP_PORT (default 18443), RSYNC_PORT (default 1873)
USAGE
}
die() {
echo "error: $*" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--fixture-root) FIXTURE_ROOT="$2"; shift 2 ;;
--case) CASE_NAME="$2"; shift 2 ;;
--pid-root) PID_ROOT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
[[ -n "$FIXTURE_ROOT" ]] || die "--fixture-root is required"
FIXTURE_ROOT="$(cd "$FIXTURE_ROOT" 2>/dev/null && pwd)" || die "fixture root not found"
CASE_ROOT="$FIXTURE_ROOT/cases/$CASE_NAME"
[[ -d "$CASE_ROOT/repository" && -d "$CASE_ROOT/http" ]] || die "fixture case not found: $CASE_NAME"
if [[ -z "$PID_ROOT" ]]; then
PID_ROOT="$FIXTURE_ROOT/services/$CASE_NAME"
fi
mkdir -p "$PID_ROOT"
[[ ! -f "$PID_ROOT/pids.env" ]] || die "services already appear to be running: $PID_ROOT/pids.env"
rm -f "$PID_ROOT/rsyncd.pid" "$PID_ROOT/rsyncd.lock"
RSYNCD_CONF="$PID_ROOT/rsyncd.conf"
RSYNC_UID="$(id -un)"
RSYNC_GID="$(id -gn)"
cat > "$RSYNCD_CONF" <<EOF
uid = $RSYNC_UID
gid = $RSYNC_GID
use chroot = no
read only = yes
port = $RSYNC_PORT
pid file = $PID_ROOT/rsyncd.pid
lock file = $PID_ROOT/rsyncd.lock
log file = $PID_ROOT/rsyncd.log
[custom]
path = $CASE_ROOT/repository
comment = #142 fixed custom RPKI repository
read only = yes
EOF
rsync --daemon --no-detach --config="$RSYNCD_CONF" >"$PID_ROOT/rsyncd.stdout.log" 2>&1 &
RSYNC_PID=$!
python3 "$SCRIPT_DIR/serve_https.py" \
--directory "$CASE_ROOT/http" \
--certfile "$FIXTURE_ROOT/certs/rrdp-server.pem" \
--keyfile "$FIXTURE_ROOT/certs/rrdp-server.key" \
--port "$RRDP_PORT" \
>"$PID_ROOT/https.log" 2>&1 &
HTTPS_PID=$!
printf 'case=%s\nrrdp_pid=%s\nrsync_pid=%s\nrrdp_port=%s\nrsync_port=%s\n' \
"$CASE_NAME" "$HTTPS_PID" "$RSYNC_PID" "$RRDP_PORT" "$RSYNC_PORT" > "$PID_ROOT/pids.env"
sleep 1
kill -0 "$HTTPS_PID" 2>/dev/null || die "HTTPS fixture server exited; see $PID_ROOT/https.log"
kill -0 "$RSYNC_PID" 2>/dev/null || die "rsync fixture server exited; see $PID_ROOT/rsyncd.stdout.log"
echo "fixed RRDP/rsync services started case=$CASE_NAME rrdp=$RRDP_PORT rsync=$RSYNC_PORT pid_root=$PID_ROOT"

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
PID_ROOT="$1"
[[ -n "$PID_ROOT" ]] || { echo "Usage: $0 <pid-root>" >&2; exit 2; }
[[ -f "$PID_ROOT/pids.env" ]] || { echo "services are not running: $PID_ROOT" >&2; exit 2; }
# shellcheck disable=SC1090
source "$PID_ROOT/pids.env"
service_pids=("${rrdp_pid:-}" "${rsync_pid:-}")
for pid in "${service_pids[@]}"; do
if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
fi
done
for pid in "${service_pids[@]}"; do
[[ "$pid" =~ ^[0-9]+$ ]] || continue
for _ in $(seq 1 50); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.1
done
done
rm -f "$PID_ROOT/pids.env"
echo "fixed RRDP/rsync services stopped: $PID_ROOT"

View File

@@ -6,7 +6,7 @@ This package deploys ours RP on Linux servers whose host architecture matches th
The package includes four architecture-matched images: ours RP runtime, artifact metrics, Prometheus, and Grafana. Deployment does not need to pull application images on the target host. Runtime state, run artifacts, logs, Prometheus data and Grafana data are persisted through host bind mounts. The package includes four architecture-matched images: ours RP runtime, artifact metrics, Prometheus, and Grafana. Deployment does not need to pull application images on the target host. Runtime state, run artifacts, logs, Prometheus data and Grafana data are persisted through host bind mounts.
Every installer build rebuilds the ours RP runtime and artifact metrics images. The deliverable name is `ours-rp-installer-{arch}-{git8}-{YYYYMMDDTHHMMSSZ}.tar.gz`, and both ours RP image tags use the current source commit. `PACKAGE-MANIFEST.env` records the full commit, UTC build time, dirty state, and image archive hashes; `./status.sh --brief` displays that provenance after installation. Every installer build rebuilds the ours RP runtime and artifact metrics images. The deliverable name is `ours-rp-installer-{arch}-{git8}-{YYYYMMDDTHHMMSSZ}.tar.gz`, and both ours RP image tags use the current source commit. `PACKAGE-MANIFEST.env` records the full commit, UTC build time, dirty state, and image archive hashes; `./scripts/status.sh --brief` displays that provenance after installation.
By default the host architecture must match `PACKAGE_ARCH` from `PACKAGE-MANIFEST.env`. If you intentionally want to run this package on a different host architecture through QEMU/binfmt, set: By default the host architecture must match `PACKAGE_ARCH` from `PACKAGE-MANIFEST.env`. If you intentionally want to run this package on a different host architecture through QEMU/binfmt, set:
@@ -18,7 +18,7 @@ ALLOW_CROSS_ARCH=1
`compose/` is the single production Docker Compose template source. The Docker installer build script copies this directory directly. Do not maintain a second copy of the Compose, Prometheus, or Grafana dashboard files elsewhere. `compose/` is the single production Docker Compose template source. The Docker installer build script copies this directory directly. Do not maintain a second copy of the Compose, Prometheus, or Grafana dashboard files elsewhere.
The legacy direct Compose deployment entry point has been removed. For either amd64 or arm64, build the matching installer package with `scripts/docker/build_docker_installer_package.sh`, then use `install.sh`, `start.sh`, and `status.sh` as described here. The legacy direct Compose deployment entry point has been removed. For either amd64 or arm64, build the matching installer package with `scripts/docker/build_docker_installer_package.sh`, then use `scripts/install.sh`, `scripts/start.sh`, and `scripts/status.sh` as described here.
## Quick Start ## Quick Start
@@ -26,11 +26,11 @@ The legacy direct Compose deployment entry point has been removed. For either am
tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz
cd ours-rp-installer-__PACKAGE_ARCH__-* cd ours-rp-installer-__PACKAGE_ARCH__-*
./install.sh ./scripts/install.sh
cp .env.example .env # install.sh creates .env automatically if missing cp .env.example .env # install.sh creates .env automatically if missing
vim .env vim .env
./start.sh ./scripts/start.sh
./status.sh ./scripts/status.sh
``` ```
Defaults: Defaults:
@@ -52,7 +52,7 @@ Defaults:
- `METRICS_PLATFORM=__PACKAGE_PLATFORM__` - `METRICS_PLATFORM=__PACKAGE_PLATFORM__`
- `MONITOR_PLATFORM=__PACKAGE_PLATFORM__` - `MONITOR_PLATFORM=__PACKAGE_PLATFORM__`
- `ALLOW_CROSS_ARCH=0` - `ALLOW_CROSS_ARCH=0`
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`, which mounts an empty fallback directory by default; set it to a real host directory such as `/root/rpki/report` to ingest reports from a separately deployed RTR service - `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`, which mounts an empty fallback directory by default; after deploying a separate RTR service, register its same-host report directory through `scripts/register_rtr_monitor.sh`
## First Start Semantics ## First Start Semantics
@@ -117,14 +117,13 @@ Change the password and restrict public access for production deployments.
## Optional RTR report metrics input ## Optional RTR report metrics input
If a separately deployed RTR service on the same host continuously writes `rtr-source-*`, `rtr-runtime-*`, and `rtr-clients-*` JSON reports, configure: If a separately deployed RTR service on the same host continuously writes `rtr-source-*`, `rtr-runtime-*`, and `rtr-clients-*` JSON reports, run:
```bash ```bash
RTR_REPORT_DIR=/root/rpki/report ./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
RTR_REPORT_CONTAINER_DIR=/var/lib/ours-rp/rtr-report
``` ```
The `artifact-metrics` container mounts the host directory read-only and reads it through `RPKI_METRICS_RTR_REPORT_DIR`, producing `ours_rp_rtr_*` metrics. By default, `RTR_REPORT_DIR` points at an empty fallback directory under the installer data root, so the metrics container also starts cleanly without an RTR service. The command accepts only an absolute path on the same host, validates the candidate Compose mount, and atomically updates `.env`. It recreates only `artifact-metrics`, Prometheus, and Grafana; it does not restart or recreate `ours-rp-soak`. The `artifact-metrics` container mounts the directory read-only and reads it through `RPKI_METRICS_RTR_REPORT_DIR`, producing `ours_rp_rtr_*` metrics. By default, `RTR_REPORT_DIR` points at an empty fallback directory under the installer data root, so the metrics container also starts cleanly without an RTR service.
## Data Directory ## Data Directory
@@ -172,12 +171,12 @@ Check the latest `run-meta.json` for:
## Common Commands ## Common Commands
```bash ```bash
./status.sh ./scripts/status.sh
./logs.sh ours-rp-soak --tail 200 ./scripts/logs.sh ours-rp-soak --tail 200
./restart.sh ./scripts/restart.sh
./stop.sh ./scripts/stop.sh
./cleanup.sh --keep-runs 100 --execute ./scripts/cleanup.sh --keep-runs 100 --execute
./uninstall.sh ./scripts/uninstall.sh
``` ```
For finite acceptance tests, for example `MAX_RUNS=3`, also set: For finite acceptance tests, for example `MAX_RUNS=3`, also set:
@@ -191,5 +190,5 @@ Otherwise Compose `unless-stopped` will restart the container after it exits suc
`uninstall.sh` keeps data by default. Use the following only when you really want to delete `HOST_DATA_DIR`: `uninstall.sh` keeps data by default. Use the following only when you really want to delete `HOST_DATA_DIR`:
```bash ```bash
./uninstall.sh --purge-data ./scripts/uninstall.sh --purge-data
``` ```

View File

@@ -6,7 +6,7 @@
安装包内置与包架构一致的四类镜像:ours RP runtime、artifact metrics、Prometheus、Grafana。部署时不需要现场拉取应用镜像。运行产物、状态数据库、日志、Prometheus 和 Grafana 数据均通过宿主机目录挂载保存。 安装包内置与包架构一致的四类镜像:ours RP runtime、artifact metrics、Prometheus、Grafana。部署时不需要现场拉取应用镜像。运行产物、状态数据库、日志、Prometheus 和 Grafana 数据均通过宿主机目录挂载保存。
每次构建安装包都会重新构建 ours RP runtime 与 artifact metrics 镜像。交付包命名为 `ours-rp-installer-{arch}-{git8}-{YYYYMMDDTHHMMSSZ}.tar.gz`,两类 ours RP 镜像分别使用当前源码 commit 的 tag。完整 commit、UTC 构建时间、dirty 状态和镜像归档 hash 保存在 `PACKAGE-MANIFEST.env`,可通过 `./status.sh --brief` 查看。 每次构建安装包都会重新构建 ours RP runtime 与 artifact metrics 镜像。交付包命名为 `ours-rp-installer-{arch}-{git8}-{YYYYMMDDTHHMMSSZ}.tar.gz`,两类 ours RP 镜像分别使用当前源码 commit 的 tag。完整 commit、UTC 构建时间、dirty 状态和镜像归档 hash 保存在 `PACKAGE-MANIFEST.env`,可通过 `./scripts/status.sh --brief` 查看。
默认情况下,宿主架构必须与 `PACKAGE-MANIFEST.env` 中的 `PACKAGE_ARCH` 匹配;若确需在异构主机上通过 QEMU/binfmt 运行,必须显式设置: 默认情况下,宿主架构必须与 `PACKAGE-MANIFEST.env` 中的 `PACKAGE_ARCH` 匹配;若确需在异构主机上通过 QEMU/binfmt 运行,必须显式设置:
@@ -18,7 +18,7 @@ ALLOW_CROSS_ARCH=1
`compose/` 是唯一的生产 Docker Compose 模板来源,Docker 安装包构建脚本会直接复制该目录。不要在其他目录维护第二份 compose、Prometheus 或 Grafana dashboard 文件。 `compose/` 是唯一的生产 Docker Compose 模板来源,Docker 安装包构建脚本会直接复制该目录。不要在其他目录维护第二份 compose、Prometheus 或 Grafana dashboard 文件。
旧的直接 Compose 部署入口已经移除。需要部署 amd64 或 arm64 时,统一先用 `scripts/docker/build_docker_installer_package.sh` 构建对应架构安装包,再按本说明执行 `install.sh`、`start.sh` 和 `status.sh`。 旧的直接 Compose 部署入口已经移除。需要部署 amd64 或 arm64 时,统一先用 `scripts/docker/build_docker_installer_package.sh` 构建对应架构安装包,再按本说明执行 `scripts/install.sh`、`scripts/start.sh` 和 `scripts/status.sh`。
## 快速开始 ## 快速开始
@@ -26,11 +26,11 @@ ALLOW_CROSS_ARCH=1
tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz
cd ours-rp-installer-__PACKAGE_ARCH__-* cd ours-rp-installer-__PACKAGE_ARCH__-*
./install.sh ./scripts/install.sh
cp .env.example .env # 如 install.sh 已自动创建,可直接编辑现有 .env cp .env.example .env # 如 install.sh 已自动创建,可直接编辑现有 .env
vim .env vim .env
./start.sh ./scripts/start.sh
./status.sh ./scripts/status.sh
``` ```
默认配置: 默认配置:
@@ -52,7 +52,7 @@ vim .env
- `METRICS_PLATFORM=__PACKAGE_PLATFORM__` - `METRICS_PLATFORM=__PACKAGE_PLATFORM__`
- `MONITOR_PLATFORM=__PACKAGE_PLATFORM__` - `MONITOR_PLATFORM=__PACKAGE_PLATFORM__`
- `ALLOW_CROSS_ARCH=0` - `ALLOW_CROSS_ARCH=0`
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;如需接入独立 RTR 服务 report,可设置为宿主机真实目录,例如 `/root/rpki/report` - `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;独立 RTR 服务部署完成后,使用 `scripts/register_rtr_monitor.sh` 注册同机 report 目录
## 首次启动语义 ## 首次启动语义
@@ -117,14 +117,13 @@ GRAFANA_ADMIN_PASSWORD=admin
## 可选 RTR report 监控接入 ## 可选 RTR report 监控接入
如果同一台机器上已经有独立部署的 RTR 服务,并且它持续输出 `rtr-source-*`、`rtr-runtime-*`、`rtr-clients-*` JSON report,可在 `.env` 中配置: 如果同一台机器上已经有独立部署的 RTR 服务,并且它持续输出 `rtr-source-*`、`rtr-runtime-*`、`rtr-clients-*` JSON report,执行:
```bash ```bash
RTR_REPORT_DIR=/root/rpki/report ./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
RTR_REPORT_CONTAINER_DIR=/var/lib/ours-rp/rtr-report
``` ```
`artifact-metrics` 容器会以只读方式挂载该目录,并通过 `RPKI_METRICS_RTR_REPORT_DIR` 读取 report,输出 `ours_rp_rtr_*` 指标。默认 `RTR_REPORT_DIR` 指向安装包数据目录下的空 fallback 目录,因此未接入 RTR 服务时 metrics 容器也能稳定启动。 该命令只接受本机绝对路径,会先校验候选 Compose 挂载,再原子更新 `.env`。随后只重建 `artifact-metrics`、Prometheus 和 Grafana,不重启或重建 `ours-rp-soak`。`artifact-metrics` 以只读方式挂载该目录,并通过 `RPKI_METRICS_RTR_REPORT_DIR` 读取 report,输出 `ours_rp_rtr_*` 指标。默认 `RTR_REPORT_DIR` 指向安装包数据目录下的空 fallback 目录,因此未接入 RTR 服务时 metrics 容器也能稳定启动。
## 数据目录 ## 数据目录
@@ -172,12 +171,12 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
## 常用命令 ## 常用命令
```bash ```bash
./status.sh ./scripts/status.sh
./logs.sh ours-rp-soak --tail 200 ./scripts/logs.sh ours-rp-soak --tail 200
./restart.sh ./scripts/restart.sh
./stop.sh ./scripts/stop.sh
./cleanup.sh --keep-runs 100 --execute ./scripts/cleanup.sh --keep-runs 100 --execute
./uninstall.sh ./scripts/uninstall.sh
``` ```
如果做有限轮次验收,例如 `MAX_RUNS=3`,建议同时设置: 如果做有限轮次验收,例如 `MAX_RUNS=3`,建议同时设置:
@@ -191,7 +190,7 @@ SOAK_RESTART_POLICY=no
`uninstall.sh` 默认不删除数据。只有显式执行: `uninstall.sh` 默认不删除数据。只有显式执行:
```bash ```bash
./uninstall.sh --purge-data ./scripts/uninstall.sh --purge-data
``` ```
才会删除 `HOST_DATA_DIR`。 才会删除 `HOST_DATA_DIR`。

View File

@@ -3,7 +3,7 @@
## Install ## Install
```bash ```bash
./install.sh ./scripts/install.sh
``` ```
The installer is idempotent: The installer is idempotent:
@@ -17,13 +17,13 @@ The installer is idempotent:
## Start ## Start
```bash ```bash
./start.sh ./scripts/start.sh
``` ```
Start without waiting for the first snapshot: Start without waiting for the first snapshot:
```bash ```bash
./start.sh --no-wait-first-run ./scripts/start.sh --no-wait-first-run
``` ```
If you intentionally want to run this package on a different host architecture through QEMU/binfmt, first set: If you intentionally want to run this package on a different host architecture through QEMU/binfmt, first set:
@@ -32,20 +32,20 @@ If you intentionally want to run this package on a different host architecture t
ALLOW_CROSS_ARCH=1 ALLOW_CROSS_ARCH=1
``` ```
Then rerun `./install.sh` or `./self-check.sh`, and only then will the scripts attempt to enable the matching binfmt handler. Then rerun `./scripts/install.sh` or `./scripts/self-check.sh`, and only then will the scripts attempt to enable the matching binfmt handler.
## Stop and Restart ## Stop and Restart
```bash ```bash
./stop.sh ./scripts/stop.sh
./restart.sh ./scripts/restart.sh
``` ```
## Status Checks ## Status Checks
```bash ```bash
./status.sh ./scripts/status.sh
./self-check.sh ./scripts/self-check.sh
``` ```
Important checks: Important checks:
@@ -73,15 +73,20 @@ Important checks:
To verify RTR report ingestion: To verify RTR report ingestion:
```bash ```bash
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
./scripts/status.sh
curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_' | head curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_' | head
``` ```
Registration accepts only an absolute directory on the same host. The command retains a timestamped `.env` backup and updates only the metrics, Prometheus, and Grafana sidecars; the `ours-rp-soak` container ID must remain unchanged.
## Upgrade ## Upgrade
Extract the new package into a new directory and explicitly reuse the existing `.env` through the upgrade script: Extract the new package into a new directory and explicitly reuse the existing `.env` through the upgrade script:
```bash ```bash
./upgrade.sh --reuse-env-from /path/to/old-installer/.env ./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
``` ```
If the new package directory already has a `.env`, the upgrade script keeps it. If the new package directory already has a `.env`, the upgrade script keeps it.
@@ -125,8 +130,8 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
## Cleanup ## Cleanup
```bash ```bash
./cleanup.sh --keep-runs 100 ./scripts/cleanup.sh --keep-runs 100
./cleanup.sh --keep-runs 100 --execute ./scripts/cleanup.sh --keep-runs 100 --execute
``` ```
Cleanup is dry-run by default. Add `--execute` after reviewing the output. Cleanup is dry-run by default. Add `--execute` after reviewing the output.

View File

@@ -3,7 +3,7 @@
## 安装 ## 安装
```bash ```bash
./install.sh ./scripts/install.sh
``` ```
安装脚本是幂等的: 安装脚本是幂等的:
@@ -17,13 +17,13 @@
## 启动 ## 启动
```bash ```bash
./start.sh ./scripts/start.sh
``` ```
如需后台启动后不等待首轮 snapshot: 如需后台启动后不等待首轮 snapshot:
```bash ```bash
./start.sh --no-wait-first-run ./scripts/start.sh --no-wait-first-run
``` ```
如需显式允许异构主机通过 QEMU/binfmt 跑包,先在 `.env` 中设置: 如需显式允许异构主机通过 QEMU/binfmt 跑包,先在 `.env` 中设置:
@@ -32,20 +32,20 @@
ALLOW_CROSS_ARCH=1 ALLOW_CROSS_ARCH=1
``` ```
然后重新执行 `./install.sh` 或 `./self-check.sh`,脚本才会尝试启用对应 binfmt。 然后重新执行 `./scripts/install.sh` 或 `./scripts/self-check.sh`,脚本才会尝试启用对应 binfmt。
## 停止和重启 ## 停止和重启
```bash ```bash
./stop.sh ./scripts/stop.sh
./restart.sh ./scripts/restart.sh
``` ```
## 状态检查 ## 状态检查
```bash ```bash
./status.sh ./scripts/status.sh
./self-check.sh ./scripts/self-check.sh
``` ```
重点检查项: 重点检查项:
@@ -73,15 +73,20 @@ ALLOW_CROSS_ARCH=1
如需确认 RTR report 已接入: 如需确认 RTR report 已接入:
```bash ```bash
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
./scripts/status.sh
curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_' | head curl -s "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep '^ours_rp_rtr_' | head
``` ```
注册只支持同机的绝对目录。注册命令会保留一份带时间戳的 `.env` 备份,并且只更新 metrics、Prometheus、Grafana 三个旁路容器;`ours-rp-soak` 的容器 ID 应保持不变。
## 升级 ## 升级
把新安装包解压到新目录后,推荐通过升级脚本显式复用旧 `.env`: 把新安装包解压到新目录后,推荐通过升级脚本显式复用旧 `.env`:
```bash ```bash
./upgrade.sh --reuse-env-from /path/to/old-installer/.env ./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
``` ```
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖。 如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖。
@@ -125,8 +130,8 @@ PERIODIC_SNAPSHOT_MAX_DELTAS=100
## 清理 ## 清理
```bash ```bash
./cleanup.sh --keep-runs 100 ./scripts/cleanup.sh --keep-runs 100
./cleanup.sh --keep-runs 100 --execute ./scripts/cleanup.sh --keep-runs 100 --execute
``` ```
默认 dry-run,确认后加 `--execute`。 默认 dry-run,确认后加 `--execute`。

View File

@@ -12,7 +12,7 @@ docker compose version
If missing, run: If missing, run:
```bash ```bash
./install.sh ./scripts/install.sh
``` ```
## Package Architecture Mismatch ## Package Architecture Mismatch
@@ -55,11 +55,11 @@ docker run --rm --platform __PACKAGE_PLATFORM__ \
## RTR report metrics are empty ## RTR report metrics are empty
Check `.env`: First check registration and the report directory:
```bash ```bash
grep '^RTR_REPORT_DIR=' .env ./scripts/status.sh
test -d "$(grep '^RTR_REPORT_DIR=' .env | cut -d= -f2-)" && echo ok ./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
``` ```
Check the metrics container mount: Check the metrics container mount:
@@ -74,7 +74,7 @@ docker inspect "$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2-)-artifact-me
All-five snapshot can be slow, especially under QEMU. Increase timeout: All-five snapshot can be slow, especially under QEMU. Increase timeout:
```bash ```bash
./start.sh --timeout-secs 14400 ./scripts/start.sh --timeout-secs 14400
``` ```
## Output Counts Are Too Low ## Output Counts Are Too Low

View File

@@ -12,7 +12,7 @@ docker compose version
如果缺失,重新执行: 如果缺失,重新执行:
```bash ```bash
./install.sh ./scripts/install.sh
``` ```
## 架构不匹配报错 ## 架构不匹配报错
@@ -55,11 +55,11 @@ docker run --rm --platform __PACKAGE_PLATFORM__ \
## RTR report 指标为空 ## RTR report 指标为空
检查 `.env`: 先检查注册状态和 report 目录:
```bash ```bash
grep '^RTR_REPORT_DIR=' .env ./scripts/status.sh
test -d "$(grep '^RTR_REPORT_DIR=' .env | cut -d= -f2-)" && echo ok ./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
``` ```
检查 metrics 容器是否挂载该目录: 检查 metrics 容器是否挂载该目录:
@@ -74,7 +74,7 @@ docker inspect "$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2-)-artifact-me
all5 snapshot 可能很慢,尤其在 QEMU 环境。可以提高超时: all5 snapshot 可能很慢,尤其在 QEMU 环境。可以提高超时:
```bash ```bash
./start.sh --timeout-secs 14400 ./scripts/start.sh --timeout-secs 14400
``` ```
## 产物数量异常偏低 ## 产物数量异常偏低

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
DRY_RUN=1 DRY_RUN=1
KEEP_RUNS="" KEEP_RUNS=""
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
Usage: ./cleanup.sh [--execute] [--keep-runs N] Usage: ./scripts/cleanup.sh [--execute] [--keep-runs N]
By default this is a dry-run. It removes old run_* directories beyond KEEP_RUNS By default this is a dry-run. It removes old run_* directories beyond KEEP_RUNS
and clears tmp contents. and clears tmp contents.

View File

@@ -224,6 +224,82 @@ validate_rtr_report_dir() {
[[ -d "$RTR_REPORT_DIR" ]] || die "RTR_REPORT_DIR does not exist or is not a directory: $RTR_REPORT_DIR" [[ -d "$RTR_REPORT_DIR" ]] || die "RTR_REPORT_DIR does not exist or is not a directory: $RTR_REPORT_DIR"
} }
validate_rtr_registration_dir() {
local report_dir="$1"
[[ -n "$report_dir" ]] || die "--report-dir is required"
[[ "$report_dir" = /* ]] || die "--report-dir must be an absolute host path: $report_dir"
[[ "$report_dir" =~ ^/[A-Za-z0-9._/@:+,-]+$ ]] || die "--report-dir contains unsupported characters: $report_dir"
[[ -d "$report_dir" ]] || die "--report-dir does not exist: $report_dir"
[[ -r "$report_dir" ]] || die "--report-dir is not readable: $report_dir"
}
rtr_report_file_count() {
local report_dir="$1"
{
find "$report_dir" -maxdepth 1 -type f \( \
-name 'rtr-source-*.json' -o \
-name 'rtr-runtime-*.json' -o \
-name 'rtr-clients-*.json' \
\) -print 2>/dev/null || true
} | wc -l | tr -d '[:space:]'
}
latest_rtr_report_file() {
local report_dir="$1"
{
find "$report_dir" -maxdepth 1 -type f \( \
-name 'rtr-source-*.json' -o \
-name 'rtr-runtime-*.json' -o \
-name 'rtr-clients-*.json' \
\) -printf '%T@ %p\n' 2>/dev/null || true
} | sort -nr | awk 'NR == 1 { sub(/^[^ ]+ /, ""); print }'
}
replace_env_key_file() {
local env_path="$1"
local key="$2"
local value="$3"
local temp_path
[[ "$key" =~ ^[A-Z][A-Z0-9_]*$ ]] || die "invalid environment key: $key"
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "environment value must be single-line"
temp_path="$(mktemp "${env_path}.tmp.XXXXXX")"
awk -v key="$key" -v value="$value" '
BEGIN { replaced = 0 }
$0 ~ "^" key "=" {
print key "=" value
replaced = 1
next
}
{ print }
END {
if (!replaced) {
print key "=" value
}
}
' "$env_path" > "$temp_path"
chmod --reference="$env_path" "$temp_path" 2>/dev/null || true
mv "$temp_path" "$env_path"
}
wait_for_endpoint() {
local url="$1"
local timeout_secs="$2"
local elapsed=0
while (( elapsed < timeout_secs )); do
if endpoint_ok "$url"; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
create_data_dirs() { create_data_dirs() {
load_env load_env
mkdir -p \ mkdir -p \

View File

@@ -1,12 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
Usage: ./install.sh [--skip-dep-install] Usage: ./scripts/install.sh [--skip-dep-install]
Install or update the ours RP Docker installer package idempotently. Install or update the ours RP Docker installer package idempotently.
USAGE USAGE

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
load_env load_env
compose_cmd --profile core --profile sidecar --profile monitor logs "$@" compose_cmd --profile core --profile sidecar --profile monitor logs "$@"

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
REPORT_DIR=""
DRY_RUN=0
CANDIDATE_ENV=""
CONFIG_FILE=""
ORIGINAL_ENV_FILE=""
ENV_BACKUP=""
REGISTRATION_APPLIED=0
usage() {
cat <<'EOF'
Usage: ./scripts/register_rtr_monitor.sh --report-dir <absolute-host-path> [--dry-run]
Registers one local RTR report directory for the artifact metrics sidecar.
The directory must be on the same host as this installer and may contain
rtr-source-*.json, rtr-runtime-*.json, and rtr-clients-*.json report files.
This command recreates only artifact-metrics, Prometheus, and Grafana. It does
not restart or recreate the ours-rp-soak container.
EOF
}
cleanup() {
[[ -n "$CANDIDATE_ENV" && -f "$CANDIDATE_ENV" ]] && rm -f "$CANDIDATE_ENV"
[[ -n "$CONFIG_FILE" && -f "$CONFIG_FILE" ]] && rm -f "$CONFIG_FILE"
return 0
}
trap cleanup EXIT
restore_failed_registration() {
if (( REGISTRATION_APPLIED == 0 )) || [[ -z "$ENV_BACKUP" || ! -f "$ENV_BACKUP" ]]; then
return 0
fi
warn "registration failed after .env replacement; restoring previous metrics configuration"
cp -p "$ENV_BACKUP" "$ORIGINAL_ENV_FILE"
ENV_FILE="$ORIGINAL_ENV_FILE"
load_env || warn "unable to reload restored .env"
compose_cmd --profile sidecar up -d --force-recreate artifact-metrics \
|| warn "unable to recreate artifact-metrics with restored configuration"
}
fail_after_apply() {
restore_failed_registration
die "$*"
}
while (( $# > 0 )); do
case "$1" in
--report-dir)
[[ $# -ge 2 ]] || die "--report-dir requires a value"
REPORT_DIR="$2"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
require_cmd docker
require_cmd curl
require_cmd grep
require_cmd jq
validate_rtr_registration_dir "$REPORT_DIR"
load_env
assert_arch_compatibility
ORIGINAL_ENV_FILE="$ENV_FILE"
REPORT_FILE_COUNT="$(rtr_report_file_count "$REPORT_DIR")"
LATEST_REPORT_FILE="$(latest_rtr_report_file "$REPORT_DIR")"
if (( REPORT_FILE_COUNT == 0 )); then
warn "no recognized RTR report JSON files are currently present in $REPORT_DIR"
fi
CANDIDATE_ENV="$(mktemp "$INSTALLER_ROOT/.env.rtr-register.XXXXXX")"
cp -p "$ORIGINAL_ENV_FILE" "$CANDIDATE_ENV"
replace_env_key_file "$CANDIDATE_ENV" "RTR_REPORT_DIR" "$REPORT_DIR"
ENV_FILE="$CANDIDATE_ENV"
load_env
CONFIG_FILE="$(mktemp "$INSTALLER_ROOT/.rtr-compose-config.XXXXXX")"
compose_cmd --profile sidecar --profile monitor config --format json > "$CONFIG_FILE"
jq -e --arg source "$REPORT_DIR" --arg target "$RTR_REPORT_CONTAINER_DIR" '
.services["artifact-metrics"].volumes
| any(.[]; .type == "bind" and .source == $source and .target == $target and .read_only == true)
' "$CONFIG_FILE" >/dev/null || die "candidate compose configuration is missing expected read-only RTR bind mount"
if (( DRY_RUN == 1 )); then
printf 'dry_run=ok\n'
printf 'report_dir=%s\n' "$REPORT_DIR"
printf 'recognized_report_files=%s\n' "$REPORT_FILE_COUNT"
printf 'latest_report=%s\n' "${LATEST_REPORT_FILE:--}"
printf 'expected_mount_source=%s\n' "$REPORT_DIR"
printf 'expected_mount_target=%s\n' "$RTR_REPORT_CONTAINER_DIR"
exit 0
fi
ENV_FILE="$ORIGINAL_ENV_FILE"
load_env
soak_container_before="$(compose_cmd ps -q ours-rp-soak || true)"
ENV_BACKUP="${ORIGINAL_ENV_FILE}.rtr-register-$(date -u +%Y%m%dT%H%M%SZ).bak"
cp -p "$ORIGINAL_ENV_FILE" "$ENV_BACKUP"
mv "$CANDIDATE_ENV" "$ORIGINAL_ENV_FILE"
CANDIDATE_ENV=""
REGISTRATION_APPLIED=1
load_env
compose_cmd --profile sidecar up -d --force-recreate artifact-metrics \
|| fail_after_apply "unable to recreate artifact-metrics"
compose_cmd --profile sidecar --profile monitor up -d prometheus grafana \
|| fail_after_apply "unable to start Prometheus and Grafana"
soak_container_after="$(compose_cmd ps -q ours-rp-soak || true)"
if [[ -n "$soak_container_before" && "$soak_container_before" != "$soak_container_after" ]]; then
fail_after_apply "ours-rp-soak container changed during RTR registration; investigate before continuing"
fi
wait_for_endpoint "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" 60 \
|| fail_after_apply "artifact-metrics did not become ready"
wait_for_endpoint "http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/ready" 60 \
|| fail_after_apply "Prometheus did not become ready"
wait_for_endpoint "http://127.0.0.1:${GRAFANA_PORT:-3000}/api/health" 60 \
|| fail_after_apply "Grafana did not become ready"
if (( REPORT_FILE_COUNT > 0 )); then
curl -fsS "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" | grep -q '^ours_rp_rtr_' \
|| fail_after_apply "artifact-metrics exposes no RTR metrics after registration"
fi
printf 'registration=ok\n'
printf 'report_dir=%s\n' "$REPORT_DIR"
printf 'recognized_report_files=%s\n' "$REPORT_FILE_COUNT"
printf 'latest_report=%s\n' "${LATEST_REPORT_FILE:--}"
printf 'env_backup=%s\n' "$ENV_BACKUP"
printf 'ours_rp_soak_container_unchanged=%s\n' "${soak_container_before:-not-running}"

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
QUICK=0 QUICK=0
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do
shift shift
;; ;;
-h|--help) -h|--help)
echo "Usage: ./self-check.sh [--quick]" echo "Usage: ./scripts/self-check.sh [--quick]"
exit 0 exit 0
;; ;;
*) *)

View File

@@ -1,15 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
WAIT_FIRST_RUN=1 WAIT_FIRST_RUN=1
TIMEOUT_SECS="" TIMEOUT_SECS=""
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
Usage: ./start.sh [--no-wait-first-run] [--timeout-secs N] Usage: ./scripts/start.sh [--no-wait-first-run] [--timeout-secs N]
Start ours RP. If no successful run exists, wait for the first snapshot to succeed Start ours RP. If no successful run exists, wait for the first snapshot to succeed
before starting metrics, Prometheus and Grafana. before starting metrics, Prometheus and Grafana.

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
BRIEF=0 BRIEF=0
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do
shift shift
;; ;;
-h|--help) -h|--help)
echo "Usage: ./status.sh [--brief]" echo "Usage: ./scripts/status.sh [--brief]"
exit 0 exit 0
;; ;;
*) *)
@@ -76,4 +76,12 @@ if [[ "$BRIEF" == "0" ]]; then
endpoint_ok "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" && echo "metrics=ok" || echo "metrics=unavailable" endpoint_ok "http://127.0.0.1:${METRICS_PORT:-9556}/metrics" && echo "metrics=ok" || echo "metrics=unavailable"
endpoint_ok "http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/ready" && echo "prometheus=ok" || echo "prometheus=unavailable" endpoint_ok "http://127.0.0.1:${PROMETHEUS_PORT:-9090}/-/ready" && echo "prometheus=ok" || echo "prometheus=unavailable"
endpoint_ok "http://127.0.0.1:${GRAFANA_PORT:-3000}/api/health" && echo "grafana=ok" || echo "grafana=unavailable" endpoint_ok "http://127.0.0.1:${GRAFANA_PORT:-3000}/api/health" && echo "grafana=ok" || echo "grafana=unavailable"
echo
if [[ "$RTR_REPORT_DIR" == "$HOST_DATA_DIR/empty-rtr-report" ]]; then
echo "rtr_registration=fallback-empty-directory"
else
echo "rtr_registration=registered"
fi
echo "rtr_report_files=$(rtr_report_file_count "$RTR_REPORT_DIR")"
echo "rtr_latest_report=$(latest_rtr_report_file "$RTR_REPORT_DIR" || true)"
fi fi

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
load_env load_env
compose_cmd --profile core --profile sidecar --profile monitor stop "$@" compose_cmd --profile core --profile sidecar --profile monitor stop "$@"

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
PURGE_DATA=0 PURGE_DATA=0
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do
shift shift
;; ;;
-h|--help) -h|--help)
echo "Usage: ./uninstall.sh [--purge-data]" echo "Usage: ./scripts/uninstall.sh [--purge-data]"
exit 0 exit 0
;; ;;
*) *)

View File

@@ -1,15 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh # shellcheck source=common.sh
source "$SCRIPT_DIR/scripts/common.sh" source "$SCRIPT_DIR/common.sh"
REUSE_ENV_FROM="" REUSE_ENV_FROM=""
UPDATE_PACKAGE_IMAGE=1 UPDATE_PACKAGE_IMAGE=1
usage() { usage() {
cat <<'USAGE' cat <<'USAGE'
Usage: ./upgrade.sh [--reuse-env-from /path/to/.env] [--keep-reused-image] Usage: ./scripts/upgrade.sh [--reuse-env-from /path/to/.env] [--keep-reused-image]
By default, --reuse-env-from creates the new .env from this package's By default, --reuse-env-from creates the new .env from this package's
.env.example first, then overlays existing user settings from the old .env. .env.example first, then overlays existing user settings from the old .env.

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$BASH_SOURCE")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PATCH_SOURCE="$REPO_ROOT/deploy/docker-installer/custom-tal-patch"
BASE_COMPONENT_PACKAGE=""
OUT_DIR="$(printenv OUT_DIR 2>/dev/null || true)"
PREFIX="$(printenv PREFIX 2>/dev/null || true)"
ALLOW_DIRTY=0
[[ -n "$OUT_DIR" ]] || OUT_DIR="$REPO_ROOT/target/custom-tal-patch"
[[ -n "$PREFIX" ]] || PREFIX="ours-rp-custom-tal-patch"
usage() {
cat <<'USAGE'
Usage:
scripts/docker/build_custom_tal_patch.sh \
--base-component-package <ours-rp-installer-*.tar.gz> \
[--out-dir <dir>] [--prefix <name>] [--allow-dirty]
The patch targets the Arm64 component package from the customer stack. It
overlays the runner, Compose, fixture mount and test-service tools; it does not
rebuild or replace the runtime image.
USAGE
}
die() {
echo "error: $*" >&2
exit 2
}
while [[ $# -gt 0 ]]; do
case "$1" in
--base-component-package) BASE_COMPONENT_PACKAGE="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--allow-dirty) ALLOW_DIRTY=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown option: $1" ;;
esac
done
[[ -f "$BASE_COMPONENT_PACKAGE" ]] || die "missing base component package: $BASE_COMPONENT_PACKAGE"
[[ -d "$PATCH_SOURCE" ]] || die "missing patch source: $PATCH_SOURCE"
SOURCE_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
SOURCE_COMMIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short=8 HEAD)"
SOURCE_DIRTY=false
if [[ -n "$(git -C "$REPO_ROOT" status --short)" ]]; then
SOURCE_DIRTY=true
fi
if [[ "$SOURCE_DIRTY" == true && "$ALLOW_DIRTY" != 1 ]]; then
die "source worktree is dirty; use --allow-dirty for a development patch"
fi
package_root="$(tar -tzf "$BASE_COMPONENT_PACKAGE" | awk -F/ '!seen { print $1; seen = 1 }')"
[[ -n "$package_root" ]] || die "cannot determine component package root"
manifest_text="$(tar -xOf "$BASE_COMPONENT_PACKAGE" "$package_root/PACKAGE-MANIFEST.env")" \
|| die "base component package has no PACKAGE-MANIFEST.env"
read_manifest_value() {
printf '%s\n' "$manifest_text" | awk -F= -v key="$1" '$1 == key { print substr($0, index($0, "=") + 1); exit }'
}
base_source_commit="$(read_manifest_value source_commit)"
base_package_arch="$(read_manifest_value package_arch)"
base_runtime_image="$(read_manifest_value runtime_image)"
[[ "$base_source_commit" == 9f4f4cf069e9fa8f6f97ed0f1065adac38abe82f ]] \
|| die "base source commit mismatch: $base_source_commit"
[[ "$base_package_arch" == arm64 ]] || die "base package must be arm64: $base_package_arch"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
patch_name="$PREFIX-$SOURCE_COMMIT_SHORT"
if [[ "$SOURCE_DIRTY" == true ]]; then
patch_name="$patch_name-dirty"
fi
patch_name="$patch_name-$timestamp"
stage="$OUT_DIR/$patch_name"
tar_path="$OUT_DIR/$patch_name.tar.gz"
base_sha256="$(sha256sum "$BASE_COMPONENT_PACKAGE" | awk '{ print $1 }')"
rm -rf "$stage"
mkdir -p "$stage/payload/compose" "$stage/payload/scripts/soak" \
"$stage/payload/custom-fixtures" "$stage/payload/tools"
cp "$PATCH_SOURCE/apply_custom_tal_patch.sh" "$stage/"
cp "$PATCH_SOURCE/rollback_custom_tal_patch.sh" "$stage/"
cp "$PATCH_SOURCE/custom-tal.env.example" "$stage/"
cp "$REPO_ROOT/deploy/docker-installer/compose/docker-compose.yml" "$stage/payload/compose/"
cp "$REPO_ROOT/scripts/soak/run_soak.sh" "$stage/payload/scripts/soak/"
cp "$REPO_ROOT/deploy/docker-installer/custom-fixtures/README.md" "$stage/payload/custom-fixtures/"
cp "$PATCH_SOURCE/tools/generate_custom_fixture.py" "$stage/payload/tools/"
cp "$PATCH_SOURCE/tools/serve_https.py" "$stage/payload/tools/"
cp "$PATCH_SOURCE/tools/start_fixed_services.sh" "$stage/payload/tools/"
cp "$PATCH_SOURCE/tools/stop_fixed_services.sh" "$stage/payload/tools/"
chmod +x "$stage/apply_custom_tal_patch.sh" "$stage/rollback_custom_tal_patch.sh" \
"$stage/payload/scripts/soak/run_soak.sh" "$stage/payload/tools/"*.sh \
"$stage/payload/tools/"*.py
{
echo "patch_schema_version=1"
echo "patch_name=$patch_name"
echo "created_at_utc=$timestamp"
echo "source_commit=$SOURCE_COMMIT"
echo "source_commit_short=$SOURCE_COMMIT_SHORT"
echo "source_dirty=$SOURCE_DIRTY"
echo "base_component_archive=$(basename "$BASE_COMPONENT_PACKAGE")"
echo "base_component_archive_sha256=$base_sha256"
echo "base_component_root=$package_root"
echo "base_source_commit=$base_source_commit"
echo "base_package_arch=$base_package_arch"
echo "base_runtime_image=$base_runtime_image"
echo "overlay_runtime_image_unchanged=true"
echo "custom_mode=RIRS=custom,TAL_INPUT_MODE=custom-file-with-ta"
echo "rrdp_port=18443"
echo "rsync_port=1873"
echo "payload_root=payload"
while IFS= read -r relative_path; do
safe_name="$(printf '%s' "$relative_path" | tr '/.' '__')"
file_sha256="$(sha256sum "$stage/payload/$relative_path" | awk '{ print $1 }')"
echo "payload_"$safe_name"_sha256=$file_sha256"
done < <(cd "$stage/payload" && find . -type f -printf '%P\n' | sort)
} > "$stage/PATCH-MANIFEST.env"
cat > "$stage/PATCH-SUMMARY.txt" <<EOF
patch_name: $patch_name
base_component_archive: $(basename "$BASE_COMPONENT_PACKAGE")
base_component_archive_sha256: $base_sha256
base_source_commit: $base_source_commit
source_commit: $SOURCE_COMMIT
source_dirty: $SOURCE_DIRTY
runtime_image: $base_runtime_image
runtime_image_changed: false
custom_fixture_generator: payload/tools/generate_custom_fixture.py
fixed_services: payload/tools/start_fixed_services.sh
EOF
tar -C "$OUT_DIR" -czf "$tar_path" "$patch_name"
patch_sha256="$(sha256sum "$tar_path" | awk '{ print $1 }')"
{
echo "patch=$tar_path"
echo "patch_dir=$stage"
echo "patch_sha256=$patch_sha256"
echo "manifest=$stage/PATCH-MANIFEST.env"
echo "base_component_archive_sha256=$base_sha256"
echo "source_commit=$SOURCE_COMMIT"
echo "source_dirty=$SOURCE_DIRTY"
} > "$OUT_DIR/$patch_name.summary.env"
echo "patch built: $tar_path"

View File

@@ -437,7 +437,12 @@ cross_arch_default: deny
cross_arch_enable_var: ALLOW_CROSS_ARCH cross_arch_enable_var: ALLOW_CROSS_ARCH
EOF EOF
chmod +x "$stage"/*.sh "$stage/scripts"/*.sh chmod +x "$stage/scripts"/*.sh
if find "$stage" -maxdepth 1 -type f -name '*.sh' -print -quit | grep -q .; then
echo "error: operational scripts must be installed under scripts/ only" >&2
exit 1
fi
tar -C "$OUT_DIR" -czf "$tar_path" "$package_name" tar -C "$OUT_DIR" -czf "$tar_path" "$package_name"
package_sha256="$(sha256sum "$tar_path" | awk '{print $1}')" package_sha256="$(sha256sum "$tar_path" | awk '{print $1}')"
rm -rf "$monitor_image_stage" rm -rf "$monitor_image_stage"

View File

@@ -15,6 +15,10 @@ INTERVAL_SECS="${INTERVAL_SECS:-0}"
STOP_AFTER_SECS="${STOP_AFTER_SECS:-0}" STOP_AFTER_SECS="${STOP_AFTER_SECS:-0}"
RIRS="${RIRS:-afrinic,apnic,arin,lacnic,ripe}" RIRS="${RIRS:-afrinic,apnic,arin,lacnic,ripe}"
TAL_INPUT_MODE="${TAL_INPUT_MODE:-file-with-ta}" TAL_INPUT_MODE="${TAL_INPUT_MODE:-file-with-ta}"
CUSTOM_TAL_PATH="${CUSTOM_TAL_PATH:-$PACKAGE_ROOT/custom-fixtures/tal/custom.tal}"
CUSTOM_TA_PATH="${CUSTOM_TA_PATH:-$PACKAGE_ROOT/custom-fixtures/ta/custom-ta.cer}"
CUSTOM_TAL_URI="${CUSTOM_TAL_URI:-https://host.docker.internal:18443/tal/custom.tal}"
HTTP_ROOT_CERT_PATHS="${HTTP_ROOT_CERT_PATHS:-}"
RESOURCE_VALIDATION_MODE="${RESOURCE_VALIDATION_MODE:-validation-update-03}" RESOURCE_VALIDATION_MODE="${RESOURCE_VALIDATION_MODE:-validation-update-03}"
RUN_ROOT="${RUN_ROOT:-$PACKAGE_ROOT}" RUN_ROOT="${RUN_ROOT:-$PACKAGE_ROOT}"
RETAIN_RUNS="${RETAIN_RUNS:-10}" RETAIN_RUNS="${RETAIN_RUNS:-10}"
@@ -118,10 +122,10 @@ validate_rsync_scope() {
validate_tal_input_mode() { validate_tal_input_mode() {
case "$TAL_INPUT_MODE" in case "$TAL_INPUT_MODE" in
file-with-ta|file-live-ta|url) file-with-ta|file-live-ta|custom-file-with-ta|url)
;; ;;
*) *)
die "TAL_INPUT_MODE must be file-with-ta, file-live-ta or url: $TAL_INPUT_MODE" die "TAL_INPUT_MODE must be file-with-ta, file-live-ta, custom-file-with-ta or url: $TAL_INPUT_MODE"
;; ;;
esac esac
} }
@@ -155,14 +159,46 @@ parse_rirs() {
afrinic|apnic|arin|lacnic|ripe) afrinic|apnic|arin|lacnic|ripe)
RIR_LIST+=("$normalized") RIR_LIST+=("$normalized")
;; ;;
custom)
[[ "$TAL_INPUT_MODE" == "custom-file-with-ta" ]] \
|| die "RIRS=custom requires TAL_INPUT_MODE=custom-file-with-ta"
RIR_LIST+=("$normalized")
;;
*) *)
die "invalid RIRS entry: $raw_token; allowed: afrinic,apnic,arin,lacnic,ripe" die "invalid RIRS entry: $raw_token; allowed: afrinic,apnic,arin,lacnic,ripe,custom (custom mode only)"
;; ;;
esac esac
done done
[[ "${#RIR_LIST[@]}" -gt 0 ]] || die "RIRS must contain at least one RIR" [[ "${#RIR_LIST[@]}" -gt 0 ]] || die "RIRS must contain at least one RIR"
} }
validate_custom_inputs() {
local root_cert_path
local -a root_cert_paths=()
if [[ "$TAL_INPUT_MODE" == "custom-file-with-ta" ]]; then
[[ "${#RIR_LIST[@]}" -eq 1 && "${RIR_LIST[0]}" == "custom" ]] \
|| die "custom-file-with-ta requires exactly RIRS=custom"
[[ -s "$CUSTOM_TAL_PATH" ]] \
|| die "missing custom TAL: $CUSTOM_TAL_PATH"
[[ -s "$CUSTOM_TA_PATH" ]] \
|| die "missing custom TA DER: $CUSTOM_TA_PATH"
[[ -n "$CUSTOM_TAL_URI" ]] \
|| die "CUSTOM_TAL_URI is required in custom-file-with-ta mode"
elif [[ "$RIRS" == *custom* ]]; then
die "RIRS=custom requires TAL_INPUT_MODE=custom-file-with-ta"
fi
if [[ -n "$HTTP_ROOT_CERT_PATHS" ]]; then
# shellcheck disable=SC2206
root_cert_paths=( $HTTP_ROOT_CERT_PATHS )
for root_cert_path in "${root_cert_paths[@]}"; do
[[ -s "$root_cert_path" ]] \
|| die "missing HTTP root certificate: $root_cert_path"
done
fi
}
tal_file_for_rir() { tal_file_for_rir() {
case "$1" in case "$1" in
afrinic) printf '%s' "$FIXTURE_DIR/tal/afrinic.tal" ;; afrinic) printf '%s' "$FIXTURE_DIR/tal/afrinic.tal" ;;
@@ -170,6 +206,7 @@ tal_file_for_rir() {
arin) printf '%s' "$FIXTURE_DIR/tal/arin.tal" ;; arin) printf '%s' "$FIXTURE_DIR/tal/arin.tal" ;;
lacnic) printf '%s' "$FIXTURE_DIR/tal/lacnic.tal" ;; lacnic) printf '%s' "$FIXTURE_DIR/tal/lacnic.tal" ;;
ripe) printf '%s' "$FIXTURE_DIR/tal/ripe-ncc.tal" ;; ripe) printf '%s' "$FIXTURE_DIR/tal/ripe-ncc.tal" ;;
custom) printf '%s' "$CUSTOM_TAL_PATH" ;;
*) die "unknown RIR: $1" ;; *) die "unknown RIR: $1" ;;
esac esac
} }
@@ -181,6 +218,7 @@ ta_file_for_rir() {
arin) printf '%s' "$FIXTURE_DIR/ta/arin-ta.cer" ;; arin) printf '%s' "$FIXTURE_DIR/ta/arin-ta.cer" ;;
lacnic) printf '%s' "$FIXTURE_DIR/ta/lacnic-ta.cer" ;; lacnic) printf '%s' "$FIXTURE_DIR/ta/lacnic-ta.cer" ;;
ripe) printf '%s' "$FIXTURE_DIR/ta/ripe-ncc-ta.cer" ;; ripe) printf '%s' "$FIXTURE_DIR/ta/ripe-ncc-ta.cer" ;;
custom) printf '%s' "$CUSTOM_TA_PATH" ;;
*) die "unknown RIR: $1" ;; *) die "unknown RIR: $1" ;;
esac esac
} }
@@ -192,6 +230,7 @@ tal_url_for_rir() {
arin) printf '%s' "https://www.arin.net/resources/manage/rpki/arin.tal" ;; arin) printf '%s' "https://www.arin.net/resources/manage/rpki/arin.tal" ;;
lacnic) printf '%s' "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal" ;; lacnic) printf '%s' "https://www.lacnic.net/innovaportal/file/4983/1/lacnic.tal" ;;
ripe) printf '%s' "https://tal.rpki.ripe.net/ripe-ncc.tal" ;; ripe) printf '%s' "https://tal.rpki.ripe.net/ripe-ncc.tal" ;;
custom) printf '%s' "$CUSTOM_TAL_URI" ;;
*) die "unknown RIR: $1" ;; *) die "unknown RIR: $1" ;;
esac esac
} }
@@ -1057,6 +1096,16 @@ build_child_args() {
CHILD_ARGS+=(--rsync-mirror-root "$TMP_DIR/rsync-mirror-{run_id}") CHILD_ARGS+=(--rsync-mirror-root "$TMP_DIR/rsync-mirror-{run_id}")
fi fi
local root_cert_path
local -a root_cert_paths=()
if [[ -n "$HTTP_ROOT_CERT_PATHS" ]]; then
# shellcheck disable=SC2206
root_cert_paths=( $HTTP_ROOT_CERT_PATHS )
for root_cert_path in "${root_cert_paths[@]}"; do
CHILD_ARGS+=(--http-root-cert "$root_cert_path")
done
fi
CHILD_ARGS+=( CHILD_ARGS+=(
--parallel-phase2-ready-batch-size 256 --parallel-phase2-ready-batch-size 256
--parallel-phase2-ready-batch-wall-time-budget-ms 100 --parallel-phase2-ready-batch-wall-time-budget-ms 100
@@ -1344,6 +1393,7 @@ main() {
validate_positive_int "DB_STATS_EXACT_EVERY" "$DB_STATS_EXACT_EVERY" validate_positive_int "DB_STATS_EXACT_EVERY" "$DB_STATS_EXACT_EVERY"
fi fi
parse_rirs parse_rirs
validate_custom_inputs
[[ -x "$RPKI_BIN" ]] || die "missing executable: $RPKI_BIN" [[ -x "$RPKI_BIN" ]] || die "missing executable: $RPKI_BIN"
[[ -x "$RPKI_DAEMON_BIN" ]] || die "missing executable: $RPKI_DAEMON_BIN" [[ -x "$RPKI_DAEMON_BIN" ]] || die "missing executable: $RPKI_DAEMON_BIN"

View File

@@ -29,6 +29,7 @@ pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
pub const OID_AUTHORITY_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.1); pub const OID_AUTHORITY_INFO_ACCESS_RAW: &[u8] = &asn1_rs::oid!(raw 1.3.6.1.5.5.7.1.1);
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32"; pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
pub const OID_CERTIFICATE_POLICIES_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.32); pub const OID_CERTIFICATE_POLICIES_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.32);
pub const OID_QT_CPS: &str = "1.3.6.1.5.5.7.2.1";
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35"; pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
pub const OID_AUTHORITY_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.35); pub const OID_AUTHORITY_KEY_IDENTIFIER_RAW: &[u8] = &asn1_rs::oid!(raw 2.5.29.35);

View File

@@ -13,11 +13,14 @@ use crate::data_model::common::{
use crate::data_model::oid::{ use crate::data_model::oid::{
OID_AD_CA_ISSUERS_RAW, OID_AD_CA_REPOSITORY, OID_AD_CA_REPOSITORY_RAW, OID_AD_RPKI_MANIFEST, OID_AD_CA_ISSUERS_RAW, OID_AD_CA_REPOSITORY, OID_AD_CA_REPOSITORY_RAW, OID_AD_RPKI_MANIFEST,
OID_AD_RPKI_MANIFEST_RAW, OID_AD_RPKI_NOTIFY, OID_AD_RPKI_NOTIFY_RAW, OID_AD_SIGNED_OBJECT, OID_AD_RPKI_MANIFEST_RAW, OID_AD_RPKI_NOTIFY, OID_AD_RPKI_NOTIFY_RAW, OID_AD_SIGNED_OBJECT,
OID_AD_SIGNED_OBJECT_RAW, OID_AUTHORITY_INFO_ACCESS_RAW, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_AD_SIGNED_OBJECT_RAW, OID_AUTHORITY_INFO_ACCESS, OID_AUTHORITY_INFO_ACCESS_RAW,
OID_AUTONOMOUS_SYS_IDS_RAW, OID_BASIC_CONSTRAINTS_RAW, OID_CERTIFICATE_POLICIES_RAW, OID_AUTHORITY_KEY_IDENTIFIER, OID_AUTHORITY_KEY_IDENTIFIER_RAW, OID_AUTONOMOUS_SYS_IDS,
OID_CP_IPADDR_ASNUMBER, OID_CP_IPADDR_ASNUMBER_RAW, OID_CRL_DISTRIBUTION_POINTS_RAW, OID_AUTONOMOUS_SYS_IDS_RAW, OID_BASIC_CONSTRAINTS, OID_BASIC_CONSTRAINTS_RAW,
OID_IP_ADDR_BLOCKS_RAW, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_CERTIFICATE_POLICIES, OID_CERTIFICATE_POLICIES_RAW, OID_CP_IPADDR_ASNUMBER,
OID_SUBJECT_INFO_ACCESS_RAW, OID_SUBJECT_KEY_IDENTIFIER_RAW, OID_CP_IPADDR_ASNUMBER_RAW, OID_CRL_DISTRIBUTION_POINTS, OID_CRL_DISTRIBUTION_POINTS_RAW,
OID_EXTENDED_KEY_USAGE, OID_IP_ADDR_BLOCKS, OID_IP_ADDR_BLOCKS_RAW, OID_KEY_USAGE, OID_QT_CPS,
OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_SUBJECT_INFO_ACCESS,
OID_SUBJECT_INFO_ACCESS_RAW, OID_SUBJECT_KEY_IDENTIFIER, OID_SUBJECT_KEY_IDENTIFIER_RAW,
}; };
/// Resource Certificate kind (semantic classification). /// Resource Certificate kind (semantic classification).
@@ -27,6 +30,14 @@ pub enum ResourceCertKind {
Ee, Ee,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResourceCertificateRole {
TrustAnchor,
Ca,
SignedObjectEe,
RouterEe,
}
/// A parsed RPKI Resource Certificate (RFC 6487) data model. /// A parsed RPKI Resource Certificate (RFC 6487) data model.
/// ///
/// This module intentionally focuses on the semantics needed by Signed Object validation and /// This module intentionally focuses on the semantics needed by Signed Object validation and
@@ -59,6 +70,7 @@ pub struct RpkixTbsCertificate {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct RcExtensions { pub struct RcExtensions {
pub basic_constraints_ca: bool, pub basic_constraints_ca: bool,
pub basic_constraints: Option<BasicConstraintsProfile>,
pub subject_key_identifier: Option<Vec<u8>>, pub subject_key_identifier: Option<Vec<u8>>,
/// Authority Key Identifier (AKI) keyIdentifier value. /// Authority Key Identifier (AKI) keyIdentifier value.
pub authority_key_identifier: Option<Vec<u8>>, pub authority_key_identifier: Option<Vec<u8>>,
@@ -68,11 +80,26 @@ pub struct RcExtensions {
pub ca_issuers_uris: Option<Vec<String>>, pub ca_issuers_uris: Option<Vec<String>>,
pub subject_info_access: Option<SubjectInfoAccess>, pub subject_info_access: Option<SubjectInfoAccess>,
pub certificate_policies_oid: Option<String>, pub certificate_policies_oid: Option<String>,
pub certificate_policies: Option<CertificatePoliciesProfile>,
pub extension_oids: Vec<String>,
pub ip_resources: Option<IpResourceSet>, pub ip_resources: Option<IpResourceSet>,
pub as_resources: Option<AsResourceSet>, pub as_resources: Option<AsResourceSet>,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BasicConstraintsProfile {
pub ca: bool,
pub critical: bool,
pub path_len_constraint: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CertificatePoliciesProfile {
pub policy_oid: String,
pub qualifier_oids: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceCertificateParsed { pub struct ResourceCertificateParsed {
pub raw_der: Vec<u8>, pub raw_der: Vec<u8>,
@@ -114,13 +141,14 @@ impl AlgorithmIdentifierValue {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct RcExtensionsParsed { pub struct RcExtensionsParsed {
pub basic_constraints_ca: Vec<bool>, pub basic_constraints: Vec<BasicConstraintsProfile>,
pub subject_key_identifier: Vec<(Vec<u8>, bool)>, pub subject_key_identifier: Vec<(Vec<u8>, bool)>,
pub authority_key_identifier: Vec<(AuthorityKeyIdentifierParsed, bool)>, pub authority_key_identifier: Vec<(AuthorityKeyIdentifierParsed, bool)>,
pub crl_distribution_points: Vec<(CrlDistributionPointsParsed, bool)>, pub crl_distribution_points: Vec<(CrlDistributionPointsParsed, bool)>,
pub authority_info_access: Vec<(AuthorityInfoAccessParsed, bool)>, pub authority_info_access: Vec<(AuthorityInfoAccessParsed, bool)>,
pub subject_info_access: Vec<(SubjectInfoAccessParsed, bool)>, pub subject_info_access: Vec<(SubjectInfoAccessParsed, bool)>,
pub certificate_policies: Vec<(Vec<String>, bool)>, pub certificate_policies: Vec<(Vec<CertificatePoliciesProfile>, bool)>,
pub extension_oids: Vec<String>,
pub ip_resources: Vec<(IpResourceSet, bool)>, pub ip_resources: Vec<(IpResourceSet, bool)>,
pub as_resources: Vec<(AsResourceSet, bool)>, pub as_resources: Vec<(AsResourceSet, bool)>,
} }
@@ -406,11 +434,45 @@ pub enum ResourceCertificateProfileError {
#[error("certificatePolicies criticality must be critical (RFC 6487 §4.8.9)")] #[error("certificatePolicies criticality must be critical (RFC 6487 §4.8.9)")]
CertificatePoliciesCriticality, CertificatePoliciesCriticality,
#[error("certificatePolicies must be present (RFC 6487 §4.8.9)")]
CertificatePoliciesMissing,
#[error( #[error(
"certificatePolicies must contain RPKI policy OID {OID_CP_IPADDR_ASNUMBER}, got {0} (RFC 6487 §4.8.9)" "certificatePolicies must contain RPKI policy OID {OID_CP_IPADDR_ASNUMBER}, got {0} (RFC 6487 §4.8.9)"
)] )]
InvalidCertificatePolicy(String), InvalidCertificatePolicy(String),
#[error("certificatePolicies may contain at most one CPS qualifier (RFC 6487 §4.8.9)")]
CertificatePoliciesTooManyQualifiers,
#[error(
"certificatePolicies qualifier must be id-qt-cps ({OID_QT_CPS}), got {0} (RFC 6487 §4.8.9)"
)]
CertificatePoliciesInvalidQualifier(String),
#[error("basicConstraints must be present in CA certificates (RFC 6487 §4.8.1)")]
BasicConstraintsMissing,
#[error("basicConstraints criticality must be critical in CA certificates (RFC 6487 §4.8.1)")]
BasicConstraintsCriticality,
#[error("basicConstraints cA must be TRUE in CA certificates (RFC 6487 §4.8.1)")]
BasicConstraintsCaFalse,
#[error(
"basicConstraints pathLenConstraint must be absent in CA certificates (RFC 6487 §4.8.1)"
)]
BasicConstraintsPathLenPresent,
#[error("basicConstraints must be absent in EE certificates (RFC 6487 §4.8.1)")]
BasicConstraintsEeMustOmit,
#[error("extension {oid} is not permitted for {role} resource certificates (RFC 6487 §4.8)")]
DisallowedExtension { role: &'static str, oid: String },
#[error("autonomousSysIds RDI field must be absent (RFC 6487 §4.8.11; RFC 3779 §3.2.3)")]
AsResourcesRdiPresent,
#[error( #[error(
"SIA id-ad-signedObject accessLocation must be URI (RFC 6487 §4.8.8.2; RFC 5280 §4.2.2.2)" "SIA id-ad-signedObject accessLocation must be URI (RFC 6487 §4.8.8.2; RFC 5280 §4.2.2.2)"
)] )]
@@ -567,6 +629,87 @@ impl ResourceCertificate {
Ok(()) Ok(())
} }
pub fn validate_rfc6487_profile(
&self,
role: ResourceCertificateRole,
) -> Result<(), ResourceCertificateProfileError> {
let role_name = match role {
ResourceCertificateRole::TrustAnchor => "trust anchor CA",
ResourceCertificateRole::Ca => "CA",
ResourceCertificateRole::SignedObjectEe => "signed-object EE",
ResourceCertificateRole::RouterEe => "router EE",
};
let ca_role = matches!(
role,
ResourceCertificateRole::TrustAnchor | ResourceCertificateRole::Ca
);
if ca_role {
let constraints = self
.tbs
.extensions
.basic_constraints
.as_ref()
.ok_or(ResourceCertificateProfileError::BasicConstraintsMissing)?;
if !constraints.critical {
return Err(ResourceCertificateProfileError::BasicConstraintsCriticality);
}
if !constraints.ca {
return Err(ResourceCertificateProfileError::BasicConstraintsCaFalse);
}
if constraints.path_len_constraint.is_some() {
return Err(ResourceCertificateProfileError::BasicConstraintsPathLenPresent);
}
} else if self.tbs.extensions.basic_constraints.is_some() {
return Err(ResourceCertificateProfileError::BasicConstraintsEeMustOmit);
}
for oid in &self.tbs.extensions.extension_oids {
if !is_permitted_extension(oid, role) {
return Err(ResourceCertificateProfileError::DisallowedExtension {
role: role_name,
oid: oid.clone(),
});
}
}
let policies = self
.tbs
.extensions
.certificate_policies
.as_ref()
.ok_or(ResourceCertificateProfileError::CertificatePoliciesMissing)?;
if policies.policy_oid != OID_CP_IPADDR_ASNUMBER {
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
policies.policy_oid.clone(),
));
}
if policies.qualifier_oids.len() > 1 {
return Err(ResourceCertificateProfileError::CertificatePoliciesTooManyQualifiers);
}
if let Some(qualifier_oid) = policies.qualifier_oids.first() {
if qualifier_oid != OID_QT_CPS {
return Err(
ResourceCertificateProfileError::CertificatePoliciesInvalidQualifier(
qualifier_oid.clone(),
),
);
}
}
if self
.tbs
.extensions
.as_resources
.as_ref()
.is_some_and(|resources| resources.rdi.is_some())
{
return Err(ResourceCertificateProfileError::AsResourcesRdiPresent);
}
Ok(())
}
pub fn validate_strict_name_profile(&self) -> Result<(), ResourceCertificateProfileError> { pub fn validate_strict_name_profile(&self) -> Result<(), ResourceCertificateProfileError> {
validate_strict_rpki_name(&self.tbs.issuer_name, "issuer")?; validate_strict_rpki_name(&self.tbs.issuer_name, "issuer")?;
validate_strict_rpki_name(&self.tbs.subject_name, "subject")?; validate_strict_rpki_name(&self.tbs.subject_name, "subject")?;
@@ -590,6 +733,22 @@ impl ResourceCertificate {
} }
} }
fn is_permitted_extension(oid: &str, role: ResourceCertificateRole) -> bool {
matches!(
oid,
OID_BASIC_CONSTRAINTS
| OID_KEY_USAGE
| OID_SUBJECT_KEY_IDENTIFIER
| OID_AUTHORITY_KEY_IDENTIFIER
| OID_CRL_DISTRIBUTION_POINTS
| OID_AUTHORITY_INFO_ACCESS
| OID_SUBJECT_INFO_ACCESS
| OID_CERTIFICATE_POLICIES
| OID_IP_ADDR_BLOCKS
| OID_AUTONOMOUS_SYS_IDS
) || (role == ResourceCertificateRole::RouterEe && oid == OID_EXTENDED_KEY_USAGE)
}
fn validate_strict_rpki_name( fn validate_strict_rpki_name(
name: &X509NameDer, name: &X509NameDer,
role: &'static str, role: &'static str,
@@ -744,13 +903,24 @@ mod strict_name_tests {
#[test] #[test]
fn profile_rejects_rfc8360_v2_policy_oid() { fn profile_rejects_rfc8360_v2_policy_oid() {
let extensions = RcExtensionsParsed { let extensions = RcExtensionsParsed {
basic_constraints_ca: vec![true], basic_constraints: vec![BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
}],
subject_key_identifier: Vec::new(), subject_key_identifier: Vec::new(),
authority_key_identifier: Vec::new(), authority_key_identifier: Vec::new(),
crl_distribution_points: Vec::new(), crl_distribution_points: Vec::new(),
authority_info_access: Vec::new(), authority_info_access: Vec::new(),
subject_info_access: Vec::new(), subject_info_access: Vec::new(),
certificate_policies: vec![(vec!["1.3.6.1.5.5.7.14.3".to_string()], true)], certificate_policies: vec![(
vec![CertificatePoliciesProfile {
policy_oid: "1.3.6.1.5.5.7.14.3".to_string(),
qualifier_oids: Vec::new(),
}],
true,
)],
extension_oids: Vec::new(),
ip_resources: Vec::new(), ip_resources: Vec::new(),
as_resources: Vec::new(), as_resources: Vec::new(),
}; };
@@ -821,23 +991,25 @@ impl RcExtensionsParsed {
// NOTE(perf): `self` is consumed. Prefer moving decoded fields out rather than cloning, // NOTE(perf): `self` is consumed. Prefer moving decoded fields out rather than cloning,
// especially for large resource sets and URI lists. // especially for large resource sets and URI lists.
let RcExtensionsParsed { let RcExtensionsParsed {
basic_constraints_ca, basic_constraints,
subject_key_identifier, subject_key_identifier,
authority_key_identifier, authority_key_identifier,
crl_distribution_points, crl_distribution_points,
authority_info_access, authority_info_access,
subject_info_access, subject_info_access,
certificate_policies, certificate_policies,
extension_oids,
ip_resources, ip_resources,
as_resources, as_resources,
} = self; } = self;
if basic_constraints_ca.len() > 1 { if basic_constraints.len() > 1 {
return Err(ResourceCertificateProfileError::DuplicateExtension( return Err(ResourceCertificateProfileError::DuplicateExtension(
"basicConstraints", "basicConstraints",
)); ));
} }
let basic_constraints_ca = basic_constraints_ca.first().copied().unwrap_or(false); let basic_constraints = basic_constraints.into_iter().next();
let basic_constraints_ca = basic_constraints.as_ref().is_some_and(|bc| bc.ca);
let subject_key_identifier = match subject_key_identifier.len() { let subject_key_identifier = match subject_key_identifier.len() {
0 => None, 0 => None,
@@ -1032,25 +1204,25 @@ impl RcExtensionsParsed {
} }
}; };
let certificate_policies_oid = match certificate_policies.len() { let certificate_policies = match certificate_policies.len() {
0 => None, 0 => None,
1 => { 1 => {
let (oids, critical) = certificate_policies.into_iter().next().expect("len==1"); let (policies, critical) = certificate_policies.into_iter().next().expect("len==1");
if !critical { if !critical {
return Err(ResourceCertificateProfileError::CertificatePoliciesCriticality); return Err(ResourceCertificateProfileError::CertificatePoliciesCriticality);
} }
if oids.len() != 1 { if policies.len() != 1 {
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy( return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
"expected exactly one policy".into(), "expected exactly one policy".into(),
)); ));
} }
let policy_oid = oids.into_iter().next().expect("len==1"); let policy = policies.into_iter().next().expect("len==1");
if policy_oid != OID_CP_IPADDR_ASNUMBER { if policy.policy_oid != OID_CP_IPADDR_ASNUMBER {
return Err(ResourceCertificateProfileError::InvalidCertificatePolicy( return Err(ResourceCertificateProfileError::InvalidCertificatePolicy(
policy_oid, policy.policy_oid,
)); ));
} }
Some(OID_CP_IPADDR_ASNUMBER.to_string()) Some(policy)
} }
_ => { _ => {
return Err(ResourceCertificateProfileError::DuplicateExtension( return Err(ResourceCertificateProfileError::DuplicateExtension(
@@ -1093,12 +1265,17 @@ impl RcExtensionsParsed {
Ok(RcExtensions { Ok(RcExtensions {
basic_constraints_ca, basic_constraints_ca,
basic_constraints,
subject_key_identifier, subject_key_identifier,
authority_key_identifier, authority_key_identifier,
crl_distribution_points_uris, crl_distribution_points_uris,
ca_issuers_uris, ca_issuers_uris,
subject_info_access, subject_info_access,
certificate_policies_oid, certificate_policies_oid: certificate_policies
.as_ref()
.map(|_| OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies,
extension_oids,
ip_resources, ip_resources,
as_resources, as_resources,
}) })
@@ -1127,26 +1304,32 @@ fn algorithm_identifier_value(
fn parse_extensions_parse( fn parse_extensions_parse(
exts: &[X509Extension<'_>], exts: &[X509Extension<'_>],
) -> Result<RcExtensionsParsed, ResourceCertificateParseError> { ) -> Result<RcExtensionsParsed, ResourceCertificateParseError> {
let mut basic_constraints_ca: Vec<bool> = Vec::new(); let mut basic_constraints: Vec<BasicConstraintsProfile> = Vec::new();
let mut ski: Vec<(Vec<u8>, bool)> = Vec::new(); let mut ski: Vec<(Vec<u8>, bool)> = Vec::new();
let mut aki: Vec<(AuthorityKeyIdentifierParsed, bool)> = Vec::new(); let mut aki: Vec<(AuthorityKeyIdentifierParsed, bool)> = Vec::new();
let mut crldp: Vec<(CrlDistributionPointsParsed, bool)> = Vec::new(); let mut crldp: Vec<(CrlDistributionPointsParsed, bool)> = Vec::new();
let mut aia: Vec<(AuthorityInfoAccessParsed, bool)> = Vec::new(); let mut aia: Vec<(AuthorityInfoAccessParsed, bool)> = Vec::new();
let mut sia: Vec<(SubjectInfoAccessParsed, bool)> = Vec::new(); let mut sia: Vec<(SubjectInfoAccessParsed, bool)> = Vec::new();
let mut cert_policies: Vec<(Vec<String>, bool)> = Vec::new(); let mut cert_policies: Vec<(Vec<CertificatePoliciesProfile>, bool)> = Vec::new();
let mut extension_oids: Vec<String> = Vec::with_capacity(exts.len());
let mut ip_resources: Vec<(IpResourceSet, bool)> = Vec::new(); let mut ip_resources: Vec<(IpResourceSet, bool)> = Vec::new();
let mut as_resources: Vec<(AsResourceSet, bool)> = Vec::new(); let mut as_resources: Vec<(AsResourceSet, bool)> = Vec::new();
for ext in exts { for ext in exts {
let oid = ext.oid.as_bytes(); let oid = ext.oid.as_bytes();
extension_oids.push(ext.oid.to_id_string());
if oid == OID_BASIC_CONSTRAINTS_RAW { if oid == OID_BASIC_CONSTRAINTS_RAW {
let ParsedExtension::BasicConstraints(bc) = ext.parsed_extension() else { let ParsedExtension::BasicConstraints(bc) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse( return Err(ResourceCertificateParseError::Parse(
"basicConstraints parse failed".into(), "basicConstraints parse failed".into(),
)); ));
}; };
basic_constraints_ca.push(bc.ca); basic_constraints.push(BasicConstraintsProfile {
ca: bc.ca,
critical: ext.critical,
path_len_constraint: bc.path_len_constraint,
});
} else if oid == OID_SUBJECT_KEY_IDENTIFIER_RAW { } else if oid == OID_SUBJECT_KEY_IDENTIFIER_RAW {
let ParsedExtension::SubjectKeyIdentifier(s) = ext.parsed_extension() else { let ParsedExtension::SubjectKeyIdentifier(s) = ext.parsed_extension() else {
return Err(ResourceCertificateParseError::Parse( return Err(ResourceCertificateParseError::Parse(
@@ -1195,16 +1378,30 @@ fn parse_extensions_parse(
"certificatePolicies parse failed".into(), "certificatePolicies parse failed".into(),
)); ));
}; };
let mut oids: Vec<String> = Vec::with_capacity(cp.len()); let mut policies: Vec<CertificatePoliciesProfile> = Vec::with_capacity(cp.len());
for p in cp.iter() { for p in cp.iter() {
let b = p.policy_id.as_bytes(); let b = p.policy_id.as_bytes();
if b == OID_CP_IPADDR_ASNUMBER_RAW { let policy_oid = if b == OID_CP_IPADDR_ASNUMBER_RAW {
oids.push(OID_CP_IPADDR_ASNUMBER.to_string()); OID_CP_IPADDR_ASNUMBER.to_string()
} else { } else {
oids.push(p.policy_id.to_id_string()); p.policy_id.to_id_string()
};
let qualifier_oids = p
.policy_qualifiers
.as_ref()
.map(|qualifiers| {
qualifiers
.iter()
.map(|qualifier| qualifier.policy_qualifier_id.to_id_string())
.collect()
})
.unwrap_or_default();
policies.push(CertificatePoliciesProfile {
policy_oid,
qualifier_oids,
});
} }
} cert_policies.push((policies, ext.critical));
cert_policies.push((oids, ext.critical));
} else if oid == OID_IP_ADDR_BLOCKS_RAW { } else if oid == OID_IP_ADDR_BLOCKS_RAW {
let parsed = IpResourceSet::decode_extn_value(ext.value) let parsed = IpResourceSet::decode_extn_value(ext.value)
.map_err(|_e| ResourceCertificateParseError::InvalidIpResourcesEncoding)?; .map_err(|_e| ResourceCertificateParseError::InvalidIpResourcesEncoding)?;
@@ -1217,13 +1414,14 @@ fn parse_extensions_parse(
} }
Ok(RcExtensionsParsed { Ok(RcExtensionsParsed {
basic_constraints_ca, basic_constraints,
subject_key_identifier: ski, subject_key_identifier: ski,
authority_key_identifier: aki, authority_key_identifier: aki,
crl_distribution_points: crldp, crl_distribution_points: crldp,
authority_info_access: aia, authority_info_access: aia,
subject_info_access: sia, subject_info_access: sia,
certificate_policies: cert_policies, certificate_policies: cert_policies,
extension_oids,
ip_resources, ip_resources,
as_resources, as_resources,
}) })

View File

@@ -4,6 +4,7 @@ use crate::data_model::oid::{
use crate::data_model::rc::{ use crate::data_model::rc::{
AsIdOrRange, AsIdentifierChoice, ResourceCertKind, ResourceCertificate, AsIdOrRange, AsIdentifierChoice, ResourceCertKind, ResourceCertificate,
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError, ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
ResourceCertificateRole,
}; };
use crate::validation::cert_path::{CertPathError, validate_ee_cert_path_with_predecoded_ee}; use crate::validation::cert_path::{CertPathError, validate_ee_cert_path_with_predecoded_ee};
use x509_parser::extensions::ParsedExtension; use x509_parser::extensions::ParsedExtension;
@@ -202,6 +203,7 @@ impl BgpsecRouterCertificateParsed {
self, self,
) -> Result<BgpsecRouterCertificate, BgpsecRouterCertificateProfileError> { ) -> Result<BgpsecRouterCertificate, BgpsecRouterCertificateProfileError> {
let rc = self.rc_parsed.validate_profile()?; let rc = self.rc_parsed.validate_profile()?;
rc.validate_rfc6487_profile(ResourceCertificateRole::RouterEe)?;
if rc.kind != ResourceCertKind::Ee { if rc.kind != ResourceCertKind::Ee {
return Err(BgpsecRouterCertificateProfileError::NotEe); return Err(BgpsecRouterCertificateProfileError::NotEe);
} }

View File

@@ -8,7 +8,7 @@ use crate::data_model::oid::{
OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_SIGNED_DATA, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA256_WITH_RSA_ENCRYPTION_RAW, OID_SIGNED_DATA,
OID_SIGNED_DATA_RAW, OID_SUBJECT_INFO_ACCESS, OID_SIGNED_DATA_RAW, OID_SUBJECT_INFO_ACCESS,
}; };
use crate::data_model::rc::{ResourceCertificate, SubjectInfoAccess}; use crate::data_model::rc::{ResourceCertificate, ResourceCertificateRole, SubjectInfoAccess};
use asn1_rs::{Any, Class, FromBer, FromDer as Asn1FromDer, Header, Tag}; use asn1_rs::{Any, Class, FromBer, FromDer as Asn1FromDer, Header, Tag};
use ring::digest; use ring::digest;
use x509_parser::extensions::ParsedExtension; use x509_parser::extensions::ParsedExtension;
@@ -822,6 +822,8 @@ fn validate_ee_certificate(
}; };
} }
}; };
rc.validate_rfc6487_profile(ResourceCertificateRole::SignedObjectEe)
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;
if strict_name { if strict_name {
rc.validate_strict_name_profile() rc.validate_strict_name_profile()
.map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?; .map_err(|e| SignedObjectValidateError::EeCertificateParse(e.to_string()))?;

View File

@@ -5,6 +5,7 @@ use crate::data_model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::data_model::rc::{ use crate::data_model::rc::{
AsIdentifierChoice, IpAddressChoice, ResourceCertKind, ResourceCertificate, AsIdentifierChoice, IpAddressChoice, ResourceCertKind, ResourceCertificate,
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError, ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
ResourceCertificateRole,
}; };
use crate::data_model::tal::Tal; use crate::data_model::tal::Tal;
@@ -218,6 +219,7 @@ impl TaCertificateParsed {
if rc_ca.kind != ResourceCertKind::Ca { if rc_ca.kind != ResourceCertKind::Ca {
return Err(TaCertificateProfileError::NotCa); return Err(TaCertificateProfileError::NotCa);
} }
rc_ca.validate_rfc6487_profile(ResourceCertificateRole::TrustAnchor)?;
if rc_ca.tbs.issuer_name != rc_ca.tbs.subject_name { if rc_ca.tbs.issuer_name != rc_ca.tbs.subject_name {
return Err(TaCertificateProfileError::NotSelfSignedIssuerSubject); return Err(TaCertificateProfileError::NotSelfSignedIssuerSubject);
@@ -244,6 +246,9 @@ pub enum TrustAnchorError {
#[error("TA certificate error: {0} (RFC 8630 §2.3)")] #[error("TA certificate error: {0} (RFC 8630 §2.3)")]
TaCertificate(#[from] TaCertificateDecodeError), TaCertificate(#[from] TaCertificateDecodeError),
#[error("TA certificate self-signature error: {0} (RFC 8630 §2.3)")]
TaSelfSignature(#[from] TaCertificateVerifyError),
#[error("{0}")] #[error("{0}")]
Bind(#[from] TrustAnchorBindError), Bind(#[from] TrustAnchorBindError),
} }
@@ -269,6 +274,7 @@ impl TrustAnchor {
resolved_uri: Option<&Url>, resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorError> { ) -> Result<Self, TrustAnchorError> {
let ta_certificate = TaCertificate::decode_der(ta_der)?; let ta_certificate = TaCertificate::decode_der(ta_der)?;
ta_certificate.verify_self_signature()?;
Ok(Self::bind(tal, ta_certificate, resolved_uri)?) Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
} }
@@ -278,6 +284,7 @@ impl TrustAnchor {
resolved_uri: Option<&Url>, resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorError> { ) -> Result<Self, TrustAnchorError> {
let ta_certificate = TaCertificate::decode_der_with_strict_name(ta_der)?; let ta_certificate = TaCertificate::decode_der_with_strict_name(ta_der)?;
ta_certificate.verify_self_signature()?;
Ok(Self::bind(tal, ta_certificate, resolved_uri)?) Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
} }

View File

@@ -19,7 +19,7 @@ pub use config::{
ALL_COLUMN_FAMILY_NAMES, CF_CHILD_CERTIFICATE_CACHE_PROJECTION, CF_MANIFEST_REPLAY_META, ALL_COLUMN_FAMILY_NAMES, CF_CHILD_CERTIFICATE_CACHE_PROJECTION, CF_MANIFEST_REPLAY_META,
CF_PUBLICATION_POINT_CACHE_PROJECTION, CF_RAW_BY_HASH, CF_REPOSITORY_VIEW, CF_PUBLICATION_POINT_CACHE_PROJECTION, CF_RAW_BY_HASH, CF_REPOSITORY_VIEW,
CF_ROA_CACHE_PROJECTION, CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER, CF_ROA_CACHE_PROJECTION, CF_RRDP_SOURCE, CF_RRDP_SOURCE_MEMBER, CF_RRDP_URI_OWNER,
CF_TRANSPORT_PREFETCH, CF_VCIR, column_family_descriptors, CF_TRANSPORT_PREFETCH, CF_VCIR, CF_VCIR_FAILED_FETCH_REUSE_IDENTITY, column_family_descriptors,
}; };
use keys::*; use keys::*;
use pack::compute_sha256_32; use pack::compute_sha256_32;
@@ -585,6 +585,56 @@ impl VcirInstanceGate {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirFailedFetchReuseIdentity {
#[serde(rename = "c")]
#[serde(with = "serde_bytes_32")]
pub current_ca_sha256: [u8; 32],
#[serde(rename = "t")]
#[serde(with = "serde_bytes_32")]
pub ta_context_digest: [u8; 32],
#[serde(rename = "p")]
#[serde(with = "serde_bytes_32")]
pub parent_context_digest: [u8; 32],
#[serde(rename = "f")]
#[serde(with = "serde_bytes_32")]
pub policy_fingerprint: [u8; 32],
#[serde(rename = "nb")]
pub effective_not_before: PackTime,
#[serde(rename = "nu")]
pub effective_until: PackTime,
}
impl VcirFailedFetchReuseIdentity {
pub fn validate_internal(&self) -> StorageResult<()> {
let effective_not_before = parse_time(
"vcir_failed_fetch_reuse_identity.effective_not_before",
&self.effective_not_before,
)?;
let effective_until = parse_time(
"vcir_failed_fetch_reuse_identity.effective_until",
&self.effective_until,
)?;
if effective_not_before >= effective_until {
return Err(StorageError::InvalidData {
entity: "vcir_failed_fetch_reuse_identity.effective_window",
detail: "effective_not_before must be before effective_until".to_string(),
});
}
Ok(())
}
pub fn contains_validation_time(&self, validation_time: time::OffsetDateTime) -> bool {
let Ok(effective_not_before) = self.effective_not_before.parse() else {
return false;
};
let Ok(effective_until) = self.effective_until.parse() else {
return false;
};
validation_time >= effective_not_before && validation_time < effective_until
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VcirChildEntry { pub struct VcirChildEntry {
pub child_manifest_rsync_uri: String, pub child_manifest_rsync_uri: String,
@@ -1062,6 +1112,7 @@ pub struct RoaCacheObjectMeta {
pub source_object_hash: [u8; 32], pub source_object_hash: [u8; 32],
pub ee_serial: Vec<u8>, pub ee_serial: Vec<u8>,
pub crl_uri: String, pub crl_uri: String,
pub earliest_safe_reuse_time: PackTime,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -1127,6 +1178,8 @@ pub struct RoaCacheObjectProjection {
pub ee_serial: Option<Vec<u8>>, pub ee_serial: Option<Vec<u8>>,
#[serde(rename = "c", default, skip_serializing_if = "Option::is_none")] #[serde(rename = "c", default, skip_serializing_if = "Option::is_none")]
pub crl_uri: Option<String>, pub crl_uri: Option<String>,
#[serde(rename = "n", default, skip_serializing_if = "Option::is_none")]
pub earliest_safe_reuse_time_unix: Option<i64>,
#[serde(rename = "x")] #[serde(rename = "x")]
pub outputs_effective_until_unix: i64, pub outputs_effective_until_unix: i64,
#[serde(rename = "o")] #[serde(rename = "o")]
@@ -1150,6 +1203,15 @@ impl RoaCacheObjectProjection {
if let Some(crl_uri) = &self.crl_uri { if let Some(crl_uri) = &self.crl_uri {
validate_non_empty("roa_cache_projection.entries[].crl_uri", crl_uri)?; validate_non_empty("roa_cache_projection.entries[].crl_uri", crl_uri)?;
} }
if let Some(earliest_safe_reuse_time_unix) = self.earliest_safe_reuse_time_unix
&& earliest_safe_reuse_time_unix >= self.outputs_effective_until_unix
{
return Err(StorageError::InvalidData {
entity: "roa_cache_projection.entries[].effective_window",
detail: "earliest_safe_reuse_time_unix must be before outputs_effective_until_unix"
.to_string(),
});
}
if self.outputs.is_empty() { if self.outputs.is_empty() {
return Err(StorageError::InvalidData { return Err(StorageError::InvalidData {
entity: "roa_cache_projection.entries[]", entity: "roa_cache_projection.entries[]",
@@ -1867,6 +1929,9 @@ impl RoaCacheProjection {
vcir: &ValidatedCaInstanceResult, vcir: &ValidatedCaInstanceResult,
context: Option<&RoaCacheProjectionContext>, context: Option<&RoaCacheProjectionContext>,
) -> StorageResult<Option<Self>> { ) -> StorageResult<Option<Self>> {
let Some(context) = context else {
return Ok(None);
};
let mut issuer_ca_sha256_hex = None; let mut issuer_ca_sha256_hex = None;
let mut crl_sha256_by_uri = Vec::new(); let mut crl_sha256_by_uri = Vec::new();
for artifact in &vcir.related_artifacts { for artifact in &vcir.related_artifacts {
@@ -1893,13 +1958,18 @@ impl RoaCacheProjection {
} }
crl_sha256_by_uri.sort_by(|left, right| left.uri.cmp(&right.uri)); crl_sha256_by_uri.sort_by(|left, right| left.uri.cmp(&right.uri));
let meta_by_uri = context.map(|context| { let meta_by_uri = context
context
.object_meta .object_meta
.iter() .iter()
.map(|meta| (meta.source_object_uri.as_str(), meta)) .map(|meta| (meta.source_object_uri.as_str(), meta))
.collect::<HashMap<_, _>>() .collect::<HashMap<_, _>>();
}); let vcir_validation_time =
vcir.last_successful_validation_time
.parse()
.map_err(|detail| StorageError::InvalidData {
entity: "roa_cache_projection.entries[].earliest_safe_reuse_time_unix",
detail,
})?;
let mut entries: Vec<RoaCacheObjectProjection> = Vec::new(); let mut entries: Vec<RoaCacheObjectProjection> = Vec::new();
let mut entry_index_by_uri: HashMap<String, usize> = HashMap::new(); let mut entry_index_by_uri: HashMap<String, usize> = HashMap::new();
for output in &vcir.local_outputs { for output in &vcir.local_outputs {
@@ -1915,13 +1985,9 @@ impl RoaCacheProjection {
entity: "roa_cache_projection.entries[].outputs_effective_until_unix", entity: "roa_cache_projection.entries[].outputs_effective_until_unix",
detail, detail,
})?; })?;
let meta = meta_by_uri let Some(meta) = meta_by_uri.get(output.source_object_uri.as_str()).copied() else {
.as_ref()
.and_then(|meta| meta.get(output.source_object_uri.as_str()).copied());
if context.is_some() && meta.is_none() {
continue; continue;
} };
if let Some(meta) = meta {
if meta.source_object_hash != output.source_object_hash { if meta.source_object_hash != output.source_object_hash {
return Err(StorageError::InvalidData { return Err(StorageError::InvalidData {
entity: "roa_cache_projection.entries[]", entity: "roa_cache_projection.entries[]",
@@ -1931,7 +1997,14 @@ impl RoaCacheProjection {
), ),
}); });
} }
} let earliest_safe_reuse_time_unix = meta
.earliest_safe_reuse_time
.parse()
.map(|time| time.max(vcir_validation_time).unix_timestamp())
.map_err(|detail| StorageError::InvalidData {
entity: "roa_cache_projection.entries[].earliest_safe_reuse_time_unix",
detail,
})?;
if let Some(entry_index) = entry_index_by_uri.get(output.source_object_uri.as_str()) { if let Some(entry_index) = entry_index_by_uri.get(output.source_object_uri.as_str()) {
let entry = &mut entries[*entry_index]; let entry = &mut entries[*entry_index];
if entry.source_object_hash != output.source_object_hash { if entry.source_object_hash != output.source_object_hash {
@@ -1952,8 +2025,9 @@ impl RoaCacheProjection {
entries.push(RoaCacheObjectProjection { entries.push(RoaCacheObjectProjection {
source_object_uri: output.source_object_uri.clone(), source_object_uri: output.source_object_uri.clone(),
source_object_hash: output.source_object_hash, source_object_hash: output.source_object_hash,
ee_serial: meta.map(|meta| meta.ee_serial.clone()), ee_serial: Some(meta.ee_serial.clone()),
crl_uri: meta.map(|meta| meta.crl_uri.clone()), crl_uri: Some(meta.crl_uri.clone()),
earliest_safe_reuse_time_unix: Some(earliest_safe_reuse_time_unix),
outputs_effective_until_unix: projected_output_effective_until, outputs_effective_until_unix: projected_output_effective_until,
outputs: vec![projected_output], outputs: vec![projected_output],
}); });
@@ -1968,8 +2042,8 @@ impl RoaCacheProjection {
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(), manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
instance_effective_until: vcir.instance_gate.instance_effective_until.clone(), instance_effective_until: vcir.instance_gate.instance_effective_until.clone(),
issuer_ca_sha256_hex, issuer_ca_sha256_hex,
parent_context_digest: context.map(|context| context.parent_context_digest), parent_context_digest: Some(context.parent_context_digest),
policy_fingerprint: context.map(|context| context.policy_fingerprint), policy_fingerprint: Some(context.policy_fingerprint),
crl_sha256_by_uri, crl_sha256_by_uri,
entries, entries,
}; };
@@ -2774,6 +2848,24 @@ fn write_roa_cache_projection_to_batch(
Ok(()) Ok(())
} }
fn write_vcir_failed_fetch_reuse_identity_to_batch(
failed_fetch_reuse_identity_cf: &ColumnFamily,
batch: &mut WriteBatch,
manifest_rsync_uri: &str,
failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>,
) -> StorageResult<()> {
let key = vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri);
match failed_fetch_reuse_identity {
Some(identity) => {
identity.validate_internal()?;
let value = encode_cbor(identity, "vcir_failed_fetch_reuse_identity")?;
batch.put_cf(failed_fetch_reuse_identity_cf, key.as_bytes(), value);
}
None => batch.delete_cf(failed_fetch_reuse_identity_cf, key.as_bytes()),
}
Ok(())
}
fn write_publication_point_cache_projection_to_batch( fn write_publication_point_cache_projection_to_batch(
projection_cf: &ColumnFamily, projection_cf: &ColumnFamily,
batch: &mut WriteBatch, batch: &mut WriteBatch,
@@ -3267,6 +3359,19 @@ impl RocksStore {
self.put_vcir_with_publication_point_cache_projection(vcir, None) self.put_vcir_with_publication_point_cache_projection(vcir, None)
} }
pub fn put_vcir_with_failed_fetch_reuse_identity(
&self,
vcir: &ValidatedCaInstanceResult,
failed_fetch_reuse_identity: &VcirFailedFetchReuseIdentity,
) -> StorageResult<()> {
self.put_vcir_with_projection_action(
vcir,
None,
PublicationPointCacheProjectionWriteAction::Keep,
Some(failed_fetch_reuse_identity),
)
}
pub fn put_vcir_with_publication_point_cache_projection( pub fn put_vcir_with_publication_point_cache_projection(
&self, &self,
vcir: &ValidatedCaInstanceResult, vcir: &ValidatedCaInstanceResult,
@@ -3288,6 +3393,7 @@ impl RocksStore {
vcir, vcir,
roa_cache_context, roa_cache_context,
publication_point_projection_action, publication_point_projection_action,
None,
) )
} }
@@ -3296,9 +3402,11 @@ impl RocksStore {
vcir: &ValidatedCaInstanceResult, vcir: &ValidatedCaInstanceResult,
roa_cache_context: Option<&RoaCacheProjectionContext>, roa_cache_context: Option<&RoaCacheProjectionContext>,
publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>, publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>,
failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>,
) -> StorageResult<()> { ) -> StorageResult<()> {
vcir.validate_internal()?; vcir.validate_internal()?;
let vcir_cf = self.cf(CF_VCIR)?; let vcir_cf = self.cf(CF_VCIR)?;
let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?; let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?; let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?;
let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?; let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?;
@@ -3308,6 +3416,12 @@ impl RocksStore {
let key = vcir_key(&vcir.manifest_rsync_uri); let key = vcir_key(&vcir.manifest_rsync_uri);
let value = encode_cbor(vcir, "vcir")?; let value = encode_cbor(vcir, "vcir")?;
batch.put_cf(vcir_cf, key.as_bytes(), value); batch.put_cf(vcir_cf, key.as_bytes(), value);
write_vcir_failed_fetch_reuse_identity_to_batch(
failed_fetch_reuse_identity_cf,
&mut batch,
&vcir.manifest_rsync_uri,
failed_fetch_reuse_identity,
)?;
let replay_key = manifest_replay_meta_key(&replay_meta.manifest_rsync_uri); let replay_key = manifest_replay_meta_key(&replay_meta.manifest_rsync_uri);
let replay_value = encode_cbor(&replay_meta, "manifest_replay_meta")?; let replay_value = encode_cbor(&replay_meta, "manifest_replay_meta")?;
batch.put_cf(replay_cf, replay_key.as_bytes(), replay_value); batch.put_cf(replay_cf, replay_key.as_bytes(), replay_value);
@@ -3371,6 +3485,21 @@ impl RocksStore {
vcir: &ValidatedCaInstanceResult, vcir: &ValidatedCaInstanceResult,
roa_cache_context: Option<&RoaCacheProjectionContext>, roa_cache_context: Option<&RoaCacheProjectionContext>,
publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>, publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>,
) -> StorageResult<VcirReplaceTimingBreakdown> {
self.replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
vcir,
roa_cache_context,
publication_point_projection_action,
None,
)
}
pub(crate) fn replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
&self,
vcir: &ValidatedCaInstanceResult,
roa_cache_context: Option<&RoaCacheProjectionContext>,
publication_point_projection_action: PublicationPointCacheProjectionWriteAction<'_>,
failed_fetch_reuse_identity: Option<&VcirFailedFetchReuseIdentity>,
) -> StorageResult<VcirReplaceTimingBreakdown> { ) -> StorageResult<VcirReplaceTimingBreakdown> {
let mut timing = VcirReplaceTimingBreakdown { let mut timing = VcirReplaceTimingBreakdown {
rss_before_kb: process_vm_rss_kb(), rss_before_kb: process_vm_rss_kb(),
@@ -3385,6 +3514,7 @@ impl RocksStore {
let batch_build_started = std::time::Instant::now(); let batch_build_started = std::time::Instant::now();
let vcir_cf = self.cf(CF_VCIR)?; let vcir_cf = self.cf(CF_VCIR)?;
let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?; let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?; let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?;
let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?; let pp_projection_cf = self.cf(CF_PUBLICATION_POINT_CACHE_PROJECTION)?;
@@ -3397,6 +3527,12 @@ impl RocksStore {
timing.vcir_value_bytes = vcir_value.len() as u64; timing.vcir_value_bytes = vcir_value.len() as u64;
batch.put_cf(vcir_cf, vcir_key.as_bytes(), vcir_value); batch.put_cf(vcir_cf, vcir_key.as_bytes(), vcir_value);
timing.rss_after_vcir_encode_kb = process_vm_rss_kb(); timing.rss_after_vcir_encode_kb = process_vm_rss_kb();
write_vcir_failed_fetch_reuse_identity_to_batch(
failed_fetch_reuse_identity_cf,
&mut batch,
&vcir.manifest_rsync_uri,
failed_fetch_reuse_identity,
)?;
let replay_meta_encode_started = std::time::Instant::now(); let replay_meta_encode_started = std::time::Instant::now();
let replay_meta = ManifestReplayMeta::from_vcir(vcir); let replay_meta = ManifestReplayMeta::from_vcir(vcir);
@@ -3465,6 +3601,27 @@ impl RocksStore {
Ok(Some(vcir)) Ok(Some(vcir))
} }
pub fn get_vcir_failed_fetch_reuse_identity(
&self,
manifest_rsync_uri: &str,
) -> StorageResult<Option<VcirFailedFetchReuseIdentity>> {
let cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
let key = vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri);
let Some(bytes) = self
.db
.get_cf(cf, key.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?
else {
return Ok(None);
};
let identity = decode_cbor::<VcirFailedFetchReuseIdentity>(
&bytes,
"vcir_failed_fetch_reuse_identity",
)?;
identity.validate_internal()?;
Ok(Some(identity))
}
pub fn get_manifest_replay_meta( pub fn get_manifest_replay_meta(
&self, &self,
manifest_rsync_uri: &str, manifest_rsync_uri: &str,
@@ -4308,11 +4465,18 @@ impl RocksStore {
pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> { pub fn delete_vcir(&self, manifest_rsync_uri: &str) -> StorageResult<()> {
let vcir_cf = self.cf(CF_VCIR)?; let vcir_cf = self.cf(CF_VCIR)?;
let failed_fetch_reuse_identity_cf = self.cf(CF_VCIR_FAILED_FETCH_REUSE_IDENTITY)?;
let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?; let replay_cf = self.cf(CF_MANIFEST_REPLAY_META)?;
let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?; let projection_cf = self.cf(CF_ROA_CACHE_PROJECTION)?;
let mut batch = WriteBatch::default(); let mut batch = WriteBatch::default();
let key = vcir_key(manifest_rsync_uri); let key = vcir_key(manifest_rsync_uri);
batch.delete_cf(vcir_cf, key.as_bytes()); batch.delete_cf(vcir_cf, key.as_bytes());
let failed_fetch_reuse_identity_key =
vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri);
batch.delete_cf(
failed_fetch_reuse_identity_cf,
failed_fetch_reuse_identity_key.as_bytes(),
);
let replay_key = manifest_replay_meta_key(manifest_rsync_uri); let replay_key = manifest_replay_meta_key(manifest_rsync_uri);
batch.delete_cf(replay_cf, replay_key.as_bytes()); batch.delete_cf(replay_cf, replay_key.as_bytes());
let projection_key = roa_cache_projection_key(manifest_rsync_uri); let projection_key = roa_cache_projection_key(manifest_rsync_uri);

View File

@@ -4,6 +4,7 @@ pub const CF_REPOSITORY_VIEW: &str = "repository_view";
pub const CF_RAW_BY_HASH: &str = "raw_by_hash"; pub const CF_RAW_BY_HASH: &str = "raw_by_hash";
pub const CF_RAW_BLOB: &str = "raw_blob"; pub const CF_RAW_BLOB: &str = "raw_blob";
pub const CF_VCIR: &str = "vcir"; pub const CF_VCIR: &str = "vcir";
pub const CF_VCIR_FAILED_FETCH_REUSE_IDENTITY: &str = "vcir_failed_fetch_reuse_identity";
pub const CF_MANIFEST_REPLAY_META: &str = "manifest_replay_meta"; pub const CF_MANIFEST_REPLAY_META: &str = "manifest_replay_meta";
pub const CF_ROA_CACHE_PROJECTION: &str = "roa_cache_projection"; pub const CF_ROA_CACHE_PROJECTION: &str = "roa_cache_projection";
pub const CF_PUBLICATION_POINT_CACHE_PROJECTION: &str = "publication_point_cache_projection"; pub const CF_PUBLICATION_POINT_CACHE_PROJECTION: &str = "publication_point_cache_projection";
@@ -18,6 +19,7 @@ pub const ALL_COLUMN_FAMILY_NAMES: &[&str] = &[
CF_RAW_BY_HASH, CF_RAW_BY_HASH,
CF_RAW_BLOB, CF_RAW_BLOB,
CF_VCIR, CF_VCIR,
CF_VCIR_FAILED_FETCH_REUSE_IDENTITY,
CF_MANIFEST_REPLAY_META, CF_MANIFEST_REPLAY_META,
CF_ROA_CACHE_PROJECTION, CF_ROA_CACHE_PROJECTION,
CF_PUBLICATION_POINT_CACHE_PROJECTION, CF_PUBLICATION_POINT_CACHE_PROJECTION,
@@ -32,6 +34,8 @@ pub(super) const REPOSITORY_VIEW_KEY_PREFIX: &str = "repo_view:";
pub(super) const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:"; pub(super) const RAW_BY_HASH_KEY_PREFIX: &str = "rawbyhash:";
pub(super) const RAW_BLOB_KEY_PREFIX: &str = "rawblob:"; pub(super) const RAW_BLOB_KEY_PREFIX: &str = "rawblob:";
pub(super) const VCIR_KEY_PREFIX: &str = "vcir:"; pub(super) const VCIR_KEY_PREFIX: &str = "vcir:";
pub(super) const VCIR_FAILED_FETCH_REUSE_IDENTITY_KEY_PREFIX: &str =
"vcir_failed_fetch_reuse_identity:";
pub(super) const MANIFEST_REPLAY_META_KEY_PREFIX: &str = "manifest_replay_meta:"; pub(super) const MANIFEST_REPLAY_META_KEY_PREFIX: &str = "manifest_replay_meta:";
pub(super) const ROA_CACHE_PROJECTION_KEY_PREFIX: &str = "roa_cache_projection:"; pub(super) const ROA_CACHE_PROJECTION_KEY_PREFIX: &str = "roa_cache_projection:";
pub(super) const PUBLICATION_POINT_CACHE_PROJECTION_KEY_PREFIX: &str = pub(super) const PUBLICATION_POINT_CACHE_PROJECTION_KEY_PREFIX: &str =

View File

@@ -26,6 +26,10 @@ pub(super) fn vcir_key(manifest_rsync_uri: &str) -> String {
format!("{VCIR_KEY_PREFIX}{manifest_rsync_uri}") format!("{VCIR_KEY_PREFIX}{manifest_rsync_uri}")
} }
pub(super) fn vcir_failed_fetch_reuse_identity_key(manifest_rsync_uri: &str) -> String {
format!("{VCIR_FAILED_FETCH_REUSE_IDENTITY_KEY_PREFIX}{manifest_rsync_uri}")
}
pub(super) fn manifest_replay_meta_key(manifest_rsync_uri: &str) -> String { pub(super) fn manifest_replay_meta_key(manifest_rsync_uri: &str) -> String {
format!("{MANIFEST_REPLAY_META_KEY_PREFIX}{manifest_rsync_uri}") format!("{MANIFEST_REPLAY_META_KEY_PREFIX}{manifest_rsync_uri}")
} }

View File

@@ -284,6 +284,28 @@ fn sample_vcir(manifest_rsync_uri: &str) -> ValidatedCaInstanceResult {
} }
} }
fn roa_cache_projection_context(vcir: &ValidatedCaInstanceResult) -> RoaCacheProjectionContext {
let roa_output = vcir
.local_outputs
.iter()
.find(|output| {
output.output_type == VcirOutputType::Vrp
&& output.source_object_type == VcirSourceObjectType::Roa
})
.expect("sample VCIR has a ROA output");
RoaCacheProjectionContext {
parent_context_digest: [0x31; 32],
policy_fingerprint: [0x32; 32],
object_meta: vec![RoaCacheObjectMeta {
source_object_uri: roa_output.source_object_uri.clone(),
source_object_hash: roa_output.source_object_hash,
ee_serial: vec![0x01],
crl_uri: vcir.current_crl_rsync_uri.clone(),
earliest_safe_reuse_time: vcir.last_successful_validation_time.clone(),
}],
}
}
#[test] #[test]
fn vcir_ccr_manifest_projection_validate_accepts_valid_projection() { fn vcir_ccr_manifest_projection_validate_accepts_valid_projection() {
let projection = sample_ccr_manifest_projection( let projection = sample_ccr_manifest_projection(
@@ -332,7 +354,8 @@ fn vcir_ccr_manifest_projection_validate_rejects_invalid_fields() {
#[test] #[test]
fn roa_cache_projection_from_vcir_keeps_only_roa_vrp_outputs() { fn roa_cache_projection_from_vcir_keeps_only_roa_vrp_outputs() {
let vcir = sample_vcir("rsync://example.test/repo/current.mft"); let vcir = sample_vcir("rsync://example.test/repo/current.mft");
let projection = RoaCacheProjection::from_vcir(&vcir) let context = roa_cache_projection_context(&vcir);
let projection = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context))
.expect("projection build") .expect("projection build")
.expect("projection exists"); .expect("projection exists");
@@ -366,7 +389,8 @@ fn roa_cache_projection_groups_multiple_outputs_by_roa_uri() {
vcir.local_outputs.push(second); vcir.local_outputs.push(second);
vcir.summary.local_vrp_count = 2; vcir.summary.local_vrp_count = 2;
let projection = RoaCacheProjection::from_vcir(&vcir) let context = roa_cache_projection_context(&vcir);
let projection = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context))
.expect("projection build") .expect("projection build")
.expect("projection exists"); .expect("projection exists");
@@ -748,7 +772,8 @@ fn roa_cache_projection_rejects_duplicate_uri_with_different_hash() {
vcir.local_outputs.push(duplicate); vcir.local_outputs.push(duplicate);
vcir.summary.local_vrp_count = 2; vcir.summary.local_vrp_count = 2;
let err = RoaCacheProjection::from_vcir(&vcir) let context = roa_cache_projection_context(&vcir);
let err = RoaCacheProjection::from_vcir_with_context(&vcir, Some(&context))
.expect_err("same ROA URI with different hash must fail"); .expect_err("same ROA URI with different hash must fail");
assert!(err.to_string().contains("source object hash mismatch")); assert!(err.to_string().contains("source object hash mismatch"));
} }
@@ -1206,7 +1231,10 @@ fn vcir_roundtrip_and_validation_failures_are_reported() {
let store = RocksStore::open(td.path()).expect("open rocksdb"); let store = RocksStore::open(td.path()).expect("open rocksdb");
let vcir = sample_vcir("rsync://example.test/repo/current.mft"); let vcir = sample_vcir("rsync://example.test/repo/current.mft");
store.put_vcir(&vcir).expect("put vcir"); let roa_context = roa_cache_projection_context(&vcir);
store
.put_vcir_with_projections(&vcir, Some(&roa_context), None)
.expect("put vcir");
let got = store let got = store
.get_vcir(&vcir.manifest_rsync_uri) .get_vcir(&vcir.manifest_rsync_uri)
.expect("get vcir") .expect("get vcir")
@@ -1446,8 +1474,13 @@ fn replace_vcir_and_manifest_replay_meta_replaces_current_entry() {
previous.summary.local_vrp_count = 1; previous.summary.local_vrp_count = 1;
previous.summary.local_aspa_count = 0; previous.summary.local_aspa_count = 0;
previous.summary.local_router_key_count = 0; previous.summary.local_router_key_count = 0;
let previous_roa_context = roa_cache_projection_context(&previous);
let previous_timing = store let previous_timing = store
.replace_vcir_and_manifest_replay_meta(&previous) .replace_vcir_manifest_replay_meta_and_projections(
&previous,
Some(&previous_roa_context),
None,
)
.expect("store previous vcir"); .expect("store previous vcir");
assert!(previous_timing.vcir_value_bytes > 0); assert!(previous_timing.vcir_value_bytes > 0);
assert!(previous_timing.replay_meta_value_bytes > 0); assert!(previous_timing.replay_meta_value_bytes > 0);

View File

@@ -3,7 +3,8 @@ use crate::data_model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
use crate::data_model::oid::OID_KEY_USAGE_RAW; use crate::data_model::oid::OID_KEY_USAGE_RAW;
use crate::data_model::rc::{ use crate::data_model::rc::{
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertKind, AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertKind,
ResourceCertificate, ResourceCertificateDecodeError, ResourceCertificate, ResourceCertificateDecodeError, ResourceCertificateProfileError,
ResourceCertificateRole,
}; };
use crate::policy::ResourceValidationMode; use crate::policy::ResourceValidationMode;
use x509_parser::prelude::{FromDer, X509Certificate}; use x509_parser::prelude::{FromDer, X509Certificate};
@@ -108,6 +109,12 @@ pub enum CaPathError {
#[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")] #[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
IssuerDecode(ResourceCertificateDecodeError), IssuerDecode(ResourceCertificateDecodeError),
#[error("child CA certificate profile validation failed: {0} (RFC 6487 §4.8)")]
ChildProfile(ResourceCertificateProfileError),
#[error("issuer CA certificate profile validation failed: {0} (RFC 6487 §4.8)")]
IssuerProfile(ResourceCertificateProfileError),
#[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")] #[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")]
CrlDecode(#[from] CrlDecodeError), CrlDecode(#[from] CrlDecodeError),
@@ -244,12 +251,18 @@ pub fn validate_subordinate_ca_cert_with_resource_validation_mode(
if child_ca.kind != ResourceCertKind::Ca { if child_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::ChildNotCa); return Err(CaPathError::ChildNotCa);
} }
child_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::ChildProfile)?;
let issuer_ca = let issuer_ca =
ResourceCertificate::decode_der(issuer_ca_der).map_err(CaPathError::IssuerDecode)?; ResourceCertificate::decode_der(issuer_ca_der).map_err(CaPathError::IssuerDecode)?;
if issuer_ca.kind != ResourceCertKind::Ca { if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::IssuerNotCa); return Err(CaPathError::IssuerNotCa);
} }
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::IssuerProfile)?;
let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?; let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?;
if !x509_names_equivalent(&child_ca.tbs.issuer_name, &issuer_ca.tbs.subject_name) { if !x509_names_equivalent(&child_ca.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
@@ -379,6 +392,12 @@ pub fn validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources(
if issuer_ca.kind != ResourceCertKind::Ca { if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::IssuerNotCa); return Err(CaPathError::IssuerNotCa);
} }
child_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::ChildProfile)?;
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CaPathError::IssuerProfile)?;
if !x509_names_equivalent(&child_ca.tbs.issuer_name, &issuer_ca.tbs.subject_name) { if !x509_names_equivalent(&child_ca.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CaPathError::IssuerSubjectMismatch { return Err(CaPathError::IssuerSubjectMismatch {
@@ -1375,12 +1394,14 @@ fn bytes_leq(a: &[u8], b: &[u8]) -> bool {
mod tests { mod tests {
use super::*; use super::*;
use crate::data_model::common::X509NameDer; use crate::data_model::common::X509NameDer;
use crate::data_model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::data_model::rc::{ use crate::data_model::rc::{
Afi, AsIdOrRange, AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressFamily, Afi, AsIdOrRange, AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressFamily,
IpAddressOrRange, IpPrefix, IpResourceSet, IpAddressOrRange, IpPrefix, IpResourceSet,
}; };
use crate::data_model::rc::{ use crate::data_model::rc::{
RcExtensions, ResourceCertKind, ResourceCertificate, RpkixTbsCertificate, BasicConstraintsProfile, CertificatePoliciesProfile, RcExtensions, ResourceCertKind,
ResourceCertificate, RpkixTbsCertificate,
}; };
use der_parser::num_bigint::BigUint; use der_parser::num_bigint::BigUint;
use std::process::Command; use std::process::Command;
@@ -1410,12 +1431,27 @@ mod tests {
subject_public_key_info: Vec::new(), subject_public_key_info: Vec::new(),
extensions: RcExtensions { extensions: RcExtensions {
basic_constraints_ca: kind == ResourceCertKind::Ca, basic_constraints_ca: kind == ResourceCertKind::Ca,
basic_constraints: (kind == ResourceCertKind::Ca).then_some(
BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
},
),
subject_key_identifier: ski, subject_key_identifier: ski,
authority_key_identifier: aki, authority_key_identifier: aki,
crl_distribution_points_uris: crldp, crl_distribution_points_uris: crldp,
ca_issuers_uris: aia, ca_issuers_uris: aia,
subject_info_access: None, subject_info_access: None,
certificate_policies_oid: None, certificate_policies_oid: (kind == ResourceCertKind::Ca)
.then_some(OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies: (kind == ResourceCertKind::Ca).then_some(
CertificatePoliciesProfile {
policy_oid: OID_CP_IPADDR_ASNUMBER.to_string(),
qualifier_oids: Vec::new(),
},
),
extension_oids: Vec::new(),
ip_resources: None, ip_resources: None,
as_resources: None, as_resources: None,
}, },

View File

@@ -2,6 +2,7 @@ use crate::data_model::common::BigUnsigned;
use crate::data_model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl}; use crate::data_model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
use crate::data_model::rc::{ use crate::data_model::rc::{
ResourceCertKind, ResourceCertificate, ResourceCertificateDecodeError, ResourceCertKind, ResourceCertificate, ResourceCertificateDecodeError,
ResourceCertificateProfileError, ResourceCertificateRole,
}; };
use crate::data_model::signed_object::{EeKeyUsageSummary, ResourceEeCertificate}; use crate::data_model::signed_object::{EeKeyUsageSummary, ResourceEeCertificate};
use ring::signature; use ring::signature;
@@ -34,6 +35,9 @@ pub enum CertPathError {
)] )]
IssuerNotCa, IssuerNotCa,
#[error("issuer CA certificate profile validation failed: {0} (RFC 6487 §4.8)")]
IssuerProfile(ResourceCertificateProfileError),
#[error("EE certificate must be an EE resource certificate (RFC 6487 §4)")] #[error("EE certificate must be an EE resource certificate (RFC 6487 §4)")]
EeNotEe, EeNotEe,
@@ -128,6 +132,9 @@ pub fn validate_ee_cert_path(
if issuer_ca.kind != ResourceCertKind::Ca { if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CertPathError::IssuerNotCa); return Err(CertPathError::IssuerNotCa);
} }
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CertPathError::IssuerProfile)?;
let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?; let issuer_spki = parse_subject_pki_from_der(&issuer_ca.tbs.subject_public_key_info)?;
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) { if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
@@ -311,6 +318,9 @@ fn validate_ee_cert_path_bindings_and_status(
if issuer_ca.kind != ResourceCertKind::Ca { if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CertPathError::IssuerNotCa); return Err(CertPathError::IssuerNotCa);
} }
issuer_ca
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.map_err(CertPathError::IssuerProfile)?;
if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) { if !x509_names_equivalent(&ee.tbs.issuer_name, &issuer_ca.tbs.subject_name) {
return Err(CertPathError::IssuerSubjectMismatch { return Err(CertPathError::IssuerSubjectMismatch {
@@ -515,8 +525,10 @@ fn is_serial_revoked_by_crl(ee: &ResourceCertificate, crl: &RpkixCrl) -> bool {
mod tests { mod tests {
use super::*; use super::*;
use crate::data_model::common::X509NameDer; use crate::data_model::common::X509NameDer;
use crate::data_model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::data_model::rc::{ use crate::data_model::rc::{
RcExtensions, ResourceCertKind, ResourceCertificate, RpkixTbsCertificate, BasicConstraintsProfile, CertificatePoliciesProfile, RcExtensions, ResourceCertKind,
ResourceCertificate, RpkixTbsCertificate,
}; };
use der_parser::num_bigint::BigUint; use der_parser::num_bigint::BigUint;
@@ -545,12 +557,27 @@ mod tests {
subject_public_key_info: Vec::new(), subject_public_key_info: Vec::new(),
extensions: RcExtensions { extensions: RcExtensions {
basic_constraints_ca: kind == ResourceCertKind::Ca, basic_constraints_ca: kind == ResourceCertKind::Ca,
basic_constraints: (kind == ResourceCertKind::Ca).then_some(
BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
},
),
subject_key_identifier: ski, subject_key_identifier: ski,
authority_key_identifier: aki, authority_key_identifier: aki,
crl_distribution_points_uris: crldp, crl_distribution_points_uris: crldp,
ca_issuers_uris: aia, ca_issuers_uris: aia,
subject_info_access: None, subject_info_access: None,
certificate_policies_oid: None, certificate_policies_oid: (kind == ResourceCertKind::Ca)
.then_some(OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies: (kind == ResourceCertKind::Ca).then_some(
CertificatePoliciesProfile {
policy_oid: OID_CP_IPADDR_ASNUMBER.to_string(),
qualifier_oids: Vec::new(),
},
),
extension_oids: Vec::new(),
ip_resources: None, ip_resources: None,
as_resources: None, as_resources: None,
}, },

View File

@@ -506,6 +506,7 @@ pub struct CachedRoaValidationResult {
source_object_hash: [u8; 32], source_object_hash: [u8; 32],
ee_serial: Option<Vec<u8>>, ee_serial: Option<Vec<u8>>,
crl_uri: Option<String>, crl_uri: Option<String>,
earliest_safe_reuse_time_unix: i64,
outputs_effective_until_unix: i64, outputs_effective_until_unix: i64,
outputs: Vec<VcirLocalOutput>, outputs: Vec<VcirLocalOutput>,
} }
@@ -543,6 +544,12 @@ impl RoaValidationCacheView {
} }
for entry in &projection.entries { for entry in &projection.entries {
let Some(earliest_safe_reuse_time_unix) = entry.earliest_safe_reuse_time_unix else {
continue;
};
if time::OffsetDateTime::from_unix_timestamp(earliest_safe_reuse_time_unix).is_err() {
continue;
}
let outputs = entry let outputs = entry
.outputs .outputs
.iter() .iter()
@@ -563,6 +570,7 @@ impl RoaValidationCacheView {
source_object_hash: entry.source_object_hash, source_object_hash: entry.source_object_hash,
ee_serial: entry.ee_serial.clone(), ee_serial: entry.ee_serial.clone(),
crl_uri: entry.crl_uri.clone(), crl_uri: entry.crl_uri.clone(),
earliest_safe_reuse_time_unix,
outputs_effective_until_unix: entry.outputs_effective_until_unix, outputs_effective_until_unix: entry.outputs_effective_until_unix,
outputs, outputs,
}, },
@@ -674,7 +682,9 @@ impl RoaValidationCacheView {
if cached.outputs.is_empty() { if cached.outputs.is_empty() {
return_entry_gate!(RoaCacheLookupResult::Miss); return_entry_gate!(RoaCacheLookupResult::Miss);
} }
if cached.outputs_effective_until_unix <= validation_time.unix_timestamp() { if validation_time.unix_timestamp() < cached.earliest_safe_reuse_time_unix
|| cached.outputs_effective_until_unix <= validation_time.unix_timestamp()
{
return_entry_gate!(RoaCacheLookupResult::ExpiredBlocked); return_entry_gate!(RoaCacheLookupResult::ExpiredBlocked);
} }
@@ -760,6 +770,10 @@ impl RoaValidationCacheView {
source_object_hash: file.sha256, source_object_hash: file.sha256,
ee_serial: ee_serial.clone(), ee_serial: ee_serial.clone(),
crl_uri: crl_uri.to_string(), crl_uri: crl_uri.to_string(),
earliest_safe_reuse_time: PackTime::from_utc_offset_datetime(
time::OffsetDateTime::from_unix_timestamp(cached.earliest_safe_reuse_time_unix)
.expect("cached ROA safe reuse time must be valid"),
),
}), }),
}; };
metrics.materialize_nanos = metrics metrics.materialize_nanos = metrics
@@ -821,6 +835,14 @@ fn crl_valid_at_time(
validation_time >= this_update && validation_time < next_update validation_time >= this_update && validation_time < next_update
} }
fn roa_cache_earliest_safe_reuse_time(
ee_not_before: time::OffsetDateTime,
crl_this_update: time::OffsetDateTime,
validation_time: time::OffsetDateTime,
) -> PackTime {
PackTime::from_utc_offset_datetime(ee_not_before.max(crl_this_update).max(validation_time))
}
fn record_roa_cache_block( fn record_roa_cache_block(
stats: &mut RoaValidationCacheStats, stats: &mut RoaValidationCacheStats,
lookup_result: &RoaCacheLookupResult, lookup_result: &RoaCacheLookupResult,
@@ -2736,6 +2758,11 @@ fn process_roa_with_issuer(
source_object_hash: file.sha256, source_object_hash: file.sha256,
ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be, ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be,
crl_uri: issuer_crl_rsync_uri.to_string(), crl_uri: issuer_crl_rsync_uri.to_string(),
earliest_safe_reuse_time: roa_cache_earliest_safe_reuse_time(
ee.resource_cert.tbs.validity_not_before,
verified_crl.crl.this_update.utc,
validation_time,
),
}; };
if !collect_vcir_local_outputs { if !collect_vcir_local_outputs {
return Ok((vrps, Vec::new(), Some(cache_object_meta))); return Ok((vrps, Vec::new(), Some(cache_object_meta)));
@@ -2866,6 +2893,11 @@ fn process_roa_with_issuer_parallel_cached(
source_object_hash: file.sha256, source_object_hash: file.sha256,
ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be, ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be,
crl_uri: issuer_crl_rsync_uri.clone(), crl_uri: issuer_crl_rsync_uri.clone(),
earliest_safe_reuse_time: roa_cache_earliest_safe_reuse_time(
ee.resource_cert.tbs.validity_not_before,
verified_crl.crl.this_update.utc,
validation_time,
),
}; };
if !collect_vcir_local_outputs { if !collect_vcir_local_outputs {
return Ok((vrps, Vec::new(), Some(cache_object_meta))); return Ok((vrps, Vec::new(), Some(cache_object_meta)));
@@ -3908,6 +3940,7 @@ mod tests {
source_object_hash: roa_hash, source_object_hash: roa_hash,
ee_serial: vec![0x01], ee_serial: vec![0x01],
crl_uri: TEST_CRL_URI.to_string(), crl_uri: TEST_CRL_URI.to_string(),
earliest_safe_reuse_time: vcir.last_successful_validation_time.clone(),
}], }],
}), }),
) )
@@ -4299,7 +4332,7 @@ mod tests {
issuer_der, issuer_der,
crl_hash, crl_hash,
roa_hash, roa_hash,
fixed_time("2026-06-04T00:00:00Z"), fixed_time("2026-06-04T01:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"), fixed_time("2026-06-08T00:00:00Z"),
); );
let projection = sample_roa_cache_projection(&vcir, roa_hash); let projection = sample_roa_cache_projection(&vcir, roa_hash);
@@ -4313,6 +4346,58 @@ mod tests {
)); ));
} }
#[test]
fn roa_validation_cache_view_blocks_before_safe_reuse_time() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_bytes = b"current-crl".to_vec();
let crl_hash = sha256_32(&crl_bytes);
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let mut projection = sample_roa_cache_projection(&vcir, roa_hash);
projection.entries[0].earliest_safe_reuse_time_unix =
Some(fixed_time("2026-06-06T00:00:00Z").unix_timestamp());
let view = RoaValidationCacheView::from_projection(&projection, validation_time);
let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
let mut crl_cache = sample_crl_cache(crl_bytes);
assert!(matches!(
view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
RoaCacheLookupResult::ExpiredBlocked
));
}
#[test]
fn roa_validation_cache_view_treats_legacy_entry_without_lower_bound_as_miss() {
let validation_time = fixed_time("2026-06-05T00:00:00Z");
let issuer_der = b"issuer-ca";
let crl_hash = sha256_32(b"current-crl");
let roa_hash = [0x11; 32];
let vcir = sample_roa_cache_vcir(
issuer_der,
crl_hash,
roa_hash,
fixed_time("2026-06-07T00:00:00Z"),
fixed_time("2026-06-08T00:00:00Z"),
);
let mut projection = sample_roa_cache_projection(&vcir, roa_hash);
projection.entries[0].earliest_safe_reuse_time_unix = None;
let view = RoaValidationCacheView::from_projection(&projection, validation_time);
let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
let mut crl_cache = sample_crl_cache(b"current-crl".to_vec());
assert!(matches!(
view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
RoaCacheLookupResult::Miss
));
}
#[test] #[test]
fn roa_validation_cache_stats_records_vcir_miss_to_timing() { fn roa_validation_cache_stats_records_vcir_miss_to_timing() {
let stats = RoaValidationCacheStats::for_input(RoaValidationCacheInput::enabled(None), 3); let stats = RoaValidationCacheStats::for_input(RoaValidationCacheInput::enabled(None), 3);
@@ -4594,6 +4679,7 @@ mod tests {
source_object_hash: roa_hash, source_object_hash: roa_hash,
ee_serial: Some(vec![0x01]), ee_serial: Some(vec![0x01]),
crl_uri: None, crl_uri: None,
earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
outputs_effective_until_unix, outputs_effective_until_unix,
outputs: vec![output.clone()], outputs: vec![output.clone()],
}, },
@@ -4610,6 +4696,7 @@ mod tests {
source_object_hash: roa_hash, source_object_hash: roa_hash,
ee_serial: None, ee_serial: None,
crl_uri: Some(TEST_CRL_URI.to_string()), crl_uri: Some(TEST_CRL_URI.to_string()),
earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
outputs_effective_until_unix, outputs_effective_until_unix,
outputs: vec![output.clone()], outputs: vec![output.clone()],
}, },
@@ -4626,6 +4713,7 @@ mod tests {
source_object_hash: roa_hash, source_object_hash: roa_hash,
ee_serial: Some(vec![0x01]), ee_serial: Some(vec![0x01]),
crl_uri: Some(TEST_CRL_URI.to_string()), crl_uri: Some(TEST_CRL_URI.to_string()),
earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
outputs_effective_until_unix, outputs_effective_until_unix,
outputs: vec![output.clone()], outputs: vec![output.clone()],
}, },
@@ -4652,6 +4740,7 @@ mod tests {
source_object_hash: [0xff; 32], source_object_hash: [0xff; 32],
ee_serial: Some(vec![0x01]), ee_serial: Some(vec![0x01]),
crl_uri: Some(TEST_CRL_URI.to_string()), crl_uri: Some(TEST_CRL_URI.to_string()),
earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
outputs_effective_until_unix, outputs_effective_until_unix,
outputs: vec![output.clone()], outputs: vec![output.clone()],
}, },
@@ -4668,6 +4757,7 @@ mod tests {
source_object_hash: roa_hash, source_object_hash: roa_hash,
ee_serial: Some(vec![0x01]), ee_serial: Some(vec![0x01]),
crl_uri: Some(TEST_CRL_URI.to_string()), crl_uri: Some(TEST_CRL_URI.to_string()),
earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
outputs_effective_until_unix, outputs_effective_until_unix,
outputs: Vec::new(), outputs: Vec::new(),
}, },
@@ -4688,6 +4778,7 @@ mod tests {
source_object_hash: roa_hash, source_object_hash: roa_hash,
ee_serial: Some(vec![0x01]), ee_serial: Some(vec![0x01]),
crl_uri: Some(TEST_CRL_URI.to_string()), crl_uri: Some(TEST_CRL_URI.to_string()),
earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
outputs_effective_until_unix, outputs_effective_until_unix,
outputs: vec![output], outputs: vec![output],
}, },

View File

@@ -33,8 +33,9 @@ use crate::storage::{
PublicationPointCacheProjectionWriteAction, RawByHashEntry, RoaCacheProjectionContext, PublicationPointCacheProjectionWriteAction, RawByHashEntry, RoaCacheProjectionContext,
RocksStore, ValidatedCaInstanceResult, VcirArtifactKind, VcirArtifactRole, RocksStore, ValidatedCaInstanceResult, VcirArtifactKind, VcirArtifactRole,
VcirArtifactValidationStatus, VcirAuditSummary, VcirCcrManifestProjection, VcirChildEntry, VcirArtifactValidationStatus, VcirAuditSummary, VcirCcrManifestProjection, VcirChildEntry,
VcirInstanceGate, VcirLocalOutput, VcirLocalOutputPayload, VcirOutputType, VcirRelatedArtifact, VcirFailedFetchReuseIdentity, VcirInstanceGate, VcirLocalOutput, VcirLocalOutputPayload,
VcirReplaceTimingBreakdown, VcirSourceObjectType, VcirSummary, VcirOutputType, VcirRelatedArtifact, VcirReplaceTimingBreakdown, VcirSourceObjectType,
VcirSummary,
}; };
use crate::sync::repo::{ use crate::sync::repo::{
sync_publication_point, sync_publication_point_replay, sync_publication_point_replay_delta, sync_publication_point, sync_publication_point_replay, sync_publication_point_replay_delta,
@@ -1751,6 +1752,7 @@ impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
self.store, self.store,
ca, ca,
&fresh_err, &fresh_err,
self.policy,
self.validation_time, self.validation_time,
) )
.map_err(|e| format!("failed fetch VCIR projection failed: {e}"))?; .map_err(|e| format!("failed fetch VCIR projection failed: {e}"))?;
@@ -2314,9 +2316,19 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
let issuer_ca_decode_error: Option<String>; let issuer_ca_decode_error: Option<String>;
let issuer_ca = match crate::data_model::rc::ResourceCertificate::decode_der(issuer_ca_der) { let issuer_ca = match crate::data_model::rc::ResourceCertificate::decode_der(issuer_ca_der) {
Ok(v) => { Ok(v) => {
match v.validate_rfc6487_profile(crate::data_model::rc::ResourceCertificateRole::Ca) {
Ok(()) => {
issuer_ca_decode_error = None; issuer_ca_decode_error = None;
Some(v) Some(v)
} }
Err(e) => {
issuer_ca_decode_error = Some(format!(
"issuer CA profile validation failed: {e} (RFC 6487 §4.8)"
));
None
}
}
}
Err(e) => { Err(e) => {
issuer_ca_decode_error = Some(format!( issuer_ca_decode_error = Some(format!(
"issuer CA decode failed: {e} (RFC 5280 §4.1; RFC 6487 §4)" "issuer CA decode failed: {e} (RFC 5280 §4.1; RFC 6487 §4)"
@@ -4256,6 +4268,7 @@ fn project_current_instance_vcir_on_failed_fetch(
store: &RocksStore, store: &RocksStore,
ca: &CaInstanceHandle, ca: &CaInstanceHandle,
fresh_err: &ManifestFreshError, fresh_err: &ManifestFreshError,
policy: &Policy,
validation_time: time::OffsetDateTime, validation_time: time::OffsetDateTime,
) -> Result<VcirReuseProjection, String> { ) -> Result<VcirReuseProjection, String> {
let mut warnings = Vec::new(); let mut warnings = Vec::new();
@@ -4264,80 +4277,57 @@ fn project_current_instance_vcir_on_failed_fetch(
.get_vcir(&ca.manifest_rsync_uri) .get_vcir(&ca.manifest_rsync_uri)
.map_err(|e| format!("load VCIR failed: {e}"))? .map_err(|e| format!("load VCIR failed: {e}"))?
else { else {
warnings.push( return Ok(failed_fetch_no_cache_projection(
Warning::new(format!("manifest failed fetch: {fresh_err}")) ca,
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) fresh_err,
.with_context(&ca.manifest_rsync_uri), None,
);
warnings.push(
Warning::new(
"no latest validated result for current CA instance; no cached output reused", "no latest validated result for current CA instance; no cached output reused",
) ));
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(&ca.manifest_rsync_uri),
);
return Ok(VcirReuseProjection {
source: PublicationPointSource::FailedFetchNoCache,
vcir: None,
ccr_manifest_projection: None,
snapshot: None,
objects: empty_objects_output(),
child_audits: Vec::new(),
discovered_children: Vec::new(),
warnings,
});
}; };
if !vcir.audit_summary.failed_fetch_eligible { if !vcir.audit_summary.failed_fetch_eligible {
warnings.push( return Ok(failed_fetch_no_cache_projection(
Warning::new(format!("manifest failed fetch: {fresh_err}")) ca,
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) fresh_err,
.with_context(&ca.manifest_rsync_uri), Some(vcir),
);
warnings.push(
Warning::new(
"latest VCIR is not marked failed-fetch eligible; no cached output reused", "latest VCIR is not marked failed-fetch eligible; no cached output reused",
) ));
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(&ca.manifest_rsync_uri),
);
return Ok(VcirReuseProjection {
source: PublicationPointSource::FailedFetchNoCache,
vcir: Some(vcir),
ccr_manifest_projection: None,
snapshot: None,
objects: empty_objects_output(),
child_audits: Vec::new(),
discovered_children: Vec::new(),
warnings,
});
} }
let instance_effective_until = let reuse_identity = match store.get_vcir_failed_fetch_reuse_identity(&ca.manifest_rsync_uri) {
parse_snapshot_time_value(&vcir.instance_gate.instance_effective_until)?; Ok(Some(identity)) => identity,
if validation_time > instance_effective_until { Ok(None) => {
warnings.push( return Ok(failed_fetch_no_cache_projection(
Warning::new(format!("manifest failed fetch: {fresh_err}")) ca,
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) fresh_err,
.with_context(&ca.manifest_rsync_uri), Some(vcir),
); "latest VCIR reuse identity is missing; no cached output or child reused",
warnings.push( ));
Warning::new( }
"latest VCIR instance_gate expired; current instance contributes no cached output", Err(error) => {
) return Ok(failed_fetch_no_cache_projection(
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")]) ca,
.with_context(&ca.manifest_rsync_uri), fresh_err,
); Some(vcir),
return Ok(VcirReuseProjection { &format!(
source: PublicationPointSource::FailedFetchNoCache, "latest VCIR reuse identity is invalid ({error}); no cached output or child reused"
vcir: Some(vcir), ),
ccr_manifest_projection: None, ));
snapshot: None, }
objects: empty_objects_output(), };
child_audits: Vec::new(), if !failed_fetch_reuse_identity_matches_current(
discovered_children: Vec::new(), &reuse_identity,
warnings, &vcir,
}); ca,
policy,
validation_time,
) {
return Ok(failed_fetch_no_cache_projection(
ca,
fresh_err,
Some(vcir),
"latest VCIR reuse identity does not match the current CA context or time window; no cached output or child reused",
));
} }
let ccr_manifest_projection = reuse_ccr_manifest_projection_from_vcir(ca, &vcir)?; let ccr_manifest_projection = reuse_ccr_manifest_projection_from_vcir(ca, &vcir)?;
@@ -4367,6 +4357,73 @@ fn project_current_instance_vcir_on_failed_fetch(
}) })
} }
fn failed_fetch_no_cache_projection(
ca: &CaInstanceHandle,
fresh_err: &ManifestFreshError,
vcir: Option<ValidatedCaInstanceResult>,
reason: &str,
) -> VcirReuseProjection {
let warnings = vec![
Warning::new(format!("manifest failed fetch: {fresh_err}"))
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(&ca.manifest_rsync_uri),
Warning::new(reason)
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(&ca.manifest_rsync_uri),
];
VcirReuseProjection {
source: PublicationPointSource::FailedFetchNoCache,
vcir,
ccr_manifest_projection: None,
snapshot: None,
objects: empty_objects_output(),
child_audits: Vec::new(),
discovered_children: Vec::new(),
warnings,
}
}
fn failed_fetch_reuse_identity_for_fresh_result(
ca: &CaInstanceHandle,
policy: &Policy,
validation_time: time::OffsetDateTime,
effective_until: PackTime,
) -> Result<VcirFailedFetchReuseIdentity, String> {
let current_ca_sha256 = ca.ca_certificate_sha256_32().ok_or_else(|| {
"current CA certificate hash unavailable for VCIR reuse identity".to_string()
})?;
let identity = VcirFailedFetchReuseIdentity {
current_ca_sha256,
ta_context_digest: ta_context_digest_for_ca(ca),
parent_context_digest: parent_context_digest_for_ca(ca),
policy_fingerprint: publication_point_cache_policy_fingerprint(policy),
effective_not_before: PackTime::from_utc_offset_datetime(validation_time),
effective_until,
};
identity
.validate_internal()
.map_err(|error| error.to_string())?;
Ok(identity)
}
fn failed_fetch_reuse_identity_matches_current(
cached: &VcirFailedFetchReuseIdentity,
vcir: &ValidatedCaInstanceResult,
ca: &CaInstanceHandle,
policy: &Policy,
validation_time: time::OffsetDateTime,
) -> bool {
let Some(current_ca_sha256) = ca.ca_certificate_sha256_32() else {
return false;
};
cached.current_ca_sha256 == current_ca_sha256
&& cached.ta_context_digest == ta_context_digest_for_ca(ca)
&& cached.parent_context_digest == parent_context_digest_for_ca(ca)
&& cached.policy_fingerprint == publication_point_cache_policy_fingerprint(policy)
&& cached.effective_until == vcir.instance_gate.instance_effective_until
&& cached.contains_validation_time(validation_time)
}
#[cfg(test)] #[cfg(test)]
fn reconstruct_snapshot_from_vcir( fn reconstruct_snapshot_from_vcir(
store: &RocksStore, store: &RocksStore,
@@ -5280,8 +5337,14 @@ fn persist_vcir_for_fresh_result_with_timing(
.expect("publication point projection must exist when guard is not active"), .expect("publication point projection must exist when guard is not active"),
) )
}; };
let failed_fetch_reuse_identity = failed_fetch_reuse_identity_for_fresh_result(
ca,
policy,
validation_time,
vcir.instance_gate.instance_effective_until.clone(),
)?;
let replace_timing = store let replace_timing = store
.replace_vcir_manifest_replay_meta_and_projection_action( .replace_vcir_manifest_replay_meta_and_projection_action_with_failed_fetch_reuse_identity(
&vcir, &vcir,
Some(&RoaCacheProjectionContext { Some(&RoaCacheProjectionContext {
parent_context_digest: parent_context_digest_for_ca(ca), parent_context_digest: parent_context_digest_for_ca(ca),
@@ -5289,6 +5352,7 @@ fn persist_vcir_for_fresh_result_with_timing(
object_meta: objects.roa_cache_object_meta.clone(), object_meta: objects.roa_cache_object_meta.clone(),
}), }),
publication_point_cache_projection_action, publication_point_cache_projection_action,
Some(&failed_fetch_reuse_identity),
) )
.map_err(|e| format!("store VCIR and manifest replay meta failed: {e}"))?; .map_err(|e| format!("store VCIR and manifest replay meta failed: {e}"))?;
timing.replace_vcir_ms = replace_vcir_started.elapsed().as_millis() as u64; timing.replace_vcir_ms = replace_vcir_started.elapsed().as_millis() as u64;

View File

@@ -753,6 +753,44 @@ fn sample_vcir_for_projection(
} }
} }
fn put_vcir_for_failed_fetch_reuse(
store: &RocksStore,
ca: &CaInstanceHandle,
policy: &Policy,
vcir: &ValidatedCaInstanceResult,
) {
let validation_time = vcir
.last_successful_validation_time
.parse()
.expect("parse VCIR validation time");
let identity = failed_fetch_reuse_identity_for_fresh_result(
ca,
policy,
validation_time,
vcir.instance_gate.instance_effective_until.clone(),
)
.expect("build VCIR failed-fetch reuse identity");
store
.put_vcir_with_failed_fetch_reuse_identity(vcir, &identity)
.expect("put reusable VCIR");
}
fn sample_ca_for_failed_fetch_reuse(vcir: &ValidatedCaInstanceResult) -> CaInstanceHandle {
CaInstanceHandle {
depth: 0,
tal_id: "test-tal".to_string(),
parent_manifest_rsync_uri: None,
ca_certificate: CaCertificateRef::inline_der(Vec::new()),
ca_certificate_rsync_uri: None,
effective_ip_resources: None,
effective_as_resources: None,
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
manifest_rsync_uri: vcir.manifest_rsync_uri.clone(),
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: Some("https://example.test/notify.xml".to_string()),
}
}
#[test] #[test]
fn never_http_fetcher_returns_error() { fn never_http_fetcher_returns_error() {
let f = NeverHttpFetcher; let f = NeverHttpFetcher;
@@ -3827,7 +3865,6 @@ fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() {
let repo_bytes_db = store_dir.path().join("repo-bytes.db"); let repo_bytes_db = store_dir.path().join("repo-bytes.db");
let store = RocksStore::open_with_external_repo_bytes(&main_db, &repo_bytes_db) let store = RocksStore::open_with_external_repo_bytes(&main_db, &repo_bytes_db)
.expect("open rocksdb with external repo bytes"); .expect("open rocksdb with external repo bytes");
store.put_vcir(&vcir).expect("put vcir");
store store
.put_blob_bytes_batch(&[(child_cert_hash.clone(), g.child_ca_der.clone())]) .put_blob_bytes_batch(&[(child_cert_hash.clone(), g.child_ca_der.clone())])
.expect("put child cert repo bytes"); .expect("put child cert repo bytes");
@@ -3852,6 +3889,8 @@ fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() {
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: Some("https://example.test/notify.xml".to_string()), rrdp_notification_uri: Some("https://example.test/notify.xml".to_string()),
}; };
let policy = Policy::default();
put_vcir_for_failed_fetch_reuse(&store, &ca, &policy, &vcir);
let projection = project_current_instance_vcir_on_failed_fetch( let projection = project_current_instance_vcir_on_failed_fetch(
&store, &store,
@@ -3859,6 +3898,7 @@ fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() {
&ManifestFreshError::RepoSyncFailed { &ManifestFreshError::RepoSyncFailed {
detail: "synthetic".to_string(), detail: "synthetic".to_string(),
}, },
&policy,
now, now,
) )
.expect("project vcir"); .expect("project vcir");
@@ -3913,6 +3953,79 @@ fn project_current_instance_vcir_reuses_local_outputs_and_restores_children() {
); );
} }
#[test]
fn project_current_instance_vcir_does_not_reuse_without_identity() {
let now = time::OffsetDateTime::now_utc();
let child_cert_hash = sha256_hex(b"child-cert");
let vcir = sample_vcir_for_projection(now, &child_cert_hash);
let store_dir = tempfile::tempdir().expect("store dir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
store.put_vcir(&vcir).expect("put legacy-style VCIR");
let ca = sample_ca_for_failed_fetch_reuse(&vcir);
let projection = project_current_instance_vcir_on_failed_fetch(
&store,
&ca,
&ManifestFreshError::RepoSyncFailed {
detail: "synthetic".to_string(),
},
&Policy::default(),
now,
)
.expect("project VCIR");
assert_eq!(
projection.source,
PublicationPointSource::FailedFetchNoCache
);
assert!(projection.objects.vrps.is_empty());
assert!(projection.discovered_children.is_empty());
assert!(
projection
.warnings
.iter()
.any(|warning| warning.message.contains("reuse identity is missing"))
);
}
#[test]
fn project_current_instance_vcir_does_not_reuse_when_ta_context_changes() {
let now = time::OffsetDateTime::now_utc();
let child_cert_hash = sha256_hex(b"child-cert");
let vcir = sample_vcir_for_projection(now, &child_cert_hash);
let store_dir = tempfile::tempdir().expect("store dir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
let policy = Policy::default();
let original_ca = sample_ca_for_failed_fetch_reuse(&vcir);
put_vcir_for_failed_fetch_reuse(&store, &original_ca, &policy, &vcir);
let mut changed_ca = original_ca;
changed_ca.tal_id = "different-tal".to_string();
let projection = project_current_instance_vcir_on_failed_fetch(
&store,
&changed_ca,
&ManifestFreshError::RepoSyncFailed {
detail: "synthetic".to_string(),
},
&policy,
now,
)
.expect("project VCIR");
assert_eq!(
projection.source,
PublicationPointSource::FailedFetchNoCache
);
assert!(projection.objects.vrps.is_empty());
assert!(projection.discovered_children.is_empty());
assert!(
projection
.warnings
.iter()
.any(|warning| warning.message.contains("identity does not match"))
);
}
#[test] #[test]
fn project_current_instance_vcir_returns_no_output_when_instance_gate_expired() { fn project_current_instance_vcir_returns_no_output_when_instance_gate_expired() {
let now = time::OffsetDateTime::now_utc(); let now = time::OffsetDateTime::now_utc();
@@ -3950,6 +4063,7 @@ fn project_current_instance_vcir_returns_no_output_when_instance_gate_expired()
&ManifestFreshError::RepoSyncFailed { &ManifestFreshError::RepoSyncFailed {
detail: "synthetic".to_string(), detail: "synthetic".to_string(),
}, },
&Policy::default(),
now, now,
) )
.expect("project vcir"); .expect("project vcir");
@@ -3975,7 +4089,6 @@ fn project_current_instance_vcir_keeps_real_fresh_validation_warning() {
let repo_bytes_db = store_dir.path().join("repo-bytes.db"); let repo_bytes_db = store_dir.path().join("repo-bytes.db");
let store = RocksStore::open_with_external_repo_bytes(&main_db, &repo_bytes_db) let store = RocksStore::open_with_external_repo_bytes(&main_db, &repo_bytes_db)
.expect("open rocksdb with external repo bytes"); .expect("open rocksdb with external repo bytes");
store.put_vcir(&vcir).expect("put vcir");
store store
.put_blob_bytes_batch(&[(child_cert_hash, b"child-cert".to_vec())]) .put_blob_bytes_batch(&[(child_cert_hash, b"child-cert".to_vec())])
.expect("put child cert repo bytes"); .expect("put child cert repo bytes");
@@ -3993,6 +4106,8 @@ fn project_current_instance_vcir_keeps_real_fresh_validation_warning() {
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: None, rrdp_notification_uri: None,
}; };
let policy = Policy::default();
put_vcir_for_failed_fetch_reuse(&store, &ca, &policy, &vcir);
let projection = project_current_instance_vcir_on_failed_fetch( let projection = project_current_instance_vcir_on_failed_fetch(
&store, &store,
@@ -4000,6 +4115,7 @@ fn project_current_instance_vcir_keeps_real_fresh_validation_warning() {
&ManifestFreshError::HashMismatch { &ManifestFreshError::HashMismatch {
rsync_uri: "rsync://example.test/repo/issuer/a.roa".to_string(), rsync_uri: "rsync://example.test/repo/issuer/a.roa".to_string(),
}, },
&policy,
now, now,
) )
.expect("project vcir"); .expect("project vcir");
@@ -4048,6 +4164,7 @@ fn project_current_instance_vcir_returns_no_output_when_latest_result_missing()
&ManifestFreshError::RepoSyncFailed { &ManifestFreshError::RepoSyncFailed {
detail: "synthetic".to_string(), detail: "synthetic".to_string(),
}, },
&Policy::default(),
now, now,
) )
.expect("project without cached vcir"); .expect("project without cached vcir");
@@ -4099,6 +4216,7 @@ fn project_current_instance_vcir_returns_no_output_when_latest_result_is_ineligi
&ManifestFreshError::RepoSyncFailed { &ManifestFreshError::RepoSyncFailed {
detail: "synthetic".to_string(), detail: "synthetic".to_string(),
}, },
&Policy::default(),
now, now,
) )
.expect("project ineligible vcir"); .expect("project ineligible vcir");
@@ -4128,7 +4246,6 @@ fn project_current_instance_vcir_rejects_mismatched_ccr_projection_uri() {
let store_dir = tempfile::tempdir().expect("store dir"); let store_dir = tempfile::tempdir().expect("store dir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb"); let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
store.put_vcir(&vcir).expect("put vcir");
let ca = CaInstanceHandle { let ca = CaInstanceHandle {
depth: 0, depth: 0,
@@ -4143,6 +4260,8 @@ fn project_current_instance_vcir_rejects_mismatched_ccr_projection_uri() {
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(), publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: None, rrdp_notification_uri: None,
}; };
let policy = Policy::default();
put_vcir_for_failed_fetch_reuse(&store, &ca, &policy, &vcir);
let err = project_current_instance_vcir_on_failed_fetch( let err = project_current_instance_vcir_on_failed_fetch(
&store, &store,
@@ -4150,6 +4269,7 @@ fn project_current_instance_vcir_rejects_mismatched_ccr_projection_uri() {
&ManifestFreshError::RepoSyncFailed { &ManifestFreshError::RepoSyncFailed {
detail: "synthetic".to_string(), detail: "synthetic".to_string(),
}, },
&policy,
now, now,
) )
.unwrap_err(); .unwrap_err();

View File

@@ -383,7 +383,7 @@ fn metrics_for(obj_type: ObjType, der: &[u8]) -> Result<Metrics, String> {
ObjType::Cer => { ObjType::Cer => {
let parsed = ResourceCertificate::parse_der(der).map_err(|e| e.to_string())?; let parsed = ResourceCertificate::parse_der(der).map_err(|e| e.to_string())?;
let spki_len = parsed.subject_public_key_info.len() as u32; let spki_len = parsed.subject_public_key_info.len() as u32;
let ext_count = (parsed.extensions.basic_constraints_ca.len() let ext_count = (parsed.extensions.basic_constraints.len()
+ parsed.extensions.subject_key_identifier.len() + parsed.extensions.subject_key_identifier.len()
+ parsed.extensions.authority_key_identifier.len() + parsed.extensions.authority_key_identifier.len()
+ parsed.extensions.crl_distribution_points.len() + parsed.extensions.crl_distribution_points.len()

View File

@@ -25,6 +25,7 @@ fn dummy_ee(
subject_public_key_info: vec![], subject_public_key_info: vec![],
extensions: RcExtensions { extensions: RcExtensions {
basic_constraints_ca: false, basic_constraints_ca: false,
basic_constraints: None,
subject_key_identifier: Some(vec![0x01]), subject_key_identifier: Some(vec![0x01]),
authority_key_identifier: None, authority_key_identifier: None,
crl_distribution_points_uris: None, crl_distribution_points_uris: None,
@@ -35,6 +36,8 @@ fn dummy_ee(
}, },
)), )),
certificate_policies_oid: None, certificate_policies_oid: None,
certificate_policies: None,
extension_oids: Vec::new(),
ip_resources, ip_resources,
as_resources, as_resources,
}, },

View File

@@ -81,6 +81,7 @@ CN = Test Issuer CA
[ v3_issuer_ca ] [ v3_issuer_ca ]
basicConstraints = critical,CA:true basicConstraints = critical,CA:true
keyUsage = critical, keyCertSign, cRLSign keyUsage = critical, keyCertSign, cRLSign
certificatePolicies = critical,1.3.6.1.5.5.7.14.2
subjectKeyIdentifier = hash subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always authorityKeyIdentifier = keyid:always

View File

@@ -88,6 +88,18 @@ fn offline_discovery_from_apnic_tal_and_ta_der_fixture_works() {
} }
} }
#[test]
fn offline_discovery_rejects_tampered_ta_self_signature() {
let tal_bytes = apnic_tal_bytes();
let mut ta_der = apnic_ta_der();
let last = ta_der.last_mut().expect("TA fixture must not be empty");
*last ^= 0x01;
let err = discover_root_ca_instance_from_tal_and_ta_der(&tal_bytes, &ta_der, None)
.expect_err("tampered TA signature must not form a root CA instance");
assert!(err.to_string().contains("self-signature"), "{err}");
}
#[test] #[test]
fn discover_root_from_tal_url_works_with_mock_fetcher() { fn discover_root_from_tal_url_works_with_mock_fetcher() {
let tal_bytes = apnic_tal_bytes(); let tal_bytes = apnic_tal_bytes();

View File

@@ -0,0 +1,131 @@
use der_parser::num_bigint::BigUint;
use rpki::data_model::common::X509NameDer;
use rpki::data_model::oid::{
OID_AUTONOMOUS_SYS_IDS, OID_BASIC_CONSTRAINTS, OID_CERTIFICATE_POLICIES, OID_CP_IPADDR_ASNUMBER,
};
use rpki::data_model::rc::{
AsIdOrRange, AsIdentifierChoice, AsResourceSet, BasicConstraintsProfile,
CertificatePoliciesProfile, RcExtensions, ResourceCertKind, ResourceCertificate,
ResourceCertificateProfileError, ResourceCertificateRole, RpkixTbsCertificate,
};
use time::OffsetDateTime;
fn valid_ca_certificate() -> ResourceCertificate {
let validation_time = OffsetDateTime::from_unix_timestamp(0).expect("valid epoch");
ResourceCertificate {
raw_der: Vec::new(),
kind: ResourceCertKind::Ca,
tbs: RpkixTbsCertificate {
version: 2,
serial_number: BigUint::from(1u32),
signature_algorithm: "1.2.840.113549.1.1.11".to_string(),
issuer_name: X509NameDer(b"CN=issuer".to_vec()),
subject_name: X509NameDer(b"CN=subject".to_vec()),
validity_not_before: validation_time,
validity_not_after: validation_time,
subject_public_key_info: Vec::new(),
extensions: RcExtensions {
basic_constraints_ca: true,
basic_constraints: Some(BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
}),
subject_key_identifier: None,
authority_key_identifier: None,
crl_distribution_points_uris: None,
ca_issuers_uris: None,
subject_info_access: None,
certificate_policies_oid: Some(OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies: Some(CertificatePoliciesProfile {
policy_oid: OID_CP_IPADDR_ASNUMBER.to_string(),
qualifier_oids: Vec::new(),
}),
extension_oids: vec![
OID_BASIC_CONSTRAINTS.to_string(),
OID_CERTIFICATE_POLICIES.to_string(),
],
ip_resources: None,
as_resources: None,
},
},
}
}
#[test]
fn ca_profile_accepts_required_basic_constraints_and_policy() {
valid_ca_certificate()
.validate_rfc6487_profile(ResourceCertificateRole::Ca)
.expect("valid CA profile");
}
#[test]
fn ca_profile_rejects_noncritical_basic_constraints() {
let mut certificate = valid_ca_certificate();
certificate
.tbs
.extensions
.basic_constraints
.as_mut()
.expect("constraints")
.critical = false;
assert!(matches!(
certificate.validate_rfc6487_profile(ResourceCertificateRole::Ca),
Err(ResourceCertificateProfileError::BasicConstraintsCriticality)
));
}
#[test]
fn ca_profile_rejects_unknown_extension() {
let mut certificate = valid_ca_certificate();
certificate
.tbs
.extensions
.extension_oids
.push("1.2.3.4".to_string());
assert!(matches!(
certificate.validate_rfc6487_profile(ResourceCertificateRole::Ca),
Err(ResourceCertificateProfileError::DisallowedExtension { .. })
));
}
#[test]
fn ca_profile_rejects_non_cps_policy_qualifier() {
let mut certificate = valid_ca_certificate();
certificate
.tbs
.extensions
.certificate_policies
.as_mut()
.expect("certificate policies")
.qualifier_oids
.push("1.2.3.4".to_string());
assert!(matches!(
certificate.validate_rfc6487_profile(ResourceCertificateRole::Ca),
Err(ResourceCertificateProfileError::CertificatePoliciesInvalidQualifier(_))
));
}
#[test]
fn ca_profile_rejects_autonomous_system_rdi() {
let mut certificate = valid_ca_certificate();
certificate.tbs.extensions.as_resources = Some(AsResourceSet {
asnum: None,
rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(
64512,
)])),
});
certificate
.tbs
.extensions
.extension_oids
.push(OID_AUTONOMOUS_SYS_IDS.to_string());
assert!(matches!(
certificate.validate_rfc6487_profile(ResourceCertificateRole::Ca),
Err(ResourceCertificateProfileError::AsResourcesRdiPresent)
));
}

View File

@@ -28,6 +28,7 @@ fn dummy_ee(
subject_public_key_info: vec![], subject_public_key_info: vec![],
extensions: RcExtensions { extensions: RcExtensions {
basic_constraints_ca: false, basic_constraints_ca: false,
basic_constraints: None,
subject_key_identifier: Some(vec![0x01]), subject_key_identifier: Some(vec![0x01]),
authority_key_identifier: None, authority_key_identifier: None,
crl_distribution_points_uris: None, crl_distribution_points_uris: None,
@@ -38,6 +39,8 @@ fn dummy_ee(
}, },
)), )),
certificate_policies_oid: None, certificate_policies_oid: None,
certificate_policies: None,
extension_oids: Vec::new(),
ip_resources, ip_resources,
as_resources, as_resources,
}, },

View File

@@ -794,13 +794,22 @@ fn ee_certificate_missing_signed_object_sia_is_rejected() {
)); ));
let err = RpkiSignedObject::decode_der(&so).unwrap_err(); let err = RpkiSignedObject::decode_der(&so).unwrap_err();
assert!(matches!( let rejected_for_sia = matches!(
err, &err,
SignedObjectDecodeError::Validate(SignedObjectValidateError::EeCertificateMissingSia) SignedObjectDecodeError::Validate(SignedObjectValidateError::EeCertificateMissingSia)
| SignedObjectDecodeError::Validate( | SignedObjectDecodeError::Validate(
SignedObjectValidateError::EeCertificateMissingSignedObjectSia SignedObjectValidateError::EeCertificateMissingSignedObjectSia
) )
)); );
let rejected_for_ee_profile = matches!(
&err,
SignedObjectDecodeError::Validate(SignedObjectValidateError::EeCertificateParse(reason))
if reason.contains("basicConstraints must be absent")
);
assert!(
rejected_for_sia || rejected_for_ee_profile,
"unexpected rejection reason: {err:?}"
);
} }
#[test] #[test]
@@ -834,12 +843,21 @@ fn ee_certificate_sia_without_signed_object_access_method_is_rejected() {
vec![si], vec![si],
)); ));
let err = RpkiSignedObject::decode_der(&so).unwrap_err(); let err = RpkiSignedObject::decode_der(&so).unwrap_err();
assert!(matches!( let rejected_for_sia = matches!(
err, &err,
SignedObjectDecodeError::Validate( SignedObjectDecodeError::Validate(
SignedObjectValidateError::EeCertificateMissingSignedObjectSia SignedObjectValidateError::EeCertificateMissingSignedObjectSia
) )
)); );
let rejected_for_ee_profile = matches!(
&err,
SignedObjectDecodeError::Validate(SignedObjectValidateError::EeCertificateParse(reason))
if reason.contains("basicConstraints must be absent")
);
assert!(
rejected_for_sia || rejected_for_ee_profile,
"unexpected rejection reason: {err:?}"
);
} }
#[test] #[test]

View File

@@ -68,12 +68,15 @@ fn ta_certificate_rejects_non_self_signed_ca() {
fn ta_constraints_require_policies_and_ski() { fn ta_constraints_require_policies_and_ski() {
let rc = dummy_rc_ca(RcExtensions { let rc = dummy_rc_ca(RcExtensions {
basic_constraints_ca: true, basic_constraints_ca: true,
basic_constraints: None,
subject_key_identifier: None, subject_key_identifier: None,
authority_key_identifier: None, authority_key_identifier: None,
crl_distribution_points_uris: None, crl_distribution_points_uris: None,
ca_issuers_uris: None, ca_issuers_uris: None,
subject_info_access: None, subject_info_access: None,
certificate_policies_oid: None, certificate_policies_oid: None,
certificate_policies: None,
extension_oids: Vec::new(),
ip_resources: Some(rpki::data_model::rc::IpResourceSet { families: vec![] }), ip_resources: Some(rpki::data_model::rc::IpResourceSet { families: vec![] }),
as_resources: None, as_resources: None,
}); });
@@ -97,12 +100,15 @@ fn ta_constraints_require_non_empty_resources_and_no_inherit() {
// Missing both IP and AS resources. // Missing both IP and AS resources.
let rc = dummy_rc_ca(RcExtensions { let rc = dummy_rc_ca(RcExtensions {
basic_constraints_ca: true, basic_constraints_ca: true,
basic_constraints: None,
subject_key_identifier: Some(vec![1]), subject_key_identifier: Some(vec![1]),
authority_key_identifier: None, authority_key_identifier: None,
crl_distribution_points_uris: None, crl_distribution_points_uris: None,
ca_issuers_uris: None, ca_issuers_uris: None,
subject_info_access: None, subject_info_access: None,
certificate_policies_oid: Some(OID_CP_IPADDR_ASNUMBER.to_string()), certificate_policies_oid: Some(OID_CP_IPADDR_ASNUMBER.to_string()),
certificate_policies: None,
extension_oids: Vec::new(),
ip_resources: None, ip_resources: None,
as_resources: None, as_resources: None,
}); });

View File

@@ -1,4 +1,6 @@
use rpki::data_model::ta::{TrustAnchor, TrustAnchorBindError, TrustAnchorError}; use rpki::data_model::ta::{
TaCertificateVerifyError, TrustAnchor, TrustAnchorBindError, TrustAnchorError,
};
use rpki::data_model::tal::Tal; use rpki::data_model::tal::Tal;
use url::Url; use url::Url;
@@ -76,3 +78,21 @@ fn bind_rejects_resolved_uri_not_listed_in_tal() {
)) ))
)); ));
} }
#[test]
fn bind_rejects_tampered_ta_self_signature() {
let tal_raw =
std::fs::read("tests/fixtures/tal/apnic-rfc7730-https.tal").expect("read TAL fixture");
let tal = Tal::decode_bytes(&tal_raw).expect("decode TAL fixture");
let mut ta_der = std::fs::read("tests/fixtures/ta/apnic-ta.cer").expect("read TA fixture");
let last = ta_der.last_mut().expect("TA fixture must not be empty");
*last ^= 0x01;
assert!(matches!(
TrustAnchor::bind_der(tal, &ta_der, None),
Err(TrustAnchorError::TaSelfSignature(
TaCertificateVerifyError::InvalidSelfSignature(_)
))
));
}