35 Commits

Author SHA1 Message Date
850ff218ce add custom TAL installer patch 2026-08-06 10:48:38 +08:00
9f4f4cf069 20260713 unify installer scripts and RTR registration 2026-07-13 12:46:40 +08:00
b66b425512 20260711 对齐EE证书负例与profile校验 2026-07-12 08:09:59 +08:00
363e69d11d 20260711 修正ROA缓存projection存储测试 2026-07-12 07:40:28 +08:00
da6e2e515d 20260711 修正证书路径测试profile夹具 2026-07-12 07:24:45 +08:00
4a64fa7900 20260711 补充P1缓存与证书profile回归 2026-07-12 07:15:32 +08:00
1ce897dbff 20260711 完善资源证书基础profile校验 2026-07-12 07:04:16 +08:00
08281fa231 20260711 收紧验证缓存复用边界 2026-07-12 06:53:59 +08:00
60796299b0 secure validation cache reuse 2026-07-11 21:57:46 +08:00
6de0bced1c add role-aware resource certificate profile 2026-07-11 21:40:52 +08:00
8e37ef48cb 20260711 验证TAL信任锚自签名 2026-07-11 21:13:52 +08:00
8613cfae60 20260710 完善安装包镜像溯源元数据 2026-07-10 18:13:33 +08:00
f3934c3bf8 20260710 Docker构建固定本地基础镜像 2026-07-10 17:36:55 +08:00
00613afdde 20260710 Docker构建复用本地基础镜像 2026-07-10 16:41:29 +08:00
ea6ac27b8d 20260710 安装包重建提交标识镜像 2026-07-10 16:00:51 +08:00
de4f40eb2c 20260710 rpctl统一实验标识为exp-id 2026-07-10 15:25:37 +08:00
87607a3991 20260710 收敛Docker安装包Compose模板 2026-07-10 14:44:43 +08:00
96d82957a2 20260710 增加四RP本地控制台rpctl 2026-07-10 12:39:24 +08:00
63ac3ee254 20260707 支持本地HTTP root证书 2026-07-07 23:04:44 +08:00
b0f76738e5 20260707 通用化Docker安装包模板命名 2026-07-07 11:10:21 +08:00
c00d9390dc 20260707 安装包默认清理daemon临时目录 2026-07-07 10:26:30 +08:00
a5b194ce35 20260701 add validation-update-03 VRS mode 2026-07-01 20:28:25 +08:00
a501cfb709 20260701 installer接入RTR监控目录 2026-07-01 12:06:49 +08:00
f8988b18cd split metrics installer image 2026-07-01 00:53:31 +08:00
a67640db83 add rtr report metrics 2026-06-30 18:28:52 +08:00
d5270308d6 fix installer image retag 2026-06-30 17:24:36 +08:00
6c45cc94f3 add multi-arch docker installer 2026-06-30 16:18:41 +08:00
371c819126 drop tracked pycache files 2026-06-29 23:25:19 +08:00
a329fed3df fix lifecycle delta count meta 2026-06-29 23:25:19 +08:00
860bad2113 add lifecycle run state 2026-06-29 23:25:19 +08:00
0610a8291c preserve tmp mountpoint on reset 2026-06-29 18:37:14 +08:00
b83acb281b fix tmp failure isolation 2026-06-29 18:37:14 +08:00
8d15188ea8 fix periodic snapshot upgrade env merge 2026-06-29 16:59:00 +08:00
4ca08a12a4 fix periodic snapshot upgrade edge cases 2026-06-29 16:53:10 +08:00
fe8d4fcb14 add periodic snapshot reset 2026-06-29 16:47:47 +08:00
100 changed files with 11692 additions and 3644 deletions

View File

@@ -1,87 +0,0 @@
# ours RP ARM64 Docker Compose 示例配置
# 复制为 .env 后再启动:
# cp .env.example .env
# 业务镜像名称;需要先通过 docker load 导入远端 Docker。
RPKI_IMAGE=ours-rp-runtime-arm64:dev
# 核心 RP 镜像强制以 ARM64 运行;远端233为 x86_64,需要 binfmt/qemu 支持。
RPKI_PLATFORM=linux/arm64
# 验收默认只跑 APNIC;all5 可改成 afrinic,apnic,arin,lacnic,ripe。
RIRS=apnic
# 固定跑两轮:首轮 snapshot,第二轮 delta。
# 负数表示持续运行;ARM64 QEMU 首版验收不建议默认持续运行。
MAX_RUNS=2
# 两轮之间间隔秒数。
INTERVAL_SECS=0
# 保留最近多少个 run 目录。
RETAIN_RUNS=5
# TA/TAL 输入模式:
# - file-with-ta:完全使用镜像内 fixture;
# - file-live-ta:snapshot 轮先完成 live TA 获取,delta 轮后台刷新 TA;
# - url:从 TAL URL 拉取。
TAL_INPUT_MODE=file-live-ta
# rsync 默认按 module-root 批量拉取,和当前 ours RP soak 默认优化配置一致。
RSYNC_SCOPE=module-root
# 容器里不需要杀宿主机上的其他 RP 进程;远端宿主机如有竞争进程,请在宿主机侧处理。
DISABLE_COMPETING_RPS=0
# 运行态数据目录。容器内固定路径,外部由 compose volume 保存。
RUN_ROOT=/var/lib/ours-rp
DB_DIR=/var/lib/ours-rp/state/db
RSYNC_MIRROR_ROOT=/var/lib/ours-rp/state/rsync-mirror
# 每轮结束后清理 daemon 临时目录。
# Docker Compose 中 tmp 是独立 volume 挂载点,不能删除挂载点本身;默认关闭。
CLEAN_TMP_AFTER_RUN=0
# 报告使用 compact JSON,降低写盘体积。
OUTPUT_COMPACT_REPORT=1
# 复用 rsync mirror,避免 delta 每轮从零拉取。
ALLOW_RSYNC_MIRROR_REUSE=1
# 前一轮失败时,新一轮从 snapshot 恢复。
FAILURE_SNAPSHOT_RESET=1
# QEMU 验收优先降低额外统计开销;需要精确 DB 统计时改为 3 或其他正整数。
DB_STATS_EXACT_EVERY=0
# 开启当前主线使用的验证缓存与请求预取能力。
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=1
RPKI_ANALYZE=1
RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation"
# 进度日志阈值。QEMU 下执行较慢,阈值不宜过低。
RPKI_PROGRESS_LOG=1
RPKI_PROGRESS_SLOW_SECS=20
RPKI_PROGRESS_STAGE_FRESH_SLOW_MS=2000
RPKI_PROGRESS_PP_CONTROL_SLOW_MS=200
RPKI_PROGRESS_PP_CACHE_SLOW_MS=100
RPKI_PROGRESS_CONTROL_LOOP_SLOW_MS=2000
# live TA 刷新超时。
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
LIVE_TA_REFRESH_MAX_TIME_SECS=120
# file-live-ta 在 snapshot 轮先完成 live TA 获取再启动子进程,避免首轮使用旧 fixture TA。
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
# metrics sidecar 配置。
METRICS_INSTANCE=remote233-arm64-qemu
METRICS_PORT=9556
METRICS_POLL_SECS=10
# Prometheus / Grafana 配置。
PROMETHEUS_PORT=9090
PROMETHEUS_RETENTION=7d
GRAFANA_PORT=3000
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin

View File

@@ -1,94 +0,0 @@
services:
ours-rp-soak:
image: ${RPKI_IMAGE:-ours-rp-runtime-arm64:dev}
platform: ${RPKI_PLATFORM:-linux/arm64}
container_name: ours-rp-arm64-soak
env_file:
- ./.env
environment:
PACKAGE_ROOT: /opt/ours-rp
ENV_FILE: /opt/ours-rp/.env
RUN_ROOT: /var/lib/ours-rp
BIN_DIR: /opt/ours-rp/bin
FIXTURE_DIR: /opt/ours-rp/fixtures
volumes:
- ./.env:/opt/ours-rp/.env:ro
- rpki-state:/var/lib/ours-rp/state
- rpki-runs:/var/lib/ours-rp/runs
- rpki-logs:/var/lib/ours-rp/logs
- rpki-tmp:/var/lib/ours-rp/tmp
restart: "no"
profiles:
- core
artifact-metrics:
image: ${RPKI_IMAGE:-ours-rp-runtime-arm64:dev}
platform: ${RPKI_PLATFORM:-linux/arm64}
container_name: ours-rp-arm64-artifact-metrics
env_file:
- ./.env
command:
- /opt/ours-rp/bin/rpki_artifact_metrics
- --run-root
- /var/lib/ours-rp
- --listen
- 0.0.0.0:9556
- --poll-secs
- ${METRICS_POLL_SECS:-10}
- --instance
- ${METRICS_INSTANCE:-remote233-arm64-qemu}
ports:
- "${METRICS_PORT:-9556}:9556"
volumes:
- rpki-state:/var/lib/ours-rp/state:ro
- rpki-runs:/var/lib/ours-rp/runs:ro
- rpki-logs:/var/lib/ours-rp/logs:ro
restart: unless-stopped
profiles:
- sidecar
prometheus:
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}
container_name: ours-rp-arm64-prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-7d}
- --web.enable-lifecycle
depends_on:
- artifact-metrics
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
restart: unless-stopped
profiles:
- monitor
grafana:
image: ${GRAFANA_IMAGE:-grafana/grafana:11.3.1}
container_name: ours-rp-arm64-grafana
depends_on:
- prometheus
ports:
- "${GRAFANA_PORT:-3000}:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
restart: unless-stopped
profiles:
- monitor
volumes:
rpki-state:
rpki-runs:
rpki-logs:
rpki-tmp:
prometheus-data:
grafana-data:

View File

@@ -1,875 +0,0 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_cir_trust_anchors",
"legendFormat": "RIRs",
"refId": "A"
}
],
"title": "Current Run RIRs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_run_duration_seconds",
"legendFormat": "wall",
"refId": "A"
}
],
"title": "Latest Wall Time",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_run_max_rss_bytes",
"legendFormat": "rss",
"refId": "A"
}
],
"title": "Latest Max RSS",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_publication_points",
"legendFormat": "publication points",
"refId": "A"
}
],
"title": "Publication Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 4,
"w": 6,
"h": 4
},
"id": 9,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_run_sequence",
"legendFormat": "seq",
"refId": "A"
}
],
"title": "Latest Run Sequence",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 2,
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "orange",
"value": 90
},
{
"color": "green",
"value": 98
}
]
}
},
"overrides": []
},
"gridPos": {
"x": 6,
"y": 4,
"w": 6,
"h": 4
},
"id": 10,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"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)",
"legendFormat": "PP cache hit ratio",
"refId": "A"
}
],
"title": "Latest PP Cache Hit Ratio",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 4,
"w": 6,
"h": 4
},
"id": 11,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_vrps{kind=\"total\"}",
"legendFormat": "VRPs raw",
"refId": "A"
}
],
"title": "VRPs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0
},
"overrides": []
},
"gridPos": {
"x": 18,
"y": 4,
"w": 6,
"h": 4
},
"id": 12,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_vaps",
"legendFormat": "VAPs",
"refId": "A"
}
],
"title": "VAPs",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 8,
"w": 12,
"h": 8
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_duration_seconds",
"legendFormat": "wall",
"refId": "A"
},
{
"expr": "ours_rp_stage_duration_seconds{stage=\"validation\"}",
"legendFormat": "validation",
"refId": "B"
}
],
"title": "Run / Validation Duration",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 8,
"w": 12,
"h": 8
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_vrps{kind=\"total\"}",
"legendFormat": "VRPs raw",
"refId": "A"
},
{
"expr": "ours_rp_vrps{kind=\"unique\"}",
"legendFormat": "VRPs unique",
"refId": "D"
},
{
"expr": "ours_rp_vaps",
"legendFormat": "VAPs",
"refId": "B"
},
{
"expr": "ours_rp_cir_objects",
"legendFormat": "CIR objects",
"refId": "C"
}
],
"title": "Output and Input Sizes",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"decimals": 0,
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 16,
"w": 12,
"h": 8
},
"id": 8,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_large_publication_points",
"legendFormat": "> {{object_count_gt}} objects",
"refId": "A"
}
],
"title": "Large Publication Points by Object Count",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 16,
"w": 12,
"h": 8
},
"id": 13,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"validation\"}",
"legendFormat": "validation",
"refId": "A"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"report_write\"}",
"legendFormat": "report write",
"refId": "E"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"ccr_write\"}",
"legendFormat": "ccr write",
"refId": "F"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"cir_write\"}",
"legendFormat": "cir write",
"refId": "G"
}
],
"title": "Output Stage Durations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "bytes",
"decimals": 2
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 24,
"w": 12,
"h": 8
},
"id": 14,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_max_rss_bytes",
"legendFormat": "Max RSS",
"refId": "A"
}
],
"title": "Max RSS Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "percent",
"decimals": 2,
"min": 0,
"max": 100,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "orange",
"value": 90
},
{
"color": "green",
"value": 98
}
]
}
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 24,
"w": 12,
"h": 8
},
"id": 17,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"min",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"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)",
"legendFormat": "PP cache hit ratio",
"refId": "A"
}
],
"title": "PP Cache Hit Ratio",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "bytes",
"decimals": 2
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 32,
"w": 24,
"h": 8
},
"id": 15,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_state_db_size_bytes",
"legendFormat": "{{db}}",
"refId": "A"
}
],
"title": "State DB Size Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 40,
"w": 24,
"h": 8
},
"id": 16,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_state_db_files",
"legendFormat": "{{db}}",
"refId": "A"
}
],
"title": "State DB File Count Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none",
"decimals": 0,
"min": 0
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 48,
"w": 24,
"h": 8
},
"id": 18,
"options": {
"legend": {
"calcs": [
"lastNotNull",
"max"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "sum by (job, instance, exported_instance) (ours_rp_repo_terminal_state_count{terminal_state=\"fresh\"})",
"legendFormat": "fresh pp",
"refId": "A"
},
{
"expr": "sum by (job, instance, exported_instance) (ours_rp_cir_objects_by_source_type{exported_source=\"fresh\",object_type=\"roa\"})",
"legendFormat": "fresh roa",
"refId": "B"
},
{
"expr": "sum by (job, instance, exported_instance) (ours_rp_cir_objects_by_source_type{exported_source=\"fresh\",object_type=\"manifest\"})",
"legendFormat": "fresh mft",
"refId": "C"
},
{
"expr": "sum by (job, instance, exported_instance) (ours_rp_cir_objects_by_source_type{exported_source=\"fresh\",object_type=\"certificate\"})",
"legendFormat": "fresh crt",
"refId": "D"
},
{
"expr": "sum by (job, instance, exported_instance) (ours_rp_cir_objects_by_source_type{exported_source=\"fresh\",object_type=\"crl\"})",
"legendFormat": "fresh crl",
"refId": "E"
}
],
"title": "Fresh PP / Object Counts by Run",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 40,
"tags": [
"ours-rp",
"rpki",
"soak"
],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Ours RP Soak Overview",
"uid": "ours-rp-soak-overview",
"version": 4,
"weekStart": ""
}

View File

@@ -1,12 +0,0 @@
apiVersion: 1
providers:
- name: ours-rp-arm64
orgId: 1
folder: Ours RP ARM64
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards

View File

@@ -1,77 +0,0 @@
# ours RP ARM64 installer configuration
# 中文说明见 docs/README.zh-CN.md。English guide: docs/README.en.md
# Compose project name.
COMPOSE_PROJECT_NAME=ours-rp-arm64
# Runtime image loaded from images/*.tar.gz by install.sh.
RPKI_IMAGE=ours-rp-runtime-arm64:dev
RPKI_PLATFORM=linux/arm64
# Restart policy for the soak container. Production default keeps the daemon alive.
# For finite acceptance tests such as MAX_RUNS=3, set SOAK_RESTART_POLICY=no to avoid an extra restarted run.
SOAK_RESTART_POLICY=unless-stopped
# Host-side persistent data directory. All state/runs/logs/monitoring data are bind-mounted here.
HOST_DATA_DIR=/var/lib/ours-rp-arm64
# RIR list. Options: afrinic,apnic,arin,lacnic,ripe
RIRS=afrinic,apnic,arin,lacnic,ripe
# Negative MAX_RUNS means keep running forever. Default production interval is 10 minutes.
MAX_RUNS=-1
INTERVAL_SECS=600
RETAIN_RUNS=100
# TAL/TA input mode:
# 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.
# url: pass TAL URL to child process.
TAL_INPUT_MODE=file-live-ta
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
LIVE_TA_REFRESH_MAX_TIME_SECS=120
# Sync and runtime behavior.
RSYNC_SCOPE=module-root
DISABLE_COMPETING_RPS=0
RUN_ROOT=/var/lib/ours-rp
DB_DIR=/var/lib/ours-rp/state/db
RSYNC_MIRROR_ROOT=/var/lib/ours-rp/state/rsync-mirror
CLEAN_TMP_AFTER_RUN=0
OUTPUT_COMPACT_REPORT=1
ALLOW_RSYNC_MIRROR_REUSE=1
FAILURE_SNAPSHOT_RESET=1
DB_STATS_EXACT_EVERY=0
# Validation and performance options aligned with current optimized soak defaults.
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=1
RPKI_ANALYZE=1
RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation"
# Progress logs.
RPKI_PROGRESS_LOG=1
RPKI_PROGRESS_SLOW_SECS=20
RPKI_PROGRESS_STAGE_FRESH_SLOW_MS=2000
RPKI_PROGRESS_PP_CONTROL_SLOW_MS=200
RPKI_PROGRESS_PP_CACHE_SLOW_MS=100
RPKI_PROGRESS_CONTROL_LOOP_SLOW_MS=2000
# Metrics sidecar.
METRICS_INSTANCE=arm64-installer
METRICS_PORT=9556
METRICS_POLL_SECS=10
# Prometheus / Grafana.
# Monitor images are packaged as ARM64 docker-save archives and loaded by install.sh.
MONITOR_PLATFORM=linux/arm64
PROMETHEUS_IMAGE=prom/prometheus:v2.55.1
GRAFANA_IMAGE=grafana/grafana:11.3.1
PROMETHEUS_PORT=9090
PROMETHEUS_RETENTION=7d
GRAFANA_PORT=3000
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
# First snapshot waiting timeout used by start.sh.
FIRST_RUN_WAIT_TIMEOUT_SECS=7200

View File

@@ -1,90 +0,0 @@
services:
ours-rp-soak:
image: ${RPKI_IMAGE:-ours-rp-runtime-arm64:dev}
platform: ${RPKI_PLATFORM:-linux/arm64}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-arm64}-soak
env_file:
- ../.env
environment:
PACKAGE_ROOT: /opt/ours-rp
ENV_FILE: /opt/ours-rp/.env
RUN_ROOT: /var/lib/ours-rp
BIN_DIR: /opt/ours-rp/bin
FIXTURE_DIR: /opt/ours-rp/fixtures
volumes:
- ../.env:/opt/ours-rp/.env:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/state:/var/lib/ours-rp/state
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/runs:/var/lib/ours-rp/runs
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/logs:/var/lib/ours-rp/logs
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/tmp:/var/lib/ours-rp/tmp
restart: ${SOAK_RESTART_POLICY:-unless-stopped}
profiles:
- core
artifact-metrics:
image: ${RPKI_IMAGE:-ours-rp-runtime-arm64:dev}
platform: ${RPKI_PLATFORM:-linux/arm64}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-arm64}-artifact-metrics
env_file:
- ../.env
command:
- /opt/ours-rp/bin/rpki_artifact_metrics
- --run-root
- /var/lib/ours-rp
- --listen
- 0.0.0.0:9556
- --poll-secs
- ${METRICS_POLL_SECS:-10}
- --instance
- ${METRICS_INSTANCE:-arm64-installer}
ports:
- "${METRICS_PORT:-9556}:9556"
volumes:
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/state:/var/lib/ours-rp/state:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/runs:/var/lib/ours-rp/runs:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/logs:/var/lib/ours-rp/logs:ro
restart: unless-stopped
profiles:
- sidecar
prometheus:
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}
platform: ${MONITOR_PLATFORM:-linux/arm64}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-arm64}-prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-7d}
- --web.enable-lifecycle
depends_on:
- artifact-metrics
user: "0:0"
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/prometheus:/prometheus
restart: unless-stopped
profiles:
- monitor
grafana:
image: ${GRAFANA_IMAGE:-grafana/grafana:11.3.1}
platform: ${MONITOR_PLATFORM:-linux/arm64}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-arm64}-grafana
depends_on:
- prometheus
user: "0:0"
ports:
- "${GRAFANA_PORT:-3000}:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}/grafana:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
restart: unless-stopped
profiles:
- monitor

View File

@@ -1,761 +0,0 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_publication_points",
"legendFormat": "publication points",
"refId": "A"
}
],
"title": "Publication Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 0
},
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_ok\"}",
"legendFormat": "rrdp ok",
"refId": "A"
}
],
"title": "RRDP OK Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 0
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "sum(ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_ok\"}) or vector(0)",
"legendFormat": "fallback",
"refId": "A"
}
],
"title": "Rsync Fallback Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 0
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_repo_terminal_state_count{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache",
"refId": "A"
}
],
"title": "Failed No Cache Points",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 4
},
"id": 5,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"repo_sync_total\"}",
"legendFormat": "repo sync total",
"refId": "A"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"rrdp_download_total\"}",
"legendFormat": "rrdp download",
"refId": "B"
},
{
"expr": "ours_rp_run_stage_duration_seconds{stage=\"rsync_download_total\"}",
"legendFormat": "rsync download",
"refId": "C"
}
],
"title": "Repo Sync Download Durations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 12,
"w": 12,
"h": 8
},
"id": 6,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count",
"legendFormat": "{{phase}}",
"refId": "A"
}
],
"title": "Repo Sync Phase Counts",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 12,
"w": 12,
"h": 8
},
"id": 7,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "rrdp failed, rsync ok",
"refId": "A"
},
{
"expr": "ours_rp_repo_sync_phase_count{phase=\"rrdp_failed_rsync_failed\"}",
"legendFormat": "rrdp failed, rsync failed",
"refId": "B"
},
{
"expr": "ours_rp_repo_terminal_state_count{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache",
"refId": "C"
},
{
"expr": "ours_rp_tree_instances{state=\"failed\"}",
"legendFormat": "tree failed",
"refId": "D"
}
],
"title": "Repo Failure / Fallback Counts",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 20,
"w": 12,
"h": 8
},
"id": 8,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_repo_sync_phase_duration_seconds_total{phase=\"rrdp_failed_rsync_ok\"}",
"legendFormat": "rsync fallback duration",
"refId": "A"
},
{
"expr": "ours_rp_repo_sync_phase_duration_seconds_total{phase=\"rrdp_failed_rsync_failed\"}",
"legendFormat": "failed duration",
"refId": "B"
},
{
"expr": "ours_rp_repo_terminal_state_duration_seconds_total{terminal_state=\"failed_no_cache\"}",
"legendFormat": "failed no cache duration",
"refId": "C"
}
],
"title": "Repo Failure / Fallback Durations",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 29,
"w": 12,
"h": 9
},
"id": 9,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "ours_rp_rrdp_rsync_failed_repository_duration_seconds",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "RRDP + Rsync Failed Repositories",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"terminal_state": true,
"rank": true,
"transport": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"phase": 2,
"uri": 3,
"Value": 4
},
"renameByName": {
"Value": "duration"
}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 12,
"y": 29,
"w": 12,
"h": 9
},
"id": 11,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "topk(20, ours_rp_top_repository_sync_duration_seconds)",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Top 20 Repositories by Sync Duration",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"terminal_state": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"phase": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"rank": 2,
"transport": 3,
"uri": 4,
"Value": 5
},
"renameByName": {
"Value": "value"
}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "none"
},
"overrides": []
},
"gridPos": {
"x": 0,
"y": 38,
"w": 24,
"h": 9
},
"id": 10,
"options": {
"showHeader": true,
"cellHeight": "sm",
"footer": {
"show": false,
"reducer": [
"sum"
],
"countRows": false,
"fields": ""
}
},
"pluginVersion": "11.3.1",
"targets": [
{
"expr": "topk(20, ours_rp_top_publication_point_object_count)",
"format": "table",
"instant": true,
"legendFormat": "",
"refId": "A"
}
],
"title": "Top Publication Points by Objects",
"type": "table",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"job": true,
"__name__": true,
"publication_points": true,
"instance": true,
"repo_id": true,
"phase": true,
"pp_id": true,
"exported_instance": true,
"rp": true,
"source": true
},
"indexByName": {
"Time": 0,
"host": 1,
"rank": 2,
"terminal_state": 3,
"transport": 4,
"uri": 5,
"Value": 6
},
"renameByName": {}
}
}
]
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"description": "Per-repository sync success in the latest successful run; 1 means successful, 0 means failed or failed_no_cache.",
"fieldConfig": {
"defaults": {
"unit": "bool"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 47
},
"id": 12,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"expr": "ours_rp_repository_sync_success",
"legendFormat": "{{host}} {{repo_id}}",
"refId": "A"
}
],
"title": "Repository Sync Success by Repo",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"description": "Per-repository total sync duration aggregated from publication point repo_sync_duration_ms.",
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 55
},
"id": 13,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"expr": "ours_rp_repository_sync_duration_seconds{stat=\"sum\"}",
"legendFormat": "{{host}} {{repo_id}}",
"refId": "A"
}
],
"title": "Repository Sync Duration by Repo",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"description": "Per-repository downloaded bytes attributed from report.json downloads events.",
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 63
},
"id": 14,
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"expr": "ours_rp_repository_download_bytes",
"legendFormat": "{{host}} {{repo_id}}",
"refId": "A"
}
],
"title": "Repository Download Bytes by Repo",
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 40,
"tags": [
"ours-rp",
"rpki",
"soak",
"repo-sync"
],
"templating": {
"list": []
},
"time": {
"from": "now-30m",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Ours RP Repo Sync",
"uid": "ours-rp-repo-sync",
"version": 3,
"weekStart": ""
}

View File

@@ -1,10 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true

View File

@@ -1,13 +0,0 @@
global:
scrape_interval: 5s
evaluation_interval: 5s
scrape_configs:
- job_name: ours-rp-artifact-metrics
metrics_path: /metrics
static_configs:
- targets:
- artifact-metrics:9556
labels:
rp: ours-rp
source: arm64-compose-artifact-sidecar

View File

@@ -1,95 +0,0 @@
# ours RP ARM64 Installer Guide
## Goal
This package deploys ours RP on a `linux/arm64` server with Docker Compose and continuously runs all five RIR validation.
The package includes the ours RP ARM64 runtime image, Prometheus ARM64 image and Grafana ARM64 image, so deployment does not pull application images on the target host. Runtime state, run artifacts, logs, Prometheus data and Grafana data are persisted through host bind mounts.
## Quick Start
```bash
tar -xzf ours-rp-arm64-installer-*.tar.gz
cd ours-rp-arm64-installer-*
./install.sh
cp .env.example .env # install.sh creates .env automatically if missing
vim .env
./start.sh
./status.sh
```
Defaults:
- `RIRS=afrinic,apnic,arin,lacnic,ripe`
- `MAX_RUNS=-1`
- `INTERVAL_SECS=600`
- `TAL_INPUT_MODE=file-live-ta`
- `LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1`
- `HOST_DATA_DIR=/var/lib/ours-rp-arm64`
- `SOAK_RESTART_POLICY=unless-stopped`
- `MONITOR_PLATFORM=linux/arm64`
## First Start Semantics
If there is no successful run under `HOST_DATA_DIR/runs`, `start.sh` starts the core `ours-rp-soak` service first and waits for the first snapshot to succeed before starting metrics, Prometheus and Grafana.
The first snapshot refreshes live TA certificates before starting the RP process.
## Ports
Default ports:
- metrics: `http://<host>:9556/metrics`
- Prometheus: `http://<host>:9090`
- Grafana: `http://<host>:3000`
Grafana credentials come from `.env`:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
Change the password and restrict public access for production deployments.
## Data Directory
Default host directory:
```text
/var/lib/ours-rp-arm64/
state/
runs/
logs/
tmp/
prometheus/
grafana/
```
Each `runs/run_XXXX/` directory contains `report.json`, `result.ccr`, `input.cir`, `vrps.csv`, `vaps.csv`, `stage-timing.json`, logs and metadata.
## Common Commands
```bash
./status.sh
./logs.sh ours-rp-soak --tail 200
./restart.sh
./stop.sh
./cleanup.sh --keep-runs 100 --execute
./uninstall.sh
```
`uninstall.sh` keeps data by default. Use the following only when you really want to delete `HOST_DATA_DIR`:
```bash
./uninstall.sh --purge-data
```
For finite acceptance tests, for example `MAX_RUNS=3`, also set:
```bash
SOAK_RESTART_POLICY=no
```
Otherwise Compose `unless-stopped` will restart the container after it exits successfully.

View File

@@ -1,97 +0,0 @@
# ours RP ARM64 安装包使用说明
## 目标
本安装包用于在 `linux/arm64` 服务器上通过 Docker Compose 部署 ours RP,并持续运行 all5 RIR 同步验证任务。
安装包内置 ours RP ARM64 runtime、Prometheus ARM64、Grafana ARM64 镜像,部署时不需要现场拉取应用镜像。运行产物、状态数据库、日志、Prometheus 和 Grafana 数据均通过宿主机目录挂载保存。
## 快速开始
```bash
tar -xzf ours-rp-arm64-installer-*.tar.gz
cd ours-rp-arm64-installer-*
./install.sh
cp .env.example .env # 如 install.sh 已自动创建,可直接编辑现有 .env
vim .env
./start.sh
./status.sh
```
默认配置:
- `RIRS=afrinic,apnic,arin,lacnic,ripe`
- `MAX_RUNS=-1`
- `INTERVAL_SECS=600`
- `TAL_INPUT_MODE=file-live-ta`
- `LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1`
- `HOST_DATA_DIR=/var/lib/ours-rp-arm64`
- `SOAK_RESTART_POLICY=unless-stopped`
- `MONITOR_PLATFORM=linux/arm64`
## 首次启动语义
如果 `HOST_DATA_DIR/runs` 下没有成功 run,`start.sh` 会先启动核心 `ours-rp-soak`,等待第一轮 snapshot 成功后再启动 metrics、Prometheus 和 Grafana。
第一轮 snapshot 会先拉取 live TA,避免 clean state 使用旧 fixture TA。
## 访问端口
默认端口:
- metrics: `http://<host>:9556/metrics`
- Prometheus: `http://<host>:9090`
- Grafana: `http://<host>:3000`
Grafana 默认账号密码来自 `.env`:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
生产部署时应修改密码并限制外部访问。
## 数据目录
默认宿主机目录:
```text
/var/lib/ours-rp-arm64/
state/
runs/
logs/
tmp/
prometheus/
grafana/
```
`runs/run_XXXX/` 中包含每轮 `report.json`、`result.ccr`、`input.cir`、`vrps.csv`、`vaps.csv`、`stage-timing.json`、日志和元数据。
## 常用命令
```bash
./status.sh
./logs.sh ours-rp-soak --tail 200
./restart.sh
./stop.sh
./cleanup.sh --keep-runs 100 --execute
./uninstall.sh
```
如果做有限轮次验收,例如 `MAX_RUNS=3`,建议同时设置:
```bash
SOAK_RESTART_POLICY=no
```
否则 Compose 的 `unless-stopped` 策略会在容器正常退出后再次拉起下一轮。
`uninstall.sh` 默认不删除数据。只有显式执行:
```bash
./uninstall.sh --purge-data
```
才会删除 `HOST_DATA_DIR`。

View File

@@ -1,68 +0,0 @@
# Operations Guide
## Install
```bash
./install.sh
```
The installer is idempotent:
- existing `.env` is kept;
- existing Docker/Compose installation is reused;
- repeated loading of packaged ours RP, Prometheus and Grafana ARM64 images is safe;
- existing data directory is reused.
## Start
```bash
./start.sh
```
Start without waiting for the first snapshot:
```bash
./start.sh --no-wait-first-run
```
## Stop and Restart
```bash
./stop.sh
./restart.sh
```
## Status Checks
```bash
./status.sh
./self-check.sh
```
Important checks:
- Docker/Compose availability;
- runtime, Prometheus and Grafana images exist;
- `HOST_DATA_DIR` is writable;
- Compose config is valid;
- latest run status;
- metrics, Prometheus and Grafana endpoints.
## Upgrade
Extract the new package, reuse the existing `.env` and `HOST_DATA_DIR`, then run:
```bash
./upgrade.sh
```
Upgrade does not delete runtime data.
## Cleanup
```bash
./cleanup.sh --keep-runs 100
./cleanup.sh --keep-runs 100 --execute
```
Cleanup is dry-run by default. Add `--execute` after reviewing the output.

View File

@@ -1,68 +0,0 @@
# 运维手册
## 安装
```bash
./install.sh
```
安装脚本是幂等的:
- 已有 `.env` 不覆盖;
- 已安装 Docker/Compose 则跳过;
- 包内 ours RP、Prometheus、Grafana ARM64 镜像重复加载是安全的;
- 数据目录已存在则复用。
## 启动
```bash
./start.sh
```
如需后台启动后不等待首轮 snapshot:
```bash
./start.sh --no-wait-first-run
```
## 停止和重启
```bash
./stop.sh
./restart.sh
```
## 状态检查
```bash
./status.sh
./self-check.sh
```
重点检查项:
- Docker/Compose 可用;
- runtime、Prometheus、Grafana 镜像存在;
- `HOST_DATA_DIR` 可写;
- Compose 配置合法;
- 最新 run 状态;
- metrics、Prometheus、Grafana endpoint。
## 升级
把新安装包解压到新目录后,可以复用旧 `.env` 和 `HOST_DATA_DIR`。执行:
```bash
./upgrade.sh
```
升级不会删除运行数据。
## 清理
```bash
./cleanup.sh --keep-runs 100
./cleanup.sh --keep-runs 100 --execute
```
默认 dry-run,确认后加 `--execute`。

View File

@@ -1,68 +0,0 @@
# Troubleshooting
## Docker or Compose Is Unavailable
Run:
```bash
docker version
docker compose version
```
If missing, run:
```bash
./install.sh
```
## ARM64 Image Cannot Run
Running ARM64 images on x86_64 requires binfmt/qemu:
```bash
docker run --rm --privileged tonistiigi/binfmt --install arm64
docker run --rm --platform linux/arm64 debian:bookworm-slim uname -m
```
Expected output: `aarch64`.
## First Snapshot Times Out
All-five snapshot can be slow, especially under QEMU. Increase timeout:
```bash
./start.sh --timeout-secs 14400
```
## Output Counts Are Too Low
Check:
```bash
grep LIVE_TA_REFRESH_BEFORE_SNAPSHOT .env
ls -l /var/lib/ours-rp-arm64/state/live-ta
tail -100 /var/lib/ours-rp-arm64/logs/live-ta-refresh-*.log
```
In `file-live-ta` mode, snapshot should wait until live TA refresh succeeds.
## Grafana Login Fails
Check `.env`:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
If Grafana has already started, changing `.env` may not reset the existing Grafana database. Stop services and back up/clean `${HOST_DATA_DIR}/grafana` if needed.
## A Finite Acceptance Test Starts an Extra Run
If `.env` sets a finite `MAX_RUNS=3` while `SOAK_RESTART_POLICY=unless-stopped`, Docker Compose restarts the soak container after it exits successfully.
For finite tests, set:
```bash
SOAK_RESTART_POLICY=no
```

View File

@@ -1,68 +0,0 @@
# 故障排查
## Docker 或 Compose 不可用
执行:
```bash
docker version
docker compose version
```
如果缺失,重新执行:
```bash
./install.sh
```
## ARM64 镜像无法运行
在 x86_64 机器上运行 ARM64 镜像需要 binfmt/qemu:
```bash
docker run --rm --privileged tonistiigi/binfmt --install arm64
docker run --rm --platform linux/arm64 debian:bookworm-slim uname -m
```
预期输出 `aarch64`。
## 首轮 snapshot 超时
all5 snapshot 可能很慢,尤其在 QEMU 环境。可以提高超时:
```bash
./start.sh --timeout-secs 14400
```
## 产物数量异常偏低
检查:
```bash
grep LIVE_TA_REFRESH_BEFORE_SNAPSHOT .env
ls -l /var/lib/ours-rp-arm64/state/live-ta
tail -100 /var/lib/ours-rp-arm64/logs/live-ta-refresh-*.log
```
`file-live-ta` 模式下,snapshot 应等待 live TA 成功刷新。
## Grafana 无法登录
确认 `.env` 中:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
如果曾经启动过 Grafana,修改 `.env` 不一定重置已有 Grafana 数据库账号。可以停止服务后按需备份并清理 `${HOST_DATA_DIR}/grafana`。
## 有限轮次验收后又多跑了一轮
如果 `.env` 中设置了 `MAX_RUNS=3` 这类有限轮次,同时 `SOAK_RESTART_POLICY=unless-stopped`,Docker Compose 会在 soak 容器正常退出后重新启动容器。
有限验收建议设置:
```bash
SOAK_RESTART_POLICY=no
```

View File

@@ -1,265 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ENV_FILE:-$INSTALLER_ROOT/.env}"
ENV_EXAMPLE="$INSTALLER_ROOT/.env.example"
COMPOSE_FILE="$INSTALLER_ROOT/compose/docker-compose.yml"
log() {
printf '[ours-rp-installer] %s\n' "$*"
}
warn() {
printf '[ours-rp-installer][WARN] %s\n' "$*" >&2
}
die() {
printf '[ours-rp-installer][ERROR] %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
load_env() {
if [[ ! -f "$ENV_FILE" ]]; then
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
cp "$ENV_EXAMPLE" "$ENV_FILE"
log "created .env from .env.example"
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
HOST_DATA_DIR="${HOST_DATA_DIR:-/var/lib/ours-rp-arm64}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ours-rp-arm64}"
RPKI_IMAGE="${RPKI_IMAGE:-ours-rp-runtime-arm64:dev}"
RPKI_PLATFORM="${RPKI_PLATFORM:-linux/arm64}"
MONITOR_PLATFORM="${MONITOR_PLATFORM:-linux/arm64}"
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
FIRST_RUN_WAIT_TIMEOUT_SECS="${FIRST_RUN_WAIT_TIMEOUT_SECS:-7200}"
}
compose_cmd() {
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" -p "${COMPOSE_PROJECT_NAME:-ours-rp-arm64}" "$@"
}
create_data_dirs() {
load_env
mkdir -p \
"$HOST_DATA_DIR/state" \
"$HOST_DATA_DIR/runs" \
"$HOST_DATA_DIR/logs" \
"$HOST_DATA_DIR/tmp" \
"$HOST_DATA_DIR/prometheus" \
"$HOST_DATA_DIR/grafana"
chmod 755 "$HOST_DATA_DIR" "$HOST_DATA_DIR/state" "$HOST_DATA_DIR/runs" "$HOST_DATA_DIR/logs" "$HOST_DATA_DIR/tmp" || true
chmod 777 "$HOST_DATA_DIR/prometheus" "$HOST_DATA_DIR/grafana" || true
}
latest_run_dir() {
load_env
find "$HOST_DATA_DIR/runs" -maxdepth 1 -mindepth 1 -type d -name 'run_*' 2>/dev/null | sort | tail -1
}
latest_success_run_dir() {
load_env
find "$HOST_DATA_DIR/runs" -maxdepth 2 -type f -path '*/run-summary.json' 2>/dev/null \
| while read -r summary; do
if jq -e '.status == "success"' "$summary" >/dev/null 2>&1; then
dirname "$summary"
fi
done | sort | tail -1
}
has_success_run() {
[[ -n "$(latest_success_run_dir)" ]]
}
print_run_summary() {
local run_dir="$1"
local summary="$run_dir/run-summary.json"
local meta="$run_dir/run-meta.json"
local timing="$run_dir/stage-timing.json"
local process_time="$run_dir/process-time.txt"
local vrps_file="$run_dir/vrps.csv"
local vaps_file="$run_dir/vaps.csv"
local status="unknown"
local sync_mode="unknown"
local wall_ms="null"
local validation_ms="null"
local repo_sync_ms="null"
local max_rss_kb="null"
local publication_points="null"
local vrps="null"
local vaps="null"
local warnings="null"
[[ -f "$summary" ]] || {
warn "missing run-summary.json in $run_dir"
return 1
}
status="$(jq -r '.status // "unknown"' "$summary" 2>/dev/null || echo unknown)"
wall_ms="$(jq -r '.wallMs // .wall_ms // "null"' "$summary" 2>/dev/null || echo null)"
warnings="$(jq -r '.warningCount // .warnings // "null"' "$summary" 2>/dev/null || echo null)"
if [[ -f "$meta" ]]; then
sync_mode="$(jq -r '.sync_mode // .syncMode // "unknown"' "$meta" 2>/dev/null || echo unknown)"
status="$(jq -r --arg fallback "$status" '.status // $fallback' "$meta" 2>/dev/null || echo "$status")"
fi
if [[ -f "$timing" ]]; then
validation_ms="$(jq -r '.validation_ms // "null"' "$timing" 2>/dev/null || echo null)"
repo_sync_ms="$(jq -r '.repo_sync_ms_total // "null"' "$timing" 2>/dev/null || echo null)"
publication_points="$(jq -r '.publication_points // "null"' "$timing" 2>/dev/null || echo null)"
fi
if [[ -f "$process_time" ]]; then
max_rss_kb="$(awk -F': ' '/Maximum resident set size/ {print $2; found=1} END {if (!found) print "null"}' "$process_time")"
fi
if [[ -f "$vrps_file" ]]; then
vrps="$(( $(wc -l < "$vrps_file") > 0 ? $(wc -l < "$vrps_file") - 1 : 0 ))"
fi
if [[ -f "$vaps_file" ]]; then
vaps="$(( $(wc -l < "$vaps_file") > 0 ? $(wc -l < "$vaps_file") - 1 : 0 ))"
fi
jq -n \
--arg run "$(basename "$run_dir")" \
--arg status "$status" \
--arg syncMode "$sync_mode" \
--argjson wallMs "$wall_ms" \
--argjson validationMs "$validation_ms" \
--argjson repoSyncMs "$repo_sync_ms" \
--argjson maxRssKb "$max_rss_kb" \
--argjson vrps "$vrps" \
--argjson vaps "$vaps" \
--argjson publicationPoints "$publication_points" \
--argjson warnings "$warnings" \
'{run:$run,status:$status,syncMode:$syncMode,wallMs:$wallMs,validationMs:$validationMs,repoSyncMs:$repoSyncMs,maxRssKb:$maxRssKb,vrps:$vrps,vaps:$vaps,publicationPoints:$publicationPoints,warnings:$warnings}'
}
wait_for_new_success_run() {
local before_latest="$1"
local timeout_secs="$2"
local start_epoch now run_dir summary meta status meta_status
start_epoch="$(date +%s)"
while true; do
run_dir="$(latest_run_dir || true)"
if [[ -n "$run_dir" && "$run_dir" != "$before_latest" ]]; then
summary="$run_dir/run-summary.json"
meta="$run_dir/run-meta.json"
if [[ -f "$summary" ]]; then
status="$(jq -r '.status // "unknown"' "$summary" 2>/dev/null || echo unknown)"
if [[ "$status" == "success" ]]; then
meta_status="unknown"
if [[ -f "$meta" ]]; then
meta_status="$(jq -r '.status // "unknown"' "$meta" 2>/dev/null || echo unknown)"
fi
if [[ "$meta_status" == "success" ]]; then
print_run_summary "$run_dir" || true
return 0
fi
fi
if [[ "$status" == "failed" || "$status" == "error" ]]; then
print_run_summary "$run_dir" || true
die "run failed: $run_dir"
fi
fi
fi
now="$(date +%s)"
if (( now - start_epoch > timeout_secs )); then
die "timed out waiting for first successful run after ${timeout_secs}s"
fi
sleep 10
done
}
docker_compose_available() {
docker compose version >/dev/null 2>&1
}
install_docker_if_missing() {
if command -v docker >/dev/null 2>&1 && docker_compose_available && command -v jq >/dev/null 2>&1 && command -v rsync >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then
log "docker and docker compose are already installed"
return 0
fi
if [[ "${SKIP_DEP_INSTALL:-0}" == "1" ]]; then
die "docker/docker compose missing and SKIP_DEP_INSTALL=1"
fi
if ! command -v apt-get >/dev/null 2>&1; then
die "docker/docker compose missing; automatic install currently supports apt-get only"
fi
log "installing missing runtime packages via apt"
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl jq rsync gzip tar docker.io
if ! docker_compose_available; then
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-v2
elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-plugin
elif apt-cache show docker-compose >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose
fi
fi
systemctl enable --now docker >/dev/null 2>&1 || true
docker_compose_available || die "docker compose is still unavailable after install"
}
load_installer_images() {
require_cmd docker
shopt -s nullglob
local image
local found=0
for image in "$INSTALLER_ROOT"/images/*.tar "$INSTALLER_ROOT"/images/*.tar.gz; do
found=1
log "loading docker image: $image"
if [[ "$image" == *.gz ]]; then
gzip -dc "$image" | docker load
else
docker load -i "$image"
fi
done
shopt -u nullglob
(( found == 1 )) || warn "no image tar found under $INSTALLER_ROOT/images"
}
ensure_binfmt_if_needed() {
require_cmd docker
load_env
local host_arch
host_arch="$(uname -m)"
if [[ "$RPKI_PLATFORM" == "linux/arm64" && "$host_arch" != "aarch64" && "$host_arch" != "arm64" ]]; then
log "host arch is $host_arch; ensuring binfmt/qemu for arm64"
docker run --rm --privileged tonistiigi/binfmt --install arm64
fi
}
verify_runtime_image() {
load_env
require_cmd docker
log "verifying runtime image $RPKI_IMAGE on $RPKI_PLATFORM"
docker image inspect "$RPKI_IMAGE" >/dev/null
docker run --rm --platform "$RPKI_PLATFORM" "$RPKI_IMAGE" /opt/ours-rp/bin/rpki --help >/tmp/ours-rp-arm64-rpki-help.txt
head -5 /tmp/ours-rp-arm64-rpki-help.txt || true
}
verify_image_platform() {
local image="$1"
local expected_platform="$2"
local role="$3"
local actual_platform
docker image inspect "$image" >/dev/null
actual_platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null || echo unknown)"
[[ "$actual_platform" == "$expected_platform" ]] || die "$role image platform mismatch: image=$image expected=$expected_platform actual=$actual_platform"
}
verify_monitor_images() {
load_env
require_cmd docker
verify_image_platform "$PROMETHEUS_IMAGE" "$MONITOR_PLATFORM" "prometheus"
verify_image_platform "$GRAFANA_IMAGE" "$MONITOR_PLATFORM" "grafana"
}
endpoint_ok() {
local url="$1"
curl -fsS --max-time 5 "$url" >/dev/null 2>&1
}

View File

@@ -1,55 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh
source "$SCRIPT_DIR/scripts/common.sh"
BRIEF=0
while [[ $# -gt 0 ]]; do
case "$1" in
--brief)
BRIEF=1
shift
;;
-h|--help)
echo "Usage: ./status.sh [--brief]"
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
load_env
echo "installer_root=$INSTALLER_ROOT"
echo "host_data_dir=$HOST_DATA_DIR"
echo "image=$RPKI_IMAGE"
echo "platform=$RPKI_PLATFORM"
echo "rirs=${RIRS:-}"
echo "max_runs=${MAX_RUNS:-}"
echo "interval_secs=${INTERVAL_SECS:-}"
echo
if command -v docker >/dev/null 2>&1; then
docker version --format 'docker={{.Server.Version}}' 2>/dev/null || echo "docker=unavailable"
docker compose version 2>/dev/null || true
compose_cmd --profile core --profile sidecar --profile monitor ps || true
else
echo "docker=missing"
fi
echo
df -h "$HOST_DATA_DIR" 2>/dev/null || true
echo
latest="$(latest_run_dir || true)"
if [[ -n "$latest" ]]; then
echo "latest_run=$latest"
print_run_summary "$latest" || true
else
echo "latest_run=none"
fi
if [[ "$BRIEF" == "0" ]]; then
echo
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:${GRAFANA_PORT:-3000}/api/health" && echo "grafana=ok" || echo "grafana=unavailable"
fi

View File

@@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/common.sh
source "$SCRIPT_DIR/scripts/common.sh"
load_env
install_docker_if_missing
load_installer_images
ensure_binfmt_if_needed
verify_runtime_image
verify_monitor_images
compose_cmd --profile core --profile sidecar --profile monitor up -d --force-recreate
"$SCRIPT_DIR/status.sh" --brief || true

View File

@@ -0,0 +1,123 @@
# ours RP Docker installer configuration
# `build_docker_installer_package.sh` rewrites package-specific placeholders
# when producing amd64/arm64 release packages.
# 中文说明见 docs/README.zh-CN.md。English guide: docs/README.en.md
# Package metadata and architecture guardrails.
PACKAGE_ARCH=__PACKAGE_ARCH__
PACKAGE_PLATFORM=__PACKAGE_PLATFORM__
ALLOW_CROSS_ARCH=0
# Compose project name.
COMPOSE_PROJECT_NAME=ours-rp-__PACKAGE_ARCH__-installer
# Runtime image rebuilt from the current source commit while packaging.
RPKI_IMAGE=__RUNTIME_IMAGE__
RPKI_PLATFORM=__PACKAGE_PLATFORM__
# Metrics image rebuilt from the current source commit while packaging.
METRICS_IMAGE=__METRICS_IMAGE__
METRICS_PLATFORM=__PACKAGE_PLATFORM__
# Restart policy for the soak container. Production default keeps the daemon alive.
# For finite acceptance tests such as MAX_RUNS=3, set SOAK_RESTART_POLICY=no to avoid an extra restarted run.
SOAK_RESTART_POLICY=unless-stopped
# Host-side persistent data directory. All state/runs/logs/monitoring data are bind-mounted here.
HOST_DATA_DIR=__HOST_DATA_DIR__
# RIR list. Options: afrinic,apnic,arin,lacnic,ripe
RIRS=afrinic,apnic,arin,lacnic,ripe
# Negative MAX_RUNS means keep running forever. Default production interval is 10 minutes.
MAX_RUNS=-1
INTERVAL_SECS=600
RETAIN_RUNS=100
# TAL/TA input mode:
# 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.
# custom-file-with-ta: use one custom TAL + TA from the read-only custom fixture mount.
# url: pass TAL URL to child process.
TAL_INPUT_MODE=file-live-ta
LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS=15
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.
RSYNC_SCOPE=module-root
DISABLE_COMPETING_RPS=0
RUN_ROOT=/var/lib/ours-rp
DB_DIR=/var/lib/ours-rp/state/db
RSYNC_MIRROR_ROOT=/var/lib/ours-rp/state/rsync-mirror
# 每轮完成并复制出正式 run 产物后清理 tmp/daemon-run_*,避免长跑占满磁盘。
CLEAN_TMP_AFTER_RUN=1
OUTPUT_COMPACT_REPORT=1
ALLOW_RSYNC_MIRROR_REUSE=1
FAILURE_SNAPSHOT_RESET=1
# Periodic snapshot reset of active state DB.
# 0: keep existing behavior.
# 1: after one successful snapshot, allow at most N successful delta runs;
# the next run is forced to snapshot and active state/db is rebuilt from empty.
# Lifecycle run state is persisted independently at:
# ${HOST_DATA_DIR}/state/run-lifecycle-state.json
# It is not affected by run retention or state/db reset.
PERIODIC_SNAPSHOT_RESET=0
PERIODIC_SNAPSHOT_MAX_DELTAS=100
DB_STATS_EXACT_EVERY=0
# Resource certificate validation mode passed to the rpki child process.
# Options: validation-update-03 | rfc6487
RESOURCE_VALIDATION_MODE=validation-update-03
# Validation and performance options aligned with current optimized soak defaults.
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=1
RPKI_ANALYZE=1
RPKI_EXTRA_ARGS="--enable-transport-request-prefetch --enable-publication-point-validation-cache --enable-roa-validation-cache --parallel-max-repo-sync-workers-global 4 --parallel-phase2-object-workers 4 --memory-trim-after-validation"
# Progress logs.
RPKI_PROGRESS_LOG=1
RPKI_PROGRESS_SLOW_SECS=20
RPKI_PROGRESS_STAGE_FRESH_SLOW_MS=2000
RPKI_PROGRESS_PP_CONTROL_SLOW_MS=200
RPKI_PROGRESS_PP_CACHE_SLOW_MS=100
RPKI_PROGRESS_CONTROL_LOOP_SLOW_MS=2000
# Metrics sidecar.
METRICS_INSTANCE=__PACKAGE_ARCH__-installer
METRICS_PORT=9556
METRICS_POLL_SECS=10
# Optional external RTR report directory produced by a separately deployed RTR service.
# Default points to an installer-managed empty fallback directory. To enable real RTR
# metrics, set this to the host-side report directory, for example:
# RTR_REPORT_DIR=/root/rpki/report
RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report
RTR_REPORT_CONTAINER_DIR=/var/lib/ours-rp/rtr-report
# Prometheus / Grafana.
# Monitor images are packaged as docker-save archives and loaded by install.sh.
MONITOR_PLATFORM=__PACKAGE_PLATFORM__
PROMETHEUS_IMAGE=prom/prometheus:v2.55.1
GRAFANA_IMAGE=grafana/grafana:11.3.1
PROMETHEUS_PORT=9090
PROMETHEUS_RETENTION=7d
GRAFANA_PORT=3000
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
# First snapshot waiting timeout used by start.sh.
FIRST_RUN_WAIT_TIMEOUT_SECS=7200

View File

@@ -0,0 +1,100 @@
services:
ours-rp-soak:
image: ${RPKI_IMAGE:?RPKI_IMAGE is required}
platform: ${RPKI_PLATFORM:?RPKI_PLATFORM is required}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-package-installer}-soak
env_file:
- ../.env
environment:
PACKAGE_ROOT: /opt/ours-rp
ENV_FILE: /opt/ours-rp/.env
RUN_ROOT: /var/lib/ours-rp
BIN_DIR: /opt/ours-rp/bin
FIXTURE_DIR: /opt/ours-rp/fixtures
CUSTOM_FIXTURE_DIR: /opt/ours-rp/custom-fixtures
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ../.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}/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}/tmp:/var/lib/ours-rp/tmp
restart: ${SOAK_RESTART_POLICY:-unless-stopped}
profiles:
- core
artifact-metrics:
image: ${METRICS_IMAGE:?METRICS_IMAGE is required}
platform: ${METRICS_PLATFORM:?METRICS_PLATFORM is required}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-package-installer}-artifact-metrics
env_file:
- ../.env
environment:
RPKI_METRICS_RTR_REPORT_DIR: ${RTR_REPORT_CONTAINER_DIR:-/var/lib/ours-rp/rtr-report}
command:
- /opt/ours-rp/bin/rpki_artifact_metrics
- --run-root
- /var/lib/ours-rp
- --listen
- 0.0.0.0:9556
- --poll-secs
- ${METRICS_POLL_SECS:-10}
- --instance
- ${METRICS_INSTANCE:-package-installer}
ports:
- "${METRICS_PORT:-9556}:9556"
volumes:
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/state:/var/lib/ours-rp/state:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/runs:/var/lib/ours-rp/runs:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/logs:/var/lib/ours-rp/logs:ro
- ${RTR_REPORT_DIR:-/var/lib/ours-rp-package-installer/empty-rtr-report}:${RTR_REPORT_CONTAINER_DIR:-/var/lib/ours-rp/rtr-report}:ro
restart: unless-stopped
profiles:
- sidecar
prometheus:
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}
platform: ${MONITOR_PLATFORM:?MONITOR_PLATFORM is required}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-package-installer}-prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-7d}
- --web.enable-lifecycle
depends_on:
- artifact-metrics
user: "0:0"
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/prometheus:/prometheus
restart: unless-stopped
profiles:
- monitor
grafana:
image: ${GRAFANA_IMAGE:-grafana/grafana:11.3.1}
platform: ${MONITOR_PLATFORM:?MONITOR_PLATFORM is required}
container_name: ${COMPOSE_PROJECT_NAME:-ours-rp-package-installer}-grafana
depends_on:
- prometheus
user: "0:0"
ports:
- "${GRAFANA_PORT:-3000}:3000"
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ${HOST_DATA_DIR:-/var/lib/ours-rp-package-installer}/grafana:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
restart: unless-stopped
profiles:
- monitor

View File

@@ -0,0 +1,666 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 1,
"title": "RTR Metrics Enabled",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_metrics_enabled)",
"legendFormat": "enabled",
"refId": "A",
"instant": true
}
]
},
{
"id": 2,
"title": "Refresh Success",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 4,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_source_refresh_status{status=\"success\"})",
"legendFormat": "success",
"refId": "A",
"instant": true
}
]
},
{
"id": 3,
"title": "Consecutive Failures",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 9,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_source_refresh_consecutive_failures)",
"legendFormat": "failures",
"refId": "A",
"instant": true
}
]
},
{
"id": 4,
"title": "Last Success Age",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 14,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 0,
"min": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_source_last_success_age_seconds)",
"legendFormat": "age",
"refId": "A",
"instant": true
}
]
},
{
"id": 5,
"title": "RTR RSS",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 19,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "bytes",
"decimals": 0,
"min": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_process_rss_bytes)",
"legendFormat": "rss",
"refId": "A",
"instant": true
}
]
},
{
"id": 6,
"title": "Data Quality Totals",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 4
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_data_quality_items{stage=~\"ccr_input|before_slurm|after_slurm\",type=\"total\"}",
"legendFormat": "{{stage}} total",
"refId": "A"
},
{
"expr": "ours_rp_rtr_data_quality_items{stage=\"after_slurm\",type=\"vrp\"}",
"legendFormat": "after_slurm vrp",
"refId": "B"
},
{
"expr": "ours_rp_rtr_data_quality_items{stage=\"after_slurm\",type=\"aspa\"}",
"legendFormat": "after_slurm aspa",
"refId": "C"
}
]
},
{
"id": 7,
"title": "SLURM Filters / Assertions",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 4
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_slurm_filters",
"legendFormat": "filter {{type}}",
"refId": "A"
},
{
"expr": "ours_rp_rtr_slurm_assertions",
"legendFormat": "assert {{type}}",
"refId": "B"
}
]
},
{
"id": 8,
"title": "Cache Ready / Delta Window",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 12
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_cache_ready",
"legendFormat": "ready",
"refId": "A"
},
{
"expr": "ours_rp_rtr_cache_delta_window_length",
"legendFormat": "v{{version}} length",
"refId": "B"
}
]
},
{
"id": 9,
"title": "Cache Snapshot Items",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 12
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_cache_snapshot_items",
"legendFormat": "v{{version}} {{type}}",
"refId": "A"
}
]
},
{
"id": 10,
"title": "Latest Delta Items",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 12
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_cache_delta_items",
"legendFormat": "v{{version}} {{direction}} {{type}}",
"refId": "A"
}
]
},
{
"id": 11,
"title": "Connections",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 20
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_active_connections",
"legendFormat": "active",
"refId": "A"
},
{
"expr": "ours_rp_rtr_connections",
"legendFormat": "{{transport}}",
"refId": "B"
}
]
},
{
"id": 12,
"title": "Connection Utilization / Max",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 20
},
"fieldConfig": {
"defaults": {
"min": 0
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": ".*utilization.*"
},
"properties": [
{
"id": "unit",
"value": "percentunit"
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": ".*max.*"
},
"properties": [
{
"id": "unit",
"value": "short"
}
]
}
]
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_connection_utilization",
"legendFormat": "utilization",
"refId": "A"
},
{
"expr": "ours_rp_rtr_max_connections",
"legendFormat": "max",
"refId": "B"
}
]
},
{
"id": 13,
"title": "Report Age",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 20
},
"fieldConfig": {
"defaults": {
"unit": "s",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_source_report_age_seconds",
"legendFormat": "source",
"refId": "A"
},
{
"expr": "ours_rp_rtr_runtime_report_age_seconds",
"legendFormat": "runtime",
"refId": "B"
},
{
"expr": "ours_rp_rtr_clients_report_age_seconds",
"legendFormat": "clients",
"refId": "C"
}
]
}
],
"refresh": "10s",
"schemaVersion": 40,
"tags": [
"rpki",
"inter-rp",
"routinator"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timezone": "browser",
"title": "RTR Service Overview",
"uid": "ours-rp-rtr-overview",
"version": 1
}

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

@@ -1,9 +1,9 @@
apiVersion: 1 apiVersion: 1
providers: providers:
- name: ours-rp-arm64 - name: ours-rp-docker-installer
orgId: 1 orgId: 1
folder: Ours RP ARM64 folder: Ours RP Docker Installer
type: file type: file
disableDeletion: false disableDeletion: false
updateIntervalSeconds: 10 updateIntervalSeconds: 10

View File

@@ -10,4 +10,4 @@ scrape_configs:
- artifact-metrics:9556 - artifact-metrics:9556
labels: labels:
rp: ours-rp rp: ours-rp
source: arm64-compose-artifact-sidecar source: docker-installer-artifact-sidecar

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

@@ -0,0 +1,194 @@
# ours RP Docker Installer Guide (`__PACKAGE_ARCH__` / `__PACKAGE_PLATFORM__`)
## Goal
This package deploys ours RP on Linux servers whose host architecture matches the packaged installer metadata, using Docker Compose and continuous all-five RIR validation.
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; `./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:
```bash
ALLOW_CROSS_ARCH=1
```
## Compose Template Source
`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 `scripts/install.sh`, `scripts/start.sh`, and `scripts/status.sh` as described here.
## Quick Start
```bash
tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz
cd ours-rp-installer-__PACKAGE_ARCH__-*
./scripts/install.sh
cp .env.example .env # install.sh creates .env automatically if missing
vim .env
./scripts/start.sh
./scripts/status.sh
```
Defaults:
- `PACKAGE_ARCH=__PACKAGE_ARCH__`
- `PACKAGE_PLATFORM=__PACKAGE_PLATFORM__`
- `RIRS=afrinic,apnic,arin,lacnic,ripe`
- `MAX_RUNS=-1`
- `INTERVAL_SECS=600`
- `TAL_INPUT_MODE=file-live-ta`
- `RESOURCE_VALIDATION_MODE=validation-update-03`
- `LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1`
- `PERIODIC_SNAPSHOT_RESET=0`
- `PERIODIC_SNAPSHOT_MAX_DELTAS=100`
- `HOST_DATA_DIR=__HOST_DATA_DIR__`
- `SOAK_RESTART_POLICY=unless-stopped`
- `RPKI_IMAGE=__RUNTIME_IMAGE__`
- `METRICS_IMAGE=__METRICS_IMAGE__`
- `METRICS_PLATFORM=__PACKAGE_PLATFORM__`
- `MONITOR_PLATFORM=__PACKAGE_PLATFORM__`
- `ALLOW_CROSS_ARCH=0`
- `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
If there is no successful run under `HOST_DATA_DIR/runs`, `start.sh` starts the core `ours-rp-soak` service first and waits for the first snapshot to succeed before starting metrics, Prometheus and Grafana.
Container/image mapping:
- `ours-rp-soak` uses `RPKI_IMAGE`
- `artifact-metrics` uses `METRICS_IMAGE`
- `prometheus` / `grafana` use their monitor images
The first snapshot refreshes live TA certificates before starting the RP process.
## Resource Validation Mode
New knob:
```bash
RESOURCE_VALIDATION_MODE=validation-update-03
```
Supported values:
- `validation-update-03`: default behavior, using the current validation-update draft semantics;
- `rfc6487`: switch back to the original RFC 6487 resource containment behavior.
The installer / soak runner passes `--resource-validation-mode` to every `rpki` child start.
When an upgraded deployment reuses an older `.env` that does not contain this key, the installer appends the new default `validation-update-03` automatically.
## Architecture Guardrails
The key scripts read:
- `PACKAGE_ARCH` / `PACKAGE_PLATFORM` from `.env`
- `PACKAGE-MANIFEST.env`
- the current host `uname -m`
Default behavior:
1. matching host and package architectures: run natively;
2. mismatched host and package architectures: fail with an explicit error;
3. only when `ALLOW_CROSS_ARCH=1` is set do the scripts attempt to enable matching `binfmt/qemu`.
That means an `arm64` package on an `x86_64` host does not silently switch to emulation by default.
## Ports
Default ports:
- metrics: `http://<host>:9556/metrics`
- Prometheus: `http://<host>:9090`
- Grafana: `http://<host>:3000`
Grafana credentials come from `.env`:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
Change the password and restrict public access for production deployments.
## 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, run:
```bash
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
```
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
Default host directory:
```text
__HOST_DATA_DIR__/
state/
runs/
logs/
tmp/
prometheus/
grafana/
```
Each `runs/run_XXXX/` directory contains `report.json`, `result.ccr`, `input.cir`, `vrps.csv`, `vaps.csv`, `stage-timing.json`, logs and metadata.
## Periodic Snapshot Reset
New knobs:
```bash
PERIODIC_SNAPSHOT_RESET=0
PERIODIC_SNAPSHOT_MAX_DELTAS=100
```
Semantics:
- disabled by default, keeping previous behavior unchanged;
- when enabled, one successful snapshot is followed by at most `N` successful delta runs;
- after the threshold is reached, the next run is forced to snapshot;
- before that forced snapshot, only the active `state/db` is reset, while `runs/`, `logs/`, `state/rsync-mirror`, `.env`, and Prometheus/Grafana data are preserved;
- the counter is persisted independently in `HOST_DATA_DIR/state/run-lifecycle-state.json`, so it does not depend on retained `runs/` history;
- if that lifecycle file is corrupt, it is backed up as `run-lifecycle-state.json.corrupt.<timestamp>.<pid>` before best-effort bootstrap from retained runs;
- after a successful forced snapshot, the old DB staging is deleted so disk usage does not keep growing elsewhere.
Check the latest `run-meta.json` for:
- `sync_mode`
- `snapshot_reason`
- `periodic_snapshot_delta_count`
- `periodic_snapshot_forced`
- `reset_db_cleanup_status`
## Common Commands
```bash
./scripts/status.sh
./scripts/logs.sh ours-rp-soak --tail 200
./scripts/restart.sh
./scripts/stop.sh
./scripts/cleanup.sh --keep-runs 100 --execute
./scripts/uninstall.sh
```
For finite acceptance tests, for example `MAX_RUNS=3`, also set:
```bash
SOAK_RESTART_POLICY=no
```
Otherwise Compose `unless-stopped` will restart the container after it exits successfully.
`uninstall.sh` keeps data by default. Use the following only when you really want to delete `HOST_DATA_DIR`:
```bash
./scripts/uninstall.sh --purge-data
```

View File

@@ -0,0 +1,196 @@
# ours RP Docker 安装包使用说明(`__PACKAGE_ARCH__` / `__PACKAGE_PLATFORM__`)
## 目标
本安装包用于在目标架构与包元数据匹配的 Linux 服务器上,通过 Docker Compose 部署 ours RP,并持续运行 all5 RIR 同步验证任务。
安装包内置与包架构一致的四类镜像: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`,可通过 `./scripts/status.sh --brief` 查看。
默认情况下,宿主架构必须与 `PACKAGE-MANIFEST.env` 中的 `PACKAGE_ARCH` 匹配;若确需在异构主机上通过 QEMU/binfmt 运行,必须显式设置:
```bash
ALLOW_CROSS_ARCH=1
```
## Compose 模板来源
`compose/` 是唯一的生产 Docker Compose 模板来源,Docker 安装包构建脚本会直接复制该目录。不要在其他目录维护第二份 compose、Prometheus 或 Grafana dashboard 文件。
旧的直接 Compose 部署入口已经移除。需要部署 amd64 或 arm64 时,统一先用 `scripts/docker/build_docker_installer_package.sh` 构建对应架构安装包,再按本说明执行 `scripts/install.sh`、`scripts/start.sh` 和 `scripts/status.sh`。
## 快速开始
```bash
tar -xzf ours-rp-installer-__PACKAGE_ARCH__-*.tar.gz
cd ours-rp-installer-__PACKAGE_ARCH__-*
./scripts/install.sh
cp .env.example .env # 如 install.sh 已自动创建,可直接编辑现有 .env
vim .env
./scripts/start.sh
./scripts/status.sh
```
默认配置:
- `PACKAGE_ARCH=__PACKAGE_ARCH__`
- `PACKAGE_PLATFORM=__PACKAGE_PLATFORM__`
- `RIRS=afrinic,apnic,arin,lacnic,ripe`
- `MAX_RUNS=-1`
- `INTERVAL_SECS=600`
- `TAL_INPUT_MODE=file-live-ta`
- `RESOURCE_VALIDATION_MODE=validation-update-03`
- `LIVE_TA_REFRESH_BEFORE_SNAPSHOT=1`
- `PERIODIC_SNAPSHOT_RESET=0`
- `PERIODIC_SNAPSHOT_MAX_DELTAS=100`
- `HOST_DATA_DIR=__HOST_DATA_DIR__`
- `SOAK_RESTART_POLICY=unless-stopped`
- `RPKI_IMAGE=__RUNTIME_IMAGE__`
- `METRICS_IMAGE=__METRICS_IMAGE__`
- `METRICS_PLATFORM=__PACKAGE_PLATFORM__`
- `MONITOR_PLATFORM=__PACKAGE_PLATFORM__`
- `ALLOW_CROSS_ARCH=0`
- `RTR_REPORT_DIR=__HOST_DATA_DIR__/empty-rtr-report`,默认挂载空目录;独立 RTR 服务部署完成后,使用 `scripts/register_rtr_monitor.sh` 注册同机 report 目录
## 首次启动语义
如果 `HOST_DATA_DIR/runs` 下没有成功 run,`start.sh` 会先启动核心 `ours-rp-soak`,等待第一轮 snapshot 成功后再启动 metrics、Prometheus 和 Grafana。
容器镜像分工:
- `ours-rp-soak` 使用 `RPKI_IMAGE`
- `artifact-metrics` 使用 `METRICS_IMAGE`
- `prometheus` / `grafana` 使用各自 monitor image
第一轮 snapshot 会先拉取 live TA,避免 clean state 使用旧 fixture TA。
## 资源验证模式
新增配置:
```bash
RESOURCE_VALIDATION_MODE=validation-update-03
```
可选值:
- `validation-update-03`:默认,按当前 validation update draft 语义运行;
- `rfc6487`:切换为 RFC 6487 原始资源包含判定语义。
installer / soak runner 每次启动 `rpki` 子进程时都会显式传入 `--resource-validation-mode`。
如果升级时复用旧 `.env` 且缺少该变量,installer 会自动补入默认值 `validation-update-03`,不会破坏旧 `.env` 复用流程。
## 架构检查
关键脚本会读取:
- `.env` 中的 `PACKAGE_ARCH` / `PACKAGE_PLATFORM`
- `PACKAGE-MANIFEST.env`
- 当前宿主 `uname -m`
默认行为:
1. 宿主与包架构匹配:直接运行;
2. 宿主与包架构不匹配:明确报错并停止;
3. 仅当 `ALLOW_CROSS_ARCH=1` 时,脚本才会尝试启用对应架构的 `binfmt/qemu`。
因此在 `x86_64` 主机上运行 `arm64` 包,不会默认静默进入模拟执行。
## 访问端口
默认端口:
- metrics: `http://<host>:9556/metrics`
- Prometheus: `http://<host>:9090`
- Grafana: `http://<host>:3000`
Grafana 默认账号密码来自 `.env`:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
生产部署时应修改密码并限制外部访问。
## 可选 RTR report 监控接入
如果同一台机器上已经有独立部署的 RTR 服务,并且它持续输出 `rtr-source-*`、`rtr-runtime-*`、`rtr-clients-*` JSON report,执行:
```bash
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report
```
该命令只接受本机绝对路径,会先校验候选 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 容器也能稳定启动。
## 数据目录
默认宿主机目录:
```text
__HOST_DATA_DIR__/
state/
runs/
logs/
tmp/
prometheus/
grafana/
```
`runs/run_XXXX/` 中包含每轮 `report.json`、`result.ccr`、`input.cir`、`vrps.csv`、`vaps.csv`、`stage-timing.json`、日志和元数据。
## 定期 snapshot reset
新增配置:
```bash
PERIODIC_SNAPSHOT_RESET=0
PERIODIC_SNAPSHOT_MAX_DELTAS=100
```
语义:
- 默认关闭,行为与旧版本一致;
- 开启后,一次成功 snapshot 后最多连续执行 `N` 个成功 delta;
- 达到阈值后,下一轮强制跑 snapshot;
- 强制 snapshot 前只重置 active `state/db`,保留 `runs/`、`logs/`、`state/rsync-mirror`、`.env`、Prometheus/Grafana 数据;
- 周期计数保存在独立 lifecycle 文件 `HOST_DATA_DIR/state/run-lifecycle-state.json`,不依赖 `runs/` 保留窗口;
- lifecycle 文件损坏时会先备份为 `run-lifecycle-state.json.corrupt.<timestamp>.<pid>`,再从当前保留 run 尽力 bootstrap;
- 强制 snapshot 成功后旧 DB staging 会被删除,避免磁盘只是换目录继续增长。
可通过最新 `run-meta.json` 中的以下字段确认:
- `sync_mode`
- `snapshot_reason`
- `periodic_snapshot_delta_count`
- `periodic_snapshot_forced`
- `reset_db_cleanup_status`
## 常用命令
```bash
./scripts/status.sh
./scripts/logs.sh ours-rp-soak --tail 200
./scripts/restart.sh
./scripts/stop.sh
./scripts/cleanup.sh --keep-runs 100 --execute
./scripts/uninstall.sh
```
如果做有限轮次验收,例如 `MAX_RUNS=3`,建议同时设置:
```bash
SOAK_RESTART_POLICY=no
```
否则 Compose 的 `unless-stopped` 策略会在容器正常退出后再次拉起下一轮。
`uninstall.sh` 默认不删除数据。只有显式执行:
```bash
./scripts/uninstall.sh --purge-data
```
才会删除 `HOST_DATA_DIR`。

View File

@@ -0,0 +1,137 @@
# Operations Guide (`__PACKAGE_ARCH__`)
## Install
```bash
./scripts/install.sh
```
The installer is idempotent:
- existing `.env` is kept;
- existing Docker/Compose installation is reused;
- repeated loading of packaged runtime, metrics, Prometheus and Grafana images for the package architecture is safe;
- existing data directory is reused;
- matching host/package architecture is required by default.
## Start
```bash
./scripts/start.sh
```
Start without waiting for the first snapshot:
```bash
./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:
```bash
ALLOW_CROSS_ARCH=1
```
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
```bash
./scripts/stop.sh
./scripts/restart.sh
```
## Status Checks
```bash
./scripts/status.sh
./scripts/self-check.sh
```
Important checks:
- Docker/Compose availability;
- runtime, metrics, Prometheus and Grafana images exist;
- `HOST_DATA_DIR` is writable;
- Compose config is valid;
- latest run status;
- metrics, Prometheus and Grafana endpoints;
- whether `host_arch`, `package_arch`, and `arch_mode` match expectations.
`status.sh` also prints:
- `package_arch`
- `package_platform`
- `runtime_image`
- `metrics_image`
- `allow_cross_arch`
- `periodic_snapshot_reset`
- `periodic_snapshot_max_deltas`
- `rtr_report_dir`
- `rtr_report_container_dir`
To verify RTR report ingestion:
```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
```
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
Extract the new package into a new directory and explicitly reuse the existing `.env` through the upgrade script:
```bash
./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 an older `.env` does not contain `METRICS_IMAGE` or `METRICS_PLATFORM`, the install/upgrade flow backfills package-matched defaults automatically.
Upgrade does not delete:
- `runs/`
- `logs/`
- `state/rsync-mirror`
- runtime configuration referenced by `.env`
- Prometheus / Grafana data
To validate periodic forced snapshot behavior, temporarily set:
```bash
PERIODIC_SNAPSHOT_RESET=1
PERIODIC_SNAPSHOT_MAX_DELTAS=2
```
Then confirm the latest `run-meta.json` contains:
```bash
snapshot_reason=periodic_snapshot_delta_limit
```
And inspect the independent lifecycle state:
```bash
jq '{last_run,last_success_snapshot,successful_deltas_since_snapshot,state_health}' \
"${HOST_DATA_DIR}/state/run-lifecycle-state.json"
```
After validation, restore:
```bash
PERIODIC_SNAPSHOT_MAX_DELTAS=100
```
## Cleanup
```bash
./scripts/cleanup.sh --keep-runs 100
./scripts/cleanup.sh --keep-runs 100 --execute
```
Cleanup is dry-run by default. Add `--execute` after reviewing the output.

View File

@@ -0,0 +1,137 @@
# 运维手册(`__PACKAGE_ARCH__`)
## 安装
```bash
./scripts/install.sh
```
安装脚本是幂等的:
- 已有 `.env` 不覆盖;
- 已安装 Docker/Compose 则跳过;
- 包内 runtime、metrics、Prometheus、Grafana 对应架构镜像重复加载是安全的;
- 数据目录已存在则复用;
- 默认要求宿主与包架构匹配。
## 启动
```bash
./scripts/start.sh
```
如需后台启动后不等待首轮 snapshot:
```bash
./scripts/start.sh --no-wait-first-run
```
如需显式允许异构主机通过 QEMU/binfmt 跑包,先在 `.env` 中设置:
```bash
ALLOW_CROSS_ARCH=1
```
然后重新执行 `./scripts/install.sh` 或 `./scripts/self-check.sh`,脚本才会尝试启用对应 binfmt。
## 停止和重启
```bash
./scripts/stop.sh
./scripts/restart.sh
```
## 状态检查
```bash
./scripts/status.sh
./scripts/self-check.sh
```
重点检查项:
- Docker/Compose 可用;
- runtime、metrics、Prometheus、Grafana 镜像存在;
- `HOST_DATA_DIR` 可写;
- Compose 配置合法;
- 最新 run 状态;
- metrics、Prometheus、Grafana endpoint;
- `host_arch`、`package_arch`、`arch_mode` 是否符合预期。
`status.sh` 还会显示:
- `package_arch`
- `package_platform`
- `runtime_image`
- `metrics_image`
- `allow_cross_arch`
- `periodic_snapshot_reset`
- `periodic_snapshot_max_deltas`
- `rtr_report_dir`
- `rtr_report_container_dir`
如需确认 RTR report 已接入:
```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
```
注册只支持同机的绝对目录。注册命令会保留一份带时间戳的 `.env` 备份,并且只更新 metrics、Prometheus、Grafana 三个旁路容器;`ours-rp-soak` 的容器 ID 应保持不变。
## 升级
把新安装包解压到新目录后,推荐通过升级脚本显式复用旧 `.env`:
```bash
./scripts/upgrade.sh --reuse-env-from /path/to/old-installer/.env
```
如果新目录已经存在 `.env`,升级脚本会保留它,不覆盖。
如果旧 `.env` 缺失 `METRICS_IMAGE` 或 `METRICS_PLATFORM`,升级/安装链路会按当前包架构自动补齐默认值。
升级不会删除以下数据:
- `runs/`
- `logs/`
- `state/rsync-mirror`
- `.env` 对应的运行配置
- Prometheus / Grafana 数据
验证定期 forced snapshot 时,可临时设置:
```bash
PERIODIC_SNAPSHOT_RESET=1
PERIODIC_SNAPSHOT_MAX_DELTAS=2
```
然后检查最新 `run-meta.json` 应出现:
```bash
snapshot_reason=periodic_snapshot_delta_limit
```
并检查独立 lifecycle 状态:
```bash
jq '{last_run,last_success_snapshot,successful_deltas_since_snapshot,state_health}' \
"${HOST_DATA_DIR}/state/run-lifecycle-state.json"
```
验证完成后恢复:
```bash
PERIODIC_SNAPSHOT_MAX_DELTAS=100
```
## 清理
```bash
./scripts/cleanup.sh --keep-runs 100
./scripts/cleanup.sh --keep-runs 100 --execute
```
默认 dry-run,确认后加 `--execute`。

View File

@@ -0,0 +1,142 @@
# Troubleshooting (`__PACKAGE_ARCH__` / `__PACKAGE_PLATFORM__`)
## Docker or Compose Is Unavailable
Run:
```bash
docker version
docker compose version
```
If missing, run:
```bash
./scripts/install.sh
```
## Package Architecture Mismatch
If `status.sh`, `install.sh`, or `self-check.sh` reports:
```text
package architecture mismatch
```
the host `uname -m` does not match the packaged `PACKAGE_ARCH=__PACKAGE_ARCH__`. The default behavior is to fail clearly rather than silently switching to emulation.
Only if you intentionally accept QEMU/binfmt cross-architecture execution should you set:
```bash
ALLOW_CROSS_ARCH=1
```
Then rerun install or self-check.
## `__PACKAGE_ARCH__` Image Cannot Run
Running `__PACKAGE_PLATFORM__` images on a different host architecture requires binfmt/qemu. The installer only attempts this automatically when `ALLOW_CROSS_ARCH=1`; you can also enable it manually:
```bash
docker run --rm --privileged tonistiigi/binfmt --install __PACKAGE_ARCH__
docker run --rm --platform __PACKAGE_PLATFORM__ debian:bookworm-slim uname -m
```
For `arm64` packages the expected output is `aarch64`; for `amd64` packages the expected output is `x86_64`.
To confirm the metrics image is also loaded correctly:
```bash
docker image inspect "$(grep '^METRICS_IMAGE=' .env | cut -d= -f2-)"
docker run --rm --platform __PACKAGE_PLATFORM__ \
"$(grep '^METRICS_IMAGE=' .env | cut -d= -f2-)" \
/opt/ours-rp/bin/rpki_artifact_metrics --help
```
## RTR report metrics are empty
First check registration and the report directory:
```bash
./scripts/status.sh
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
```
Check the metrics container mount:
```bash
docker inspect "$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2-)-artifact-metrics" \
| jq '.[0].Mounts[] | select(.Destination=="/var/lib/ours-rp/rtr-report")'
```
## First Snapshot Times Out
All-five snapshot can be slow, especially under QEMU. Increase timeout:
```bash
./scripts/start.sh --timeout-secs 14400
```
## Output Counts Are Too Low
Check:
```bash
grep LIVE_TA_REFRESH_BEFORE_SNAPSHOT .env
ls -l __HOST_DATA_DIR__/state/live-ta
tail -100 __HOST_DATA_DIR__/logs/live-ta-refresh-*.log
```
In `file-live-ta` mode, snapshot should wait until live TA refresh succeeds.
## Grafana Login Fails
Check `.env`:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
If Grafana has already started, changing `.env` may not reset the existing Grafana database. Stop services and back up/clean `${HOST_DATA_DIR}/grafana` if needed.
## A Finite Acceptance Test Starts an Extra Run
If `.env` sets a finite `MAX_RUNS=3` while `SOAK_RESTART_POLICY=unless-stopped`, Docker Compose restarts the soak container after it exits successfully.
For finite tests, set:
```bash
SOAK_RESTART_POLICY=no
```
## How to Confirm a Periodic Forced Snapshot
Check the latest run metadata:
```bash
latest="$(find ${HOST_DATA_DIR}/runs -maxdepth 1 -type d -name 'run_*' | sort | tail -1)"
jq '{run_id,sync_mode,snapshot_reason,periodic_snapshot_delta_count,periodic_snapshot_forced,reset_db_cleanup_status}' "$latest/run-meta.json"
```
For a threshold-triggered reset you should see:
- `sync_mode: "snapshot"`
- `snapshot_reason: "periodic_snapshot_delta_limit"`
- `periodic_snapshot_forced: true`
To confirm delta counting still advances after retained runs are pruned, also inspect:
```bash
jq '{last_success_snapshot,successful_deltas_since_snapshot,recent_runs_count:(.recent_runs|length)}' \
"${HOST_DATA_DIR}/state/run-lifecycle-state.json"
```
## Lifecycle State File Is Corrupt
The script backs up the corrupt file before best-effort bootstrap from retained runs:
```bash
ls -1 "${HOST_DATA_DIR}/state"/run-lifecycle-state.json.corrupt.*
jq . "${HOST_DATA_DIR}/state/run-lifecycle-state.json"
```

View File

@@ -0,0 +1,142 @@
# 故障排查(`__PACKAGE_ARCH__` / `__PACKAGE_PLATFORM__`)
## Docker 或 Compose 不可用
执行:
```bash
docker version
docker compose version
```
如果缺失,重新执行:
```bash
./scripts/install.sh
```
## 架构不匹配报错
当 `status.sh` / `install.sh` / `self-check.sh` 报出:
```text
package architecture mismatch
```
说明宿主 `uname -m` 与包的 `PACKAGE_ARCH=__PACKAGE_ARCH__` 不一致。默认行为就是失败而不是静默模拟运行。
只有在你明确接受 QEMU/binfmt 跨架构运行时,才设置:
```bash
ALLOW_CROSS_ARCH=1
```
然后重新执行安装或自检。
## `__PACKAGE_ARCH__` 镜像无法运行
在异构主机上运行 `__PACKAGE_PLATFORM__` 镜像需要 binfmt/qemu。安装脚本只有在 `ALLOW_CROSS_ARCH=1` 时才会自动尝试启用;也可以手动执行:
```bash
docker run --rm --privileged tonistiigi/binfmt --install __PACKAGE_ARCH__
docker run --rm --platform __PACKAGE_PLATFORM__ debian:bookworm-slim uname -m
```
对于 `arm64` 包,预期输出 `aarch64`;对于 `amd64` 包,预期输出 `x86_64`。
如需进一步确认 metrics 镜像也已正确加载:
```bash
docker image inspect "$(grep '^METRICS_IMAGE=' .env | cut -d= -f2-)"
docker run --rm --platform __PACKAGE_PLATFORM__ \
"$(grep '^METRICS_IMAGE=' .env | cut -d= -f2-)" \
/opt/ours-rp/bin/rpki_artifact_metrics --help
```
## RTR report 指标为空
先检查注册状态和 report 目录:
```bash
./scripts/status.sh
./scripts/register_rtr_monitor.sh --report-dir /root/rpki/report --dry-run
```
检查 metrics 容器是否挂载该目录:
```bash
docker inspect "$(grep '^COMPOSE_PROJECT_NAME=' .env | cut -d= -f2-)-artifact-metrics" \
| jq '.[0].Mounts[] | select(.Destination=="/var/lib/ours-rp/rtr-report")'
```
## 首轮 snapshot 超时
all5 snapshot 可能很慢,尤其在 QEMU 环境。可以提高超时:
```bash
./scripts/start.sh --timeout-secs 14400
```
## 产物数量异常偏低
检查:
```bash
grep LIVE_TA_REFRESH_BEFORE_SNAPSHOT .env
ls -l __HOST_DATA_DIR__/state/live-ta
tail -100 __HOST_DATA_DIR__/logs/live-ta-refresh-*.log
```
`file-live-ta` 模式下,snapshot 应等待 live TA 成功刷新。
## Grafana 无法登录
确认 `.env` 中:
```bash
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin
```
如果曾经启动过 Grafana,修改 `.env` 不一定重置已有 Grafana 数据库账号。可以停止服务后按需备份并清理 `${HOST_DATA_DIR}/grafana`。
## 有限轮次验收后又多跑了一轮
如果 `.env` 中设置了 `MAX_RUNS=3` 这类有限轮次,同时 `SOAK_RESTART_POLICY=unless-stopped`,Docker Compose 会在 soak 容器正常退出后重新启动容器。
有限验收建议设置:
```bash
SOAK_RESTART_POLICY=no
```
## 如何确认触发了定期 forced snapshot
检查最新 run metadata:
```bash
latest="$(find ${HOST_DATA_DIR}/runs -maxdepth 1 -type d -name 'run_*' | sort | tail -1)"
jq '{run_id,sync_mode,snapshot_reason,periodic_snapshot_delta_count,periodic_snapshot_forced,reset_db_cleanup_status}' "$latest/run-meta.json"
```
阈值触发时应看到:
- `sync_mode: "snapshot"`
- `snapshot_reason: "periodic_snapshot_delta_limit"`
- `periodic_snapshot_forced: true`
如需确认 retain 裁剪后仍在累计 delta,可继续检查:
```bash
jq '{last_success_snapshot,successful_deltas_since_snapshot,recent_runs_count:(.recent_runs|length)}' \
"${HOST_DATA_DIR}/state/run-lifecycle-state.json"
```
## Lifecycle 状态文件损坏
脚本会先备份损坏文件,再从当前保留的 `runs/` 尽力 bootstrap:
```bash
ls -1 "${HOST_DATA_DIR}/state"/run-lifecycle-state.json.corrupt.*
jq . "${HOST_DATA_DIR}/state/run-lifecycle-state.json"
```

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

@@ -0,0 +1,597 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALLER_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_FILE="${ENV_FILE:-$INSTALLER_ROOT/.env}"
ENV_EXAMPLE="$INSTALLER_ROOT/.env.example"
COMPOSE_FILE="$INSTALLER_ROOT/compose/docker-compose.yml"
MANIFEST_FILE="${MANIFEST_FILE:-$INSTALLER_ROOT/PACKAGE-MANIFEST.env}"
log() {
printf '[ours-rp-installer] %s\n' "$*"
}
warn() {
printf '[ours-rp-installer][WARN] %s\n' "$*" >&2
}
die() {
printf '[ours-rp-installer][ERROR] %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
normalize_arch() {
case "$1" in
amd64|x86_64|linux/amd64)
printf 'amd64\n'
;;
arm64|aarch64|linux/arm64)
printf 'arm64\n'
;;
*)
return 1
;;
esac
}
platform_for_arch() {
printf 'linux/%s\n' "$1"
}
env_file_has_key() {
local env_path="$1"
local key="$2"
[[ -f "$env_path" ]] && grep -Eq "^${key}=" "$env_path"
}
persist_env_default() {
local env_path="$1"
local key="$2"
local value="$3"
local tmp_env
if env_file_has_key "$env_path" "$key"; then
return 0
fi
tmp_env="$(mktemp)"
cat "$env_path" > "$tmp_env"
printf '%s=%s\n' "$key" "$value" >> "$tmp_env"
mv "$tmp_env" "$env_path"
}
env_flag_enabled() {
case "${1:-0}" in
1|true|TRUE|yes|YES|on|ON)
return 0
;;
*)
return 1
;;
esac
}
detect_host_arch() {
local raw_arch
raw_arch="$(uname -m)"
normalize_arch "$raw_arch" || die "unsupported host architecture: $raw_arch"
}
load_manifest() {
MANIFEST_PACKAGE_ARCH_RAW=""
MANIFEST_PACKAGE_PLATFORM_RAW=""
if [[ -f "$MANIFEST_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$MANIFEST_FILE"
set +a
MANIFEST_PACKAGE_ARCH_RAW="${PACKAGE_ARCH:-${package_arch:-}}"
MANIFEST_PACKAGE_PLATFORM_RAW="${PACKAGE_PLATFORM:-${package_platform:-}}"
fi
}
effective_package_arch() {
if [[ -n "${PACKAGE_ARCH:-}" ]]; then
normalize_arch "$PACKAGE_ARCH" || die "unsupported PACKAGE_ARCH=${PACKAGE_ARCH}"
return 0
fi
if [[ -n "${PACKAGE_PLATFORM:-}" ]]; then
normalize_arch "$PACKAGE_PLATFORM" || die "unsupported PACKAGE_PLATFORM=${PACKAGE_PLATFORM}"
return 0
fi
if [[ -n "${RPKI_PLATFORM:-}" ]]; then
normalize_arch "$RPKI_PLATFORM" || die "unsupported RPKI_PLATFORM=${RPKI_PLATFORM}"
return 0
fi
if [[ -n "${METRICS_PLATFORM:-}" ]]; then
normalize_arch "$METRICS_PLATFORM" || die "unsupported METRICS_PLATFORM=${METRICS_PLATFORM}"
return 0
fi
if [[ -n "${MONITOR_PLATFORM:-}" ]]; then
normalize_arch "$MONITOR_PLATFORM" || die "unsupported MONITOR_PLATFORM=${MONITOR_PLATFORM}"
return 0
fi
die "unable to determine package architecture from manifest or env"
}
arch_mode() {
local host_arch package_arch
host_arch="$(detect_host_arch)"
package_arch="$(effective_package_arch)"
if [[ "$host_arch" == "$package_arch" ]]; then
printf 'native\n'
return 0
fi
if env_flag_enabled "${ALLOW_CROSS_ARCH:-0}"; then
printf 'cross-arch-enabled\n'
return 0
fi
printf 'mismatch-blocked\n'
}
assert_arch_compatibility() {
local host_arch package_arch package_platform mode
host_arch="$(detect_host_arch)"
package_arch="$(effective_package_arch)"
package_platform="${PACKAGE_PLATFORM:-${RPKI_PLATFORM:-$(platform_for_arch "$package_arch")}}"
mode="$(arch_mode)"
case "$mode" in
native)
return 0
;;
cross-arch-enabled)
log "cross-arch execution explicitly enabled: host_arch=$host_arch package_arch=$package_arch package_platform=$package_platform"
return 0
;;
mismatch-blocked)
die "package architecture mismatch: host_arch=$host_arch package_arch=$package_arch package_platform=$package_platform. Set ALLOW_CROSS_ARCH=1 in $ENV_FILE to allow explicit QEMU/binfmt emulation."
;;
*)
die "unknown arch compatibility mode: $mode"
;;
esac
}
load_env() {
load_manifest
if [[ ! -f "$ENV_FILE" ]]; then
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
cp "$ENV_EXAMPLE" "$ENV_FILE"
log "created .env from .env.example"
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
if [[ -n "${MANIFEST_PACKAGE_ARCH_RAW:-}" ]]; then
manifest_package_arch="$(normalize_arch "$MANIFEST_PACKAGE_ARCH_RAW")" || die "unsupported manifest package arch: $MANIFEST_PACKAGE_ARCH_RAW"
if [[ -n "${PACKAGE_ARCH:-}" ]]; then
env_package_arch="$(normalize_arch "$PACKAGE_ARCH")" || die "unsupported env package arch: $PACKAGE_ARCH"
[[ "$env_package_arch" == "$manifest_package_arch" ]] || die "env PACKAGE_ARCH=$env_package_arch mismatches manifest PACKAGE_ARCH=$manifest_package_arch"
fi
PACKAGE_ARCH="$manifest_package_arch"
else
if [[ -z "${PACKAGE_ARCH:-}" ]]; then
PACKAGE_ARCH="$(effective_package_arch)"
else
PACKAGE_ARCH="$(normalize_arch "$PACKAGE_ARCH")"
fi
fi
if [[ -n "${MANIFEST_PACKAGE_PLATFORM_RAW:-}" ]]; then
manifest_package_platform="$MANIFEST_PACKAGE_PLATFORM_RAW"
if [[ -n "${PACKAGE_PLATFORM:-}" && "$PACKAGE_PLATFORM" != "$manifest_package_platform" ]]; then
die "env PACKAGE_PLATFORM=$PACKAGE_PLATFORM mismatches manifest PACKAGE_PLATFORM=$manifest_package_platform"
fi
PACKAGE_PLATFORM="$manifest_package_platform"
else
PACKAGE_PLATFORM="${PACKAGE_PLATFORM:-$(platform_for_arch "$PACKAGE_ARCH")}"
fi
HOST_DATA_DIR="${HOST_DATA_DIR:-/var/lib/ours-rp-${PACKAGE_ARCH}-installer}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-ours-rp-${PACKAGE_ARCH}-installer}"
RPKI_IMAGE="${RPKI_IMAGE:-ours-rp-runtime-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
RPKI_PLATFORM="${RPKI_PLATFORM:-$PACKAGE_PLATFORM}"
METRICS_IMAGE="${METRICS_IMAGE:-ours-rp-metrics-${PACKAGE_ARCH}:${source_commit_short:-unknown}}"
METRICS_PLATFORM="${METRICS_PLATFORM:-$PACKAGE_PLATFORM}"
RTR_REPORT_DIR="${RTR_REPORT_DIR:-$HOST_DATA_DIR/empty-rtr-report}"
RTR_REPORT_CONTAINER_DIR="${RTR_REPORT_CONTAINER_DIR:-/var/lib/ours-rp/rtr-report}"
MONITOR_PLATFORM="${MONITOR_PLATFORM:-$PACKAGE_PLATFORM}"
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
METRICS_INSTANCE="${METRICS_INSTANCE:-${PACKAGE_ARCH}-installer}"
ALLOW_CROSS_ARCH="${ALLOW_CROSS_ARCH:-0}"
RESOURCE_VALIDATION_MODE="${RESOURCE_VALIDATION_MODE:-validation-update-03}"
FIRST_RUN_WAIT_TIMEOUT_SECS="${FIRST_RUN_WAIT_TIMEOUT_SECS:-7200}"
persist_env_default "$ENV_FILE" "METRICS_IMAGE" "$METRICS_IMAGE"
persist_env_default "$ENV_FILE" "METRICS_PLATFORM" "$METRICS_PLATFORM"
persist_env_default "$ENV_FILE" "RTR_REPORT_DIR" "$RTR_REPORT_DIR"
persist_env_default "$ENV_FILE" "RTR_REPORT_CONTAINER_DIR" "$RTR_REPORT_CONTAINER_DIR"
persist_env_default "$ENV_FILE" "RESOURCE_VALIDATION_MODE" "$RESOURCE_VALIDATION_MODE"
}
compose_cmd() {
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" -p "$COMPOSE_PROJECT_NAME" "$@"
}
validate_rtr_report_dir() {
load_env
[[ -n "${RTR_REPORT_DIR:-}" ]] || return 0
[[ "$RTR_REPORT_DIR" = /* ]] || die "RTR_REPORT_DIR must be an absolute host path: $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() {
load_env
mkdir -p \
"$HOST_DATA_DIR/state" \
"$HOST_DATA_DIR/runs" \
"$HOST_DATA_DIR/logs" \
"$HOST_DATA_DIR/tmp" \
"$HOST_DATA_DIR/empty-rtr-report" \
"$HOST_DATA_DIR/prometheus" \
"$HOST_DATA_DIR/grafana"
validate_rtr_report_dir
chmod 755 "$HOST_DATA_DIR" "$HOST_DATA_DIR/state" "$HOST_DATA_DIR/runs" "$HOST_DATA_DIR/logs" "$HOST_DATA_DIR/tmp" || true
chmod 777 "$HOST_DATA_DIR/prometheus" "$HOST_DATA_DIR/grafana" || true
}
latest_run_dir() {
load_env
find "$HOST_DATA_DIR/runs" -maxdepth 1 -mindepth 1 -type d -name 'run_*' 2>/dev/null | sort | tail -1
}
latest_success_run_dir() {
load_env
find "$HOST_DATA_DIR/runs" -maxdepth 2 -type f -path '*/run-summary.json' 2>/dev/null \
| while read -r summary; do
if jq -e '.status == "success"' "$summary" >/dev/null 2>&1; then
dirname "$summary"
fi
done | sort | tail -1
}
has_success_run() {
[[ -n "$(latest_success_run_dir)" ]]
}
print_run_summary() {
local run_dir="$1"
local summary="$run_dir/run-summary.json"
local meta="$run_dir/run-meta.json"
local timing="$run_dir/stage-timing.json"
local process_time="$run_dir/process-time.txt"
local vrps_file="$run_dir/vrps.csv"
local vaps_file="$run_dir/vaps.csv"
local status="unknown"
local sync_mode="unknown"
local wall_ms="null"
local validation_ms="null"
local repo_sync_ms="null"
local max_rss_kb="null"
local publication_points="null"
local vrps="null"
local vaps="null"
local warnings="null"
[[ -f "$summary" ]] || {
warn "missing run-summary.json in $run_dir"
return 1
}
status="$(jq -r '.status // "unknown"' "$summary" 2>/dev/null || echo unknown)"
wall_ms="$(jq -r '.wallMs // .wall_ms // "null"' "$summary" 2>/dev/null || echo null)"
warnings="$(jq -r '.warningCount // .warnings // "null"' "$summary" 2>/dev/null || echo null)"
if [[ -f "$meta" ]]; then
sync_mode="$(jq -r '.sync_mode // .syncMode // "unknown"' "$meta" 2>/dev/null || echo unknown)"
status="$(jq -r --arg fallback "$status" '.status // $fallback' "$meta" 2>/dev/null || echo "$status")"
fi
if [[ -f "$timing" ]]; then
validation_ms="$(jq -r '.validation_ms // "null"' "$timing" 2>/dev/null || echo null)"
repo_sync_ms="$(jq -r '.repo_sync_ms_total // "null"' "$timing" 2>/dev/null || echo null)"
publication_points="$(jq -r '.publication_points // "null"' "$timing" 2>/dev/null || echo null)"
fi
if [[ -f "$process_time" ]]; then
max_rss_kb="$(awk -F': ' '/Maximum resident set size/ {print $2; found=1} END {if (!found) print "null"}' "$process_time")"
fi
if [[ -f "$vrps_file" ]]; then
vrps="$(( $(wc -l < "$vrps_file") > 0 ? $(wc -l < "$vrps_file") - 1 : 0 ))"
fi
if [[ -f "$vaps_file" ]]; then
vaps="$(( $(wc -l < "$vaps_file") > 0 ? $(wc -l < "$vaps_file") - 1 : 0 ))"
fi
jq -n \
--arg run "$(basename "$run_dir")" \
--arg status "$status" \
--arg syncMode "$sync_mode" \
--argjson wallMs "$wall_ms" \
--argjson validationMs "$validation_ms" \
--argjson repoSyncMs "$repo_sync_ms" \
--argjson maxRssKb "$max_rss_kb" \
--argjson vrps "$vrps" \
--argjson vaps "$vaps" \
--argjson publicationPoints "$publication_points" \
--argjson warnings "$warnings" \
'{run:$run,status:$status,syncMode:$syncMode,wallMs:$wallMs,validationMs:$validationMs,repoSyncMs:$repoSyncMs,maxRssKb:$maxRssKb,vrps:$vrps,vaps:$vaps,publicationPoints:$publicationPoints,warnings:$warnings}'
}
wait_for_new_success_run() {
local before_latest="$1"
local timeout_secs="$2"
local start_epoch now run_dir summary meta status meta_status
start_epoch="$(date +%s)"
while true; do
run_dir="$(latest_run_dir || true)"
if [[ -n "$run_dir" && "$run_dir" != "$before_latest" ]]; then
summary="$run_dir/run-summary.json"
meta="$run_dir/run-meta.json"
if [[ -f "$summary" ]]; then
status="$(jq -r '.status // "unknown"' "$summary" 2>/dev/null || echo unknown)"
if [[ "$status" == "success" ]]; then
meta_status="unknown"
if [[ -f "$meta" ]]; then
meta_status="$(jq -r '.status // "unknown"' "$meta" 2>/dev/null || echo unknown)"
fi
if [[ "$meta_status" == "success" ]]; then
print_run_summary "$run_dir" || true
return 0
fi
fi
if [[ "$status" == "failed" || "$status" == "error" ]]; then
print_run_summary "$run_dir" || true
die "run failed: $run_dir"
fi
fi
fi
now="$(date +%s)"
if (( now - start_epoch > timeout_secs )); then
die "timed out waiting for first successful run after ${timeout_secs}s"
fi
sleep 10
done
}
docker_compose_available() {
docker compose version >/dev/null 2>&1
}
install_docker_if_missing() {
if command -v docker >/dev/null 2>&1 && docker_compose_available && command -v jq >/dev/null 2>&1 && command -v rsync >/dev/null 2>&1 && command -v curl >/dev/null 2>&1; then
log "docker and docker compose are already installed"
return 0
fi
if [[ "${SKIP_DEP_INSTALL:-0}" == "1" ]]; then
die "docker/docker compose missing and SKIP_DEP_INSTALL=1"
fi
if ! command -v apt-get >/dev/null 2>&1; then
die "docker/docker compose missing; automatic install currently supports apt-get only"
fi
log "installing missing runtime packages via apt"
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl jq rsync gzip tar docker.io
if ! docker_compose_available; then
if apt-cache show docker-compose-v2 >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-v2
elif apt-cache show docker-compose-plugin >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-plugin
elif apt-cache show docker-compose >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose
fi
fi
systemctl enable --now docker >/dev/null 2>&1 || true
docker_compose_available || die "docker compose is still unavailable after install"
}
load_installer_images() {
load_env
require_cmd docker
shopt -s nullglob
local image load_output desired_tag
local found=0
for image in "$INSTALLER_ROOT"/images/*.tar "$INSTALLER_ROOT"/images/*.tar.gz; do
found=1
log "loading docker image: $image"
if [[ "$image" == *.gz ]]; then
load_output="$(gzip -dc "$image" | docker load)"
else
load_output="$(docker load -i "$image")"
fi
printf '%s\n' "$load_output"
desired_tag=""
case "$(basename "$image")" in
"${image_tar:-}")
desired_tag="${image_tag:-$RPKI_IMAGE}"
;;
"${metrics_image_tar:-}")
desired_tag="${metrics_image:-$METRICS_IMAGE}"
;;
"${prometheus_image_tar:-}")
desired_tag="${prometheus_image:-$PROMETHEUS_IMAGE}"
;;
"${grafana_image_tar:-}")
desired_tag="${grafana_image:-$GRAFANA_IMAGE}"
;;
esac
if [[ -n "$desired_tag" ]]; then
ensure_loaded_image_tag "$desired_tag" "$load_output" "$image"
fi
done
shopt -u nullglob
(( found == 1 )) || warn "no image tar found under $INSTALLER_ROOT/images"
}
ensure_loaded_image_tag() {
local desired_tag="$1"
local load_output="$2"
local image_path="$3"
local loaded_ref=""
local loaded_id=""
local desired_id=""
loaded_ref="$(printf '%s\n' "$load_output" | awk -F'Loaded image: ' '/Loaded image: / {print $2; exit}')"
if [[ -z "$loaded_ref" ]]; then
loaded_ref="$(printf '%s\n' "$load_output" | awk -F'Loaded image ID: ' '/Loaded image ID: / {print $2; exit}')"
fi
[[ -n "$loaded_ref" ]] || die "unable to determine loaded image reference for $image_path"
loaded_id="$(docker image inspect --format '{{.Id}}' "$loaded_ref" 2>/dev/null || true)"
desired_id="$(docker image inspect --format '{{.Id}}' "$desired_tag" 2>/dev/null || true)"
if [[ -n "$loaded_id" && -n "$desired_id" && "$loaded_id" == "$desired_id" ]]; then
return 0
fi
log "tagging loaded image for package use: source=$loaded_ref target=$desired_tag"
docker tag "$loaded_ref" "$desired_tag"
}
ensure_binfmt_if_needed() {
require_cmd docker
load_env
local host_arch package_arch
host_arch="$(detect_host_arch)"
package_arch="$(effective_package_arch)"
if [[ "$host_arch" == "$package_arch" ]]; then
return 0
fi
assert_arch_compatibility
log "host arch is $host_arch; enabling binfmt/qemu for package arch $package_arch"
docker run --rm --privileged tonistiigi/binfmt --install "$package_arch"
}
verify_runtime_image() {
load_env
require_cmd docker
log "verifying runtime image $RPKI_IMAGE on $RPKI_PLATFORM"
verify_image_platform "$RPKI_IMAGE" "$RPKI_PLATFORM" "runtime"
verify_image_usage_output "$RPKI_IMAGE" "$RPKI_PLATFORM" "runtime" /opt/ours-rp/bin/rpki --help
}
verify_metrics_image() {
load_env
require_cmd docker
log "verifying metrics image $METRICS_IMAGE on $METRICS_PLATFORM"
verify_image_platform "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics"
verify_image_usage_output "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics" /opt/ours-rp/bin/rpki_artifact_metrics --help
}
verify_image_platform() {
local image="$1"
local expected_platform="$2"
local role="$3"
local actual_platform
docker image inspect "$image" >/dev/null
actual_platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null || echo unknown)"
[[ "$actual_platform" == "$expected_platform" ]] || die "$role image platform mismatch: image=$image expected=$expected_platform actual=$actual_platform"
}
verify_image_usage_output() {
local image="$1"
local expected_platform="$2"
local role="$3"
shift 3
local help_path
local status
help_path="$(mktemp "${TMPDIR:-/tmp}/ours-rp-image-help.XXXXXX")"
set +e
docker run --rm --platform "$expected_platform" "$image" "$@" >"$help_path" 2>&1
status=$?
set -e
if [[ "$status" != "0" && "$status" != "1" ]]; then
cat "$help_path" >&2 || true
rm -f "$help_path"
die "$role image command failed: image=$image platform=$expected_platform exit=$status"
fi
grep -q '^Usage' "$help_path" || {
cat "$help_path" >&2 || true
rm -f "$help_path"
die "$role image did not emit usage output: image=$image platform=$expected_platform exit=$status"
}
head -5 "$help_path" || true
rm -f "$help_path"
}
verify_monitor_images() {
load_env
require_cmd docker
verify_image_platform "$PROMETHEUS_IMAGE" "$MONITOR_PLATFORM" "prometheus"
verify_image_platform "$GRAFANA_IMAGE" "$MONITOR_PLATFORM" "grafana"
}
endpoint_ok() {
local url="$1"
curl -fsS --max-time 5 "$url" >/dev/null 2>&1
}

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"
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 ARM64 compose package idempotently. Install or update the ours RP Docker installer package idempotently.
USAGE USAGE
} }
@@ -29,6 +29,7 @@ while [[ $# -gt 0 ]]; do
done done
load_env load_env
assert_arch_compatibility
install_docker_if_missing install_docker_if_missing
require_cmd curl require_cmd curl
require_cmd jq require_cmd jq
@@ -39,7 +40,10 @@ create_data_dirs
load_installer_images load_installer_images
ensure_binfmt_if_needed ensure_binfmt_if_needed
verify_runtime_image verify_runtime_image
verify_metrics_image
verify_monitor_images verify_monitor_images
compose_cmd --profile core --profile sidecar --profile monitor config >/tmp/ours-rp-arm64-compose-config.yml compose_config_path="$(mktemp "${TMPDIR:-/tmp}/ours-rp-compose-config.XXXXXX.yml")"
compose_cmd --profile core --profile sidecar --profile monitor config >"$compose_config_path"
"$SCRIPT_DIR/self-check.sh" --quick "$SCRIPT_DIR/self-check.sh" --quick
rm -f "$compose_config_path"
log "install complete" log "install complete"

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
;; ;;
*) *)
@@ -22,6 +22,7 @@ while [[ $# -gt 0 ]]; do
done done
load_env load_env
assert_arch_compatibility
require_cmd docker require_cmd docker
require_cmd jq require_cmd jq
docker compose version >/dev/null docker compose version >/dev/null
@@ -31,8 +32,10 @@ create_data_dirs
[[ -w "$HOST_DATA_DIR" ]] || die "data dir is not writable: $HOST_DATA_DIR" [[ -w "$HOST_DATA_DIR" ]] || die "data dir is not writable: $HOST_DATA_DIR"
compose_cmd --profile core --profile sidecar --profile monitor config >/dev/null compose_cmd --profile core --profile sidecar --profile monitor config >/dev/null
verify_image_platform "$RPKI_IMAGE" "$RPKI_PLATFORM" "runtime" verify_image_platform "$RPKI_IMAGE" "$RPKI_PLATFORM" "runtime"
verify_image_platform "$METRICS_IMAGE" "$METRICS_PLATFORM" "metrics"
verify_monitor_images verify_monitor_images
if [[ "$QUICK" == "0" ]]; then if [[ "$QUICK" == "0" ]]; then
verify_runtime_image verify_runtime_image
verify_metrics_image
fi fi
log "self-check ok" log "self-check ok"

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.
@@ -37,6 +37,7 @@ while [[ $# -gt 0 ]]; do
done done
load_env load_env
assert_arch_compatibility
create_data_dirs create_data_dirs
timeout_secs="${TIMEOUT_SECS:-$FIRST_RUN_WAIT_TIMEOUT_SECS}" timeout_secs="${TIMEOUT_SECS:-$FIRST_RUN_WAIT_TIMEOUT_SECS}"
before_latest="$(latest_run_dir || true)" before_latest="$(latest_run_dir || true)"

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
BRIEF=0
while [[ $# -gt 0 ]]; do
case "$1" in
--brief)
BRIEF=1
shift
;;
-h|--help)
echo "Usage: ./scripts/status.sh [--brief]"
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
load_env
host_arch="$(detect_host_arch)"
mode="$(arch_mode)"
echo "installer_root=$INSTALLER_ROOT"
echo "manifest_file=$MANIFEST_FILE"
echo "package_name=${package_name:-}"
echo "source_commit=${source_commit:-${git_commit:-}}"
echo "source_commit_short=${source_commit_short:-}"
echo "source_dirty=${source_dirty:-}"
echo "build_timestamp_utc=${build_timestamp_utc:-${created_at_utc:-}}"
echo "host_data_dir=$HOST_DATA_DIR"
echo "host_arch=$host_arch"
echo "package_arch=$PACKAGE_ARCH"
echo "package_platform=$PACKAGE_PLATFORM"
echo "allow_cross_arch=$ALLOW_CROSS_ARCH"
echo "arch_mode=$mode"
echo "runtime_image=$RPKI_IMAGE"
echo "runtime_platform=$RPKI_PLATFORM"
echo "runtime_image_revision=${runtime_image_revision:-}"
echo "runtime_image_dirty=${runtime_image_dirty:-}"
echo "metrics_image=$METRICS_IMAGE"
echo "metrics_platform=$METRICS_PLATFORM"
echo "metrics_image_revision=${metrics_image_revision:-}"
echo "metrics_image_dirty=${metrics_image_dirty:-}"
echo "rtr_report_dir=$RTR_REPORT_DIR"
echo "rtr_report_container_dir=$RTR_REPORT_CONTAINER_DIR"
echo "monitor_platform=$MONITOR_PLATFORM"
echo "rirs=${RIRS:-}"
echo "max_runs=${MAX_RUNS:-}"
echo "interval_secs=${INTERVAL_SECS:-}"
echo "periodic_snapshot_reset=${PERIODIC_SNAPSHOT_RESET:-0}"
echo "periodic_snapshot_max_deltas=${PERIODIC_SNAPSHOT_MAX_DELTAS:-100}"
echo
if command -v docker >/dev/null 2>&1; then
docker version --format 'docker={{.Server.Version}}' 2>/dev/null || echo "docker=unavailable"
docker compose version 2>/dev/null || true
compose_cmd --profile core --profile sidecar --profile monitor ps || true
else
echo "docker=missing"
fi
echo
df -h "$HOST_DATA_DIR" 2>/dev/null || true
echo
latest="$(latest_run_dir || true)"
if [[ -n "$latest" ]]; then
echo "latest_run=$latest"
print_run_summary "$latest" || true
else
echo "latest_run=none"
fi
if [[ "$BRIEF" == "0" ]]; then
echo
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:${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

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

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
REUSE_ENV_FROM=""
UPDATE_PACKAGE_IMAGE=1
usage() {
cat <<'USAGE'
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
.env.example first, then overlays existing user settings from the old .env.
Image tags are intentionally kept from the new package so the upgraded service
actually runs the new packaged runtime and monitor images. Use
--keep-reused-image only when you intentionally want to keep previous image tags.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--reuse-env-from)
REUSE_ENV_FROM="$2"
shift 2
;;
--keep-reused-image)
UPDATE_PACKAGE_IMAGE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
env_get_key() {
local env_path="$1"
local key="$2"
awk -F= -v key="$key" '$1 == key {sub(/^[^=]*=/, ""); print; exit}' "$env_path"
}
env_set_key() {
local env_path="$1"
local key="$2"
local value="$3"
local tmp_env
if grep -q "^${key}=" "$env_path"; then
tmp_env="$(mktemp)"
awk -v key="$key" -v value="$value" '
BEGIN { done=0 }
$0 ~ "^" key "=" { print key "=" value; done=1; next }
{ print }
END { if (!done) print key "=" value }
' "$env_path" > "$tmp_env"
mv "$tmp_env" "$env_path"
else
printf '%s=%s\n' "$key" "$value" >> "$env_path"
fi
}
overlay_reused_env() {
local source_env="$1"
local target_env="$2"
local key
local value
while IFS='=' read -r key _; do
[[ -n "$key" ]] || continue
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
case "$key" in
PACKAGE_ARCH|PACKAGE_PLATFORM|RPKI_PLATFORM|METRICS_PLATFORM|MONITOR_PLATFORM)
continue
;;
RPKI_IMAGE|METRICS_IMAGE|PROMETHEUS_IMAGE|GRAFANA_IMAGE)
[[ "$UPDATE_PACKAGE_IMAGE" == "0" ]] || continue
;;
esac
value="$(env_get_key "$source_env" "$key")"
env_set_key "$target_env" "$key" "$value"
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$source_env" || true)
}
if [[ -n "$REUSE_ENV_FROM" ]]; then
[[ -f "$REUSE_ENV_FROM" ]] || die "missing reuse env file: $REUSE_ENV_FROM"
if [[ ! -f "$ENV_FILE" ]]; then
[[ -f "$ENV_EXAMPLE" ]] || die "missing $ENV_EXAMPLE"
cp "$ENV_EXAMPLE" "$ENV_FILE"
overlay_reused_env "$REUSE_ENV_FROM" "$ENV_FILE"
log "created new package env from .env.example and overlaid user settings from $REUSE_ENV_FROM"
else
log "keeping existing env at $ENV_FILE; reuse source ignored: $REUSE_ENV_FROM"
fi
fi
load_env
assert_arch_compatibility
create_data_dirs
install_docker_if_missing
load_installer_images
ensure_binfmt_if_needed
verify_runtime_image
verify_metrics_image
verify_monitor_images
compose_cmd --profile core --profile sidecar --profile monitor up -d --force-recreate
"$SCRIPT_DIR/status.sh" --brief || true

View File

@@ -0,0 +1,117 @@
ARG BUILDER_IMAGE=rust:1-bookworm
ARG RUNTIME_IMAGE=debian:bookworm-slim
FROM --platform=$BUILDPLATFORM ${BUILDER_IMAGE} AS builder
ARG BUILDARCH
ARG TARGETARCH
ARG TARGETPLATFORM
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
clang \
cmake \
git \
libclang-dev \
make \
perl \
pkg-config \
python3 \
&& if [ "$TARGETARCH" != "$BUILDARCH" ]; then \
case "$TARGETARCH" in \
arm64) \
apt-get install -y --no-install-recommends \
g++-aarch64-linux-gnu \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
;; \
amd64) \
apt-get install -y --no-install-recommends \
g++-x86-64-linux-gnu \
gcc-x86-64-linux-gnu \
libc6-dev-amd64-cross \
;; \
*) \
echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 \
;; \
esac; \
fi \
&& rm -rf /var/lib/apt/lists/* \
&& case "$TARGETARCH" in \
amd64|arm64) ;; \
*) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 ;; \
esac
COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \
set -eux; \
case "$TARGETARCH" in \
amd64) target_triple=x86_64-unknown-linux-gnu ;; \
arm64) target_triple=aarch64-unknown-linux-gnu ;; \
*) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 ;; \
esac; \
rustup target add "$target_triple"; \
if [ "$TARGETARCH" != "$BUILDARCH" ]; then \
case "$TARGETARCH" in \
arm64) \
export \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
PKG_CONFIG_ALLOW_CROSS=1 \
;; \
amd64) \
export \
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \
CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \
CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++ \
AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar \
PKG_CONFIG_ALLOW_CROSS=1 \
;; \
*) \
echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 \
;; \
esac; \
fi; \
cargo build --release --target "$target_triple" --bin rpki_artifact_metrics; \
mkdir -p /build-out/bin; \
cp "target/$target_triple/release/rpki_artifact_metrics" /build-out/bin/
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/ours-rp
COPY --from=builder /build-out/bin/ /opt/ours-rp/bin/
ARG SOURCE_COMMIT=unknown
ARG SOURCE_DIRTY=unknown
ARG BUILD_TIMESTAMP_UTC=unknown
LABEL org.opencontainers.image.title="ours-rp-metrics" \
org.opencontainers.image.description="Ours RP artifact metrics image for multi-arch Docker Compose deployment" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}"
RUN chmod +x /opt/ours-rp/bin/* \
&& mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs
ENV RUN_ROOT=/var/lib/ours-rp \
BIN_DIR=/opt/ours-rp/bin \
RUST_BACKTRACE=1
VOLUME ["/var/lib/ours-rp"]

View File

@@ -1,8 +1,12 @@
ARG BUILDER_IMAGE=ours-rp-base-rust-amd64:1-bookworm ARG BUILDER_IMAGE=rust:1-bookworm
ARG RUNTIME_IMAGE=ours-rp-base-debian-arm64:bookworm-slim ARG RUNTIME_IMAGE=debian:bookworm-slim
FROM --platform=$BUILDPLATFORM ${BUILDER_IMAGE} AS builder FROM --platform=$BUILDPLATFORM ${BUILDER_IMAGE} AS builder
ARG BUILDARCH
ARG TARGETARCH
ARG TARGETPLATFORM
WORKDIR /src WORKDIR /src
RUN apt-get update \ RUN apt-get update \
@@ -11,48 +15,85 @@ RUN apt-get update \
ca-certificates \ ca-certificates \
clang \ clang \
cmake \ cmake \
g++-aarch64-linux-gnu \
gcc-aarch64-linux-gnu \
git \ git \
libclang-dev \ libclang-dev \
libc6-dev-arm64-cross \
make \ make \
perl \ perl \
pkg-config \ pkg-config \
python3 \ python3 \
&& if [ "$TARGETARCH" != "$BUILDARCH" ]; then \
case "$TARGETARCH" in \
arm64) \
apt-get install -y --no-install-recommends \
g++-aarch64-linux-gnu \
gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
;; \
amd64) \
apt-get install -y --no-install-recommends \
g++-x86-64-linux-gnu \
gcc-x86-64-linux-gnu \
libc6-dev-amd64-cross \
;; \
*) \
echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 \
;; \
esac; \
fi \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rustup target add aarch64-unknown-linux-gnu && case "$TARGETARCH" in \
amd64|arm64) ;; \
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 ;; \
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ esac
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
CARGO_BUILD_TARGET=aarch64-unknown-linux-gnu \
PKG_CONFIG_ALLOW_CROSS=1
COPY . . COPY . .
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/src/target \ --mount=type=cache,target=/src/target \
cargo build --release --target aarch64-unknown-linux-gnu \ set -eux; \
case "$TARGETARCH" in \
amd64) target_triple=x86_64-unknown-linux-gnu ;; \
arm64) target_triple=aarch64-unknown-linux-gnu ;; \
*) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 ;; \
esac; \
rustup target add "$target_triple"; \
if [ "$TARGETARCH" != "$BUILDARCH" ]; then \
case "$TARGETARCH" in \
arm64) \
export \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar \
PKG_CONFIG_ALLOW_CROSS=1 \
;; \
amd64) \
export \
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \
CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \
CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++ \
AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar \
PKG_CONFIG_ALLOW_CROSS=1 \
;; \
*) \
echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 2 \
;; \
esac; \
fi; \
cargo build --release --target "$target_triple" \
--bin rpki \ --bin rpki \
--bin rpki_daemon \ --bin rpki_daemon \
--bin db_stats \ --bin db_stats; \
--bin rpki_artifact_metrics \ mkdir -p /build-out/bin; \
&& mkdir -p /build-out/bin \ cp \
&& cp \ "target/$target_triple/release/rpki" \
target/aarch64-unknown-linux-gnu/release/rpki \ "target/$target_triple/release/rpki_daemon" \
target/aarch64-unknown-linux-gnu/release/rpki_daemon \ "target/$target_triple/release/db_stats" \
target/aarch64-unknown-linux-gnu/release/db_stats \
target/aarch64-unknown-linux-gnu/release/rpki_artifact_metrics \
/build-out/bin/ /build-out/bin/
FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS runtime
LABEL org.opencontainers.image.title="ours-rp-runtime" \
org.opencontainers.image.description="Ours RP runtime image for ARM64 Docker Compose deployment"
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
bash \ bash \
@@ -79,6 +120,16 @@ COPY tests/fixtures/ta/ /opt/ours-rp/fixtures/ta/
COPY fixtures/live_20260619/tal/ /opt/ours-rp/fixtures/live_20260619/tal/ COPY fixtures/live_20260619/tal/ /opt/ours-rp/fixtures/live_20260619/tal/
COPY fixtures/live_20260619/ta/ /opt/ours-rp/fixtures/live_20260619/ta/ COPY fixtures/live_20260619/ta/ /opt/ours-rp/fixtures/live_20260619/ta/
ARG SOURCE_COMMIT=unknown
ARG SOURCE_DIRTY=unknown
ARG BUILD_TIMESTAMP_UTC=unknown
LABEL org.opencontainers.image.title="ours-rp-runtime" \
org.opencontainers.image.description="Ours RP runtime image for multi-arch Docker Compose deployment" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.created="${BUILD_TIMESTAMP_UTC}" \
org.opencontainers.image.source-dirty="${SOURCE_DIRTY}"
RUN chmod +x /opt/ours-rp/run_soak.sh /opt/ours-rp/bin/* \ RUN chmod +x /opt/ours-rp/run_soak.sh /opt/ours-rp/bin/* \
&& mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs /var/lib/ours-rp/tmp && mkdir -p /var/lib/ours-rp/state /var/lib/ours-rp/runs /var/lib/ours-rp/logs /var/lib/ours-rp/tmp

View File

@@ -0,0 +1,666 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 1,
"title": "RTR Metrics Enabled",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 4,
"x": 0,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_metrics_enabled)",
"legendFormat": "enabled",
"refId": "A",
"instant": true
}
]
},
{
"id": 2,
"title": "Refresh Success",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 4,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_source_refresh_status{status=\"success\"})",
"legendFormat": "success",
"refId": "A",
"instant": true
}
]
},
{
"id": 3,
"title": "Consecutive Failures",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 9,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_source_refresh_consecutive_failures)",
"legendFormat": "failures",
"refId": "A",
"instant": true
}
]
},
{
"id": 4,
"title": "Last Success Age",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 14,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "s",
"decimals": 0,
"min": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_source_last_success_age_seconds)",
"legendFormat": "age",
"refId": "A",
"instant": true
}
]
},
{
"id": 5,
"title": "RTR RSS",
"type": "stat",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 4,
"w": 5,
"x": 19,
"y": 0
},
"fieldConfig": {
"defaults": {
"unit": "bytes",
"decimals": 0,
"min": 0
},
"overrides": []
},
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"textMode": "auto",
"wideLayout": true
},
"targets": [
{
"expr": "max(ours_rp_rtr_process_rss_bytes)",
"legendFormat": "rss",
"refId": "A",
"instant": true
}
]
},
{
"id": 6,
"title": "Data Quality Totals",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 4
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_data_quality_items{stage=~\"ccr_input|before_slurm|after_slurm\",type=\"total\"}",
"legendFormat": "{{stage}} total",
"refId": "A"
},
{
"expr": "ours_rp_rtr_data_quality_items{stage=\"after_slurm\",type=\"vrp\"}",
"legendFormat": "after_slurm vrp",
"refId": "B"
},
{
"expr": "ours_rp_rtr_data_quality_items{stage=\"after_slurm\",type=\"aspa\"}",
"legendFormat": "after_slurm aspa",
"refId": "C"
}
]
},
{
"id": 7,
"title": "SLURM Filters / Assertions",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 4
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_slurm_filters",
"legendFormat": "filter {{type}}",
"refId": "A"
},
{
"expr": "ours_rp_rtr_slurm_assertions",
"legendFormat": "assert {{type}}",
"refId": "B"
}
]
},
{
"id": 8,
"title": "Cache Ready / Delta Window",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 12
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_cache_ready",
"legendFormat": "ready",
"refId": "A"
},
{
"expr": "ours_rp_rtr_cache_delta_window_length",
"legendFormat": "v{{version}} length",
"refId": "B"
}
]
},
{
"id": 9,
"title": "Cache Snapshot Items",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 12
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_cache_snapshot_items",
"legendFormat": "v{{version}} {{type}}",
"refId": "A"
}
]
},
{
"id": 10,
"title": "Latest Delta Items",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 12
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_cache_delta_items",
"legendFormat": "v{{version}} {{direction}} {{type}}",
"refId": "A"
}
]
},
{
"id": 11,
"title": "Connections",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 20
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_active_connections",
"legendFormat": "active",
"refId": "A"
},
{
"expr": "ours_rp_rtr_connections",
"legendFormat": "{{transport}}",
"refId": "B"
}
]
},
{
"id": 12,
"title": "Connection Utilization / Max",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 20
},
"fieldConfig": {
"defaults": {
"min": 0
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": ".*utilization.*"
},
"properties": [
{
"id": "unit",
"value": "percentunit"
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": ".*max.*"
},
"properties": [
{
"id": "unit",
"value": "short"
}
]
}
]
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_connection_utilization",
"legendFormat": "utilization",
"refId": "A"
},
{
"expr": "ours_rp_rtr_max_connections",
"legendFormat": "max",
"refId": "B"
}
]
},
{
"id": 13,
"title": "Report Age",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 20
},
"fieldConfig": {
"defaults": {
"unit": "s",
"min": 0
},
"overrides": []
},
"options": {
"legend": {
"calcs": [
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"expr": "ours_rp_rtr_source_report_age_seconds",
"legendFormat": "source",
"refId": "A"
},
{
"expr": "ours_rp_rtr_runtime_report_age_seconds",
"legendFormat": "runtime",
"refId": "B"
},
{
"expr": "ours_rp_rtr_clients_report_age_seconds",
"legendFormat": "clients",
"refId": "C"
}
]
}
],
"refresh": "10s",
"schemaVersion": 40,
"tags": [
"rpki",
"inter-rp",
"routinator"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timezone": "browser",
"title": "RTR Service Overview",
"uid": "ours-rp-rtr-overview",
"version": 1
}

View File

@@ -2,215 +2,4 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" exec "$SCRIPT_DIR/build_docker_installer_package.sh" --arch arm64 "$@"
IMAGE_TAG="${IMAGE_TAG:-ours-rp-runtime-arm64:dev}"
IMAGE_TAR="${IMAGE_TAR:-}"
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
PROMETHEUS_IMAGE_TAR="${PROMETHEUS_IMAGE_TAR:-}"
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
GRAFANA_IMAGE_TAR="${GRAFANA_IMAGE_TAR:-}"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/target/arm64-installer}"
PACKAGE_PREFIX="${PACKAGE_PREFIX:-ours-rp-arm64-installer}"
TEMPLATE_DIR="${TEMPLATE_DIR:-$REPO_ROOT/deploy/arm64-installer}"
MONITOR_PLATFORM="${MONITOR_PLATFORM:-linux/arm64}"
usage() {
cat <<'USAGE'
Usage:
scripts/docker/build_arm64_installer_package.sh [options]
Options:
--image <tag> Runtime image tag recorded in package manifest.
--image-tar <path> Existing docker save tar/tar.gz to include.
--prometheus-image <tag>
Prometheus image tag to record and package.
--prometheus-image-tar <path>
Existing Prometheus docker save tar/tar.gz to include.
--grafana-image <tag>
Grafana image tag to record and package.
--grafana-image-tar <path>
Existing Grafana docker save tar/tar.gz to include.
--out-dir <path> Output directory.
--prefix <name> Package directory/tar prefix.
-h, --help Show help.
If --image-tar is omitted, the script uses the newest
target/arm64-docker/*.tar.gz file.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
IMAGE_TAG="$2"
shift 2
;;
--image-tar)
IMAGE_TAR="$2"
shift 2
;;
--prometheus-image)
PROMETHEUS_IMAGE="$2"
shift 2
;;
--prometheus-image-tar)
PROMETHEUS_IMAGE_TAR="$2"
shift 2
;;
--grafana-image)
GRAFANA_IMAGE="$2"
shift 2
;;
--grafana-image-tar)
GRAFANA_IMAGE_TAR="$2"
shift 2
;;
--out-dir)
OUT_DIR="$2"
shift 2
;;
--prefix)
PACKAGE_PREFIX="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ -d "$TEMPLATE_DIR" ]] || {
echo "missing template dir: $TEMPLATE_DIR" >&2
exit 2
}
if [[ -z "$IMAGE_TAR" ]]; then
IMAGE_TAR="$(find "$REPO_ROOT/target/arm64-docker" -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.tar' \) -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk 'NR==1 {print $2}')"
fi
[[ -n "$IMAGE_TAR" && -f "$IMAGE_TAR" ]] || {
cat >&2 <<EOF
missing runtime image tar.
Build one first, for example:
scripts/docker/build_arm64_runtime_image.sh --image $IMAGE_TAG
EOF
exit 2
}
safe_tag_name() {
printf '%s' "$1" | tr '/:' '--'
}
save_image_if_needed() {
local image="$1"
local existing_tar="$2"
local out_dir="$3"
local role="$4"
if [[ -n "$existing_tar" ]]; then
[[ -f "$existing_tar" ]] || {
echo "missing $role image tar: $existing_tar" >&2
exit 2
}
printf '%s\n' "$existing_tar"
return 0
fi
if ! docker image inspect "$image" >/dev/null 2>&1; then
cat >&2 <<EOF
missing local $role image: $image
Prepare it before building the installer package, for example:
docker pull --platform $MONITOR_PLATFORM $image
EOF
exit 2
fi
local actual_platform
actual_platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null || echo unknown)"
if [[ "$actual_platform" != "$MONITOR_PLATFORM" ]]; then
cat >&2 <<EOF
wrong platform for $role image: $image
expected: $MONITOR_PLATFORM
actual: $actual_platform
Pull the ARM64 variant explicitly:
docker pull --platform $MONITOR_PLATFORM $image
EOF
exit 2
fi
local tar_path="$out_dir/$(safe_tag_name "$image").tar.gz"
echo "saving $role image to $tar_path" >&2
docker save "$image" | gzip -c > "$tar_path"
printf '%s\n' "$tar_path"
}
mkdir -p "$OUT_DIR"
commit="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
package_name="${PACKAGE_PREFIX}-${timestamp}-${commit}"
stage="$OUT_DIR/$package_name"
tar_path="$OUT_DIR/$package_name.tar.gz"
rm -rf "$stage"
rsync -a --delete "$TEMPLATE_DIR"/ "$stage"/
mkdir -p "$stage/images"
cp "$IMAGE_TAR" "$stage/images/"
monitor_image_stage="$OUT_DIR/.monitor-images-$timestamp"
rm -rf "$monitor_image_stage"
mkdir -p "$monitor_image_stage"
prometheus_tar="$(save_image_if_needed "$PROMETHEUS_IMAGE" "$PROMETHEUS_IMAGE_TAR" "$monitor_image_stage" "prometheus")"
grafana_tar="$(save_image_if_needed "$GRAFANA_IMAGE" "$GRAFANA_IMAGE_TAR" "$monitor_image_stage" "grafana")"
cp "$prometheus_tar" "$stage/images/"
cp "$grafana_tar" "$stage/images/"
if [[ -f "$stage/.env.example" ]]; then
tmp_env="$stage/.env.example.tmp"
awk -v image="$IMAGE_TAG" -v prometheus="$PROMETHEUS_IMAGE" -v grafana="$GRAFANA_IMAGE" -v monitor_platform="$MONITOR_PLATFORM" '
BEGIN { done=0 }
/^RPKI_IMAGE=/ { print "RPKI_IMAGE=" image; done=1; next }
/^PROMETHEUS_IMAGE=/ { print "PROMETHEUS_IMAGE=" prometheus; next }
/^GRAFANA_IMAGE=/ { print "GRAFANA_IMAGE=" grafana; next }
/^MONITOR_PLATFORM=/ { print "MONITOR_PLATFORM=" monitor_platform; next }
{ print }
END { if (!done) print "RPKI_IMAGE=" image }
' "$stage/.env.example" > "$tmp_env"
mv "$tmp_env" "$stage/.env.example"
fi
cat > "$stage/PACKAGE-MANIFEST.env" <<EOF
package_name=$package_name
created_at_utc=$timestamp
git_commit=$commit
git_status_count=$(git -C "$REPO_ROOT" status --short 2>/dev/null | wc -l | tr -d ' ')
image_tag=$IMAGE_TAG
image_tar=$(basename "$IMAGE_TAR")
image_tar_size_bytes=$(wc -c < "$IMAGE_TAR")
prometheus_image=$PROMETHEUS_IMAGE
prometheus_image_tar=$(basename "$prometheus_tar")
prometheus_image_tar_size_bytes=$(wc -c < "$prometheus_tar")
grafana_image=$GRAFANA_IMAGE
grafana_image_tar=$(basename "$grafana_tar")
grafana_image_tar_size_bytes=$(wc -c < "$grafana_tar")
target_platform=linux/arm64
monitor_platform=$MONITOR_PLATFORM
EOF
chmod +x "$stage"/*.sh "$stage/scripts"/*.sh
tar -C "$OUT_DIR" -czf "$tar_path" "$package_name"
rm -rf "$monitor_image_stage"
{
echo "package=$tar_path"
echo "package_dir=$stage"
echo "package_size_bytes=$(wc -c < "$tar_path")"
echo "manifest=$stage/PACKAGE-MANIFEST.env"
} > "$OUT_DIR/$package_name.summary.env"
echo "package built: $tar_path"

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/build_docker_metrics_image.sh" --arch arm64 "$@"

View File

@@ -2,184 +2,4 @@
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" exec "$SCRIPT_DIR/build_docker_runtime_image.sh" --arch arm64 "$@"
IMAGE_TAG="${IMAGE_TAG:-ours-rp-runtime-arm64:dev}"
BUILDER_IMAGE="${BUILDER_IMAGE:-ours-rp-base-rust-amd64:1-bookworm}"
RUNTIME_IMAGE="${RUNTIME_IMAGE:-ours-rp-base-debian-arm64:bookworm-slim}"
OUT_DIR="${OUT_DIR:-$REPO_ROOT/target/arm64-docker}"
DOCKERFILE="${DOCKERFILE:-$REPO_ROOT/docker/ours-rp-runtime.Dockerfile}"
BUILDER_NAME="${BUILDER_NAME:-default}"
INSTALL_BINFMT="${INSTALL_BINFMT:-1}"
SAVE_IMAGE="${SAVE_IMAGE:-1}"
LOAD_IMAGE="${LOAD_IMAGE:-1}"
usage() {
cat <<'USAGE'
Usage:
scripts/docker/build_arm64_runtime_image.sh [options]
Options:
--image <tag> Docker image tag (default: ours-rp-runtime-arm64:dev)
--out-dir <path> Directory for docker save tar.gz (default: target/arm64-docker)
--dockerfile <path> Dockerfile path
--builder <name> buildx builder name
--no-binfmt Do not install binfmt/qemu
--no-save Build image but do not docker save it
--no-load Use buildx output tar instead of --load
-h, --help Show this help
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--image)
IMAGE_TAG="$2"
shift 2
;;
--out-dir)
OUT_DIR="$2"
shift 2
;;
--dockerfile)
DOCKERFILE="$2"
shift 2
;;
--builder)
BUILDER_NAME="$2"
shift 2
;;
--no-binfmt)
INSTALL_BINFMT=0
shift
;;
--no-save)
SAVE_IMAGE=0
shift
;;
--no-load)
LOAD_IMAGE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
require_command() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 2
}
}
safe_tag_name() {
printf '%s' "$1" | tr '/:' '--'
}
require_command docker
mkdir -p "$OUT_DIR"
require_docker_image() {
local image="$1"
if ! docker image inspect "$image" >/dev/null 2>&1; then
cat >&2 <<EOF
missing required local Docker image: $image
This script intentionally builds with local base image tags to avoid
multi-architecture tag ambiguity during cross builds.
Prepare the default base tags with:
docker pull --platform linux/amd64 rust:1-bookworm
docker tag rust:1-bookworm ours-rp-base-rust-amd64:1-bookworm
docker pull --platform linux/arm64 debian:bookworm-slim
docker tag debian:bookworm-slim ours-rp-base-debian-arm64:bookworm-slim
EOF
exit 2
fi
}
require_docker_image "$BUILDER_IMAGE"
require_docker_image "$RUNTIME_IMAGE"
if [[ "$INSTALL_BINFMT" == "1" ]]; then
echo "installing binfmt/qemu for arm64"
docker run --rm --privileged tonistiigi/binfmt --install arm64
fi
if [[ "$BUILDER_NAME" != "default" ]] && ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
docker buildx create --name "$BUILDER_NAME" --driver docker-container --use >/dev/null
else
docker buildx use "$BUILDER_NAME" >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
metadata_path="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build-metadata.json"
tar_path="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").tar.gz"
build_log="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build.log"
echo "building linux/arm64 image: $IMAGE_TAG"
echo "repo: $REPO_ROOT"
echo "dockerfile: $DOCKERFILE"
echo "builder_image: $BUILDER_IMAGE"
echo "runtime_image: $RUNTIME_IMAGE"
start_epoch="$(date +%s)"
if [[ "$LOAD_IMAGE" == "1" ]]; then
docker buildx build \
--platform linux/arm64 \
--builder "$BUILDER_NAME" \
--load \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--build-arg "BUILDER_IMAGE=$BUILDER_IMAGE" \
--build-arg "RUNTIME_IMAGE=$RUNTIME_IMAGE" \
--metadata-file "$metadata_path" \
-t "$IMAGE_TAG" \
-f "$DOCKERFILE" \
"$REPO_ROOT" 2>&1 | tee "$build_log"
else
raw_tar_path="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").tar"
docker buildx build \
--platform linux/arm64 \
--builder "$BUILDER_NAME" \
--output "type=docker,dest=$raw_tar_path" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--build-arg "BUILDER_IMAGE=$BUILDER_IMAGE" \
--build-arg "RUNTIME_IMAGE=$RUNTIME_IMAGE" \
--metadata-file "$metadata_path" \
-t "$IMAGE_TAG" \
-f "$DOCKERFILE" \
"$REPO_ROOT" 2>&1 | tee "$build_log"
gzip -f "$raw_tar_path"
tar_path="${raw_tar_path}.gz"
fi
elapsed_secs=$(( $(date +%s) - start_epoch ))
if [[ "$SAVE_IMAGE" == "1" && "$LOAD_IMAGE" == "1" ]]; then
echo "saving image to $tar_path"
docker save "$IMAGE_TAG" | gzip -c > "$tar_path"
fi
{
echo "image=$IMAGE_TAG"
echo "platform=linux/arm64"
echo "builder_image=$BUILDER_IMAGE"
echo "runtime_image=$RUNTIME_IMAGE"
echo "elapsed_secs=$elapsed_secs"
echo "metadata=$metadata_path"
echo "tar=$tar_path"
echo "tar_size_bytes=$(wc -c < "$tar_path" 2>/dev/null || echo 0)"
echo "git_commit=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "git_status_count=$(git -C "$REPO_ROOT" status --short 2>/dev/null | wc -l | tr -d ' ')"
echo "built_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build-summary.env"
echo "build complete: elapsed=${elapsed_secs}s tar=$tar_path"

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

@@ -0,0 +1,465 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TARGET_ARCH="${TARGET_ARCH:-}"
IMAGE_TAG=""
IMAGE_TAR=""
METRICS_IMAGE=""
METRICS_IMAGE_TAR=""
PROMETHEUS_IMAGE="${PROMETHEUS_IMAGE:-prom/prometheus:v2.55.1}"
PROMETHEUS_IMAGE_TAR="${PROMETHEUS_IMAGE_TAR:-}"
GRAFANA_IMAGE="${GRAFANA_IMAGE:-grafana/grafana:11.3.1}"
GRAFANA_IMAGE_TAR="${GRAFANA_IMAGE_TAR:-}"
OUT_DIR="${OUT_DIR:-}"
PACKAGE_PREFIX="${PACKAGE_PREFIX:-}"
TEMPLATE_DIR="${TEMPLATE_DIR:-$REPO_ROOT/deploy/docker-installer}"
ALLOW_DIRTY=0
usage() {
cat <<'USAGE'
Usage:
scripts/docker/build_docker_installer_package.sh --arch amd64|arm64 [options]
Options:
--arch <arch> Target architecture: amd64|arm64
--prometheus-image <tag>
Prometheus image tag to record and package.
--prometheus-image-tar <path>
Existing Prometheus docker save tar/tar.gz to include.
--grafana-image <tag>
Grafana image tag to record and package.
--grafana-image-tar <path>
Existing Grafana docker save tar/tar.gz to include.
--out-dir <path> Output directory.
--prefix <name> Package directory/tar prefix.
--template-dir <path>
Package template directory.
--allow-dirty Allow a development build from a dirty worktree and mark
image tags, package name and manifest as dirty.
-h, --help Show help.
Every package rebuilds runtime and metrics images from the current HEAD. Their
tags are ours-rp-runtime-<arch>:<git8> and ours-rp-metrics-<arch>:<git8>.
External runtime/metrics image tags and tar archives are intentionally rejected.
USAGE
}
normalize_arch() {
case "$1" in
amd64|x86_64)
printf 'amd64\n'
;;
arm64|aarch64)
printf 'arm64\n'
;;
*)
return 1
;;
esac
}
platform_for_arch() {
printf 'linux/%s\n' "$1"
}
safe_tag_name() {
printf '%s' "$1" | tr '/:' '--'
}
runtime_image_tag() {
local arch="$1"
local revision="$2"
printf 'ours-rp-runtime-%s:%s\n' "$arch" "$revision"
}
metrics_image_tag() {
local arch="$1"
local revision="$2"
printf 'ours-rp-metrics-%s:%s\n' "$arch" "$revision"
}
default_package_prefix() {
printf 'ours-rp-installer-%s\n' "$1"
}
default_host_data_dir() {
printf '/var/lib/ours-rp-%s-installer\n' "$1"
}
default_compose_project() {
printf 'ours-rp-%s-installer\n' "$1"
}
default_metrics_instance() {
printf '%s-installer\n' "$1"
}
replace_or_append_env() {
local env_path="$1"
local key="$2"
local value="$3"
local tmp_env
tmp_env="${env_path}.tmp"
awk -v key="$key" -v value="$value" '
BEGIN { done=0 }
$0 ~ "^" key "=" { print key "=" value; done=1; next }
{ print }
END { if (!done) print key "=" value }
' "$env_path" > "$tmp_env"
mv "$tmp_env" "$env_path"
}
replace_text_in_file() {
local file_path="$1"
local old_text="$2"
local new_text="$3"
OLD_TEXT="$old_text" NEW_TEXT="$new_text" perl -0pi -e 's/\Q$ENV{OLD_TEXT}\E/$ENV{NEW_TEXT}/g' "$file_path"
}
ensure_image_for_platform() {
local image="$1"
local expected_platform="$2"
local role="$3"
local actual_platform
if ! docker image inspect "$image" >/dev/null 2>&1; then
echo "pulling $role image for $expected_platform: $image" >&2
docker pull --platform "$expected_platform" "$image" >&2
fi
actual_platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null || echo unknown)"
if [[ "$actual_platform" != "$expected_platform" ]]; then
echo "re-pulling $role image for $expected_platform: $image (current=$actual_platform)" >&2
docker pull --platform "$expected_platform" "$image" >&2
actual_platform="$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null || echo unknown)"
fi
[[ "$actual_platform" == "$expected_platform" ]] || {
cat >&2 <<EOF
wrong platform for $role image: $image
expected: $expected_platform
actual: $actual_platform
EOF
exit 2
}
}
save_image_if_needed() {
local image="$1"
local existing_tar="$2"
local out_dir="$3"
local role="$4"
local expected_platform="$5"
if [[ -n "$existing_tar" ]]; then
[[ -f "$existing_tar" ]] || {
echo "missing $role image tar: $existing_tar" >&2
exit 2
}
printf '%s\n' "$existing_tar"
return 0
fi
ensure_image_for_platform "$image" "$expected_platform" "$role"
local tar_path="$out_dir/$(safe_tag_name "$image")-${TARGET_ARCH}.tar.gz"
echo "saving $role image to $tar_path" >&2
docker save "$image" | gzip -c > "$tar_path"
printf '%s\n' "$tar_path"
}
image_label() {
local image="$1"
local key="$2"
docker image inspect --format "{{ index .Config.Labels \"$key\" }}" "$image"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--arch)
TARGET_ARCH="$(normalize_arch "$2")" || {
echo "unsupported target architecture: $2" >&2
exit 2
}
shift 2
;;
--image|--image-tar|--metrics-image|--metrics-image-tar)
echo "$1 is no longer supported: installer packages always rebuild runtime and metrics images from current HEAD" >&2
exit 2
;;
--prometheus-image)
PROMETHEUS_IMAGE="$2"
shift 2
;;
--prometheus-image-tar)
PROMETHEUS_IMAGE_TAR="$2"
shift 2
;;
--grafana-image)
GRAFANA_IMAGE="$2"
shift 2
;;
--grafana-image-tar)
GRAFANA_IMAGE_TAR="$2"
shift 2
;;
--out-dir)
OUT_DIR="$2"
shift 2
;;
--prefix)
PACKAGE_PREFIX="$2"
shift 2
;;
--template-dir)
TEMPLATE_DIR="$2"
shift 2
;;
--allow-dirty)
ALLOW_DIRTY=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ -n "$TARGET_ARCH" ]] || {
echo "--arch is required" >&2
usage >&2
exit 2
}
[[ -d "$TEMPLATE_DIR" ]] || {
echo "missing template dir: $TEMPLATE_DIR" >&2
exit 2
}
SOURCE_COMMIT_FULL="$(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
cat >&2 <<EOF
refusing to build a release installer from a dirty worktree.
source_commit: $SOURCE_COMMIT_FULL
Use --allow-dirty only for development verification; the package and image tags
will be explicitly marked dirty.
EOF
exit 2
fi
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
REVISION_TOKEN="$SOURCE_COMMIT_SHORT"
if [[ "$SOURCE_DIRTY" == "true" ]]; then
REVISION_TOKEN="${REVISION_TOKEN}-dirty"
fi
IMAGE_TAG="$(runtime_image_tag "$TARGET_ARCH" "$REVISION_TOKEN")"
METRICS_IMAGE="$(metrics_image_tag "$TARGET_ARCH" "$REVISION_TOKEN")"
if [[ -z "$OUT_DIR" ]]; then
OUT_DIR="$REPO_ROOT/target/${TARGET_ARCH}-installer"
fi
if [[ -z "$PACKAGE_PREFIX" ]]; then
PACKAGE_PREFIX="$(default_package_prefix "$TARGET_ARCH")"
fi
TARGET_PLATFORM="$(platform_for_arch "$TARGET_ARCH")"
HOST_DATA_DIR_DEFAULT="$(default_host_data_dir "$TARGET_ARCH")"
COMPOSE_PROJECT_DEFAULT="$(default_compose_project "$TARGET_ARCH")"
METRICS_INSTANCE_DEFAULT="$(default_metrics_instance "$TARGET_ARCH")"
IMAGE_OUT_DIR="$REPO_ROOT/target/${TARGET_ARCH}-docker"
mkdir -p "$OUT_DIR"
echo "rebuilding runtime image: $IMAGE_TAG"
"$SCRIPT_DIR/build_docker_runtime_image.sh" \
--arch "$TARGET_ARCH" \
--image "$IMAGE_TAG" \
--out-dir "$IMAGE_OUT_DIR"
echo "rebuilding metrics image: $METRICS_IMAGE"
"$SCRIPT_DIR/build_docker_metrics_image.sh" \
--arch "$TARGET_ARCH" \
--image "$METRICS_IMAGE" \
--out-dir "$IMAGE_OUT_DIR"
IMAGE_TAR="$IMAGE_OUT_DIR/$(safe_tag_name "$IMAGE_TAG").tar.gz"
METRICS_IMAGE_TAR="$IMAGE_OUT_DIR/$(safe_tag_name "$METRICS_IMAGE").tar.gz"
[[ -f "$IMAGE_TAR" ]] || { echo "runtime image build did not produce $IMAGE_TAR" >&2; exit 2; }
[[ -f "$METRICS_IMAGE_TAR" ]] || { echo "metrics image build did not produce $METRICS_IMAGE_TAR" >&2; exit 2; }
runtime_image_revision="$(image_label "$IMAGE_TAG" "org.opencontainers.image.revision")"
metrics_image_revision="$(image_label "$METRICS_IMAGE" "org.opencontainers.image.revision")"
runtime_image_dirty="$(image_label "$IMAGE_TAG" "org.opencontainers.image.source-dirty")"
metrics_image_dirty="$(image_label "$METRICS_IMAGE" "org.opencontainers.image.source-dirty")"
[[ "$runtime_image_revision" == "$SOURCE_COMMIT_FULL" ]] || { echo "runtime image revision mismatch: $runtime_image_revision != $SOURCE_COMMIT_FULL" >&2; exit 2; }
[[ "$metrics_image_revision" == "$SOURCE_COMMIT_FULL" ]] || { echo "metrics image revision mismatch: $metrics_image_revision != $SOURCE_COMMIT_FULL" >&2; exit 2; }
[[ "$runtime_image_dirty" == "$SOURCE_DIRTY" ]] || { echo "runtime image dirty flag mismatch" >&2; exit 2; }
[[ "$metrics_image_dirty" == "$SOURCE_DIRTY" ]] || { echo "metrics image dirty flag mismatch" >&2; exit 2; }
package_name="${PACKAGE_PREFIX}-${REVISION_TOKEN}-${TIMESTAMP}"
stage="$OUT_DIR/$package_name"
tar_path="$OUT_DIR/$package_name.tar.gz"
rm -rf "$stage"
rsync -a --delete "$TEMPLATE_DIR"/ "$stage"/
mkdir -p "$stage/images"
cp "$IMAGE_TAR" "$stage/images/"
cp "$METRICS_IMAGE_TAR" "$stage/images/"
monitor_image_stage="$OUT_DIR/.monitor-images-$TARGET_ARCH-$TIMESTAMP"
rm -rf "$monitor_image_stage"
mkdir -p "$monitor_image_stage"
prometheus_tar="$(save_image_if_needed "$PROMETHEUS_IMAGE" "$PROMETHEUS_IMAGE_TAR" "$monitor_image_stage" "prometheus" "$TARGET_PLATFORM")"
grafana_tar="$(save_image_if_needed "$GRAFANA_IMAGE" "$GRAFANA_IMAGE_TAR" "$monitor_image_stage" "grafana" "$TARGET_PLATFORM")"
cp "$prometheus_tar" "$stage/images/"
cp "$grafana_tar" "$stage/images/"
if [[ -f "$stage/.env.example" ]]; then
replace_or_append_env "$stage/.env.example" "PACKAGE_ARCH" "$TARGET_ARCH"
replace_or_append_env "$stage/.env.example" "PACKAGE_PLATFORM" "$TARGET_PLATFORM"
replace_or_append_env "$stage/.env.example" "COMPOSE_PROJECT_NAME" "$COMPOSE_PROJECT_DEFAULT"
replace_or_append_env "$stage/.env.example" "RPKI_IMAGE" "$IMAGE_TAG"
replace_or_append_env "$stage/.env.example" "RPKI_PLATFORM" "$TARGET_PLATFORM"
replace_or_append_env "$stage/.env.example" "METRICS_IMAGE" "$METRICS_IMAGE"
replace_or_append_env "$stage/.env.example" "METRICS_PLATFORM" "$TARGET_PLATFORM"
replace_or_append_env "$stage/.env.example" "HOST_DATA_DIR" "$HOST_DATA_DIR_DEFAULT"
replace_or_append_env "$stage/.env.example" "RTR_REPORT_DIR" "$HOST_DATA_DIR_DEFAULT/empty-rtr-report"
replace_or_append_env "$stage/.env.example" "RTR_REPORT_CONTAINER_DIR" "/var/lib/ours-rp/rtr-report"
replace_or_append_env "$stage/.env.example" "ALLOW_CROSS_ARCH" "0"
replace_or_append_env "$stage/.env.example" "METRICS_INSTANCE" "$METRICS_INSTANCE_DEFAULT"
replace_or_append_env "$stage/.env.example" "MONITOR_PLATFORM" "$TARGET_PLATFORM"
replace_or_append_env "$stage/.env.example" "PROMETHEUS_IMAGE" "$PROMETHEUS_IMAGE"
replace_or_append_env "$stage/.env.example" "GRAFANA_IMAGE" "$GRAFANA_IMAGE"
fi
replace_text_in_file "$stage/docs/README.zh-CN.md" "__PACKAGE_ARCH__" "$TARGET_ARCH"
replace_text_in_file "$stage/docs/README.en.md" "__PACKAGE_ARCH__" "$TARGET_ARCH"
replace_text_in_file "$stage/docs/operations.zh-CN.md" "__PACKAGE_ARCH__" "$TARGET_ARCH"
replace_text_in_file "$stage/docs/operations.en.md" "__PACKAGE_ARCH__" "$TARGET_ARCH"
replace_text_in_file "$stage/docs/troubleshooting.zh-CN.md" "__PACKAGE_ARCH__" "$TARGET_ARCH"
replace_text_in_file "$stage/docs/troubleshooting.en.md" "__PACKAGE_ARCH__" "$TARGET_ARCH"
replace_text_in_file "$stage/docs/README.zh-CN.md" "__PACKAGE_PLATFORM__" "$TARGET_PLATFORM"
replace_text_in_file "$stage/docs/README.en.md" "__PACKAGE_PLATFORM__" "$TARGET_PLATFORM"
replace_text_in_file "$stage/docs/troubleshooting.zh-CN.md" "__PACKAGE_PLATFORM__" "$TARGET_PLATFORM"
replace_text_in_file "$stage/docs/troubleshooting.en.md" "__PACKAGE_PLATFORM__" "$TARGET_PLATFORM"
replace_text_in_file "$stage/docs/README.zh-CN.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
replace_text_in_file "$stage/docs/README.en.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
replace_text_in_file "$stage/docs/operations.zh-CN.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
replace_text_in_file "$stage/docs/operations.en.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
replace_text_in_file "$stage/docs/troubleshooting.zh-CN.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
replace_text_in_file "$stage/docs/troubleshooting.en.md" "__HOST_DATA_DIR__" "$HOST_DATA_DIR_DEFAULT"
replace_text_in_file "$stage/docs/README.zh-CN.md" "__RUNTIME_IMAGE__" "$IMAGE_TAG"
replace_text_in_file "$stage/docs/README.en.md" "__RUNTIME_IMAGE__" "$IMAGE_TAG"
replace_text_in_file "$stage/docs/README.zh-CN.md" "__METRICS_IMAGE__" "$METRICS_IMAGE"
replace_text_in_file "$stage/docs/README.en.md" "__METRICS_IMAGE__" "$METRICS_IMAGE"
runtime_tar_sha256="$(sha256sum "$IMAGE_TAR" | awk '{print $1}')"
metrics_tar_sha256="$(sha256sum "$METRICS_IMAGE_TAR" | awk '{print $1}')"
prometheus_tar_sha256="$(sha256sum "$prometheus_tar" | awk '{print $1}')"
grafana_tar_sha256="$(sha256sum "$grafana_tar" | awk '{print $1}')"
cat > "$stage/PACKAGE-MANIFEST.env" <<EOF
package_schema_version=2
package_name=$package_name
created_at_utc=$TIMESTAMP
build_timestamp_utc=$TIMESTAMP
source_commit=$SOURCE_COMMIT_FULL
source_commit_short=$SOURCE_COMMIT_SHORT
source_dirty=$SOURCE_DIRTY
git_commit=$SOURCE_COMMIT_FULL
git_status_count=$(git -C "$REPO_ROOT" status --short 2>/dev/null | wc -l | tr -d ' ')
PACKAGE_ARCH=$TARGET_ARCH
PACKAGE_PLATFORM=$TARGET_PLATFORM
package_arch=$TARGET_ARCH
package_platform=$TARGET_PLATFORM
cross_arch_default=deny
cross_arch_enable_var=ALLOW_CROSS_ARCH
RPKI_IMAGE=$IMAGE_TAG
RPKI_PLATFORM=$TARGET_PLATFORM
image_tag=$IMAGE_TAG
image_tar=$(basename "$IMAGE_TAR")
image_tar_size_bytes=$(wc -c < "$IMAGE_TAR")
image_tar_sha256=$runtime_tar_sha256
runtime_image=$IMAGE_TAG
runtime_image_revision=$runtime_image_revision
runtime_image_dirty=$runtime_image_dirty
rpki_image=$IMAGE_TAG
rpki_platform=$TARGET_PLATFORM
METRICS_IMAGE=$METRICS_IMAGE
METRICS_PLATFORM=$TARGET_PLATFORM
metrics_image=$METRICS_IMAGE
metrics_image_tar=$(basename "$METRICS_IMAGE_TAR")
metrics_image_tar_size_bytes=$(wc -c < "$METRICS_IMAGE_TAR")
metrics_image_tar_sha256=$metrics_tar_sha256
metrics_image_revision=$metrics_image_revision
metrics_image_dirty=$metrics_image_dirty
PROMETHEUS_IMAGE=$PROMETHEUS_IMAGE
prometheus_image=$PROMETHEUS_IMAGE
prometheus_image_tar=$(basename "$prometheus_tar")
prometheus_image_tar_size_bytes=$(wc -c < "$prometheus_tar")
prometheus_image_tar_sha256=$prometheus_tar_sha256
GRAFANA_IMAGE=$GRAFANA_IMAGE
grafana_image=$GRAFANA_IMAGE
grafana_image_tar=$(basename "$grafana_tar")
grafana_image_tar_size_bytes=$(wc -c < "$grafana_tar")
grafana_image_tar_sha256=$grafana_tar_sha256
target_platform=$TARGET_PLATFORM
MONITOR_PLATFORM=$TARGET_PLATFORM
rpki_image_tar=$(basename "$IMAGE_TAR")
monitor_platform=$TARGET_PLATFORM
metrics_platform=$TARGET_PLATFORM
default_host_data_dir=$HOST_DATA_DIR_DEFAULT
default_compose_project_name=$COMPOSE_PROJECT_DEFAULT
default_metrics_instance=$METRICS_INSTANCE_DEFAULT
EOF
cat > "$stage/PACKAGE-SUMMARY.txt" <<EOF
package_name: $package_name
source_commit: $SOURCE_COMMIT_FULL
source_commit_short: $SOURCE_COMMIT_SHORT
source_dirty: $SOURCE_DIRTY
build_timestamp_utc: $TIMESTAMP
package_arch: $TARGET_ARCH
package_platform: $TARGET_PLATFORM
runtime_image: $IMAGE_TAG
runtime_image_revision: $runtime_image_revision
metrics_image: $METRICS_IMAGE
metrics_image_revision: $metrics_image_revision
prometheus_image: $PROMETHEUS_IMAGE
grafana_image: $GRAFANA_IMAGE
default_host_data_dir: $HOST_DATA_DIR_DEFAULT
default_compose_project_name: $COMPOSE_PROJECT_DEFAULT
cross_arch_default: deny
cross_arch_enable_var: ALLOW_CROSS_ARCH
EOF
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"
package_sha256="$(sha256sum "$tar_path" | awk '{print $1}')"
rm -rf "$monitor_image_stage"
{
echo "package=$tar_path"
echo "package_dir=$stage"
echo "package_size_bytes=$(wc -c < "$tar_path")"
echo "package_sha256=$package_sha256"
echo "source_commit=$SOURCE_COMMIT_FULL"
echo "source_commit_short=$SOURCE_COMMIT_SHORT"
echo "source_dirty=$SOURCE_DIRTY"
echo "build_timestamp_utc=$TIMESTAMP"
echo "package_arch=$TARGET_ARCH"
echo "package_platform=$TARGET_PLATFORM"
echo "manifest=$stage/PACKAGE-MANIFEST.env"
echo "summary=$stage/PACKAGE-SUMMARY.txt"
} > "$OUT_DIR/$package_name.summary.env"
echo "package built: $tar_path"

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
normalize_arch() {
case "$1" in
amd64|x86_64)
printf 'amd64\n'
;;
arm64|aarch64)
printf 'arm64\n'
;;
*)
return 1
;;
esac
}
TARGET_ARCH="${TARGET_ARCH:-}"
IMAGE_TAG="${IMAGE_TAG:-}"
DOCKERFILE="${DOCKERFILE:-$REPO_ROOT/docker/ours-rp-metrics.Dockerfile}"
args=()
image_explicit=0
dockerfile_explicit=0
while [[ $# -gt 0 ]]; do
case "$1" in
--arch)
TARGET_ARCH="$(normalize_arch "$2")" || {
echo "unsupported target architecture: $2" >&2
exit 2
}
args+=("$1" "$TARGET_ARCH")
shift 2
;;
--image)
IMAGE_TAG="$2"
image_explicit=1
args+=("$1" "$2")
shift 2
;;
--dockerfile)
DOCKERFILE="$2"
dockerfile_explicit=1
args+=("$1" "$2")
shift 2
;;
*)
args+=("$1")
shift
;;
esac
done
if [[ -z "$TARGET_ARCH" ]]; then
cat >&2 <<'EOF'
missing target architecture.
Usage:
scripts/docker/build_docker_metrics_image.sh --arch amd64|arm64 [options]
EOF
exit 2
fi
if [[ -z "$IMAGE_TAG" ]]; then
SOURCE_COMMIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short=8 HEAD 2>/dev/null || echo unknown)"
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null)" ]]; then
SOURCE_COMMIT_SHORT="${SOURCE_COMMIT_SHORT}-dirty"
fi
IMAGE_TAG="ours-rp-metrics-${TARGET_ARCH}:${SOURCE_COMMIT_SHORT}"
fi
if [[ "$dockerfile_explicit" != "1" ]]; then
args=(--dockerfile "$DOCKERFILE" "${args[@]}")
fi
if [[ "$image_explicit" != "1" ]]; then
args=(--image "$IMAGE_TAG" "${args[@]}")
fi
exec "$SCRIPT_DIR/build_docker_runtime_image.sh" "${args[@]}"

View File

@@ -0,0 +1,295 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TARGET_ARCH="${TARGET_ARCH:-}"
IMAGE_TAG="${IMAGE_TAG:-}"
BUILDER_IMAGE="${BUILDER_IMAGE:-rust:1-bookworm}"
RUNTIME_IMAGE="${RUNTIME_IMAGE:-debian:bookworm-slim}"
OUT_DIR="${OUT_DIR:-}"
DOCKERFILE="${DOCKERFILE:-$REPO_ROOT/docker/ours-rp-runtime.Dockerfile}"
BUILDER_NAME="${BUILDER_NAME:-default}"
INSTALL_BINFMT="${INSTALL_BINFMT:-1}"
SAVE_IMAGE="${SAVE_IMAGE:-1}"
LOAD_IMAGE="${LOAD_IMAGE:-1}"
usage() {
cat <<'USAGE'
Usage:
scripts/docker/build_docker_runtime_image.sh --arch amd64|arm64 [options]
Options:
--arch <arch> Target architecture: amd64|arm64
--image <tag> Docker image tag (default: ours-rp-runtime-<arch>:<git8>)
--out-dir <path> Directory for docker save tar.gz (default: target/<arch>-docker)
--dockerfile <path> Dockerfile path
--builder <name> buildx builder name
--builder-image <tag>
Builder base image (default: rust:1-bookworm)
--runtime-image <tag>
Runtime base image (default: debian:bookworm-slim)
--no-binfmt Do not install binfmt/qemu for cross-architecture builds
--no-save Build image but do not docker save it
--no-load Use buildx output tar instead of --load
-h, --help Show this help
USAGE
}
normalize_arch() {
case "$1" in
amd64|x86_64)
printf 'amd64\n'
;;
arm64|aarch64)
printf 'arm64\n'
;;
*)
return 1
;;
esac
}
safe_tag_name() {
printf '%s' "$1" | tr '/:' '--'
}
local_base_image_tag() {
local role="$1"
local arch="$2"
local source_image="$3"
printf 'ours-rp-base-%s-%s:%s\n' "$role" "$arch" "$(safe_tag_name "$source_image")"
}
platform_for_arch() {
printf 'linux/%s\n' "$1"
}
default_image_tag() {
local arch="$1"
local commit_short="$2"
printf 'ours-rp-runtime-%s:%s\n' "$arch" "$commit_short"
}
require_command() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 2
}
}
host_arch() {
local raw_arch
raw_arch="$(uname -m)"
normalize_arch "$raw_arch" || {
echo "unsupported host architecture: $raw_arch" >&2
exit 2
}
}
while [[ $# -gt 0 ]]; do
case "$1" in
--arch)
TARGET_ARCH="$(normalize_arch "$2")" || {
echo "unsupported target architecture: $2" >&2
exit 2
}
shift 2
;;
--image)
IMAGE_TAG="$2"
shift 2
;;
--out-dir)
OUT_DIR="$2"
shift 2
;;
--dockerfile)
DOCKERFILE="$2"
shift 2
;;
--builder)
BUILDER_NAME="$2"
shift 2
;;
--builder-image)
BUILDER_IMAGE="$2"
shift 2
;;
--runtime-image)
RUNTIME_IMAGE="$2"
shift 2
;;
--no-binfmt)
INSTALL_BINFMT=0
shift
;;
--no-save)
SAVE_IMAGE=0
shift
;;
--no-load)
LOAD_IMAGE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ -n "$TARGET_ARCH" ]] || {
echo "--arch is required" >&2
usage >&2
exit 2
}
SOURCE_COMMIT_FULL="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo unknown)"
SOURCE_COMMIT_SHORT="$(git -C "$REPO_ROOT" rev-parse --short=8 HEAD 2>/dev/null || echo unknown)"
SOURCE_DIRTY="false"
if [[ -n "$(git -C "$REPO_ROOT" status --short 2>/dev/null)" ]]; then
SOURCE_DIRTY="true"
fi
BUILD_TIMESTAMP_UTC="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ -z "$IMAGE_TAG" ]]; then
default_revision="$SOURCE_COMMIT_SHORT"
if [[ "$SOURCE_DIRTY" == "true" ]]; then
default_revision="${default_revision}-dirty"
fi
IMAGE_TAG="$(default_image_tag "$TARGET_ARCH" "$default_revision")"
fi
if [[ -z "$OUT_DIR" ]]; then
OUT_DIR="$REPO_ROOT/target/${TARGET_ARCH}-docker"
fi
require_command docker
mkdir -p "$OUT_DIR"
target_platform="$(platform_for_arch "$TARGET_ARCH")"
local_host_arch="$(host_arch)"
builder_platform="$(platform_for_arch "$local_host_arch")"
builder_source_image="$BUILDER_IMAGE"
runtime_source_image="$RUNTIME_IMAGE"
builder_local_image="$(local_base_image_tag builder "$local_host_arch" "$builder_source_image")"
runtime_local_image="$(local_base_image_tag runtime "$TARGET_ARCH" "$runtime_source_image")"
if [[ "$INSTALL_BINFMT" == "1" && "$local_host_arch" != "$TARGET_ARCH" ]]; then
echo "installing binfmt/qemu for $TARGET_ARCH"
docker run --rm --privileged tonistiigi/binfmt --install "$TARGET_ARCH"
fi
# Pulling through the Docker daemon uses its configured proxy. Buildx then consumes
# these architecture-pinned local aliases without issuing separate registry metadata
# requests for the Dockerfile FROM instructions.
echo "pulling builder base image: $builder_source_image ($builder_platform)"
docker pull --platform "$builder_platform" "$builder_source_image"
docker tag "$builder_source_image" "$builder_local_image"
echo "pulling runtime base image: $runtime_source_image ($target_platform)"
docker pull --platform "$target_platform" "$runtime_source_image"
docker tag "$runtime_source_image" "$runtime_local_image"
BUILDER_IMAGE="$builder_local_image"
RUNTIME_IMAGE="$runtime_local_image"
if [[ "$BUILDER_NAME" != "default" ]] && ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
docker buildx create --name "$BUILDER_NAME" --driver docker-container --use >/dev/null
else
docker buildx use "$BUILDER_NAME" >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
metadata_path="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build-metadata.json"
tar_path="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").tar.gz"
build_log="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build.log"
echo "building $target_platform image: $IMAGE_TAG"
echo "repo: $REPO_ROOT"
echo "dockerfile: $DOCKERFILE"
echo "builder_source_image: $builder_source_image"
echo "runtime_source_image: $runtime_source_image"
echo "builder_local_image: $BUILDER_IMAGE"
echo "runtime_local_image: $RUNTIME_IMAGE"
echo "source_commit: $SOURCE_COMMIT_FULL"
echo "source_dirty: $SOURCE_DIRTY"
echo "build_timestamp_utc: $BUILD_TIMESTAMP_UTC"
start_epoch="$(date +%s)"
if [[ "$LOAD_IMAGE" == "1" ]]; then
docker buildx build \
--platform "$target_platform" \
--builder "$BUILDER_NAME" \
--pull=false \
--load \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--build-arg "BUILDER_IMAGE=$BUILDER_IMAGE" \
--build-arg "RUNTIME_IMAGE=$RUNTIME_IMAGE" \
--build-arg "SOURCE_COMMIT=$SOURCE_COMMIT_FULL" \
--build-arg "SOURCE_DIRTY=$SOURCE_DIRTY" \
--build-arg "BUILD_TIMESTAMP_UTC=$BUILD_TIMESTAMP_UTC" \
--metadata-file "$metadata_path" \
-t "$IMAGE_TAG" \
-f "$DOCKERFILE" \
"$REPO_ROOT" 2>&1 | tee "$build_log"
else
raw_tar_path="$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").tar"
docker buildx build \
--platform "$target_platform" \
--builder "$BUILDER_NAME" \
--pull=false \
--output "type=docker,dest=$raw_tar_path" \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--build-arg "BUILDER_IMAGE=$BUILDER_IMAGE" \
--build-arg "RUNTIME_IMAGE=$RUNTIME_IMAGE" \
--build-arg "SOURCE_COMMIT=$SOURCE_COMMIT_FULL" \
--build-arg "SOURCE_DIRTY=$SOURCE_DIRTY" \
--build-arg "BUILD_TIMESTAMP_UTC=$BUILD_TIMESTAMP_UTC" \
--metadata-file "$metadata_path" \
-t "$IMAGE_TAG" \
-f "$DOCKERFILE" \
"$REPO_ROOT" 2>&1 | tee "$build_log"
gzip -f "$raw_tar_path"
tar_path="${raw_tar_path}.gz"
fi
elapsed_secs=$(( $(date +%s) - start_epoch ))
if [[ "$SAVE_IMAGE" == "1" && "$LOAD_IMAGE" == "1" ]]; then
echo "saving image to $tar_path"
docker save "$IMAGE_TAG" | gzip -c > "$tar_path"
fi
tar_sha256=""
if [[ -f "$tar_path" ]]; then
tar_sha256="$(sha256sum "$tar_path" | awk '{print $1}')"
fi
{
echo "image=$IMAGE_TAG"
echo "platform=$target_platform"
echo "arch=$TARGET_ARCH"
echo "builder_image=$builder_source_image"
echo "runtime_image=$runtime_source_image"
echo "builder_local_image=$BUILDER_IMAGE"
echo "runtime_local_image=$RUNTIME_IMAGE"
echo "elapsed_secs=$elapsed_secs"
echo "metadata=$metadata_path"
echo "tar=$tar_path"
echo "tar_size_bytes=$(wc -c < "$tar_path" 2>/dev/null || echo 0)"
echo "tar_sha256=$tar_sha256"
echo "source_commit=$SOURCE_COMMIT_FULL"
echo "source_commit_short=$SOURCE_COMMIT_SHORT"
echo "source_dirty=$SOURCE_DIRTY"
echo "build_timestamp_utc=$BUILD_TIMESTAMP_UTC"
echo "git_commit=$SOURCE_COMMIT_FULL"
echo "git_status_count=$(git -C "$REPO_ROOT" status --short 2>/dev/null | wc -l | tr -d ' ')"
echo "built_at_utc=$BUILD_TIMESTAMP_UTC"
} > "$OUT_DIR/$(safe_tag_name "$IMAGE_TAG").build-summary.env"
echo "build complete: elapsed=${elapsed_secs}s tar=$tar_path"

View File

@@ -1,159 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REMOTE_HOST="${REMOTE_HOST:-root@47.77.204.233}"
REMOTE_ROOT="${REMOTE_ROOT:-/root/ours-rp-arm64-compose}"
IMAGE_TAG="${IMAGE_TAG:-ours-rp-runtime-arm64:dev}"
IMAGE_TAR="${IMAGE_TAR:-$REPO_ROOT/target/arm64-docker/ours-rp-runtime-arm64-dev.tar.gz}"
EXECUTE="${EXECUTE:-0}"
INSTALL_DOCKER="${INSTALL_DOCKER:-1}"
INSTALL_BINFMT="${INSTALL_BINFMT:-1}"
START_CORE="${START_CORE:-0}"
START_SIDECARS="${START_SIDECARS:-0}"
usage() {
cat <<'USAGE'
Usage:
scripts/docker/deploy_remote233_arm64_compose.sh [options]
Default is dry-run. Pass --execute to modify the remote host.
Options:
--execute Actually install/copy/load/start on remote
--remote <ssh-host> SSH host (default: root@47.77.204.233)
--remote-root <path> Remote compose root
--image <tag> Image tag loaded by docker load
--image-tar <path> Local docker save tar.gz
--no-install-docker Skip Docker installation
--no-binfmt Skip binfmt/qemu installation
--start-core Start ours-rp-soak after deploy
--start-sidecars Start artifact metrics, Prometheus and Grafana after deploy
-h, --help Show this help
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--execute)
EXECUTE=1
shift
;;
--remote)
REMOTE_HOST="$2"
shift 2
;;
--remote-root)
REMOTE_ROOT="$2"
shift 2
;;
--image)
IMAGE_TAG="$2"
shift 2
;;
--image-tar)
IMAGE_TAR="$2"
shift 2
;;
--no-install-docker)
INSTALL_DOCKER=0
shift
;;
--no-binfmt)
INSTALL_BINFMT=0
shift
;;
--start-core)
START_CORE=1
shift
;;
--start-sidecars)
START_SIDECARS=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
require_command() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 2
}
}
run_or_echo() {
if [[ "$EXECUTE" == "1" ]]; then
"$@"
else
printf 'DRY-RUN:'
printf ' %q' "$@"
printf '\n'
fi
}
remote_run() {
if [[ "$EXECUTE" == "1" ]]; then
ssh "$REMOTE_HOST" "$@"
else
printf 'DRY-RUN: ssh %q %q\n' "$REMOTE_HOST" "$*"
fi
}
require_command ssh
require_command rsync
[[ -f "$IMAGE_TAR" ]] || {
echo "missing image tar: $IMAGE_TAR" >&2
exit 2
}
compose_src="$REPO_ROOT/deploy/arm64-compose/"
[[ -f "$compose_src/docker-compose.yml" ]] || {
echo "missing compose source: $compose_src" >&2
exit 2
}
echo "remote=$REMOTE_HOST"
echo "remote_root=$REMOTE_ROOT"
echo "image=$IMAGE_TAG"
echo "image_tar=$IMAGE_TAR"
if [[ "$INSTALL_DOCKER" == "1" ]]; then
remote_run "if ! command -v docker >/dev/null 2>&1; then apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io; fi; if ! docker compose version >/dev/null 2>&1; then if apt-cache show docker-compose-plugin >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-plugin; elif apt-cache show docker-compose-v2 >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose-v2; elif apt-cache show docker-compose >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive apt-get install -y docker-compose; else echo 'no docker compose package found' >&2; exit 2; fi; fi; systemctl enable --now docker || true"
fi
remote_run "mkdir -p '$REMOTE_ROOT/images'"
run_or_echo rsync -a --delete "$compose_src" "$REMOTE_HOST:$REMOTE_ROOT/"
run_or_echo rsync -a "$IMAGE_TAR" "$REMOTE_HOST:$REMOTE_ROOT/images/"
remote_tar="$REMOTE_ROOT/images/$(basename "$IMAGE_TAR")"
remote_run "cd '$REMOTE_ROOT' && test -f .env || cp .env.example .env"
remote_run "gunzip -c '$remote_tar' | docker load"
if [[ "$INSTALL_BINFMT" == "1" ]]; then
remote_run "docker run --rm --privileged tonistiigi/binfmt --install arm64"
fi
remote_run "docker run --rm --platform linux/arm64 '$IMAGE_TAG' uname -m"
remote_run "docker run --rm --platform linux/arm64 '$IMAGE_TAG' /opt/ours-rp/bin/rpki --help >/tmp/ours-rp-arm64-help.txt && head -5 /tmp/ours-rp-arm64-help.txt"
if [[ "$START_CORE" == "1" ]]; then
remote_run "cd '$REMOTE_ROOT' && docker compose --profile core up -d ours-rp-soak"
fi
if [[ "$START_SIDECARS" == "1" ]]; then
remote_run "cd '$REMOTE_ROOT' && docker compose --profile sidecar --profile monitor up -d artifact-metrics prometheus grafana"
fi
remote_run "cd '$REMOTE_ROOT' && docker compose ps"

103
scripts/rpctl/README.md Normal file
View File

@@ -0,0 +1,103 @@
# rpctl
`rpctl` 是四款 RP 本地控制 CLI,用于在实验服务器本地独立启动、停止和观察单个 RP/profile 的 all5 实验。一个实验可包含一次 snapshot 和多次 delta 子 run。
## 部署
```bash
mkdir -p /root/rpki_4rp_ctl
rsync -a rpki_2/rpki/scripts/rpctl/ root@47.77.237.41:/root/rpki_4rp_ctl/
rsync -a specs/develop/20260707_3/feature100_four_rp_interval_archive/runtime_bundle/ root@47.77.237.41:/root/rpki_4rp_ctl_runtime_bundle/
ssh root@47.77.237.41 'cd /root/rpki_4rp_ctl && ./rpctl init --bundle /root/rpki_4rp_ctl_runtime_bundle'
```
`--bundle` 目录需要包含 #100 runtime bundle 的 `bin/`、`lib/`、`fixtures/`、`scripts/` 和 `versions.json`。不要使用早期 precheck 目录作为标准 bundle,因为它可能缺少 FORT 运行所需动态库。
## 常用命令
```bash
./rpctl list
./rpctl status
./rpctl start --rp ours-pp-object-cache --interval 10m --runs 6 --rirs all5
./rpctl start --rp routinator --interval 0 --runs 1 --rirs apnic
./rpctl continue --exp-id <successful-exp-id> --runs 3 --interval 10m
./rpctl tail
./rpctl show
./rpctl show --failed
./rpctl view --exp-id 20260709T081122Z_ours-pp-object-cache_2runs_600s
./rpctl delete --exp-id <old-exp-id> --dry-run
./rpctl paths
./rpctl stop
./rpctl stop --force
```
## Profile
| 输入别名 | 实际 profile |
|---|---|
| `ours`, `ours-rp`, `ours-cached` | `ours-pp-object-cache` |
| `ours-baseline` | `ours-no-cache` |
| `routinator` | `routinator-latest-release` |
| `rpki-client` | `rpki-client-latest-release` |
| `fort` | `fort-latest-release` |
## 数据目录
默认部署根目录就是 `rpctl` 所在目录,例如 `/root/rpki_4rp_ctl`。
```text
/root/rpki_4rp_ctl/
bin/ lib/ fixtures/ scripts/
state/current.json
logs/runner-*.log
runs/<profile>/<exp-id>/profiles/<profile>/runs/run_0001/
```
每个子 run 目录包含 `stdout.log`、`stderr.log`、`process-time.txt`、`exit-code.txt`、`run-meta.json` 以及各 RP 产物。
## 历史查看与删除
```bash
./rpctl show
./rpctl show --failed
./rpctl show --rp ours --limit 20
./rpctl show --json
```
`show` 会扫描 `runs/<profile>/<exp-id>/`,展示历史 `exp_id`、状态、start/end 时间、失败子 run 列表、运行参数、产物数量和目录路径。
默认表格为了可读性不显示完整路径;需要路径时使用:
```bash
./rpctl show --paths
```
```bash
./rpctl view --exp-id <exp-id>
./rpctl view --rp ours --exp-id <exp-id>
./rpctl view --exp-id <exp-id> --command
./rpctl view --exp-id <exp-id> --json
```
`view` 用于查看指定历史实验的摘要和每一轮子 run 明细,包括运行耗时、max RSS、VRP/VAP 数量、同步模式、exit code 和目录路径。
```bash
./rpctl delete --exp-id <old-exp-id> --dry-run
./rpctl delete --exp-id <old-exp-id> --yes
./rpctl delete --rp ours --exp-id <duplicated-exp-id> --yes
```
`delete` 只能删除非 running 的实验。建议先用 `--dry-run` 确认路径;真实删除必须显式加 `--yes`。删除内容包括 `runs/<profile>/<exp-id>/` 和匹配的 `logs/runner-<exp-id>*.log`。
如果历史实验中最后一个子 run 中途停止,且没有 `run-meta.json`、normalized 文件或原始输出文件,则 VRP/VAP、wall、RSS 会显示为 `-`,表示该子 run 没有可统计产物。
## 续跑已有成功实验
```bash
./rpctl continue --exp-id <successful-exp-id> --runs 3 --interval 10m
./rpctl continue --rp routinator --exp-id <successful-exp-id> --runs 1 --interval 0
```
`continue` 只接受已经成功结束的历史实验 `exp_id`,并在原目录下追加 `run_000N`。它不会清理 profile state,也不会给 RP 强制传 snapshot-only 参数;后续子 run 是 delta 还是 snapshot 由各 RP 根据自身持久状态决定。
续跑会复用原实验的 RIR 集合,避免在同一个 state 目录里混用不同 scope。若同一个 `exp-id` 在多个 profile 下存在,需要加 `--rp` 消歧。

1764
scripts/rpctl/rpctl Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ RUN_ROOT="${PACKAGE_ROOT}"
RETAIN_RUNS=10 RETAIN_RUNS=10
# 每轮完成并复制出 run 产物后,是否清理 tmp/daemon-run_xxxx,避免 tmp 与 runs 双份占用。 # 每轮完成并复制出 run 产物后,是否清理 tmp/daemon-run_xxxx,避免 tmp 与 runs 双份占用。
CLEAN_TMP_AFTER_RUN=0 CLEAN_TMP_AFTER_RUN=1
# 是否输出 compact report JSON。1 表示启用,0 表示关闭。 # 是否输出 compact report JSON。1 表示启用,0 表示关闭。
OUTPUT_COMPACT_REPORT=1 OUTPUT_COMPACT_REPORT=1
@@ -57,6 +57,12 @@ RSYNC_SCOPE=module-root
# 建议保持 1;设置为 0 时,检测到前一轮失败会直接停止。 # 建议保持 1;设置为 0 时,检测到前一轮失败会直接停止。
FAILURE_SNAPSHOT_RESET=1 FAILURE_SNAPSHOT_RESET=1
# 周期性 snapshot reset 独立生命周期状态文件固定保存到:
# ${RUN_ROOT}/state/run-lifecycle-state.json
# 该文件不会被 RETAIN_RUNS 清理,也不会随 state/db reset 删除。
PERIODIC_SNAPSHOT_RESET=0
PERIODIC_SNAPSHOT_MAX_DELTAS=100
# 每隔多少轮执行一次 db_stats --exact。设置为空或 0 表示关闭 exact 统计。 # 每隔多少轮执行一次 db_stats --exact。设置为空或 0 表示关闭 exact 统计。
DB_STATS_EXACT_EVERY=3 DB_STATS_EXACT_EVERY=3
@@ -75,6 +81,10 @@ RPKI_PROGRESS_PP_CONTROL_SLOW_MS=100
# 是否在运行前尝试禁用 rpki-client timer 并杀掉竞争 RP 进程。 # 是否在运行前尝试禁用 rpki-client timer 并杀掉竞争 RP 进程。
DISABLE_COMPETING_RPS=1 DISABLE_COMPETING_RPS=1
# 资源证书验证模式;会显式传给 rpki 子进程。
# 合法值:validation-update-03、rfc6487。
RESOURCE_VALIDATION_MODE=validation-update-03
# 是否启用实验性 child CRT 验证缓存。独立于 ROA cache / PP cache;默认关闭。 # 是否启用实验性 child CRT 验证缓存。独立于 ROA cache / PP cache;默认关闭。
# 开启后会额外传入 --enable-child-certificate-validation-cache。 # 开启后会额外传入 --enable-child-certificate-validation-cache。
ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=0 ENABLE_CHILD_CERTIFICATE_VALIDATION_CACHE=0
@@ -109,6 +119,11 @@ METRICS_LISTEN=0.0.0.0:9556
METRICS_POLL_SECS=5 METRICS_POLL_SECS=5
METRICS_INSTANCE=remote231-24h METRICS_INSTANCE=remote231-24h
# 可选:显式开启外部 RTR report JSON 指标采集。
# 为空或未设置时不采集;设置后应指向包含
# rtr-source-*.json / rtr-runtime-*.json / rtr-clients-*.json 的目录。
RPKI_METRICS_RTR_REPORT_DIR=
# 是否启动 query service。启动后默认只索引启动后新完成的 run,避免补历史 run 影响 RP 性能。 # 是否启动 query service。启动后默认只索引启动后新完成的 run,避免补历史 run 影响 RP 性能。
START_QUERY_SERVICE=0 START_QUERY_SERVICE=0

View File

@@ -31,6 +31,7 @@ STOP_METRICS_SERVICE_ON_EXIT="${STOP_METRICS_SERVICE_ON_EXIT:-0}"
METRICS_LISTEN="${METRICS_LISTEN:-0.0.0.0:9556}" METRICS_LISTEN="${METRICS_LISTEN:-0.0.0.0:9556}"
METRICS_POLL_SECS="${METRICS_POLL_SECS:-5}" METRICS_POLL_SECS="${METRICS_POLL_SECS:-5}"
METRICS_INSTANCE="${METRICS_INSTANCE:-remote231-24h}" METRICS_INSTANCE="${METRICS_INSTANCE:-remote231-24h}"
RPKI_METRICS_RTR_REPORT_DIR="${RPKI_METRICS_RTR_REPORT_DIR:-}"
START_QUERY_SERVICE="${START_QUERY_SERVICE:-0}" START_QUERY_SERVICE="${START_QUERY_SERVICE:-0}"
STOP_QUERY_SERVICE_ON_EXIT="${STOP_QUERY_SERVICE_ON_EXIT:-0}" STOP_QUERY_SERVICE_ON_EXIT="${STOP_QUERY_SERVICE_ON_EXIT:-0}"
QUERY_LISTEN="${QUERY_LISTEN:-0.0.0.0:9560}" QUERY_LISTEN="${QUERY_LISTEN:-0.0.0.0:9560}"
@@ -145,6 +146,11 @@ main() {
free -h > "$LOG_ROOT/24h-free-before.txt" 2>&1 || true free -h > "$LOG_ROOT/24h-free-before.txt" 2>&1 || true
if is_true "$START_METRICS_SERVICE"; then if is_true "$START_METRICS_SERVICE"; then
metrics_env=()
if [[ -n "$RPKI_METRICS_RTR_REPORT_DIR" ]]; then
metrics_env+=(RPKI_METRICS_RTR_REPORT_DIR="$RPKI_METRICS_RTR_REPORT_DIR")
fi
env "${metrics_env[@]}" \
"$BIN_DIR/rpki_artifact_metrics" \ "$BIN_DIR/rpki_artifact_metrics" \
--run-root "$RUN_ROOT" \ --run-root "$RUN_ROOT" \
--listen "$METRICS_LISTEN" \ --listen "$METRICS_LISTEN" \

View File

@@ -15,13 +15,20 @@ 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}"
RUN_ROOT="${RUN_ROOT:-$PACKAGE_ROOT}" RUN_ROOT="${RUN_ROOT:-$PACKAGE_ROOT}"
RETAIN_RUNS="${RETAIN_RUNS:-10}" RETAIN_RUNS="${RETAIN_RUNS:-10}"
CLEAN_TMP_AFTER_RUN="${CLEAN_TMP_AFTER_RUN:-0}" CLEAN_TMP_AFTER_RUN="${CLEAN_TMP_AFTER_RUN:-1}"
OUTPUT_COMPACT_REPORT="${OUTPUT_COMPACT_REPORT:-1}" OUTPUT_COMPACT_REPORT="${OUTPUT_COMPACT_REPORT:-1}"
ALLOW_RSYNC_MIRROR_REUSE="${ALLOW_RSYNC_MIRROR_REUSE:-1}" ALLOW_RSYNC_MIRROR_REUSE="${ALLOW_RSYNC_MIRROR_REUSE:-1}"
RSYNC_SCOPE="${RSYNC_SCOPE:-module-root}" RSYNC_SCOPE="${RSYNC_SCOPE:-module-root}"
FAILURE_SNAPSHOT_RESET="${FAILURE_SNAPSHOT_RESET:-1}" FAILURE_SNAPSHOT_RESET="${FAILURE_SNAPSHOT_RESET:-1}"
PERIODIC_SNAPSHOT_RESET="${PERIODIC_SNAPSHOT_RESET:-0}"
PERIODIC_SNAPSHOT_MAX_DELTAS="${PERIODIC_SNAPSHOT_MAX_DELTAS:-100}"
DB_STATS_EXACT_EVERY="${DB_STATS_EXACT_EVERY:-3}" DB_STATS_EXACT_EVERY="${DB_STATS_EXACT_EVERY:-3}"
RPKI_PROGRESS_LOG="${RPKI_PROGRESS_LOG:-1}" RPKI_PROGRESS_LOG="${RPKI_PROGRESS_LOG:-1}"
RPKI_PROGRESS_SLOW_SECS="${RPKI_PROGRESS_SLOW_SECS:-10}" RPKI_PROGRESS_SLOW_SECS="${RPKI_PROGRESS_SLOW_SECS:-10}"
@@ -44,10 +51,13 @@ META_DIR="${META_DIR:-$STATE_ROOT/meta}"
TMP_DIR="${TMP_DIR:-$RUN_ROOT/tmp}" TMP_DIR="${TMP_DIR:-$RUN_ROOT/tmp}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-$STATE_ROOT/rsync-mirror}" RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-$STATE_ROOT/rsync-mirror}"
INVALID_ROOT="$STATE_ROOT/invalid" INVALID_ROOT="$STATE_ROOT/invalid"
RESET_STAGING_ROOT="$STATE_ROOT/reset-staging"
LIVE_TA_REFRESH_DIR="${LIVE_TA_REFRESH_DIR:-$META_DIR/live-ta-refresh}" LIVE_TA_REFRESH_DIR="${LIVE_TA_REFRESH_DIR:-$META_DIR/live-ta-refresh}"
LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS="${LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS:-15}" LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS="${LIVE_TA_REFRESH_CONNECT_TIMEOUT_SECS:-15}"
LIVE_TA_REFRESH_MAX_TIME_SECS="${LIVE_TA_REFRESH_MAX_TIME_SECS:-120}" LIVE_TA_REFRESH_MAX_TIME_SECS="${LIVE_TA_REFRESH_MAX_TIME_SECS:-120}"
LIVE_TA_REFRESH_BEFORE_SNAPSHOT="${LIVE_TA_REFRESH_BEFORE_SNAPSHOT:-1}" LIVE_TA_REFRESH_BEFORE_SNAPSHOT="${LIVE_TA_REFRESH_BEFORE_SNAPSHOT:-1}"
RUN_LIFECYCLE_STATE_PATH="$STATE_ROOT/run-lifecycle-state.json"
RUN_LIFECYCLE_RECENT_RUNS_LIMIT=200
RPKI_BIN="$BIN_DIR/rpki" RPKI_BIN="$BIN_DIR/rpki"
RPKI_DAEMON_BIN="$BIN_DIR/rpki_daemon" RPKI_DAEMON_BIN="$BIN_DIR/rpki_daemon"
@@ -67,6 +77,10 @@ die() {
exit 2 exit 2
} }
warn() {
echo "warning: $*" >&2
}
is_true() { is_true() {
case "${1:-}" in case "${1:-}" in
1|true|TRUE|yes|YES|on|ON) return 0 ;; 1|true|TRUE|yes|YES|on|ON) return 0 ;;
@@ -108,10 +122,20 @@ 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
}
validate_resource_validation_mode() {
case "$RESOURCE_VALIDATION_MODE" in
validation-update-03|rfc6487)
;;
*)
die "RESOURCE_VALIDATION_MODE must be validation-update-03 or rfc6487: $RESOURCE_VALIDATION_MODE"
;; ;;
esac esac
} }
@@ -135,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" ;;
@@ -150,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
} }
@@ -161,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
} }
@@ -172,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
} }
@@ -409,10 +468,11 @@ max_existing_run_index() {
local run_name local run_name
local numeric_part local numeric_part
shopt -s nullglob shopt -s nullglob
for run_dir in "$RUNS_ROOT"/run_[0-9][0-9][0-9][0-9]; do for run_dir in "$RUNS_ROOT"/run_*; do
[[ -d "$run_dir" ]] || continue [[ -d "$run_dir" ]] || continue
run_name="$(basename "$run_dir")" run_name="$(basename "$run_dir")"
numeric_part="${run_name#run_}" numeric_part="${run_name#run_}"
[[ "$numeric_part" =~ ^[0-9]+$ ]] || continue
if (( 10#$numeric_part > max_index )); then if (( 10#$numeric_part > max_index )); then
max_index=$((10#$numeric_part)) max_index=$((10#$numeric_part))
fi fi
@@ -458,10 +518,20 @@ move_if_exists() {
fi fi
} }
clear_dir_contents() {
local dir_path="$1"
[[ -d "$dir_path" ]] || return 0
find "$dir_path" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
db_state_exists() { db_state_exists() {
[[ -e "$DB_DIR/work-db" || -e "$DB_DIR/repo-bytes.db" ]] [[ -e "$DB_DIR/work-db" || -e "$DB_DIR/repo-bytes.db" ]]
} }
delta_state_available() {
[[ -e "$DB_DIR/work-db" ]]
}
isolate_state_after_failure() { isolate_state_after_failure() {
local previous_run_id="$1" local previous_run_id="$1"
local timestamp local timestamp
@@ -470,11 +540,404 @@ isolate_state_after_failure() {
mkdir -p "$invalid_dir" mkdir -p "$invalid_dir"
move_if_exists "$DB_DIR" "$invalid_dir" move_if_exists "$DB_DIR" "$invalid_dir"
move_if_exists "$META_DIR" "$invalid_dir" move_if_exists "$META_DIR" "$invalid_dir"
move_if_exists "$TMP_DIR" "$invalid_dir" if [[ -e "$TMP_DIR" ]]; then
INVALID_TMP_PATH="$TMP_DIR"
TMP_CLEANUP_STATUS="pending"
TMP_CLEANUP_REASON="discard_after_failure"
clear_dir_contents "$TMP_DIR"
mkdir -p "$TMP_DIR"
TMP_CLEANUP_STATUS="discarded_recreated"
else
INVALID_TMP_PATH=""
TMP_CLEANUP_STATUS="not_present"
TMP_CLEANUP_REASON="discard_after_failure"
mkdir -p "$TMP_DIR"
fi
mkdir -p "$DB_DIR" "$META_DIR" "$TMP_DIR" mkdir -p "$DB_DIR" "$META_DIR" "$TMP_DIR"
INVALID_DB_PATH="$invalid_dir/$(basename "$DB_DIR")" INVALID_DB_PATH="$invalid_dir/$(basename "$DB_DIR")"
INVALID_STATE_PATH="$invalid_dir/$(basename "$META_DIR")" INVALID_STATE_PATH="$invalid_dir/$(basename "$META_DIR")"
INVALID_TMP_PATH="$invalid_dir/$(basename "$TMP_DIR")" }
periodic_snapshot_delta_scan() {
local command="$1"
shift
python3 - "$command" "$RUN_LIFECYCLE_STATE_PATH" "$RUNS_ROOT" "$RUN_LIFECYCLE_RECENT_RUNS_LIMIT" "$@" <<'PY'
import json
import os
import pathlib
import shlex
import sys
from datetime import datetime, timezone
command = sys.argv[1]
state_path = pathlib.Path(sys.argv[2])
runs_root = pathlib.Path(sys.argv[3])
recent_limit = int(sys.argv[4])
extra_args = sys.argv[5:]
def now_rfc3339() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def to_int(value):
if value is None or value == "":
return None
if isinstance(value, bool):
return None
return int(value)
def to_bool(value):
if isinstance(value, bool):
return value
if value in (None, ""):
return None
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
raise ValueError(f"invalid bool value: {value!r}")
def normalize_run_entry(entry):
if entry is None:
return None
if not isinstance(entry, dict):
raise ValueError("run entry must be an object")
run_id = entry.get("run_id") or entry.get("runId")
run_index = to_int(entry.get("run_index", entry.get("runSeq")))
if not run_id or run_index is None:
raise ValueError("run entry missing run_id/run_index")
return {
"run_id": run_id,
"run_index": run_index,
"status": entry.get("status") or "unknown",
"sync_mode": entry.get("sync_mode", entry.get("syncMode")),
"snapshot_reason": entry.get("snapshot_reason"),
"started_at_rfc3339_utc": entry.get("started_at_rfc3339_utc"),
"completed_at_rfc3339_utc": entry.get("completed_at_rfc3339_utc"),
"periodic_snapshot_reset_enabled": to_bool(entry.get("periodic_snapshot_reset_enabled")),
"periodic_snapshot_max_deltas": to_int(entry.get("periodic_snapshot_max_deltas")),
"periodic_snapshot_delta_count": to_int(entry.get("periodic_snapshot_delta_count")),
"periodic_snapshot_forced": to_bool(entry.get("periodic_snapshot_forced")),
}
def snapshot_ref(entry):
if entry is None:
return None
return {
"run_id": entry["run_id"],
"run_index": entry["run_index"],
"snapshot_reason": entry.get("snapshot_reason"),
"completed_at_rfc3339_utc": entry.get("completed_at_rfc3339_utc"),
}
def normalize_snapshot_ref(entry):
if entry is None:
return None
if not isinstance(entry, dict):
raise ValueError("snapshot ref must be an object")
run_id = entry.get("run_id") or entry.get("runId")
run_index = to_int(entry.get("run_index", entry.get("runSeq")))
if not run_id or run_index is None:
raise ValueError("snapshot ref missing run_id/run_index")
return {
"run_id": run_id,
"run_index": run_index,
"snapshot_reason": entry.get("snapshot_reason"),
"completed_at_rfc3339_utc": entry.get("completed_at_rfc3339_utc"),
}
def state_health(last_run, last_success_snapshot):
if last_success_snapshot is not None:
return "ready", "ok"
if last_run is None:
return "empty", "no_runs"
return "bootstrap_incomplete", "no_successful_snapshot_in_retained_runs"
def default_state():
health, detail = state_health(None, None)
return {
"version": 1,
"updated_at_rfc3339_utc": now_rfc3339(),
"state_health": health,
"state_detail": detail,
"recent_runs_limit": recent_limit,
"last_run": None,
"last_success_snapshot": None,
"successful_deltas_since_snapshot": None,
"recent_runs": [],
}
def finalize_state(data):
data = dict(data)
data["version"] = 1
data["updated_at_rfc3339_utc"] = now_rfc3339()
data["recent_runs_limit"] = recent_limit
recent_runs = []
for item in data.get("recent_runs", []):
recent_runs.append(normalize_run_entry(item))
if len(recent_runs) > recent_limit:
recent_runs = recent_runs[-recent_limit:]
data["recent_runs"] = recent_runs
data["last_run"] = normalize_run_entry(data.get("last_run"))
data["last_success_snapshot"] = normalize_snapshot_ref(data.get("last_success_snapshot"))
delta_count = data.get("successful_deltas_since_snapshot")
data["successful_deltas_since_snapshot"] = to_int(delta_count)
if data["last_success_snapshot"] is None:
data["successful_deltas_since_snapshot"] = None
elif data["successful_deltas_since_snapshot"] is None:
data["successful_deltas_since_snapshot"] = 0
health, detail = state_health(data["last_run"], data["last_success_snapshot"])
if health == "ready":
detail = data.get("state_detail") or detail
data["state_health"] = health
data["state_detail"] = detail
return data
def sorted_run_dirs():
candidates = []
for path in runs_root.glob("run_*"):
if not path.is_dir():
continue
suffix = path.name[4:]
if suffix.isdigit():
candidates.append((int(suffix), path))
candidates.sort()
return [path for _, path in candidates]
def parse_run_dir(run_dir):
meta_path = run_dir / "run-meta.json"
summary_path = run_dir / "run-summary.json"
if not meta_path.exists() or not summary_path.exists():
return None
with meta_path.open("r", encoding="utf-8") as handle:
meta = json.load(handle)
with summary_path.open("r", encoding="utf-8") as handle:
summary = json.load(handle)
entry = normalize_run_entry(meta)
entry["status"] = meta.get("status") or summary.get("status") or entry["status"]
return entry
def bootstrap_state(exclude_run_id=None):
data = default_state()
last_success_snapshot = None
delta_count = None
recent_runs = []
last_run = None
for run_dir in sorted_run_dirs():
if exclude_run_id and run_dir.name == exclude_run_id:
continue
try:
entry = parse_run_dir(run_dir)
except Exception:
continue
if entry is None:
continue
last_run = entry
recent_runs.append(entry)
if entry["status"] != "success":
continue
if entry.get("sync_mode") == "snapshot":
last_success_snapshot = snapshot_ref(entry)
delta_count = 0
elif entry.get("sync_mode") == "delta":
if last_success_snapshot is not None and delta_count is not None:
delta_count += 1
data["last_run"] = last_run
data["last_success_snapshot"] = last_success_snapshot
data["successful_deltas_since_snapshot"] = delta_count
data["recent_runs"] = recent_runs[-recent_limit:]
return finalize_state(data)
def backup_corrupt_state():
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup_path = state_path.with_name(f"{state_path.name}.corrupt.{timestamp}.{os.getpid()}")
backup_path.parent.mkdir(parents=True, exist_ok=True)
os.replace(state_path, backup_path)
return backup_path
def load_state_or_bootstrap(exclude_run_id=None):
if not state_path.exists():
return bootstrap_state(exclude_run_id), "bootstrapped_from_runs_missing_file", "", ""
try:
with state_path.open("r", encoding="utf-8") as handle:
raw = json.load(handle)
if not isinstance(raw, dict):
raise ValueError("state root must be an object")
if to_int(raw.get("version")) != 1:
raise ValueError("unsupported version")
state = finalize_state(raw)
return state, "state_file", "", ""
except Exception as exc:
backup_path = backup_corrupt_state()
state = bootstrap_state(exclude_run_id)
return state, "bootstrapped_from_runs_after_corrupt_backup", exc.__class__.__name__, str(backup_path)
def atomic_write_json(path, payload):
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.tmp.{os.getpid()}")
with tmp_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_path, path)
try:
dir_fd = os.open(path.parent, os.O_DIRECTORY)
except OSError:
return
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
def emit(name, value):
if value is None:
value = ""
elif isinstance(value, bool):
value = "true" if value else "false"
else:
value = str(value)
print(f"{name}={shlex.quote(value)}")
if command == "load":
max_deltas = int(extra_args[0])
state, source, detail, backup_path = load_state_or_bootstrap()
if source != "state_file":
atomic_write_json(state_path, state)
delta_count = state.get("successful_deltas_since_snapshot")
force_needed = bool(
state.get("last_success_snapshot") is not None
and delta_count is not None
and delta_count >= max_deltas
)
emit("PERIODIC_LIFECYCLE_SOURCE", source)
emit("PERIODIC_LIFECYCLE_DETAIL", detail or state.get("state_detail"))
emit("PERIODIC_LIFECYCLE_CORRUPT_BACKUP_PATH", backup_path)
emit("PERIODIC_LIFECYCLE_STATE_HEALTH", state.get("state_health"))
emit(
"PERIODIC_LIFECYCLE_LAST_SNAPSHOT_RUN_ID",
(state.get("last_success_snapshot") or {}).get("run_id"),
)
emit("PERIODIC_LIFECYCLE_DELTA_COUNT", delta_count)
emit("PERIODIC_LIFECYCLE_FORCE_NEEDED", force_needed)
elif command == "update":
run_dir = pathlib.Path(extra_args[0])
state, source, detail, backup_path = load_state_or_bootstrap(exclude_run_id=run_dir.name)
entry = parse_run_dir(run_dir)
if entry is None:
raise SystemExit(f"missing run metadata for lifecycle update: {run_dir}")
previous_snapshot = state.get("last_success_snapshot")
previous_delta_count = state.get("successful_deltas_since_snapshot")
state["last_run"] = entry
recent_runs = [item for item in state.get("recent_runs", []) if item.get("run_id") != entry["run_id"]]
recent_runs.append(entry)
state["recent_runs"] = recent_runs[-recent_limit:]
if entry["status"] == "success" and entry.get("sync_mode") == "snapshot":
state["last_success_snapshot"] = snapshot_ref(entry)
state["successful_deltas_since_snapshot"] = 0
state["state_detail"] = "ok"
elif entry["status"] == "success" and entry.get("sync_mode") == "delta":
if previous_snapshot is not None and previous_delta_count is not None:
state["last_success_snapshot"] = previous_snapshot
state["successful_deltas_since_snapshot"] = previous_delta_count + 1
state["state_detail"] = "ok"
else:
state["last_success_snapshot"] = previous_snapshot
state["successful_deltas_since_snapshot"] = None
state["state_detail"] = "success_delta_without_known_snapshot"
else:
state["last_success_snapshot"] = previous_snapshot
state["successful_deltas_since_snapshot"] = previous_delta_count
state = finalize_state(state)
atomic_write_json(state_path, state)
emit("RUN_LIFECYCLE_UPDATE_SOURCE", source)
emit("RUN_LIFECYCLE_UPDATE_DETAIL", detail or state.get("state_detail"))
emit("RUN_LIFECYCLE_UPDATE_CORRUPT_BACKUP_PATH", backup_path)
emit("RUN_LIFECYCLE_UPDATE_STATE_HEALTH", state.get("state_health"))
emit("RUN_LIFECYCLE_UPDATE_DELTA_COUNT", state.get("successful_deltas_since_snapshot"))
emit(
"RUN_LIFECYCLE_UPDATE_LAST_SNAPSHOT_RUN_ID",
(state.get("last_success_snapshot") or {}).get("run_id"),
)
else:
raise SystemExit(f"unknown lifecycle helper command: {command}")
PY
}
load_periodic_snapshot_lifecycle_context() {
eval "$(periodic_snapshot_delta_scan load "$PERIODIC_SNAPSHOT_MAX_DELTAS")"
if [[ -n "$PERIODIC_LIFECYCLE_CORRUPT_BACKUP_PATH" ]]; then
warn "run lifecycle state corrupt; backed up to $PERIODIC_LIFECYCLE_CORRUPT_BACKUP_PATH detail=${PERIODIC_LIFECYCLE_DETAIL:-unknown}"
fi
if [[ "$PERIODIC_LIFECYCLE_SOURCE" != "state_file" ]]; then
echo "run lifecycle state source=$PERIODIC_LIFECYCLE_SOURCE state_health=${PERIODIC_LIFECYCLE_STATE_HEALTH:-unknown} snapshot_run=${PERIODIC_LIFECYCLE_LAST_SNAPSHOT_RUN_ID:-none} delta_count=${PERIODIC_LIFECYCLE_DELTA_COUNT:-unknown}"
fi
if [[ "$PERIODIC_LIFECYCLE_STATE_HEALTH" == "bootstrap_incomplete" ]]; then
warn "run lifecycle state bootstrap incomplete detail=${PERIODIC_LIFECYCLE_DETAIL:-unknown}; forced snapshot counting resumes after the next successful snapshot"
fi
}
update_run_lifecycle_state() {
local run_dir="$1"
eval "$(periodic_snapshot_delta_scan update "$run_dir")"
if [[ -n "$RUN_LIFECYCLE_UPDATE_CORRUPT_BACKUP_PATH" ]]; then
warn "run lifecycle state corrupt during update; backed up to $RUN_LIFECYCLE_UPDATE_CORRUPT_BACKUP_PATH detail=${RUN_LIFECYCLE_UPDATE_DETAIL:-unknown}"
fi
}
prepare_periodic_reset_state_db() {
local run_id="$1"
RESET_DB_STAGING_PATH=""
RESET_DB_CLEANUP_STATUS=""
db_state_exists || return 0
local timestamp
local staging_root
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
staging_root="$RESET_STAGING_ROOT/${run_id}-${timestamp}"
mkdir -p "$staging_root"
mv "$DB_DIR" "$staging_root/"
mkdir -p "$DB_DIR"
RESET_DB_STAGING_PATH="$staging_root/$(basename "$DB_DIR")"
RESET_DB_CLEANUP_STATUS="pending"
}
finalize_periodic_reset_state_db() {
local final_status="$1"
local reset_db_staging_path="$2"
[[ -n "$reset_db_staging_path" ]] || {
printf '%s\n' ""
return 0
}
local staging_root
staging_root="$(dirname "$reset_db_staging_path")"
if [[ "$final_status" == "success" ]]; then
if rm -rf "$staging_root"; then
printf '%s\n' "deleted"
return 0
fi
warn "failed to delete periodic reset staging: $staging_root"
printf '%s\n' "cleanup_failed"
return 1
fi
printf '%s\n' "retained_failure"
} }
write_run_meta() { write_run_meta() {
@@ -494,10 +957,21 @@ write_run_meta() {
local daemon_exit_code="${14}" local daemon_exit_code="${14}"
local package_root="${15}" local package_root="${15}"
local env_file="${16}" local env_file="${16}"
local periodic_snapshot_reset_enabled="${17}"
local periodic_snapshot_max_deltas="${18}"
local periodic_snapshot_delta_count="${19}"
local periodic_snapshot_forced="${20}"
local reset_db_staging_path="${21}"
local reset_db_cleanup_status="${22}"
local tmp_cleanup_status="${23}"
local tmp_cleanup_reason="${24}"
python3 - "$output_path" "$status" "$run_index" "$run_id" "$sync_mode" "$snapshot_reason" \ python3 - "$output_path" "$status" "$run_index" "$run_id" "$sync_mode" "$snapshot_reason" \
"$previous_run_id" "$previous_run_success_value" "$started_at" "$completed_at" \ "$previous_run_id" "$previous_run_success_value" "$started_at" "$completed_at" \
"$invalid_db_path" "$invalid_state_path" "$invalid_tmp_path" "$daemon_exit_code" \ "$invalid_db_path" "$invalid_state_path" "$invalid_tmp_path" "$daemon_exit_code" \
"$package_root" "$env_file" <<'PY' "$package_root" "$env_file" "$periodic_snapshot_reset_enabled" \
"$periodic_snapshot_max_deltas" "$periodic_snapshot_delta_count" \
"$periodic_snapshot_forced" "$reset_db_staging_path" "$reset_db_cleanup_status" \
"$tmp_cleanup_status" "$tmp_cleanup_reason" <<'PY'
import json import json
import sys import sys
@@ -514,6 +988,9 @@ def nullable_int(value):
return None return None
return int(value) return int(value)
def bool_value(value):
return value == "true"
( (
output_path, output_path,
status, status,
@@ -531,6 +1008,14 @@ def nullable_int(value):
daemon_exit_code, daemon_exit_code,
package_root, package_root,
env_file, env_file,
periodic_snapshot_reset_enabled,
periodic_snapshot_max_deltas,
periodic_snapshot_delta_count,
periodic_snapshot_forced,
reset_db_staging_path,
reset_db_cleanup_status,
tmp_cleanup_status,
tmp_cleanup_reason,
) = sys.argv[1:] ) = sys.argv[1:]
data = { data = {
@@ -549,6 +1034,14 @@ data = {
"daemon_exit_code": nullable_int(daemon_exit_code), "daemon_exit_code": nullable_int(daemon_exit_code),
"package_root": package_root, "package_root": package_root,
"env_file": env_file, "env_file": env_file,
"periodic_snapshot_reset_enabled": bool_value(periodic_snapshot_reset_enabled),
"periodic_snapshot_max_deltas": int(periodic_snapshot_max_deltas),
"periodic_snapshot_delta_count": nullable_int(periodic_snapshot_delta_count),
"periodic_snapshot_forced": bool_value(periodic_snapshot_forced),
"reset_db_staging_path": nullable(reset_db_staging_path),
"reset_db_cleanup_status": nullable(reset_db_cleanup_status),
"tmp_cleanup_status": nullable(tmp_cleanup_status),
"tmp_cleanup_reason": nullable(tmp_cleanup_reason),
} }
with open(output_path, "w", encoding="utf-8") as handle: with open(output_path, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2, sort_keys=True) json.dump(data, handle, indent=2, sort_keys=True)
@@ -595,6 +1088,7 @@ build_child_args() {
--db "$DB_DIR/work-db" --db "$DB_DIR/work-db"
--repo-bytes-db "$DB_DIR/repo-bytes.db" --repo-bytes-db "$DB_DIR/repo-bytes.db"
--rsync-scope "$RSYNC_SCOPE" --rsync-scope "$RSYNC_SCOPE"
--resource-validation-mode "$RESOURCE_VALIDATION_MODE"
) )
if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then
CHILD_ARGS+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT") CHILD_ARGS+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
@@ -602,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
@@ -796,7 +1300,10 @@ run_one_round() {
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
write_run_meta "$run_dir/run-meta.json" "running" "$run_index" "$run_id" "$sync_mode" \ write_run_meta "$run_dir/run-meta.json" "running" "$run_index" "$run_id" "$sync_mode" \
"$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "" \ "$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "" \
"$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "" "$PACKAGE_ROOT" "$ENV_FILE" "$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "" "$PACKAGE_ROOT" "$ENV_FILE" \
"$RUN_META_PERIODIC_ENABLED" "$RUN_META_PERIODIC_MAX_DELTAS" "$RUN_META_PERIODIC_DELTA_COUNT" \
"$RUN_META_PERIODIC_FORCED" "$RUN_META_RESET_DB_STAGING_PATH" "$RUN_META_RESET_DB_CLEANUP_STATUS" \
"$RUN_META_TMP_CLEANUP_STATUS" "$RUN_META_TMP_CLEANUP_REASON"
prepare_live_ta_inputs_for_run "$run_id" "$sync_mode" prepare_live_ta_inputs_for_run "$run_id" "$sync_mode"
build_child_args build_child_args
@@ -839,9 +1346,20 @@ run_one_round() {
if [[ "$daemon_exit_code" -eq 0 && "$summary_state" == "success" ]]; then if [[ "$daemon_exit_code" -eq 0 && "$summary_state" == "success" ]]; then
final_status="success" final_status="success"
fi fi
if [[ -n "$RUN_META_RESET_DB_STAGING_PATH" ]]; then
if RUN_META_RESET_DB_CLEANUP_STATUS="$(finalize_periodic_reset_state_db "$final_status" "$RUN_META_RESET_DB_STAGING_PATH")"; then
:
else
final_status="failed"
fi
fi
write_run_meta "$run_dir/run-meta.json" "$final_status" "$run_index" "$run_id" "$sync_mode" \ write_run_meta "$run_dir/run-meta.json" "$final_status" "$run_index" "$run_id" "$sync_mode" \
"$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "$completed_at" \ "$snapshot_reason" "$previous_run_id" "$previous_success_value" "$started_at" "$completed_at" \
"$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "$daemon_exit_code" "$PACKAGE_ROOT" "$ENV_FILE" "$INVALID_DB_PATH" "$INVALID_STATE_PATH" "$INVALID_TMP_PATH" "$daemon_exit_code" "$PACKAGE_ROOT" "$ENV_FILE" \
"$RUN_META_PERIODIC_ENABLED" "$RUN_META_PERIODIC_MAX_DELTAS" "$RUN_META_PERIODIC_DELTA_COUNT" \
"$RUN_META_PERIODIC_FORCED" "$RUN_META_RESET_DB_STAGING_PATH" "$RUN_META_RESET_DB_CLEANUP_STATUS" \
"$RUN_META_TMP_CLEANUP_STATUS" "$RUN_META_TMP_CLEANUP_REASON"
update_run_lifecycle_state "$run_dir"
printf '%s\n' "$run_id" > "$META_DIR/last-run-id" printf '%s\n' "$run_id" > "$META_DIR/last-run-id"
if is_true "$CLEAN_TMP_AFTER_RUN"; then if is_true "$CLEAN_TMP_AFTER_RUN"; then
rm -rf "$daemon_state_root" rm -rf "$daemon_state_root"
@@ -869,10 +1387,13 @@ main() {
validate_positive_int "RETAIN_RUNS" "$RETAIN_RUNS" validate_positive_int "RETAIN_RUNS" "$RETAIN_RUNS"
validate_rsync_scope validate_rsync_scope
validate_tal_input_mode validate_tal_input_mode
validate_resource_validation_mode
validate_non_negative_int "PERIODIC_SNAPSHOT_MAX_DELTAS" "$PERIODIC_SNAPSHOT_MAX_DELTAS"
if [[ -n "${DB_STATS_EXACT_EVERY:-}" && "$DB_STATS_EXACT_EVERY" != "0" ]]; then if [[ -n "${DB_STATS_EXACT_EVERY:-}" && "$DB_STATS_EXACT_EVERY" != "0" ]]; then
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"
@@ -889,7 +1410,7 @@ main() {
fi fi
done done
mkdir -p "$RUNS_ROOT" "$LOG_ROOT" "$DB_DIR" "$META_DIR" "$TMP_DIR" "$INVALID_ROOT" "$LIVE_TA_REFRESH_DIR" mkdir -p "$RUNS_ROOT" "$LOG_ROOT" "$DB_DIR" "$META_DIR" "$TMP_DIR" "$INVALID_ROOT" "$RESET_STAGING_ROOT" "$LIVE_TA_REFRESH_DIR"
if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then if is_true "$ALLOW_RSYNC_MIRROR_REUSE"; then
mkdir -p "$RSYNC_MIRROR_ROOT" mkdir -p "$RSYNC_MIRROR_ROOT"
fi fi
@@ -918,6 +1439,33 @@ main() {
INVALID_DB_PATH="" INVALID_DB_PATH=""
INVALID_STATE_PATH="" INVALID_STATE_PATH=""
INVALID_TMP_PATH="" INVALID_TMP_PATH=""
TMP_CLEANUP_STATUS=""
TMP_CLEANUP_REASON=""
PERIODIC_LIFECYCLE_SOURCE=""
PERIODIC_LIFECYCLE_DETAIL=""
PERIODIC_LIFECYCLE_CORRUPT_BACKUP_PATH=""
PERIODIC_LIFECYCLE_STATE_HEALTH=""
PERIODIC_LIFECYCLE_LAST_SNAPSHOT_RUN_ID=""
PERIODIC_LIFECYCLE_DELTA_COUNT=""
PERIODIC_LIFECYCLE_FORCE_NEEDED="false"
RESET_DB_STAGING_PATH=""
RESET_DB_CLEANUP_STATUS=""
RUN_META_PERIODIC_DELTA_COUNT=""
if is_true "$PERIODIC_SNAPSHOT_RESET"; then
RUN_META_PERIODIC_ENABLED="true"
load_periodic_snapshot_lifecycle_context
if [[ -n "$PERIODIC_LIFECYCLE_DELTA_COUNT" ]]; then
RUN_META_PERIODIC_DELTA_COUNT="$PERIODIC_LIFECYCLE_DELTA_COUNT"
fi
else
RUN_META_PERIODIC_ENABLED="false"
fi
RUN_META_PERIODIC_MAX_DELTAS="$PERIODIC_SNAPSHOT_MAX_DELTAS"
RUN_META_PERIODIC_FORCED="false"
RUN_META_RESET_DB_STAGING_PATH=""
RUN_META_RESET_DB_CLEANUP_STATUS=""
RUN_META_TMP_CLEANUP_STATUS=""
RUN_META_TMP_CLEANUP_REASON=""
local previous_run_id="" local previous_run_id=""
local previous_success_value="" local previous_success_value=""
local sync_mode="snapshot" local sync_mode="snapshot"
@@ -926,8 +1474,19 @@ main() {
previous_run_id="$(printf 'run_%04d' $((next_index - 1)))" previous_run_id="$(printf 'run_%04d' $((next_index - 1)))"
if previous_run_success "$RUNS_ROOT/$previous_run_id"; then if previous_run_success "$RUNS_ROOT/$previous_run_id"; then
previous_success_value="true" previous_success_value="true"
if [[ -e "$DB_DIR/work-db" ]]; then if delta_state_available; then
sync_mode="delta" sync_mode="delta"
if is_true "$PERIODIC_SNAPSHOT_RESET"; then
if [[ "$PERIODIC_LIFECYCLE_FORCE_NEEDED" == "true" ]]; then
RUN_META_PERIODIC_FORCED="true"
sync_mode="snapshot"
snapshot_reason="periodic_snapshot_delta_limit"
prepare_periodic_reset_state_db "$(printf 'run_%04d' "$next_index")"
RUN_META_RESET_DB_STAGING_PATH="$RESET_DB_STAGING_PATH"
RUN_META_RESET_DB_CLEANUP_STATUS="$RESET_DB_CLEANUP_STATUS"
echo "periodic snapshot reset forcing snapshot run=$(printf 'run_%04d' "$next_index") delta_count=$PERIODIC_LIFECYCLE_DELTA_COUNT max_deltas=$PERIODIC_SNAPSHOT_MAX_DELTAS"
fi
fi
else else
sync_mode="snapshot" sync_mode="snapshot"
snapshot_reason="missing_db" snapshot_reason="missing_db"
@@ -936,6 +1495,8 @@ main() {
previous_success_value="false" previous_success_value="false"
if is_true "$FAILURE_SNAPSHOT_RESET"; then if is_true "$FAILURE_SNAPSHOT_RESET"; then
isolate_state_after_failure "$previous_run_id" isolate_state_after_failure "$previous_run_id"
RUN_META_TMP_CLEANUP_STATUS="$TMP_CLEANUP_STATUS"
RUN_META_TMP_CLEANUP_REASON="$TMP_CLEANUP_REASON"
sync_mode="snapshot" sync_mode="snapshot"
snapshot_reason="previous_run_failed" snapshot_reason="previous_run_failed"
else else
@@ -946,6 +1507,8 @@ main() {
sync_mode="snapshot" sync_mode="snapshot"
if db_state_exists; then if db_state_exists; then
isolate_state_after_failure "no_previous_run" isolate_state_after_failure "no_previous_run"
RUN_META_TMP_CLEANUP_STATUS="$TMP_CLEANUP_STATUS"
RUN_META_TMP_CLEANUP_REASON="$TMP_CLEANUP_REASON"
snapshot_reason="no_successful_previous_run" snapshot_reason="no_successful_previous_run"
else else
snapshot_reason="first_run" snapshot_reason="first_run"

View File

@@ -24,7 +24,7 @@ use crate::memory_telemetry::{
}; };
use crate::parallel::config::{ParallelPhase1Config, ParallelPhase2Config}; use crate::parallel::config::{ParallelPhase1Config, ParallelPhase2Config};
use crate::parallel::types::TalInputSpec; use crate::parallel::types::TalInputSpec;
use crate::policy::{Policy, StrictPolicy}; use crate::policy::{Policy, ResourceValidationMode, StrictPolicy};
use crate::storage::{RocksStore, VcirStorageSummary}; use crate::storage::{RocksStore, VcirStorageSummary};
use crate::validation::run_tree_from_tal::{ use crate::validation::run_tree_from_tal::{
RunTreeFromTalAuditOutput, run_tree_from_multiple_tals_parallel_phase2_audit, RunTreeFromTalAuditOutput, run_tree_from_multiple_tals_parallel_phase2_audit,
@@ -130,6 +130,7 @@ pub struct CliArgs {
pub repo_bytes_db: Option<PathBuf>, pub repo_bytes_db: Option<PathBuf>,
pub policy_path: Option<PathBuf>, pub policy_path: Option<PathBuf>,
pub strict_policy: Option<StrictPolicy>, pub strict_policy: Option<StrictPolicy>,
pub resource_validation_mode: Option<ResourceValidationMode>,
pub report_json_path: Option<PathBuf>, pub report_json_path: Option<PathBuf>,
pub report_json_compact: bool, pub report_json_compact: bool,
pub skip_report_build: bool, pub skip_report_build: bool,
@@ -162,6 +163,7 @@ pub struct CliArgs {
pub rsync_command: Option<PathBuf>, pub rsync_command: Option<PathBuf>,
pub http_timeout_secs: u64, pub http_timeout_secs: u64,
pub http_root_cert_paths: Vec<PathBuf>,
pub rsync_timeout_secs: u64, pub rsync_timeout_secs: u64,
pub rsync_mirror_root: Option<PathBuf>, pub rsync_mirror_root: Option<PathBuf>,
pub rsync_scope_policy: RsyncScopePolicy, pub rsync_scope_policy: RsyncScopePolicy,
@@ -189,6 +191,8 @@ Options:
--repo-bytes-db <path> External repo object bytes DB path (optional) --repo-bytes-db <path> External repo object bytes DB path (optional)
--policy <path> Policy TOML path (optional) --policy <path> Policy TOML path (optional)
--strict [policies] Enable strict policies (default all; comma list: name,cms-der,signed-attrs; none disables) --strict [policies] Enable strict policies (default all; comma list: name,cms-der,signed-attrs; none disables)
--resource-validation-mode <validation-update-03|rfc6487>
Resource certificate validation mode (default: validation-update-03)
--report-json <path> Write full audit report as JSON (optional) --report-json <path> Write full audit report as JSON (optional)
--report-json-compact Write report JSON without pretty-printing (requires --report-json) --report-json-compact Write report JSON without pretty-printing (requires --report-json)
--skip-report-build Skip full audit report construction when --report-json is not requested --skip-report-build Skip full audit report construction when --report-json is not requested
@@ -251,6 +255,7 @@ Options:
--disable-rrdp Disable RRDP and synchronize only via rsync --disable-rrdp Disable RRDP and synchronize only via rsync
--rsync-command <path> Use this rsync command instead of the default rsync binary --rsync-command <path> Use this rsync command instead of the default rsync binary
--http-timeout-secs <n> HTTP fetch timeout seconds (default: 20) --http-timeout-secs <n> HTTP fetch timeout seconds (default: 20)
--http-root-cert <path> Extra PEM root certificate trusted by HTTPS fetches (repeatable)
--rsync-timeout-secs <n> rsync I/O timeout seconds (default: 60) --rsync-timeout-secs <n> rsync I/O timeout seconds (default: 60)
--rsync-mirror-root <path> Persist rsync mirrors under this directory (default: disabled) --rsync-mirror-root <path> Persist rsync mirrors under this directory (default: disabled)
--rsync-scope <policy> rsync scope policy: host, publication-point, or module-root (default: module-root) --rsync-scope <policy> rsync scope policy: host, publication-point, or module-root (default: module-root)
@@ -278,6 +283,7 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let mut repo_bytes_db: Option<PathBuf> = None; let mut repo_bytes_db: Option<PathBuf> = None;
let mut policy_path: Option<PathBuf> = None; let mut policy_path: Option<PathBuf> = None;
let mut strict_policy: Option<StrictPolicy> = None; let mut strict_policy: Option<StrictPolicy> = None;
let mut resource_validation_mode: Option<ResourceValidationMode> = None;
let mut report_json_path: Option<PathBuf> = None; let mut report_json_path: Option<PathBuf> = None;
let mut report_json_compact: bool = false; let mut report_json_compact: bool = false;
let mut skip_report_build: bool = false; let mut skip_report_build: bool = false;
@@ -309,6 +315,7 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let mut disable_rrdp: bool = false; let mut disable_rrdp: bool = false;
let mut rsync_command: Option<PathBuf> = None; let mut rsync_command: Option<PathBuf> = None;
let mut http_timeout_secs: u64 = 30; let mut http_timeout_secs: u64 = 30;
let mut http_root_cert_paths: Vec<PathBuf> = Vec::new();
let mut rsync_timeout_secs: u64 = 30; let mut rsync_timeout_secs: u64 = 30;
let mut rsync_mirror_root: Option<PathBuf> = None; let mut rsync_mirror_root: Option<PathBuf> = None;
let mut rsync_scope_policy = RsyncScopePolicy::default(); let mut rsync_scope_policy = RsyncScopePolicy::default();
@@ -475,6 +482,19 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let spec = arg.strip_prefix("--strict=").expect("prefix checked"); let spec = arg.strip_prefix("--strict=").expect("prefix checked");
strict_policy = Some(StrictPolicy::parse_cli_spec(Some(spec))?); strict_policy = Some(StrictPolicy::parse_cli_spec(Some(spec))?);
} }
"--resource-validation-mode" => {
i += 1;
let v = argv
.get(i)
.ok_or("--resource-validation-mode requires a value")?;
resource_validation_mode = Some(ResourceValidationMode::parse_cli_value(v)?);
}
_ if arg.starts_with("--resource-validation-mode=") => {
let spec = arg
.strip_prefix("--resource-validation-mode=")
.expect("prefix checked");
resource_validation_mode = Some(ResourceValidationMode::parse_cli_value(spec)?);
}
"--report-json" => { "--report-json" => {
i += 1; i += 1;
let v = argv.get(i).ok_or("--report-json requires a value")?; let v = argv.get(i).ok_or("--report-json requires a value")?;
@@ -619,6 +639,11 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
.parse::<u64>() .parse::<u64>()
.map_err(|_| format!("invalid --http-timeout-secs: {v}"))?; .map_err(|_| format!("invalid --http-timeout-secs: {v}"))?;
} }
"--http-root-cert" => {
i += 1;
let v = argv.get(i).ok_or("--http-root-cert requires a value")?;
http_root_cert_paths.push(PathBuf::from(v));
}
"--rsync-timeout-secs" => { "--rsync-timeout-secs" => {
i += 1; i += 1;
let v = argv.get(i).ok_or("--rsync-timeout-secs requires a value")?; let v = argv.get(i).ok_or("--rsync-timeout-secs requires a value")?;
@@ -929,6 +954,7 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
repo_bytes_db, repo_bytes_db,
policy_path, policy_path,
strict_policy, strict_policy,
resource_validation_mode,
report_json_path, report_json_path,
report_json_compact, report_json_compact,
skip_report_build, skip_report_build,
@@ -959,6 +985,7 @@ pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
disable_rrdp, disable_rrdp,
rsync_command, rsync_command,
http_timeout_secs, http_timeout_secs,
http_root_cert_paths,
rsync_timeout_secs, rsync_timeout_secs,
rsync_mirror_root, rsync_mirror_root,
rsync_scope_policy, rsync_scope_policy,
@@ -1974,12 +2001,23 @@ pub fn run(argv: &[String]) -> Result<(), String> {
if let Some(strict_policy) = args.strict_policy { if let Some(strict_policy) = args.strict_policy {
policy.strict = strict_policy; policy.strict = strict_policy;
} }
if let Some(resource_validation_mode) = args.resource_validation_mode {
policy.resource_validation_mode = resource_validation_mode;
}
if args.disable_rrdp { if args.disable_rrdp {
policy.sync_preference = crate::policy::SyncPreference::RsyncOnly; policy.sync_preference = crate::policy::SyncPreference::RsyncOnly;
} }
let validation_time = args let validation_time = args
.validation_time .validation_time
.unwrap_or_else(time::OffsetDateTime::now_utc); .unwrap_or_else(time::OffsetDateTime::now_utc);
let http_root_certificates_pem = args
.http_root_cert_paths
.iter()
.map(|path| {
std::fs::read(path)
.map_err(|e| format!("read HTTP root certificate failed: {}: {e}", path.display()))
})
.collect::<Result<Vec<_>, _>>()?;
let store = if args.raw_store_db.is_some() || args.repo_bytes_db.is_some() { let store = if args.raw_store_db.is_some() || args.repo_bytes_db.is_some() {
Arc::new( Arc::new(
@@ -2200,6 +2238,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
} else if let Some(dir) = args.rsync_local_dir.as_ref() { } else if let Some(dir) = args.rsync_local_dir.as_ref() {
let http = BlockingHttpFetcher::new(HttpFetcherConfig { let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)), timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)),
extra_root_certificates_pem: http_root_certificates_pem.clone(),
..HttpFetcherConfig::default() ..HttpFetcherConfig::default()
}) })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -2218,6 +2257,7 @@ pub fn run(argv: &[String]) -> Result<(), String> {
} else { } else {
let http = BlockingHttpFetcher::new(HttpFetcherConfig { let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)), timeout: std::time::Duration::from_secs(args.http_timeout_secs.max(1)),
extra_root_certificates_pem: http_root_certificates_pem.clone(),
..HttpFetcherConfig::default() ..HttpFetcherConfig::default()
}) })
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;

View File

@@ -2,6 +2,7 @@ use super::*;
use crate::memory_telemetry::{ use crate::memory_telemetry::{
MemoryTelemetryCheckpoint, MemoryTelemetrySummary, ProcessMemorySnapshot, MemoryTelemetryCheckpoint, MemoryTelemetrySummary, ProcessMemorySnapshot,
}; };
use crate::policy::ResourceValidationMode;
use crate::storage::{ use crate::storage::{
RocksDbMemorySnapshot, RocksDbMemoryTotals, VcirCcrProjectionSizeBreakdown, RocksDbMemorySnapshot, RocksDbMemoryTotals, VcirCcrProjectionSizeBreakdown,
VcirChildResourceSizeBreakdown, VcirCoreFieldSizeBreakdown, VcirFieldSizeBreakdown, VcirChildResourceSizeBreakdown, VcirCoreFieldSizeBreakdown, VcirFieldSizeBreakdown,
@@ -19,6 +20,7 @@ fn parse_help_returns_usage() {
assert!(err.contains("--parallel-phase2-object-workers"), "{err}"); assert!(err.contains("--parallel-phase2-object-workers"), "{err}");
assert!(err.contains("--memory-trim-after-validation"), "{err}"); assert!(err.contains("--memory-trim-after-validation"), "{err}");
assert!(err.contains("--enable-roa-validation-cache"), "{err}"); assert!(err.contains("--enable-roa-validation-cache"), "{err}");
assert!(err.contains("--resource-validation-mode"), "{err}");
assert!( assert!(
err.contains("--publication-point-cache-observe-only"), err.contains("--publication-point-cache-observe-only"),
"{err}" "{err}"
@@ -287,6 +289,29 @@ fn parse_accepts_host_rsync_scope_policy() {
assert_eq!(args.rsync_scope_policy, RsyncScopePolicy::Host); assert_eq!(args.rsync_scope_policy, RsyncScopePolicy::Host);
} }
#[test]
fn parse_accepts_repeatable_http_root_cert_paths() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--http-root-cert".to_string(),
"local-ca-1.pem".to_string(),
"--http-root-cert".to_string(),
"local-ca-2.pem".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert_eq!(
args.http_root_cert_paths,
vec![
PathBuf::from("local-ca-1.pem"),
PathBuf::from("local-ca-2.pem")
]
);
}
#[test] #[test]
fn parse_rejects_invalid_rsync_scope_policy() { fn parse_rejects_invalid_rsync_scope_policy() {
let argv = vec![ let argv = vec![
@@ -340,6 +365,39 @@ fn parse_accepts_strict_without_value_as_all() {
assert_eq!(args.strict_policy, Some(StrictPolicy::all())); assert_eq!(args.strict_policy, Some(StrictPolicy::all()));
} }
#[test]
fn parse_accepts_resource_validation_mode() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--resource-validation-mode".to_string(),
"rfc6487".to_string(),
];
let args = parse_args(&argv).expect("parse args");
assert_eq!(
args.resource_validation_mode,
Some(ResourceValidationMode::Rfc6487)
);
}
#[test]
fn parse_rejects_unknown_resource_validation_mode() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--resource-validation-mode".to_string(),
"bogus".to_string(),
];
let err = parse_args(&argv).expect_err("unknown mode should fail");
assert!(err.contains("unknown resource validation mode"), "{err}");
}
#[test] #[test]
fn parse_rejects_unknown_strict_policy() { fn parse_rejects_unknown_strict_policy() {
let argv = vec![ let argv = vec![
@@ -1407,6 +1465,10 @@ fn read_policy_accepts_valid_toml() {
policy.signed_object_failure_policy, policy.signed_object_failure_policy,
crate::policy::SignedObjectFailurePolicy::DropPublicationPoint crate::policy::SignedObjectFailurePolicy::DropPublicationPoint
); );
assert_eq!(
policy.resource_validation_mode,
ResourceValidationMode::ValidationUpdate03
);
assert_eq!(policy.strict, StrictPolicy::default()); assert_eq!(policy.strict, StrictPolicy::default());
} }
@@ -1435,6 +1497,19 @@ fn read_policy_accepts_strict_table() {
); );
} }
#[test]
fn read_policy_accepts_resource_validation_mode() {
let dir = tempfile::tempdir().expect("tmpdir");
let p = dir.path().join("policy.toml");
std::fs::write(&p, "resource_validation_mode = \"rfc6487\"\n").expect("write policy");
let policy = read_policy(Some(&p)).expect("parse policy");
assert_eq!(
policy.resource_validation_mode,
ResourceValidationMode::Rfc6487
);
}
#[test] #[test]
fn read_policy_reports_missing_file() { fn read_policy_reports_missing_file() {
let dir = tempfile::tempdir().expect("tmpdir"); let dir = tempfile::tempdir().expect("tmpdir");

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,
@@ -740,6 +899,40 @@ mod strict_name_tests {
let err = validate_strict_rpki_name(&name, "subject").expect_err("strict name fails"); let err = validate_strict_rpki_name(&name, "subject").expect_err("strict name fails");
assert!(err.to_string().contains("exactly once"), "{err}"); assert!(err.to_string().contains("exactly once"), "{err}");
} }
#[test]
fn profile_rejects_rfc8360_v2_policy_oid() {
let extensions = RcExtensionsParsed {
basic_constraints: vec![BasicConstraintsProfile {
ca: true,
critical: true,
path_len_constraint: None,
}],
subject_key_identifier: Vec::new(),
authority_key_identifier: Vec::new(),
crl_distribution_points: Vec::new(),
authority_info_access: Vec::new(),
subject_info_access: Vec::new(),
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(),
as_resources: Vec::new(),
};
let err = extensions
.validate_profile(true)
.expect_err("v2 policy OID must remain invalid");
assert!(
matches!(&err, ResourceCertificateProfileError::InvalidCertificatePolicy(oid) if oid == "1.3.6.1.5.5.7.14.3"),
"{err}"
);
}
} }
impl ResourceCertificateParsed { impl ResourceCertificateParsed {
@@ -798,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,
@@ -1009,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(
@@ -1070,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,
}) })
@@ -1104,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(
@@ -1172,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)?;
@@ -1194,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

@@ -29,6 +29,8 @@ pub struct HttpFetcherConfig {
/// Larger timeout used for RRDP snapshot / delta bodies. /// Larger timeout used for RRDP snapshot / delta bodies.
pub large_body_timeout: Duration, pub large_body_timeout: Duration,
pub user_agent: String, pub user_agent: String,
/// Extra PEM trust anchors for HTTPS transport tests or private RRDP endpoints.
pub extra_root_certificates_pem: Vec<Vec<u8>>,
} }
impl Default for HttpFetcherConfig { impl Default for HttpFetcherConfig {
@@ -38,6 +40,7 @@ impl Default for HttpFetcherConfig {
timeout: Duration::from_secs(30), timeout: Duration::from_secs(30),
large_body_timeout: Duration::from_secs(180), large_body_timeout: Duration::from_secs(180),
user_agent: "rpki-dev/0.1 (stage2)".to_string(), user_agent: "rpki-dev/0.1 (stage2)".to_string(),
extra_root_certificates_pem: Vec::new(),
} }
} }
} }
@@ -61,22 +64,28 @@ impl BlockingHttpFetcher {
let short_timeout = config.timeout; let short_timeout = config.timeout;
let large_body_timeout = std::cmp::max(config.large_body_timeout, config.timeout); let large_body_timeout = std::cmp::max(config.large_body_timeout, config.timeout);
let connect_timeout = std::cmp::min(config.connect_timeout, config.timeout); let connect_timeout = std::cmp::min(config.connect_timeout, config.timeout);
let short_client = Client::builder() let short_client = Self::client_builder(
.connect_timeout(connect_timeout) &config,
.timeout(config.timeout) connect_timeout,
.user_agent(config.user_agent.clone()) config.timeout,
config.user_agent.clone(),
)?
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let large_body_client = Client::builder() let large_body_client = Self::client_builder(
.connect_timeout(connect_timeout) &config,
.timeout(large_body_timeout) connect_timeout,
.user_agent(config.user_agent) large_body_timeout,
config.user_agent.clone(),
)?
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let retry_short_client = Client::builder() let retry_short_client = Self::client_builder(
.connect_timeout(Duration::from_secs(1)) &config,
.timeout(Duration::from_secs(1)) Duration::from_secs(1),
.user_agent("rpki-dev/0.1 (stage2)") Duration::from_secs(1),
"rpki-dev/0.1 (stage2)".to_string(),
)?
.build() .build()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(Self { Ok(Self {
@@ -88,11 +97,29 @@ impl BlockingHttpFetcher {
}) })
} }
fn client_builder(
config: &HttpFetcherConfig,
connect_timeout: Duration,
timeout: Duration,
user_agent: String,
) -> Result<reqwest::blocking::ClientBuilder, String> {
let mut builder = Client::builder()
.connect_timeout(connect_timeout)
.timeout(timeout)
.user_agent(user_agent);
for (idx, pem) in config.extra_root_certificates_pem.iter().enumerate() {
let certificate = reqwest::Certificate::from_pem(pem)
.map_err(|e| format!("parse HTTP root certificate #{idx} failed: {e}"))?;
builder = builder.add_root_certificate(certificate);
}
Ok(builder)
}
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> { pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let (client, timeout_profile, timeout_value) = self.client_for_uri(uri); let (client, timeout_profile, timeout_value) = self.client_for_uri(uri);
let resp = client.get(uri).send().map_err(|e| { let resp = client.get(uri).send().map_err(|e| {
let msg = format!("http request failed: {e}"); let msg = format!("http request failed: {e:?}");
crate::progress_log::emit( crate::progress_log::emit(
"http_fetch_failed", "http_fetch_failed",
serde_json::json!({ serde_json::json!({
@@ -222,7 +249,7 @@ impl Fetcher for BlockingHttpFetcher {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
let (client, timeout_profile, timeout_value) = self.client_for_uri(uri); let (client, timeout_profile, timeout_value) = self.client_for_uri(uri);
let resp = client.get(uri).send().map_err(|e| { let resp = client.get(uri).send().map_err(|e| {
let msg = format!("http request failed: {e}"); let msg = format!("http request failed: {e:?}");
crate::progress_log::emit( crate::progress_log::emit(
"http_fetch_failed", "http_fetch_failed",
serde_json::json!({ serde_json::json!({

View File

@@ -39,6 +39,32 @@ impl Default for SignedObjectFailurePolicy {
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResourceValidationMode {
#[serde(rename = "validation-update-03")]
ValidationUpdate03,
#[serde(rename = "rfc6487")]
Rfc6487,
}
impl Default for ResourceValidationMode {
fn default() -> Self {
Self::ValidationUpdate03
}
}
impl ResourceValidationMode {
pub fn parse_cli_value(raw: &str) -> Result<Self, String> {
match raw.trim() {
"validation-update-03" => Ok(Self::ValidationUpdate03),
"rfc6487" => Ok(Self::Rfc6487),
value => Err(format!(
"unknown resource validation mode: {value}; supported: validation-update-03,rfc6487"
)),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct StrictPolicy { pub struct StrictPolicy {
@@ -99,6 +125,7 @@ pub struct Policy {
pub sync_preference: SyncPreference, pub sync_preference: SyncPreference,
pub ca_failed_fetch_policy: CaFailedFetchPolicy, pub ca_failed_fetch_policy: CaFailedFetchPolicy,
pub signed_object_failure_policy: SignedObjectFailurePolicy, pub signed_object_failure_policy: SignedObjectFailurePolicy,
pub resource_validation_mode: ResourceValidationMode,
pub strict: StrictPolicy, pub strict: StrictPolicy,
} }
@@ -108,6 +135,7 @@ impl Default for Policy {
sync_preference: SyncPreference::default(), sync_preference: SyncPreference::default(),
ca_failed_fetch_policy: CaFailedFetchPolicy::default(), ca_failed_fetch_policy: CaFailedFetchPolicy::default(),
signed_object_failure_policy: SignedObjectFailurePolicy::default(), signed_object_failure_policy: SignedObjectFailurePolicy::default(),
resource_validation_mode: ResourceValidationMode::default(),
strict: StrictPolicy::default(), strict: StrictPolicy::default(),
} }
} }
@@ -150,6 +178,19 @@ mod tests {
assert!(StrictPolicy::parse_cli_spec(Some("bogus")).is_err()); assert!(StrictPolicy::parse_cli_spec(Some("bogus")).is_err());
} }
#[test]
fn resource_validation_mode_parses_cli_values() {
assert_eq!(
ResourceValidationMode::parse_cli_value("validation-update-03").unwrap(),
ResourceValidationMode::ValidationUpdate03
);
assert_eq!(
ResourceValidationMode::parse_cli_value("rfc6487").unwrap(),
ResourceValidationMode::Rfc6487
);
assert!(ResourceValidationMode::parse_cli_value("bogus").is_err());
}
#[test] #[test]
fn policy_toml_accepts_strict_table() { fn policy_toml_accepts_strict_table() {
let policy = Policy::from_toml_str( let policy = Policy::from_toml_str(
@@ -170,4 +211,18 @@ mod tests {
} }
); );
} }
#[test]
fn policy_toml_accepts_resource_validation_mode() {
let policy = Policy::from_toml_str(
r#"
resource_validation_mode = "rfc6487"
"#,
)
.expect("parse policy");
assert_eq!(
policy.resource_validation_mode,
ResourceValidationMode::Rfc6487
);
}
} }

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

@@ -15,6 +15,7 @@ use sha2::{Digest, Sha256};
const LARGE_PP_OBJECT_THRESHOLDS: &[u64] = &[10, 50, 100, 500, 1000, 5000, 10000, 50000]; const LARGE_PP_OBJECT_THRESHOLDS: &[u64] = &[10, 50, 100, 500, 1000, 5000, 10000, 50000];
const PP_SYNC_SECONDS_BUCKETS: &[f64] = &[0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0]; const PP_SYNC_SECONDS_BUCKETS: &[f64] = &[0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0];
const RTR_COUNT_TYPES: &[&str] = &["total", "vrp", "router_key", "aspa"];
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
struct Args { struct Args {
@@ -160,6 +161,7 @@ struct MetricsSnapshot {
top_pp_by_sync_duration: Vec<TopPublicationPoint>, top_pp_by_sync_duration: Vec<TopPublicationPoint>,
cir: Option<CirMetrics>, cir: Option<CirMetrics>,
ccr: Option<CcrMetrics>, ccr: Option<CcrMetrics>,
rtr: RtrMetrics,
} }
#[derive(Clone, Debug, Default, Serialize)] #[derive(Clone, Debug, Default, Serialize)]
@@ -173,6 +175,106 @@ struct ServiceMetrics {
runs_root: String, runs_root: String,
} }
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrMetrics {
enabled: bool,
report_dir: Option<String>,
parse_errors: Vec<String>,
parse_errors_total: u64,
files: BTreeMap<String, RtrFileScan>,
source: Option<RtrSourceMetrics>,
runtime: Option<RtrRuntimeMetrics>,
clients: Option<RtrClientsMetrics>,
}
impl RtrMetrics {
fn preferred_service(&self) -> Option<&RtrServiceMetrics> {
self.runtime
.as_ref()
.and_then(|runtime| runtime.service.as_ref())
.or_else(|| {
self.clients
.as_ref()
.and_then(|clients| clients.service.as_ref())
})
}
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrFileScan {
seen: u64,
latest_path: Option<String>,
latest_generated_at: Option<String>,
latest_timestamp_seconds: Option<f64>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrSourceMetrics {
file_path: String,
generated_at: Option<String>,
generated_at_seconds: Option<f64>,
phase: Option<String>,
refresh_status: Option<String>,
refresh_changed: Option<bool>,
refresh_duration_seconds: Option<f64>,
refresh_consecutive_failures: Option<u64>,
refresh_last_success_at: Option<String>,
refresh_last_success_seconds: Option<f64>,
ccr_file_size_bytes: Option<u64>,
data_quality: BTreeMap<String, BTreeMap<String, u64>>,
slurm_filters: BTreeMap<String, u64>,
slurm_assertions: BTreeMap<String, u64>,
cache_availability: Option<String>,
cache_versions: Vec<RtrCacheVersion>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrCacheVersion {
version: u64,
serial: Option<u64>,
snapshot: BTreeMap<String, u64>,
latest_delta_announced: BTreeMap<String, u64>,
latest_delta_withdrawn: BTreeMap<String, u64>,
delta_window_length: Option<u64>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrRuntimeMetrics {
file_path: String,
generated_at: Option<String>,
generated_at_seconds: Option<f64>,
phase: Option<String>,
process_rss_bytes: Option<u64>,
service: Option<RtrServiceMetrics>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrClientsMetrics {
file_path: String,
generated_at: Option<String>,
generated_at_seconds: Option<f64>,
phase: Option<String>,
service: Option<RtrServiceMetrics>,
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct RtrServiceMetrics {
started_at: Option<String>,
uptime_seconds: Option<u64>,
active_connections: Option<u64>,
connections_by_transport: BTreeMap<String, u64>,
session_listeners: Option<u64>,
max_connections: Option<u64>,
connection_utilization: Option<f64>,
}
#[derive(Clone, Debug, Default, Serialize)] #[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct RunScanSummary { struct RunScanSummary {
@@ -391,13 +493,29 @@ struct RunRecord {
} }
fn scan_run_root(input_root: &Path, instance: &str) -> Result<MetricsSnapshot, String> { fn scan_run_root(input_root: &Path, instance: &str) -> Result<MetricsSnapshot, String> {
scan_run_root_incremental(input_root, instance, None) let rtr_report_dir = configured_rtr_report_dir();
scan_run_root_incremental_with_rtr_dir(input_root, instance, None, rtr_report_dir.as_deref())
} }
fn scan_run_root_incremental( fn scan_run_root_incremental(
input_root: &Path, input_root: &Path,
instance: &str, instance: &str,
previous: Option<&MetricsSnapshot>, previous: Option<&MetricsSnapshot>,
) -> Result<MetricsSnapshot, String> {
let rtr_report_dir = configured_rtr_report_dir();
scan_run_root_incremental_with_rtr_dir(
input_root,
instance,
previous,
rtr_report_dir.as_deref(),
)
}
fn scan_run_root_incremental_with_rtr_dir(
input_root: &Path,
instance: &str,
previous: Option<&MetricsSnapshot>,
rtr_report_dir: Option<&Path>,
) -> Result<MetricsSnapshot, String> { ) -> Result<MetricsSnapshot, String> {
let started = Instant::now(); let started = Instant::now();
let runs_root = resolve_runs_root(input_root); let runs_root = resolve_runs_root(input_root);
@@ -451,9 +569,11 @@ fn scan_run_root_incremental(
} }
} }
snapshot.rtr = scan_rtr_reports(rtr_report_dir, previous.map(|snapshot| &snapshot.rtr));
snapshot.service.last_scan_timestamp_seconds = unix_now_seconds(); snapshot.service.last_scan_timestamp_seconds = unix_now_seconds();
snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64(); snapshot.service.last_scan_duration_seconds = started.elapsed().as_secs_f64();
snapshot.service.last_reload_success = snapshot.service.parse_errors.is_empty(); snapshot.service.last_reload_success =
snapshot.service.parse_errors.is_empty() && snapshot.rtr.parse_errors.is_empty();
Ok(snapshot) Ok(snapshot)
} }
@@ -502,6 +622,291 @@ fn resolve_runs_root(input_root: &Path) -> PathBuf {
} }
} }
fn configured_rtr_report_dir() -> Option<PathBuf> {
std::env::var("RPKI_METRICS_RTR_REPORT_DIR")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn scan_rtr_reports(report_dir: Option<&Path>, previous: Option<&RtrMetrics>) -> RtrMetrics {
let mut metrics = RtrMetrics {
enabled: report_dir.is_some(),
report_dir: report_dir.map(|path| path.display().to_string()),
parse_errors_total: previous.map(|prev| prev.parse_errors_total).unwrap_or(0),
..RtrMetrics::default()
};
let Some(report_dir) = report_dir else {
return metrics;
};
if !report_dir.is_dir() {
metrics.parse_errors.push(format!(
"rtr report dir is not a directory: {}",
report_dir.display()
));
metrics.parse_errors_total = metrics
.parse_errors_total
.saturating_add(metrics.parse_errors.len() as u64);
return metrics;
}
metrics.files.insert(
"source".to_string(),
scan_rtr_kind(
report_dir,
&["rtr-source-*.json"],
&mut metrics.parse_errors,
),
);
metrics.files.insert(
"runtime".to_string(),
scan_rtr_kind(
report_dir,
&["rtr-runtime-*.json"],
&mut metrics.parse_errors,
),
);
metrics.files.insert(
"clients".to_string(),
scan_rtr_kind(
report_dir,
&["rtr-clients-*.json", "rtr-client-*.json"],
&mut metrics.parse_errors,
),
);
if let Some(file) = metrics
.files
.get("source")
.and_then(|scan| scan.latest_path.as_deref())
{
if let Some(value) = read_rtr_json(&PathBuf::from(file), &mut metrics.parse_errors) {
metrics.source = Some(parse_rtr_source_metrics(&PathBuf::from(file), &value));
if let Some(source) = metrics.source.as_ref() {
if let Some(scan) = metrics.files.get_mut("source") {
scan.latest_generated_at = source.generated_at.clone();
scan.latest_timestamp_seconds = source.generated_at_seconds;
}
}
}
}
if let Some(file) = metrics
.files
.get("runtime")
.and_then(|scan| scan.latest_path.as_deref())
{
if let Some(value) = read_rtr_json(&PathBuf::from(file), &mut metrics.parse_errors) {
metrics.runtime = Some(parse_rtr_runtime_metrics(&PathBuf::from(file), &value));
if let Some(runtime) = metrics.runtime.as_ref() {
if let Some(scan) = metrics.files.get_mut("runtime") {
scan.latest_generated_at = runtime.generated_at.clone();
scan.latest_timestamp_seconds = runtime.generated_at_seconds;
}
}
}
}
if let Some(file) = metrics
.files
.get("clients")
.and_then(|scan| scan.latest_path.as_deref())
{
if let Some(value) = read_rtr_json(&PathBuf::from(file), &mut metrics.parse_errors) {
metrics.clients = Some(parse_rtr_clients_metrics(&PathBuf::from(file), &value));
if let Some(clients) = metrics.clients.as_ref() {
if let Some(scan) = metrics.files.get_mut("clients") {
scan.latest_generated_at = clients.generated_at.clone();
scan.latest_timestamp_seconds = clients.generated_at_seconds;
}
}
}
}
metrics.parse_errors_total = metrics
.parse_errors_total
.saturating_add(metrics.parse_errors.len() as u64);
metrics
}
fn scan_rtr_kind(report_dir: &Path, patterns: &[&str], errors: &mut Vec<String>) -> RtrFileScan {
let mut names = BTreeSet::new();
for pattern in patterns {
match glob_rtr_pattern(report_dir, pattern) {
Ok(found) => {
for path in found {
names.insert(path);
}
}
Err(err) => errors.push(err),
}
}
let latest_path = names.iter().next_back().cloned();
RtrFileScan {
seen: names.len() as u64,
latest_path: latest_path.map(|path| path.display().to_string()),
latest_generated_at: None,
latest_timestamp_seconds: None,
}
}
fn glob_rtr_pattern(report_dir: &Path, pattern: &str) -> Result<Vec<PathBuf>, String> {
let Some((prefix, suffix)) = pattern.split_once('*') else {
return Err(format!("unsupported RTR report pattern: {pattern}"));
};
let mut matches = Vec::new();
let entries = fs::read_dir(report_dir)
.map_err(|e| format!("read rtr report dir failed: {}: {e}", report_dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("read rtr report entry failed: {e}"))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name.starts_with(prefix) && name.ends_with(suffix) {
matches.push(path);
}
}
matches.sort();
Ok(matches)
}
fn read_rtr_json(path: &Path, errors: &mut Vec<String>) -> Option<Value> {
let Ok(bytes) = fs::read(path) else {
errors.push(format!("read rtr report failed: {}", path.display()));
return None;
};
match serde_json::from_slice::<Value>(&bytes) {
Ok(value) => Some(value),
Err(err) => {
errors.push(format!(
"parse rtr report failed: {}: {err}",
path.display()
));
None
}
}
}
fn parse_rtr_source_metrics(path: &Path, value: &Value) -> RtrSourceMetrics {
let generated_at = json_str(value, &["generated_at"]).map(|value| value.to_string());
let generated_at_seconds = generated_at.as_deref().and_then(parse_rfc3339_to_unix);
let mut data_quality = BTreeMap::new();
for stage in ["ccr_input", "invalid", "before_slurm", "after_slurm"] {
if let Some(stage_metrics) =
json_counts_map(value.pointer(&format!("/data_quality/{stage}")))
{
data_quality.insert(stage.to_string(), stage_metrics);
}
}
let cache_versions = value
.pointer("/cache/versions")
.and_then(|value| value.as_array())
.map(|versions| {
versions
.iter()
.map(|version| RtrCacheVersion {
version: json_u64(version, &["version"]).unwrap_or(0),
serial: json_u64(version, &["serial"]),
snapshot: json_counts_map(version.get("snapshot")).unwrap_or_default(),
latest_delta_announced: json_counts_map(
version.pointer("/latest_delta/announced"),
)
.unwrap_or_default(),
latest_delta_withdrawn: json_counts_map(
version.pointer("/latest_delta/withdrawn"),
)
.unwrap_or_default(),
delta_window_length: json_u64(version, &["delta_window", "length"]),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
RtrSourceMetrics {
file_path: path.display().to_string(),
generated_at,
generated_at_seconds,
phase: json_str(value, &["phase"]).map(|value| value.to_string()),
refresh_status: json_str(value, &["refresh", "status"]).map(|value| value.to_string()),
refresh_changed: json_bool(value, &["refresh", "changed"]),
refresh_duration_seconds: json_u64(value, &["refresh", "duration_ms"])
.map(|value| value as f64 / 1000.0),
refresh_consecutive_failures: json_u64(value, &["refresh", "consecutive_failures"]),
refresh_last_success_at: json_str(value, &["refresh", "last_success_at"])
.map(|value| value.to_string()),
refresh_last_success_seconds: json_str(value, &["refresh", "last_success_at"])
.and_then(parse_rfc3339_to_unix),
ccr_file_size_bytes: json_u64(value, &["source", "ccr_file_size_bytes"]),
data_quality,
slurm_filters: json_counts_map(value.pointer("/data_quality/slurm_filters"))
.unwrap_or_default(),
slurm_assertions: json_counts_map(value.pointer("/data_quality/slurm_assertions"))
.unwrap_or_default(),
cache_availability: json_str(value, &["cache", "availability"])
.map(|value| value.to_string()),
cache_versions,
}
}
fn parse_rtr_runtime_metrics(path: &Path, value: &Value) -> RtrRuntimeMetrics {
let generated_at = json_str(value, &["generated_at"]).map(|value| value.to_string());
let generated_at_seconds = generated_at.as_deref().and_then(parse_rfc3339_to_unix);
RtrRuntimeMetrics {
file_path: path.display().to_string(),
generated_at,
generated_at_seconds,
phase: json_str(value, &["phase"]).map(|value| value.to_string()),
process_rss_bytes: json_u64(value, &["process", "rss_mib"])
.map(|value| value.saturating_mul(1024 * 1024)),
service: parse_rtr_service_metrics(value),
}
}
fn parse_rtr_clients_metrics(path: &Path, value: &Value) -> RtrClientsMetrics {
let generated_at = json_str(value, &["generated_at"]).map(|value| value.to_string());
let generated_at_seconds = generated_at.as_deref().and_then(parse_rfc3339_to_unix);
RtrClientsMetrics {
file_path: path.display().to_string(),
generated_at,
generated_at_seconds,
phase: json_str(value, &["phase"]).map(|value| value.to_string()),
service: parse_rtr_service_metrics(value),
}
}
fn parse_rtr_service_metrics(value: &Value) -> Option<RtrServiceMetrics> {
let service = value.get("service")?;
Some(RtrServiceMetrics {
started_at: json_str(service, &["started_at"]).map(|value| value.to_string()),
uptime_seconds: json_u64(service, &["uptime_seconds"]),
active_connections: json_u64(service, &["active_connections"]),
connections_by_transport: json_counts_map(service.get("connections_by_transport"))
.unwrap_or_default(),
session_listeners: json_u64(service, &["session_listeners"]),
max_connections: json_u64(service, &["max_connections"]),
connection_utilization: json_f64(service, &["connection_utilization"]),
})
}
fn json_counts_map(value: Option<&Value>) -> Option<BTreeMap<String, u64>> {
let mut out = BTreeMap::new();
let object = value?.as_object()?;
for (key, value) in object {
if let Some(count) = value.as_u64() {
out.insert(key.clone(), count);
}
}
Some(out)
}
fn collect_run_records( fn collect_run_records(
runs_root: &Path, runs_root: &Path,
errors: &mut Vec<String>, errors: &mut Vec<String>,
@@ -1229,9 +1634,298 @@ fn render_metrics(snapshot: &MetricsSnapshot) -> String {
if let Some(ccr) = snapshot.ccr.as_ref() { if let Some(ccr) = snapshot.ccr.as_ref() {
render_ccr_metrics(&mut writer, instance, ccr); render_ccr_metrics(&mut writer, instance, ccr);
} }
render_rtr_metrics(&mut writer, instance, snapshot);
out out
} }
fn render_rtr_metrics(writer: &mut PromWriter<'_>, instance: &str, snapshot: &MetricsSnapshot) {
let rtr = &snapshot.rtr;
let scan_now = snapshot.service.last_scan_timestamp_seconds;
writer.gauge(
"ours_rp_rtr_metrics_enabled",
"Whether RTR report collection is enabled by environment variable",
&[label("instance", instance)],
bool_value(rtr.enabled),
);
writer.counter(
"ours_rp_rtr_metrics_parse_errors_total",
"Total RTR report parse errors observed by the artifact metrics service",
&[label("instance", instance)],
rtr.parse_errors_total as f64,
);
writer.gauge(
"ours_rp_rtr_metrics_parse_errors",
"Current RTR report parse error count in the latest scan",
&[label("instance", instance)],
rtr.parse_errors.len() as f64,
);
for kind in ["source", "runtime", "clients"] {
let scan = rtr.files.get(kind).cloned().unwrap_or_default();
writer.gauge(
"ours_rp_rtr_report_files_seen",
"RTR report files seen by kind",
&[label("instance", instance), label("kind", kind)],
scan.seen as f64,
);
if let Some(timestamp) = scan.latest_timestamp_seconds {
writer.gauge(
"ours_rp_rtr_report_latest_timestamp_seconds",
"Latest RTR report generated timestamp by kind",
&[label("instance", instance), label("kind", kind)],
timestamp,
);
writer.gauge(
"ours_rp_rtr_report_age_seconds",
"Age of the latest RTR report by kind at last scan time",
&[label("instance", instance), label("kind", kind)],
(scan_now - timestamp).max(0.0),
);
}
}
if let Some(source) = rtr.source.as_ref() {
if let Some(status) = source.refresh_status.as_deref() {
writer.gauge(
"ours_rp_rtr_source_refresh_status",
"Current RTR source refresh status",
&[label("instance", instance), label("status", status)],
1.0,
);
}
if let Some(value) = source.refresh_changed {
writer.gauge(
"ours_rp_rtr_source_refresh_changed",
"Whether the latest RTR source refresh changed data",
&[label("instance", instance)],
bool_value(value),
);
}
if let Some(value) = source.refresh_duration_seconds {
writer.gauge(
"ours_rp_rtr_source_refresh_duration_seconds",
"Latest RTR source refresh duration",
&[label("instance", instance)],
value,
);
}
if let Some(value) = source.refresh_consecutive_failures {
writer.gauge(
"ours_rp_rtr_source_refresh_consecutive_failures",
"Consecutive RTR source refresh failures",
&[label("instance", instance)],
value as f64,
);
}
if let Some(value) = source.refresh_last_success_seconds {
writer.gauge(
"ours_rp_rtr_source_last_success_age_seconds",
"Age of the last successful RTR source refresh at last scan time",
&[label("instance", instance)],
(scan_now - value).max(0.0),
);
}
if let Some(value) = source.generated_at_seconds {
writer.gauge(
"ours_rp_rtr_source_report_age_seconds",
"Age of the latest RTR source report at last scan time",
&[label("instance", instance)],
(scan_now - value).max(0.0),
);
}
if let Some(value) = source.ccr_file_size_bytes {
writer.gauge(
"ours_rp_rtr_source_ccr_file_size_bytes",
"RTR source CCR input file size",
&[label("instance", instance)],
value as f64,
);
}
for (stage, counts) in &source.data_quality {
for (kind, count) in counts {
writer.gauge(
"ours_rp_rtr_data_quality_items",
"RTR data quality item counts by stage and type",
&[
label("instance", instance),
label("stage", stage),
label("type", kind),
],
*count as f64,
);
}
}
for (kind, count) in &source.slurm_filters {
writer.gauge(
"ours_rp_rtr_slurm_filters",
"RTR SLURM filtered item counts by type",
&[label("instance", instance), label("type", kind)],
*count as f64,
);
}
for (kind, count) in &source.slurm_assertions {
writer.gauge(
"ours_rp_rtr_slurm_assertions",
"RTR SLURM asserted item counts by type",
&[label("instance", instance), label("type", kind)],
*count as f64,
);
}
if let Some(availability) = source.cache_availability.as_deref() {
writer.gauge(
"ours_rp_rtr_cache_ready",
"Whether RTR cache availability is ready",
&[label("instance", instance)],
bool_value(availability == "ready"),
);
}
for version in &source.cache_versions {
let version_label = version.version.to_string();
if let Some(serial) = version.serial {
writer.gauge(
"ours_rp_rtr_cache_version_serial",
"RTR cache version serial by version",
&[
label("instance", instance),
label("version", &version_label),
],
serial as f64,
);
}
if let Some(length) = version.delta_window_length {
writer.gauge(
"ours_rp_rtr_cache_delta_window_length",
"RTR cache delta window length by version",
&[
label("instance", instance),
label("version", &version_label),
],
length as f64,
);
}
for kind in RTR_COUNT_TYPES {
writer.gauge(
"ours_rp_rtr_cache_snapshot_items",
"RTR cache snapshot item counts by version and type",
&[
label("instance", instance),
label("version", &version_label),
label("type", kind),
],
version.snapshot.get(*kind).copied().unwrap_or(0) as f64,
);
}
for (direction, counts) in [
("announced", &version.latest_delta_announced),
("withdrawn", &version.latest_delta_withdrawn),
] {
for kind in RTR_COUNT_TYPES {
writer.gauge(
"ours_rp_rtr_cache_delta_items",
"RTR cache latest delta item counts by version, direction and type",
&[
label("instance", instance),
label("version", &version_label),
label("direction", direction),
label("type", kind),
],
counts.get(*kind).copied().unwrap_or(0) as f64,
);
}
}
}
}
if let Some(runtime) = rtr.runtime.as_ref() {
if let Some(value) = runtime.process_rss_bytes {
writer.gauge(
"ours_rp_rtr_process_rss_bytes",
"RTR process resident set size",
&[label("instance", instance)],
value as f64,
);
}
}
if let Some(runtime_age) = rtr
.runtime
.as_ref()
.and_then(|runtime| runtime.generated_at_seconds)
.map(|timestamp| (scan_now - timestamp).max(0.0))
{
writer.gauge(
"ours_rp_rtr_runtime_report_age_seconds",
"Age of the latest RTR runtime report at last scan time",
&[label("instance", instance)],
runtime_age,
);
}
if let Some(clients_age) = rtr
.clients
.as_ref()
.and_then(|clients| clients.generated_at_seconds)
.map(|timestamp| (scan_now - timestamp).max(0.0))
{
writer.gauge(
"ours_rp_rtr_clients_report_age_seconds",
"Age of the latest RTR clients report at last scan time",
&[label("instance", instance)],
clients_age,
);
}
if let Some(service) = rtr.preferred_service() {
if let Some(value) = service.uptime_seconds {
writer.gauge(
"ours_rp_rtr_service_uptime_seconds",
"RTR service uptime",
&[label("instance", instance)],
value as f64,
);
}
if let Some(value) = service.active_connections {
writer.gauge(
"ours_rp_rtr_active_connections",
"RTR active connections",
&[label("instance", instance)],
value as f64,
);
}
for (transport, count) in &service.connections_by_transport {
writer.gauge(
"ours_rp_rtr_connections",
"RTR connections by transport",
&[label("instance", instance), label("transport", transport)],
*count as f64,
);
}
if let Some(value) = service.session_listeners {
writer.gauge(
"ours_rp_rtr_session_listeners",
"RTR session listeners",
&[label("instance", instance)],
value as f64,
);
}
if let Some(value) = service.max_connections {
writer.gauge(
"ours_rp_rtr_max_connections",
"RTR maximum configured connections",
&[label("instance", instance)],
value as f64,
);
}
if let Some(value) = service.connection_utilization {
writer.gauge(
"ours_rp_rtr_connection_utilization",
"RTR connection utilization ratio",
&[label("instance", instance)],
value,
);
}
}
}
fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &LatestRunMetrics) { fn render_latest_metrics(writer: &mut PromWriter<'_>, instance: &str, latest: &LatestRunMetrics) {
writer.gauge( writer.gauge(
"ours_rp_run_sequence", "ours_rp_run_sequence",
@@ -1831,6 +2525,7 @@ fn render_status_json(snapshot: &MetricsSnapshot) -> Result<String, String> {
"latestRun": snapshot.latest_run, "latestRun": snapshot.latest_run,
"cir": snapshot.cir, "cir": snapshot.cir,
"ccr": snapshot.ccr, "ccr": snapshot.ccr,
"rtr": snapshot.rtr,
"topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration, "topRepositoriesBySyncDuration": snapshot.top_repos_by_sync_duration,
"topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count, "topPublicationPointsByObjectCount": snapshot.top_pp_by_object_count,
"topPublicationPointsBySyncDuration": snapshot.top_pp_by_sync_duration, "topPublicationPointsBySyncDuration": snapshot.top_pp_by_sync_duration,
@@ -2144,6 +2839,14 @@ fn json_f64(value: &Value, path: &[&str]) -> Option<f64> {
.or_else(|| current.as_u64().map(|v| v as f64)) .or_else(|| current.as_u64().map(|v| v as f64))
} }
fn json_bool(value: &Value, path: &[&str]) -> Option<bool> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
current.as_bool()
}
fn parse_rfc3339_to_unix(value: &str) -> Option<f64> { fn parse_rfc3339_to_unix(value: &str) -> Option<f64> {
time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
.ok() .ok()
@@ -2371,6 +3074,179 @@ mod tests {
assert!(snapshot.latest_run.is_none()); assert!(snapshot.latest_run.is_none());
} }
#[test]
fn scan_fixture_exports_rtr_metrics_when_env_enabled() {
let td = TempDir::new().expect("tempdir");
let runs = td.path().join("runs");
fs::create_dir_all(&runs).expect("create runs");
let rtr = td.path().join("rtr");
fs::create_dir_all(&rtr).expect("create rtr");
fs::write(
rtr.join("rtr-source-20260625142349411798736.json"),
r#"{
"schema_version": 2,
"generated_at": "2026-06-25T14:23:49.411798736+08:00",
"phase": "refresh_skipped_unchanged",
"source": { "ccr_file_size_bytes": 21763268 },
"refresh": {
"last_success_at": "2026-06-25T14:23:49.411053688+08:00",
"status": "success",
"changed": false,
"duration_ms": 33,
"consecutive_failures": 0
},
"data_quality": {
"ccr_input": { "total": 833492, "vrp": 832054, "router_key": 0, "aspa": 1438 },
"invalid": { "total": 0, "vrp": 0, "router_key": 0, "aspa": 0 },
"before_slurm": { "total": 833492, "vrp": 832054, "router_key": 0, "aspa": 1438 },
"after_slurm": { "total": 828563, "vrp": 827126, "router_key": 0, "aspa": 1437 },
"slurm_filters": { "prefix": 1, "router_key": 0, "aspa": 1 },
"slurm_assertions": { "prefix": 0, "router_key": 0, "aspa": 0 }
},
"cache": {
"availability": "ready",
"versions": [
{
"version": 2,
"serial": 9,
"snapshot": { "total": 828563, "vrp": 827126, "router_key": 0, "aspa": 1437 },
"latest_delta": {
"announced": { "total": 1, "vrp": 1, "router_key": 0, "aspa": 0 },
"withdrawn": { "total": 1, "vrp": 1, "router_key": 0, "aspa": 0 }
},
"delta_window": { "length": 4 }
}
]
}
}"#,
)
.expect("source");
fs::write(
rtr.join("rtr-runtime-20260625142610957948453.json"),
r#"{
"schema_version": 2,
"generated_at": "2026-06-25T14:26:10.957948453+08:00",
"phase": "runtime_periodic",
"service": {
"started_at": "2026-06-24T11:51:08.599427589+08:00",
"uptime_seconds": 95714,
"active_connections": 0,
"connections_by_transport": { "tcp": 0, "tls": 0, "ssh": 0 },
"session_listeners": 0,
"max_connections": 512,
"connection_utilization": 0.0
},
"process": { "rss_mib": 1285 }
}"#,
)
.expect("runtime");
fs::write(
rtr.join("rtr-clients-20260624115123282266076.json"),
r#"{
"schema_version": 2,
"generated_at": "2026-06-24T11:51:23.282266076+08:00",
"phase": "startup",
"service": {
"started_at": "2026-06-24T11:51:08.599427589+08:00",
"uptime_seconds": 14,
"active_connections": 0,
"connections_by_transport": { "tcp": 0, "tls": 0, "ssh": 0 },
"session_listeners": 0,
"max_connections": 512,
"connection_utilization": 0.0
}
}"#,
)
.expect("clients");
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
.expect("scan");
assert!(snapshot.rtr.enabled);
assert_eq!(snapshot.rtr.files["source"].seen, 1);
assert_eq!(
snapshot
.rtr
.source
.as_ref()
.unwrap()
.refresh_status
.as_deref(),
Some("success")
);
assert_eq!(
snapshot.rtr.source.as_ref().unwrap().data_quality["after_slurm"]["aspa"],
1437
);
assert_eq!(
snapshot.rtr.runtime.as_ref().unwrap().process_rss_bytes,
Some(1285 * 1024 * 1024)
);
assert_eq!(
snapshot
.rtr
.preferred_service()
.and_then(|service| service.max_connections),
Some(512)
);
let metrics = render_metrics(&snapshot);
assert!(metrics.contains("ours_rp_rtr_metrics_enabled"));
assert!(metrics.contains(r#"ours_rp_rtr_cache_ready{instance="test"} 1"#));
assert!(metrics.contains(
r#"ours_rp_rtr_data_quality_items{instance="test",stage="after_slurm",type="aspa"} 1437"#
));
assert!(metrics.contains(
r#"ours_rp_rtr_cache_snapshot_items{instance="test",version="2",type="vrp"} 827126"#
));
assert!(metrics.contains(
r#"ours_rp_rtr_cache_delta_items{instance="test",version="2",direction="withdrawn",type="total"} 1"#
));
assert!(metrics.contains(r#"ours_rp_rtr_process_rss_bytes{instance="test"} 1347420160"#));
assert!(metrics.contains(r#"ours_rp_rtr_max_connections{instance="test"} 512"#));
assert!(metrics.contains(r#"ours_rp_rtr_connections{instance="test",transport="ssh"} 0"#));
let status = render_status_json(&snapshot).expect("status");
assert!(status.contains(r#""enabled": true"#));
assert!(status.contains(r#""processRssBytes": 1347420160"#));
}
#[test]
fn rtr_bad_json_and_missing_fields_do_not_crash() {
let td = TempDir::new().expect("tempdir");
let runs = td.path().join("runs");
fs::create_dir_all(&runs).expect("create runs");
let rtr = td.path().join("rtr");
fs::create_dir_all(&rtr).expect("create rtr");
fs::write(rtr.join("rtr-source-20260625000000000000000.json"), "{bad").expect("bad");
fs::write(
rtr.join("rtr-runtime-20260625000000000000000.json"),
r#"{"generated_at":"2026-06-25T14:26:10+08:00","process":{}}"#,
)
.expect("runtime");
fs::write(
rtr.join("rtr-clients-20260625000000000000000.json"),
r#"{"generated_at":"2026-06-25T14:26:10+08:00"}"#,
)
.expect("clients");
let snapshot = scan_run_root_incremental_with_rtr_dir(td.path(), "test", None, Some(&rtr))
.expect("scan");
assert!(snapshot.rtr.enabled);
assert_eq!(snapshot.rtr.files["source"].seen, 1);
assert!(snapshot.rtr.source.is_none());
assert!(snapshot.rtr.runtime.is_some());
assert!(snapshot.rtr.clients.is_some());
assert!(!snapshot.rtr.parse_errors.is_empty());
let metrics = render_metrics(&snapshot);
assert!(metrics.contains("ours_rp_rtr_metrics_parse_errors_total"));
assert!(metrics.contains(r#"ours_rp_rtr_metrics_enabled{instance="test"} 1"#));
}
fn sample_cir() -> Vec<u8> { fn sample_cir() -> Vec<u8> {
let rejected = vec![CirRejectedObject { let rejected = vec![CirRejectedObject {
object_uri: "rsync://repo.example/a/bad.roa".to_string(), object_uri: "rsync://repo.example/a/bad.roa".to_string(),

View File

@@ -3,8 +3,10 @@ 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 x509_parser::prelude::{FromDer, X509Certificate}; use x509_parser::prelude::{FromDer, X509Certificate};
use crate::validation::x509_name::x509_names_equivalent; use crate::validation::x509_name::x509_names_equivalent;
@@ -18,6 +20,7 @@ pub struct ValidatedSubordinateCa {
pub issuer_crl: RpkixCrl, pub issuer_crl: RpkixCrl,
pub effective_ip_resources: Option<IpResourceSet>, pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>, pub effective_as_resources: Option<AsResourceSet>,
pub resource_warnings: ResourceValidationWarnings,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -25,6 +28,41 @@ pub struct ValidatedSubordinateCaLite {
pub child_ca: ResourceCertificate, pub child_ca: ResourceCertificate,
pub effective_ip_resources: Option<IpResourceSet>, pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>, pub effective_as_resources: Option<AsResourceSet>,
pub resource_warnings: ResourceValidationWarnings,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ResourceValidationWarnings {
pub ip_reduced_by_vrs: bool,
pub as_reduced_by_vrs: bool,
pub ip_vrs_empty: bool,
pub as_vrs_empty: bool,
}
impl ResourceValidationWarnings {
pub fn is_empty(&self) -> bool {
!self.ip_reduced_by_vrs
&& !self.as_reduced_by_vrs
&& !self.ip_vrs_empty
&& !self.as_vrs_empty
}
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if self.ip_reduced_by_vrs {
parts.push("ip_reduced_by_vrs");
}
if self.as_reduced_by_vrs {
parts.push("as_reduced_by_vrs");
}
if self.ip_vrs_empty {
parts.push("ip_vrs_empty");
}
if self.as_vrs_empty {
parts.push("as_vrs_empty");
}
parts.join(",")
}
} }
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -71,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),
@@ -178,17 +222,47 @@ pub fn validate_subordinate_ca_cert(
issuer_effective_ip: Option<&IpResourceSet>, issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>, issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime, validation_time: time::OffsetDateTime,
) -> Result<ValidatedSubordinateCa, CaPathError> {
validate_subordinate_ca_cert_with_resource_validation_mode(
child_ca_der,
issuer_ca_der,
issuer_crl_der,
issuer_ca_rsync_uri,
issuer_crl_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
ResourceValidationMode::Rfc6487,
)
}
pub fn validate_subordinate_ca_cert_with_resource_validation_mode(
child_ca_der: &[u8],
issuer_ca_der: &[u8],
issuer_crl_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: &str,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
resource_validation_mode: ResourceValidationMode,
) -> Result<ValidatedSubordinateCa, CaPathError> { ) -> Result<ValidatedSubordinateCa, CaPathError> {
let child_ca = ResourceCertificate::decode_der(child_ca_der)?; let child_ca = ResourceCertificate::decode_der(child_ca_der)?;
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) {
@@ -230,13 +304,20 @@ pub fn validate_subordinate_ca_cert(
return Err(CaPathError::ChildRevoked); return Err(CaPathError::ChildRevoked);
} }
let effective_ip_resources = resolve_child_ip_resources( let ResourceResolution {
effective_ip_resources,
effective_as_resources,
warnings: resource_warnings,
} = resolve_child_resources(
child_ca.tbs.extensions.ip_resources.as_ref(), child_ca.tbs.extensions.ip_resources.as_ref(),
issuer_effective_ip, issuer_effective_ip,
)?;
let effective_as_resources = resolve_child_as_resources(
child_ca.tbs.extensions.as_resources.as_ref(), child_ca.tbs.extensions.as_resources.as_ref(),
issuer_effective_as, issuer_effective_as,
&IssuerEffectiveResourcesIndex::from_effective_resources(
issuer_effective_ip,
issuer_effective_as,
)?,
resource_validation_mode,
)?; )?;
if effective_ip_resources.is_none() && effective_as_resources.is_none() { if effective_ip_resources.is_none() && effective_as_resources.is_none() {
return Err(CaPathError::ResourcesMissing); return Err(CaPathError::ResourcesMissing);
@@ -248,6 +329,7 @@ pub fn validate_subordinate_ca_cert(
issuer_crl, issuer_crl,
effective_ip_resources, effective_ip_resources,
effective_as_resources, effective_as_resources,
resource_warnings,
}) })
} }
@@ -285,6 +367,7 @@ pub fn validate_subordinate_ca_cert_with_prevalidated_issuer(
issuer_effective_as, issuer_effective_as,
&issuer_resources_index, &issuer_resources_index,
validation_time, validation_time,
ResourceValidationMode::Rfc6487,
) )
} }
@@ -301,6 +384,7 @@ pub fn validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources(
issuer_effective_as: Option<&AsResourceSet>, issuer_effective_as: Option<&AsResourceSet>,
issuer_resources_index: &IssuerEffectiveResourcesIndex, issuer_resources_index: &IssuerEffectiveResourcesIndex,
validation_time: time::OffsetDateTime, validation_time: time::OffsetDateTime,
resource_validation_mode: ResourceValidationMode,
) -> Result<ValidatedSubordinateCaLite, CaPathError> { ) -> Result<ValidatedSubordinateCaLite, CaPathError> {
if child_ca.kind != ResourceCertKind::Ca { if child_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::ChildNotCa); return Err(CaPathError::ChildNotCa);
@@ -308,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 {
@@ -347,17 +437,17 @@ pub fn validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources(
return Err(CaPathError::ChildRevoked); return Err(CaPathError::ChildRevoked);
} }
let effective_ip_resources = resolve_child_ip_resources_indexed( let ResourceResolution {
effective_ip_resources,
effective_as_resources,
warnings: resource_warnings,
} = resolve_child_resources(
child_ca.tbs.extensions.ip_resources.as_ref(), child_ca.tbs.extensions.ip_resources.as_ref(),
issuer_effective_ip, issuer_effective_ip,
issuer_resources_index.parent_ip_by_afi_items.as_ref(),
&issuer_resources_index.parent_ip_merged_intervals,
)?;
let effective_as_resources = resolve_child_as_resources_indexed(
child_ca.tbs.extensions.as_resources.as_ref(), child_ca.tbs.extensions.as_resources.as_ref(),
issuer_effective_as, issuer_effective_as,
issuer_resources_index.parent_asnum_intervals.as_deref(), issuer_resources_index,
issuer_resources_index.parent_rdi_intervals.as_deref(), resource_validation_mode,
)?; )?;
if effective_ip_resources.is_none() && effective_as_resources.is_none() { if effective_ip_resources.is_none() && effective_as_resources.is_none() {
return Err(CaPathError::ResourcesMissing); return Err(CaPathError::ResourcesMissing);
@@ -367,6 +457,7 @@ pub fn validate_subordinate_ca_cert_with_prevalidated_issuer_and_resources(
child_ca, child_ca,
effective_ip_resources, effective_ip_resources,
effective_as_resources, effective_as_resources,
resource_warnings,
}) })
} }
@@ -506,6 +597,66 @@ fn is_serial_revoked_by_crl(cert: &ResourceCertificate, crl: &RpkixCrl) -> bool
.any(|rc| rc.serial_number == serial) .any(|rc| rc.serial_number == serial)
} }
#[derive(Clone, Debug)]
struct ResourceResolution {
effective_ip_resources: Option<IpResourceSet>,
effective_as_resources: Option<AsResourceSet>,
warnings: ResourceValidationWarnings,
}
fn resolve_child_resources(
child_ip: Option<&IpResourceSet>,
issuer_effective_ip: Option<&IpResourceSet>,
child_as: Option<&AsResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
issuer_resources_index: &IssuerEffectiveResourcesIndex,
mode: ResourceValidationMode,
) -> Result<ResourceResolution, CaPathError> {
match mode {
ResourceValidationMode::Rfc6487 => Ok(ResourceResolution {
effective_ip_resources: resolve_child_ip_resources_indexed(
child_ip,
issuer_effective_ip,
issuer_resources_index.parent_ip_by_afi_items.as_ref(),
&issuer_resources_index.parent_ip_merged_intervals,
)?,
effective_as_resources: resolve_child_as_resources_indexed(
child_as,
issuer_effective_as,
issuer_resources_index.parent_asnum_intervals.as_deref(),
issuer_resources_index.parent_rdi_intervals.as_deref(),
)?,
warnings: ResourceValidationWarnings::default(),
}),
ResourceValidationMode::ValidationUpdate03 => {
let (effective_ip_resources, ip_reduced_by_vrs, ip_vrs_empty) =
resolve_child_ip_resources_vrs(
child_ip,
issuer_effective_ip,
issuer_resources_index.parent_ip_by_afi_items.as_ref(),
&issuer_resources_index.parent_ip_merged_intervals,
)?;
let (effective_as_resources, as_reduced_by_vrs, as_vrs_empty) =
resolve_child_as_resources_vrs(
child_as,
issuer_effective_as,
issuer_resources_index.parent_asnum_intervals.as_deref(),
issuer_resources_index.parent_rdi_intervals.as_deref(),
)?;
Ok(ResourceResolution {
effective_ip_resources,
effective_as_resources,
warnings: ResourceValidationWarnings {
ip_reduced_by_vrs,
as_reduced_by_vrs,
ip_vrs_empty,
as_vrs_empty,
},
})
}
}
}
fn resolve_child_ip_resources( fn resolve_child_ip_resources(
child_ip: Option<&IpResourceSet>, child_ip: Option<&IpResourceSet>,
issuer_effective: Option<&IpResourceSet>, issuer_effective: Option<&IpResourceSet>,
@@ -581,6 +732,77 @@ fn resolve_child_ip_resources_indexed(
})) }))
} }
fn resolve_child_ip_resources_vrs(
child_ip: Option<&IpResourceSet>,
issuer_effective: Option<&IpResourceSet>,
parent_by_afi: Option<
&BTreeMap<crate::data_model::rc::Afi, Vec<crate::data_model::rc::IpAddressOrRange>>,
>,
parent_intervals_by_afi: &HashMap<crate::data_model::rc::Afi, Vec<(Vec<u8>, Vec<u8>)>>,
) -> Result<(Option<IpResourceSet>, bool, bool), CaPathError> {
let Some(child_ip) = child_ip else {
return Ok((None, false, false));
};
if child_ip.has_any_inherit() && issuer_effective.is_none() {
return Err(CaPathError::InheritWithoutParentResources);
}
let parent_by_afi = parent_by_afi.unwrap_or_else(|| {
static EMPTY: std::sync::OnceLock<
BTreeMap<crate::data_model::rc::Afi, Vec<crate::data_model::rc::IpAddressOrRange>>,
> = std::sync::OnceLock::new();
EMPTY.get_or_init(BTreeMap::new)
});
let mut out_families = Vec::new();
let mut reduced = false;
let mut saw_declared = false;
for fam in &child_ip.families {
match &fam.choice {
IpAddressChoice::Inherit => {
let items = parent_by_afi
.get(&fam.afi)
.ok_or(CaPathError::InheritWithoutParentResources)?;
out_families.push(crate::data_model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(items.clone()),
});
}
IpAddressChoice::AddressesOrRanges(items) => {
saw_declared = saw_declared || !items.is_empty();
let parent_intervals = parent_intervals_by_afi
.get(&fam.afi)
.map(Vec::as_slice)
.unwrap_or(&[]);
let intersections =
intersect_ip_items_with_parent_intervals(items, parent_intervals);
let child_intervals = ip_items_to_merged_intervals(items);
if intervals_changed(&child_intervals, &intersections) {
reduced = true;
}
if !intersections.is_empty() {
out_families.push(crate::data_model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(ip_intervals_to_ranges(
fam.afi,
&intersections,
)),
});
}
}
}
}
let empty = saw_declared && out_families.is_empty();
Ok((
Some(IpResourceSet {
families: out_families,
}),
reduced,
empty,
))
}
fn resolve_child_as_resources( fn resolve_child_as_resources(
child_as: Option<&AsResourceSet>, child_as: Option<&AsResourceSet>,
issuer_effective: Option<&AsResourceSet>, issuer_effective: Option<&AsResourceSet>,
@@ -656,6 +878,80 @@ fn resolve_child_as_resources_indexed(
Ok(Some(AsResourceSet { asnum, rdi })) Ok(Some(AsResourceSet { asnum, rdi }))
} }
fn resolve_child_as_resources_vrs(
child_as: Option<&AsResourceSet>,
issuer_effective: Option<&AsResourceSet>,
parent_asnum_intervals: Option<&[(u32, u32)]>,
parent_rdi_intervals: Option<&[(u32, u32)]>,
) -> Result<(Option<AsResourceSet>, bool, bool), CaPathError> {
let Some(child_as) = child_as else {
return Ok((None, false, false));
};
if issuer_effective.is_none()
&& (matches!(child_as.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(child_as.rdi, Some(AsIdentifierChoice::Inherit)))
{
return Err(CaPathError::InheritWithoutParentResources);
}
let (asnum, asnum_reduced, asnum_empty) = resolve_as_choice_vrs(
child_as.asnum.as_ref(),
issuer_effective.and_then(|p| p.asnum.as_ref()),
parent_asnum_intervals,
)?;
let (rdi, rdi_reduced, rdi_empty) = resolve_as_choice_vrs(
child_as.rdi.as_ref(),
issuer_effective.and_then(|p| p.rdi.as_ref()),
parent_rdi_intervals,
)?;
Ok((
Some(AsResourceSet { asnum, rdi }),
asnum_reduced || rdi_reduced,
asnum_empty || rdi_empty,
))
}
fn resolve_as_choice_vrs(
child: Option<&AsIdentifierChoice>,
parent: Option<&AsIdentifierChoice>,
parent_intervals_hint: Option<&[(u32, u32)]>,
) -> Result<(Option<AsIdentifierChoice>, bool, bool), CaPathError> {
let Some(child) = child else {
return Ok((None, false, false));
};
match child {
AsIdentifierChoice::Inherit => {
let parent = parent
.cloned()
.ok_or(CaPathError::InheritWithoutParentResources)?;
Ok((Some(parent), false, false))
}
AsIdentifierChoice::AsIdsOrRanges(_) => {
let child_intervals = as_choice_to_merged_intervals(child);
let parent_intervals;
let parent_intervals = match parent_intervals_hint {
Some(v) => v,
None => {
parent_intervals = parent
.map(as_choice_to_merged_intervals)
.unwrap_or_default();
parent_intervals.as_slice()
}
};
let intersections = intersect_as_intervals(&child_intervals, parent_intervals);
let reduced = child_intervals != intersections;
let empty = !child_intervals.is_empty() && intersections.is_empty();
Ok((
Some(AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(
&intersections,
))),
reduced,
empty,
))
}
}
}
fn as_choice_subset( fn as_choice_subset(
child: Option<&AsIdentifierChoice>, child: Option<&AsIdentifierChoice>,
parent: Option<&AsIdentifierChoice>, parent: Option<&AsIdentifierChoice>,
@@ -911,6 +1207,134 @@ fn ip_family_items_subset_with_parent_intervals(
true true
} }
fn ip_items_to_merged_intervals(
items: &[crate::data_model::rc::IpAddressOrRange],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut intervals = Vec::new();
for item in items {
match item {
crate::data_model::rc::IpAddressOrRange::Prefix(p) => {
intervals.push(prefix_to_range(p))
}
crate::data_model::rc::IpAddressOrRange::Range(r) => {
intervals.push((r.min.clone(), r.max.clone()))
}
}
}
intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals(&intervals)
}
fn intersect_ip_items_with_parent_intervals(
items: &[crate::data_model::rc::IpAddressOrRange],
parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let child_intervals = ip_items_to_merged_intervals(items);
intersect_ip_intervals(&child_intervals, parent_intervals)
}
fn intersect_ip_intervals(
child_intervals: &[(Vec<u8>, Vec<u8>)],
parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in child_intervals {
while parent_index < parent_intervals.len()
&& parent_intervals[parent_index].1.as_slice() < child_min.as_slice()
{
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent_intervals.len()
&& parent_intervals[scan].0.as_slice() <= child_max.as_slice()
{
let (parent_min, parent_max) = &parent_intervals[scan];
let min = if bytes_leq(child_min, parent_min) {
parent_min.clone()
} else {
child_min.clone()
};
let max = if bytes_leq(child_max, parent_max) {
child_max.clone()
} else {
parent_max.clone()
};
if bytes_leq(&min, &max) {
out.push((min, max));
}
scan += 1;
}
}
merge_ip_intervals(&out)
}
fn ip_intervals_to_ranges(
afi: crate::data_model::rc::Afi,
intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<crate::data_model::rc::IpAddressOrRange> {
intervals
.iter()
.map(|(min, max)| {
crate::data_model::rc::IpAddressOrRange::Range(crate::data_model::rc::IpAddressRange {
min: normalize_ip_bytes(afi, min),
max: normalize_ip_bytes(afi, max),
})
})
.collect()
}
fn normalize_ip_bytes(afi: crate::data_model::rc::Afi, bytes: &[u8]) -> Vec<u8> {
let target_len = afi.octets_len();
if bytes.len() == target_len {
return bytes.to_vec();
}
let mut out = vec![0u8; target_len];
let copy_len = bytes.len().min(target_len);
out[..copy_len].copy_from_slice(&bytes[..copy_len]);
out
}
fn intervals_changed<T: PartialEq>(left: &[T], right: &[T]) -> bool {
left != right
}
fn intersect_as_intervals(child: &[(u32, u32)], parent: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in child {
while parent_index < parent.len() && parent[parent_index].1 < *child_min {
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent.len() && parent[scan].0 <= *child_max {
let min = (*child_min).max(parent[scan].0);
let max = (*child_max).min(parent[scan].1);
if min <= max {
out.push((min, max));
}
scan += 1;
}
}
merge_as_intervals(&out)
}
fn as_intervals_to_items(intervals: &[(u32, u32)]) -> Vec<crate::data_model::rc::AsIdOrRange> {
intervals
.iter()
.map(|(min, max)| {
if min == max {
crate::data_model::rc::AsIdOrRange::Id(*min)
} else {
crate::data_model::rc::AsIdOrRange::Range {
min: *min,
max: *max,
}
}
})
.collect()
}
fn merge_ip_intervals(v: &[(Vec<u8>, Vec<u8>)]) -> Vec<(Vec<u8>, Vec<u8>)> { fn merge_ip_intervals(v: &[(Vec<u8>, Vec<u8>)]) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new(); let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for (min, max) in v { for (min, max) in v {
@@ -970,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, AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressFamily, IpAddressOrRange, Afi, AsIdOrRange, AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressFamily,
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;
@@ -1005,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,
}, },
@@ -1225,6 +1666,233 @@ mod tests {
assert!(matches!(err, CaPathError::InheritWithoutParentResources)); assert!(matches!(err, CaPathError::InheritWithoutParentResources));
} }
#[test]
fn validation_update_03_intersects_overclaiming_child_ip_resources() {
let issuer = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
},
)]),
}],
};
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv4,
prefix_len: 23,
addr: vec![10, 0, 0, 0],
},
)]),
}],
};
let index = IssuerEffectiveResourcesIndex::from_effective_resources(Some(&issuer), None)
.expect("index");
let resolved = resolve_child_resources(
Some(&child),
Some(&issuer),
None,
None,
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.ip_reduced_by_vrs);
assert!(!resolved.warnings.ip_vrs_empty);
let effective = resolved.effective_ip_resources.expect("effective ip");
assert!(effective.families[0].contains_prefix(&IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
}));
assert!(!effective.families[0].contains_prefix(&IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 1, 0],
}));
}
#[test]
fn validation_update_03_keeps_empty_vrs_instead_of_rejecting_child_ca() {
let issuer = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
},
)]),
}],
};
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![192, 0, 2, 0],
},
)]),
}],
};
let index = IssuerEffectiveResourcesIndex::from_effective_resources(Some(&issuer), None)
.expect("index");
let strict_err = resolve_child_resources(
Some(&child),
Some(&issuer),
None,
None,
&index,
ResourceValidationMode::Rfc6487,
)
.unwrap_err();
assert!(matches!(strict_err, CaPathError::ResourcesNotSubset));
let resolved = resolve_child_resources(
Some(&child),
Some(&issuer),
None,
None,
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.ip_reduced_by_vrs);
assert!(resolved.warnings.ip_vrs_empty);
assert!(
resolved
.effective_ip_resources
.expect("effective ip")
.families
.is_empty()
);
}
#[test]
fn validation_update_03_intersects_overclaiming_child_as_resources() {
let issuer = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64510,
},
])),
rdi: None,
};
let child = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64505,
max: 64520,
},
])),
rdi: None,
};
let index = IssuerEffectiveResourcesIndex::from_effective_resources(None, Some(&issuer))
.expect("index");
let resolved = resolve_child_resources(
None,
None,
Some(&child),
Some(&issuer),
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.as_reduced_by_vrs);
assert!(!resolved.warnings.as_vrs_empty);
let effective = resolved.effective_as_resources.expect("effective as");
assert_eq!(
effective.asnum,
Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64505,
max: 64510,
},
]))
);
}
#[test]
fn validation_update_03_records_all_resource_warning_summary_parts() {
let issuer_ip = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![10, 0, 0, 0],
},
)]),
}],
};
let child_ip = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
IpPrefix {
afi: Afi::Ipv4,
prefix_len: 24,
addr: vec![192, 0, 2, 0],
},
)]),
}],
};
let issuer_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64510,
},
])),
rdi: None,
};
let child_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64520,
max: 64530,
},
])),
rdi: None,
};
let index = IssuerEffectiveResourcesIndex::from_effective_resources(
Some(&issuer_ip),
Some(&issuer_as),
)
.expect("index");
let resolved = resolve_child_resources(
Some(&child_ip),
Some(&issuer_ip),
Some(&child_as),
Some(&issuer_as),
&index,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resolution");
assert!(resolved.warnings.ip_reduced_by_vrs);
assert!(resolved.warnings.ip_vrs_empty);
assert!(resolved.warnings.as_reduced_by_vrs);
assert!(resolved.warnings.as_vrs_empty);
assert_eq!(
resolved.warnings.summary(),
"ip_reduced_by_vrs,as_reduced_by_vrs,ip_vrs_empty,as_vrs_empty"
);
assert!(!resolved.warnings.is_empty());
}
#[test] #[test]
fn child_aki_mismatch_is_rejected() { fn child_aki_mismatch_is_rejected() {
let issuer = dummy_cert( let issuer = dummy_cert(

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

@@ -13,7 +13,7 @@ use crate::parallel::config::ParallelPhase2Config;
use crate::parallel::object_worker::{ use crate::parallel::object_worker::{
ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError, ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError,
}; };
use crate::policy::{Policy, SignedObjectFailurePolicy}; use crate::policy::{Policy, ResourceValidationMode, SignedObjectFailurePolicy};
use crate::report::{RfcRef, Warning}; use crate::report::{RfcRef, Warning};
use crate::storage::{ use crate::storage::{
PackFile, PackTime, RoaCacheObjectMeta, RoaCacheProjection, VcirLocalOutput, PackFile, PackTime, RoaCacheObjectMeta, RoaCacheProjection, VcirLocalOutput,
@@ -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,
@@ -1226,6 +1248,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
collect_vcir_local_outputs, collect_vcir_local_outputs,
policy.strict.cms_der, policy.strict.cms_der,
policy.strict.name, policy.strict.name,
policy.resource_validation_mode,
) )
} }
blocked @ (RoaCacheLookupResult::HashBlocked blocked @ (RoaCacheLookupResult::HashBlocked
@@ -1251,6 +1274,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
collect_vcir_local_outputs, collect_vcir_local_outputs,
policy.strict.cms_der, policy.strict.cms_der,
policy.strict.name, policy.strict.name,
policy.resource_validation_mode,
) )
} }
} }
@@ -1273,6 +1297,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
collect_vcir_local_outputs, collect_vcir_local_outputs,
policy.strict.cms_der, policy.strict.cms_der,
policy.strict.name, policy.strict.name,
policy.resource_validation_mode,
) )
}; };
match result.outcome { match result.outcome {
@@ -1386,6 +1411,7 @@ pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPoi
collect_vcir_local_outputs, collect_vcir_local_outputs,
policy.strict.cms_der, policy.strict.cms_der,
policy.strict.name, policy.strict.name,
policy.resource_validation_mode,
) { ) {
Ok((att, local_output)) => { Ok((att, local_output)) => {
stats.aspa_ok += 1; stats.aspa_ok += 1;
@@ -1735,6 +1761,7 @@ pub(crate) struct RoaTaskShared {
issuer_resources_index: Arc<IssuerResourcesIndex>, issuer_resources_index: Arc<IssuerResourcesIndex>,
issuer_effective_ip: Option<Arc<crate::data_model::rc::IpResourceSet>>, issuer_effective_ip: Option<Arc<crate::data_model::rc::IpResourceSet>>,
issuer_effective_as: Option<Arc<crate::data_model::rc::AsResourceSet>>, issuer_effective_as: Option<Arc<crate::data_model::rc::AsResourceSet>>,
resource_validation_mode: ResourceValidationMode,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -1746,6 +1773,7 @@ pub(crate) struct OwnedRoaTask {
collect_vcir_local_outputs: bool, collect_vcir_local_outputs: bool,
strict_cms_der: bool, strict_cms_der: bool,
strict_name: bool, strict_name: bool,
resource_validation_mode: ResourceValidationMode,
pub(crate) submitted_at: Option<Instant>, pub(crate) submitted_at: Option<Instant>,
} }
@@ -1854,6 +1882,7 @@ fn validate_owned_roa_task(worker_index: usize, task: OwnedRoaTask) -> RoaTaskRe
task.collect_vcir_local_outputs, task.collect_vcir_local_outputs,
task.strict_cms_der, task.strict_cms_der,
task.strict_name, task.strict_name,
task.resource_validation_mode,
) )
.map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk { .map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
vrps, vrps,
@@ -1884,6 +1913,7 @@ pub(crate) struct ParallelObjectsStage {
collect_vcir_local_outputs: bool, collect_vcir_local_outputs: bool,
strict_cms_der: bool, strict_cms_der: bool,
strict_name: bool, strict_name: bool,
resource_validation_mode: ResourceValidationMode,
roa_task_indices: Vec<usize>, roa_task_indices: Vec<usize>,
cached_roa_results: Vec<RoaTaskResult>, cached_roa_results: Vec<RoaTaskResult>,
roa_cache_stats: RoaValidationCacheStats, roa_cache_stats: RoaValidationCacheStats,
@@ -1921,6 +1951,7 @@ impl ParallelObjectsStage {
collect_vcir_local_outputs: self.collect_vcir_local_outputs, collect_vcir_local_outputs: self.collect_vcir_local_outputs,
strict_cms_der: self.strict_cms_der, strict_cms_der: self.strict_cms_der,
strict_name: self.strict_name, strict_name: self.strict_name,
resource_validation_mode: self.resource_validation_mode,
submitted_at: None, submitted_at: None,
}); });
}); });
@@ -2257,11 +2288,13 @@ pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: Publicati
)), )),
issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new), issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new),
issuer_effective_as: issuer_effective_as.cloned().map(Arc::new), issuer_effective_as: issuer_effective_as.cloned().map(Arc::new),
resource_validation_mode: policy.resource_validation_mode,
}), }),
validation_time, validation_time,
collect_vcir_local_outputs, collect_vcir_local_outputs,
strict_cms_der: policy.strict.cms_der, strict_cms_der: policy.strict.cms_der,
strict_name: policy.strict.name, strict_name: policy.strict.name,
resource_validation_mode: policy.resource_validation_mode,
roa_task_indices, roa_task_indices,
cached_roa_results, cached_roa_results,
roa_cache_stats, roa_cache_stats,
@@ -2370,6 +2403,7 @@ pub(crate) fn reduce_parallel_roa_stage(
collect_vcir_local_outputs, collect_vcir_local_outputs,
strict_cms_der, strict_cms_der,
strict_name, strict_name,
shared.resource_validation_mode,
) { ) {
Ok((att, local_output)) => { Ok((att, local_output)) => {
stats.aspa_ok += 1; stats.aspa_ok += 1;
@@ -2604,6 +2638,7 @@ pub(crate) fn validate_roa_task_serial(
collect_vcir_local_outputs: bool, collect_vcir_local_outputs: bool,
strict_cms_der: bool, strict_cms_der: bool,
strict_name: bool, strict_name: bool,
resource_validation_mode: ResourceValidationMode,
) -> RoaTaskResult { ) -> RoaTaskResult {
let outcome = process_roa_with_issuer( let outcome = process_roa_with_issuer(
task.file, task.file,
@@ -2621,6 +2656,7 @@ pub(crate) fn validate_roa_task_serial(
collect_vcir_local_outputs, collect_vcir_local_outputs,
strict_cms_der, strict_cms_der,
strict_name, strict_name,
resource_validation_mode,
) )
.map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk { .map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
vrps, vrps,
@@ -2655,6 +2691,7 @@ fn process_roa_with_issuer(
collect_vcir_local_outputs: bool, collect_vcir_local_outputs: bool,
strict_cms_der: bool, strict_cms_der: bool,
strict_name: bool, strict_name: bool,
resource_validation_mode: ResourceValidationMode,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> { ) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
let _decode = timing let _decode = timing
.as_ref() .as_ref()
@@ -2706,20 +2743,26 @@ fn process_roa_with_issuer(
let _subset = timing let _subset = timing
.as_ref() .as_ref()
.map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total")); .map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total"));
validate_ee_resources_subset( let ee_vrs = validate_ee_resources_for_mode(
&ee.resource_cert, &ee.resource_cert,
issuer_effective_ip, issuer_effective_ip,
issuer_effective_as, issuer_effective_as,
issuer_resources_index, issuer_resources_index,
resource_validation_mode,
)?; )?;
drop(_subset); drop(_subset);
let vrps = roa_to_vrps(&roa); let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
let cache_object_meta = RoaCacheObjectMeta { let cache_object_meta = RoaCacheObjectMeta {
source_object_uri: file.rsync_uri.clone(), source_object_uri: file.rsync_uri.clone(),
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)));
@@ -2777,6 +2820,7 @@ fn process_roa_with_issuer_parallel_cached(
collect_vcir_local_outputs: bool, collect_vcir_local_outputs: bool,
strict_cms_der: bool, strict_cms_der: bool,
strict_name: bool, strict_name: bool,
resource_validation_mode: ResourceValidationMode,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> { ) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
let _decode = timing let _decode = timing
.as_ref() .as_ref()
@@ -2834,20 +2878,26 @@ fn process_roa_with_issuer_parallel_cached(
let _subset = timing let _subset = timing
.as_ref() .as_ref()
.map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total")); .map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total"));
validate_ee_resources_subset( let ee_vrs = validate_ee_resources_for_mode(
&ee.resource_cert, &ee.resource_cert,
issuer_effective_ip, issuer_effective_ip,
issuer_effective_as, issuer_effective_as,
issuer_resources_index, issuer_resources_index,
resource_validation_mode,
)?; )?;
drop(_subset); drop(_subset);
let vrps = roa_to_vrps(&roa); let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
let cache_object_meta = RoaCacheObjectMeta { let cache_object_meta = RoaCacheObjectMeta {
source_object_uri: file.rsync_uri.clone(), source_object_uri: file.rsync_uri.clone(),
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)));
@@ -2905,6 +2955,7 @@ fn process_aspa_with_issuer(
collect_vcir_local_outputs: bool, collect_vcir_local_outputs: bool,
strict_cms_der: bool, strict_cms_der: bool,
strict_name: bool, strict_name: bool,
resource_validation_mode: ResourceValidationMode,
) -> Result<(AspaAttestation, Option<VcirLocalOutput>), ObjectValidateError> { ) -> Result<(AspaAttestation, Option<VcirLocalOutput>), ObjectValidateError> {
let _decode = timing let _decode = timing
.as_ref() .as_ref()
@@ -2956,14 +3007,17 @@ fn process_aspa_with_issuer(
let _subset = timing let _subset = timing
.as_ref() .as_ref()
.map(|t| t.span_phase("objects_aspa_validate_ee_resources_subset_total")); .map(|t| t.span_phase("objects_aspa_validate_ee_resources_subset_total"));
validate_ee_resources_subset( let ee_vrs = validate_ee_resources_for_mode(
&ee.resource_cert, &ee.resource_cert,
issuer_effective_ip, issuer_effective_ip,
issuer_effective_as, issuer_effective_as,
issuer_resources_index, issuer_resources_index,
resource_validation_mode,
)?; )?;
drop(_subset); drop(_subset);
validate_aspa_customer_in_vrs(&aspa, ee_vrs.asn.as_ref())?;
let attestation = AspaAttestation { let attestation = AspaAttestation {
customer_as_id: aspa.aspa.customer_as_id, customer_as_id: aspa.aspa.customer_as_id,
provider_as_ids: aspa.aspa.provider_as_ids.clone(), provider_as_ids: aspa.aspa.provider_as_ids.clone(),
@@ -3119,6 +3173,176 @@ fn validate_ee_resources_subset(
Ok(()) Ok(())
} }
#[derive(Debug)]
struct EeVerifiedResources {
ip: Option<crate::data_model::rc::IpResourceSet>,
asn: Option<crate::data_model::rc::AsResourceSet>,
}
fn validate_ee_resources_for_mode(
ee: &ResourceCertificate,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
mode: ResourceValidationMode,
) -> Result<EeVerifiedResources, ObjectValidateError> {
match mode {
ResourceValidationMode::Rfc6487 => {
validate_ee_resources_subset(
ee,
issuer_effective_ip,
issuer_effective_as,
issuer_resources_index,
)?;
Ok(EeVerifiedResources {
ip: ee.tbs.extensions.ip_resources.clone(),
asn: ee.tbs.extensions.as_resources.clone(),
})
}
ResourceValidationMode::ValidationUpdate03 => {
let ip = match ee.tbs.extensions.ip_resources.as_ref() {
Some(child_ip) => Some(intersect_ee_ip_resources_vrs(
child_ip,
issuer_effective_ip,
issuer_resources_index,
)?),
None => None,
};
let asn = match ee.tbs.extensions.as_resources.as_ref() {
Some(child_as) => Some(intersect_ee_as_resources_vrs(
child_as,
issuer_effective_as,
issuer_resources_index,
)?),
None => None,
};
Ok(EeVerifiedResources { ip, asn })
}
}
}
fn intersect_ee_ip_resources_vrs(
child_ip: &crate::data_model::rc::IpResourceSet,
issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
) -> Result<crate::data_model::rc::IpResourceSet, ObjectValidateError> {
if child_ip.has_any_inherit() {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
let _ = issuer_effective_ip;
let mut families = Vec::new();
for fam in &child_ip.families {
let parent_intervals = match fam.afi {
crate::data_model::rc::Afi::Ipv4 => issuer_resources_index.ip_v4.as_deref(),
crate::data_model::rc::Afi::Ipv6 => issuer_resources_index.ip_v6.as_deref(),
}
.unwrap_or(&[]);
let items = match &fam.choice {
IpAddressChoice::Inherit => return Err(ObjectValidateError::EeResourcesNotSubset),
IpAddressChoice::AddressesOrRanges(items) => items,
};
let intersections = intersect_ip_items_with_parent_intervals(items, parent_intervals);
if !intersections.is_empty() {
families.push(crate::data_model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(ip_intervals_to_ranges(
fam.afi,
&intersections,
)),
});
}
}
Ok(crate::data_model::rc::IpResourceSet { families })
}
fn intersect_ee_as_resources_vrs(
child_as: &crate::data_model::rc::AsResourceSet,
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
issuer_resources_index: &IssuerResourcesIndex,
) -> Result<crate::data_model::rc::AsResourceSet, ObjectValidateError> {
let _ = issuer_effective_as;
if matches!(child_as.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(child_as.rdi, Some(AsIdentifierChoice::Inherit))
{
return Err(ObjectValidateError::EeResourcesNotSubset);
}
let asnum = child_as.asnum.as_ref().map(|choice| {
let child_intervals = as_choice_to_merged_intervals(choice);
AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(&intersect_as_intervals(
&child_intervals,
issuer_resources_index.asnum.as_deref().unwrap_or(&[]),
)))
});
let rdi = child_as.rdi.as_ref().map(|choice| {
let child_intervals = as_choice_to_merged_intervals(choice);
AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(&intersect_as_intervals(
&child_intervals,
issuer_resources_index.rdi.as_deref().unwrap_or(&[]),
)))
});
Ok(crate::data_model::rc::AsResourceSet { asnum, rdi })
}
fn roa_to_vrps_with_vrs(
roa: &RoaObject,
ee_vrs_ip: Option<&crate::data_model::rc::IpResourceSet>,
) -> Result<Vec<Vrp>, ObjectValidateError> {
let vrps = roa_to_vrps(roa);
let Some(ee_vrs_ip) = ee_vrs_ip else {
return Err(ObjectValidateError::EeResourcesNotSubset);
};
for vrp in &vrps {
let rc_prefix = roa_prefix_to_rc_prefix(&vrp.prefix);
if !ee_vrs_ip.contains_prefix(&rc_prefix) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
}
Ok(vrps)
}
fn validate_aspa_customer_in_vrs(
aspa: &AspaObject,
ee_vrs_as: Option<&crate::data_model::rc::AsResourceSet>,
) -> Result<(), ObjectValidateError> {
let Some(ee_vrs_as) = ee_vrs_as else {
return Err(ObjectValidateError::EeResourcesNotSubset);
};
if !as_resource_set_contains_asn(ee_vrs_as, aspa.aspa.customer_as_id) {
return Err(ObjectValidateError::EeResourcesNotSubset);
}
Ok(())
}
fn as_resource_set_contains_asn(
resources: &crate::data_model::rc::AsResourceSet,
asn: u32,
) -> bool {
let Some(choice) = resources.asnum.as_ref() else {
return false;
};
match choice {
AsIdentifierChoice::Inherit => false,
AsIdentifierChoice::AsIdsOrRanges(items) => items.iter().any(|item| match item {
crate::data_model::rc::AsIdOrRange::Id(id) => *id == asn,
crate::data_model::rc::AsIdOrRange::Range { min, max } => *min <= asn && asn <= *max,
}),
}
}
fn roa_prefix_to_rc_prefix(prefix: &IpPrefix) -> RcIpPrefix {
let afi = match prefix.afi {
RoaAfi::Ipv4 => crate::data_model::rc::Afi::Ipv4,
RoaAfi::Ipv6 => crate::data_model::rc::Afi::Ipv6,
};
let mut addr = prefix.addr.to_vec();
addr.truncate(afi.octets_len());
RcIpPrefix {
afi,
prefix_len: prefix.prefix_len,
addr,
}
}
fn as_resources_is_subset(child: &AsResourceSet, parent: &AsResourceSet) -> bool { fn as_resources_is_subset(child: &AsResourceSet, parent: &AsResourceSet) -> bool {
as_choice_subset(child.asnum.as_ref(), parent.asnum.as_ref()) as_choice_subset(child.asnum.as_ref(), parent.asnum.as_ref())
&& as_choice_subset(child.rdi.as_ref(), parent.rdi.as_ref()) && as_choice_subset(child.rdi.as_ref(), parent.rdi.as_ref())
@@ -3292,6 +3516,121 @@ fn ip_resources_is_subset_indexed(
true true
} }
fn ip_items_to_merged_intervals(
items: &[crate::data_model::rc::IpAddressOrRange],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut intervals = Vec::new();
for item in items {
match item {
IpAddressOrRange::Prefix(p) => intervals.push(prefix_to_range(p)),
IpAddressOrRange::Range(r) => intervals.push((r.min.clone(), r.max.clone())),
}
}
intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
merge_ip_intervals_in_place(&mut intervals);
intervals
}
fn intersect_ip_items_with_parent_intervals(
items: &[crate::data_model::rc::IpAddressOrRange],
parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<(Vec<u8>, Vec<u8>)> {
let child_intervals = ip_items_to_merged_intervals(items);
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in &child_intervals {
while parent_index < parent_intervals.len()
&& parent_intervals[parent_index].1.as_slice() < child_min.as_slice()
{
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent_intervals.len()
&& parent_intervals[scan].0.as_slice() <= child_max.as_slice()
{
let (parent_min, parent_max) = &parent_intervals[scan];
let min = if bytes_leq(child_min, parent_min) {
parent_min.clone()
} else {
child_min.clone()
};
let max = if bytes_leq(child_max, parent_max) {
child_max.clone()
} else {
parent_max.clone()
};
if bytes_leq(&min, &max) {
out.push((min, max));
}
scan += 1;
}
}
merge_ip_intervals_in_place(&mut out);
out
}
fn ip_intervals_to_ranges(
afi: crate::data_model::rc::Afi,
intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<crate::data_model::rc::IpAddressOrRange> {
intervals
.iter()
.map(|(min, max)| {
IpAddressOrRange::Range(crate::data_model::rc::IpAddressRange {
min: normalize_ip_bytes(afi, min),
max: normalize_ip_bytes(afi, max),
})
})
.collect()
}
fn normalize_ip_bytes(afi: crate::data_model::rc::Afi, bytes: &[u8]) -> Vec<u8> {
let target_len = afi.octets_len();
if bytes.len() == target_len {
return bytes.to_vec();
}
let mut out = vec![0u8; target_len];
let copy_len = bytes.len().min(target_len);
out[..copy_len].copy_from_slice(&bytes[..copy_len]);
out
}
fn intersect_as_intervals(child: &[(u32, u32)], parent: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out = Vec::new();
let mut parent_index = 0usize;
for (child_min, child_max) in child {
while parent_index < parent.len() && parent[parent_index].1 < *child_min {
parent_index += 1;
}
let mut scan = parent_index;
while scan < parent.len() && parent[scan].0 <= *child_max {
let min = (*child_min).max(parent[scan].0);
let max = (*child_max).min(parent[scan].1);
if min <= max {
out.push((min, max));
}
scan += 1;
}
}
merge_as_intervals(&out)
}
fn as_intervals_to_items(intervals: &[(u32, u32)]) -> Vec<crate::data_model::rc::AsIdOrRange> {
intervals
.iter()
.map(|(min, max)| {
if min == max {
crate::data_model::rc::AsIdOrRange::Id(*min)
} else {
crate::data_model::rc::AsIdOrRange::Range {
min: *min,
max: *max,
}
}
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum AfiKey { enum AfiKey {
V4, V4,
@@ -3601,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(),
}], }],
}), }),
) )
@@ -3992,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);
@@ -4006,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);
@@ -4287,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()],
}, },
@@ -4303,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()],
}, },
@@ -4319,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()],
}, },
@@ -4345,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()],
}, },
@@ -4361,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(),
}, },
@@ -4381,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],
}, },
@@ -4724,6 +5122,120 @@ mod tests {
assert!(matches!(err, ObjectValidateError::EeResourcesNotSubset)); assert!(matches!(err, ObjectValidateError::EeResourcesNotSubset));
} }
#[test]
fn validation_update_03_ee_resources_reduce_to_vrs_for_roa_checks() {
let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa_der = std::fs::read(roa_path).expect("read roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let ee = &roa.signed_object.signed_data.certificates[0].resource_cert;
let ee_ip = ee
.tbs
.extensions
.ip_resources
.as_ref()
.expect("fixture EE has IP resources");
let first_family = ee_ip.families.first().expect("family");
let first_item = match &first_family.choice {
IpAddressChoice::AddressesOrRanges(items) => items.first().expect("item").clone(),
IpAddressChoice::Inherit => panic!("fixture should not inherit"),
};
let issuer_ip = IpResourceSet {
families: vec![IpAddressFamily {
afi: first_family.afi,
choice: IpAddressChoice::AddressesOrRanges(vec![first_item]),
}],
};
let idx = build_issuer_resources_index(Some(&issuer_ip), None);
let strict_err = validate_ee_resources_for_mode(
ee,
Some(&issuer_ip),
None,
&idx,
ResourceValidationMode::Rfc6487,
)
.unwrap_err();
assert!(matches!(
strict_err,
ObjectValidateError::EeResourcesNotSubset
));
let vrs = validate_ee_resources_for_mode(
ee,
Some(&issuer_ip),
None,
&idx,
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs resource resolution");
assert_eq!(vrs.ip.expect("vrs ip").families.len(), 1);
}
#[test]
fn validation_update_03_ee_as_vrs_filters_aspa_customer_resources() {
let issuer_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64510,
},
])),
rdi: None,
};
let child_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64505,
max: 64520,
},
])),
rdi: None,
};
let idx = build_issuer_resources_index(None, Some(&issuer_as));
let vrs = intersect_ee_as_resources_vrs(&child_as, Some(&issuer_as), &idx)
.expect("intersect AS resources");
assert!(as_resource_set_contains_asn(&vrs, 64505));
assert!(as_resource_set_contains_asn(&vrs, 64510));
assert!(!as_resource_set_contains_asn(&vrs, 64511));
}
#[test]
fn validation_update_03_empty_ee_vrs_rejects_roa_and_aspa_outputs() {
let roa_der =
fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
let empty_ip_vrs = IpResourceSet {
families: Vec::new(),
};
let roa_err = roa_to_vrps_with_vrs(&roa, Some(&empty_ip_vrs)).unwrap_err();
assert!(matches!(roa_err, ObjectValidateError::EeResourcesNotSubset));
let missing_ip_err = roa_to_vrps_with_vrs(&roa, None).unwrap_err();
assert!(matches!(
missing_ip_err,
ObjectValidateError::EeResourcesNotSubset
));
let aspa_der = fixture_bytes(
"tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa",
);
let aspa = AspaObject::decode_der(&aspa_der).expect("decode aspa");
let empty_as_vrs = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(Vec::new())),
rdi: None,
};
let aspa_err = validate_aspa_customer_in_vrs(&aspa, Some(&empty_as_vrs)).unwrap_err();
assert!(matches!(
aspa_err,
ObjectValidateError::EeResourcesNotSubset
));
let missing_as_err = validate_aspa_customer_in_vrs(&aspa, None).unwrap_err();
assert!(matches!(
missing_as_err,
ObjectValidateError::EeResourcesNotSubset
));
}
#[test] #[test]
fn extra_rfc_refs_for_crl_selection_distinguishes_crl_errors() { fn extra_rfc_refs_for_crl_selection_distinguishes_crl_errors() {
assert_eq!( assert_eq!(
@@ -4976,11 +5488,13 @@ mod tests {
issuer_resources_index: Arc::new(IssuerResourcesIndex::default()), issuer_resources_index: Arc::new(IssuerResourcesIndex::default()),
issuer_effective_ip: None, issuer_effective_ip: None,
issuer_effective_as: None, issuer_effective_as: None,
resource_validation_mode: ResourceValidationMode::default(),
}), }),
validation_time: OffsetDateTime::now_utc(), validation_time: OffsetDateTime::now_utc(),
collect_vcir_local_outputs: false, collect_vcir_local_outputs: false,
strict_cms_der: false, strict_cms_der: false,
strict_name: false, strict_name: false,
resource_validation_mode: ResourceValidationMode::default(),
roa_task_indices: vec![0, 1], roa_task_indices: vec![0, 1],
cached_roa_results: Vec::new(), cached_roa_results: Vec::new(),
roa_cache_stats: RoaValidationCacheStats::default(), roa_cache_stats: RoaValidationCacheStats::default(),

View File

@@ -21,7 +21,7 @@ use crate::data_model::router_cert::{
use crate::fetch::rsync::RsyncFetcher; use crate::fetch::rsync::RsyncFetcher;
use crate::parallel::config::ParallelPhase2Config; use crate::parallel::config::ParallelPhase2Config;
use crate::parallel::repo_runtime::{RepoSyncRuntime, RepoSyncRuntimeOutcome}; use crate::parallel::repo_runtime::{RepoSyncRuntime, RepoSyncRuntimeOutcome};
use crate::policy::Policy; use crate::policy::{Policy, ResourceValidationMode};
use crate::replay::archive::ReplayArchiveIndex; use crate::replay::archive::ReplayArchiveIndex;
use crate::replay::delta_archive::ReplayDeltaArchiveIndex; use crate::replay::delta_archive::ReplayDeltaArchiveIndex;
use crate::report::{RfcRef, Warning}; use crate::report::{RfcRef, Warning};
@@ -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,
@@ -932,6 +933,7 @@ impl<'a> Rpkiv1PublicationPointRunner<'a> {
&fresh_point, &fresh_point,
self.validation_time, self.validation_time,
self.timing.as_ref(), self.timing.as_ref(),
self.policy,
if self.enable_child_certificate_validation_cache { if self.enable_child_certificate_validation_cache {
Some(ChildCertificateValidationCacheContext { Some(ChildCertificateValidationCacheContext {
store: self.store, store: self.store,
@@ -1750,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}"))?;
@@ -2259,12 +2262,14 @@ fn discover_children_from_fresh_snapshot_with_audit<P: PublicationPointData>(
return Err("lazy CA certificate requires store-backed child discovery".to_string()); return Err("lazy CA certificate requires store-backed child discovery".to_string());
} }
}; };
let default_policy = Policy::default();
discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der( discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der(
issuer, issuer,
issuer_ca_der, issuer_ca_der,
publication_point, publication_point,
validation_time, validation_time,
timing, timing,
&default_policy,
None, None,
) )
} }
@@ -2282,12 +2287,14 @@ fn discover_children_from_fresh_snapshot_with_audit_cached<P: PublicationPointDa
return Err("lazy CA certificate requires store-backed child discovery".to_string()); return Err("lazy CA certificate requires store-backed child discovery".to_string());
} }
}; };
let default_policy = Policy::default();
discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der( discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der(
issuer, issuer,
issuer_ca_der, issuer_ca_der,
publication_point, publication_point,
validation_time, validation_time,
timing, timing,
&default_policy,
cache_context, cache_context,
) )
} }
@@ -2300,6 +2307,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
publication_point: &P, publication_point: &P,
validation_time: time::OffsetDateTime, validation_time: time::OffsetDateTime,
timing: Option<&TimingHandle>, timing: Option<&TimingHandle>,
policy: &Policy,
cache_context: Option<ChildCertificateValidationCacheContext<'_>>, cache_context: Option<ChildCertificateValidationCacheContext<'_>>,
) -> Result<ChildDiscoveryOutput, String> { ) -> Result<ChildDiscoveryOutput, String> {
let locked_files = publication_point.files(); let locked_files = publication_point.files();
@@ -2308,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)"
@@ -2906,6 +2924,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
issuer.effective_as_resources.as_ref(), issuer.effective_as_resources.as_ref(),
&issuer_resources_index, &issuer_resources_index,
validation_time, validation_time,
policy.resource_validation_mode,
) { ) {
Ok(v) => v, Ok(v) => v,
Err(CaPathError::ChildNotCa) => { Err(CaPathError::ChildNotCa) => {
@@ -2949,12 +2968,32 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
match router_result { match router_result {
Ok(router) => { Ok(router) => {
let router_asns = match router_asns_for_resource_mode(
&router.asns,
issuer.effective_as_resources.as_ref(),
policy.resource_validation_mode,
) {
Ok(v) => v,
Err(detail) => {
router_error = router_error.saturating_add(1);
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::RouterCertificate,
result: AuditObjectResult::Error,
detail: Some(format!(
"router certificate AS resource validation failed: {detail}"
)),
});
continue;
}
};
router_ok = router_ok.saturating_add(1); router_ok = router_ok.saturating_add(1);
let source_object_hash = sha256_hex_from_32(&f.sha256); let source_object_hash = sha256_hex_from_32(&f.sha256);
let item_effective_until = PackTime::from_utc_offset_datetime( let item_effective_until = PackTime::from_utc_offset_datetime(
router.resource_cert.tbs.validity_not_after, router.resource_cert.tbs.validity_not_after,
); );
for as_id in &router.asns { for as_id in &router_asns {
router_keys.push(RouterKeyPayload { router_keys.push(RouterKeyPayload {
as_id: *as_id, as_id: *as_id,
ski: router.subject_key_identifier.clone(), ski: router.subject_key_identifier.clone(),
@@ -3006,8 +3045,7 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
effective_not_before, effective_not_before,
effective_until, effective_until,
payload: ChildCertificateCachePayload::Router { payload: ChildCertificateCachePayload::Router {
router_keys: router router_keys: router_asns
.asns
.iter() .iter()
.map(|as_id| ChildCertificateCacheRouterKeyProjection { .map(|as_id| ChildCertificateCacheRouterKeyProjection {
as_id: *as_id, as_id: *as_id,
@@ -3096,6 +3134,20 @@ fn discover_children_from_fresh_snapshot_with_audit_cached_with_issuer_der<
validate_sub_ca_nanos = validate_sub_ca_nanos validate_sub_ca_nanos = validate_sub_ca_nanos
.saturating_add(t1.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64); .saturating_add(t1.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64);
if !validated.resource_warnings.is_empty() {
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Ok,
detail: Some(format!(
"resource validation warning ({:?}): {}",
policy.resource_validation_mode,
validated.resource_warnings.summary()
)),
});
}
let eff_ip_items = ip_item_count(validated.effective_ip_resources.as_ref()); let eff_ip_items = ip_item_count(validated.effective_ip_resources.as_ref());
match bucketize(eff_ip_items) { match bucketize(eff_ip_items) {
0 => eff_ip_items_bucket_le_10 = eff_ip_items_bucket_le_10.saturating_add(1), 0 => eff_ip_items_bucket_le_10 = eff_ip_items_bucket_le_10.saturating_add(1),
@@ -3482,6 +3534,65 @@ fn is_non_router_certificate(err: &BgpsecRouterCertificatePathError) -> bool {
) )
} }
fn router_asns_for_resource_mode(
router_asns: &[u32],
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
mode: ResourceValidationMode,
) -> Result<Vec<u32>, String> {
let Some(issuer_effective_as) = issuer_effective_as else {
return Err("issuer has no effective AS resources".to_string());
};
match mode {
ResourceValidationMode::Rfc6487 => {
let outside: Vec<u32> = router_asns
.iter()
.copied()
.filter(|asn| !as_resource_set_contains_asn(issuer_effective_as, *asn))
.collect();
if outside.is_empty() {
Ok(router_asns.to_vec())
} else {
Err(format!(
"router AS resources are not a subset of issuer effective AS resources: {outside:?}"
))
}
}
ResourceValidationMode::ValidationUpdate03 => {
let filtered: Vec<u32> = router_asns
.iter()
.copied()
.filter(|asn| as_resource_set_contains_asn(issuer_effective_as, *asn))
.collect();
if filtered.is_empty() {
Err("router AS resources have empty validated resource set".to_string())
} else {
Ok(filtered)
}
}
}
}
fn as_resource_set_contains_asn(
resources: &crate::data_model::rc::AsResourceSet,
asn: u32,
) -> bool {
let Some(choice) = resources.asnum.as_ref() else {
return false;
};
match choice {
crate::data_model::rc::AsIdentifierChoice::Inherit => false,
crate::data_model::rc::AsIdentifierChoice::AsIdsOrRanges(items) => {
items.iter().any(|item| match item {
crate::data_model::rc::AsIdOrRange::Id(id) => *id == asn,
crate::data_model::rc::AsIdOrRange::Range { min, max } => {
*min <= asn && asn <= *max
}
})
}
}
}
fn select_issuer_crl_uri_for_child<'a>( fn select_issuer_crl_uri_for_child<'a>(
child: &'a crate::data_model::rc::ResourceCertificate, child: &'a crate::data_model::rc::ResourceCertificate,
crl_cache: &std::collections::HashMap<String, CachedIssuerCrl>, crl_cache: &std::collections::HashMap<String, CachedIssuerCrl>,
@@ -3568,6 +3679,7 @@ fn validate_subordinate_ca_cert_with_cached_issuer(
issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>, issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
issuer_resources_index: &IssuerEffectiveResourcesIndex, issuer_resources_index: &IssuerEffectiveResourcesIndex,
validation_time: time::OffsetDateTime, validation_time: time::OffsetDateTime,
resource_validation_mode: crate::policy::ResourceValidationMode,
) -> Result<ValidatedSubordinateCaLite, CaPathError> { ) -> Result<ValidatedSubordinateCaLite, CaPathError> {
let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?; let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?;
@@ -3584,6 +3696,7 @@ fn validate_subordinate_ca_cert_with_cached_issuer(
issuer_effective_as, issuer_effective_as,
issuer_resources_index, issuer_resources_index,
validation_time, validation_time,
resource_validation_mode,
) )
} }
@@ -4155,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();
@@ -4163,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)?;
@@ -4266,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,
@@ -5179,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),
@@ -5188,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

@@ -1,5 +1,5 @@
use super::*; use super::*;
use crate::data_model::rc::ResourceCertificate; use crate::data_model::rc::{AsIdOrRange, AsIdentifierChoice, AsResourceSet, ResourceCertificate};
use crate::data_model::roa::RoaAfi; use crate::data_model::roa::RoaAfi;
use crate::fetch::rsync::LocalDirRsyncFetcher; use crate::fetch::rsync::LocalDirRsyncFetcher;
use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher}; use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher};
@@ -29,6 +29,48 @@ fn ipv4_addr(octets: [u8; 4]) -> [u8; 16] {
addr addr
} }
#[test]
fn publication_point_cache_policy_fingerprint_includes_resource_validation_mode() {
let mut strict_policy = Policy::default();
strict_policy.resource_validation_mode = ResourceValidationMode::Rfc6487;
let mut vrs_policy = Policy::default();
vrs_policy.resource_validation_mode = ResourceValidationMode::ValidationUpdate03;
assert_ne!(
publication_point_cache_policy_fingerprint(&strict_policy),
publication_point_cache_policy_fingerprint(&vrs_policy)
);
}
#[test]
fn router_asns_for_resource_mode_filters_vrs_and_rejects_strict_overclaim() {
let issuer_as = AsResourceSet {
asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
AsIdOrRange::Range {
min: 64500,
max: 64510,
},
])),
rdi: None,
};
let strict = router_asns_for_resource_mode(
&[64505, 64520],
Some(&issuer_as),
ResourceValidationMode::Rfc6487,
)
.unwrap_err();
assert!(strict.contains("not a subset"), "{strict}");
let vrs = router_asns_for_resource_mode(
&[64505, 64520],
Some(&issuer_as),
ResourceValidationMode::ValidationUpdate03,
)
.expect("vrs filters router asns");
assert_eq!(vrs, vec![64505]);
}
struct NeverHttpFetcher; struct NeverHttpFetcher;
impl Fetcher for NeverHttpFetcher { impl Fetcher for NeverHttpFetcher {
fn fetch(&self, _uri: &str) -> Result<Vec<u8>, String> { fn fetch(&self, _uri: &str) -> Result<Vec<u8>, String> {
@@ -711,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;
@@ -3785,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");
@@ -3810,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,
@@ -3817,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");
@@ -3871,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();
@@ -3908,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");
@@ -3933,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");
@@ -3951,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,
@@ -3958,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");
@@ -4006,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");
@@ -4057,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");
@@ -4086,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,
@@ -4101,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,
@@ -4108,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(_)
))
));
}