14 Commits

398 changed files with 47675 additions and 32736 deletions

View File

@@ -1,9 +0,0 @@
.git
.gitignore
.idea
target
rtr-db
tests
specs
scripts
README.md

6
.gitignore vendored
View File

@@ -1,5 +1,3 @@
target/
rtr-db/
.idea/
logs/
report/
Cargo.lock
perf.*

3528
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,38 +3,33 @@ name = "rpki"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "rpki_rtr"
path = "src/main_rtr.rs"
[features]
default = ["full"]
# Full build used by the main RP implementation (includes RocksDB-backed storage).
full = ["dep:rocksdb"]
profile = ["dep:pprof", "dep:flate2"]
[dependencies]
der-parser = "10.0.0"
asn1-rs = "0.7.1"
der-parser = { version = "10.0.0", features = ["serialize"] }
hex = "0.4.3"
base64 = "0.22.1"
sha2 = "0.10.8"
thiserror = "2.0.18"
time = "0.3.45"
ring = "0.17.14"
x509-parser = { version = "0.18.0", features = ["verify"] }
url = "2.5.8"
asn1-rs = "0.7.1"
asn1-rs-derive = "0.6.0"
asn1 = "0.23.0"
arc-swap = "1.7.0"
chrono = { version = "0.4.44", features = ["serde"] }
chrono-tz = "0.10"
bytes = "1.11.1"
tokio = { version = "1.49.0", features = ["full"] }
rand = "0.10.0"
rocksdb = { version = "0.21.0", default-features = false }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
base64 = "0.22"
anyhow = "1"
tracing = "0.1.44"
sha2 = "0.10"
tempfile = "3"
tokio-rustls = "0.26"
rustls = "0.23"
rustls-pemfile = "2"
rustls-pki-types = "1.14.0"
socket2 = "0.5"
russh = { version = "0.60.0", default-features = false, features = ["ring", "rsa"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.140"
toml = "0.8.20"
rocksdb = { version = "0.22.0", optional = true, default-features = false, features = ["lz4"] }
serde_cbor = "0.11.2"
roxmltree = "0.20.0"
uuid = { version = "1.7.0", features = ["v4"] }
reqwest = { version = "0.12.12", default-features = false, features = ["blocking", "rustls-tls"] }
pprof = { version = "0.14.1", optional = true, features = ["flamegraph", "prost-codec"] }
flate2 = { version = "1.0.35", optional = true }
[dev-dependencies]
tempfile = "3.16.0"

View File

@@ -1,238 +0,0 @@
# RTR Client 使用说明
本文说明仓库内三类 RTR Client 的用途、启动方式和验证命令:
- `deploy/client`: 本仓库自带的 `rtr_debug_client`,适合做 RTR 协议调试、连通性检查和单次查询。
- `deploy/frr`: FRR 作为黑盒 RTR Client,适合验证真实路由软件是否能消费 server 下发的 VRP。
- `deploy/bird`: BIRD 作为黑盒 RTR Client,适合验证 TCP 或 SSH transport 下的 RPKI/ROA/ASPA 数据导入。
以下命令默认从仓库根目录执行。
## 目录
- [前置条件](#前置条件)
- [rtr_debug_client](#rtr_debug_client)
- [TCP](#tcp)
- [TLS/mTLS](#tlsmtls)
- [SSH](#ssh)
- [多客户端压测](#多客户端压测)
- [FRR Client](#frr-client)
- [BIRD Client](#bird-client)
- [TCP](#tcp-1)
- [SSH](#ssh-1)
- [如何判断 Client 正常](#如何判断-client-正常)
## 前置条件
先启动 RTR Server。TCP 模式是最常用的本地验证方式:
```bash
./deploy/server/build-run.sh up --mode tcp
```
如果要测试 TLS 或 SSH,需要先启动对应 server 模式,并准备匹配的证书或 SSH key:
```bash
./deploy/server/build-run.sh up --mode tls
./deploy/server/build-run.sh up --mode ssh
```
TLS/SSH 所需的本地开发证书和密钥可以通过脚本生成:
```bash
bash scripts/generate-certs.sh
```
已有 `certs/` 时使用 `--force` 覆盖。脚本生成的 `certs/tls` 和 `certs/ssh` 文件名与本仓库 compose 默认配置一致。
Client 连接地址不要求固定在某个 Docker 网络里。`deploy/client` 的 TCP/TLS/SSH 模式分别通过 `RPKI_RTR_TCP_SERVER_ADDR`、`RPKI_RTR_TLS_SERVER_ADDR`、`RPKI_RTR_SSH_SERVER_ADDR` 指定 server 地址。`deploy/bird` 通过 `RPKI_BIRD_RPKI_HOST`/`RPKI_BIRD_RPKI_PORT` 指定 TCP server 地址,通过 `RPKI_BIRD_SSH_RPKI_HOST`/`RPKI_RTR_SSH_PORT` 指定 SSH server 地址。示例 compose 里的默认值只是为了和本仓库 server compose 直接联调。
`deploy/frr` 示例默认连接 `127.0.0.1:323`,可以通过 `RPKI_FRR_RPKI_HOST`/`RPKI_FRR_RPKI_PORT` 覆盖。
## rtr_debug_client
`rtr_debug_client` 是本仓库提供的调试客户端。它会发起 RTR Reset Query 或 Serial Query,并打印 server 返回的 PDU 摘要。Docker 配置默认执行:
```text
<server_addr> <protocol_version> reset --keep-after-error --summary-only
```
### TCP
```bash
./deploy/client/build-run.sh up --mode tcp
./deploy/client/build-run.sh logs --mode tcp
./deploy/client/build-run.sh down --mode tcp
```
默认连接 `rpki-rtr:323`,协议版本默认是 `2`。可以通过 `deploy/client/.env` 或环境变量覆盖:
```bash
RPKI_RTR_TCP_SERVER_ADDR=10.0.0.12:323 \
RPKI_RTR_PROTOCOL_VERSION=2 \
./deploy/client/build-run.sh up --mode tcp
```
### TLS/mTLS
```bash
./deploy/client/build-run.sh up --mode tls
./deploy/client/build-run.sh logs --mode tls
./deploy/client/build-run.sh down --mode tls
```
TLS 模式默认读取 `certs/tls` 下的示例证书,并使用 `localhost` 作为 server name。常用变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `RPKI_RTR_TLS_SERVER_ADDR` | `rpki-rtr:324` | TLS RTR server 地址 |
| `RPKI_RTR_TLS_SERVER_NAME` | `localhost` | 必须匹配 server 证书 SAN dNSName |
| `RPKI_RTR_TLS_CA_CERT_PATH` | `/app/certs/client-ca.crt` | 容器内 CA 证书路径 |
| `RPKI_RTR_TLS_CLIENT_CERT_PATH` | `/app/certs/client-good.crt` | 容器内 client 证书路径 |
| `RPKI_RTR_TLS_CLIENT_KEY_PATH` | `/app/certs/client-good.key` | 容器内 client 私钥路径 |
| `RPKI_RTR_TLS_CERTS_HOST_DIR` | `../../certs/tls` | 宿主机 TLS 证书目录 |
### SSH
```bash
./deploy/client/build-run.sh up --mode ssh
./deploy/client/build-run.sh logs --mode ssh
./deploy/client/build-run.sh down --mode ssh
```
SSH 模式默认连接 `rpki-rtr-ssh:22`,使用 key 认证,并校验 server public key。常用变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `RPKI_RTR_SSH_SERVER_ADDR` | `rpki-rtr:22` | SSH RTR server 地址 |
| `RPKI_RTR_SSH_USERNAME` | `rpki-rtr` | SSH 用户名 |
| `RPKI_RTR_CLIENT_KEYS_VOLUME` | `../../certs/ssh:/app/certs:ro` | 宿主机 SSH key 目录挂载 |
| `RPKI_RTR_CLIENT_KEY_PATH` | `/app/certs/rtr-client.key` | 容器内 client 私钥 |
| `RPKI_RTR_SSH_SERVER_PUBKEY_PATH` | `/app/certs/ssh_host_rsa_key.pub` | 容器内 server public key |
### 多客户端压测
`--clients multi` 会启动 5 个 TCP debug client,用于观察并发连接、client report 和 server 连接统计。
```bash
./deploy/client/build-run.sh up --mode tcp --clients multi
./deploy/client/build-run.sh logs --mode tcp --clients multi
./deploy/client/build-run.sh down --mode tcp --clients multi
```
## FRR Client
FRR 示例用于验证标准路由软件能否通过 RTR over TCP 从 server 获取前缀验证数据。
启动:
```bash
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml up -d
```
默认连接 `127.0.0.1:323`。可以通过环境变量覆盖:
```bash
RPKI_FRR_RPKI_HOST=10.0.0.12 \
RPKI_FRR_RPKI_PORT=323 \
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml up -d
```
验证连接和数据:
```bash
docker exec -it frr-rpki-client vtysh -c "show rpki configuration"
docker exec -it frr-rpki-client vtysh -c "show rpki cache-connection"
docker exec -it frr-rpki-client vtysh -c "show rpki prefix-table"
```
停止:
```bash
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml down
```
FRR 容器启动时会读取 `deploy/frr/frr.conf.template`,并生成 `/etc/frr/frr.conf`。模板中的 RTR cache 配置为:
```text
rpki cache tcp ${RPKI_FRR_RPKI_HOST} ${RPKI_FRR_RPKI_PORT} preference ${RPKI_FRR_RPKI_PREFERENCE}
```
如果需要调整 polling、timeout、BGP router-id 等其他 FRR 配置,可以修改 `deploy/frr/frr.conf.template`,或在 compose 中挂载自己的模板和 entrypoint。
## BIRD Client
BIRD 示例用于验证 BIRD 3.x 对 RTR v2、ROA 和 ASPA 的导入情况。容器启动后会周期性输出 RPKI 协议状态和表项摘要。
### TCP
启动:
```bash
docker compose --env-file deploy/bird/.env -f deploy/bird/docker-compose.yml up -d --build
```
查看日志:
```bash
docker logs -f bird-rpki-client
```
停止:
```bash
docker compose --env-file deploy/bird/.env -f deploy/bird/docker-compose.yml down
```
默认连接 `rpki-rtr:323`。可以通过环境变量覆盖:
```bash
RPKI_BIRD_RPKI_HOST=10.0.0.12 \
RPKI_BIRD_RPKI_PORT=323 \
docker compose --env-file deploy/bird/.env -f deploy/bird/docker-compose.yml up -d --build
```
### SSH
先启动 server SSH 模式:
```bash
./deploy/server/build-run.sh up --mode ssh
```
再启动 BIRD SSH client:
```bash
docker compose --env-file deploy/bird/.env \
-f deploy/bird/docker-compose.yml \
-f deploy/bird/docker-compose.ssh.yml \
up -d --build
```
查看日志:
```bash
docker logs -f bird-rpki-client
```
SSH 模式默认读取 `certs/ssh` 目录下的 key,并使用 `bird.conf.ssh.template` 生成运行时配置。关键默认值:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `RPKI_BIRD_SSH_RPKI_HOST` | `rpki-rtr` | SSH RTR server 主机名 |
| `RPKI_RTR_SSH_PORT` | `22` | SSH RTR server 端口 |
| `RPKI_BIRD_SSH_CERTS_HOST_DIR` | `../../certs/ssh` | 宿主机 SSH 证书和 key 目录,复用 `rtr-client.key` |
## 如何判断 Client 正常
- `rtr_debug_client`: 日志中能看到 Reset Query 成功、EndOfData 和 payload 统计。
- FRR: `show rpki cache-connection` 显示已连接,`show rpki prefix-table` 有 VRP 条目。
- BIRD: 日志中 RPKI protocol 状态为 `up`,ROA/ASPA 表项摘要不为空。
如果 client 无法连接,优先检查:
- server 是否按对应 transport 启动。
- client 配置的 server 地址是否从当前容器或主机可达。
- TLS 的 server name 是否匹配证书 SAN。
- SSH 的用户名、client key 和 server public key 是否匹配。
- `RPKI_RTR_PROTOCOL_VERSION` 或 BIRD 配置中的 `min version`/`max version` 是否和 server 能力一致。

377
README.md
View File

@@ -1,373 +1,28 @@
# RPKI RTR Server
这是一个用 Rust 实现的 RPKI-to-Router (RTR) cache server。服务端从 CCR 文件中读取 RPKI 数据,生成 RTR cache snapshot 和 delta,并通过 RTR 协议提供给路由器或调试客户端。
当前代码重点覆盖:
# 单元测试
- RTR server:TCP 默认启用,可选启用 TLS/mTLS 和进程内 SSH transport。
- 数据输入:从 CCR 目录选择最新 `.ccr` 文件,解析 VRP 和 VAP/ASPA。
- 本地策略:可选加载 SLURM 文件,对 CCR 解析出的 payload 做过滤和本地断言。
- 持久化:使用 RocksDB 保存 RTR cache 状态。
- 运行报告:输出 source、client、runtime 三类 JSON report。
- 运行期管理:可选启用 HTTP Admin API,修改部分 runtime config、触发 source reload、管理 SLURM 文件。
- 调试工具:`rtr_debug_client` 支持 TCP、TLS 和 SSH 连接 RTR server。
```
cargo test
## 目录
- [协议参考](#协议参考)
- [项目结构](#项目结构)
- [构建与测试](#构建与测试)
- [数据输入](#数据输入)
- [快速启动](#快速启动)
- [前置数据生成](#前置数据生成)
- [Docker 运行](#docker-运行)
- [跨架构构建](#跨架构构建)
- [传输模式](#传输模式)
- [主要环境变量](#主要环境变量)
- [运行报告](#运行报告)
- [Admin API](#admin-api)
- [调试客户端](#调试客户端)
- [部署入口](#部署入口)
- [开发说明](#开发说明)
## 协议参考
- RTR: [RFC 6810](https://www.rfc-editor.org/rfc/rfc6810.html), [RFC 8210](https://www.rfc-editor.org/rfc/rfc8210.html)
- SLURM: [RFC 8416](https://www.rfc-editor.org/rfc/rfc8416.html)
- CCR: [draft-ietf-sidrops-rpki-ccr](https://www.ietf.org/archive/id/draft-ietf-sidrops-rpki-ccr-02.html)
## 项目结构
| 路径 | 说明 |
| --- | --- |
| `src/main_rtr.rs` | RTR server 入口 |
| `src/rtr/` | RTR 协议、cache、store、server transport、admin、report |
| `src/source/` | CCR/SLURM source 加载流程 |
| `src/slurm/` | SLURM 文件解析、合并和策略应用 |
| `src/data_model/` | RPKI 相关数据结构 |
| `src/bin/rtr_debug_client/` | RTR 调试客户端 |
| `deploy/server/` | server Docker 镜像和 compose |
| `deploy/client/` | debug client Docker 镜像和 compose |
| `deploy/frr/`, `deploy/bird/` | FRR/BIRD 作为 RTR client 的测试部署 |
| `tests/` | 单元和集成测试 |
## 构建与测试
```bash
cargo build --bin rpki_rtr
cargo build --bin rtr_debug_client
cargo test
# 查看输出
cargo test -- --nocapture
```
服务端 binary 名称是 `rpki_rtr`:
# 覆盖率(cargo-llvm-cov)
```bash
cargo run --bin rpki_rtr
安装工具:
```
rustup component add llvm-tools-preview
cargo install cargo-llvm-cov --locked
```
默认配置会监听 `0.0.0.0:323`,从 `./data` 查找 CCR 文件,并使用 `./rtr-db` 保存 RocksDB 数据。启动前需要确保 CCR 目录里存在可解析的 `.ccr` 文件。
统计行覆盖率并要求 >=90%:
## 数据输入
服务端启动和刷新时会读取 `RPKI_RTR_CCR_DIR` 指向的目录:
1. 如果该目录下存在包含 `.ccr` 文件的子目录,先按子目录名排序选择最新子目录。
2. 在选中的目录中按文件名排序选择最新 `.ccr` 文件。
3. 从 CCR 中解析 VRP 和 VAP/ASPA。
4. 如果配置了 `RPKI_RTR_SLURM_DIR`,加载其中启用的 `*.slurm` 文件并应用本地策略。
## 快速启动
### 前置数据生成
TLS/mTLS 或 SSH transport 需要本地证书和密钥。开发和 Docker 联调可用脚本生成 compose 默认引用的最小文件集:
```bash
bash scripts/10_generate-certs.sh
```
./scripts/coverage.sh
# 或
cargo llvm-cov --fail-under-lines 90
```
如需覆盖已有 `certs/` 内容:
```bash
bash scripts/10_generate-certs.sh --force
```
`10_generate-certs.sh` 常用参数:
| 参数 | 说明 |
| --- | --- |
| `--out-dir DIR` | 指定输出目录,默认生成到 `./certs`。 |
| `--server-dns NAME` | 指定 TLS server 证书的 dNSName SAN,默认 `localhost`。 |
| `--tls-only` | 只生成 `certs/tls` 下的 TLS 证书和私钥。 |
| `--ssh-only` | 只生成 `certs/ssh` 下的 SSH host key、client key 和 `authorized_keys`。 |
| `--force` | 覆盖已有文件。未指定时,如果目标文件已存在会直接报错退出。 |
| `-h`, `--help` | 查看脚本帮助。 |
TLS server name 默认是 `localhost`。如果需要其它 DNS 名称,需要同时设置 `scripts/10_generate-certs.sh --server-dns <name>` 和 client 的 `RPKI_RTR_TLS_SERVER_NAME=<name>`。
### Docker 运行
推荐使用 `deploy/server/build-run.sh` 和 `deploy/client/build-run.sh` 分别管理 server 与 debug client:
以下命令默认从仓库根目录执行。
```bash
chmod +x deploy/server/build-run.sh deploy/client/build-run.sh
./deploy/server/build-run.sh up --mode tcp
./deploy/client/build-run.sh up --mode tcp
./deploy/client/build-run.sh down
./deploy/server/build-run.sh down
```
脚本常用参数:
| 参数/变量 | server | client | 说明 |
| --- | --- | --- | --- |
| `--mode base|tcp|tls|ssh`, `MODE` | yes | yes | 选择 compose 模式,默认 `tcp`。 |
| `--clients single|multi`, `CLIENTS` | no | yes | 单 client 或 5 client,默认 `single`。 |
| `--no-build`, `BUILD=0` | yes | yes | 只运行已有镜像,不执行 compose build。 |
| `--server-image TAG`, `SERVER_IMAGE` | yes | no | server 镜像名;会传给 compose 的 `RPKI_RTR_SERVER_IMAGE`。 |
| `--client-image TAG`, `CLIENT_IMAGE` | no | yes | client 镜像名;会传给 compose 的 `RPKI_RTR_CLIENT_IMAGE`。 |
| `--platform VALUE`, `TARGET_PLATFORM` | yes | yes | `buildx` 目标平台,默认 `linux/arm64`。 |
| `--push`, `BUILDX_PUSH=1` | yes | yes | `buildx` 构建后推送到 registry;不设置时加载到本地 Docker。 |
| `--skip-base-pull`, `SKIP_BASE_IMAGE_PULL=1` | yes | yes | 跳过 buildx 前的基础镜像预拉取。 |
支持的 action 包括 `up`、`build`、`buildx`、`rebuild`、`down`/`stop`、`restart`、`logs`、`ps`。完整帮助见:
```bash
./deploy/server/build-run.sh --help
./deploy/client/build-run.sh --help
```
### 跨架构构建
示例:x86 开发机本地构建 ARM 镜像,打包后传到 ARM 部署机。
```bash
./deploy/server/build-run.sh buildx \
--platform linux/arm64 \
--server-image rpki-rtr:arm64
./deploy/client/build-run.sh buildx \
--platform linux/arm64 \
--client-image rpki-rtr-debug-client:arm64
mkdir -p dist/images
docker save rpki-rtr:arm64 -o dist/images/rpki-rtr-arm64.tar
docker save rpki-rtr-debug-client:arm64 -o dist/images/rpki-rtr-debug-client-arm64.tar
scp dist/images/*.tar user@arm-host:/tmp/
```
ARM 部署机加载镜像并运行:
```bash
docker load -i /tmp/rpki-rtr-arm64.tar
docker load -i /tmp/rpki-rtr-debug-client-arm64.tar
SERVER_IMAGE=rpki-rtr:arm64 \
BUILD=0 ./deploy/server/build-run.sh up --mode tcp
CLIENT_IMAGE=rpki-rtr-debug-client:arm64 \
BUILD=0 ./deploy/client/build-run.sh up --mode tcp
```
```bash
docker compose -f deploy/server/docker-compose.yml up -d --build
docker compose -f deploy/server/docker-compose.yml down
```
TLS/mTLS 示例:
```bash
docker compose -f deploy/server/docker-compose.tls.yml up -d --build
docker compose -f deploy/server/docker-compose.tls.yml down
```
## 传输模式
TCP 默认启用。TLS 和 SSH 是附加监听,不会替代 TCP。
| 模式 | 默认地址 | 启用方式 |
| --- | --- | --- |
| TCP | `0.0.0.0:323` | 默认启用 |
| TLS/mTLS | `0.0.0.0:324` | `RPKI_RTR_ENABLE_TLS=true` |
| SSH | `0.0.0.0:22` | `RPKI_RTR_ENABLE_SSH=true` |
TLS server 会加载服务端证书、私钥和 client CA。SSH server 使用 OpenSSH host key 和 authorized_keys 文件;`RPKI_RTR_SSH_AUTH_MODE` 支持 `key`、`password`、`both`。
## 主要环境变量
布尔值支持 `true/false`、`1/0`、`yes/no`、`on/off`。
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `RPKI_RTR_ENABLE_TLS` | `false` | 是否启用 TLS/mTLS 监听 |
| `RPKI_RTR_ENABLE_SSH` | `false` | 是否启用 SSH 监听 |
| `RPKI_RTR_TCP_ADDR` | `0.0.0.0:323` | TCP 监听地址 |
| `RPKI_RTR_TLS_ADDR` | `0.0.0.0:324` | TLS 监听地址 |
| `RPKI_RTR_SSH_ADDR` | `0.0.0.0:22` | SSH 监听地址 |
| `RPKI_RTR_SSH_PORT` | 未设置 | 仅覆盖 `RPKI_RTR_SSH_ADDR` 中的端口 |
| `RPKI_RTR_DB_PATH` | `./rtr-db` | RocksDB 路径 |
| `RPKI_RTR_CCR_DIR` | `./data` | CCR 输入目录 |
| `RPKI_RTR_SLURM_DIR` | 未设置 | SLURM 目录;空值表示禁用 |
| `RPKI_RTR_REPORT_DIR` | `./report` | JSON report 输出目录 |
| `RPKI_RTR_MAX_DELTA` | `100` | 每个 RTR 版本保留的最大 delta 数,必须大于 0 |
| `RPKI_RTR_PRUNE_DELTA_BY_SNAPSHOT_SIZE` | `false` | 是否按 snapshot wire size 继续裁剪 delta window |
| `RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS` | `300` | source 刷新间隔,单位秒 |
| `RPKI_RTR_REFRESH_INTERVAL_SECS` | 未设置 | 旧变量名,仍兼容;建议使用 `RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS` |
| `RPKI_RTR_TIMING_REFRESH_SECS` | `3600` | RTR EndOfData `refresh` 字段 |
| `RPKI_RTR_TIMING_RETRY_SECS` | `600` | RTR EndOfData `retry` 字段 |
| `RPKI_RTR_TIMING_EXPIRE_SECS` | `7200` | RTR EndOfData `expire` 字段,必须大于 refresh 和 retry |
| `RPKI_RTR_MAX_CONNECTIONS` | `512` | 最大并发 RTR 连接数 |
| `RPKI_RTR_MAX_CONCURRENT_HANDSHAKES` | `128` | 最大并发握手数,不能大于最大连接数 |
| `RPKI_RTR_NOTIFY_QUEUE_SIZE` | `1024` | Serial Notify 广播队列大小 |
| `RPKI_RTR_TCP_KEEPALIVE_SECS` | `60` | TCP keepalive;设为 `0` 表示禁用 |
| `RPKI_RTR_WARN_INSECURE_TCP` | `true` | TCP 模式是否输出安全提示 |
| `RPKI_RTR_REQUIRE_TLS_SERVER_DNS_NAME_SAN` | `false` | 是否要求 TLS 服务端证书包含 dNSName SAN |
| `RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH` | `true` | 是否校验 TLS client 证书 SAN IP 与 peer IP 匹配 |
| `RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS` | `300` | runtime report 周期 |
| `RPKI_RTR_REPORT_HISTORY_LIMIT` | `10` | 每类 report 滚动保留数量 |
| `RPKI_RTR_TIMEZONE` | `Asia/Shanghai` | 日志和 report 使用的时区 |
| `RPKI_RTR_ADMIN_ADDR` | 未设置 | Admin API 监听地址;未设置时关闭 |
| `RPKI_RTR_ADMIN_TOKEN` | 未设置 | Admin API Bearer token;非 loopback 监听时必须设置 |
TLS 相关路径:
| 变量 | 默认值 |
| --- | --- |
| `RPKI_RTR_TLS_CERT_PATH` | `./certs/tls/server-dns.crt` |
| `RPKI_RTR_TLS_KEY_PATH` | `./certs/tls/server-dns.key` |
| `RPKI_RTR_TLS_CLIENT_CA_PATH` | `./certs/tls/client-ca.crt` |
SSH 相关配置:
| 变量 | 默认值 |
| --- | --- |
| `RPKI_RTR_SSH_HOST_KEY_PATH` | `./certs/ssh/ssh_host_rsa_key` |
| `RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH` | `./certs/ssh/rtr-authorized_keys` |
| `RPKI_RTR_SSH_USERNAME` | `rpki-rtr` |
| `RPKI_RTR_SSH_SUBSYSTEM_NAME` | `rpki-rtr` |
| `RPKI_RTR_SSH_AUTH_MODE` | `key` |
| `RPKI_RTR_SSH_PASSWORD` | 未设置 |
## 运行报告
服务会在 `RPKI_RTR_REPORT_DIR` 下写入 JSON report,并按 `RPKI_RTR_REPORT_HISTORY_LIMIT` 滚动保留:
- `rtr-source-*.json`:CCR/SLURM source、fingerprint、刷新状态、数据质量、cache 统计。
- `rtr-clients-*.json`:client 连接数和连接方式统计。
- `rtr-runtime-*.json`:进程状态、服务状态和当前生效的 runtime config。
## Admin API
Admin API 默认关闭。设置 `RPKI_RTR_ADMIN_ADDR` 后启用:
```bash
export RPKI_RTR_ADMIN_ADDR=127.0.0.1:8323
export RPKI_RTR_ADMIN_TOKEN=change-me
```
如果监听非 loopback 地址,例如 `0.0.0.0:8323`,必须设置 `RPKI_RTR_ADMIN_TOKEN`。设置 token 后,请求需要携带:
```http
Authorization: Bearer change-me
```
当前实现的主要接口:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/admin/rtr/health` | 健康检查和能力开关 |
| `GET` | `/admin/rtr/config` | 查看 runtime config |
| `POST` | `/admin/rtr/config` | 修改部分 runtime config |
| `GET` | `/admin/rtr/logs/tail` | 读取 stdout/stderr 日志 tail |
| `POST` | `/admin/rtr/slurm/reload` | 触发 source reload |
| `GET` | `/admin/rtr/slurm/files` | 列出 SLURM 文件 |
| `POST` | `/admin/rtr/slurm/files` | 新增或覆盖 SLURM 文件 |
| `GET` | `/admin/rtr/slurm/files/{name}` | 读取 SLURM 文件 |
| `PUT` | `/admin/rtr/slurm/files/{name}` | 新增或覆盖指定 SLURM 文件 |
| `DELETE` | `/admin/rtr/slurm/files/{name}` | 删除 SLURM 文件 |
| `POST` | `/admin/rtr/slurm/files/{name}/enable` | 启用 `.slurm.disabled` 文件 |
| `POST` | `/admin/rtr/slurm/files/{name}/disable` | 禁用 `.slurm` 文件 |
示例:
```bash
curl http://127.0.0.1:8323/admin/rtr/config \
-H "Authorization: Bearer change-me"
curl -X POST http://127.0.0.1:8323/admin/rtr/config \
-H "Content-Type: application/json" \
-H "Authorization: Bearer change-me" \
-d '{"max_delta": 6, "source_refresh_interval_seconds": 60}'
```
## 调试客户端
`rtr_debug_client` 用于手动发起 Reset Query 或 Serial Query,并观察服务端返回的 PDU。
RTR Client 的 Docker 调试、FRR/BIRD 互通验证和常用排障步骤见 `README.client.md`。
TCP:
```bash
cargo run --bin rtr_debug_client -- 127.0.0.1:323 1 reset
cargo run --bin rtr_debug_client -- 127.0.0.1:323 1 serial 42 100
```
TLS:
```bash
cargo run --bin rtr_debug_client -- \
127.0.0.1:324 1 reset \
--tls \
--ca-cert certs/tls/client-ca.crt \
--server-name localhost \
--client-cert certs/tls/client-good.crt \
--client-key certs/tls/client-good.key
```
SSH:
```bash
cargo run --bin rtr_debug_client -- \
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-key certs/ssh/rtr-client.key \
--ssh-server-key certs/ssh/ssh_host_rsa_key.pub
```
SSH password auth 也已实现;SSH 模式下 `--ssh-key` 和 `--ssh-password` 二选一,同时必须通过 `--ssh-known-hosts` 或 `--ssh-server-key` 做 host key 校验。
更多参数见 `src/bin/rtr_debug_client/README.md`。
## 部署入口
| 用途 | Compose 文件 |
| --- | --- |
| Server | `deploy/server/docker-compose.yml` |
| Server TCP 示例 | `deploy/server/docker-compose.tcp.yml` |
| Server TLS 示例 | `deploy/server/docker-compose.tls.yml` |
| Server SSH 示例 | `deploy/server/docker-compose.ssh.yml` |
| Debug client | `deploy/client/docker-compose.yml` |
| Debug client 多实例 | `deploy/client/docker-compose.clients.yml` |
| FRR client | `deploy/frr/docker-compose.yml` |
| BIRD client | `deploy/bird/docker-compose.yml` |
RTR Client 统一使用说明见 `README.client.md`。
通用操作:
```bash
COMPOSE_FILE=deploy/server/docker-compose.yml
docker compose -f "${COMPOSE_FILE}" up -d --build
docker compose -f "${COMPOSE_FILE}" ps
docker compose -f "${COMPOSE_FILE}" down
```
## 开发说明
- 代码中的 RTR cache 按协议版本维护状态;v0 只包含 Route Origin,v1 包含 Route Origin 和 Router Key,v2 包含 Route Origin、Router Key 和 ASPA。
- 当前 source pipeline 从 CCR 生成 Route Origin 和 ASPA,再可选应用 SLURM。
- `RtrStore::save_cache_state_versioned(...)` 是 cache 状态写入 RocksDB 的核心入口,相关边界测试在 `tests/test_store_boundary.rs`。
- Admin API 的日志 tail 依赖部署入口写出的 stdout/stderr 日志文件;容器入口脚本会把日志写到 `/app/logs`。

View File

@@ -0,0 +1,8 @@
[package]
name = "ours-manifest-bench"
version = "0.1.0"
edition = "2024"
[dependencies]
rpki = { path = "../..", default-features = false }

View File

@@ -0,0 +1,145 @@
use rpki::data_model::manifest::ManifestObject;
use std::hint::black_box;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, Clone)]
struct Config {
sample: Option<String>,
manifest_path: Option<PathBuf>,
iterations: u64,
warmup_iterations: u64,
repeats: u32,
}
fn usage_and_exit() -> ! {
eprintln!(
"Usage:\n ours-manifest-bench (--sample <name> | --manifest <path>) [--iterations N] [--warmup-iterations N] [--repeats N]\n\nExamples:\n cargo run --release -- --sample small-01 --iterations 20000 --warmup-iterations 2000 --repeats 3\n cargo run --release -- --manifest ../../tests/benchmark/selected_der/small-01.mft"
);
std::process::exit(2);
}
fn parse_args() -> Config {
let mut sample: Option<String> = None;
let mut manifest_path: Option<PathBuf> = None;
let mut iterations: u64 = 20_000;
let mut warmup_iterations: u64 = 2_000;
let mut repeats: u32 = 3;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--sample" => sample = Some(args.next().unwrap_or_else(|| usage_and_exit())),
"--manifest" => {
manifest_path = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit())))
}
"--iterations" => {
iterations = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"--warmup-iterations" => {
warmup_iterations = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"--repeats" => {
repeats = args
.next()
.unwrap_or_else(|| usage_and_exit())
.parse()
.unwrap_or_else(|_| usage_and_exit())
}
"-h" | "--help" => usage_and_exit(),
_ => usage_and_exit(),
}
}
if sample.is_none() && manifest_path.is_none() {
usage_and_exit();
}
if sample.is_some() && manifest_path.is_some() {
usage_and_exit();
}
Config {
sample,
manifest_path,
iterations,
warmup_iterations,
repeats,
}
}
fn derive_manifest_path(sample: &str) -> PathBuf {
// Assumes current working directory is `rpki/benchmark/ours_manifest_bench`.
PathBuf::from(format!("../../tests/benchmark/selected_der/{sample}.mft"))
}
fn main() {
let cfg = parse_args();
let manifest_path = cfg
.manifest_path
.clone()
.unwrap_or_else(|| derive_manifest_path(cfg.sample.as_deref().unwrap()));
let bytes = std::fs::read(&manifest_path).unwrap_or_else(|e| {
eprintln!("read manifest fixture failed: {e}; path={}", manifest_path.display());
std::process::exit(1);
});
let decoded_once = ManifestObject::decode_der(&bytes).unwrap_or_else(|e| {
eprintln!("decode failed: {e}; path={}", manifest_path.display());
std::process::exit(1);
});
let file_count = decoded_once.manifest.file_count();
let mut round_ns_per_op: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
let mut round_ops_per_s: Vec<f64> = Vec::with_capacity(cfg.repeats as usize);
for _round in 0..cfg.repeats {
for _ in 0..cfg.warmup_iterations {
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("warmup decode");
black_box(obj);
}
let start = Instant::now();
for _ in 0..cfg.iterations {
let obj = ManifestObject::decode_der(black_box(&bytes)).expect("timed decode");
black_box(obj);
}
let elapsed = start.elapsed();
let ns_per_op = (elapsed.as_secs_f64() * 1e9) / (cfg.iterations as f64);
let ops_per_s = (cfg.iterations as f64) / elapsed.as_secs_f64();
round_ns_per_op.push(ns_per_op);
round_ops_per_s.push(ops_per_s);
}
let avg_ns_per_op = round_ns_per_op.iter().sum::<f64>() / (round_ns_per_op.len() as f64);
let avg_ops_per_s = round_ops_per_s.iter().sum::<f64>() / (round_ops_per_s.len() as f64);
let sample_name = cfg.sample.clone().unwrap_or_else(|| {
manifest_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| manifest_path.display().to_string())
});
let sample_name = sample_name
.strip_suffix(".mft")
.unwrap_or(&sample_name)
.to_string();
println!("fixture: {}", manifest_path.display());
println!();
println!("| sample | avg ns/op | ops/s | file count |");
println!("|---|---:|---:|---:|");
println!(
"| {} | {:.2} | {:.2} | {} |",
sample_name, avg_ns_per_op, avg_ops_per_s, file_count
);
}

View File

@@ -0,0 +1,8 @@
[package]
name = "routinator-object-bench"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
rpki = { version = "=0.19.1", features = ["repository"] }

View File

@@ -0,0 +1,552 @@
use rpki::repository::cert::Cert;
use rpki::repository::crl::Crl;
use rpki::repository::manifest::Manifest;
use rpki::repository::roa::Roa;
use rpki::repository::aspa::Aspa;
use rpki::repository::resources::{AsResources, IpResources};
use std::hint::black_box;
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum ObjType {
Cer,
Crl,
Manifest,
Roa,
Aspa,
}
impl ObjType {
fn parse(s: &str) -> Result<Self, String> {
match s {
"cer" => Ok(Self::Cer),
"crl" => Ok(Self::Crl),
"manifest" => Ok(Self::Manifest),
"roa" => Ok(Self::Roa),
"aspa" => Ok(Self::Aspa),
_ => Err("type must be one of: cer, crl, manifest, roa, aspa".into()),
}
}
fn as_str(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "manifest",
ObjType::Roa => "roa",
ObjType::Aspa => "aspa",
}
}
fn ext(self) -> &'static str {
match self {
ObjType::Cer => "cer",
ObjType::Crl => "crl",
ObjType::Manifest => "mft",
ObjType::Roa => "roa",
ObjType::Aspa => "asa",
}
}
}
#[derive(Clone, Debug)]
struct Sample {
obj_type: ObjType,
name: String,
path: PathBuf,
}
#[derive(Clone, Debug)]
struct Config {
dir: PathBuf,
type_filter: Option<ObjType>,
sample_filter: Option<String>,
fixed_iters: Option<u64>,
warmup_iters: u64,
rounds: u64,
min_round_ms: u64,
max_adaptive_iters: u64,
strict: bool,
cert_inspect: bool,
out_csv: Option<PathBuf>,
out_md: Option<PathBuf>,
}
fn usage_and_exit(err: Option<&str>) -> ! {
if let Some(err) = err {
eprintln!("error: {err}");
eprintln!();
}
eprintln!(
"Usage:\n\
cargo run --release --manifest-path rpki/benchmark/routinator_object_bench/Cargo.toml -- [OPTIONS]\n\
\n\
Options:\n\
--dir <PATH> Fixtures root dir (default: ../../tests/benchmark/selected_der_v2)\n\
--type <cer|crl|manifest|roa|aspa> Filter by type\n\
--sample <NAME> Filter by sample name (e.g. p50)\n\
--iters <N> Fixed iterations per round (optional; otherwise adaptive)\n\
--warmup-iters <N> Warmup iterations (default: 50)\n\
--rounds <N> Rounds (default: 5)\n\
--min-round-ms <MS> Adaptive: minimum round time (default: 200)\n\
--max-iters <N> Adaptive: maximum iters (default: 1_000_000)\n\
--strict <true|false> Strict DER where applicable (default: true)\n\
--cert-inspect Also run Cert::inspect_ca/inspect_ee where applicable (default: false)\n\
--out-csv <PATH> Write CSV output\n\
--out-md <PATH> Write Markdown output\n\
"
);
std::process::exit(2);
}
fn parse_bool(s: &str, name: &str) -> bool {
match s {
"1" | "true" | "TRUE" | "yes" | "YES" => true,
"0" | "false" | "FALSE" | "no" | "NO" => false,
_ => usage_and_exit(Some(&format!("{name} must be true/false"))),
}
}
fn parse_u64(s: &str, name: &str) -> u64 {
s.parse::<u64>()
.unwrap_or_else(|_| usage_and_exit(Some(&format!("{name} must be an integer"))))
}
fn default_samples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/benchmark/selected_der_v2")
}
fn parse_args() -> Config {
let mut dir: PathBuf = default_samples_dir();
let mut type_filter: Option<ObjType> = None;
let mut sample_filter: Option<String> = None;
let mut fixed_iters: Option<u64> = None;
let mut warmup_iters: u64 = 50;
let mut rounds: u64 = 5;
let mut min_round_ms: u64 = 200;
let mut max_adaptive_iters: u64 = 1_000_000;
let mut strict: bool = true;
let mut cert_inspect: bool = false;
let mut out_csv: Option<PathBuf> = None;
let mut out_md: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--dir" => dir = PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None))),
"--type" => {
type_filter = Some(ObjType::parse(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
)
.unwrap_or_else(|e| usage_and_exit(Some(&e))))
}
"--sample" => {
sample_filter = Some(args.next().unwrap_or_else(|| usage_and_exit(None)))
}
"--iters" => {
fixed_iters = Some(parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--iters",
))
}
"--warmup-iters" => {
warmup_iters = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--warmup-iters",
)
}
"--rounds" => {
rounds = parse_u64(&args.next().unwrap_or_else(|| usage_and_exit(None)), "--rounds")
}
"--min-round-ms" => {
min_round_ms = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--min-round-ms",
)
}
"--max-iters" => {
max_adaptive_iters = parse_u64(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--max-iters",
)
}
"--strict" => {
strict = parse_bool(
&args.next().unwrap_or_else(|| usage_and_exit(None)),
"--strict",
)
}
"--cert-inspect" => cert_inspect = true,
"--out-csv" => out_csv = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
"--out-md" => out_md = Some(PathBuf::from(args.next().unwrap_or_else(|| usage_and_exit(None)))),
"-h" | "--help" => usage_and_exit(None),
_ => usage_and_exit(Some(&format!("unknown argument: {arg}"))),
}
}
if warmup_iters == 0 {
usage_and_exit(Some("--warmup-iters must be > 0"));
}
if rounds == 0 {
usage_and_exit(Some("--rounds must be > 0"));
}
if min_round_ms == 0 {
usage_and_exit(Some("--min-round-ms must be > 0"));
}
if max_adaptive_iters == 0 {
usage_and_exit(Some("--max-iters must be > 0"));
}
if let Some(n) = fixed_iters {
if n == 0 {
usage_and_exit(Some("--iters must be > 0"));
}
}
Config {
dir,
type_filter,
sample_filter,
fixed_iters,
warmup_iters,
rounds,
min_round_ms,
max_adaptive_iters,
strict,
cert_inspect,
out_csv,
out_md,
}
}
fn read_samples(root: &Path) -> Vec<Sample> {
let mut out = Vec::new();
for obj_type in [
ObjType::Cer,
ObjType::Crl,
ObjType::Manifest,
ObjType::Roa,
ObjType::Aspa,
] {
let dir = root.join(obj_type.as_str());
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for ent in rd.flatten() {
let path = ent.path();
if path.extension().and_then(|s| s.to_str()) != Some(obj_type.ext()) {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
out.push(Sample { obj_type, name, path });
}
}
out.sort_by(|a, b| a.obj_type.cmp(&b.obj_type).then_with(|| a.name.cmp(&b.name)));
out
}
fn choose_iters_adaptive<F: FnMut()>(mut op: F, min_round_ms: u64, max_iters: u64) -> u64 {
let min_secs = (min_round_ms as f64) / 1e3;
let mut iters: u64 = 1;
loop {
let start = Instant::now();
for _ in 0..iters {
op();
}
let elapsed = start.elapsed().as_secs_f64();
if elapsed >= min_secs {
return iters;
}
if iters >= max_iters {
return iters;
}
iters = (iters.saturating_mul(2)).min(max_iters);
}
}
fn count_ip(res: &IpResources) -> u64 {
if res.is_inherited() {
return 1;
}
let Ok(blocks) = res.to_blocks() else {
return 0;
};
blocks.iter().count() as u64
}
fn count_as(res: &AsResources) -> u64 {
if res.is_inherited() {
return 1;
}
let Ok(blocks) = res.to_blocks() else {
return 0;
};
blocks.iter().count() as u64
}
fn complexity(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) -> u64 {
match obj_type {
ObjType::Cer => {
let cert = Cert::decode(bytes).expect("decode cert");
if cert_inspect {
if cert.is_ca() {
cert.inspect_ca(strict).expect("inspect ca");
} else {
cert.inspect_ee(strict).expect("inspect ee");
}
}
count_ip(cert.v4_resources())
.saturating_add(count_ip(cert.v6_resources()))
.saturating_add(count_as(cert.as_resources()))
}
ObjType::Crl => {
let crl = Crl::decode(bytes).expect("decode crl");
crl.revoked_certs().iter().count() as u64
}
ObjType::Manifest => {
let mft = Manifest::decode(bytes, strict).expect("decode manifest");
if cert_inspect {
mft.cert().inspect_ee(strict).expect("inspect ee");
}
mft.content().len() as u64
}
ObjType::Roa => {
let roa = Roa::decode(bytes, strict).expect("decode roa");
if cert_inspect {
roa.cert().inspect_ee(strict).expect("inspect ee");
}
roa.content().iter().count() as u64
}
ObjType::Aspa => {
let asa = Aspa::decode(bytes, strict).expect("decode aspa");
if cert_inspect {
asa.cert().inspect_ee(strict).expect("inspect ee");
}
asa.content().provider_as_set().len() as u64
}
}
}
fn decode_profile(obj_type: ObjType, bytes: &[u8], strict: bool, cert_inspect: bool) {
match obj_type {
ObjType::Cer => {
let cert = Cert::decode(black_box(bytes)).expect("decode cert");
if cert_inspect {
if cert.is_ca() {
cert.inspect_ca(strict).expect("inspect ca");
} else {
cert.inspect_ee(strict).expect("inspect ee");
}
}
black_box(cert);
}
ObjType::Crl => {
let crl = Crl::decode(black_box(bytes)).expect("decode crl");
black_box(crl);
}
ObjType::Manifest => {
let mft = Manifest::decode(black_box(bytes), strict).expect("decode manifest");
if cert_inspect {
mft.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(mft);
}
ObjType::Roa => {
let roa = Roa::decode(black_box(bytes), strict).expect("decode roa");
if cert_inspect {
roa.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(roa);
}
ObjType::Aspa => {
let asa = Aspa::decode(black_box(bytes), strict).expect("decode aspa");
if cert_inspect {
asa.cert().inspect_ee(strict).expect("inspect ee");
}
black_box(asa);
}
}
}
#[derive(Clone, Debug)]
struct ResultRow {
obj_type: String,
sample: String,
size_bytes: usize,
complexity: u64,
avg_ns_per_op: f64,
ops_per_sec: f64,
}
fn render_markdown(title: &str, rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str(&format!("# {title}\n\n"));
out.push_str("| type | sample | size_bytes | complexity | avg ns/op | ops/s |\n");
out.push_str("|---|---|---:|---:|---:|---:|\n");
for r in rows {
out.push_str(&format!(
"| {} | {} | {} | {} | {:.2} | {:.2} |\n",
r.obj_type, r.sample, r.size_bytes, r.complexity, r.avg_ns_per_op, r.ops_per_sec
));
}
out
}
fn render_csv(rows: &[ResultRow]) -> String {
let mut out = String::new();
out.push_str("type,sample,size_bytes,complexity,avg_ns_per_op,ops_per_sec\n");
for r in rows {
let sample = r.sample.replace('"', "\"\"");
out.push_str(&format!(
"{},{},{},{},{:.6},{:.6}\n",
r.obj_type,
format!("\"{}\"", sample),
r.size_bytes,
r.complexity,
r.avg_ns_per_op,
r.ops_per_sec
));
}
out
}
fn create_parent_dirs(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap_or_else(|e| {
panic!("create_dir_all {}: {e}", parent.display());
});
}
}
fn write_text_file(path: &Path, content: &str) {
create_parent_dirs(path);
std::fs::write(path, content).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
fn main() {
let cfg = parse_args();
let mut samples = read_samples(&cfg.dir);
if samples.is_empty() {
usage_and_exit(Some(&format!(
"no samples found under: {}",
cfg.dir.display()
)));
}
if let Some(t) = cfg.type_filter {
samples.retain(|s| s.obj_type == t);
if samples.is_empty() {
usage_and_exit(Some(&format!("no sample matched --type {}", t.as_str())));
}
}
if let Some(filter) = cfg.sample_filter.as_deref() {
samples.retain(|s| s.name == filter);
if samples.is_empty() {
usage_and_exit(Some(&format!("no sample matched --sample {filter}")));
}
}
println!("# Routinator baseline (rpki crate) decode benchmark (selected_der_v2)");
println!();
println!("- dir: {}", cfg.dir.display());
println!("- strict: {}", cfg.strict);
println!("- cert_inspect: {}", cfg.cert_inspect);
if let Some(t) = cfg.type_filter {
println!("- type: {}", t.as_str());
}
if let Some(s) = cfg.sample_filter.as_deref() {
println!("- sample: {}", s);
}
if let Some(n) = cfg.fixed_iters {
println!("- iters: {} (fixed)", n);
} else {
println!(
"- warmup: {} iters, rounds: {}, min_round: {}ms (adaptive iters, max {})",
cfg.warmup_iters, cfg.rounds, cfg.min_round_ms, cfg.max_adaptive_iters
);
}
if let Some(p) = cfg.out_csv.as_ref() {
println!("- out_csv: {}", p.display());
}
if let Some(p) = cfg.out_md.as_ref() {
println!("- out_md: {}", p.display());
}
println!();
println!("| type | sample | size_bytes | complexity | avg ns/op | ops/s |");
println!("|---|---|---:|---:|---:|---:|");
let mut rows: Vec<ResultRow> = Vec::with_capacity(samples.len());
for sample in &samples {
let bytes = std::fs::read(&sample.path)
.unwrap_or_else(|e| panic!("read {}: {e}", sample.path.display()));
let size_bytes = bytes.len();
let complexity = complexity(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
for _ in 0..cfg.warmup_iters {
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
}
let mut per_round_ns_per_op = Vec::with_capacity(cfg.rounds as usize);
for _round in 0..cfg.rounds {
let iters = if let Some(n) = cfg.fixed_iters {
n
} else {
choose_iters_adaptive(
|| decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect),
cfg.min_round_ms,
cfg.max_adaptive_iters,
)
};
let start = Instant::now();
for _ in 0..iters {
decode_profile(sample.obj_type, bytes.as_slice(), cfg.strict, cfg.cert_inspect);
}
let elapsed = start.elapsed();
let total_ns = elapsed.as_secs_f64() * 1e9;
per_round_ns_per_op.push(total_ns / (iters as f64));
}
let avg_ns = per_round_ns_per_op.iter().sum::<f64>() / (per_round_ns_per_op.len() as f64);
let ops_per_sec = 1e9_f64 / avg_ns;
println!(
"| {} | {} | {} | {} | {:.2} | {:.2} |",
sample.obj_type.as_str(),
sample.name,
size_bytes,
complexity,
avg_ns,
ops_per_sec
);
rows.push(ResultRow {
obj_type: sample.obj_type.as_str().to_string(),
sample: sample.name.clone(),
size_bytes,
complexity,
avg_ns_per_op: avg_ns,
ops_per_sec,
});
}
if let Some(path) = cfg.out_md.as_ref() {
let md = render_markdown(
"Routinator baseline (rpki crate) decode+inspect (selected_der_v2)",
&rows,
);
write_text_file(path, &md);
eprintln!("Wrote {}", path.display());
}
if let Some(path) = cfg.out_csv.as_ref() {
let csv = render_csv(&rows);
write_text_file(path, &csv);
eprintln!("Wrote {}", path.display());
}
}

View File

@@ -1,23 +0,0 @@
{
"slurmVersion": 2,
"validationOutputFilters": {
"prefixFilters": [
{
"prefix": "24.0.0.0/8",
"comment": "Filter many VRPs in current CCR sample"
}
],
"bgpsecFilters": [],
"aspaFilters": [
{
"customerAsn": 80,
"comment": "Filter one ASPA known to exist in current CCR sample"
}
]
},
"locallyAddedAssertions": {
"prefixAssertions": [],
"bgpsecAssertions": [],
"aspaAssertions": []
}
}

View File

@@ -1,33 +0,0 @@
# Build-time image knob.
RPKI_BIRD_VERSION=3.2.1
# TCP mode target endpoint.
RPKI_BIRD_RPKI_HOST=rpki-rtr
RPKI_BIRD_RPKI_PORT=323
# SSH mode target endpoint.
RPKI_BIRD_SSH_RPKI_HOST=rpki-rtr
RPKI_RTR_SSH_PORT=22
# Config template paths in container.
RPKI_BIRD_CONFIG_TEMPLATE_PATH=/config/bird.conf.template
RPKI_BIRD_SSH_CONFIG_TEMPLATE_PATH=/config/bird.conf.ssh.template
# Observation and output knobs.
RPKI_BIRD_OBSERVE_PROTO=rpki_tcp
RPKI_BIRD_OBSERVE_MODE=interval
RPKI_BIRD_OBSERVE_DEBOUNCE_SECS=1
RPKI_BIRD_OBSERVE_INTERVAL=30
RPKI_BIRD_OBSERVE_ASPA_TABLE=rtr_aspa
RPKI_BIRD_OBSERVE_ROA4_TABLE=rtr_roa_v4
RPKI_BIRD_OBSERVE_ROA6_TABLE=rtr_roa_v6
RPKI_BIRD_OBSERVE_ASPA_COUNT=3
RPKI_BIRD_OBSERVE_ROA4_COUNT=3
RPKI_BIRD_OBSERVE_ROA6_COUNT=3
RPKI_BIRD_SHOW_ASPA=1
RPKI_BIRD_SHOW_ROA4=1
RPKI_BIRD_SHOW_ROA6=1
# Host volume mounts.
RPKI_BIRD_LOG_HOST_DIR=../../logs/bird
RPKI_BIRD_SSH_CERTS_HOST_DIR=../../certs/ssh

View File

@@ -1,56 +0,0 @@
FROM debian:bookworm-slim AS builder
ARG BIRD_VERSION=3.2.1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
bison \
flex \
m4 \
perl \
ca-certificates \
wget \
xz-utils \
libreadline-dev \
libncurses-dev \
libssh-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
RUN wget -O bird.tar.gz "https://bird.nic.cz/download/bird-${BIRD_VERSION}.tar.gz" \
&& tar -xzf bird.tar.gz \
&& mv "bird-${BIRD_VERSION}" bird
WORKDIR /build/bird
RUN ./configure \
--prefix=/usr \
--sysconfdir=/etc/bird \
--localstatedir=/run \
&& make -j"$(nproc)" \
&& make install
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
gettext-base \
tzdata \
netcat-openbsd \
libreadline8 \
libncurses6 \
libtinfo6 \
libssh-4 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/sbin/bird /usr/sbin/bird
COPY --from=builder /usr/sbin/birdc /usr/sbin/birdc
COPY --from=builder /etc/bird /etc/bird
ENV TZ=Asia/Shanghai
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /run/bird
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,90 +0,0 @@
# BIRD Minimal RTR Client Config
This folder provides a minimal BIRD setup for black-box interop testing
against this repository's RTR server defaults.
Server defaults in this repo:
- TCP: `0.0.0.0:323`
- SSH: `0.0.0.0:22` (when enabled on server)
## Files
- `Dockerfile`: builds a minimal BIRD2 runtime image.
- `bird.conf.example`: sample `/etc/bird/bird.conf`.
- `bird.conf.ssh.example`: sample SSH transport `/etc/bird/bird.conf`.
- `entrypoint.sh`: starts BIRD in foreground mode.
- `docker-compose.yml`: one-click local TCP test client.
- `docker-compose.ssh.yml`: compose override for SSH transport.
By default, the container uses event-driven observation and prints snapshots
only when BIRD reports RPKI-related changes.
## Docker quick start
From repository root:
```bash
docker compose -f deploy/bird/docker-compose.yml up --build
```
Use another terminal to inspect:
```bash
docker logs -f bird-rpki-client
```
If protocol state is `up`, the RTR client path is working.
Detached mode:
```bash
docker compose -f deploy/bird/docker-compose.yml up -d --build
docker logs -f bird-rpki-client
```
Stop:
```bash
docker compose -f deploy/bird/docker-compose.yml down
```
## SSH quick start
Start server in SSH mode first:
```bash
docker compose -f deploy/server/docker-compose.ssh.yml up -d --build
```
Start BIRD client with SSH override:
```bash
docker compose \
-f deploy/bird/docker-compose.yml \
-f deploy/bird/docker-compose.ssh.yml \
up --build
```
In detached mode, observe with:
```bash
docker logs -f bird-rpki-client
```
## Notes
- This setup renders `bird.conf.template` at startup and uses `RPKI_HOST` / `RPKI_PORT`
for the RTR endpoint.
- `network_mode: host` expects your RTR server to be reachable at
`host.docker.internal:323` from the container.
- Observation is controlled by env vars:
`OBSERVE_MODE` (`event` by default, `interval` as fallback),
`OBSERVE_DEBOUNCE_SECS` (default `1`),
`OBSERVE_INTERVAL` (seconds, used when `OBSERVE_MODE=interval`),
and `OBSERVE_PROTO`.
- SSH mode mounts `../../certs/ssh` into `/config/ssh` and expects:
`rtr-client.key` and `ssh_host_rsa_key.pub`.
- Entrypoint auto-generates `/run/bird/known_hosts` from
`/config/ssh/ssh_host_rsa_key.pub` for BIRD SSH host-key verification.
- If your environment does not support Docker host networking, switch to a
bridge network and replace `remote` addresses accordingly.

View File

@@ -1,80 +0,0 @@
# BIRD 最小化 RTR 客户端配置
本目录提供最小化 BIRD 配置,用于与本仓库 RTR Server 做黑盒互通测试。
本仓库默认 RTR 监听地址:
- TCP: `0.0.0.0:323`
- SSH: `0.0.0.0:22`(仅在 server SSH 模式启用时)
## 文件说明
- `Dockerfile`: 构建最小 BIRD 运行镜像(包含 SSH transport 支持)。
- `bird.conf.example`: `/etc/bird/bird.conf` 的 TCP 示例。
- `bird.conf.ssh.example`: `/etc/bird/bird.conf` 的 SSH transport 示例。
- `entrypoint.sh`: 前台启动 BIRD,并周期输出协议快照。
- `docker-compose.yml`: TCP 一键启动。
- `docker-compose.ssh.yml`: SSH 覆盖配置。
容器默认每 30 秒向日志输出一次 RPKI 协议状态快照。
## Docker 快速启动(TCP)
在仓库根目录执行:
```bash
docker compose -f deploy/bird/docker-compose.yml up --build
```
另开终端查看日志:
```bash
docker logs -f bird-rpki-client
```
如果协议状态显示 `up`,说明 RTR 客户端链路正常。
后台模式:
```bash
docker compose -f deploy/bird/docker-compose.yml up -d --build
docker logs -f bird-rpki-client
```
停止:
```bash
docker compose -f deploy/bird/docker-compose.yml down
```
## SSH 快速启动
先启动 server 的 SSH 模式:
```bash
docker compose -f deploy/server/docker-compose.ssh.yml up -d --build
```
再启动 BIRD SSH 客户端:
```bash
docker compose \
-f deploy/bird/docker-compose.yml \
-f deploy/bird/docker-compose.ssh.yml \
up --build
```
查看日志:
```bash
docker logs -f bird-rpki-client
```
## 说明
- 当前 compose 使用 `network_mode: host`,默认通过 `host.docker.internal` 访问 server。
- 观测频率由环境变量控制:`OBSERVE_INTERVAL`(秒,默认 `30`)和 `OBSERVE_PROTO`。
- SSH 模式会将 `../../certs/ssh` 挂载到容器 `/config/ssh`,并使用:
`rtr-client.key`、`ssh_host_rsa_key.pub`。
- 入口脚本会基于 `/config/ssh/ssh_host_rsa_key.pub` 自动生成
`/run/bird/known_hosts`,用于 BIRD 的 SSH 主机密钥校验。
- 如果你运行在 Docker Desktop(非 Linux 原生 host network 场景),建议改为自定义 bridge 网络并将 `remote` 地址改成可达的 server 容器名或宿主机地址。

View File

@@ -1,31 +0,0 @@
log stderr all;
debug protocols all;
router id 192.0.2.2;
roa4 table rtr_roa_v4;
roa6 table rtr_roa_v6;
aspa table rtr_aspa;
protocol device {
}
protocol rpki rpki_ssh {
roa4 { table rtr_roa_v4; };
roa6 { table rtr_roa_v6; };
aspa { table rtr_aspa; };
remote "host.docker.internal" port 22;
min version 2;
max version 2;
refresh 3600;
retry 600;
expire 7200;
transport ssh {
user "rpki-rtr";
bird private key "/config/ssh/rtr-client.key";
remote public key "/run/bird/known_hosts";
};
}

View File

@@ -1,28 +0,0 @@
log stderr all;
router id 192.0.2.2;
roa4 table rtr_roa_v4;
roa6 table rtr_roa_v6;
aspa table rtr_aspa;
protocol device {
}
protocol rpki rpki_tcp {
roa4 { table rtr_roa_v4; };
roa6 { table rtr_roa_v6; };
aspa { table rtr_aspa; };
remote "host.docker.internal" port 323;
min version 2;
max version 2;
refresh 3600;
retry 600;
expire 7200;
transport tcp {
authentication none;
};
}

View File

@@ -1,28 +0,0 @@
log stderr all;
router id 192.0.2.2;
roa4 table rtr_roa_v4;
roa6 table rtr_roa_v6;
aspa table rtr_aspa;
protocol device {
}
protocol rpki rpki_tcp {
roa4 { table rtr_roa_v4; };
roa6 { table rtr_roa_v6; };
aspa { table rtr_aspa; };
remote "host.docker.internal" port 323;
min version 2;
max version 2;
refresh 3600;
retry 600;
expire 7200;
transport tcp {
authentication none;
};
}

View File

@@ -1,30 +0,0 @@
log stderr all;
router id 192.0.2.2;
roa4 table rtr_roa_v4;
roa6 table rtr_roa_v6;
aspa table rtr_aspa;
protocol device {
}
protocol rpki rpki_ssh {
roa4 { table rtr_roa_v4; };
roa6 { table rtr_roa_v6; };
aspa { table rtr_aspa; };
remote "host.docker.internal" port 22;
min version 2;
max version 2;
refresh 3600;
retry 600;
expire 7200;
transport ssh {
user "rpki-rtr";
bird private key "/config/ssh/rtr-client.key";
remote public key "/run/bird/known_hosts";
};
}

View File

@@ -1,30 +0,0 @@
log stderr all;
router id 192.0.2.2;
roa4 table rtr_roa_v4;
roa6 table rtr_roa_v6;
aspa table rtr_aspa;
protocol device {
}
protocol rpki rpki_ssh {
roa4 { table rtr_roa_v4; };
roa6 { table rtr_roa_v6; };
aspa { table rtr_aspa; };
remote "${RPKI_HOST}" port ${RPKI_PORT};
min version 2;
max version 2;
refresh 3600;
retry 600;
expire 7200;
transport ssh {
user "rpki-rtr";
bird private key "/config/ssh/rtr-client.key";
remote public key "/run/bird/known_hosts";
};
}

View File

@@ -1,28 +0,0 @@
log stderr all;
router id 192.0.2.2;
roa4 table rtr_roa_v4;
roa6 table rtr_roa_v6;
aspa table rtr_aspa;
protocol device {
}
protocol rpki rpki_tcp {
roa4 { table rtr_roa_v4; };
roa6 { table rtr_roa_v6; };
aspa { table rtr_aspa; };
remote "${RPKI_HOST}" port ${RPKI_PORT};
min version 2;
max version 2;
refresh 3600;
retry 600;
expire 7200;
transport tcp {
authentication none;
};
}

View File

@@ -1,17 +0,0 @@
services:
bird-rpki-client:
environment:
BIRD_CONFIG_TEMPLATE_PATH: "${RPKI_BIRD_SSH_CONFIG_TEMPLATE_PATH:-/config/bird.conf.ssh.template}"
RPKI_HOST: "${RPKI_BIRD_SSH_RPKI_HOST:-rpki-rtr}"
RPKI_PORT: "${RPKI_RTR_SSH_PORT:-22}"
OBSERVE_PROTO: "rpki_ssh"
volumes:
- ./bird.conf.ssh.template:/config/bird.conf.ssh.template:ro
- ${RPKI_BIRD_SSH_CERTS_HOST_DIR:-../../certs/ssh}:/config/ssh:ro
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,41 +0,0 @@
services:
bird-rpki-client:
build:
context: .
dockerfile: Dockerfile
args:
BIRD_VERSION: "${RPKI_BIRD_VERSION:-3.2.1}"
container_name: bird-rpki-client
restart: unless-stopped
environment:
BIRD_CONFIG_TEMPLATE_PATH: "${RPKI_BIRD_CONFIG_TEMPLATE_PATH:-/config/bird.conf.template}"
RPKI_HOST: "${RPKI_BIRD_RPKI_HOST:-rpki-rtr}"
RPKI_PORT: "${RPKI_BIRD_RPKI_PORT:-323}"
OBSERVE_PROTO: "${RPKI_BIRD_OBSERVE_PROTO:-rpki_tcp}"
OBSERVE_MODE: "${RPKI_BIRD_OBSERVE_MODE:-interval}"
OBSERVE_DEBOUNCE_SECS: "${RPKI_BIRD_OBSERVE_DEBOUNCE_SECS:-1}"
OBSERVE_INTERVAL: "${RPKI_BIRD_OBSERVE_INTERVAL:-30}"
OBSERVE_ASPA_TABLE: "${RPKI_BIRD_OBSERVE_ASPA_TABLE:-rtr_aspa}"
OBSERVE_ROA4_TABLE: "${RPKI_BIRD_OBSERVE_ROA4_TABLE:-rtr_roa_v4}"
OBSERVE_ROA6_TABLE: "${RPKI_BIRD_OBSERVE_ROA6_TABLE:-rtr_roa_v6}"
OBSERVE_ASPA_COUNT: "${RPKI_BIRD_OBSERVE_ASPA_COUNT:-3}"
OBSERVE_ROA4_COUNT: "${RPKI_BIRD_OBSERVE_ROA4_COUNT:-3}"
OBSERVE_ROA6_COUNT: "${RPKI_BIRD_OBSERVE_ROA6_COUNT:-3}"
SHOW_ASPA: "${RPKI_BIRD_SHOW_ASPA:-1}"
SHOW_ROA4: "${RPKI_BIRD_SHOW_ROA4:-1}"
SHOW_ROA6: "${RPKI_BIRD_SHOW_ROA6:-1}"
volumes:
- ./bird.conf.template:/config/bird.conf.template:ro
- ${RPKI_BIRD_LOG_HOST_DIR:-../../logs/bird}:/app/logs
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,218 +0,0 @@
#!/bin/sh
set -eu
mkdir -p /run/bird
SOCK_PATH="/run/bird/bird.ctl"
PROTO="${OBSERVE_PROTO:-rpki_tcp}"
INTERVAL="${OBSERVE_INTERVAL:-30}"
MODE="${OBSERVE_MODE:-event}"
DEBOUNCE_SECS="${OBSERVE_DEBOUNCE_SECS:-1}"
RPKI_HOST="${RPKI_HOST:-host.docker.internal}"
RPKI_PORT="${RPKI_PORT:-323}"
BIRD_CONFIG_PATH="${BIRD_CONFIG_PATH:-/config/bird.conf}"
BIRD_CONFIG_TEMPLATE_PATH="${BIRD_CONFIG_TEMPLATE_PATH:-/config/bird.conf.template}"
ASPA_TABLE="${OBSERVE_ASPA_TABLE:-rtr_aspa}"
ROA4_TABLE="${OBSERVE_ROA4_TABLE:-rtr_roa_v4}"
ROA6_TABLE="${OBSERVE_ROA6_TABLE:-rtr_roa_v6}"
ASPA_COUNT="${OBSERVE_ASPA_COUNT:-3}"
ROA4_COUNT="${OBSERVE_ROA4_COUNT:-3}"
ROA6_COUNT="${OBSERVE_ROA6_COUNT:-3}"
SHOW_ASPA="${SHOW_ASPA:-1}"
SHOW_ROA4="${SHOW_ROA4:-1}"
SHOW_ROA6="${SHOW_ROA6:-1}"
SSH_HOST_PUBKEY_PATH="${SSH_HOST_PUBKEY_PATH:-/config/ssh/ssh_host_rsa_key.pub}"
SSH_KNOWN_HOSTS_PATH="${SSH_KNOWN_HOSTS_PATH:-/run/bird/known_hosts}"
LOG_DIR="${LOG_DIR:-/app/logs}"
LOG_NAME="${LOG_NAME:-${HOSTNAME:-bird-rpki-client}}"
STDOUT_LOG="${LOG_DIR}/${LOG_NAME}.stdout.log"
STDERR_LOG="${LOG_DIR}/${LOG_NAME}.stderr.log"
mkdir -p "$LOG_DIR"
exec >>"$STDOUT_LOG" 2>>"$STDERR_LOG"
if [ -r "$BIRD_CONFIG_TEMPLATE_PATH" ]; then
RENDERED_CONFIG_PATH="/run/bird/bird.generated.conf"
envsubst '${RPKI_HOST} ${RPKI_PORT}' < "$BIRD_CONFIG_TEMPLATE_PATH" > "$RENDERED_CONFIG_PATH"
BIRD_CONFIG_PATH="$RENDERED_CONFIG_PATH"
echo "[entrypoint] rendered config : $BIRD_CONFIG_TEMPLATE_PATH -> $BIRD_CONFIG_PATH"
fi
ensure_ssh_known_hosts() {
if [ -s "$SSH_KNOWN_HOSTS_PATH" ]; then
return
fi
if [ ! -r "$SSH_HOST_PUBKEY_PATH" ]; then
echo "[entrypoint] WARNING: SSH host key file not found: $SSH_HOST_PUBKEY_PATH"
return
fi
set -- $(awk 'NF >= 2 { print $1, $2; exit }' "$SSH_HOST_PUBKEY_PATH")
if [ $# -ne 2 ]; then
echo "[entrypoint] WARNING: invalid SSH host key format in $SSH_HOST_PUBKEY_PATH"
return
fi
key_type="$1"
key_data="$2"
if echo "$key_type" | grep -q '^ssh-'; then
{
echo "$RPKI_HOST $key_type $key_data"
echo "[$RPKI_HOST]:$RPKI_PORT $key_type $key_data"
} > "$SSH_KNOWN_HOSTS_PATH"
else
cp "$SSH_HOST_PUBKEY_PATH" "$SSH_KNOWN_HOSTS_PATH"
fi
chmod 600 "$SSH_KNOWN_HOSTS_PATH" || true
echo "[entrypoint] generated known_hosts: $SSH_KNOWN_HOSTS_PATH"
}
print_first_n_objects() {
table_name="$1"
max_objects="$2"
birdc -s "$SOCK_PATH" show route table "$table_name" all 2>/dev/null | awk -v max="$max_objects" '
BEGIN {
count = 0
}
# 直接跳过空行
/^[[:space:]]*$/ {
next
}
# 保留 birdc 的表头
/^BIRD / {
print
next
}
/^Table / {
print
next
}
# 非缩进且不是表头,视为一个新对象的开始
/^[^[:space:]]/ {
count++
if (count > max) {
exit
}
print
next
}
# 缩进内容,只有在已进入前 max 个对象时才打印
{
if (count > 0 && count <= max) {
print
}
}
' || true
}
print_snapshot() {
echo "==== $(date +"%Y-%m-%dT%H:%M:%S%:z") RPKI snapshot ($PROTO) ===="
birdc -s "$SOCK_PATH" show protocols all "$PROTO" || true
if [ "$SHOW_ASPA" = "1" ]; then
echo "---- ASPA table ($ASPA_TABLE, first ${ASPA_COUNT} objects) ----"
print_first_n_objects "$ASPA_TABLE" "$ASPA_COUNT"
fi
if [ "$SHOW_ROA4" = "1" ]; then
echo "---- ROA4 table ($ROA4_TABLE, first ${ROA4_COUNT} objects) ----"
print_first_n_objects "$ROA4_TABLE" "$ROA4_COUNT"
fi
if [ "$SHOW_ROA6" = "1" ]; then
echo "---- ROA6 table ($ROA6_TABLE, first ${ROA6_COUNT} objects) ----"
print_first_n_objects "$ROA6_TABLE" "$ROA6_COUNT"
fi
}
is_rpki_related_event() {
line="$1"
echo "$line" | grep -Eiq "$PROTO|$ASPA_TABLE|$ROA4_TABLE|$ROA6_TABLE|rpki|rtr"
}
echo "[entrypoint] starting bird"
echo "[entrypoint] config : $BIRD_CONFIG_PATH"
echo "[entrypoint] observe proto : $PROTO"
echo "[entrypoint] observe mode : $MODE"
echo "[entrypoint] observe interval : $INTERVAL (used when mode=interval)"
echo "[entrypoint] debounce secs : $DEBOUNCE_SECS (used when mode=event)"
echo "[entrypoint] target : $RPKI_HOST:$RPKI_PORT"
echo "[entrypoint] show aspa : $SHOW_ASPA ($ASPA_TABLE, first $ASPA_COUNT objects)"
echo "[entrypoint] show roa4 : $SHOW_ROA4 ($ROA4_TABLE, first $ROA4_COUNT objects)"
echo "[entrypoint] show roa6 : $SHOW_ROA6 ($ROA6_TABLE, first $ROA6_COUNT objects)"
if [ "$PROTO" = "rpki_ssh" ] || grep -q 'transport[[:space:]]\+ssh' "$BIRD_CONFIG_PATH"; then
ensure_ssh_known_hosts
fi
if nc -zvw3 "$RPKI_HOST" "$RPKI_PORT"; then
echo "[entrypoint] TCP connectivity to $RPKI_HOST:$RPKI_PORT OK"
else
echo "[entrypoint] WARNING: cannot connect to $RPKI_HOST:$RPKI_PORT before BIRD starts"
fi
bird -f -c "$BIRD_CONFIG_PATH" -s "$SOCK_PATH" &
BIRD_PID="$!"
sleep 1
case "$INTERVAL" in
''|*[!0-9]*)
INTERVAL=0
;;
esac
case "$MODE" in
event)
case "$DEBOUNCE_SECS" in
''|*[!0-9]*)
DEBOUNCE_SECS=1
;;
esac
echo "[entrypoint] waiting for BIRD monitor events and only printing on RPKI-related changes"
LAST_PRINT_TS=0
if birdc -s "$SOCK_PATH" monitor all 2>/dev/null | while IFS= read -r line; do
[ -n "$line" ] || continue
if ! is_rpki_related_event "$line"; then
continue
fi
now_ts="$(date +%s)"
if [ $((now_ts - LAST_PRINT_TS)) -lt "$DEBOUNCE_SECS" ]; then
continue
fi
LAST_PRINT_TS="$now_ts"
echo "[monitor] $line"
print_snapshot
done; then
:
else
echo "[entrypoint] WARNING: birdc monitor failed, fallback to interval polling"
MODE="interval"
fi
;;
esac
if [ "$MODE" = "interval" ] && [ "$INTERVAL" -gt 0 ]; then
while kill -0 "$BIRD_PID" 2>/dev/null; do
print_snapshot
sleep "$INTERVAL"
done
fi
wait "$BIRD_PID"

View File

@@ -1,32 +0,0 @@
# Target RTR server addresses for client compose files
# RPKI_RTR_CLIENT_IMAGE=rpki-rtr-debug-client-arm64:v1.2
RPKI_RTR_CLIENT_IMAGE=rpki-rtr-debug-client-amd64:v1.2
# TCP example: 10.0.0.12:323
# TLS example: rpki.example.com:324
# SSH example: 10.0.0.12:22
RPKI_RTR_TCP_SERVER_ADDR=rpki-rtr:323
RPKI_RTR_TLS_SERVER_ADDR=rpki-rtr:324
RPKI_RTR_SSH_SERVER_ADDR=rpki-rtr:22
# RTR protocol version used as client command second argument (supported: 0,1,2)
RPKI_RTR_PROTOCOL_VERSION=2
# TLS server name used by --server-name in TLS mode
# Must match server certificate SAN dNSName.
RPKI_RTR_TLS_SERVER_NAME=localhost
RPKI_RTR_TLS_CA_CERT_PATH=/app/certs/client-ca.crt
RPKI_RTR_TLS_CLIENT_CERT_PATH=/app/certs/client-good.crt
RPKI_RTR_TLS_CLIENT_KEY_PATH=/app/certs/client-good.key
RPKI_RTR_TLS_CERTS_HOST_DIR=../../certs/tls
# Shared client logs mount on host.
RPKI_RTR_CLIENT_LOG_HOST_DIR=../../logs/client
# SSH mode examples:
# RPKI_RTR_SSH_SERVER_ADDR=10.0.0.12:2222
RPKI_RTR_CLIENT_KEYS_VOLUME=../../certs/ssh:/app/certs:ro
RPKI_RTR_CLIENT_KEY_PATH=/app/certs/rtr-client.key
RPKI_RTR_SSH_SERVER_PUBKEY_PATH=/app/certs/ssh_host_rsa_key.pub
RPKI_RTR_SSH_USERNAME=rpki-rtr
RPKI_RTR_SSH_PASSWORD=

View File

@@ -1,26 +0,0 @@
FROM rust:1.89-bookworm AS builder
WORKDIR /build
RUN apt-get update \
&& apt-get install -y --no-install-recommends clang libclang-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --bin rtr_debug_client
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV TZ=Asia/Shanghai
COPY --from=builder /build/target/release/rtr_debug_client /usr/local/bin/rtr_debug_client
COPY --chmod=755 deploy/client/entrypoint.sh /usr/local/bin/rtr-debug-client-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/rtr-debug-client-entrypoint.sh"]

View File

@@ -1,237 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
MODE="${MODE:-tcp}"
ACTION="up"
DETACH="${DETACH:-1}"
BUILD="${BUILD:-1}"
CLIENTS="${CLIENTS:-single}"
TARGET_PLATFORM="${TARGET_PLATFORM:-linux/arm64}"
CLIENT_IMAGE="${CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}}"
BUILDX_PUSH="${BUILDX_PUSH:-0}"
BUILDX_BUILDER="${BUILDX_BUILDER:-}"
SKIP_BASE_IMAGE_PULL="${SKIP_BASE_IMAGE_PULL:-0}"
usage() {
cat <<'EOF'
Usage:
deploy/client/build-run.sh [action] [options]
Actions:
up Build and run debug client (default)
build Build debug client image
buildx Build debug client image with docker buildx
rebuild Rebuild image without cache, then run client
down|stop Stop client compose stack
restart Stop, then build and run client
logs Follow client logs
ps Show client compose status
Options:
--mode MODE base|tcp|tls|ssh (default: tcp)
--clients MODE single|multi (default: single)
--platform VALUE Target platform for buildx (default: linux/arm64)
--client-image TAG
Client image tag for buildx and compose
--push Push buildx image instead of loading it locally
--builder NAME buildx builder name
--skip-base-pull Do not pull Dockerfile base images before buildx
--foreground Run single client in foreground
--no-build Start without --build
-h|--help Show this help
EOF
}
die() {
echo "error: $*" >&2
exit 1
}
has_compose() {
docker compose version >/dev/null 2>&1
}
has_buildx() {
docker buildx version >/dev/null 2>&1
}
pull_base_images() {
[[ "${SKIP_BASE_IMAGE_PULL}" == "1" ]] && return
local platform image
local -a platforms images
IFS=',' read -r -a platforms <<< "${TARGET_PLATFORM}"
images=(rust:1.89-bookworm debian:bookworm-slim)
for platform in "${platforms[@]}"; do
for image in "${images[@]}"; do
docker pull --platform "${platform}" "${image}"
done
done
}
compose_files() {
if [[ "${CLIENTS}" == "multi" ]]; then
[[ "${MODE}" == "tcp" || "${MODE}" == "base" ]] || die "--clients multi is only defined for tcp/base mode"
printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.clients.yml"
return
fi
case "${MODE}" in
base) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.yml" ;;
tcp) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.tcp.yml" ;;
tls) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.tls.yml" ;;
ssh) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.ssh.yml" ;;
*) die "unsupported mode '${MODE}', expected base|tcp|tls|ssh" ;;
esac
}
compose_args() {
local -a args files
args=()
[[ -f "${SCRIPT_DIR}/.env" ]] && args+=(--env-file "${SCRIPT_DIR}/.env")
mapfile -t files < <(compose_files)
args+=("${files[@]}")
printf '%s\n' "${args[@]}"
}
compose_run() {
local -a args
mapfile -t args < <(compose_args)
docker compose "${args[@]}" "$@"
}
up_client() {
local -a up_args
up_args=(up)
[[ "${CLIENTS}" == "multi" || "${DETACH}" == "1" ]] && up_args+=(-d)
[[ "${BUILD}" == "1" && "${CLIENTS}" != "multi" ]] && up_args+=(--build)
compose_run "${up_args[@]}"
}
build_client() {
if [[ "${CLIENTS}" == "multi" ]]; then
docker build "$@" -f "${SCRIPT_DIR}/Dockerfile" -t "${CLIENT_IMAGE}" "${REPO_ROOT}"
else
compose_run build "$@"
fi
}
buildx_client() {
has_buildx || die "docker buildx is not available"
if [[ "${TARGET_PLATFORM}" == *,* && "${BUILDX_PUSH}" != "1" ]]; then
die "multi-platform buildx output requires --push"
fi
local -a output_args builder_args
[[ "${BUILDX_PUSH}" == "1" ]] && output_args=(--push) || output_args=(--load)
[[ -n "${BUILDX_BUILDER}" ]] && builder_args=(--builder "${BUILDX_BUILDER}") || builder_args=()
pull_base_images
docker buildx build "${builder_args[@]}" \
--platform "${TARGET_PLATFORM}" \
-f "${SCRIPT_DIR}/Dockerfile" \
-t "${CLIENT_IMAGE}" \
"${output_args[@]}" \
"${REPO_ROOT}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
up|build|buildx|rebuild|down|stop|restart|logs|ps)
ACTION="$1"
shift
;;
--mode)
[[ $# -ge 2 ]] || die "--mode requires a value"
MODE="$2"
shift 2
;;
--clients)
[[ $# -ge 2 ]] || die "--clients requires a value"
CLIENTS="$2"
shift 2
;;
--platform)
[[ $# -ge 2 ]] || die "--platform requires a value"
TARGET_PLATFORM="$2"
shift 2
;;
--client-image)
[[ $# -ge 2 ]] || die "--client-image requires a value"
CLIENT_IMAGE="$2"
shift 2
;;
--push)
BUILDX_PUSH=1
shift
;;
--builder)
[[ $# -ge 2 ]] || die "--builder requires a value"
BUILDX_BUILDER="$2"
shift 2
;;
--skip-base-pull)
SKIP_BASE_IMAGE_PULL=1
shift
;;
--foreground)
DETACH=0
shift
;;
--no-build)
BUILD=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument '$1'"
;;
esac
done
[[ "${CLIENTS}" == "single" || "${CLIENTS}" == "multi" ]] || die "unsupported clients '${CLIENTS}', expected single|multi"
export RPKI_RTR_CLIENT_IMAGE="${CLIENT_IMAGE}"
if [[ "${ACTION}" == "buildx" ]]; then
has_buildx || die "docker buildx is not available"
else
has_compose || die "docker compose is not available"
fi
cd "${REPO_ROOT}"
case "${ACTION}" in
up)
up_client
;;
build)
build_client
;;
buildx)
buildx_client
;;
rebuild)
build_client --no-cache
up_client
;;
down|stop)
compose_run down
;;
restart)
compose_run down
up_client
;;
logs)
compose_run logs -f
;;
ps)
compose_run ps
;;
esac

View File

@@ -1,52 +0,0 @@
version: "3.9"
services:
rtr-client-1:
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command: ["${RPKI_RTR_TCP_SERVER_ADDR:-rpki-rtr:323}", "${RPKI_RTR_PROTOCOL_VERSION:-2}", "reset", "--keep-after-error", "--summary-only"]
volumes:
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
networks:
- rpki_net
rtr-client-2:
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command: ["${RPKI_RTR_TCP_SERVER_ADDR:-rpki-rtr:323}", "${RPKI_RTR_PROTOCOL_VERSION:-2}", "reset", "--keep-after-error", "--summary-only"]
volumes:
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
networks:
- rpki_net
rtr-client-3:
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command: ["${RPKI_RTR_TCP_SERVER_ADDR:-rpki-rtr:323}", "${RPKI_RTR_PROTOCOL_VERSION:-2}", "reset", "--keep-after-error", "--summary-only"]
volumes:
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
networks:
- rpki_net
rtr-client-4:
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command: ["${RPKI_RTR_TCP_SERVER_ADDR:-rpki-rtr:323}", "${RPKI_RTR_PROTOCOL_VERSION:-2}", "reset", "--keep-after-error", "--summary-only"]
volumes:
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
networks:
- rpki_net
rtr-client-5:
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command: ["${RPKI_RTR_TCP_SERVER_ADDR:-rpki-rtr:323}", "${RPKI_RTR_PROTOCOL_VERSION:-2}", "reset", "--keep-after-error", "--summary-only"]
volumes:
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,36 +0,0 @@
version: "3.9"
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command:
[
"${RPKI_RTR_SSH_SERVER_ADDR:-rpki-rtr:22}",
"${RPKI_RTR_PROTOCOL_VERSION:-2}",
"reset",
"--ssh",
"--ssh-user",
"${RPKI_RTR_SSH_USERNAME:-rpki-rtr}",
"--ssh-password",
"${RPKI_RTR_SSH_PASSWORD}",
"--ssh-server-key",
"${RPKI_RTR_SSH_SERVER_PUBKEY_PATH:-/app/certs/ssh_host_rsa_key.pub}",
"--keep-after-error",
"--summary-only"
]
volumes:
- ${RPKI_RTR_CLIENT_KEYS_VOLUME:-../../certs/ssh:/app/certs:ro}
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
stdin_open: true
tty: true
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,36 +0,0 @@
version: "3.9"
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command:
[
"${RPKI_RTR_SSH_SERVER_ADDR:-rpki-rtr:22}",
"${RPKI_RTR_PROTOCOL_VERSION:-2}",
"reset",
"--ssh",
"--ssh-user",
"${RPKI_RTR_SSH_USERNAME:-rpki-rtr}",
"--ssh-key",
"${RPKI_RTR_CLIENT_KEY_PATH:-/app/certs/rtr-client.key}",
"--ssh-server-key",
"${RPKI_RTR_SSH_SERVER_PUBKEY_PATH:-/app/certs/ssh_host_rsa_key.pub}",
"--keep-after-error",
"--summary-only"
]
volumes:
- ${RPKI_RTR_CLIENT_KEYS_VOLUME:-../../certs/ssh:/app/certs:ro}
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
stdin_open: true
tty: true
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,19 +0,0 @@
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command: ["${RPKI_RTR_TCP_SERVER_ADDR:-rpki-rtr:323}", "${RPKI_RTR_PROTOCOL_VERSION:-2}", "reset", "--keep-after-error", "--summary-only"]
volumes:
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
stdin_open: true
tty: true
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,38 +0,0 @@
version: "3.9"
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command:
[
"${RPKI_RTR_TLS_SERVER_ADDR:-rpki-rtr:324}",
"${RPKI_RTR_PROTOCOL_VERSION:-2}",
"reset",
"--tls",
"--ca-cert",
"${RPKI_RTR_TLS_CA_CERT_PATH:-/app/certs/client-ca.crt}",
"--server-name",
"${RPKI_RTR_TLS_SERVER_NAME:-localhost}",
"--client-cert",
"${RPKI_RTR_TLS_CLIENT_CERT_PATH:-/app/certs/client-good.crt}",
"--client-key",
"${RPKI_RTR_TLS_CLIENT_KEY_PATH:-/app/certs/client-good.key}",
"--keep-after-error",
"--summary-only"
]
volumes:
- ${RPKI_RTR_TLS_CERTS_HOST_DIR:-../../certs/tls}:/app/certs:ro
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
stdin_open: true
tty: true
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,19 +0,0 @@
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client:latest}
command: ["${RPKI_RTR_TCP_SERVER_ADDR:-rpki-rtr:323}", "${RPKI_RTR_PROTOCOL_VERSION:-2}", "reset", "--keep-after-error", "--summary-only"]
volumes:
- ${RPKI_RTR_CLIENT_LOG_HOST_DIR:-../../logs/client}:/app/logs
restart: no
stdin_open: true
tty: true
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,10 +0,0 @@
#!/bin/sh
set -eu
mkdir -p /app/logs
log_name="${HOSTNAME:-rtr-debug-client}"
stdout_log="/app/logs/${log_name}.stdout.log"
stderr_log="/app/logs/${log_name}.stderr.log"
exec /usr/local/bin/rtr_debug_client "$@" >>"$stdout_log" 2>>"$stderr_log"

View File

@@ -1,4 +0,0 @@
# RTR server endpoint used by deploy/frr/frr.conf.template.
RPKI_FRR_RPKI_HOST=127.0.0.1
RPKI_FRR_RPKI_PORT=323
RPKI_FRR_RPKI_PREFERENCE=1

View File

@@ -1,78 +0,0 @@
# FRR Minimal RTR Client Config
中文文档: [README.zh.md](./README.zh.md)
This folder provides a minimal FRR setup for black-box interop testing
against this repository's RTR server defaults.
Server defaults in this repo:
- TCP: `0.0.0.0:323`
- TLS: `0.0.0.0:324`
Reference:
- `src/main.rs`
## Files
- `daemons.example`: sample `/etc/frr/daemons`
- `frr.conf.example`: static sample `/etc/frr/frr.conf`
- `frr.conf.template`: Docker template used to generate `/etc/frr/frr.conf`
- `entrypoint.sh`: renders `frr.conf.template` from environment variables before starting FRR
- `.env`: default Docker environment values
## How to apply on an FRR host
1. Copy `daemons.example` to `/etc/frr/daemons`.
2. Copy `frr.conf.example` to `/etc/frr/frr.conf`.
3. Restart FRR:
```bash
sudo systemctl restart frr
```
## Verify
```bash
vtysh -c "show rpki configuration"
vtysh -c "show rpki cache-server"
vtysh -c "show rpki cache-connection"
vtysh -c "show rpki prefix-table"
```
If `show rpki cache-connection` is connected and `show rpki prefix-table`
contains VRPs, the RTR client path is working.
## Docker quick start
From repository root:
```bash
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml up -d
docker exec -it frr-rpki-client vtysh -c "show rpki cache-connection"
docker exec -it frr-rpki-client vtysh -c "show rpki prefix-table"
```
The Docker example defaults to `127.0.0.1:323`. Override the RTR server
endpoint with environment variables:
```bash
RPKI_FRR_RPKI_HOST=10.0.0.12 \
RPKI_FRR_RPKI_PORT=323 \
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml up -d
```
Stop:
```bash
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml down
```
## Notes
- This setup targets RTR over TCP (`rpki cache tcp`).
- Docker mode renders `frr.conf.template` into `/etc/frr/frr.conf` at startup.
- Keep protocol-level conformance checks in Rust tests and
`src/bin/rtr_debug_client`.
- `network_mode: host` is kept for the minimal FRR container example, but the
RTR server address is controlled by `RPKI_FRR_RPKI_HOST` and
`RPKI_FRR_RPKI_PORT`.

View File

@@ -1,83 +0,0 @@
# FRR 最小化 RTR 客户端配置
本目录提供一个 FRR 最小配置,用于和本仓库 RTR Server 做黑盒互通测试。
本仓库默认 RTR 监听地址:
- TCP:`0.0.0.0:323`
- TLS:`0.0.0.0:324`
参考实现:
- `src/main.rs`
## 文件说明
- `daemons.example`:示例 `/etc/frr/daemons`
- `frr.conf.example`:静态示例 `/etc/frr/frr.conf`
- `frr.conf.template`:Docker 启动时生成 `/etc/frr/frr.conf` 的模板
- `entrypoint.sh`:启动 FRR 前根据环境变量渲染 `frr.conf.template`
- `.env`:Docker 示例使用的默认环境变量
## 在 FRR 主机上应用
1. 复制 `daemons.example` 到 `/etc/frr/daemons`
2. 复制 `frr.conf.example` 到 `/etc/frr/frr.conf`
3. 重启 FRR:
```bash
sudo systemctl restart frr
```
## 验证命令
```bash
vtysh -c "show rpki configuration"
vtysh -c "show rpki cache-server"
vtysh -c "show rpki cache-connection"
vtysh -c "show rpki prefix-table"
```
当 `show rpki cache-connection` 显示已连接,且 `show rpki prefix-table` 出现 VRP 时,说明 RTR 客户端链路工作正常。
## Docker 快速启动
在仓库根目录执行:
```bash
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml up -d
docker exec -it frr-rpki-client vtysh -c "show rpki cache-connection"
docker exec -it frr-rpki-client vtysh -c "show rpki prefix-table"
```
Docker 示例默认连接 `127.0.0.1:323`。如果 RTR Server 在其他地址,可以通过环境变量覆盖:
```bash
RPKI_FRR_RPKI_HOST=10.0.0.12 \
RPKI_FRR_RPKI_PORT=323 \
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml up -d
```
停止:
```bash
docker compose --env-file deploy/frr/.env -f deploy/frr/docker-compose.yml down
```
## 可测试范围
- FRR 作为 RTR Client 与本仓库 Server 的 TCP 建链能力
- FRR 侧基础会话状态可见性(`cache-server` / `cache-connection`)
- VRP 下发与导入是否成功(`prefix-table` 是否有条目)
- 基于服务端数据变化触发的前缀表更新(可通过替换 `data` 后观察)
- 黑盒互通回归:用于确认“路由器客户端视角”功能可用
## 不覆盖范围
- 不替代协议级单元测试/集成测试(PDU 细节、异常路径、边界条件)
- 不替代 `src/bin/rtr_debug_client` 的逐报文调试能力
- 默认示例以 TCP 为主,TLS/mTLS 需按你的证书与 FRR 配置单独扩展
## 说明
- 当前示例主要针对 RTR over TCP(`rpki cache tcp`)
- Docker 模式会在启动时把 `frr.conf.template` 渲染为 `/etc/frr/frr.conf`
- `network_mode: host` 仍用于最小化 FRR 容器示例,但 RTR Server 地址由 `RPKI_FRR_RPKI_HOST` 和 `RPKI_FRR_RPKI_PORT` 控制

View File

@@ -1,7 +0,0 @@
# Minimal FRR daemons config for RPKI testing
zebra=yes
bgpd=yes
# Enable bgpd RPKI module
bgpd_options=" -A 127.0.0.1 -M rpki"

View File

@@ -1,16 +0,0 @@
services:
frr-rpki-client:
image: quay.io/frrouting/frr:10.2.2
container_name: frr-rpki-client
restart: unless-stopped
network_mode: host
privileged: true
environment:
RPKI_FRR_RPKI_HOST: "${RPKI_FRR_RPKI_HOST:-127.0.0.1}"
RPKI_FRR_RPKI_PORT: "${RPKI_FRR_RPKI_PORT:-323}"
RPKI_FRR_RPKI_PREFERENCE: "${RPKI_FRR_RPKI_PREFERENCE:-1}"
entrypoint: ["/bin/sh", "/config/entrypoint.sh"]
volumes:
- ./daemons.example:/etc/frr/daemons:ro
- ./frr.conf.template:/config/frr.conf.template:ro
- ./entrypoint.sh:/config/entrypoint.sh:ro

View File

@@ -1,24 +0,0 @@
#!/bin/sh
set -eu
: "${RPKI_FRR_RPKI_HOST:=127.0.0.1}"
: "${RPKI_FRR_RPKI_PORT:=323}"
: "${RPKI_FRR_RPKI_PREFERENCE:=1}"
: "${RPKI_FRR_CONFIG_TEMPLATE_PATH:=/config/frr.conf.template}"
: "${RPKI_FRR_CONFIG_PATH:=/etc/frr/frr.conf}"
escape_sed() {
printf '%s' "$1" | sed 's/[&|\\]/\\&/g'
}
host="$(escape_sed "${RPKI_FRR_RPKI_HOST}")"
port="$(escape_sed "${RPKI_FRR_RPKI_PORT}")"
preference="$(escape_sed "${RPKI_FRR_RPKI_PREFERENCE}")"
sed \
-e "s|\${RPKI_FRR_RPKI_HOST}|${host}|g" \
-e "s|\${RPKI_FRR_RPKI_PORT}|${port}|g" \
-e "s|\${RPKI_FRR_RPKI_PREFERENCE}|${preference}|g" \
"${RPKI_FRR_CONFIG_TEMPLATE_PATH}" > "${RPKI_FRR_CONFIG_PATH}"
exec /usr/lib/frr/docker-start

View File

@@ -1,22 +0,0 @@
frr version 10.2
frr defaults traditional
hostname rpki-lab
service integrated-vtysh-config
!
debug rpki
!
rpki
rpki polling_period 10
rpki timeout 10
rpki retry_interval 10
rpki expire_interval 7200
rpki cache tcp 127.0.0.1 323 preference 1
exit
!
router bgp 65001
bgp router-id 192.0.2.1
!
address-family ipv4 unicast
exit-address-family
!
line vty

View File

@@ -1,22 +0,0 @@
frr version 10.2
frr defaults traditional
hostname rpki-lab
service integrated-vtysh-config
!
debug rpki
!
rpki
rpki polling_period 10
rpki timeout 10
rpki retry_interval 10
rpki expire_interval 7200
rpki cache tcp ${RPKI_FRR_RPKI_HOST} ${RPKI_FRR_RPKI_PORT} preference ${RPKI_FRR_RPKI_PREFERENCE}
exit
!
router bgp 65001
bgp router-id 192.0.2.1
!
address-family ipv4 unicast
exit-address-family
!
line vty

View File

@@ -1,49 +0,0 @@
# Core runtime knobs.
# RPKI_RTR_SERVER_IMAGE=rpki-rtr-arm64:v1.2
RPKI_RTR_SERVER_IMAGE=rpki-rtr-amd64:v1.2
# Data source directories on host.
RPKI_RTR_CCR_HOST_DIR=../../data
RPKI_RTR_SLURM_HOST_DIR=../../data
# In-container data source directories.
RPKI_RTR_CCR_DIR=/app/data
RPKI_RTR_SLURM_DIR=/app/slurm
RPKI_RTR_REPORT_HOST_DIR=../../report
# In-container runtime paths.
RPKI_RTR_DB_PATH=/app/rtr-db
RPKI_RTR_REPORT_DIR=/app/report
# Persistent directories on host.
RPKI_RTR_DB_HOST_DIR=../../rtr-db
RPKI_RTR_LOG_HOST_DIR=../../logs/server
RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS=300
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS=300
RPKI_RTR_REPORT_HISTORY_LIMIT=10
RPKI_RTR_TIMEZONE=Asia/Shanghai
RPKI_RTR_MAX_DELTA=10
RPKI_RTR_MAX_CONNECTIONS=100000
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES=128
RPKI_RTR_ADMIN_ADDR=0.0.0.0:8323
RPKI_RTR_ADMIN_TOKEN=qwert
RUST_LOG=info
# TLS mode knobs.
RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH=false
RPKI_RTR_TLS_CERT_PATH=/app/certs/server-dns.crt
RPKI_RTR_TLS_KEY_PATH=/app/certs/server-dns.key
RPKI_RTR_TLS_CLIENT_CA_PATH=/app/certs/client-ca.crt
RPKI_RTR_TLS_CERTS_HOST_DIR=../../certs/tls
# SSH mode knobs.
RPKI_RTR_SSH_HOST_PORT=2222
RPKI_RTR_SSH_CONTAINER_PORT=22
RPKI_RTR_SSH_AUTH_MODE=key
RPKI_RTR_SSH_USERNAME=rpki-rtr
RPKI_RTR_SSH_SUBSYSTEM_NAME=rpki-rtr
RPKI_RTR_SSH_HOST_KEY_PATH=/app/certs/ssh_host_rsa_key
RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH=/app/certs/rtr-authorized_keys
RPKI_RTR_SSH_CERTS_HOST_DIR=../../certs/ssh

View File

@@ -1,99 +0,0 @@
# Deployment (Docker Compose)
This project runs `src/main.rs` as a long-running server that:
1. loads latest `.ccr` from a configured directory,
2. applies optional SLURM filtering,
3. starts RTR server.
The container runs `rpki` directly as PID 1.
## Files
- `deploy/server/Dockerfile`
- `deploy/server/docker-compose.yml`
## Runtime Paths in Container
- CCR directory: `/app/data`
- RocksDB directory: `/app/rtr-db`
- SLURM directory: `/app/slurm`
- Report directory: `/app/report`
- TLS cert directory (optional): `/app/certs`
## Path Configuration via `.env`
- `RPKI_RTR_CCR_HOST_DIR`: host CCR directory mounted into container
- `RPKI_RTR_SLURM_HOST_DIR`: host SLURM directory mounted into container
- `RPKI_RTR_CCR_DIR`: in-container CCR directory path
- `RPKI_RTR_SLURM_DIR`: in-container SLURM directory path
- `RPKI_RTR_DB_HOST_DIR`: host RocksDB directory
- `RPKI_RTR_LOG_HOST_DIR`: host log directory
- `RPKI_RTR_REPORT_HOST_DIR`: host directory receiving split RTR JSON reports
- `RPKI_RTR_DB_PATH`: in-container RocksDB directory
- `RPKI_RTR_REPORT_DIR`: in-container report directory
## Runtime Configuration via `.env`
- Core: `RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS`, `RPKI_RTR_MAX_DELTA`, `RPKI_RTR_MAX_CONCURRENT_HANDSHAKES`, `RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS`, `RPKI_RTR_REPORT_HISTORY_LIMIT`, `RPKI_RTR_TIMEZONE`, `RPKI_RTR_ADMIN_ADDR`, `RPKI_RTR_ADMIN_TOKEN`, `RUST_LOG`
- TCP mode: `RPKI_RTR_MAX_CONNECTIONS`
- TLS mode: `RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH`, `RPKI_RTR_TLS_CERT_PATH`, `RPKI_RTR_TLS_KEY_PATH`, `RPKI_RTR_TLS_CLIENT_CA_PATH`, `RPKI_RTR_TLS_CERTS_HOST_DIR`
- SSH mode: `RPKI_RTR_SSH_HOST_PORT`, `RPKI_RTR_SSH_CONTAINER_PORT`, `RPKI_RTR_SSH_AUTH_MODE`, `RPKI_RTR_SSH_USERNAME`, `RPKI_RTR_SSH_SUBSYSTEM_NAME`, `RPKI_RTR_SSH_HOST_KEY_PATH`, `RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH`, `RPKI_RTR_SSH_CERTS_HOST_DIR`
## Start
```bash
docker compose -f deploy/server/docker-compose.yml up -d --build
```
## Stop
```bash
docker compose -f deploy/server/docker-compose.yml down
```
## Logs
```bash
docker compose -f deploy/server/docker-compose.yml logs -f rpki-rtr
```
The admin API can also stream the redirected log file:
```bash
curl -N "http://127.0.0.1:8323/admin/rtr/logs/tail?stream=stdout&lines=200" \
-H "Authorization: Bearer $RPKI_RTR_ADMIN_TOKEN"
```
It reads `/app/logs/${HOSTNAME}.stdout.log` or `.stderr.log` by default. Set
`RPKI_RTR_LOG_DIR` and `RPKI_RTR_LOG_NAME` to override that lookup.
## Runtime Report
The server writes split JSON reports. Each report file uses a local-time
timestamp suffix and each category keeps `RPKI_RTR_REPORT_HISTORY_LIMIT` files,
defaulting to 10.
- `rtr-source-*.json`: CCR and SLURM source metadata, latest refresh status,
data quality counts, cache snapshot counts, and delta counts. Written on
startup and source refresh events.
- `rtr-clients-*.json`: active client connection counts and counts by transport
(`tcp`, `tls`, `ssh`). Written on startup, whenever the active connection
count changes.
- `rtr-runtime-*.json`: service start time, uptime, process RSS, and
non-sensitive runtime configuration. Written on startup and every
`RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS`, defaulting to 300 seconds.
Timestamps in logs and report JSON files use `RPKI_RTR_TIMEZONE`, which
defaults to `Asia/Shanghai`. Use IANA timezone names such as `Asia/Shanghai`,
`Europe/London`, `America/New_York`, or `UTC`; `Shanghai` is accepted as a
convenience alias for `Asia/Shanghai`.
## Runtime Admin Config
The admin endpoint is disabled by default. Set `RPKI_RTR_ADMIN_ADDR` to enable
`POST /admin/rtr/config`. If the address is not loopback, `RPKI_RTR_ADMIN_TOKEN`
must also be set and requests must include `Authorization: Bearer <token>`.
The endpoint accepts partial JSON updates. See `docs/rtr-admin-api.md` for the
complete request/response schema, examples, and runtime apply semantics.

View File

@@ -1,85 +0,0 @@
FROM rust:1.89-bookworm AS builder
WORKDIR /build
ENV CARGO_BUILD_JOBS=2
RUN set -eux; \
cat > /etc/apt/sources.list.d/debian.sources <<'EOF'
Types: deb
URIs: http://mirrors.tuna.tsinghua.edu.cn/debian
Suites: bookworm bookworm-updates
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://mirrors.tuna.tsinghua.edu.cn/debian-security
Suites: bookworm-security
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
RUN apt-get update \
&& apt-get install -y --fix-missing --no-install-recommends \
-o Acquire::Retries=10 \
-o Acquire::http::Timeout=60 \
build-essential \
cmake \
pkg-config \
clang \
libclang-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --bin rpki_rtr
FROM debian:bookworm-slim AS runtime
RUN set -eux; \
cat > /etc/apt/sources.list.d/debian.sources <<'EOF'
Types: deb
URIs: http://mirrors.tuna.tsinghua.edu.cn/debian
Suites: bookworm bookworm-updates
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
Types: deb
URIs: http://mirrors.tuna.tsinghua.edu.cn/debian-security
Suites: bookworm-security
Components: main
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
EOF
RUN apt-get update \
&& apt-get install -y --fix-missing --no-install-recommends \
-o Acquire::Retries=10 \
-o Acquire::http::Timeout=60 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/rpki_rtr /usr/local/bin/rpki_rtr
COPY --chmod=755 deploy/server/entrypoint.sh /usr/local/bin/rpki-rtr-entrypoint.sh
RUN mkdir -p /app/data /app/rtr-db /app/certs /app/slurm /app/logs /app/report
ENV RPKI_RTR_ENABLE_TLS=false \
RPKI_RTR_TCP_ADDR=0.0.0.0:323 \
RPKI_RTR_TLS_ADDR=0.0.0.0:324 \
RPKI_RTR_DB_PATH=/app/rtr-db \
RPKI_RTR_CCR_DIR=/app/data \
RPKI_RTR_SLURM_DIR=/app/slurm \
RPKI_RTR_REPORT_DIR=/app/report \
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS=300 \
RPKI_RTR_REPORT_HISTORY_LIMIT=10 \
RPKI_RTR_REFRESH_INTERVAL_SECS=300 \
RPKI_RTR_ADMIN_ADDR="" \
RPKI_RTR_ADMIN_TOKEN=""
EXPOSE 323 324
ENTRYPOINT ["/usr/local/bin/rpki-rtr-entrypoint.sh"]

View File

@@ -1,215 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
MODE="${MODE:-tcp}"
ACTION="up"
DETACH="${DETACH:-1}"
BUILD="${BUILD:-1}"
TARGET_PLATFORM="${TARGET_PLATFORM:-linux/arm64}"
SERVER_IMAGE="${SERVER_IMAGE:-${RPKI_RTR_SERVER_IMAGE:-rpki-rtr:latest}}"
BUILDX_PUSH="${BUILDX_PUSH:-0}"
BUILDX_BUILDER="${BUILDX_BUILDER:-}"
SKIP_BASE_IMAGE_PULL="${SKIP_BASE_IMAGE_PULL:-0}"
usage() {
cat <<'EOF'
Usage:
deploy/server/build-run.sh [action] [options]
Actions:
up Build and start server (default)
build Build server image with docker compose
buildx Build server image with docker buildx
rebuild Rebuild image without cache, then start server
down|stop Stop server compose stack
restart Stop, then build and start server
logs Follow server logs
ps Show server compose status
Options:
--mode MODE base|tcp|tls|ssh (default: tcp)
--platform VALUE Target platform for buildx (default: linux/arm64)
--server-image TAG
Server image tag for buildx and compose
--push Push buildx image instead of loading it locally
--builder NAME buildx builder name
--skip-base-pull Do not pull Dockerfile base images before buildx
--foreground Run server in foreground
--no-build Start without --build
-h|--help Show this help
EOF
}
die() {
echo "error: $*" >&2
exit 1
}
has_compose() {
docker compose version >/dev/null 2>&1
}
has_buildx() {
docker buildx version >/dev/null 2>&1
}
pull_base_images() {
[[ "${SKIP_BASE_IMAGE_PULL}" == "1" ]] && return
local platform image
local -a platforms images
IFS=',' read -r -a platforms <<< "${TARGET_PLATFORM}"
images=(rust:1.89-bookworm debian:bookworm-slim)
for platform in "${platforms[@]}"; do
for image in "${images[@]}"; do
docker pull --platform "${platform}" "${image}"
done
done
}
compose_files() {
case "${MODE}" in
base) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.yml" ;;
tcp) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.tcp.yml" ;;
tls) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.tls.yml" ;;
ssh) printf '%s\n' -f "${SCRIPT_DIR}/docker-compose.ssh.yml" ;;
*) die "unsupported mode '${MODE}', expected base|tcp|tls|ssh" ;;
esac
}
compose_args() {
local -a args files
args=()
[[ -f "${SCRIPT_DIR}/.env" ]] && args+=(--env-file "${SCRIPT_DIR}/.env")
mapfile -t files < <(compose_files)
args+=("${files[@]}")
printf '%s\n' "${args[@]}"
}
compose_run() {
local -a args
mapfile -t args < <(compose_args)
docker compose "${args[@]}" "$@"
}
up_server() {
local -a up_args
up_args=(up)
[[ "${DETACH}" == "1" ]] && up_args+=(-d)
[[ "${BUILD}" == "1" ]] && up_args+=(--build)
compose_run "${up_args[@]}"
}
buildx_server() {
has_buildx || die "docker buildx is not available"
if [[ "${TARGET_PLATFORM}" == *,* && "${BUILDX_PUSH}" != "1" ]]; then
die "multi-platform buildx output requires --push"
fi
local -a output_args builder_args
[[ "${BUILDX_PUSH}" == "1" ]] && output_args=(--push) || output_args=(--load)
[[ -n "${BUILDX_BUILDER}" ]] && builder_args=(--builder "${BUILDX_BUILDER}") || builder_args=()
pull_base_images
docker buildx build "${builder_args[@]}" \
--platform "${TARGET_PLATFORM}" \
-f "${SCRIPT_DIR}/Dockerfile" \
-t "${SERVER_IMAGE}" \
"${output_args[@]}" \
"${REPO_ROOT}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
up|build|buildx|rebuild|down|stop|restart|logs|ps)
ACTION="$1"
shift
;;
--mode)
[[ $# -ge 2 ]] || die "--mode requires a value"
MODE="$2"
shift 2
;;
--platform)
[[ $# -ge 2 ]] || die "--platform requires a value"
TARGET_PLATFORM="$2"
shift 2
;;
--server-image)
[[ $# -ge 2 ]] || die "--server-image requires a value"
SERVER_IMAGE="$2"
shift 2
;;
--push)
BUILDX_PUSH=1
shift
;;
--builder)
[[ $# -ge 2 ]] || die "--builder requires a value"
BUILDX_BUILDER="$2"
shift 2
;;
--skip-base-pull)
SKIP_BASE_IMAGE_PULL=1
shift
;;
--foreground)
DETACH=0
shift
;;
--no-build)
BUILD=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument '$1'"
;;
esac
done
export RPKI_RTR_SERVER_IMAGE="${SERVER_IMAGE}"
if [[ "${ACTION}" == "buildx" ]]; then
has_buildx || die "docker buildx is not available"
else
has_compose || die "docker compose is not available"
fi
cd "${REPO_ROOT}"
case "${ACTION}" in
up)
up_server
;;
build)
compose_run build
;;
buildx)
buildx_server
;;
rebuild)
compose_run build --no-cache
up_server
;;
down|stop)
compose_run down
;;
restart)
compose_run down
up_server
;;
logs)
compose_run logs -f
;;
ps)
compose_run ps
;;
esac

View File

@@ -1,54 +0,0 @@
version: "3.9"
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-rpki-rtr:latest}
container_name: rpki-rtr-ssh
restart: no
ports:
- "323:323"
- "${RPKI_RTR_SSH_HOST_PORT:-2222}:${RPKI_RTR_SSH_CONTAINER_PORT:-22}"
- "8323:8323"
environment:
RPKI_RTR_ENABLE_TLS: "false"
RPKI_RTR_ENABLE_SSH: "true"
RPKI_RTR_TCP_ADDR: "0.0.0.0:323"
RPKI_RTR_SSH_ADDR: "0.0.0.0:${RPKI_RTR_SSH_CONTAINER_PORT:-22}"
RPKI_RTR_SSH_HOST_KEY_PATH: "${RPKI_RTR_SSH_HOST_KEY_PATH:-/app/certs/ssh_host_rsa_key}"
RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH: "${RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH:-/app/certs/rtr-authorized_keys}"
RPKI_RTR_SSH_USERNAME: "${RPKI_RTR_SSH_USERNAME:-rpki-rtr}"
RPKI_RTR_SSH_SUBSYSTEM_NAME: "${RPKI_RTR_SSH_SUBSYSTEM_NAME:-rpki-rtr}"
# SSH auth mode: key | password | both
RPKI_RTR_SSH_AUTH_MODE: "${RPKI_RTR_SSH_AUTH_MODE:-key}"
# Optional: enable password authentication in addition to publickey
# RPKI_RTR_SSH_PASSWORD: "test-password"
RPKI_RTR_DB_PATH: "${RPKI_RTR_DB_PATH:-/app/rtr-db}"
RPKI_RTR_CCR_DIR: "${RPKI_RTR_CCR_DIR:-/app/data}"
RPKI_RTR_SLURM_DIR: "${RPKI_RTR_SLURM_DIR:-/app/slurm}"
RPKI_RTR_REPORT_DIR: "${RPKI_RTR_REPORT_DIR:-/app/report}"
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS: "${RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS:-300}"
RPKI_RTR_REPORT_HISTORY_LIMIT: "${RPKI_RTR_REPORT_HISTORY_LIMIT:-10}"
RPKI_RTR_TIMEZONE: "${RPKI_RTR_TIMEZONE:-Asia/Shanghai}"
RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS: "${RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS:-300}"
RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}"
RPKI_RTR_ADMIN_ADDR: "${RPKI_RTR_ADMIN_ADDR:-}"
RPKI_RTR_ADMIN_TOKEN: "${RPKI_RTR_ADMIN_TOKEN:-}"
RUST_LOG: "${RUST_LOG:-info}"
volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro
- ${RPKI_RTR_DB_HOST_DIR:-../../rtr-db}:${RPKI_RTR_DB_PATH:-/app/rtr-db}
- ${RPKI_RTR_SLURM_HOST_DIR:-../../data}:${RPKI_RTR_SLURM_DIR:-/app/slurm}
- ${RPKI_RTR_SSH_CERTS_HOST_DIR:-../../certs/ssh}:/app/certs:ro
- ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs
- ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report}
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,44 +0,0 @@
version: "3.9"
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-rpki-rtr:latest}
container_name: rpki-rtr-tcp
restart: no
ports:
- "323:323"
- "8323:8323"
environment:
RPKI_RTR_ENABLE_TLS: "false"
RPKI_RTR_ENABLE_SSH: "false"
RPKI_RTR_TCP_ADDR: "0.0.0.0:323"
RPKI_RTR_DB_PATH: "${RPKI_RTR_DB_PATH:-/app/rtr-db}"
RPKI_RTR_CCR_DIR: "${RPKI_RTR_CCR_DIR:-/app/data}"
RPKI_RTR_SLURM_DIR: "${RPKI_RTR_SLURM_DIR:-/app/slurm}"
RPKI_RTR_REPORT_DIR: "${RPKI_RTR_REPORT_DIR:-/app/report}"
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS: "${RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS:-300}"
RPKI_RTR_REPORT_HISTORY_LIMIT: "${RPKI_RTR_REPORT_HISTORY_LIMIT:-10}"
RPKI_RTR_TIMEZONE: "${RPKI_RTR_TIMEZONE:-Asia/Shanghai}"
RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS: "${RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS:-60}"
RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONNECTIONS: "${RPKI_RTR_MAX_CONNECTIONS:-100000}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}"
RPKI_RTR_ADMIN_ADDR: "${RPKI_RTR_ADMIN_ADDR:-}"
RPKI_RTR_ADMIN_TOKEN: "${RPKI_RTR_ADMIN_TOKEN:-}"
RUST_LOG: "${RUST_LOG:-info}"
volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro
- ${RPKI_RTR_DB_HOST_DIR:-../../rtr-db}:${RPKI_RTR_DB_PATH:-/app/rtr-db}
- ${RPKI_RTR_SLURM_HOST_DIR:-../../data}:${RPKI_RTR_SLURM_DIR:-/app/slurm}
- ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs
- ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report}
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,48 +0,0 @@
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-rpki-rtr:latest}
container_name: rpki-rtr-tls
restart: no
ports:
# - "323:323"
- "324:324"
- "8323:8323"
environment:
RPKI_RTR_ENABLE_TLS: "true"
RPKI_RTR_ENABLE_SSH: "false"
# RPKI_RTR_TCP_ADDR: "0.0.0.0:323"
RPKI_RTR_TLS_ADDR: "0.0.0.0:324"
RPKI_RTR_TLS_CERT_PATH: "${RPKI_RTR_TLS_CERT_PATH:-/app/certs/server-dns.crt}"
RPKI_RTR_TLS_KEY_PATH: "${RPKI_RTR_TLS_KEY_PATH:-/app/certs/server-dns.key}"
RPKI_RTR_TLS_CLIENT_CA_PATH: "${RPKI_RTR_TLS_CLIENT_CA_PATH:-/app/certs/client-ca.crt}"
RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH: "${RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH:-false}"
RPKI_RTR_DB_PATH: "${RPKI_RTR_DB_PATH:-/app/rtr-db}"
RPKI_RTR_CCR_DIR: "${RPKI_RTR_CCR_DIR:-/app/data}"
RPKI_RTR_SLURM_DIR: "${RPKI_RTR_SLURM_DIR:-/app/slurm}"
RPKI_RTR_REPORT_DIR: "${RPKI_RTR_REPORT_DIR:-/app/report}"
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS: "${RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS:-300}"
RPKI_RTR_REPORT_HISTORY_LIMIT: "${RPKI_RTR_REPORT_HISTORY_LIMIT:-10}"
RPKI_RTR_TIMEZONE: "${RPKI_RTR_TIMEZONE:-Asia/Shanghai}"
RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS: "${RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS:-300}"
RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}"
RPKI_RTR_ADMIN_ADDR: "${RPKI_RTR_ADMIN_ADDR:-}"
RPKI_RTR_ADMIN_TOKEN: "${RPKI_RTR_ADMIN_TOKEN:-}"
RUST_LOG: "${RUST_LOG:-info}"
volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro
- ${RPKI_RTR_DB_HOST_DIR:-../../rtr-db}:${RPKI_RTR_DB_PATH:-/app/rtr-db}
- ${RPKI_RTR_SLURM_HOST_DIR:-../../data}:${RPKI_RTR_SLURM_DIR:-/app/slurm}
- ${RPKI_RTR_TLS_CERTS_HOST_DIR:-../../certs/tls}:/app/certs:ro
- ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs
- ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report}
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,58 +0,0 @@
version: "3.9"
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-rpki-rtr:latest}
container_name: rpki-rtr
restart: no
ports:
- "323:323"
- "324:324"
- "8323:8323"
# SSH mode example:
# - "22:22"
environment:
RPKI_RTR_ENABLE_TLS: "false"
RPKI_RTR_TCP_ADDR: "0.0.0.0:323"
RPKI_RTR_TLS_ADDR: "0.0.0.0:324"
RPKI_RTR_DB_PATH: "${RPKI_RTR_DB_PATH:-/app/rtr-db}"
RPKI_RTR_CCR_DIR: "${RPKI_RTR_CCR_DIR:-/app/data}"
RPKI_RTR_SLURM_DIR: "${RPKI_RTR_SLURM_DIR:-/app/slurm}"
RPKI_RTR_REPORT_DIR: "${RPKI_RTR_REPORT_DIR:-/app/report}"
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS: "${RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS:-300}"
RPKI_RTR_REPORT_HISTORY_LIMIT: "${RPKI_RTR_REPORT_HISTORY_LIMIT:-10}"
RPKI_RTR_TIMEZONE: "${RPKI_RTR_TIMEZONE:-Asia/Shanghai}"
RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS: "${RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS:-300}"
RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}"
RPKI_RTR_ADMIN_ADDR: "${RPKI_RTR_ADMIN_ADDR:-}"
RPKI_RTR_ADMIN_TOKEN: "${RPKI_RTR_ADMIN_TOKEN:-}"
RUST_LOG: "${RUST_LOG:-info}"
# SSH mode example:
# RPKI_RTR_ENABLE_SSH: "true"
# RPKI_RTR_SSH_ADDR: "0.0.0.0:22"
# RPKI_RTR_SSH_PORT: "22"
# RPKI_RTR_SSH_HOST_KEY_PATH: "/app/certs/ssh_host_rsa_key"
# RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH: "/app/certs/rtr-authorized_keys"
# RPKI_RTR_SSH_USERNAME: "rpki-rtr"
# RPKI_RTR_SSH_SUBSYSTEM_NAME: "rpki-rtr"
# Optional: enable password auth in addition to publickey
# RPKI_RTR_SSH_PASSWORD: "test-password"
volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro
- ${RPKI_RTR_DB_HOST_DIR:-../../rtr-db}:${RPKI_RTR_DB_PATH:-/app/rtr-db}
- ${RPKI_RTR_SLURM_HOST_DIR:-../../data}:${RPKI_RTR_SLURM_DIR:-/app/slurm}
- ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs
- ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report}
# TLS mode example:
# - ../../certs/ssh:/app/certs:ro
networks:
- rpki_net
networks:
rpki_net:
name: rpki_net
driver: bridge

View File

@@ -1,10 +0,0 @@
#!/bin/sh
set -eu
mkdir -p /app/logs
log_name="${HOSTNAME:-rpki-rtr}"
stdout_log="/app/logs/${log_name}.stdout.log"
stderr_log="/app/logs/${log_name}.stderr.log"
exec /usr/local/bin/rpki_rtr "$@" >>"$stdout_log" 2>>"$stderr_log"

View File

@@ -1,310 +0,0 @@
# RTR Admin API
本文档描述 RTR server 当前提供的 HTTP 管理接口。
## 启用方式
Admin API 默认关闭。设置 `RPKI_RTR_ADMIN_ADDR` 后启用:
```env
RPKI_RTR_ADMIN_ADDR=127.0.0.1:8323
RPKI_RTR_ADMIN_TOKEN=change-me
```
如果在 Docker 中需要从宿主机访问,容器内建议监听:
```env
RPKI_RTR_ADMIN_ADDR=0.0.0.0:8323
RPKI_RTR_ADMIN_TOKEN=change-me
```
并在 compose 中映射端口:
```yaml
ports:
- "8323:8323"
```
安全规则:
- `RPKI_RTR_ADMIN_ADDR` 为空时,不启动 Admin API。
- 非 loopback 地址,例如 `0.0.0.0:8323`,必须配置 `RPKI_RTR_ADMIN_TOKEN`,否则 admin server 会拒绝启动。
- 设置 token 后,请求必须带 `Authorization: Bearer <token>`。
## 通用 Headers
```http
Authorization: Bearer change-me
Content-Type: application/json
```
GET 请求不需要 `Content-Type`。
## GET /admin/rtr/health
用于检查 Admin API 是否可达,以及当前启用了哪些管理能力。
```bash
curl http://127.0.0.1:8323/admin/rtr/health \
-H "Authorization: Bearer change-me"
```
响应示例:
```json
{
"status": "ok",
"config_api": true,
"source_reload_api": true,
"slurm_api": true,
"logs_api": true
}
```
## GET /admin/rtr/logs/tail
实时读取 RTR 服务日志,效果类似 `tail -f`。默认读取 stdout 日志,先返回最近 200 行,然后持续输出新增内容。
```bash
curl -N "http://127.0.0.1:8323/admin/rtr/logs/tail?stream=stdout&lines=200&follow=true" \
-H "Authorization: Bearer change-me"
```
Query 参数:
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `stream` | `stdout` | 可选 `stdout` 或 `stderr`。 |
| `lines` | `200` | 首次返回的历史行数,范围会限制在 `1..=5000`。 |
| `follow` | `true` | `true` 时保持连接并持续输出新增日志;`false` 时只返回当前 tail 内容。 |
日志文件路径按容器 entrypoint 的规则解析:
- 目录:`RPKI_RTR_LOG_DIR`,默认 `/app/logs`。
- 文件名前缀:`RPKI_RTR_LOG_NAME`,未设置时使用 `HOSTNAME`,再未设置时为 `rpki-rtr`。
- stdout 文件:`<name>.stdout.log`。
- stderr 文件:`<name>.stderr.log`。
## GET /admin/rtr/config
查询当前运行中的 runtime 配置。
```bash
curl http://127.0.0.1:8323/admin/rtr/config \
-H "Authorization: Bearer change-me"
```
响应示例:
```json
{
"status": "ok",
"config": {
"max_delta": 10,
"prune_delta_by_snapshot_size": true,
"source_refresh_interval_seconds": 300,
"runtime_report_interval_seconds": 300,
"report_history_limit": 10,
"timezone": "Asia/Shanghai",
"timing": {
"refresh": 3600,
"retry": 600,
"expire": 7200
}
}
}
```
## POST /admin/rtr/config
运行中动态修改 RTR server 的部分 runtime 配置。请求体是 JSON object,支持部分更新,只需要传要修改的字段。
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `max_delta` | integer | 每个 RTR 版本最多保留的 delta 条数,必须 `>= 1`。 |
| `prune_delta_by_snapshot_size` | boolean | 是否按 snapshot wire size 裁剪 delta window。 |
| `source_refresh_interval_seconds` | integer | CCR/SLURM source refresh 间隔,单位秒,必须 `>= 1`。 |
| `runtime_report_interval_seconds` | integer | `rtr-runtime-*.json` 周期写入间隔,单位秒,必须 `>= 1`。 |
| `report_history_limit` | integer | 每类 report 文件滚动保留数量,必须 `>= 1`。 |
| `timezone` | string | IANA 时区名,例如 `Asia/Shanghai`、`UTC`、`Europe/London`。 |
| `timing.refresh` | integer | RTR EndOfData `refresh`,单位秒。 |
| `timing.retry` | integer | RTR EndOfData `retry`,单位秒。 |
| `timing.expire` | integer | RTR EndOfData `expire`,必须大于 `refresh` 和 `retry`。 |
示例:
```bash
curl -X POST http://127.0.0.1:8323/admin/rtr/config \
-H "Content-Type: application/json" \
-H "Authorization: Bearer change-me" \
-d '{"max_delta": 6}'
```
成功响应:
```json
{
"status": "ok",
"config": {
"max_delta": 6,
"prune_delta_by_snapshot_size": true,
"source_refresh_interval_seconds": 300,
"runtime_report_interval_seconds": 300,
"report_history_limit": 10,
"timezone": "Asia/Shanghai",
"timing": {
"refresh": 3600,
"retry": 600,
"expire": 7200
}
}
}
```
## GET /admin/rtr/slurm/files
列出 `RPKI_RTR_SLURM_DIR` 下的 SLURM 文件。只返回合法的 `*.slurm` 和 `*.slurm.disabled` 文件,不返回 `.backup/`。
```bash
curl http://127.0.0.1:8323/admin/rtr/slurm/files \
-H "Authorization: Bearer change-me"
```
响应示例:
```json
{
"status": "ok",
"files": [
{
"name": "local-policy.slurm",
"path": "/app/slurm/local-policy.slurm",
"enabled": true,
"size_bytes": 168,
"modified_unix_seconds": 1782100000
},
{
"name": "old-policy.slurm.disabled",
"path": "/app/slurm/old-policy.slurm.disabled",
"enabled": false,
"size_bytes": 168,
"modified_unix_seconds": 1782100100
}
]
}
```
## GET /admin/rtr/slurm/files/{name}
读取单个 SLURM 文件,前端编辑页面可以直接使用这个接口加载内容。
```bash
curl http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm \
-H "Authorization: Bearer change-me"
```
响应示例:
```json
{
"status": "ok",
"file": {
"name": "local-policy.slurm",
"path": "/app/slurm/local-policy.slurm",
"enabled": true,
"size_bytes": 168,
"modified_unix_seconds": 1782100000,
"content": "{ \"slurmVersion\": 1, \"validationOutputFilters\": { \"prefixFilters\": [], \"bgpsecFilters\": [] }, \"locallyAddedAssertions\": { \"prefixAssertions\": [], \"bgpsecAssertions\": [] } }"
}
}
```
## POST /admin/rtr/slurm/files
新增或覆盖一个 SLURM 文件。文件名放在请求体中。
```json
{
"name": "local-policy.slurm",
"content": "{ \"slurmVersion\": 1, \"validationOutputFilters\": { \"prefixFilters\": [], \"bgpsecFilters\": [] }, \"locallyAddedAssertions\": { \"prefixAssertions\": [], \"bgpsecAssertions\": [] } }",
"reload": true
}
```
## PUT /admin/rtr/slurm/files/{name}
新增或覆盖指定 SLURM 文件。
```bash
curl -X PUT http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm \
-H "Content-Type: application/json" \
-H "Authorization: Bearer change-me" \
-d '{
"content": "{ \"slurmVersion\": 1, \"validationOutputFilters\": { \"prefixFilters\": [], \"bgpsecFilters\": [] }, \"locallyAddedAssertions\": { \"prefixAssertions\": [], \"bgpsecAssertions\": [] } }",
"reload": true
}'
```
## DELETE /admin/rtr/slurm/files/{name}
删除指定 SLURM 文件。删除前会先备份。
```bash
curl -X DELETE "http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm?reload=true" \
-H "Authorization: Bearer change-me"
```
## POST /admin/rtr/slurm/files/{name}/disable
将启用文件改名为失效文件:
```text
local-policy.slurm -> local-policy.slurm.disabled
```
```bash
curl -X POST "http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm/disable?reload=true" \
-H "Authorization: Bearer change-me"
```
## POST /admin/rtr/slurm/files/{name}/enable
将失效文件改名为启用文件:
```text
local-policy.slurm.disabled -> local-policy.slurm
```
```bash
curl -X POST "http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm.disabled/enable?reload=true" \
-H "Authorization: Bearer change-me"
```
## POST /admin/rtr/slurm/reload
不修改文件,只立即触发一次 CCR/SLURM source reload。
```bash
curl -X POST http://127.0.0.1:8323/admin/rtr/slurm/reload \
-H "Authorization: Bearer change-me"
```
## SLURM 文件规则
- `*.slurm`:启用,会被 source refresh 加载。
- `*.slurm.disabled`:失效,不会被加载。
- `.backup/`:接口自动生成的备份目录。
- 文件名只能是 basename,不能包含 `/`、`\` 或 `..`。
- 文件名只能包含 ASCII 字母、数字、`.`、`_`、`-`。
- 写入内容必须是合法 JSON,并且能被解析为合法 SLURM 文件。
- 单个请求体默认限制为 5 MiB。
- 覆盖、删除前会写入 `.backup/` 备份。
## 错误响应
| HTTP 状态码 | 含义 |
| --- | --- |
| `400 Bad Request` | JSON 非法、配置值非法、SLURM 文件名非法、目标文件不存在、reload 失败等。 |
| `401 Unauthorized` | 配置了 token,但请求缺少或使用了错误的 `Authorization`。 |
| `404 Not Found` | 路径或方法不支持。 |
| `413 Payload Too Large` | 请求体超过限制。 |

2734
model.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
IMAGE_DIR="${ROOT_DIR}/images"
SERVER_ENV_FILE="${ROOT_DIR}/deploy/server/.env"
CONSOLE_ENV_FILE="${ROOT_DIR}/deploy/console/.env"
SERVER_TAR="${RPKI_RTR_SERVER_IMAGE_TAR:-}"
CLIENT_TAR="${RPKI_RTR_CLIENT_IMAGE_TAR:-}"
CONSOLE_TAR="${RPKI_RTR_CONSOLE_IMAGE_TAR:-}"
usage() {
cat <<'EOF'
Usage: scripts/00_0_install.sh [options]
Load offline Docker image tarballs from ./images, then run scripts/00_1_pre_check.sh.
Options:
--server-tar FILE Server image tarball. Env: RPKI_RTR_SERVER_IMAGE_TAR
--client-tar FILE Debug client image tarball. Env: RPKI_RTR_CLIENT_IMAGE_TAR
--console-tar FILE Console image tarball. Env: RPKI_RTR_CONSOLE_IMAGE_TAR
-h, --help Show this help.
Default tarballs under ./images:
server: rpki-rtr-*.tar, excluding rpki-rtr-debug-client-*.tar
client: rpki-rtr-debug-client-*.tar
console: rpki-console-ui-*.tar
EOF
}
log() {
printf '[00_install] %s\n' "$*" >&2
}
stage() {
printf '\n====== %s ======\n' "$*" >&2
}
die() {
printf '[00_install] ERROR: %s\n' "$*" >&2
exit 1
}
set_env_var() {
local env_file="$1"
local key="$2"
local value="$3"
local tmp_file
[ -f "${env_file}" ] || die "env file not found: ${env_file}"
tmp_file="${env_file}.tmp.$$"
awk -v key="${key}" -v value="${value}" '
BEGIN { done = 0 }
$0 ~ "^[[:space:]]*#?[[:space:]]*" key "=" {
if (!done) {
print key "=" value
done = 1
}
next
}
{ print }
END {
if (!done) {
print key "=" value
}
}
' "${env_file}" >"${tmp_file}"
mv "${tmp_file}" "${env_file}"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--server-tar)
[ "$#" -ge 2 ] || die "--server-tar requires a value"
SERVER_TAR="$2"
shift 2
;;
--client-tar)
[ "$#" -ge 2 ] || die "--client-tar requires a value"
CLIENT_TAR="$2"
shift 2
;;
--console-tar)
[ "$#" -ge 2 ] || die "--console-tar requires a value"
CONSOLE_TAR="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
resolve_tar() {
local path="$1"
if [ -f "${path}" ]; then
cd "$(dirname "${path}")" && printf '%s/%s\n' "$(pwd -P)" "$(basename "${path}")"
return 0
fi
if [ -f "${ROOT_DIR}/${path}" ]; then
cd "$(dirname "${ROOT_DIR}/${path}")" && printf '%s/%s\n' "$(pwd -P)" "$(basename "${path}")"
return 0
fi
return 1
}
load_image() {
local label="$1"
local tar_path="$2"
local output image
[ -f "${tar_path}" ] || die "${label} image tarball not found: ${tar_path}"
output="$(docker load -i "${tar_path}" 2>&1)" || {
printf '%s\n' "${output}" >&2
die "failed to load ${label} image from ${tar_path}"
}
printf '%s\n' "${output}" >&2
image="$(printf '%s\n' "${output}" | sed -n 's/^Loaded image: //p' | tail -n 1)"
[ -n "${image}" ] || die "cannot determine ${label} image name from docker load output"
printf '%s\n' "${image}"
}
find_single_tar() {
local label="$1"
local include_pattern="$2"
local exclude_pattern="${3:-}"
local matches=()
local file
while IFS= read -r -d '' file; do
matches+=("${file}")
done < <(
if [ -n "${exclude_pattern}" ]; then
find "${IMAGE_DIR}" -maxdepth 1 -type f -name "${include_pattern}" ! -name "${exclude_pattern}" -print0 | sort -z
else
find "${IMAGE_DIR}" -maxdepth 1 -type f -name "${include_pattern}" -print0 | sort -z
fi
)
case "${#matches[@]}" in
0)
die "no ${label} image tarball found under ${IMAGE_DIR} matching ${include_pattern}"
;;
1)
printf '%s\n' "${matches[0]}"
;;
*)
printf '[00_install] ERROR: multiple %s image tarballs found:\n' "${label}" >&2
printf ' %s\n' "${matches[@]}" >&2
die "please specify --${label}-tar or RPKI_RTR_${label^^}_IMAGE_TAR"
;;
esac
}
stage "Checking Docker"
command -v docker >/dev/null 2>&1 || die "docker command not found"
[ -d "${IMAGE_DIR}" ] || die "image directory not found: ${IMAGE_DIR}"
[ -f "${SERVER_ENV_FILE}" ] || die "server env file not found: ${SERVER_ENV_FILE}"
if [ -n "${SERVER_TAR}" ]; then
SERVER_TAR="$(resolve_tar "${SERVER_TAR}")" || die "server image tarball not found: ${SERVER_TAR}"
else
SERVER_TAR="$(find_single_tar server 'rpki-rtr-*.tar' 'rpki-rtr-debug-client-*.tar')"
fi
if [ -n "${CLIENT_TAR}" ]; then
CLIENT_TAR="$(resolve_tar "${CLIENT_TAR}")" || die "client image tarball not found: ${CLIENT_TAR}"
else
CLIENT_TAR="$(find_single_tar client 'rpki-rtr-debug-client-*.tar')"
fi
if [ -n "${CONSOLE_TAR}" ]; then
CONSOLE_TAR="$(resolve_tar "${CONSOLE_TAR}")" || die "console image tarball not found: ${CONSOLE_TAR}"
else
CONSOLE_TAR="$(find_single_tar console 'rpki-console-ui-*.tar')"
fi
stage "Loading Docker Images"
SERVER_IMAGE="$(load_image server "${SERVER_TAR}")"
CLIENT_IMAGE="$(load_image client "${CLIENT_TAR}")"
CONSOLE_IMAGE="$(load_image console "${CONSOLE_TAR}")"
stage "Updating Deploy Env"
set_env_var "${SERVER_ENV_FILE}" RPKI_RTR_SERVER_IMAGE "${SERVER_IMAGE}"
set_env_var "${CONSOLE_ENV_FILE}" RPKI_CONSOLE_UI_IMAGE "${CONSOLE_IMAGE}"
stage "Running Pre-Check"
RPKI_RTR_SERVER_IMAGE="${SERVER_IMAGE}" \
RPKI_RTR_CLIENT_IMAGE="${CLIENT_IMAGE}" \
bash "${SCRIPT_DIR}/00_1_pre_check.sh" || die "pre-check failed"
stage "Install Finished"

View File

@@ -1,155 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PRECHECK_WORK_DIR="${PRECHECK_WORK_DIR:-${ROOT_DIR}/.precheck}"
PRECHECK_ENV_FILE="${PRECHECK_ENV_FILE:-${PRECHECK_WORK_DIR}/precheck.env}"
PRECHECK_WAIT_SECS="${PRECHECK_WAIT_SECS:-60}"
KEEP_PRECHECK="${KEEP_PRECHECK:-0}"
log() {
printf '[00_1_pre_check] %s\n' "$*"
}
stage() {
printf '\n====== %s ======\n' "$*"
}
die() {
printf '[00_1_pre_check] ERROR: %s\n' "$*" >&2
exit 1
}
command -v docker >/dev/null 2>&1 || die "docker command not found"
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
die "docker compose is not available"
fi
stage "Preparing Pre-Check Data"
bash "${SCRIPT_DIR}/00_2_pre_check_prepare.sh" || die "prepare step failed"
set -a
# shellcheck disable=SC1090
. "${PRECHECK_ENV_FILE}"
set +a
ADMIN_PORT="${RPKI_RTR_ADMIN_ADDR##*:}"
ADMIN_TOKEN="${RPKI_RTR_ADMIN_TOKEN}"
server_compose() {
"${COMPOSE_BIN[@]}" --env-file "${PRECHECK_ENV_FILE}" -p "${PRECHECK_PROJECT}" -f "${PRECHECK_SERVER_COMPOSE_FILE}" "$@"
}
client_compose() {
"${COMPOSE_BIN[@]}" --env-file "${PRECHECK_ENV_FILE}" -p "${PRECHECK_PROJECT}" -f "${PRECHECK_CLIENT_COMPOSE_FILE}" "$@"
}
cleanup() {
KEEP_PRECHECK="${KEEP_PRECHECK}" PRECHECK_WORK_DIR="${PRECHECK_WORK_DIR}" PRECHECK_ENV_FILE="${PRECHECK_ENV_FILE}" \
bash "${SCRIPT_DIR}/00_3_pre_check_cleanup.sh"
}
trap cleanup EXIT
http_get() {
local host="$1"
local port="$2"
local path="$3"
local token="$4"
exec 3<>"/dev/tcp/${host}/${port}" || return 1
printf 'GET %s HTTP/1.1\r\nHost: %s:%s\r\nAuthorization: Bearer %s\r\nConnection: close\r\n\r\n' \
"${path}" "${host}" "${port}" "${token}" >&3
cat <&3
exec 3>&-
}
wait_for_admin_health() {
local deadline=$((SECONDS + PRECHECK_WAIT_SECS))
local response
log "waiting for server health"
while [ "${SECONDS}" -lt "${deadline}" ]; do
response="$(http_get 127.0.0.1 "${ADMIN_PORT}" /admin/rtr/health "${ADMIN_TOKEN}" 2>/dev/null || true)"
if printf '%s' "${response}" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'; then
log "admin health check passed"
return 0
fi
if [ "$(server_compose ps -q rpki-rtr | wc -l | tr -d ' ')" = "0" ]; then
server_compose logs rpki-rtr || true
die "server container is not running"
fi
sleep 1
done
server_compose logs rpki-rtr || true
die "admin health check did not pass within ${PRECHECK_WAIT_SECS}s"
}
wait_for_client_eod() {
local deadline=$((SECONDS + PRECHECK_WAIT_SECS))
local log_file
log "waiting for client response"
while [ "${SECONDS}" -lt "${deadline}" ]; do
for log_file in "${PRECHECK_WORK_DIR}"/logs/client/*.stdout.log; do
[ -f "${log_file}" ] || continue
if grep -q 'EndOfData:' "${log_file}" && grep -q 'summary[[:space:]]*:' "${log_file}"; then
log "client received EndOfData and payload summary"
return 0
fi
if grep -qi 'Error Report' "${log_file}"; then
cat "${log_file}" >&2
die "client received RTR Error Report"
fi
done
sleep 1
done
find "${PRECHECK_WORK_DIR}/logs/client" -type f -maxdepth 1 -print -exec cat {} \; >&2 || true
die "client did not receive EndOfData and summary within ${PRECHECK_WAIT_SECS}s"
}
assert_no_error_logs() {
local bad
log "checking logs"
bad="$(find "${PRECHECK_WORK_DIR}/logs" -type f -name '*.log' -print0 \
| xargs -0 grep -Ein 'panic|failed|RTR admin config server exited|Error Report' 2>/dev/null || true)"
if [ -n "${bad}" ]; then
printf '%s\n' "${bad}" >&2
die "error pattern found in pre-check logs"
fi
}
assert_report_written() {
local deadline=$((SECONDS + PRECHECK_WAIT_SECS))
log "waiting for source report"
while [ "${SECONDS}" -lt "${deadline}" ]; do
if find "${PRECHECK_WORK_DIR}/report" -type f -name 'rtr-source-*.json' | grep -q .; then
log "source report written"
return 0
fi
sleep 1
done
die "server did not write rtr-source report within ${PRECHECK_WAIT_SECS}s"
}
stage "Starting RTR Server"
server_compose up -d --no-build rpki-rtr
wait_for_admin_health
assert_report_written
stage "Starting Debug Client"
client_compose up -d --no-build rtr-debug-client
wait_for_client_eod
assert_no_error_logs
stage "Pre-Check Passed"

View File

@@ -1,54 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
OUT_PATH="${1:-}"
usage() {
cat <<'EOF'
Usage: scripts/00_2_generate_ccr.sh OUT.ccr
Generate a small deterministic CCR snapshot for pre-check.
This script writes a fixed base64-encoded CCR fixture and does not require Python.
The generated snapshot contains:
- VRP: 10.0.0.0/24 maxLen 24 AS65001
- VRP: 10.0.1.0/24 maxLen 24 AS65003
- VRP: 2001:db8:1::/48 maxLen 48 AS65002
- ASPA: customer AS65010 providers AS65011, AS65012, AS65013
- ASPA: customer AS65020 provider AS65021
EOF
}
die() {
printf '[00_2_generate_ccr] ERROR: %s\n' "$*" >&2
exit 1
}
if [ "${OUT_PATH}" = "-h" ] || [ "${OUT_PATH}" = "--help" ]; then
usage
exit 0
fi
[ -n "${OUT_PATH}" ] || die "missing OUT.ccr"
mkdir -p "$(dirname "${OUT_PATH}")"
decode_base64() {
if command -v base64 >/dev/null 2>&1; then
base64 -d
return
fi
if command -v openssl >/dev/null 2>&1; then
openssl base64 -d
return
fi
die "base64 or openssl command is required to write CCR fixture"
}
cat <<'EOF' | decode_base64 >"${OUT_PATH}"
MIIBAwYLKoZIhvcNAQkQATaggfMwgfACAQAwDQYJYIZIAWUDBAIBBQAYDzIwMjYwNDAzMDAwMjAx
WqJ9MHswVzAaAgMA/ekwEzARBAIAATALMAkDBAAKAAACARgwGgIDAP3rMBMwEQQCAAEwCzAJAwQA
CgABAgEYMB0CAwD96jAWMBQEAgACMA4wDAMHACABDbgAAQIBMAQgsUgCHfy5AJcFp5RymOD0HuGO
wfhoWpll90ugYECGQPSjTDBKMCYwFgIDAP3yMA8CAwD98wIDAP30AgMA/fUwDAIDAP38MAUCAwD9
/QQgRPxVPOOUsUuuV4tZ8BG5PHNtKiaEr23Ird+yNxlWJ+w=
EOF

View File

@@ -1,96 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PRECHECK_WORK_DIR="${PRECHECK_WORK_DIR:-${ROOT_DIR}/.precheck}"
PRECHECK_PROJECT="${PRECHECK_PROJECT:-rpki-precheck}"
PRECHECK_ADMIN_TOKEN="${PRECHECK_ADMIN_TOKEN:-rpki-precheck-token}"
PRECHECK_PROTOCOL_VERSION="${PRECHECK_PROTOCOL_VERSION:-2}"
PRECHECK_SERVER_IMAGE="${PRECHECK_SERVER_IMAGE:-${RPKI_RTR_SERVER_IMAGE:-rpki-rtr-arm64:v1.2}}"
PRECHECK_CLIENT_IMAGE="${PRECHECK_CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE:-rpki-rtr-debug-client-arm64:v1.2}}"
log() {
printf '[00_1_pre_check_prepare] %s\n' "$*"
}
die() {
printf '[00_1_pre_check_prepare] ERROR: %s\n' "$*" >&2
exit 1
}
abs_dir() {
local path="$1"
mkdir -p "${path}"
cd "${path}" && pwd -P
}
[ -f "${SCRIPT_DIR}/00_2_generate_ccr.sh" ] || die "missing ${SCRIPT_DIR}/00_2_generate_ccr.sh"
rm -rf "${PRECHECK_WORK_DIR}"
mkdir -p \
"${PRECHECK_WORK_DIR}/ccr" \
"${PRECHECK_WORK_DIR}/slurm" \
"${PRECHECK_WORK_DIR}/db" \
"${PRECHECK_WORK_DIR}/logs/server" \
"${PRECHECK_WORK_DIR}/logs/client" \
"${PRECHECK_WORK_DIR}/report"
bash "${SCRIPT_DIR}/00_2_generate_ccr.sh" \
"${PRECHECK_WORK_DIR}/ccr/20260403T000201Z-precheck.ccr"
cat >"${PRECHECK_WORK_DIR}/slurm/precheck.slurm" <<'EOF'
{
"slurmVersion": 2,
"validationOutputFilters": {
"prefixFilters": [],
"bgpsecFilters": [],
"aspaFilters": []
},
"locallyAddedAssertions": {
"prefixAssertions": [
{
"prefix": "203.0.113.0/24",
"maxPrefixLength": 24,
"asn": 64496,
"comment": "pre-check local assertion"
}
],
"bgpsecAssertions": [],
"aspaAssertions": []
}
}
EOF
PRECHECK_WORK_DIR_ABS="$(abs_dir "${PRECHECK_WORK_DIR}")"
cat >"${PRECHECK_WORK_DIR}/precheck.env" <<EOF
PRECHECK_WORK_DIR=${PRECHECK_WORK_DIR_ABS}
PRECHECK_PROJECT=${PRECHECK_PROJECT}
RPKI_RTR_SERVER_IMAGE=${PRECHECK_SERVER_IMAGE}
RPKI_RTR_CLIENT_IMAGE=${PRECHECK_CLIENT_IMAGE}
PRECHECK_SERVER_COMPOSE_FILE=${ROOT_DIR}/deploy/server/docker-compose.tcp.yml
PRECHECK_CLIENT_COMPOSE_FILE=${ROOT_DIR}/deploy/client/docker-compose.tcp.yml
RPKI_RTR_CCR_HOST_DIR=${PRECHECK_WORK_DIR_ABS}/ccr
RPKI_RTR_SLURM_HOST_DIR=${PRECHECK_WORK_DIR_ABS}/slurm
RPKI_RTR_DB_HOST_DIR=${PRECHECK_WORK_DIR_ABS}/db
RPKI_RTR_LOG_HOST_DIR=${PRECHECK_WORK_DIR_ABS}/logs/server
RPKI_RTR_CLIENT_LOG_HOST_DIR=${PRECHECK_WORK_DIR_ABS}/logs/client
RPKI_RTR_REPORT_HOST_DIR=${PRECHECK_WORK_DIR_ABS}/report
RPKI_RTR_CCR_DIR=/app/data
RPKI_RTR_SLURM_DIR=/app/slurm
RPKI_RTR_DB_PATH=/app/rtr-db
RPKI_RTR_REPORT_DIR=/app/report
RPKI_RTR_ADMIN_ADDR=0.0.0.0:8323
RPKI_RTR_ADMIN_TOKEN=${PRECHECK_ADMIN_TOKEN}
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS=5
RPKI_RTR_REPORT_HISTORY_LIMIT=3
RPKI_RTR_TIMEZONE=Asia/Shanghai
RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS=30
RPKI_RTR_MAX_DELTA=10
RPKI_RTR_MAX_CONNECTIONS=32
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES=8
RPKI_RTR_TCP_SERVER_ADDR=rpki-rtr:323
RPKI_RTR_PROTOCOL_VERSION=${PRECHECK_PROTOCOL_VERSION}
RUST_LOG=info
EOF

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
PRECHECK_WORK_DIR="${PRECHECK_WORK_DIR:-${ROOT_DIR}/.precheck}"
ENV_FILE="${PRECHECK_ENV_FILE:-${PRECHECK_WORK_DIR}/precheck.env}"
KEEP_PRECHECK="${KEEP_PRECHECK:-0}"
if [ -f "${ENV_FILE}" ]; then
set -a
# shellcheck disable=SC1090
. "${ENV_FILE}"
set +a
fi
PRECHECK_PROJECT="${PRECHECK_PROJECT:-rpki-precheck}"
PRECHECK_SERVER_COMPOSE_FILE="${PRECHECK_SERVER_COMPOSE_FILE:-${ROOT_DIR}/deploy/server/docker-compose.tcp.yml}"
PRECHECK_CLIENT_COMPOSE_FILE="${PRECHECK_CLIENT_COMPOSE_FILE:-${ROOT_DIR}/deploy/client/docker-compose.tcp.yml}"
COMPOSE_BIN=()
log() {
printf '[00_1_pre_check_cleanup] %s\n' "$*"
}
compose_run() {
local compose_file="$1"
shift
if [ "${#COMPOSE_BIN[@]}" -eq 0 ]; then
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
return 0
fi
fi
"${COMPOSE_BIN[@]}" --env-file "${ENV_FILE}" -p "${PRECHECK_PROJECT}" -f "${compose_file}" "$@"
}
if command -v docker >/dev/null 2>&1; then
log "stopping pre-check client compose stack"
[ -f "${PRECHECK_CLIENT_COMPOSE_FILE}" ] && compose_run "${PRECHECK_CLIENT_COMPOSE_FILE}" down --remove-orphans >/dev/null 2>&1 || true
log "stopping pre-check server compose stack"
[ -f "${PRECHECK_SERVER_COMPOSE_FILE}" ] && compose_run "${PRECHECK_SERVER_COMPOSE_FILE}" down --remove-orphans >/dev/null 2>&1 || true
fi
if [ "${KEEP_PRECHECK}" = "1" ]; then
log "KEEP_PRECHECK=1, preserving ${PRECHECK_WORK_DIR}"
else
rm -rf "${PRECHECK_WORK_DIR}"
log "removed ${PRECHECK_WORK_DIR}"
fi

View File

@@ -1,221 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
COMMON_ENV_FILE="${ROOT_DIR}/environment.server"
SERVER_ENV_FILE="${ROOT_DIR}/deploy/server/.env"
CONSOLE_ENV_FILE="${ROOT_DIR}/deploy/console/.env"
CONSOLE_COMPOSE_FILE="${ROOT_DIR}/deploy/console/docker-compose.yml"
MODE="${RPKI_RTR_SERVER_MODE:-tcp}"
COMPOSE_PROJECT="${RPKI_RTR_COMPOSE_PROJECT:-rpki-rtr}"
CONSOLE_COMPOSE_PROJECT="${RPKI_CONSOLE_COMPOSE_PROJECT:-rpki-console}"
WAIT_SECS="${RPKI_START_WAIT_SECS:-20}"
usage() {
cat <<'EOF'
Usage: scripts/01_start.sh [options]
Read root ./environment.server, update deploy/server/.env, then start the RTR server and console.
Options:
--env FILE Common env file. Default: ./environment.server
--mode MODE Server mode: tcp|tls|ssh|base. Default: tcp
--wait SECS Wait timeout for container running checks. Default: 20
-h, --help Show this help.
EOF
}
log() {
printf '[01_start] %s\n' "$*" >&2
}
stage() {
printf '\n====== %s ======\n' "$*" >&2
}
die() {
printf '[01_start] ERROR: %s\n' "$*" >&2
exit 1
}
set_env_var() {
local env_file="$1"
local key="$2"
local value="$3"
local tmp_file
tmp_file="${env_file}.tmp.$$"
awk -v key="${key}" -v value="${value}" '
BEGIN { done = 0 }
$0 ~ "^[[:space:]]*#?[[:space:]]*" key "=" {
if (!done) {
print key "=" value
done = 1
}
next
}
{ print }
END {
if (!done) {
print key "=" value
}
}
' "${env_file}" >"${tmp_file}"
mv "${tmp_file}" "${env_file}"
}
compose_file_for_mode() {
case "$1" in
tcp) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.tcp.yml" ;;
tls) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.tls.yml" ;;
ssh) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.ssh.yml" ;;
base) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.yml" ;;
*) die "unsupported mode '$1', expected tcp|tls|ssh|base" ;;
esac
}
update_env_from_common() {
local target_env_file="$1"
local updated_count=0
local line key value
while IFS= read -r line || [ -n "${line}" ]; do
case "${line}" in
''|\#*) continue ;;
*=*)
key="${line%%=*}"
value="${line#*=}"
set_env_var "${target_env_file}" "${key}" "${value}"
updated_count=$((updated_count + 1))
;;
esac
done <"${COMMON_ENV_FILE}"
printf '%s\n' "${updated_count}"
}
server_compose() {
"${COMPOSE_BIN[@]}" --env-file "${SERVER_ENV_FILE}" -p "${COMPOSE_PROJECT}" -f "${SERVER_COMPOSE_FILE}" "$@"
}
console_compose() {
"${COMPOSE_BIN[@]}" --env-file "${CONSOLE_ENV_FILE}" -p "${CONSOLE_COMPOSE_PROJECT}" -f "${CONSOLE_COMPOSE_FILE}" "$@"
}
container_running() {
local compose_kind="$1"
local service="$2"
local cid state
if [ "${compose_kind}" = "server" ]; then
cid="$(server_compose ps -q "${service}" 2>/dev/null || true)"
else
cid="$(console_compose ps -q "${service}" 2>/dev/null || true)"
fi
[ -n "${cid}" ] || return 1
state="$(docker inspect -f '{{.State.Running}}' "${cid}" 2>/dev/null || true)"
[ "${state}" = "true" ]
}
show_service_debug() {
local compose_kind="$1"
local service="$2"
log "${service} is not running, current compose status:"
if [ "${compose_kind}" = "server" ]; then
server_compose ps "${service}" >&2 || true
log "${service} recent logs:"
server_compose logs --tail=80 "${service}" >&2 || true
else
console_compose ps "${service}" >&2 || true
log "${service} recent logs:"
console_compose logs --tail=80 "${service}" >&2 || true
fi
}
wait_for_running() {
local compose_kind="$1"
local service="$2"
local deadline=$((SECONDS + WAIT_SECS))
while [ "${SECONDS}" -lt "${deadline}" ]; do
if container_running "${compose_kind}" "${service}"; then
log "${service} is running"
return 0
fi
sleep 1
done
show_service_debug "${compose_kind}" "${service}"
die "${service} failed to reach running state after ${WAIT_SECS}s"
}
while [ "$#" -gt 0 ]; do
case "$1" in
--env)
[ "$#" -ge 2 ] || die "--env requires a value"
COMMON_ENV_FILE="$2"
shift 2
;;
--mode)
[ "$#" -ge 2 ] || die "--mode requires a value"
MODE="$2"
shift 2
;;
--wait)
[ "$#" -ge 2 ] || die "--wait requires a value"
WAIT_SECS="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
SERVER_COMPOSE_FILE="$(compose_file_for_mode "${MODE}")"
stage "Checking Docker"
command -v docker >/dev/null 2>&1 || die "docker command not found"
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
die "docker compose is not available"
fi
[ -f "${SERVER_ENV_FILE}" ] || die "server env file not found: ${SERVER_ENV_FILE}"
[ -f "${SERVER_COMPOSE_FILE}" ] || die "server compose file not found: ${SERVER_COMPOSE_FILE}"
[ -f "${CONSOLE_ENV_FILE}" ] || die "console env file not found: ${CONSOLE_ENV_FILE}"
[ -f "${CONSOLE_COMPOSE_FILE}" ] || die "console compose file not found: ${CONSOLE_COMPOSE_FILE}"
if [ -f "${COMMON_ENV_FILE}" ]; then
stage "Reading Environment"
server_updated_count="$(update_env_from_common "${SERVER_ENV_FILE}")"
log "updated ${server_updated_count} server env variable(s)"
else
stage "Reading Environment"
log "environment.server file not found, skip env update"
fi
stage "Starting RTR Server"
log "starting docker compose service: rpki-rtr"
server_compose up -d --no-build rpki-rtr
log "waiting for rpki-rtr to be running"
wait_for_running server rpki-rtr
stage "Starting Console"
log "starting docker compose service: rpki-console-ui"
console_compose up -d --no-build rpki-console-ui
log "waiting for rpki-console-ui to be running"
wait_for_running console rpki-console-ui
stage "Start Finished"

View File

@@ -1,184 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER_ENV_FILE="${ROOT_DIR}/deploy/server/.env"
CONSOLE_ENV_FILE="${ROOT_DIR}/deploy/console/.env"
SERVER_COMPOSE_FILE="${ROOT_DIR}/deploy/server/docker-compose.tcp.yml"
CONSOLE_COMPOSE_FILE="${ROOT_DIR}/deploy/console/docker-compose.yml"
SERVER_PROJECT="${RPKI_RTR_COMPOSE_PROJECT:-rpki-rtr}"
CONSOLE_PROJECT="${RPKI_CONSOLE_COMPOSE_PROJECT:-rpki-console}"
WAIT_SECS="${RPKI_SELF_CHECK_WAIT_SECS:-60}"
usage() {
cat <<'EOF'
Usage: scripts/02_self_check.sh [options]
Check whether the RTR server and console started by scripts/01_start.sh are healthy.
Options:
--wait SECS Wait timeout for each check. Default: 60
-h, --help Show this help.
EOF
}
log() {
printf '[02_self_check] %s\n' "$*" >&2
}
stage() {
printf '\n====== %s ======\n' "$*" >&2
}
die() {
printf '[02_self_check] ERROR: %s\n' "$*" >&2
exit 1
}
while [ "$#" -gt 0 ]; do
case "$1" in
--wait)
[ "$#" -ge 2 ] || die "--wait requires a value"
WAIT_SECS="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
command -v docker >/dev/null 2>&1 || die "docker command not found"
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
die "docker compose is not available"
fi
[ -f "${SERVER_ENV_FILE}" ] || die "server env file not found: ${SERVER_ENV_FILE}"
[ -f "${SERVER_COMPOSE_FILE}" ] || die "server compose file not found: ${SERVER_COMPOSE_FILE}"
[ -f "${CONSOLE_ENV_FILE}" ] || die "console env file not found: ${CONSOLE_ENV_FILE}"
[ -f "${CONSOLE_COMPOSE_FILE}" ] || die "console compose file not found: ${CONSOLE_COMPOSE_FILE}"
set -a
# shellcheck disable=SC1090
. "${SERVER_ENV_FILE}"
# shellcheck disable=SC1090
. "${CONSOLE_ENV_FILE}"
set +a
RTR_PORT="${RPKI_RTR_TCP_HOST_PORT:-323}"
ADMIN_PORT="${RPKI_RTR_ADMIN_ADDR##*:}"
ADMIN_PORT="${ADMIN_PORT:-8323}"
ADMIN_TOKEN="${RPKI_RTR_ADMIN_TOKEN:-}"
CONSOLE_PORT="${RPKI_CONSOLE_UI_PORT:-5173}"
server_compose() {
"${COMPOSE_BIN[@]}" --env-file "${SERVER_ENV_FILE}" -p "${SERVER_PROJECT}" -f "${SERVER_COMPOSE_FILE}" "$@"
}
console_compose() {
"${COMPOSE_BIN[@]}" --env-file "${CONSOLE_ENV_FILE}" -p "${CONSOLE_PROJECT}" -f "${CONSOLE_COMPOSE_FILE}" "$@"
}
container_running() {
local compose_kind="$1"
local service="$2"
local cid state
if [ "${compose_kind}" = "server" ]; then
cid="$(server_compose ps -q "${service}" 2>/dev/null || true)"
else
cid="$(console_compose ps -q "${service}" 2>/dev/null || true)"
fi
[ -n "${cid}" ] || return 1
state="$(docker inspect -f '{{.State.Running}}' "${cid}" 2>/dev/null || true)"
[ "${state}" = "true" ]
}
tcp_open() {
local host="$1"
local port="$2"
if command -v nc >/dev/null 2>&1; then
nc -z "${host}" "${port}" >/dev/null 2>&1
return
fi
(exec 3<>"/dev/tcp/${host}/${port}") >/dev/null 2>&1
}
http_get() {
local url="$1"
local token="${2:-}"
if command -v curl >/dev/null 2>&1; then
if [ -n "${token}" ]; then
curl -fsS -H "Authorization: Bearer ${token}" "${url}"
else
curl -fsS "${url}"
fi
return
fi
die "curl command is required for HTTP health checks"
}
wait_until() {
local label="$1"
shift
local deadline=$((SECONDS + WAIT_SECS))
while [ "${SECONDS}" -lt "${deadline}" ]; do
if "$@"; then
log "${label}: ok"
return 0
fi
sleep 1
done
die "${label}: failed after ${WAIT_SECS}s"
}
show_command() {
printf '[02_self_check] check command: %s\n' "$*" >&2
}
check_admin_health() {
local body
body="$(http_get "http://127.0.0.1:${ADMIN_PORT}/admin/rtr/health" "${ADMIN_TOKEN}" 2>/dev/null || true)"
printf '%s' "${body}" | grep -q '"status"[[:space:]]*:[[:space:]]*"ok"'
}
check_console_http() {
http_get "http://127.0.0.1:${CONSOLE_PORT}/" >/dev/null 2>&1
}
stage "Checking Containers"
show_command "${COMPOSE_BIN[*]} --env-file ${SERVER_ENV_FILE} -p ${SERVER_PROJECT} -f ${SERVER_COMPOSE_FILE} ps -q rpki-rtr && docker inspect"
wait_until "server container" container_running server rpki-rtr
show_command "${COMPOSE_BIN[*]} --env-file ${CONSOLE_ENV_FILE} -p ${CONSOLE_PROJECT} -f ${CONSOLE_COMPOSE_FILE} ps -q rpki-console-ui && docker inspect"
wait_until "console container" container_running console rpki-console-ui
stage "Checking Server"
show_command "tcp connect 127.0.0.1:${RTR_PORT}"
wait_until "RTR port ${RTR_PORT}" tcp_open 127.0.0.1 "${RTR_PORT}"
show_command "curl -fsS -H 'Authorization: Bearer <token>' http://127.0.0.1:${ADMIN_PORT}/admin/rtr/health"
wait_until "Admin health" check_admin_health
stage "Checking Console"
show_command "tcp connect 127.0.0.1:${CONSOLE_PORT}"
wait_until "Console port ${CONSOLE_PORT}" tcp_open 127.0.0.1 "${CONSOLE_PORT}"
show_command "curl -fsS http://127.0.0.1:${CONSOLE_PORT}/"
wait_until "Console HTTP" check_console_http
stage "Self-Check Passed"

View File

@@ -1,92 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER_ENV_FILE="${ROOT_DIR}/deploy/server/.env"
CONSOLE_ENV_FILE="${ROOT_DIR}/deploy/console/.env"
MODE="${RPKI_RTR_SERVER_MODE:-tcp}"
SERVER_PROJECT="${RPKI_RTR_COMPOSE_PROJECT:-rpki-rtr}"
CONSOLE_PROJECT="${RPKI_CONSOLE_COMPOSE_PROJECT:-rpki-console}"
usage() {
cat <<'EOF'
Usage: scripts/03_shutdown.sh [options]
Stop services started by scripts/01_start.sh.
Options:
--mode MODE Server mode: tcp|tls|ssh|base. Default: tcp
-h, --help Show this help.
EOF
}
log() {
printf '[03_shutdown] %s\n' "$*" >&2
}
stage() {
printf '\n====== %s ======\n' "$*" >&2
}
die() {
printf '[03_shutdown] ERROR: %s\n' "$*" >&2
exit 1
}
compose_file_for_mode() {
case "$1" in
tcp) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.tcp.yml" ;;
tls) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.tls.yml" ;;
ssh) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.ssh.yml" ;;
base) printf '%s\n' "${ROOT_DIR}/deploy/server/docker-compose.yml" ;;
*) die "unsupported mode '$1', expected tcp|tls|ssh|base" ;;
esac
}
while [ "$#" -gt 0 ]; do
case "$1" in
--mode)
[ "$#" -ge 2 ] || die "--mode requires a value"
MODE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
SERVER_COMPOSE_FILE="$(compose_file_for_mode "${MODE}")"
CONSOLE_COMPOSE_FILE="${ROOT_DIR}/deploy/console/docker-compose.yml"
stage "Checking Docker"
command -v docker >/dev/null 2>&1 || die "docker command not found"
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
die "docker compose is not available"
fi
stage "Stopping Console"
if [ -f "${CONSOLE_ENV_FILE}" ] && [ -f "${CONSOLE_COMPOSE_FILE}" ]; then
"${COMPOSE_BIN[@]}" --env-file "${CONSOLE_ENV_FILE}" -p "${CONSOLE_PROJECT}" -f "${CONSOLE_COMPOSE_FILE}" down --remove-orphans
else
log "console compose/env not found, skip"
fi
stage "Stopping RTR Server"
if [ -f "${SERVER_ENV_FILE}" ] && [ -f "${SERVER_COMPOSE_FILE}" ]; then
"${COMPOSE_BIN[@]}" --env-file "${SERVER_ENV_FILE}" -p "${SERVER_PROJECT}" -f "${SERVER_COMPOSE_FILE}" down --remove-orphans
else
log "server compose/env not found, skip"
fi
stage "Shutdown Finished"

View File

@@ -1,152 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER_DIR="${ROOT_DIR}/deploy/server"
SERVER_ENV_FILE="${SERVER_DIR}/.env"
SERVER_COMPOSE_FILE="${SERVER_DIR}/docker-compose.tcp.yml"
COMPOSE_PROJECT="${RPKI_RTR_COMPOSE_PROJECT:-rpki-rtr}"
FORCE=0
usage() {
cat <<'EOF'
Usage: scripts/04_cleanup.sh [options]
Remove server generated directories: logs, reports, and RTR database.
Options:
--force Clean even if the server container appears to be running.
-h, --help Show this help.
EOF
}
log() {
printf '[04_cleanup] %s\n' "$*" >&2
}
stage() {
printf '\n====== %s ======\n' "$*" >&2
}
die() {
printf '[04_cleanup] ERROR: %s\n' "$*" >&2
exit 1
}
env_value() {
local key="$1"
local default_value="$2"
local line
line="$(grep -E "^[[:space:]]*${key}=" "${SERVER_ENV_FILE}" | tail -n 1 || true)"
if [ -n "${line}" ]; then
printf '%s\n' "${line#*=}"
else
printf '%s\n' "${default_value}"
fi
}
resolve_host_path() {
local raw_path="$1"
case "${raw_path}" in
/*|[A-Za-z]:/*|[A-Za-z]:\\*)
printf '%s\n' "${raw_path}"
;;
*)
(cd "${SERVER_DIR}" && cd "${raw_path}" 2>/dev/null && pwd -P) || printf '%s/%s\n' "${SERVER_DIR}" "${raw_path}"
;;
esac
}
compose_available() {
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
return 0
fi
if command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
return 0
fi
return 1
}
server_running() {
local cid state
[ -f "${SERVER_COMPOSE_FILE}" ] || return 1
compose_available || return 1
cid="$("${COMPOSE_BIN[@]}" --env-file "${SERVER_ENV_FILE}" -p "${COMPOSE_PROJECT}" -f "${SERVER_COMPOSE_FILE}" ps -q rpki-rtr 2>/dev/null || true)"
[ -n "${cid}" ] || return 1
state="$(docker inspect -f '{{.State.Running}}' "${cid}" 2>/dev/null || true)"
[ "${state}" = "true" ]
}
clean_dir() {
local label="$1"
local path="$2"
local root_with_slash
if [ ! -d "${path}" ]; then
log "${label}: ${path} not found, skip"
return 0
fi
root_with_slash="${ROOT_DIR}/"
case "${path}/" in
"${root_with_slash}"*) ;;
*)
log "ERROR: ${label} path is outside repo root, refuse to remove: ${path}"
return 1
;;
esac
log "${label}: removing ${path}"
if ! rm -rf "${path}"; then
log "ERROR: failed to remove ${label}: ${path}"
return 1
fi
}
while [ "$#" -gt 0 ]; do
case "$1" in
--force)
FORCE=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
stage "Checking Server"
[ -f "${SERVER_ENV_FILE}" ] || die "server env file not found: ${SERVER_ENV_FILE}"
if [ "${FORCE}" -ne 1 ] && command -v docker >/dev/null 2>&1 && server_running; then
die "rpki-rtr is still running, run scripts/03_shutdown.sh first or use --force"
fi
LOG_DIR="${ROOT_DIR}/logs"
REPORT_DIR="$(resolve_host_path "$(env_value RPKI_RTR_REPORT_HOST_DIR '../../report')")"
DB_DIR="$(resolve_host_path "$(env_value RPKI_RTR_DB_HOST_DIR '../../rtr-db')")"
stage "Removing Server Files"
failed_count=0
clean_dir "logs" "${LOG_DIR}" || failed_count=$((failed_count + 1))
clean_dir "report" "${REPORT_DIR}" || failed_count=$((failed_count + 1))
clean_dir "rtr-db" "${DB_DIR}" || failed_count=$((failed_count + 1))
if [ "${failed_count}" -gt 0 ]; then
die "${failed_count} cleanup item(s) failed"
fi
stage "Cleanup Finished"

View File

@@ -1,303 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${ROOT_DIR}/certs"
TLS_DIR="${OUT_DIR}/tls"
SSH_DIR="${OUT_DIR}/ssh"
SERVER_DNS="localhost"
FORCE=0
DO_TLS=1
DO_SSH=1
usage() {
cat <<'USAGE'
Generate local development TLS certificates and SSH keys for RTR Docker deploys.
Usage:
scripts/10_generate-certs.sh [options]
Options:
--out-dir DIR Output directory, default: ./certs
--server-dns NAME TLS server dNSName SAN, default: localhost
--tls-only Generate only TLS files
--ssh-only Generate only SSH files
--force Overwrite existing files
-h, --help Show this help
Generated TLS files:
certs/tls/client-ca.crt
certs/tls/client-ca.key
certs/tls/server-dns.crt
certs/tls/server-dns.key
certs/tls/client-good.crt
certs/tls/client-good.key
Generated SSH files:
certs/ssh/ssh_host_rsa_key
certs/ssh/ssh_host_rsa_key.pub
certs/ssh/rtr-client.key
certs/ssh/rtr-client.key.pub
certs/ssh/rtr-authorized_keys
USAGE
}
die() {
echo "error: $*" >&2
exit 1
}
info() {
echo "[generate-certs] $*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
}
run_quiet() {
local log_file
log_file="$(mktemp)"
if "$@" >"${log_file}" 2>&1; then
rm -f "${log_file}"
return 0
fi
cat "${log_file}" >&2
rm -f "${log_file}"
return 1
}
ensure_abs_out_dir() {
case "${OUT_DIR}" in
/*) ;;
[A-Za-z]:*) ;;
*) OUT_DIR="${ROOT_DIR}/${OUT_DIR}" ;;
esac
TLS_DIR="${OUT_DIR}/tls"
SSH_DIR="${OUT_DIR}/ssh"
}
check_overwrite() {
local path="$1"
if [[ -e "${path}" && "${FORCE}" != "1" ]]; then
die "${path} already exists; rerun with --force to overwrite"
fi
}
write_tls_config_files() {
local tmp_dir="$1"
cat >"${tmp_dir}/ca.cnf" <<'EOF'
[ req ]
distinguished_name = req_distinguished_name
prompt = no
[ req_distinguished_name ]
CN = rpki-rtr-client-ca
EOF
cat >"${tmp_dir}/server.cnf" <<EOF
[ req ]
distinguished_name = req_distinguished_name
prompt = no
[ req_distinguished_name ]
CN = ${SERVER_DNS}
EOF
cat >"${tmp_dir}/client.cnf" <<'EOF'
[ req ]
distinguished_name = req_distinguished_name
prompt = no
[ req_distinguished_name ]
CN = rpki-rtr-client
EOF
cat >"${tmp_dir}/server.ext" <<EOF
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:${SERVER_DNS}
EOF
cat >"${tmp_dir}/client.ext" <<'EOF'
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=clientAuth
EOF
}
generate_tls() {
need_cmd openssl
mkdir -p "${TLS_DIR}"
info "generating TLS certificates in ${TLS_DIR}"
info "TLS server dNSName SAN: ${SERVER_DNS}"
local files=(
"${TLS_DIR}/client-ca.crt"
"${TLS_DIR}/client-ca.key"
"${TLS_DIR}/server-dns.crt"
"${TLS_DIR}/server-dns.key"
"${TLS_DIR}/client-good.crt"
"${TLS_DIR}/client-good.key"
)
for file in "${files[@]}"; do
check_overwrite "${file}"
done
local tmp_dir
tmp_dir="$(mktemp -d)"
write_tls_config_files "${tmp_dir}"
run_quiet openssl genrsa -out "${TLS_DIR}/client-ca.key" 2048
info "created TLS CA key: ${TLS_DIR}/client-ca.key"
run_quiet openssl req -x509 -new -nodes \
-config "${tmp_dir}/ca.cnf" \
-key "${TLS_DIR}/client-ca.key" \
-sha256 -days 3650 \
-out "${TLS_DIR}/client-ca.crt"
info "created TLS CA cert: ${TLS_DIR}/client-ca.crt"
run_quiet openssl genrsa -out "${TLS_DIR}/server-dns.key" 2048
info "created TLS server key: ${TLS_DIR}/server-dns.key"
run_quiet openssl req -new \
-config "${tmp_dir}/server.cnf" \
-key "${TLS_DIR}/server-dns.key" \
-out "${tmp_dir}/server-dns.csr"
run_quiet openssl x509 -req \
-in "${tmp_dir}/server-dns.csr" \
-CA "${TLS_DIR}/client-ca.crt" \
-CAkey "${TLS_DIR}/client-ca.key" \
-CAcreateserial \
-out "${TLS_DIR}/server-dns.crt" \
-days 3650 -sha256 \
-extfile "${tmp_dir}/server.ext"
info "created TLS server cert: ${TLS_DIR}/server-dns.crt"
run_quiet openssl genrsa -out "${TLS_DIR}/client-good.key" 2048
info "created TLS client key: ${TLS_DIR}/client-good.key"
run_quiet openssl req -new \
-config "${tmp_dir}/client.cnf" \
-key "${TLS_DIR}/client-good.key" \
-out "${tmp_dir}/client-good.csr"
run_quiet openssl x509 -req \
-in "${tmp_dir}/client-good.csr" \
-CA "${TLS_DIR}/client-ca.crt" \
-CAkey "${TLS_DIR}/client-ca.key" \
-CAcreateserial \
-out "${TLS_DIR}/client-good.crt" \
-days 3650 -sha256 \
-extfile "${tmp_dir}/client.ext"
info "created TLS client cert: ${TLS_DIR}/client-good.crt"
rm -f "${TLS_DIR}/client-ca.srl"
rm -rf "${tmp_dir}"
chmod 600 "${TLS_DIR}"/*.key
chmod 644 "${TLS_DIR}"/*.crt
}
generate_ssh_key() {
local path="$1"
local comment="$2"
check_overwrite "${path}"
check_overwrite "${path}.pub"
rm -f "${path}" "${path}.pub"
run_quiet ssh-keygen -q -t rsa -b 3072 -m PEM -N "" -C "${comment}" -f "${path}"
}
generate_ssh() {
need_cmd ssh-keygen
mkdir -p "${SSH_DIR}"
info "generating SSH keys in ${SSH_DIR}"
generate_ssh_key "${SSH_DIR}/ssh_host_rsa_key" "rpki-rtr-host"
info "created SSH host key: ${SSH_DIR}/ssh_host_rsa_key"
generate_ssh_key "${SSH_DIR}/rtr-client.key" "rpki-rtr-debug-client"
info "created SSH client key: ${SSH_DIR}/rtr-client.key"
check_overwrite "${SSH_DIR}/rtr-authorized_keys"
cp "${SSH_DIR}/rtr-client.key.pub" "${SSH_DIR}/rtr-authorized_keys"
info "created SSH authorized_keys: ${SSH_DIR}/rtr-authorized_keys"
chmod 600 \
"${SSH_DIR}/ssh_host_rsa_key" \
"${SSH_DIR}/rtr-client.key"
chmod 644 "${SSH_DIR}"/*.pub "${SSH_DIR}/rtr-authorized_keys"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--out-dir)
[[ $# -ge 2 ]] || die "--out-dir requires a value"
OUT_DIR="$2"
shift 2
;;
--server-dns)
[[ $# -ge 2 ]] || die "--server-dns requires a value"
SERVER_DNS="$2"
shift 2
;;
--tls-only)
DO_TLS=1
DO_SSH=0
shift
;;
--ssh-only)
DO_TLS=0
DO_SSH=1
shift
;;
--force)
FORCE=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
ensure_abs_out_dir
if [[ "${DO_TLS}" == "1" ]]; then
generate_tls
fi
if [[ "${DO_SSH}" == "1" ]]; then
generate_ssh
fi
info "done"
info "generated files under ${OUT_DIR}"
if [[ "${DO_TLS}" == "1" ]]; then
cat <<EOF
TLS files:
${TLS_DIR}/client-ca.crt
${TLS_DIR}/server-dns.crt
${TLS_DIR}/server-dns.key
${TLS_DIR}/client-good.crt
${TLS_DIR}/client-good.key
${TLS_DIR}/client-ca.key (local signing key; not mounted by compose)
EOF
fi
if [[ "${DO_SSH}" == "1" ]]; then
cat <<EOF
SSH files:
${SSH_DIR}/ssh_host_rsa_key
${SSH_DIR}/ssh_host_rsa_key.pub
${SSH_DIR}/rtr-client.key
${SSH_DIR}/rtr-client.key.pub
${SSH_DIR}/rtr-authorized_keys
EOF
fi

View File

@@ -1,136 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
COMMON_ENV_FILE="${ROOT_DIR}/environment.client"
CLIENT_ENV_FILE="${ROOT_DIR}/deploy/client/.env"
MODE="${RPKI_RTR_CLIENT_MODE:-tcp}"
COMPOSE_PROJECT="${RPKI_RTR_CLIENT_COMPOSE_PROJECT:-rpki-rtr-client}"
usage() {
cat <<'EOF'
Usage: scripts/11_start_client.sh [options]
Read root ./environment.client, update deploy/client/.env, then start the RTR debug client.
Options:
--mode MODE Client mode: tcp|tls|ssh. Default: tcp
--env FILE Client common env file. Default: ./environment.client
-h, --help Show this help.
EOF
}
log() {
printf '[11_start_client] %s\n' "$*" >&2
}
stage() {
printf '\n====== %s ======\n' "$*" >&2
}
die() {
printf '[11_start_client] ERROR: %s\n' "$*" >&2
exit 1
}
set_env_var() {
local env_file="$1"
local key="$2"
local value="$3"
local tmp_file
tmp_file="${env_file}.tmp.$$"
awk -v key="${key}" -v value="${value}" '
BEGIN { done = 0 }
$0 ~ "^[[:space:]]*#?[[:space:]]*" key "=" {
if (!done) {
print key "=" value
done = 1
}
next
}
{ print }
END {
if (!done) {
print key "=" value
}
}
' "${env_file}" >"${tmp_file}"
mv "${tmp_file}" "${env_file}"
}
compose_file_for_mode() {
case "$1" in
tcp) printf '%s\n' "${ROOT_DIR}/deploy/client/docker-compose.tcp.yml" ;;
tls) printf '%s\n' "${ROOT_DIR}/deploy/client/docker-compose.tls.yml" ;;
ssh) printf '%s\n' "${ROOT_DIR}/deploy/client/docker-compose.ssh.yml" ;;
*) die "unsupported mode '$1', expected tcp|tls|ssh" ;;
esac
}
while [ "$#" -gt 0 ]; do
case "$1" in
--mode)
[ "$#" -ge 2 ] || die "--mode requires a value"
MODE="$2"
shift 2
;;
--env)
[ "$#" -ge 2 ] || die "--env requires a value"
COMMON_ENV_FILE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
CLIENT_COMPOSE_FILE="$(compose_file_for_mode "${MODE}")"
stage "Checking Docker"
command -v docker >/dev/null 2>&1 || die "docker command not found"
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
die "docker compose is not available"
fi
[ -f "${CLIENT_ENV_FILE}" ] || die "deploy client env file not found: ${CLIENT_ENV_FILE}"
[ -f "${CLIENT_COMPOSE_FILE}" ] || die "client compose file not found: ${CLIENT_COMPOSE_FILE}"
stage "Reading Client Environment"
if [ -f "${COMMON_ENV_FILE}" ]; then
updated_count=0
while IFS= read -r line || [ -n "${line}" ]; do
case "${line}" in
''|\#*) continue ;;
*=*)
key="${line%%=*}"
value="${line#*=}"
set_env_var "${CLIENT_ENV_FILE}" "${key}" "${value}"
updated_count=$((updated_count + 1))
;;
esac
done <"${COMMON_ENV_FILE}"
log "updated ${updated_count} client env variable(s)"
else
log "environment.client file not found, skip env update"
fi
stage "Starting RTR Debug Client"
log "mode: ${MODE}"
"${COMPOSE_BIN[@]}" --env-file "${CLIENT_ENV_FILE}" -p "${COMPOSE_PROJECT}" -f "${CLIENT_COMPOSE_FILE}" up -d --no-build rtr-debug-client
log "checking client compose status"
"${COMPOSE_BIN[@]}" --env-file "${CLIENT_ENV_FILE}" -p "${COMPOSE_PROJECT}" -f "${CLIENT_COMPOSE_FILE}" ps rtr-debug-client
stage "Client Started"

View File

@@ -1,82 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CLIENT_ENV_FILE="${ROOT_DIR}/deploy/client/.env"
MODE="${RPKI_RTR_CLIENT_MODE:-tcp}"
COMPOSE_PROJECT="${RPKI_RTR_CLIENT_COMPOSE_PROJECT:-rpki-rtr-client}"
usage() {
cat <<'EOF'
Usage: scripts/12_shutdown_client.sh [options]
Stop the RTR debug client started by scripts/11_start_client.sh.
Options:
--mode MODE Client mode: tcp|tls|ssh. Default: tcp
-h, --help Show this help.
EOF
}
log() {
printf '[12_shutdown_client] %s\n' "$*" >&2
}
stage() {
printf '\n====== %s ======\n' "$*" >&2
}
die() {
printf '[12_shutdown_client] ERROR: %s\n' "$*" >&2
exit 1
}
compose_file_for_mode() {
case "$1" in
tcp) printf '%s\n' "${ROOT_DIR}/deploy/client/docker-compose.tcp.yml" ;;
tls) printf '%s\n' "${ROOT_DIR}/deploy/client/docker-compose.tls.yml" ;;
ssh) printf '%s\n' "${ROOT_DIR}/deploy/client/docker-compose.ssh.yml" ;;
*) die "unsupported mode '$1', expected tcp|tls|ssh" ;;
esac
}
while [ "$#" -gt 0 ]; do
case "$1" in
--mode)
[ "$#" -ge 2 ] || die "--mode requires a value"
MODE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown option: $1"
;;
esac
done
CLIENT_COMPOSE_FILE="$(compose_file_for_mode "${MODE}")"
stage "Checking Docker"
command -v docker >/dev/null 2>&1 || die "docker command not found"
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
die "docker compose is not available"
fi
stage "Stopping RTR Debug Client"
log "mode: ${MODE}"
if [ -f "${CLIENT_ENV_FILE}" ] && [ -f "${CLIENT_COMPOSE_FILE}" ]; then
"${COMPOSE_BIN[@]}" --env-file "${CLIENT_ENV_FILE}" -p "${COMPOSE_PROJECT}" -f "${CLIENT_COMPOSE_FILE}" down --remove-orphans
else
log "client compose/env not found, skip"
fi
stage "Client Shutdown Finished"

View File

@@ -1,218 +0,0 @@
# 脚本说明
`scripts/` 目录用于离线安装、启动、健康检查、关闭和清理。
所有脚本建议在仓库根目录执行:
```bash
bash scripts/00_0_install.sh
```
## 目录
- [推荐流程](#推荐流程)
- [环境变量文件](#环境变量文件)
- [00 安装](#00-安装)
- [00 Pre-Check](#00-pre-check)
- [01 启动 Server 和 Console](#01-启动-server-和-console)
- [02 自检](#02-自检)
- [03 关闭 Server 和 Console](#03-关闭-server-和-console)
- [04 清理](#04-清理)
- [10 生成证书](#10-生成证书)
- [11 启动 Client](#11-启动-client)
- [12 关闭 Client](#12-关闭-client)
- [Windows 路径注意事项](#windows-路径注意事项)
## 推荐流程
```bash
# 1. 加载镜像并执行 pre-check。
bash scripts/00_0_install.sh
# 2. 启动真实 server 和 console。
bash scripts/01_start.sh --mode tcp
# 3. 检查 server 和 console 是否正常。
bash scripts/02_self_check.sh
# 4. 关闭 server 和 console。
bash scripts/03_shutdown.sh --mode tcp
# 5. 清理 server 运行产生的文件。
bash scripts/04_cleanup.sh
```
client 单独启动和关闭:
```bash
bash scripts/11_start_client.sh --mode tcp
bash scripts/12_shutdown_client.sh --mode tcp
```
支持的模式:
- server:`tcp`、`tls`、`ssh`、`base`
- client:`tcp`、`tls`、`ssh`
## 环境变量文件
`01_start.sh` 会读取根目录下的 `environment.server`。如果文件存在,会将其中所有 `KEY=VALUE` 同步到 `deploy/server/.env`。
示例:
```env
RPKI_RTR_CCR_HOST_DIR=/opt/rpki-rtr/data
RPKI_RTR_SLURM_HOST_DIR=/opt/rpki-rtr/data
RPKI_RTR_DB_HOST_DIR=/opt/rpki-rtr/rtr-db
RPKI_RTR_LOG_HOST_DIR=/opt/rpki-rtr/logs/server
RPKI_RTR_REPORT_HOST_DIR=/opt/rpki-rtr/report
```
`11_start_client.sh` 会读取根目录下的 `environment.client`。如果文件存在,会将其中所有 `KEY=VALUE` 同步到 `deploy/client/.env`。
如果 `environment.server` 或 `environment.client` 不存在,对应脚本会跳过更新,直接使用已有的 deploy `.env` 文件。
## 00 安装
`00_0_install.sh` 用于从 `images/` 目录加载离线 Docker 镜像,更新 deploy `.env` 中的镜像名,然后执行 pre-check。
默认镜像包查找规则:
- server:`images/rpki-rtr-*.tar`,排除 `rpki-rtr-debug-client-*.tar`
- client:`images/rpki-rtr-debug-client-*.tar`
- console:`images/rpki-console-ui-*.tar`
也可以显式指定镜像包:
```bash
bash scripts/00_0_install.sh \
--server-tar images/rpki-rtr-amd64-v1.2.tar \
--client-tar images/rpki-rtr-debug-client-amd64-v1.2.tar \
--console-tar images/rpki-console-ui-amd64-v1.0.tar
```
脚本会从 `docker load` 输出中解析真实镜像名,并写入:
- `deploy/server/.env` 的 `RPKI_RTR_SERVER_IMAGE`
- `deploy/console/.env` 的 `RPKI_CONSOLE_UI_IMAGE`
## 00 Pre-Check
`00_1_pre_check.sh` 会在 `.precheck/` 下准备隔离测试数据,使用仓库里的 Docker Compose 文件启动 server 和 debug client,然后检查:
- admin health 接口返回正常。
- server 写出了 source report。
- debug client 收到了 EndOfData 和 payload summary。
- pre-check 日志中没有 fatal 错误模式。
相关辅助脚本:
- `00_2_generate_ccr.sh`:生成固定的 CCR 测试文件。
- `00_2_pre_check_prepare.sh`:创建 `.precheck/` 数据、SLURM 文件和 env 文件。
- `00_3_pre_check_cleanup.sh`:停止 pre-check 容器并清理 `.precheck/`。
## 01 启动 Server 和 Console
`01_start.sh` 用于启动真实的 RTR server 和 console。
```bash
bash scripts/01_start.sh --mode tcp
```
启动后脚本会检查容器是否进入 `running` 状态。如果容器启动后退出,脚本会打印 compose 状态和最近日志,然后报错退出,不会继续打印 `Start Finished`。
常用参数:
```bash
bash scripts/01_start.sh --mode tls
bash scripts/01_start.sh --mode ssh
bash scripts/01_start.sh --wait 60
bash scripts/01_start.sh --env ./environment.server
```
注意:console 不读取 `environment.server`,只使用 `deploy/console/.env`。
## 02 自检
`02_self_check.sh` 用于检查 `01_start.sh` 启动的 server 和 console 是否正常。
```bash
bash scripts/02_self_check.sh
```
检查内容包括:
- server 容器是否 running。
- console 容器是否 running。
- RTR TCP 端口是否可连接。
- admin health 接口是否健康。
- console 端口和 HTTP 根路径是否可访问。
如果启动较慢,可以增加等待时间:
```bash
bash scripts/02_self_check.sh --wait 120
```
## 03 关闭 Server 和 Console
`03_shutdown.sh` 用于关闭 `01_start.sh` 启动的服务。
```bash
bash scripts/03_shutdown.sh --mode tcp
```
脚本会先关闭 console,再关闭 RTR server。
## 04 清理
`04_cleanup.sh` 用于删除 server 运行产生的目录:
- `logs/`
- `report/`
- `rtr-db/`
推荐先关闭服务,再清理:
```bash
bash scripts/03_shutdown.sh
bash scripts/04_cleanup.sh
```
清理前脚本会检查 `rpki-rtr` 是否仍在运行。如果仍在运行,会拒绝清理。只有明确需要强制清理时才使用:
```bash
bash scripts/04_cleanup.sh --force
```
清理脚本会拒绝删除仓库根目录之外的路径。如果某一项清理失败,会继续清理后续项,最后返回失败。
## 10 生成证书
`10_generate_certs.sh` 用于生成 TLS/SSH 模式需要的本地证书材料(测试用)。
```bash
bash scripts/10_generate_certs.sh
```
## 11 启动 Client
`11_start_client.sh` 用于启动 RTR debug client。
```bash
bash scripts/11_start_client.sh --mode tcp
bash scripts/11_start_client.sh --mode tls
bash scripts/11_start_client.sh --mode ssh
```
如果 `environment.client` 存在,脚本会将其中所有 `KEY=VALUE` 同步到 `deploy/client/.env`;如果不存在,则直接使用已有的 `deploy/client/.env`。
## 12 关闭 Client
`12_shutdown_client.sh` 用于关闭 `11_start_client.sh` 启动的 client。
```bash
bash scripts/12_shutdown_client.sh --mode tcp
```
关闭时建议使用和启动时相同的 mode。

View File

@@ -0,0 +1,70 @@
# RPKI Benchmarks (Stage2, selected_der_v2)
This directory contains a reproducible, one-click benchmark to measure **decode + profile validate**
performance for all supported object types and compare **OURS** against the **Routinator baseline**
(`rpki` crate `=0.19.1` with `repository` feature).
## What it measures
Dataset:
- Fixtures: `rpki/tests/benchmark/selected_der_v2/`
- Objects: `cer`, `crl`, `manifest` (`.mft`), `roa`, `aspa` (`.asa`)
- Samples: 10 quantiles per type (`min/p01/p10/p25/p50/p75/p90/p95/p99/max`) → 50 files total
Metrics:
- **decode+validate**: `decode_der` (parse + profile validate) for each object file
- **landing** (OURS only): `PackFile::from_bytes_compute_sha256` + CBOR encode + `RocksDB put_raw`
- **compare**: ratio `ours_ns/op ÷ rout_ns/op` for decode+validate
## Default benchmark settings
Both OURS and Routinator baseline use the same run settings:
- warmup: `10` iterations
- rounds: `3`
- adaptive loop target: `min_round_ms=200` (with an internal max of `1_000_000` iters)
- strict DER: `true` (baseline)
- cert inspect: `false` (baseline)
You can override the settings via environment variables in the runner script:
- `BENCH_WARMUP_ITERS` (default `10`)
- `BENCH_ROUNDS` (default `3`)
- `BENCH_MIN_ROUND_MS` (default `200`)
## One-click run (OURS + Routinator compare)
From the `rpki/` crate directory:
```bash
./scripts/benchmark/run_stage2_selected_der_v2_release.sh
```
Outputs are written under:
- `rpki/target/bench/`
- OURS decode+validate: `stage2_selected_der_v2_decode_release_<TS>.{md,csv}`
- OURS landing: `stage2_selected_der_v2_landing_release_<TS>.{md,csv}`
- Routinator: `stage2_selected_der_v2_routinator_decode_release_<TS>.{md,csv}`
- Compare: `stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_<TS>.{md,csv}`
- Summary: `stage2_selected_der_v2_compare_summary_<TS>.md`
### Why decode and landing are separated
The underlying benchmark can run in `BENCH_MODE=both`, but the **landing** part writes to RocksDB
and may trigger background work (e.g., compactions) that can **skew subsequent decode timings**.
For a fair OURS-vs-Routinator comparison, the runner script:
- runs `BENCH_MODE=decode_validate` for comparison, and
- runs `BENCH_MODE=landing` separately for landing-only numbers.
## Notes
- The Routinator baseline benchmark is implemented in-repo under:
- `rpki/benchmark/routinator_object_bench/`
- It pins `rpki = "=0.19.1"` in its `Cargo.toml`.
- This benchmark is implemented as an `#[ignore]` integration test:
- `rpki/tests/bench_stage2_decode_profile_selected_der_v2.rs`
- The runner script invokes it with `cargo test --release ... -- --ignored --nocapture`.

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
# Stage2 (selected_der_v2) decode+profile validate benchmark.
# Runs:
# 1) OURS decode+validate benchmark and writes MD/CSV.
# 2) OURS landing benchmark and writes MD/CSV.
# 3) Routinator baseline decode benchmark (rpki crate =0.19.1).
# 4) Produces a joined compare CSV/MD and a short geomean summary.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
OUT_DIR="$ROOT_DIR/target/bench"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
WARMUP_ITERS="${BENCH_WARMUP_ITERS:-10}"
ROUNDS="${BENCH_ROUNDS:-3}"
MIN_ROUND_MS="${BENCH_MIN_ROUND_MS:-200}"
OURS_MD="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.md"
OURS_CSV="$OUT_DIR/stage2_selected_der_v2_decode_release_${TS}.csv"
OURS_LANDING_MD="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.md"
OURS_LANDING_CSV="$OUT_DIR/stage2_selected_der_v2_landing_release_${TS}.csv"
ROUT_MD="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.md"
ROUT_CSV="$OUT_DIR/stage2_selected_der_v2_routinator_decode_release_${TS}.csv"
COMPARE_MD="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.md"
COMPARE_CSV="$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release_${TS}.csv"
SUMMARY_MD="$OUT_DIR/stage2_selected_der_v2_compare_summary_${TS}.md"
echo "[1/4] OURS: decode+validate benchmark (release)..." >&2
BENCH_MODE="decode_validate" \
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
BENCH_ROUNDS="$ROUNDS" \
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
BENCH_OUT_MD="$OURS_MD" \
BENCH_OUT_CSV="$OURS_CSV" \
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
echo "[2/4] OURS: landing benchmark (release)..." >&2
BENCH_MODE="landing" \
BENCH_WARMUP_ITERS="$WARMUP_ITERS" \
BENCH_ROUNDS="$ROUNDS" \
BENCH_MIN_ROUND_MS="$MIN_ROUND_MS" \
BENCH_OUT_MD_LANDING="$OURS_LANDING_MD" \
BENCH_OUT_CSV_LANDING="$OURS_LANDING_CSV" \
cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture >/dev/null
echo "[3/4] Routinator baseline + compare join..." >&2
OURS_CSV="$OURS_CSV" \
ROUT_CSV="$ROUT_CSV" \
ROUT_MD="$ROUT_MD" \
COMPARE_CSV="$COMPARE_CSV" \
COMPARE_MD="$COMPARE_MD" \
WARMUP_ITERS="$WARMUP_ITERS" \
ROUNDS="$ROUNDS" \
MIN_ROUND_MS="$MIN_ROUND_MS" \
scripts/stage2_perf_compare_m4.sh >/dev/null
echo "[4/4] Summary (geomean ratios)..." >&2
python3 - "$COMPARE_CSV" "$SUMMARY_MD" <<'PY'
import csv
import math
import sys
from pathlib import Path
from datetime import datetime, timezone
in_csv = Path(sys.argv[1])
out_md = Path(sys.argv[2])
rows = list(csv.DictReader(in_csv.open(newline="")))
ratios = {}
for r in rows:
ratios.setdefault(r["type"], []).append(float(r["ratio_ours_over_rout"]))
def geomean(vals):
return math.exp(sum(math.log(v) for v in vals) / len(vals))
def p50(vals):
v = sorted(vals)
n = len(v)
if n % 2 == 1:
return v[n // 2]
return (v[n // 2 - 1] + v[n // 2]) / 2.0
all_vals = [float(r["ratio_ours_over_rout"]) for r in rows]
types = ["all"] + sorted(ratios.keys())
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lines = []
lines.append("# Stage2 selected_der_v2 compare summary (release)\n\n")
lines.append(f"- recorded_at_utc: `{now}`\n")
lines.append(f"- inputs_csv: `{in_csv}`\n\n")
lines.append("| type | n | min | p50 | geomean | max | >1 count |\n")
lines.append("|---|---:|---:|---:|---:|---:|---:|\n")
for t in types:
vals = all_vals if t == "all" else ratios[t]
vals_sorted = sorted(vals)
lines.append(
f"| {t} | {len(vals_sorted)} | {vals_sorted[0]:.4f} | {p50(vals_sorted):.4f} | "
f"{geomean(vals_sorted):.4f} | {vals_sorted[-1]:.4f} | {sum(1 for v in vals_sorted if v>1.0)} |\n"
)
out_md.write_text("".join(lines), encoding="utf-8")
print(out_md)
PY
echo "Done." >&2
echo "- OURS decode MD: $OURS_MD" >&2
echo "- OURS decode CSV: $OURS_CSV" >&2
echo "- OURS landing MD: $OURS_LANDING_MD" >&2
echo "- OURS landing CSV: $OURS_LANDING_CSV" >&2
echo "- Routinator: $ROUT_MD" >&2
echo "- Compare MD: $COMPARE_MD" >&2
echo "- Compare CSV: $COMPARE_CSV" >&2
echo "- Summary MD: $SUMMARY_MD" >&2

80
scripts/coverage.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Requires:
# rustup component add llvm-tools-preview
# cargo install cargo-llvm-cov --locked
run_out="$(mktemp)"
text_out="$(mktemp)"
html_out="$(mktemp)"
cleanup() {
rm -f "$run_out" "$text_out" "$html_out"
}
trap cleanup EXIT
# Preserve colored output even though we post-process output by running under a pseudo-TTY.
# We run tests only once, then generate both CLI text + HTML reports without rerunning tests.
set +e
cargo llvm-cov clean --workspace >/dev/null 2>&1
# 1) Run tests once to collect coverage data (no report).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov --no-report" "$run_out" >/dev/null 2>&1
run_status="$?"
# 2) CLI summary report + fail-under gate (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --fail-under-lines 90" "$text_out" >/dev/null 2>&1
text_status="$?"
# 3) HTML report (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --html" "$html_out" >/dev/null 2>&1
html_status="$?"
set -e
strip_script_noise() {
tr -d '\r' | sed '/^Script \(started\|done\) on /d'
}
strip_ansi_for_parse() {
awk '
{
line = $0
gsub(/\033\[[0-9;]*[A-Za-z]/, "", line) # CSI escapes
gsub(/\033\([A-Za-z]/, "", line) # charset escapes (e.g., ESC(B)
gsub(/\r/, "", line)
print line
}
'
}
cat "$run_out" | strip_script_noise
cat "$text_out" | strip_script_noise
cat "$html_out" | strip_script_noise
cat "$run_out" | strip_ansi_for_parse | awk '
BEGIN {
passed=0; failed=0; ignored=0; measured=0; filtered=0;
}
/^test result: / {
if (match($0, /([0-9]+) passed; ([0-9]+) failed; ([0-9]+) ignored; ([0-9]+) measured; ([0-9]+) filtered out;/, m)) {
passed += m[1]; failed += m[2]; ignored += m[3]; measured += m[4]; filtered += m[5];
}
}
END {
executed = passed + failed;
total = passed + failed + ignored + measured;
printf("\nTEST SUMMARY (all suites): passed=%d failed=%d ignored=%d measured=%d filtered_out=%d executed=%d total=%d\n",
passed, failed, ignored, measured, filtered, executed, total);
}
'
echo
echo "HTML report: target/llvm-cov/html/index.html"
status="$text_status"
if [ "$run_status" -ne 0 ]; then status="$run_status"; fi
if [ "$html_status" -ne 0 ]; then status="$html_status"; fi
exit "$status"

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env bash
set -euo pipefail
# M2: Run per-sample decode+profile benchmark (Ours vs Routinator) on selected_der fixtures.
#
# Outputs:
# - specs/develop/20260224/data/m2_manifest_decode_profile_compare.csv
# - specs/develop/20260224/data/m2_raw.log
#
# Note: This script assumes Routinator benchmark repo exists at:
# /home/yuyr/dev/rust_playground/routinator/benchmark
#
# It also assumes fixtures exist under:
# rpki/tests/benchmark/selected_der/*.mft
# routinator/benchmark/fixtures/selected_der/*.mft
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RPKI_DIR="$ROOT_DIR"
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
DATE_TAG="${DATE_TAG:-20260224}"
OUT_DIR="$RPKI_DIR/../specs/develop/${DATE_TAG}/data"
OUT_CSV="${OUT_CSV:-$OUT_DIR/m2_manifest_decode_profile_compare.csv}"
OUT_RAW="${OUT_RAW:-$OUT_DIR/m2_raw.log}"
REPEATS="${REPEATS:-3}"
# Iterations / warmups (kept moderate for interactive iteration).
ITER_SMALL="${ITER_SMALL:-20000}"
ITER_MEDIUM="${ITER_MEDIUM:-20000}"
ITER_LARGE="${ITER_LARGE:-20000}"
ITER_XLARGE="${ITER_XLARGE:-2000}"
WARM_SMALL="${WARM_SMALL:-2000}"
WARM_MEDIUM="${WARM_MEDIUM:-2000}"
WARM_LARGE="${WARM_LARGE:-2000}"
WARM_XLARGE="${WARM_XLARGE:-200}"
SAMPLES=(
small-01
small-02
medium-01
medium-02
large-01
large-02
xlarge-01
xlarge-02
)
mkdir -p "$OUT_DIR"
: > "$OUT_RAW"
echo "sample,bucket,manifest_file_count,ours_avg_ns_per_op,ours_ops_per_s,rout_avg_ns_per_op,rout_ops_per_s,ratio_ours_over_rout,iterations,repeats,warmup" > "$OUT_CSV"
echo "[1/3] Build ours benchmark (release)..." | tee -a "$OUT_RAW"
(cd "$OURS_BENCH_DIR" && cargo build --release -q)
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
echo "[2/3] Build routinator benchmark (release)..." | tee -a "$OUT_RAW"
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
taskset_prefix=""
if command -v taskset >/dev/null 2>&1; then
if [[ -n "${TASKSET_CPU:-}" ]]; then
taskset_prefix="taskset -c ${TASKSET_CPU}"
fi
fi
bucket_for() {
local s="$1"
case "$s" in
small-*) echo "small" ;;
medium-*) echo "medium" ;;
large-*) echo "large" ;;
xlarge-*) echo "xlarge" ;;
*) echo "unknown" ;;
esac
}
iters_for() {
local b="$1"
case "$b" in
small) echo "$ITER_SMALL" ;;
medium) echo "$ITER_MEDIUM" ;;
large) echo "$ITER_LARGE" ;;
xlarge) echo "$ITER_XLARGE" ;;
*) echo "$ITER_MEDIUM" ;;
esac
}
warm_for() {
local b="$1"
case "$b" in
small) echo "$WARM_SMALL" ;;
medium) echo "$WARM_MEDIUM" ;;
large) echo "$WARM_LARGE" ;;
xlarge) echo "$WARM_XLARGE" ;;
*) echo "$WARM_MEDIUM" ;;
esac
}
run_ours() {
local sample="$1"
local iters="$2"
local warm="$3"
local ours_fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
if [[ ! -f "$ours_fixture" ]]; then
echo "ours fixture not found: $ours_fixture" >&2
exit 1
fi
echo "### ours $sample" >> "$OUT_RAW"
local out
out=$($taskset_prefix "$OURS_BIN" --manifest "$ours_fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats "$REPEATS")
echo "$out" >> "$OUT_RAW"
local line
line=$(echo "$out" | rg "^\\| ${sample} \\|" | tail -n 1)
if [[ -z "${line:-}" ]]; then
echo "failed to parse ours output for $sample" >&2
exit 1
fi
# Expected final row: | sample | avg ns/op | ops/s | file count |
local avg ops cnt
avg=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$3); print $3}')
ops=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$4); print $4}')
cnt=$(echo "$line" | awk -F'|' '{gsub(/^ +| +$/,"",$5); print $5}')
echo "$avg,$ops,$cnt"
}
run_rout() {
local sample="$1"
local iters="$2"
local warm="$3"
local rout_fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
if [[ ! -f "$rout_fixture" ]]; then
echo "routinator fixture not found: $rout_fixture" >&2
exit 1
fi
echo "### routinator $sample" >> "$OUT_RAW"
local out
out=$(
$taskset_prefix "$ROUT_BIN" \
--target decode_only \
--manifest "$rout_fixture" \
--issuer "$ROUT_BENCH_DIR/fixtures/ta.cer" \
--iterations "$iters" \
--repeats "$REPEATS" \
--warmup-iterations "$warm" \
--strict false
)
echo "$out" >> "$OUT_RAW"
local avg_line cnt_line
avg_line=$(echo "$out" | rg "^ avg:")
cnt_line=$(echo "$out" | rg "^ manifest_file_count:")
local avg_ns ops_s cnt
avg_ns=$(echo "$avg_line" | awk '{print $2}')
ops_s=$(echo "$avg_line" | awk '{gsub(/[()]/,"",$4); print $4}')
cnt=$(echo "$cnt_line" | awk '{print $2}')
echo "$avg_ns,$ops_s,$cnt"
}
echo "[3/3] Run per-sample benchmarks..." | tee -a "$OUT_RAW"
for s in "${SAMPLES[@]}"; do
b=$(bucket_for "$s")
it=$(iters_for "$b")
warm=$(warm_for "$b")
IFS=, read -r ours_avg ours_ops ours_cnt < <(run_ours "$s" "$it" "$warm")
IFS=, read -r rout_avg rout_ops rout_cnt < <(run_rout "$s" "$it" "$warm")
if [[ "$ours_cnt" != "$rout_cnt" ]]; then
echo "WARNING: file count differs for $s (ours=$ours_cnt rout=$rout_cnt)" | tee -a "$OUT_RAW"
fi
ratio=$(python3 - <<PY
o=float("$ours_avg")
r=float("$rout_avg")
print(f"{(o/r):.4f}" if r != 0 else "inf")
PY
)
echo "$s,$b,$ours_cnt,$ours_avg,$ours_ops,$rout_avg,$rout_ops,$ratio,$it,$REPEATS,$warm" >> "$OUT_CSV"
echo >> "$OUT_RAW"
done
echo "Done."
echo "- CSV: $OUT_CSV"
echo "- Raw: $OUT_RAW"

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
# M3: Generate flamegraphs + top hotspots for Manifest decode+profile (Ours vs Routinator).
#
# Outputs under:
# specs/develop/20260224/flamegraph/
# specs/develop/20260224/hotspots/
# specs/develop/20260224/perf/
#
# Notes:
# - On WSL2, /usr/bin/perf is often a wrapper that fails. This script uses a real perf binary
# from /usr/lib/linux-tools/*/perf (if present).
# - Ours profiling uses perf + flamegraph --perfdata to avoid rebuilding the whole crate graph
# with RocksDB.
ROOT_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RPKI_DIR="$ROOT_REPO/rpki"
DATE_TAG="${DATE_TAG:-20260224}"
OUT_BASE="$ROOT_REPO/specs/develop/${DATE_TAG}"
OUT_FLAME="$OUT_BASE/flamegraph"
OUT_HOT="$OUT_BASE/hotspots"
OUT_PERF="$OUT_BASE/perf"
RUN_TAG="${RUN_TAG:-p2}"
OURS_BENCH_DIR="$RPKI_DIR/benchmark/ours_manifest_bench"
OURS_BIN="$OURS_BENCH_DIR/target/release/ours-manifest-bench"
ROUT_BENCH_DIR="${ROUT_BENCH_DIR:-/home/yuyr/dev/rust_playground/routinator/benchmark}"
ROUT_BIN="$ROUT_BENCH_DIR/target/release/routinator-manifest-benchmark"
ROUT_ISSUER="$ROUT_BENCH_DIR/fixtures/ta.cer"
PROFILE_HZ="${PROFILE_HZ:-99}"
mkdir -p "$OUT_FLAME" "$OUT_HOT" "$OUT_PERF"
PERF_WRAPPER_OUT="$(perf --version 2>&1 || true)"
PERF_REAL=""
if echo "${PERF_WRAPPER_OUT}" | grep -q "WARNING: perf not found for kernel"; then
PERF_REAL="$(ls -1 /usr/lib/linux-tools/*/perf 2>/dev/null | head -n 1 || true)"
else
PERF_REAL="$(command -v perf || true)"
fi
if [[ -z "${PERF_REAL}" ]]; then
echo "ERROR: usable perf binary not found (wrapper detected and no /usr/lib/linux-tools/*/perf)." >&2
exit 2
fi
SHIM_DIR="$RPKI_DIR/target/bench/tools"
mkdir -p "$SHIM_DIR"
cat > "$SHIM_DIR/perf" <<EOF
#!/usr/bin/env bash
exec "${PERF_REAL}" "\$@"
EOF
chmod +x "$SHIM_DIR/perf"
export PATH="$SHIM_DIR:$PATH"
echo "Using perf: $PERF_REAL"
echo "[1/3] Build ours benchmark with frame pointers..."
(cd "$OURS_BENCH_DIR" && RUSTFLAGS="-C force-frame-pointers=yes" cargo build --release -q)
echo "[2/3] Build routinator benchmark (release)..."
(cd "$ROUT_BENCH_DIR" && cargo build --release -q)
taskset_prefix=""
if command -v taskset >/dev/null 2>&1; then
taskset_prefix="taskset -c 0"
fi
profile_ours() {
local sample="$1"
local iters="$2"
local warm="$3"
local fixture="$RPKI_DIR/tests/benchmark/selected_der/${sample}.mft"
if [[ ! -f "$fixture" ]]; then
echo "ERROR: ours fixture not found: $fixture" >&2
exit 1
fi
local perfdata="$OUT_PERF/ours_${sample}_${RUN_TAG}.perf.data"
local svg="$OUT_FLAME/ours_${sample}_${RUN_TAG}.svg"
local tsv="$OUT_HOT/ours_${sample}_${RUN_TAG}.tsv"
echo "== ours $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
$taskset_prefix perf record -o "$perfdata" -F "$PROFILE_HZ" -g -- \
"$OURS_BIN" --manifest "$fixture" --iterations "$iters" --warmup-iterations "$warm" --repeats 1 >/dev/null
flamegraph --perfdata "$perfdata" --output "$svg" --title "ours ${sample} ManifestObject::decode_der" --deterministic >/dev/null
perf report -i "$perfdata" --stdio --no-children --sort symbol --percent-limit 0.5 \
| awk '/^[[:space:]]*[0-9.]+%/ {pct=$1; sub(/%/,"",pct); $1=""; sub(/^[[:space:]]+/,""); print pct "\t" $0}' \
> "$tsv"
}
profile_routinator() {
local sample="$1"
local iters="$2"
local warm="$3"
local fixture="$ROUT_BENCH_DIR/fixtures/selected_der/${sample}.mft"
if [[ ! -f "$fixture" ]]; then
echo "ERROR: routinator fixture not found: $fixture" >&2
exit 1
fi
local svg="$OUT_FLAME/routinator_${sample}_${RUN_TAG}.svg"
local tsv="$OUT_HOT/routinator_${sample}_${RUN_TAG}.tsv"
echo "== routinator $sample (iters=$iters warmup=$warm hz=$PROFILE_HZ)"
$taskset_prefix "$ROUT_BIN" \
--target decode_only \
--manifest "$fixture" \
--issuer "$ROUT_ISSUER" \
--iterations "$iters" \
--repeats 1 \
--warmup-iterations "$warm" \
--strict false \
--profile-hz "$PROFILE_HZ" \
--flamegraph "$svg" \
--hotspots "$tsv" \
>/dev/null
}
echo "[3/3] Profile samples..."
# Choose iterations so each capture runs ~10-20s serially.
profile_ours small-01 200000 0
profile_routinator small-01 200000 0
profile_ours large-02 50000 0
profile_routinator large-02 50000 0
profile_ours xlarge-02 5000 0
profile_routinator xlarge-02 5000 0
echo "Done."
echo "- Flamegraphs: $OUT_FLAME/"
echo "- Hotspots: $OUT_HOT/"
echo "- Perf data: $OUT_PERF/"

View File

@@ -0,0 +1,94 @@
# Manual RRDP sync (APNIC-focused)
This directory contains **manual, command-line** scripts to reproduce the workflow described in:
- `specs/develop/20260226/apnic_rrdp_delta_analysis_after_manifest_revalidation_fix_20260227T022606Z.md`
They are meant for **hands-on validation / acceptance runs**, not for CI.
## Prerequisites
- Rust toolchain (`cargo`)
- `rsync` available on PATH (for rsync fallback/objects)
- Network access (RRDP over HTTPS)
## What the scripts do
### `full_sync.sh`
- Creates a fresh RocksDB directory
- Runs a **full serial** validation from a TAL URL (default: APNIC RFC7730 TAL)
- Writes:
- run log
- audit report JSON
- run meta JSON (includes durations + download_stats)
- short summary Markdown (includes durations + download_stats)
- RocksDB key statistics (`db_stats --exact`)
- RRDP repo state dump (`rrdp_state_dump`)
### `delta_sync.sh`
- Copies an existing “baseline snapshot DB” to a new DB directory (so the baseline is not modified)
- Runs another validation against the copied DB (RRDP will prefer **delta** when available)
- Produces the same artifacts as `full_sync.sh`
- Additionally generates a Markdown **delta analysis** report by comparing:
- base vs delta report JSON
- base vs delta `rrdp_state_dump` TSV
- and includes a **duration comparison** (base vs delta) if the base meta JSON is available
- delta meta JSON includes download_stats copied from delta report JSON
## Audit report fields (report.json)
The `rpki` binary writes an audit report JSON with:
- `format_version: 2`
- `downloads`: per-download RRDP/rsync events (URI, timestamps, duration, ok/fail, error, bytes, objects stats)
- `download_stats`: aggregate counters (by kind)
These are useful for diagnosing why a run is slow (e.g. RRDP snapshot vs delta vs rsync fallback).
## Meta fields (meta.json)
The scripts generate `*_meta.json` next to `*_report.json` and include:
- `durations_secs`: wall-clock duration breakdown for the script steps
- `download_stats`: copied from `report_json.download_stats`
## Usage
Run from `rpki/`:
```bash
./scripts/manual_sync/full_sync.sh
```
After you have a baseline run, run delta against it:
```bash
./scripts/manual_sync/delta_sync.sh target/live/manual_sync/apnic_full_db_YYYYMMDDTHHMMSSZ \
target/live/manual_sync/apnic_full_report_YYYYMMDDTHHMMSSZ.json
```
If the baseline was produced by `full_sync.sh`, the delta script will auto-discover the base meta JSON
next to the base report (by replacing `_report.json` with `_meta.json`) and include base durations in
the delta analysis report.
## Configuration (env vars)
Both scripts accept overrides via env vars:
- `TAL_URL` (default: APNIC TAL URL)
- `HTTP_TIMEOUT_SECS` (default: 1800)
- `RSYNC_TIMEOUT_SECS` (default: 1800)
- `RSYNC_MIRROR_ROOT` (default: disabled; when set, passes `--rsync-mirror-root` to `rpki`)
- `VALIDATION_TIME` (RFC3339; default: now UTC)
- `OUT_DIR` (default: `rpki/target/live/manual_sync`)
- `RUN_NAME` (default: auto timestamped)
Example:
```bash
TAL_URL="https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal" \
HTTP_TIMEOUT_SECS=1800 RSYNC_TIMEOUT_SECS=1800 \
./scripts/manual_sync/full_sync.sh
```

515
scripts/manual_sync/delta_sync.sh Executable file
View File

@@ -0,0 +1,515 @@
#!/usr/bin/env bash
set -euo pipefail
# Delta sync + validation starting from a baseline snapshot DB.
#
# This script:
# 1) Copies BASE_DB_DIR -> DELTA_DB_DIR (so baseline is not modified)
# 2) Runs rpki validation again (RRDP will prefer delta if available)
# 3) Writes artifacts + a markdown delta analysis report
#
# Usage:
# ./scripts/manual_sync/delta_sync.sh <base_db_dir> <base_report_json>
#
# Outputs under OUT_DIR (default: target/live/manual_sync):
# - *_delta_db_* copied RocksDB directory
# - *_delta_report_*.json audit report
# - *_delta_run_*.log stdout/stderr log (includes summary)
# - *_delta_db_stats_*.txt db_stats --exact output
# - *_delta_rrdp_state_*.tsv rrdp_state_dump output
# - *_delta_analysis_*.md base vs delta comparison report
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
BASE_DB_DIR="${1:-}"
BASE_REPORT_JSON="${2:-}"
if [[ -z "${BASE_DB_DIR}" || -z "${BASE_REPORT_JSON}" ]]; then
echo "Usage: $0 <base_db_dir> <base_report_json>" >&2
exit 2
fi
if [[ ! -d "${BASE_DB_DIR}" ]]; then
echo "ERROR: base_db_dir is not a directory: ${BASE_DB_DIR}" >&2
exit 2
fi
if [[ ! -f "${BASE_REPORT_JSON}" ]]; then
echo "ERROR: base_report_json does not exist: ${BASE_REPORT_JSON}" >&2
exit 2
fi
TAL_URL="${TAL_URL:-https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal}"
HTTP_TIMEOUT_SECS="${HTTP_TIMEOUT_SECS:-1800}"
RSYNC_TIMEOUT_SECS="${RSYNC_TIMEOUT_SECS:-1800}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/manual_sync}"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_delta_${TS}}"
DELTA_DB_DIR="${DELTA_DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
DELTA_REPORT_JSON="${DELTA_REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
DELTA_RUN_LOG="${DELTA_RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
BASE_DB_STATS_TXT="${BASE_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_base_db_stats.txt}"
DELTA_DB_STATS_TXT="${DELTA_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_delta_db_stats.txt}"
BASE_RRDP_STATE_TSV="${BASE_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_base_rrdp_state.tsv}"
DELTA_RRDP_STATE_TSV="${DELTA_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_delta_rrdp_state.tsv}"
DELTA_ANALYSIS_MD="${DELTA_ANALYSIS_MD:-$OUT_DIR/${RUN_NAME}_delta_analysis.md}"
DELTA_META_JSON="${DELTA_META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
# Best-effort base meta discovery (produced by `full_sync.sh`).
BASE_META_JSON="${BASE_META_JSON:-}"
if [[ -z "${BASE_META_JSON}" ]]; then
guess="${BASE_REPORT_JSON%_report.json}_meta.json"
if [[ -f "${guess}" ]]; then
BASE_META_JSON="${guess}"
fi
fi
echo "== rpki manual delta sync ==" >&2
echo "tal_url=$TAL_URL" >&2
echo "base_db=$BASE_DB_DIR" >&2
echo "base_report=$BASE_REPORT_JSON" >&2
echo "delta_db=$DELTA_DB_DIR" >&2
echo "delta_report=$DELTA_REPORT_JSON" >&2
echo "== copying base DB (baseline is not modified) ==" >&2
cp -a "$BASE_DB_DIR" "$DELTA_DB_DIR"
script_start_s="$(date +%s)"
run_start_s="$(date +%s)"
cmd=(cargo run --release --bin rpki -- \
--db "$DELTA_DB_DIR" \
--tal-url "$TAL_URL" \
--http-timeout-secs "$HTTP_TIMEOUT_SECS" \
--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS" \
--report-json "$DELTA_REPORT_JSON")
if [[ -n "${RSYNC_MIRROR_ROOT}" ]]; then
cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
fi
if [[ -n "${VALIDATION_TIME}" ]]; then
cmd+=(--validation-time "$VALIDATION_TIME")
fi
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$DELTA_RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
echo "== db_stats (exact) ==" >&2
db_stats_start_s="$(date +%s)"
cargo run --release --bin db_stats -- --db "$BASE_DB_DIR" --exact 2>&1 | tee "$BASE_DB_STATS_TXT" >/dev/null
cargo run --release --bin db_stats -- --db "$DELTA_DB_DIR" --exact 2>&1 | tee "$DELTA_DB_STATS_TXT" >/dev/null
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" >"$BASE_RRDP_STATE_TSV"
cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" >"$DELTA_RRDP_STATE_TSV"
state_end_s="$(date +%s)"
state_duration_s="$((state_end_s - state_start_s))"
script_end_s="$(date +%s)"
total_duration_s="$((script_end_s - script_start_s))"
echo "== delta analysis report ==" >&2
TAL_URL="$TAL_URL" \
BASE_DB_DIR="$BASE_DB_DIR" \
DELTA_DB_DIR="$DELTA_DB_DIR" \
DELTA_RUN_LOG="$DELTA_RUN_LOG" \
VALIDATION_TIME_ARG="$VALIDATION_TIME" \
HTTP_TIMEOUT_SECS="$HTTP_TIMEOUT_SECS" \
RSYNC_TIMEOUT_SECS="$RSYNC_TIMEOUT_SECS" \
RUN_DURATION_S="$run_duration_s" \
DB_STATS_DURATION_S="$db_stats_duration_s" \
STATE_DURATION_S="$state_duration_s" \
TOTAL_DURATION_S="$total_duration_s" \
python3 - "$BASE_REPORT_JSON" "$DELTA_REPORT_JSON" "$BASE_RRDP_STATE_TSV" "$DELTA_RRDP_STATE_TSV" \
"$BASE_DB_STATS_TXT" "$DELTA_DB_STATS_TXT" "$BASE_META_JSON" "$DELTA_META_JSON" "$DELTA_ANALYSIS_MD" <<'PY'
import json
import os
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
base_report_path = Path(sys.argv[1])
delta_report_path = Path(sys.argv[2])
base_state_path = Path(sys.argv[3])
delta_state_path = Path(sys.argv[4])
base_db_stats_path = Path(sys.argv[5])
delta_db_stats_path = Path(sys.argv[6])
base_meta_path_s = sys.argv[7]
delta_meta_path = Path(sys.argv[8])
out_md_path = Path(sys.argv[9])
def load_json(p: Path):
s = p.read_text(encoding="utf-8")
try:
return json.loads(s)
except json.JSONDecodeError:
# Backwards-compat / robustness: tolerate accidental literal trailing "\\n".
s2 = s.strip()
if s2.endswith("\\n"):
s2 = s2[:-2].rstrip()
return json.loads(s2)
def load_optional_json(path_s: str):
if not path_s:
return None
p = Path(path_s)
if not p.exists():
return None
return load_json(p)
def parse_rrdp_state_tsv(p: Path):
# format: "<notify_uri>\t<serial>\t<session_id>"
out = {}
for line in p.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
parts = line.split("\t")
if len(parts) != 3:
raise SystemExit(f"invalid rrdp_state_dump line in {p}: {line!r}")
uri, serial, session = parts
out[uri] = (int(serial), session)
return out
def parse_db_stats(p: Path):
# lines: key=value
out = {}
for line in p.read_text(encoding="utf-8").splitlines():
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip()
if v.isdigit():
out[k] = int(v)
else:
out[k] = v
return out
def warnings_total(rep: dict) -> int:
return len(rep["tree"]["warnings"]) + sum(len(pp["warnings"]) for pp in rep["publication_points"])
def report_summary(rep: dict) -> dict:
return {
"validation_time": rep["meta"]["validation_time_rfc3339_utc"],
"publication_points_processed": rep["tree"]["instances_processed"],
"publication_points_failed": rep["tree"]["instances_failed"],
"rrdp_repos_unique": len({pp.get("rrdp_notification_uri") for pp in rep["publication_points"] if pp.get("rrdp_notification_uri")}),
"vrps": len(rep["vrps"]),
"aspas": len(rep["aspas"]),
"audit_publication_points": len(rep["publication_points"]),
"warnings_total": warnings_total(rep),
}
def count_repo_sync_failed(rep: dict) -> int:
# Best-effort heuristic (we don't currently expose a structured counter in the audit report).
# Keep the match conservative to avoid false positives.
def is_repo_sync_failed(msg: str) -> bool:
m = msg.lower()
return "repo sync failed" in m or "rrdp fetch failed" in m or "rsync fetch failed" in m
n = 0
for w in rep["tree"]["warnings"]:
if is_repo_sync_failed(w.get("message", "")):
n += 1
for pp in rep["publication_points"]:
for w in pp.get("warnings", []):
if is_repo_sync_failed(w.get("message", "")):
n += 1
return n
def pp_manifest_sha(pp: dict) -> str:
# In our audit format, the first object is the manifest (synthetic entry) with sha256 of manifest_bytes.
for o in pp["objects"]:
if o["kind"] == "manifest":
return o["sha256_hex"]
return ""
def pp_objects_by_uri(rep: dict):
m = {}
for pp in rep["publication_points"]:
for o in pp["objects"]:
m[o["rsync_uri"]] = (o["sha256_hex"], o["kind"])
return m
def vrp_set(rep: dict):
return {(v["asn"], v["prefix"], v["max_length"]) for v in rep["vrps"]}
def rfc_refs_str(w: dict) -> str:
refs = w.get("rfc_refs") or []
return ", ".join(refs) if refs else ""
base = load_json(base_report_path)
delta = load_json(delta_report_path)
base_sum = report_summary(base)
delta_sum = report_summary(delta)
base_db = parse_db_stats(base_db_stats_path)
delta_db = parse_db_stats(delta_db_stats_path)
base_state = parse_rrdp_state_tsv(base_state_path)
delta_state = parse_rrdp_state_tsv(delta_state_path)
base_meta = load_optional_json(base_meta_path_s)
download_stats = delta.get("download_stats") or {}
delta_meta = {
"recorded_at_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"tal_url": os.environ["TAL_URL"],
"base_db_dir": os.environ["BASE_DB_DIR"],
"delta_db_dir": os.environ["DELTA_DB_DIR"],
"base_report_json": str(base_report_path),
"delta_report_json": str(delta_report_path),
"delta_run_log": os.environ["DELTA_RUN_LOG"],
"validation_time_arg": os.environ.get("VALIDATION_TIME_ARG",""),
"http_timeout_secs": int(os.environ["HTTP_TIMEOUT_SECS"]),
"rsync_timeout_secs": int(os.environ["RSYNC_TIMEOUT_SECS"]),
"durations_secs": {
"rpki_run": int(os.environ["RUN_DURATION_S"]),
"db_stats_exact": int(os.environ["DB_STATS_DURATION_S"]),
"rrdp_state_dump": int(os.environ["STATE_DURATION_S"]),
"total_script": int(os.environ["TOTAL_DURATION_S"]),
},
"download_stats": download_stats,
}
delta_meta_path.write_text(json.dumps(delta_meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# RRDP state changes
serial_changed = 0
session_changed = 0
serial_deltas = []
for uri, (old_serial, old_sess) in base_state.items():
if uri not in delta_state:
continue
new_serial, new_sess = delta_state[uri]
if new_serial != old_serial:
serial_changed += 1
serial_deltas.append((uri, old_serial, new_serial, new_serial - old_serial))
if new_sess != old_sess:
session_changed += 1
serial_deltas.sort(key=lambda x: x[3], reverse=True)
# Publication point diffs
base_pp = {pp["manifest_rsync_uri"]: pp for pp in base["publication_points"]}
delta_pp = {pp["manifest_rsync_uri"]: pp for pp in delta["publication_points"]}
base_keys = set(base_pp.keys())
delta_keys = set(delta_pp.keys())
new_pp = sorted(delta_keys - base_keys)
missing_pp = sorted(base_keys - delta_keys)
updated_pp = 0
unchanged_pp = 0
for k in sorted(base_keys & delta_keys):
if pp_manifest_sha(base_pp[k]) != pp_manifest_sha(delta_pp[k]):
updated_pp += 1
else:
unchanged_pp += 1
# Cache usage + repo sync failure hints
def source_counts(rep: dict) -> Counter:
c = Counter()
for pp in rep["publication_points"]:
c[pp.get("source","")] += 1
return c
base_sources = source_counts(base)
delta_sources = source_counts(delta)
base_repo_sync_failed = count_repo_sync_failed(base)
delta_repo_sync_failed = count_repo_sync_failed(delta)
def cache_reason_counts(rep: dict) -> Counter:
c = Counter()
for pp in rep.get("publication_points", []):
if pp.get("source") != "fetch_cache_pp":
continue
# Use warning messages as "reason". If missing, emit a fallback bucket.
ws = pp.get("warnings", [])
if not ws:
c["(no warnings recorded)"] += 1
continue
for w in ws:
msg = w.get("message", "").strip() or "(empty warning message)"
c[msg] += 1
return c
base_cache_reasons = cache_reason_counts(base)
delta_cache_reasons = cache_reason_counts(delta)
# Object change stats (by rsync URI, sha256)
base_obj = pp_objects_by_uri(base)
delta_obj = pp_objects_by_uri(delta)
kind_stats = {k: {"added": 0, "changed": 0, "removed": 0} for k in ["manifest","crl","certificate","roa","aspa","other"]}
all_uris = set(base_obj.keys()) | set(delta_obj.keys())
for uri in all_uris:
b = base_obj.get(uri)
d = delta_obj.get(uri)
if b is None and d is not None:
kind_stats[d[1]]["added"] += 1
elif b is not None and d is None:
kind_stats[b[1]]["removed"] += 1
else:
if b[0] != d[0]:
kind_stats[d[1]]["changed"] += 1
# VRP diff
base_v = vrp_set(base)
delta_v = vrp_set(delta)
added_v = delta_v - base_v
removed_v = base_v - delta_v
def fmt_db_stats(db: dict) -> str:
keys = ["raw_objects","rrdp_object_index","fetch_cache_pp","rrdp_state","total"]
out = []
for k in keys:
if k in db:
out.append(f"- `{k}={db[k]}`")
return "\n".join(out) if out else "_(missing db_stats keys)_"
lines = []
lines.append("# APNIC RRDP 增量同步验收(manual_sync)\n\n")
lines.append(f"时间戳:`{now}`(UTC)\n\n")
lines.append("## 复现信息\n\n")
lines.append(f"- base_report:`{base_report_path}`\n")
lines.append(f"- delta_report:`{delta_report_path}`\n")
lines.append(f"- base_db_stats:`{base_db_stats_path}`\n")
lines.append(f"- delta_db_stats:`{delta_db_stats_path}`\n")
lines.append(f"- base_rrdp_state:`{base_state_path}`\n")
lines.append(f"- delta_rrdp_state:`{delta_state_path}`\n\n")
lines.append("## 运行结果概览\n\n")
lines.append("| metric | base | delta |\n")
lines.append("|---|---:|---:|\n")
for k in [
"validation_time",
"publication_points_processed",
"publication_points_failed",
"rrdp_repos_unique",
"vrps",
"aspas",
"audit_publication_points",
"warnings_total",
]:
lines.append(f"| {k} | {base_sum[k]} | {delta_sum[k]} |\n")
lines.append("\n")
def dur(meta: dict | None, key: str):
if not meta:
return None
return (meta.get("durations_secs") or {}).get(key)
base_rpki_run = dur(base_meta, "rpki_run")
delta_rpki_run = delta_meta["durations_secs"]["rpki_run"]
base_total = dur(base_meta, "total_script")
delta_total = delta_meta["durations_secs"]["total_script"]
lines.append("## 持续时间(seconds)\n\n")
lines.append("| step | base | delta |\n")
lines.append("|---|---:|---:|\n")
lines.append(f"| rpki_run | {base_rpki_run if base_rpki_run is not None else 'unknown'} | {delta_rpki_run} |\n")
lines.append(f"| total_script | {base_total if base_total is not None else 'unknown'} | {delta_total} |\n")
lines.append("\n")
if base_meta is None and base_meta_path_s:
lines.append(f"> 注:未能读取 base meta:`{base_meta_path_s}`(文件不存在或不可读)。建议用 `full_sync.sh` 生成 baseline 以获得 base 时长对比。\n\n")
lines.append("RocksDB KV(`db_stats --exact`):\n\n")
lines.append("### 基线(base)\n\n")
lines.append(fmt_db_stats(base_db) + "\n\n")
lines.append("### 增量(delta)\n\n")
lines.append(fmt_db_stats(delta_db) + "\n\n")
lines.append("## RRDP 增量是否发生(基于 `rrdp_state` 变化)\n\n")
lines.append(f"- repo_total(base)={len(base_state)}\n")
lines.append(f"- repo_total(delta)={len(delta_state)}\n")
lines.append(f"- serial_changed={serial_changed}\n")
lines.append(f"- session_changed={session_changed}\n\n")
if serial_deltas:
lines.append("serial 增长最大的 10 个 RRDP repo(old -> new):\n\n")
for uri, old, new, diff in serial_deltas[:10]:
lines.append(f"- `{uri}`:`{old} -> {new}`(+{diff})\n")
lines.append("\n")
lines.append("## 发布点(Publication Point)变化统计\n\n")
lines.append("以 `manifest_rsync_uri` 作为发布点 key,对比 base vs delta:\n\n")
lines.append(f"- base PP:`{len(base_keys)}`\n")
lines.append(f"- delta PP:`{len(delta_keys)}`\n")
lines.append(f"- `new_pp={len(new_pp)}`\n")
lines.append(f"- `missing_pp={len(missing_pp)}`\n")
lines.append(f"- `updated_pp={updated_pp}`\n")
lines.append(f"- `unchanged_pp={unchanged_pp}`\n\n")
lines.append("> 注:`new_pp/missing_pp/updated_pp` 会混入“遍历范围变化”的影响(例如 validation_time 不同、或 base 中存在更多失败 PP)。\n\n")
lines.append("## fail fetch / cache 使用情况\n\n")
lines.append(f"- repo sync failed(启发式:warning contains 'repo sync failed'/'rrdp fetch failed'/'rsync fetch failed')\n")
lines.append(f" - base:`{base_repo_sync_failed}`\n")
lines.append(f" - delta:`{delta_repo_sync_failed}`\n\n")
lines.append("- source 计数(按 `PublicationPointAudit.source`):\n\n")
lines.append(f" - base:`{dict(base_sources)}`\n")
lines.append(f" - delta:`{dict(delta_sources)}`\n\n")
def render_cache_reasons(title: str, c: Counter) -> str:
if not c:
return f"{title}:`0`(未使用 fetch_cache_pp)\n\n"
lines = []
total = sum(c.values())
lines.append(f"{title}:`{total}`\n\n")
lines.append("Top reasons(按 warning message 聚合,可能一条 PP 有多条 warning):\n\n")
for msg, n in c.most_common(10):
lines.append(f"- `{n}` × {msg}\n")
lines.append("\n")
return "".join(lines)
lines.append(render_cache_reasons("- base `source=fetch_cache_pp`", base_cache_reasons))
lines.append(render_cache_reasons("- delta `source=fetch_cache_pp`", delta_cache_reasons))
lines.append("## 文件变更统计(按对象类型)\n\n")
lines.append("按 `ObjectAuditEntry.sha256_hex` 对比(同一 rsync URI 前后 hash 变化记为 `~changed`):\n\n")
lines.append("| kind | added | changed | removed |\n")
lines.append("|---|---:|---:|---:|\n")
for kind in ["manifest","crl","certificate","roa","aspa","other"]:
st = kind_stats[kind]
lines.append(f"| {kind} | {st['added']} | {st['changed']} | {st['removed']} |\n")
lines.append("\n")
lines.append("## VRP 影响(去重后集合 diff)\n\n")
lines.append("以 `(asn, prefix, max_length)` 为 key:\n\n")
lines.append(f"- base unique VRP:`{len(base_v)}`\n")
lines.append(f"- delta unique VRP:`{len(delta_v)}`\n")
lines.append(f"- `added={len(added_v)}`\n")
lines.append(f"- `removed={len(removed_v)}`\n")
lines.append(f"- `net={len(added_v) - len(removed_v)}`\n\n")
out_md_path.write_text("".join(lines), encoding="utf-8")
print(out_md_path)
PY
echo "== done ==" >&2
echo "artifacts:" >&2
echo "- delta db: $DELTA_DB_DIR" >&2
echo "- delta report: $DELTA_REPORT_JSON" >&2
echo "- delta run log: $DELTA_RUN_LOG" >&2
echo "- delta meta json: $DELTA_META_JSON" >&2
echo "- analysis md: $DELTA_ANALYSIS_MD" >&2
echo "- base state tsv: $BASE_RRDP_STATE_TSV" >&2
echo "- delta state tsv: $DELTA_RRDP_STATE_TSV" >&2

189
scripts/manual_sync/full_sync.sh Executable file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
# Full sync + validation from a TAL URL (default: APNIC).
#
# Produces artifacts under OUT_DIR (default: target/live/manual_sync):
# - *_db_* RocksDB directory
# - *_report_*.json audit report
# - *_run_*.log stdout/stderr log (includes summary)
# - *_db_stats_*.txt db_stats --exact output
# - *_rrdp_state_*.tsv rrdp_state_dump output
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
TAL_URL="${TAL_URL:-https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal}"
HTTP_TIMEOUT_SECS="${HTTP_TIMEOUT_SECS:-1800}"
RSYNC_TIMEOUT_SECS="${RSYNC_TIMEOUT_SECS:-1800}"
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-}"
VALIDATION_TIME="${VALIDATION_TIME:-}"
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/manual_sync}"
mkdir -p "$OUT_DIR"
TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_NAME="${RUN_NAME:-apnic_full_${TS}}"
DB_DIR="${DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
REPORT_JSON="${REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
RUN_LOG="${RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
DB_STATS_TXT="${DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_db_stats.txt}"
RRDP_STATE_TSV="${RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_rrdp_state.tsv}"
RUN_META_JSON="${RUN_META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
SUMMARY_MD="${SUMMARY_MD:-$OUT_DIR/${RUN_NAME}_summary.md}"
echo "== rpki manual full sync ==" >&2
echo "tal_url=$TAL_URL" >&2
echo "db=$DB_DIR" >&2
echo "report_json=$REPORT_JSON" >&2
echo "out_dir=$OUT_DIR" >&2
cmd=(cargo run --release --bin rpki -- \
--db "$DB_DIR" \
--tal-url "$TAL_URL" \
--http-timeout-secs "$HTTP_TIMEOUT_SECS" \
--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS" \
--report-json "$REPORT_JSON")
if [[ -n "${RSYNC_MIRROR_ROOT}" ]]; then
cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
fi
if [[ -n "${VALIDATION_TIME}" ]]; then
cmd+=(--validation-time "$VALIDATION_TIME")
fi
script_start_s="$(date +%s)"
run_start_s="$(date +%s)"
(
echo "# command:"
printf '%q ' "${cmd[@]}"
echo
echo
"${cmd[@]}"
) 2>&1 | tee "$RUN_LOG" >/dev/null
run_end_s="$(date +%s)"
run_duration_s="$((run_end_s - run_start_s))"
echo "== db_stats (exact) ==" >&2
db_stats_start_s="$(date +%s)"
cargo run --release --bin db_stats -- --db "$DB_DIR" --exact 2>&1 | tee "$DB_STATS_TXT" >/dev/null
db_stats_end_s="$(date +%s)"
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
echo "== rrdp_state_dump ==" >&2
state_start_s="$(date +%s)"
cargo run --release --bin rrdp_state_dump -- --db "$DB_DIR" >"$RRDP_STATE_TSV"
state_end_s="$(date +%s)"
state_duration_s="$((state_end_s - state_start_s))"
script_end_s="$(date +%s)"
total_duration_s="$((script_end_s - script_start_s))"
echo "== write run meta + summary ==" >&2
TAL_URL="$TAL_URL" \
DB_DIR="$DB_DIR" \
REPORT_JSON="$REPORT_JSON" \
RUN_LOG="$RUN_LOG" \
HTTP_TIMEOUT_SECS="$HTTP_TIMEOUT_SECS" \
RSYNC_TIMEOUT_SECS="$RSYNC_TIMEOUT_SECS" \
VALIDATION_TIME_ARG="$VALIDATION_TIME" \
RUN_DURATION_S="$run_duration_s" \
DB_STATS_DURATION_S="$db_stats_duration_s" \
STATE_DURATION_S="$state_duration_s" \
TOTAL_DURATION_S="$total_duration_s" \
python3 - "$REPORT_JSON" "$RUN_META_JSON" "$SUMMARY_MD" <<'PY'
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
report_path = Path(sys.argv[1])
meta_path = Path(sys.argv[2])
summary_path = Path(sys.argv[3])
rep = json.loads(report_path.read_text(encoding="utf-8"))
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
download_stats = rep.get("download_stats") or {}
meta = {
"recorded_at_utc": now,
"tal_url": os.environ["TAL_URL"],
"db_dir": os.environ["DB_DIR"],
"report_json": os.environ["REPORT_JSON"],
"run_log": os.environ["RUN_LOG"],
"validation_time_rfc3339_utc": rep["meta"]["validation_time_rfc3339_utc"],
"http_timeout_secs": int(os.environ["HTTP_TIMEOUT_SECS"]),
"rsync_timeout_secs": int(os.environ["RSYNC_TIMEOUT_SECS"]),
"validation_time_arg": os.environ.get("VALIDATION_TIME_ARG",""),
"durations_secs": {
"rpki_run": int(os.environ["RUN_DURATION_S"]),
"db_stats_exact": int(os.environ["DB_STATS_DURATION_S"]),
"rrdp_state_dump": int(os.environ["STATE_DURATION_S"]),
"total_script": int(os.environ["TOTAL_DURATION_S"]),
},
"counts": {
"publication_points_processed": rep["tree"]["instances_processed"],
"publication_points_failed": rep["tree"]["instances_failed"],
"vrps": len(rep["vrps"]),
"aspas": len(rep["aspas"]),
"audit_publication_points": len(rep["publication_points"]),
},
"download_stats": download_stats,
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
lines = []
lines.append("# Manual full sync summary\\n\\n")
lines.append(f"- recorded_at_utc: `{now}`\\n")
lines.append(f"- tal_url: `{meta['tal_url']}`\\n")
lines.append(f"- db: `{meta['db_dir']}`\\n")
lines.append(f"- report_json: `{meta['report_json']}`\\n")
lines.append(f"- validation_time: `{meta['validation_time_rfc3339_utc']}`\\n\\n")
lines.append("## Results\\n\\n")
lines.append("| metric | value |\\n")
lines.append("|---|---:|\\n")
for k in ["publication_points_processed","publication_points_failed","vrps","aspas","audit_publication_points"]:
lines.append(f"| {k} | {meta['counts'][k]} |\\n")
lines.append("\\n")
lines.append("## Durations (seconds)\\n\\n")
lines.append("| step | seconds |\\n")
lines.append("|---|---:|\\n")
for k,v in meta["durations_secs"].items():
lines.append(f"| {k} | {v} |\\n")
lines.append("\\n")
lines.append("## Download Stats\\n\\n")
lines.append("- raw events: `report_json.downloads`\\n")
lines.append("- aggregated: `report_json.download_stats` (copied into meta.json)\\n\\n")
def fmt_u(v):
if v is None:
return ""
return str(v)
by_kind = download_stats.get("by_kind") or {}
lines.append("| kind | ok | fail | duration_ms_total | bytes_total | objects_count_total | objects_bytes_total |\\n")
lines.append("|---|---:|---:|---:|---:|---:|---:|\\n")
for kind in sorted(by_kind.keys()):
st = by_kind[kind] or {}
lines.append(
f"| {kind} | {st.get('ok_total',0)} | {st.get('fail_total',0)} | {st.get('duration_ms_total',0)} | {fmt_u(st.get('bytes_total'))} | {fmt_u(st.get('objects_count_total'))} | {fmt_u(st.get('objects_bytes_total'))} |\\n"
)
lines.append("\\n")
summary_path.write_text("".join(lines), encoding="utf-8")
print(summary_path)
PY
echo "== done ==" >&2
echo "artifacts:" >&2
echo "- db: $DB_DIR" >&2
echo "- report: $REPORT_JSON" >&2
echo "- run log: $RUN_LOG" >&2
echo "- db stats: $DB_STATS_TXT" >&2
echo "- rrdp state: $RRDP_STATE_TSV" >&2
echo "- meta json: $RUN_META_JSON" >&2
echo "- summary md: $SUMMARY_MD" >&2

123
scripts/stage2_perf_compare_m4.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
# M4: Compare decode+profile (OURS) vs routinator baseline (rpki crate 0.19.1)
# on selected_der_v2 fixtures (cer/crl/manifest/roa/aspa).
#
# Outputs under:
# - rpki/target/bench/stage2_selected_der_v2_routinator_decode_release.{csv,md}
# - rpki/target/bench/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.{csv,md}
#
# Notes:
# - OURS decode benchmark is produced by:
# `cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture`
# and writes `stage2_selected_der_v2_decode_release.csv` when BENCH_OUT_CSV is set.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RPKI_DIR="$ROOT_DIR"
OUT_DIR="$RPKI_DIR/target/bench"
mkdir -p "$OUT_DIR"
OURS_CSV="${OURS_CSV:-$OUT_DIR/stage2_selected_der_v2_decode_release.csv}"
ROUT_CSV="${ROUT_CSV:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.csv}"
ROUT_MD="${ROUT_MD:-$OUT_DIR/stage2_selected_der_v2_routinator_decode_release.md}"
COMPARE_CSV="${COMPARE_CSV:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.csv}"
COMPARE_MD="${COMPARE_MD:-$OUT_DIR/stage2_selected_der_v2_compare_ours_vs_routinator_decode_release.md}"
WARMUP_ITERS="${WARMUP_ITERS:-10}"
ROUNDS="${ROUNDS:-3}"
MIN_ROUND_MS="${MIN_ROUND_MS:-200}"
if [[ ! -f "$OURS_CSV" ]]; then
echo "ERROR: missing OURS CSV: $OURS_CSV" >&2
echo "Hint: run:" >&2
echo " cd rpki && BENCH_WARMUP_ITERS=$WARMUP_ITERS BENCH_ROUNDS=$ROUNDS BENCH_MIN_ROUND_MS=$MIN_ROUND_MS \\" >&2
echo " BENCH_OUT_CSV=target/bench/stage2_selected_der_v2_decode_release.csv \\" >&2
echo " cargo test --release --test bench_stage2_decode_profile_selected_der_v2 -- --ignored --nocapture" >&2
exit 2
fi
echo "[1/2] Run routinator baseline bench (release)..." >&2
(cd "$RPKI_DIR/benchmark/routinator_object_bench" && cargo run --release -q -- \
--dir "$RPKI_DIR/tests/benchmark/selected_der_v2" \
--warmup-iters "$WARMUP_ITERS" \
--rounds "$ROUNDS" \
--min-round-ms "$MIN_ROUND_MS" \
--out-csv "$ROUT_CSV" \
--out-md "$ROUT_MD")
echo "[2/2] Join CSVs + compute ratios..." >&2
python3 - "$OURS_CSV" "$ROUT_CSV" "$COMPARE_CSV" "$COMPARE_MD" <<'PY'
import csv
import sys
from pathlib import Path
ours_path = Path(sys.argv[1])
rout_path = Path(sys.argv[2])
out_csv_path = Path(sys.argv[3])
out_md_path = Path(sys.argv[4])
def read_csv(path: Path):
with path.open(newline="") as f:
return list(csv.DictReader(f))
ours_rows = read_csv(ours_path)
rout_rows = read_csv(rout_path)
rout_by_key = {(r["type"], r["sample"]): r for r in rout_rows}
out_rows = []
for r in ours_rows:
key = (r["type"], r["sample"])
rr = rout_by_key.get(key)
if rr is None:
raise SystemExit(f"missing routinator row for {key}")
ours_ns = float(r["avg_ns_per_op"])
rout_ns = float(rr["avg_ns_per_op"])
ratio = (ours_ns / rout_ns) if rout_ns != 0.0 else float("inf")
out_rows.append({
"type": r["type"],
"sample": r["sample"],
"size_bytes": r["size_bytes"],
"complexity": r["complexity"],
"ours_avg_ns_per_op": f"{ours_ns:.2f}",
"ours_ops_per_sec": f"{float(r['ops_per_sec']):.2f}",
"rout_avg_ns_per_op": f"{rout_ns:.2f}",
"rout_ops_per_sec": f"{float(rr['ops_per_sec']):.2f}",
"ratio_ours_over_rout": f"{ratio:.4f}",
})
out_rows.sort(key=lambda x: (x["type"], x["sample"]))
out_csv_path.parent.mkdir(parents=True, exist_ok=True)
with out_csv_path.open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(out_rows[0].keys()))
w.writeheader()
w.writerows(out_rows)
lines = []
lines.append("# Stage2 ours vs routinator (decode+profile, selected_der_v2)\n")
lines.append(f"- ours_csv: `{ours_path}`\n")
lines.append(f"- rout_csv: `{rout_path}`\n")
lines.append("\n")
lines.append("| type | sample | size_bytes | complexity | ours ns/op | rout ns/op | ratio |\n")
lines.append("|---|---|---:|---:|---:|---:|---:|\n")
for r in out_rows:
lines.append(
f"| {r['type']} | {r['sample']} | {r['size_bytes']} | {r['complexity']} | "
f"{r['ours_avg_ns_per_op']} | {r['rout_avg_ns_per_op']} | {r['ratio_ours_over_rout']} |\n"
)
out_md_path.parent.mkdir(parents=True, exist_ok=True)
out_md_path.write_text("".join(lines), encoding="utf-8")
PY
echo "Done." >&2
echo "- routinator CSV: $ROUT_CSV" >&2
echo "- compare CSV: $COMPARE_CSV" >&2
echo "- compare MD: $COMPARE_MD" >&2

View File

@@ -182,6 +182,8 @@ RFC 引用:RFC 5280 §4.2.2.1;RFC 5280 §4.2.2.2;RFC 5280 §4.2.1.6。
| `1.2.840.113549.1.9.5` | CMS signedAttrs: signing-time | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) |
| `1.2.840.113549.1.9.16.1.24` | ROA eContentType: id-ct-routeOriginAuthz | RFC 9582 §3 |
| `1.2.840.113549.1.9.16.1.26` | Manifest eContentType: id-ct-rpkiManifest | RFC 9286 §4.1 |
| `1.2.840.113549.1.9.16.1.35` | Ghostbusters eContentType: id-ct-rpkiGhostbusters | RFC 6493 §6;RFC 6493 §9.1 |
| `1.2.840.113549.1.9.16.1.49` | ASPA eContentType: id-ct-ASPA | `draft-ietf-sidrops-aspa-profile-21` §2 |
| `1.3.6.1.5.5.7.1.1` | X.509 v3 扩展:authorityInfoAccess | RFC 5280 §4.2.2.1 |
| `1.3.6.1.5.5.7.1.11` | X.509 v3 扩展:subjectInfoAccess | RFC 5280 §4.2.2.2;RPKI 约束见 RFC 6487 §4.8.8 |
| `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 |

View File

@@ -1,36 +1,94 @@
# 01. Trust Anchor Locator (TAL)
# 01. TAL(Trust Anchor Locator)
## 1.1 对象定位
TAL是一个数据格式/配置文件,目的是告诉RP信任锚的公钥是什么,以及相关对象可以从哪里获取。
## 1.2 数据格式 (RFC 8630 §2.2)
TAL是一个配置文件,格式定义如下:
```
The TAL is an ordered sequence of:
1. an optional comment section consisting of one or more lines each starting with the "#" character, followed by human-readable informational UTF-8 text, conforming to the restrictions defined
in Section 2 of [RFC5198], and ending with a line break,
2. a URI section that is comprised of one or more ordered lines, each containing a TA URI, and ending with a line break,
3. a line break, and
4. a subjectPublicKeyInfo [RFC5280] in DER format [X.509], encoded in base64 (see Section 4 of [RFC4648]). To avoid long lines,
line breaks MAY be inserted into the base64-encoded string.
Note that line breaks in this file can use either "<CRLF>" or "<LF>".
TAL(Trust Anchor Locator)用于向 RP 提供:
1) 可检索“当前 TA 证书”的一个或多个 URI;以及
2) 该 TA 证书的 `subjectPublicKeyInfo`(SPKI)期望值(用于绑定/防替换)。
RFC 8630 §2;RFC 8630 §2.3。
## 1.2 原始载体与编码
- 载体:文本文件(ASCII/UTF-8 兼容的行文本)。
- 行结束:允许 `CRLF` 或 `LF`。RFC 8630 §2.2。
- 结构:`[可选注释区] + URI 区 + 空行 + Base64(SPKI DER)`。RFC 8630 §2.2。
### 1.2.1 注释区
- 一行或多行,以 `#` 开头,后随人类可读 UTF-8 文本。RFC 8630 §2.2。
- 注释行文本需符合 RFC 5198 §2 的限制(RFC 8630 §2.2 引用)。
### 1.2.2 URI 区
- 一行或多行,每行一个 TA URI,按序排列。RFC 8630 §2.2。
- TA URI **MUST** 是 `rsync` 或 `https`。RFC 8630 §2.2。
### 1.2.3 空行分隔
- URI 区后必须有一个额外的换行(即空行),用于与 Base64 区分隔。RFC 8630 §2.2(第 3 点)。
### 1.2.4 SPKI(Base64)
- `subjectPublicKeyInfo` 以 DER 编码(ASN.1)后,再 Base64 编码表示。RFC 8630 §2.2(第 4 点)。
- 为避免长行,Base64 字符串中 **MAY** 插入换行。RFC 8630 §2.2。
- SPKI ASN.1 类型来自 X.509 / RFC 5280。RFC 8630 §2.2(第 4 点);RFC 5280 §4.1.2.7。
#### 1.2.4.1 `SubjectPublicKeyInfo` 的 ASN.1 定义(RFC 5280 §4.1)
TAL 中携带的是一个 X.509 `SubjectPublicKeyInfo` 的 DER 字节串(再 Base64)。其 ASN.1 定义如下:RFC 5280 §4.1。
```asn1
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
```
## 1.3 抽象数据模型
其中 `algorithm`/`subjectPublicKey` 的取值受 RPKI 算法 profile 约束(例如 RSA 2048 + SHA-256 等;SKI/AKI 计算仍用 SHA-1)。RFC 5280 §4.1.2.7;RFC 7935 §2-§3.1;RFC 6487 §4.8.2-§4.8.3。
### 1.3.1 TAL
## 1.3 解析规则(语义层)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|----------|-------------|-------------------------|--------------------------------------------|---------------|
| uris | Vec<TalUri> | 指向TA的URI列表 | 允许rsync和https协议。 | RFC 8630 §2.1 |
| comment | Vec<String> | 注释(可选) | | RFC 8630 §2.2 |
| spki_der | Vec<u8> | 原始的subjectPublicKeyInfo | x.509 SubjectPublicKeyInfo DER编码,再base64编码 | RFC 8630 §2.2 |
输入:`TalFileBytes: bytes`。
解析步骤:
### 1.3.2 TalUri
1) 按 `LF` / `CRLF` 识别行。RFC 8630 §2.2。
2) 从文件开头读取所有以 `#` 开头的行,作为 `comments`(保留去掉 `#` 后的 UTF-8 文本或保留原始行均可,但需保持 UTF-8)。RFC 8630 §2.2。
3) 继续读取一行或多行非空行,作为 `ta_uris`(保持顺序)。RFC 8630 §2.2(第 2 点)。
4) 读取一个空行(必须存在)。RFC 8630 §2.2(第 3 点)。
5) 将剩余行拼接为 Base64 文本(移除行分隔),Base64 解码得到 `subject_public_key_info_der`。RFC 8630 §2.2(第 4 点)。
6) 可选:将 `subject_public_key_info_der` 解析为 X.509 `SubjectPublicKeyInfo` 结构(用于与 TA 证书比对)。RFC 8630 §2.3;RFC 5280 §4.1.2.7。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|-------|--------|---------|---------|---------------|
| Rsync | String | rsync地址 | | RFC 8630 §2.1 |
| Https | String | https地址 | | RFC 8630 §2.1 |
URI 解析与约束:
- `ta_uris[*]` 的 scheme **MUST** 为 `rsync` 或 `https`。RFC 8630 §2.2。
- 每个 `ta_uri` **MUST** 指向“单个对象”,且 **MUST NOT** 指向目录或集合。RFC 8630 §2.3。
## 1.4 抽象数据模型(接口)
### 1.4.1 `Tal`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw` | `bytes` | TAL 原始文件字节 | 原样保留(可选但建议) | RFC 8630 §2.2 |
| `comments` | `list[Utf8Text]` | 注释行(按出现顺序) | 每行以 `#` 开头;文本为 UTF-8;内容限制见 RFC 5198 §2 | RFC 8630 §2.2 |
| `ta_uris` | `list[Uri]` | TA 证书位置列表 | 至少 1 个;按序;每个 scheme 必须是 `rsync` 或 `https` | RFC 8630 §2.2 |
| `subject_public_key_info_der` | `DerBytes` | TA 证书 SPKI 的期望 DER | Base64 解码所得 DER;Base64 中可有换行 | RFC 8630 §2.2 |
### 1.4.2 `TaUri`(可选细化)
> 若你的实现希望对 URI 做更强类型化,可在 `Tal.ta_uris` 上进一步拆分为 `TaUri` 结构。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `uri` | `Uri` | 完整 URI 文本 | scheme 为 `rsync` 或 `https` | RFC 8630 §2.2 |
| `scheme` | `enum` | `rsync` / `https` | 从 `uri` 解析 | RFC 8630 §2.2 |
## 1.5 字段级约束清单(实现对照)
- TAL 由(可选)注释区 + URI 区 + 空行 + Base64(SPKI DER) 组成。RFC 8630 §2.2。
- URI 区至少 1 行,每行一个 TA URI,顺序有意义。RFC 8630 §2.2。
- TA URI 仅允许 `rsync` 或 `https`。RFC 8630 §2.2。
- Base64 区允许插入换行。RFC 8630 §2.2。
- 每个 TA URI 必须引用单个对象,不能指向目录/集合。RFC 8630 §2.3。

View File

@@ -1,121 +0,0 @@
# 02. Trust Anchor (TA)
## 2.1 对象定位
TA是一个自签名的CA证书。
## 2.2 原始载体与编码
- 载体:X.509 certificates.
- 编码:DER(遵循 RFC 5280 的 certificate 结构与字段语义,但受限于RFC 8630 §2.3)
## 2.3 抽象数据类型
### 2.3.1 TA
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|-------------------|------------------|---------------|---------|---------------|
| name | String | 标识该TA,如apnic等 | | |
| cert_der | Vec<u8> | 原始DER内容 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
| publication_point | Uri | 获取该TA的URI | | |
### 2.3.2 ResourceSet
资源集合是来自RFC 3779的IP地址块(§2)和AS号段(§3),受约束于RFC 8630 §2.3
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|------|----------------|--------|-------------|---------------------------|
| ips | IpResourceSet | IP地址集合 | 不能是inherit | RFC 3779 §2和RFC 8630 §2.3 |
| asns | AsnResourceSet | ASN集合 | 不能是inherit | RFC 3779 §3和RFC 8630 §2.3 |
[//]: # ()
[//]: # (### 2.3.3 IpResourceSet)
[//]: # (包括IPv4和IPv6的前缀表示)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----|------------------------|----------|-------------|--------------|)
[//]: # (| v4 | PrefixSet<Ipv4Prefix> | IPv4前缀集合 | | RFC 3779 §2 |)
[//]: # (| v6 | PrefixSet<Ipv6Prefix> | IPv6前缀集合 | | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.4 AsnResourceSet)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-------|--------------------|-------|-------------|-------------|)
[//]: # (| range | RangeSet<AsnBlock> | ASN集合 | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.5 Ipv4Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|-----|-----|---------|-------------|)
[//]: # (| addr | u32 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-32 | RFC 3779 §2 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.6 Ipv6Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|------|-----|---------|-------------|)
[//]: # (| addr | u128 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-128 | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.7 AsnBlock)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----------|----------|-------|---------|--------------|)
[//]: # (| asn | Asn | ASN | | RFC 3779 §3 |)
[//]: # (| asnRange | AsnRange | ASN范围 | | RFC 3779 §3 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.8 Asn)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-----|---------|-------------|)
[//]: # (| asn | u32 | ASN | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.8 AsnRange)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-------|---------|--------------|)
[//]: # (| min | Asn | 最小ASN | | RFC 3779 §3 |)
[//]: # (| max | Asn | 最大ASN | | RFC 3779 §3 |)
# 2.4 TA校验流程(RFC 8630 §3)
1. 从TAL的URI列表中获取证书对象。(顺序访问,若前面失效,再访问后面的)
2. 验证证书格式,必须是当前、有效的自签名RPKI证书。
3. 验证公钥匹配。TAL中的SubjectPublicKeyInfo与下载证书的公钥一致。
4. 其他检查。
5. 更新本地存储库缓存。

View File

@@ -0,0 +1,88 @@
# 02. TA(Trust Anchor)自签名证书
## 2.1 对象定位
在 RP 侧,“信任锚(Trust Anchor, TA)”以一个**自签名 CA 资源证书**体现,其可获取位置与期望公钥由 TAL 提供。RFC 8630 §2.3。
本文件描述两个紧密相关的数据对象:
1) `TaCertificate`:TA 自签名资源证书本体(X.509 DER)
2) `TrustAnchor`:语义组合对象(`TAL` + `TaCertificate` 的绑定语义)
## 2.2 原始载体与编码
- 载体:X.509 证书(通常以 `.cer` 存放于仓库,但文件扩展名不作为语义依据)。
- 编码:DER。TA 证书必须符合 RPKI 资源证书 profile。RFC 8630 §2.3;RFC 6487 §4。
### 2.2.1 X.509 Certificate 的 ASN.1 定义(RFC 5280 §4.1;TA 与 RC 共享)
TA 证书与普通资源证书(RC)在编码层面都是 X.509 `Certificate`(DER)。其 ASN.1 定义如下:RFC 5280 §4.1。
```asn1
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING }
```
其中 `tbsCertificate.extensions`(v3 扩展)是 RPKI 语义的主要承载处(IP/AS 资源扩展、SIA/AIA/CRLDP 等)。RFC 5280 §4.1;RPKI 对字段/扩展存在性与关键性约束见 RFC 6487 §4。
> 说明:更完整的 RC 编码层结构(包括 Extension 外层“extnValue 二次 DER 解码”的套娃方式)在 `03_resource_certificate_rc.md` 与 `00_common_types.md` 中给出。
## 2.3 TA 证书的 RPKI 语义约束(在 RC profile 基础上额外强调)
### 2.3.1 自签名与 profile
- TA URI 指向的对象 **MUST** 是一个**自签名 CA 证书**,并且 **MUST** 符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
- 自签名证书在 RC profile 下的通用差异(例如 CRLDP/AIA 的省略规则、AKI 的规则)见 RFC 6487。RFC 6487 §4.8.3;RFC 6487 §4.8.6;RFC 6487 §4.8.7。
### 2.3.2 INR(IP/AS 资源扩展)在 TA 上的额外约束
- TA 的 INR 扩展(IP/AS 资源扩展,RFC 3779)**MUST** 是非空资源集合。RFC 8630 §2.3。
- TA 的 INR 扩展 **MUST NOT** 使用 `inherit` 形式。RFC 8630 §2.3。
- 说明:一般 RC profile 允许 `inherit`。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3.5;RFC 3779 §3.2.3.3。
### 2.3.3 TAL ↔ TA 公钥绑定
- 用于验证 TA 的公钥(来自 TAL 中的 SPKI)**MUST** 与 TA 证书中的 `subjectPublicKeyInfo` 相同。RFC 8630 §2.3。
### 2.3.4 TA 稳定性语义(实现需建模为“约束/假设”,但不属于验证结果态)
- TA 公钥与 TAL 中公钥必须保持稳定(用于 RP 侧长期信任锚)。RFC 8630 §2.3。
### 2.3.5 TA 与 CRL/Manifest 的关系(语义)
- RFC 8630 指出:TA 为自签名证书,没有对应 CRL,且不会被 manifest 列出;TA 的获取/轮换由 TAL 控制。RFC 8630 §2.3。
> 注:这条更偏“发布/运维语义”,但对数据对象建模有影响:`TrustAnchor` 组合对象不应依赖 CRL/MFT 的存在。
## 2.4 抽象数据模型(接口)
### 2.4.1 `TaCertificate`
> 该对象在字段层面复用 `RC(CA)` 的语义模型(见 `03_resource_certificate_rc.md`),但增加 TA 特有约束。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | TA 证书 DER | X.509 DER;证书 profile 约束见 RC 文档 | RFC 8630 §2.3;RFC 6487 §4 |
| `rc_ca` | `ResourceCaCertificate` | 以 RC(CA) 语义解析出的字段集合 | 必须满足“自签名 CA”分支约束;且 INR 必须非空且不允许 inherit | RFC 8630 §2.3;RFC 6487 §4;RFC 3779 §2/§3 |
### 2.4.2 `TrustAnchor`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `tal` | `Tal` | TAL 文件语义对象 | 见 `01_tal.md` | RFC 8630 §2.2 |
| `ta_certificate` | `TaCertificate` | TA 证书语义对象 | TA URI 指向的对象 | RFC 8630 §2.3 |
| `tal_spki_der` | `DerBytes` | 从 TAL 解析出的 SPKI DER | `tal.subject_public_key_info_der` | RFC 8630 §2.2 |
| `ta_spki_der` | `DerBytes` | 从 TA 证书抽取的 SPKI DER | `ta_certificate` 的 `subjectPublicKeyInfo` | RFC 8630 §2.3;RFC 5280 §4.1.2.7 |
**绑定约束(字段级)**
- `tal_spki_der` 必须与 `ta_spki_der` 完全相等(字节层面的 DER 等价)。RFC 8630 §2.3。
## 2.5 字段级约束清单(实现对照)
- TA URI 指向的对象必须是自签名 CA 证书,且符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
- TA 的 INR 扩展必须非空,且不得使用 inherit。RFC 8630 §2.3。
- TAL 中 SPKI 必须与 TA 证书的 `subjectPublicKeyInfo` 匹配。RFC 8630 §2.3。
- TA 不依赖 CRL/MFT(无对应 CRL,且不被 manifest 列出)。RFC 8630 §2.3。

View File

@@ -1,314 +0,0 @@
# 03. RC (Resource Certifications)
## 3.1 对象定位
RC是资源证书,包括CA和EE
## 3.2 原始载体与编码
- 载体:X.509 certificates.
- 编码:DER(遵循 RFC 5280 的 Certificate 结构与字段语义,但受 RPKI profile 限制)RFC 6487 §4
### 3.2.1 基本语法(RFC 5280 §4,RFC 6487 )
RC是遵循RFC5280定义的X.509Certificate语法(RFC 5280 §4),并且符合RFC 6487 §4的约束。只选取RFC 6487 §4章节列出来的字段。(Unless specifically noted as being OPTIONAL, all the fields listed
here MUST be present, and any other fields MUST NOT appear in a
conforming resource certificate.)
```
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING
}
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version MUST be v3,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
subject Name,
validity Validity,
subjectPublicKeyInfo SubjectPublicKeyInfo,
extensions [3] EXPLICIT Extensions OPTIONAL
-- If present, version MUST be v3
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
```
> 其中`Name` "a valid X.501 distinguished name"(RFC 6487 §4.4)
### 3.2.2 证书扩展字段 (RFC 6487 §4.8)
RC的证书扩展字段按照RFC 6487 §4.8的规定,有以下几个扩展:
- Basic Constraints
- Subject Key Identifier
- Authority Key Identifier
- Key Usage
- Extended Key Usage(CA证书,以及验证RPKI对象的EE证书不能出现该字段。非RPKI对象的EE可以出现EKU,但必须为non-critical)
- CRL Distribution Points
- Authority Information Access
- Subject Information Access
- SIA for CA Certificates
- SIA for EE Certificates
- Certificate Policies
- IP Resources
- AS Resources
```
# Basic Constraints
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
BasicConstraints ::= SEQUENCE {
cA BOOLEAN DEFAULT FALSE }
# Subject Key Identifier
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
SubjectKeyIdentifier ::= KeyIdentifier
KeyIdentifier ::= OCTET STRING
# Authority Key Identifier
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
AuthorityKeyIdentifier ::= SEQUENCE {
keyIdentifier [0] KeyIdentifier OPTIONAL }
# Key Usage
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
KeyUsage ::= BIT STRING {
digitalSignature (0),
nonRepudiation (1), -- recent editions of X.509 have
-- renamed this bit to contentCommitment
keyEncipherment (2),
dataEncipherment (3),
keyAgreement (4),
keyCertSign (5),
cRLSign (6),
encipherOnly (7),
decipherOnly (8) }
# Extended Key Usage
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
KeyPurposeId ::= OBJECT IDENTIFIER
# CRL Distribution Points
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
DistributionPoint ::= SEQUENCE {
distributionPoint [0] DistributionPointName OPTIONAL }
DistributionPointName ::= CHOICE {
fullName [0] GeneralNames }
## Authority Information Access
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
AuthorityInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
# AccessDescription
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
# CA 证书发布位置
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
# OCSP 服务地址
id-ad-ocsp OBJECT IDENTIFIER ::= { id-ad 1 }
# Subject Information Access
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
SubjectInfoAccessSyntax ::= SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
## Subject Information Access for CA (RFC 6487 §4.8.8.1)
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 }
必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。
必须存在一个accessMethod=id-ad-repiManifest, accessLocation=rsync URI,指向该CA的mft对象。
## Subject Information Access for EE (RFC 6487 §4.8.8.2)
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 }
必须存在一个accessMethod=id-ad-signedObject, accessLocation=rsyncURI
不允许其他的accessMethod
# Certificate Policies
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
PolicyInformation ::= SEQUENCE {
policyIdentifier CertPolicyId,
policyQualifiers SEQUENCE SIZE (1..MAX) OF PolicyQualifierInfo OPTIONAL }
CertPolicyId ::= OBJECT IDENTIFIER
PolicyQualifierInfo ::= SEQUENCE {
policyQualifierId PolicyQualifierId,
qualifier ANY DEFINED BY policyQualifierId }
-- policyQualifierIds for Internet policy qualifiers
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
Qualifier ::= CHOICE {
cPSuri CPSuri,
userNotice UserNotice }
CPSuri ::= IA5String
UserNotice ::= SEQUENCE {
noticeRef NoticeReference OPTIONAL,
explicitText DisplayText OPTIONAL }
NoticeReference ::= SEQUENCE {
organization DisplayText,
noticeNumbers SEQUENCE OF INTEGER }
DisplayText ::= CHOICE {
ia5String IA5String (SIZE (1..200)),
visibleString VisibleString (SIZE (1..200)),
bmpString BMPString (SIZE (1..200)),
utf8String UTF8String (SIZE (1..200)) }
# IP Resources
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
addressFamily OCTET STRING (SIZE (2..3)),
ipAddressChoice IPAddressChoice }
IPAddressChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
addressesOrRanges SEQUENCE OF IPAddressOrRange }
IPAddressOrRange ::= CHOICE {
addressPrefix IPAddress,
addressRange IPAddressRange }
IPAddressRange ::= SEQUENCE {
min IPAddress,
max IPAddress }
IPAddress ::= BIT STRING
# AS Resources
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
ASIdentifiers ::= SEQUENCE {
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
ASIdentifierChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
asIdsOrRanges SEQUENCE OF ASIdOrRange }
ASIdOrRange ::= CHOICE {
id ASId,
range ASRange }
ASRange ::= SEQUENCE {
min ASId,
max ASId }
ASId ::= INTEGER
```
# 3.3 抽象数据结构
采用X509 Certificate + Resource + 约束校验的方式组合
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|----------|---------------------|----------|---------|---------------|
| cert_der | Vec<u8> | 证书原始数据 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
# 3.4 约束规则
## 3.4.1 Cert约束校验规则
RFC 6487中规定的证书的字段参见[3.2.1 ](#321-基本语法rfc-5280-4rfc-6487-)
-
| 字段 | 语义 | 约束/解析规则 | RFC 引用 |
|-----------|-------|----------------------------------------------|--------------|
| version | 证书版本 | 必须是v3(值为2) | RFC6487 §4.1 |
| serial | 证书编号 | 同一个CA签发的证书编号必须唯一 | RFC6487 §4.2 |
| validity | 证书有效期 | notBefore:时间不能早于证书的生成时间。若时间段大于上级证书的有效期,也是有效的 | RFC6487 §4.6 |
## 3.4.2 Cert Extentions中字段的约束校验规则
RFC 6487中规定的扩展字段参见[3.2.2 ](#322-证书扩展字段-rfc-6487-48)
| 字段 | critical | 语义 | 约束/解析规则 | RFC 引用 |
|----------------------------|----------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|
| basicConstraints | Y | 证书类型 | CA证书:cA=TRUE; EE证书:cA=FALSE | RFC6487 §4.8.1 |
| subjectKeyIdentifier | N | 证书公钥 | SKI = SHA-1(DER-encoded SPKI bit string) | RFC6487 §4.8.2 |
| authorityKeyIdentifier | N | 父证书的公钥 | 字段只包含keyIdentifier,不能包含authorityCertIssuer和authorityCertSerialNumber;除了自签名CA外,其余证书必须出现。自签名CA若出现该字段,则等于SKI | RFC6487 §4.8.3 |
| keyUsage | Y | 证书公钥的用途权限 | CA证书:keyCertSign = TRUE, cRLSign = TRUE 其他都是FALSE。EE证书:digitalSignature = TRUE 其他都是FALSE | RFC6487 §4.8.4 |
| extendedKeyUsage | N | 扩展证书公钥的用途权限 | CA证书:不能出现EKU;验证 RPKI 对象的 EE 证书:不能出现EKU;非 RPKI 对象的 EE:可以出现EKU,但必须为non-critical. | RFC6487 §4.8.5 |
| cRLDistributionPoints | N | CRL的发布点位置 | 字段:distributionPoint,不能包含reasons、cRLIssuer。其中distributionPoint字段包含:fullName,不能包含nameRelativeToCRLIssuer。fullName的格式必须是URI。自签名证书禁止出现该字段。非自签名证书必须出现。一个CA只能有一个CRL。一个CRLDP只能包含一个distributionPoint。但一个distributionPoint字段中可以包含多于1个的URI,但必须包含rsync URI且必须是最新的。 | RFC6487 §4.8.6 |
| authorityInformationAccess | N | 签发者的发布点位置 | 除了自签名的CA,必须出现。自签名CA,禁止出现。推荐的URI访问方式是rsync,并且rsyncURI的话,必须指定accessMethod=id-ad-caIssuers | RFC6487 §4.8.7 |
| subjectInformationAccess | N | 发布点位置 | CA证书:必须存在。必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。必须存在一个accessMethod=id-ad-repiManifest,accessLocation=rsync URI,指向该CA的mft对象。 EE证书:必须存在。必须存在一个accessMethod=id-ad-signedObject,accessLocation=rsyncURI。不允许其他的accessMethod | RFC6487 §4.8.8 |
| certificatePolicies | Y | 证书策略 | 必须存在,并且只能存在一种策略:RFC 6484 — RPKI Certificate Policy (CP) | RFC6487 §4.8.9 |
| iPResources | Y | IP地址集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.10 |
| aSResources | Y | ASN集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.11 |

View File

@@ -0,0 +1,460 @@
# 03. RC(Resource Certificate:资源证书,CA/EE)
## 3.1 对象定位
资源证书(RC)是 X.509 v3 证书,遵循 PKIX profile(RFC 5280),并受 RPKI profile 进一步约束。RFC 6487 §4。
RC 在 RPKI 中至少分为两类语义用途:
- `CA 证书`:签发下级证书/CRL,并在 SIA 中声明发布点与 manifest。RFC 6487 §4.8.8.1。
- `EE 证书`:用于验证某个 RPKI Signed Object(如 ROA/MFT),在 SIA 中指向被验证对象。RFC 6487 §4.8.8.2。
## 3.2 原始载体与编码
- 载体:X.509 证书。
- 编码:DER。RFC 6487 §4(“valid X.509 public key certificate consistent with RFC 5280” + RPKI 限制)。
### 3.2.1 X.509 v3 证书基本语法(ASN.1;RFC 5280 §4.1)
资源证书在编码层面是 RFC 5280 定义的 X.509 v3 `Certificate`(DER),其中 `tbsCertificate` 携带主体字段与扩展集合(`Extensions`)。RFC 5280 §4.1。
```asn1
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING }
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version DEFAULT v1,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
validity Validity,
subject Name,
subjectPublicKeyInfo SubjectPublicKeyInfo,
issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
extensions [3] EXPLICIT Extensions OPTIONAL
-- If present, version MUST be v3
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
```
### 3.2.2 AlgorithmIdentifier(ASN.1;RFC 5280 §4.1.1.2)
```asn1
AlgorithmIdentifier ::= SEQUENCE {
algorithm OBJECT IDENTIFIER,
parameters ANY DEFINED BY algorithm OPTIONAL }
```
### 3.2.3 Name / DN 结构(ASN.1;RFC 5280 §4.1.2.4)
```asn1
Name ::= CHOICE { -- only one possibility for now --
rdnSequence RDNSequence }
RDNSequence ::= SEQUENCE OF RelativeDistinguishedName
RelativeDistinguishedName ::=
SET SIZE (1..MAX) OF AttributeTypeAndValue
AttributeTypeAndValue ::= SEQUENCE {
type AttributeType,
value AttributeValue }
AttributeType ::= OBJECT IDENTIFIER
AttributeValue ::= ANY -- DEFINED BY AttributeType
DirectoryString ::= CHOICE {
teletexString TeletexString (SIZE (1..MAX)),
printableString PrintableString (SIZE (1..MAX)),
universalString UniversalString (SIZE (1..MAX)),
utf8String UTF8String (SIZE (1..MAX)),
bmpString BMPString (SIZE (1..MAX)) }
```
### 3.2.4 GeneralNames / GeneralName(ASN.1;RFC 5280 §4.2.1.6)
> 说明:RPKI 的 AIA/SIA/CRLDP 等扩展通常把 URI 编码在 `uniformResourceIdentifier [6] IA5String` 分支中。RFC 5280 §4.2.1.6。
```asn1
GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
GeneralName ::= CHOICE {
otherName [0] OtherName,
rfc822Name [1] IA5String,
dNSName [2] IA5String,
x400Address [3] ORAddress,
directoryName [4] Name,
ediPartyName [5] EDIPartyName,
uniformResourceIdentifier [6] IA5String,
iPAddress [7] OCTET STRING,
registeredID [8] OBJECT IDENTIFIER }
OtherName ::= SEQUENCE {
type-id OBJECT IDENTIFIER,
value [0] EXPLICIT ANY DEFINED BY type-id }
EDIPartyName ::= SEQUENCE {
nameAssigner [0] DirectoryString OPTIONAL,
partyName [1] DirectoryString }
```
### 3.2.5 AIA(Authority Information Access;ASN.1;RFC 5280 §4.2.2.1)
```asn1
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
AuthorityInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
```
### 3.2.6 SIA(Subject Information Access;ASN.1;RFC 5280 §4.2.2.2)
```asn1
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
SubjectInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-caRepository OBJECT IDENTIFIER ::= { id-ad 5 }
```
### 3.2.7 RPKI 在 SIA 中新增/使用的 accessMethod OID(RFC 6487 §4.8.8.1 / §4.8.8.2;RFC 8182 §3.2)
> 说明:下列 OID 用于 `AccessDescription.accessMethod`,并放在 SIA 的 `extnValue` 内层结构中(其外层 extnID 仍为 SIA:`id-pe-subjectInfoAccess`)。RFC 6487 §4.8.8;RFC 8182 §3.2。
```asn1
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 } -- 1.3.6.1.5.5.7.48.10
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 } -- 1.3.6.1.5.5.7.48.11
id-ad-rpkiNotify OBJECT IDENTIFIER ::= { id-ad 13 } -- 1.3.6.1.5.5.7.48.13
```
### 3.2.8 CRLDistributionPoints(CRLDP;ASN.1;RFC 5280 §4.2.1.13)
```asn1
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
DistributionPoint ::= SEQUENCE {
distributionPoint [0] DistributionPointName OPTIONAL,
reasons [1] ReasonFlags OPTIONAL,
cRLIssuer [2] GeneralNames OPTIONAL }
DistributionPointName ::= CHOICE {
fullName [0] GeneralNames,
nameRelativeToCRLIssuer [1] RelativeDistinguishedName }
ReasonFlags ::= BIT STRING {
unused (0),
keyCompromise (1),
cACompromise (2),
affiliationChanged (3),
superseded (4),
cessationOfOperation (5),
certificateHold (6),
privilegeWithdrawn (7),
aACompromise (8) }
```
### 3.2.9 Certificate Policies(ASN.1;RFC 5280 §4.2.1.4)
```asn1
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
PolicyInformation ::= SEQUENCE {
policyIdentifier CertPolicyId,
policyQualifiers SEQUENCE SIZE (1..MAX) OF
PolicyQualifierInfo OPTIONAL }
CertPolicyId ::= OBJECT IDENTIFIER
PolicyQualifierInfo ::= SEQUENCE {
policyQualifierId PolicyQualifierId,
qualifier ANY DEFINED BY policyQualifierId }
-- policyQualifierIds for Internet policy qualifiers
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
Qualifier ::= CHOICE {
cPSuri CPSuri,
userNotice UserNotice }
CPSuri ::= IA5String
```
### 3.2.10 RFC 3779 IP/AS 资源扩展(ASN.1;RFC 3779 §2.2.1-§2.2.3;RFC 3779 §3.2.1-§3.2.3)
> 说明:RFC 3779 给出两个扩展的 OID 与 ASN.1 语法;它们作为 X.509 v3 扩展出现在 `extensions` 中(外层 extnID 为下列 OID)。RPKI profile 进一步约束 criticality/SAFI/RDI 等,见 RFC 6487 §4.8.10-§4.8.11。
```asn1
-- IP Address Delegation Extension
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
addressFamily OCTET STRING (SIZE (2..3)),
ipAddressChoice IPAddressChoice }
IPAddressChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
addressesOrRanges SEQUENCE OF IPAddressOrRange }
IPAddressOrRange ::= CHOICE {
addressPrefix IPAddress,
addressRange IPAddressRange }
IPAddressRange ::= SEQUENCE {
min IPAddress,
max IPAddress }
IPAddress ::= BIT STRING
-- Autonomous System Identifier Delegation Extension
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
ASIdentifiers ::= SEQUENCE {
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
ASIdentifierChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
asIdsOrRanges SEQUENCE OF ASIdOrRange }
ASIdOrRange ::= CHOICE {
id ASId,
range ASRange }
ASRange ::= SEQUENCE {
min ASId,
max ASId }
ASId ::= INTEGER
```
### 3.2.11 其它 RPKI profile 相关扩展的 ASN.1 定义(RFC 5280 §4.2.1.1-§4.2.1.3;RFC 5280 §4.2.1.9;RFC 5280 §4.2.1.12)
> 说明:这些是 RPKI 资源证书 profile(RFC 6487 §4.8)所引用的通用 PKIX 扩展语法。RPKI 对其“必须/禁止/criticality/字段允许性”有额外限制(见本文件 3.3/3.4),但编码层的 ASN.1 类型来自 RFC 5280。
```asn1
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
AuthorityKeyIdentifier ::= SEQUENCE {
keyIdentifier [0] KeyIdentifier OPTIONAL,
authorityCertIssuer [1] GeneralNames OPTIONAL,
authorityCertSerialNumber [2] CertificateSerialNumber OPTIONAL }
KeyIdentifier ::= OCTET STRING
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
SubjectKeyIdentifier ::= KeyIdentifier
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
KeyUsage ::= BIT STRING {
digitalSignature (0),
nonRepudiation (1), -- recent editions of X.509 have
-- renamed this bit to contentCommitment
keyEncipherment (2),
dataEncipherment (3),
keyAgreement (4),
keyCertSign (5),
cRLSign (6),
encipherOnly (7),
decipherOnly (8) }
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
BasicConstraints ::= SEQUENCE {
cA BOOLEAN DEFAULT FALSE,
pathLenConstraint INTEGER (0..MAX) OPTIONAL }
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
KeyPurposeId ::= OBJECT IDENTIFIER
```
## 3.3 抽象数据模型(接口)
> 说明:本模型面向“语义化解析产物”。实现可保留 `raw_der` 作为可追溯入口。
### 3.3.1 顶层联合类型:`ResourceCertificate`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | 证书 DER | 原样保留(建议) | RFC 6487 §4 |
| `tbs` | `RpkixTbsCertificate` | 证书语义字段(见下) | 仅允许 RFC 6487 允许的字段/扩展;其他字段 MUST NOT 出现 | RFC 6487 §4 |
| `kind` | `enum { ca, ee }` | 语义分类 | 来自 BasicConstraints + 用途约束 | RFC 6487 §4.8.1;RFC 6487 §4.8.8 |
### 3.3.1.1 派生类型(用于字段类型标注)
为避免在其它对象文档里反复写“`ResourceCertificate` 且 `kind==...`”,这里定义两个派生/别名类型:
- `ResourceCaCertificate`:`ResourceCertificate` 且 `kind == ca`
- `ResourceEeCertificate`:`ResourceCertificate` 且 `kind == ee`
这些派生类型不引入新字段,只是对 `ResourceCertificate.kind` 的约束化视图。
### 3.3.2 `RpkixTbsCertificate`(语义字段集合)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | X.509 版本 | MUST 为 v3(字段值为 2) | RFC 6487 §4.1 |
| `serial_number` | `int` | 序列号 | 正整数;对每 CA 签发唯一 | RFC 6487 §4.2 |
| `signature_algorithm` | `Oid` | 证书签名算法 | 必须为 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6487 §4.3;RFC 7935 §2(引用 RFC 4055) |
| `issuer_dn` | `RpkixDistinguishedName` | 颁发者 DN | 必含 1 个 CommonName;可含 1 个 serialNumber;CN 必须 PrintableString | RFC 6487 §4.4 |
| `subject_dn` | `RpkixDistinguishedName` | 主体 DN | 同 issuer 约束;且对同一 issuer 下“实体+公钥”唯一 | RFC 6487 §4.5 |
| `validity_not_before` | `UtcTime` | 有效期起 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.1;RFC 5280 §4.1.2.5 |
| `validity_not_after` | `UtcTime` | 有效期止 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.2;RFC 5280 §4.1.2.5 |
| `subject_public_key_info` | `DerBytes` | SPKI DER | 算法 profile 指定 | RFC 6487 §4.7;RFC 7935 §3.1 |
| `extensions` | `RpkixExtensions` | 扩展集合 | 见下表;criticality/存在性/内容受约束 | RFC 6487 §4.8 |
### 3.3.3 `RpkixDistinguishedName`(RPKI profile 下的 DN 语义)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `common_name` | `string` | CommonName (CN) | MUST 存在且仅 1 个;类型为 PrintableString | RFC 6487 §4.4;RFC 6487 §4.5 |
| `serial_number` | `optional[string]` | serialNumber | MAY 存在且仅 1 个 | RFC 6487 §4.4;RFC 6487 §4.5 |
| `rfc4514` | `string` | DN 的 RFC4514 字符串表示 | 便于日志/索引(实现自选) | RFC 6487 §4.5(引用 RFC4514) |
### 3.3.4 `RpkixExtensions`(核心扩展语义)
> 表中 “存在性/criticality” 指 RPKI profile 下对该扩展的要求;实现应能区分 “字段缺失” 与 “字段存在但不符合约束”。
| 字段 | 类型 | 语义 | 存在性/criticality 与内容约束 | RFC 引用 |
|---|---|---|---|---|
| `basic_constraints` | `optional[BasicConstraints]` | CA 标志 | **extnID=`2.5.29.19`**;CA 证书:MUST present & critical;EE:MUST NOT present;pathLen MUST NOT present | RFC 6487 §4.8.1;RFC 5280 §4.2.1.9 |
| `subject_key_identifier` | `bytes` | SKI | **extnID=`2.5.29.14`**;MUST present & non-critical;值为 subjectPublicKey 的 DER bit string 的 SHA-1 哈希 | RFC 6487 §4.8.2(引用 RFC 5280 §4.2.1.2) |
| `authority_key_identifier` | `optional[AuthorityKeyIdentifier]` | AKI | **extnID=`2.5.29.35`**;自签名:MAY present 且可等于 SKI;非自签名:MUST present;authorityCertIssuer/authorityCertSerialNumber MUST NOT present;non-critical | RFC 6487 §4.8.3;RFC 5280 §4.2.1.1 |
| `key_usage` | `KeyUsage` | KeyUsage | **extnID=`2.5.29.15`**;MUST present & critical;CA:仅 `keyCertSign` 与 `cRLSign` 为 TRUE;EE:仅 `digitalSignature` 为 TRUE | RFC 6487 §4.8.4;RFC 5280 §4.2.1.3 |
| `extended_key_usage` | `optional[OidSet]` | EKU | **extnID=`2.5.29.37`**;CA:MUST NOT appear;用于验证 RPKI 对象的 EE:MUST NOT appear;若出现不得标 critical | RFC 6487 §4.8.5;RFC 5280 §4.2.1.12 |
| `crl_distribution_points` | `optional[CrlDistributionPoints]` | CRLDP | **extnID=`2.5.29.31`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;仅 1 个 DistributionPoint;fullName URI;必须包含至少 1 个 `rsync://` | RFC 6487 §4.8.6;RFC 5280 §4.2.1.13 |
| `authority_info_access` | `optional[AuthorityInfoAccess]` | AIA | **extnID=`1.3.6.1.5.5.7.1.1`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;必须含 accessMethod `id-ad-caIssuers`(**`1.3.6.1.5.5.7.48.2`**) 的 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
| `subject_info_access_ca` | `optional[SubjectInfoAccessCa]` | SIA(CA) | **extnID=`1.3.6.1.5.5.7.1.11`**;CA:MUST present & non-critical;必须含 accessMethod `id-ad-caRepository`(**`1.3.6.1.5.5.7.48.5`**)(`rsync://` 目录 URI)与 `id-ad-rpkiManifest`(**`1.3.6.1.5.5.7.48.10`**)(`rsync://` 对象 URI);若 CA 使用 RRDP,还会包含 `id-ad-rpkiNotify`(**`1.3.6.1.5.5.7.48.13`**)(HTTPS Notification URI) | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2;RFC 8182 §3.2 |
| `subject_info_access_ee` | `optional[SubjectInfoAccessEe]` | SIA(EE) | **extnID=`1.3.6.1.5.5.7.1.11`**;EE:MUST present & non-critical;必须含 accessMethod `id-ad-signedObject`(**`1.3.6.1.5.5.7.48.11`**);URI **MUST include** `rsync://`;EE 的 SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
| `certificate_policies` | `CertificatePolicies` | 证书策略 | **extnID=`2.5.29.32`**;MUST present & critical;恰好 1 个 policy;并允许 0 或 1 个 CPS qualifier(若存在其 id 必为 `id-qt-cps`(**`1.3.6.1.5.5.7.2.1`**)) | RFC 6487 §4.8.9;RFC 7318 §2;RFC 5280 §4.2.1.4 |
| `ip_resources` | `optional[IpResourceSet]` | IP 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.7`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;在公用互联网场景 SAFI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.10;RFC 3779 §2.2.1;RFC 3779 §2.2.2 |
| `as_resources` | `optional[AsResourceSet]` | AS 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.8`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;RDI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.11;RFC 3779 §3.2.1;RFC 3779 §3.2.2 |
### 3.3.5 结构化子类型(建议)
#### `BasicConstraints`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca` | `bool` | 是否 CA | 由 issuer 决定;在 CA 证书中该扩展必须存在 | RFC 6487 §4.8.1 |
| `path_len_constraint` | `None` | pathLenConstraint | MUST NOT present(RPKI profile 不使用) | RFC 6487 §4.8.1 |
#### `AuthorityKeyIdentifier`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `key_identifier` | `bytes` | AKI.keyIdentifier | 使用 issuer 公钥的 SHA-1 哈希(按 RFC 5280 的定义) | RFC 6487 §4.8.3(引用 RFC 5280 §4.2.1.1) |
| `authority_cert_issuer` | `None` | authorityCertIssuer | MUST NOT present | RFC 6487 §4.8.3 |
| `authority_cert_serial_number` | `None` | authorityCertSerialNumber | MUST NOT present | RFC 6487 §4.8.3 |
#### `CrlDistributionPoints`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `distribution_point_uris` | `list[Uri]` | CRL 位置列表 | 仅 1 个 DistributionPoint;必须包含至少 1 个 `rsync://` URI 指向该 issuer 最新 CRL;可含其它 URI | RFC 6487 §4.8.6 |
#### `AuthorityInfoAccess`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca_issuers_uris` | `list[Uri]` | 上级 CA 证书位置 | accessMethod=`id-ad-caIssuers`(`1.3.6.1.5.5.7.48.2`);必含 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
#### `SubjectInfoAccessCa`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca_repository_uris` | `list[Uri]` | CA 发布点目录(repository publication point) | accessMethod=`id-ad-caRepository`(`1.3.6.1.5.5.7.48.5`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一目录”的替代访问方式;顺序表示 CA 偏好 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
| `rpki_manifest_uris` | `list[Uri]` | 当前 manifest 对象 URI | accessMethod=`id-ad-rpkiManifest`(`1.3.6.1.5.5.7.48.10`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一对象”的替代访问方式 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
| `rpki_notify_uris` | `optional[list[Uri]]` | RRDP Notification(Update Notification File)URI | accessMethod=`id-ad-rpkiNotify`(`1.3.6.1.5.5.7.48.13`);若存在则 accessLocation MUST 为 `https://` URI,指向 RRDP Notification 文件 | RFC 8182 §3.2;RFC 5280 §4.2.2.2 |
#### `SubjectInfoAccessEe`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `signed_object_uris` | `list[Uri]` | 被 EE 证书验证的签名对象位置 | accessMethod=`id-ad-signedObject`(`1.3.6.1.5.5.7.48.11`);必须包含 `rsync://`;其它 URI 可作为同对象替代机制;EE SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
#### `CertificatePolicies`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `policy_oid` | `Oid` | 唯一 policy OID | 恰好 1 个 policy;RPKI CP 分配的 OID 为 `id-cp-ipAddr-asNumber`(`1.3.6.1.5.5.7.14.2`) | RFC 6487 §4.8.9;RFC 6484 §1.2 |
| `cps_uri` | `optional[Uri]` | CPS policy qualifier URI | MAY 存在且最多 1 个;若存在其 `policyQualifierId` 必为 `id-qt-cps`;对该 URI 不施加处理要求 | RFC 7318 §2;RFC 5280 §4.2.1.4 |
## 3.4 字段级约束清单(实现对照)
- 仅允许 RFC 6487 §4 指定的字段/扩展;未列出字段 MUST NOT 出现。RFC 6487 §4。
- 证书版本必须为 v3。RFC 6487 §4.1。
- CA/EE 在 BasicConstraints 与 SIA 的约束不同。RFC 6487 §4.8.1;RFC 6487 §4.8.8.1;RFC 6487 §4.8.8.2。
- KeyUsage:CA 仅 `keyCertSign`/`cRLSign`;EE 仅 `digitalSignature`。RFC 6487 §4.8.4。
- CRLDP/AIA:自签名必须省略;非自签名必须存在并包含 `rsync://`。RFC 6487 §4.8.6;RFC 6487 §4.8.7。
- IP/AS 资源扩展:两者至少其一存在;若存在必须 critical;语义来自 RFC 3779;在公用互联网场景 SAFI 与 RDI 均不得使用。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3;RFC 3779 §3.2.3。

View File

@@ -0,0 +1,158 @@
# 05. RPKI Signed Object(CMS SignedData 外壳)
## 5.1 对象定位
ROA、Manifest 等都属于 “RPKI Signed Object”,其外壳是 CMS SignedData,并受 RFC 6488 的 profile 约束;RFC 9589 进一步更新了 `signedAttrs` 的要求。RFC 6488 §2-§4;RFC 9589 §4。
本文件描述**通用外壳模型**(eContentType/eContent 由具体对象文档给出)。
## 5.2 原始载体与编码
- 载体:CMS `ContentInfo`,其中 `contentType` 为 SignedData。RFC 6488 §2;RFC 6488 §3(1a)。
- 编码:DER。RFC 6488 §2;RFC 6488 §3(1l)。
### 5.2.1 CMS 外壳:ContentInfo(ASN.1;RFC 5652 §3)
```asn1
ContentInfo ::= SEQUENCE {
contentType ContentType,
content [0] EXPLICIT ANY DEFINED BY contentType }
ContentType ::= OBJECT IDENTIFIER
```
### 5.2.2 CMS 外壳:SignedData(ASN.1;RFC 5652 §5.1)
```asn1
id-signedData OBJECT IDENTIFIER ::= { iso(1) member-body(2)
us(840) rsadsi(113549) pkcs(1) pkcs7(7) 2 }
SignedData ::= SEQUENCE {
version CMSVersion,
digestAlgorithms DigestAlgorithmIdentifiers,
encapContentInfo EncapsulatedContentInfo,
certificates [0] IMPLICIT CertificateSet OPTIONAL,
crls [1] IMPLICIT RevocationInfoChoices OPTIONAL,
signerInfos SignerInfos }
DigestAlgorithmIdentifiers ::= SET OF DigestAlgorithmIdentifier
SignerInfos ::= SET OF SignerInfo
```
### 5.2.3 CMS 外壳:EncapsulatedContentInfo(ASN.1;RFC 5652 §5.2)
```asn1
EncapsulatedContentInfo ::= SEQUENCE {
eContentType ContentType,
eContent [0] EXPLICIT OCTET STRING OPTIONAL }
ContentType ::= OBJECT IDENTIFIER
```
> 注:CMS 允许 `eContent` 不一定 DER 编码(RFC 5652 §5.2);但 RPKI signed object profile 要求**整个对象 DER 编码**(RFC 6488 §2;RFC 6488 §3(1l)),且 eContent(payload)由对象规范定义并通常为 DER(如 ROA:RFC 9582 §4;Manifest:RFC 9286 §4.2)。
### 5.2.4 CMS 外壳:SignerInfo 与 Attribute(ASN.1;RFC 5652 §5.3)
```asn1
SignerInfo ::= SEQUENCE {
version CMSVersion,
sid SignerIdentifier,
digestAlgorithm DigestAlgorithmIdentifier,
signedAttrs [0] IMPLICIT SignedAttributes OPTIONAL,
signatureAlgorithm SignatureAlgorithmIdentifier,
signature SignatureValue,
unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONAL }
SignerIdentifier ::= CHOICE {
issuerAndSerialNumber IssuerAndSerialNumber,
subjectKeyIdentifier [0] SubjectKeyIdentifier }
SignedAttributes ::= SET SIZE (1..MAX) OF Attribute
UnsignedAttributes ::= SET SIZE (1..MAX) OF Attribute
Attribute ::= SEQUENCE {
attrType OBJECT IDENTIFIER,
attrValues SET OF AttributeValue }
AttributeValue ::= ANY
SignatureValue ::= OCTET STRING
```
### 5.2.5 RPKI 对 CMS 外壳字段的 profile 约束(RFC 6488 §2.1;RFC 6488 §3;更新:RFC 9589 §4)
> 说明:上面是 CMS 的通用 ASN.1;RPKI 进一步约束取值与允许出现的字段(例如 SignedData.version 必须为 3、crls 必须省略、signedAttrs 的内容限制等)。RFC 6488 §2-§3;RFC 9589 §4。
### 5.2.6 signedAttrs 中允许的属性与 attrType OID(RFC 6488 §2.1.6.4.1-§2.1.6.4.2;更新:RFC 9589 §4)
RPKI signed object profile 对 `SignerInfo.signedAttrs` 的 Attribute 集合施加限制(除 ASN.1 结构外,还包含“只允许哪些 attrType”的编码约束):
- `content-type`:attrType OID `1.2.840.113549.1.9.3`。RFC 6488 §2.1.6.4.1。
- `message-digest`:attrType OID `1.2.840.113549.1.9.4`。RFC 6488 §2.1.6.4.2。
- `signing-time`:attrType OID `1.2.840.113549.1.9.5`。RFC 9589 §4(更新 RFC 6488 的相关要求)。
并且:
- 每种属性在集合中只能出现一次;且 `attrValues` 虽然语法是 `SET OF`,但在 RPKI 中必须只含一个值。RFC 6488 §2.1.6.4。
## 5.3 抽象数据模型(接口)
### 5.3.1 `RpkiSignedObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | CMS DER | 原样保留(建议) | RFC 6488 §2;RFC 6488 §3(1l) |
| `content_info_content_type` | `Oid` | ContentInfo.contentType | MUST 为 SignedData:`1.2.840.113549.1.7.2` | RFC 6488 §3(1a) |
| `signed_data` | `SignedDataProfiled` | SignedData 语义字段 | 见下 | RFC 6488 §2.1;RFC 6488 §3 |
### 5.3.2 `SignedDataProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | SignedData.version | MUST 为 3 | RFC 6488 §3(1b);RFC 6488 §2.1.1 |
| `digest_algorithms` | `list[Oid]` | SignedData.digestAlgorithms | MUST contain exactly one digest algorithm,且必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §2.1.2;RFC 7935 §2(引用 RFC 5754) |
| `encap_content_info` | `EncapsulatedContentInfo` | EncapsulatedContentInfo | 见下;eContentType 由具体对象定义 | RFC 6488 §2.1.3 |
| `certificates` | `list[ResourceEeCertificate]` | SignedData.certificates | MUST present;且仅包含 1 个 EE 证书;该 EE 的 SKI 必须匹配 SignerInfo.sid | RFC 6488 §3(1c) |
| `crls` | `None` | SignedData.crls | MUST be omitted | RFC 6488 §3(1d) |
| `signer_infos` | `list[SignerInfoProfiled]` | SignedData.signerInfos | MUST contain exactly one SignerInfo | RFC 6488 §2.1;RFC 6488 §2.1(SignerInfos 约束段落) |
### 5.3.3 `EncapsulatedContentInfo`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `econtent_type` | `Oid` | eContentType | MUST 与 signedAttrs.content-type 的 attrValues 一致;具体值由对象定义(如 ROA/MFT) | RFC 6488 §3(1h);RFC 6488 §2.1.3.1 |
| `econtent_der` | `DerBytes` | eContent(对象 payload) | DER 编码的对象特定 ASN.1(ROA/MFT 文档定义);在 CMS 中以 OCTET STRING 承载 | RFC 6488 §2.1.3;RFC 9286 §4.2;RFC 9582 §4 |
### 5.3.4 `SignerInfoProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | SignerInfo.version | MUST 为 3 | RFC 6488 §3(1e) |
| `sid_ski` | `bytes` | sid(SubjectKeyIdentifier) | 必须与 EE 证书的 SKI 匹配 | RFC 6488 §3(1c) |
| `digest_algorithm` | `Oid` | SignerInfo.digestAlgorithm | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §3(1j);RFC 7935 §2(引用 RFC 5754) |
| `signature_algorithm` | `Oid` | SignerInfo.signatureAlgorithm | 生成时 MUST 为 `rsaEncryption`(`1.2.840.113549.1.1.1`);验证时实现必须接受 `rsaEncryption` 或 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6488 §3(1k);RFC 7935 §2 |
| `signed_attrs` | `SignedAttrsProfiled` | signedAttrs | MUST present;仅允许特定 3 个属性 | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) |
| `unsigned_attrs` | `None` | unsignedAttrs | MUST be omitted | RFC 6488 §3(1i) |
### 5.3.5 `SignedAttrsProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `content_type` | `Oid` | signedAttrs.content-type | attrType=`1.2.840.113549.1.9.3`;MUST present;attrValues 等于 eContentType | RFC 9589 §4;RFC 6488 §3(1h) |
| `message_digest` | `bytes` | signedAttrs.message-digest | attrType=`1.2.840.113549.1.9.4`;MUST present | RFC 9589 §4(更新 RFC 6488 §3(1f)) |
| `signing_time` | `UtcTime` | signedAttrs.signing-time | attrType=`1.2.840.113549.1.9.5`;MUST present(时间值正确性不用于安全假设) | RFC 9589 §4;RFC 9589 §5 |
| `other_attrs` | `None` | 其它 signed attributes | MUST NOT be included(binary-signing-time 也不允许) | RFC 9589 §4 |
## 5.4 字段级约束清单(实现对照)
- ContentInfo.contentType 必须为 SignedData(OID `1.2.840.113549.1.7.2`)。RFC 6488 §3(1a)。
- SignedData.version 必须为 3,且 SignerInfos 仅允许 1 个 SignerInfo。RFC 6488 §3(1b);RFC 6488 §2.1。
- SignedData.certificates 必须存在且仅含 1 个 EE 证书;该证书 SKI 必须匹配 SignerInfo.sid。RFC 6488 §3(1c)。
- SignedData.crls 必须省略。RFC 6488 §3(1d)。
- signedAttrs 必须存在,且仅允许 content-type/message-digest/signing-time;其它全部禁止。RFC 9589 §4。
- eContentType 必须与 content-type attribute 一致。RFC 6488 §3(1h)。
- unsignedAttrs 必须省略。RFC 6488 §3(1i)。
- digest/signature 算法必须符合算法 profile。RFC 6488 §3(1j)/(1k);RFC 7935 §2。
- 整个对象必须 DER 编码。RFC 6488 §3(1l)。

110
specs/06_manifest_mft.md Normal file
View File

@@ -0,0 +1,110 @@
# 06. Manifest(MFT)
## 6.1 对象定位
Manifest 是 CA 发布点内对象的“清单”(文件名 + hash),用于 RP 侧检测删除/替换/回放等不一致情况。RFC 9286 §1;RFC 9286 §6。
Manifest 是一种 RPKI Signed Object:CMS 外壳遵循 RFC 6488/9589,eContent 遵循 RFC 9286。RFC 9286 §4;RFC 6488 §4;RFC 9589 §4。
## 6.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9286 §4;RFC 6488 §2。
- eContentType:`id-ct-rpkiManifest`,OID `1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1。
- eContent:DER 编码 ASN.1 `Manifest`。RFC 9286 §4.2。
### 6.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9286 §4.1;RFC 9286 §4.2)
Manifest 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent` 的 ASN.1 由 RFC 9286 明确定义。RFC 9286 §4。
**eContentType(OID)**:RFC 9286 §4.1。
```asn1
id-smime OBJECT IDENTIFIER ::= { iso(1) member-body(2) us(840)
rsadsi(113549) pkcs(1) pkcs9(9) 16 }
id-ct OBJECT IDENTIFIER ::= { id-smime 1 }
id-ct-rpkiManifest OBJECT IDENTIFIER ::= { id-ct 26 }
```
**eContent(Manifest 结构)**:RFC 9286 §4.2。
```asn1
Manifest ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
manifestNumber INTEGER (0..MAX),
thisUpdate GeneralizedTime,
nextUpdate GeneralizedTime,
fileHashAlg OBJECT IDENTIFIER,
fileList SEQUENCE SIZE (0..MAX) OF FileAndHash
}
FileAndHash ::= SEQUENCE {
file IA5String,
hash BIT STRING
}
```
解码要点:
- `fileHashAlg` 决定 `FileAndHash.hash` 的算法与输出长度(RPKI profile 要求 SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
- `hash` 在 ASN.1 中是 BIT STRING,但 hash 输出是按字节的比特串,DER 编码时应为 “unused bits = 0” 的 octet-aligned BIT STRING(实现可据此做一致性检查)。RFC 9286 §4.2。
## 6.3 解析规则(eContent 语义层)
输入:`RpkiSignedObject`。
1) 先按通用 Signed Object 外壳解析得到 `encap_content_info.econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1;RFC 9286 §4.4(1)。
3) 将 `econtent_der` 以 DER 解析为 `Manifest` ASN.1。RFC 9286 §4.2。
4) 将 `fileList` 映射为语义字段 `files: list[FileAndHash]`,其中 `hash` 为 `fileHashAlg` 对应算法的输出字节序列。RFC 9286 §4.2.1(fileHashAlg/fileList 定义)。
## 6.4 抽象数据模型(接口)
### 6.4.1 `ManifestObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9286 §4;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.26` | RFC 9286 §4.1 |
| `manifest` | `ManifestEContent` | eContent 语义对象 | 见下 | RFC 9286 §4.2 |
### 6.4.2 `ManifestEContent`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | Manifest.version | MUST 为 0 | RFC 9286 §4.2.1(version) |
| `manifest_number` | `int` | manifestNumber | 0..MAX;可达 20 octets;issuer 必须单调递增;RP 必须可处理至 20 octets | RFC 9286 §4.2;RFC 9286 §4.2.1(manifestNumber) |
| `this_update` | `UtcTime` | thisUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须比先前生成的 manifest 更新 | RFC 9286 §4.2;RFC 9286 §4.2.1(thisUpdate) |
| `next_update` | `UtcTime` | nextUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须晚于 thisUpdate | RFC 9286 §4.2;RFC 9286 §4.2.1(nextUpdate) |
| `file_hash_alg` | `Oid` | fileHashAlg | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 9286 §4.2.1(fileHashAlg);RFC 7935 §2(引用 RFC 5754) |
| `files` | `list[FileAndHash]` | fileList | `SEQUENCE SIZE (0..MAX)`;每项含文件名与 hash | RFC 9286 §4.2;RFC 9286 §4.2.1(fileList) |
### 6.4.3 `FileAndHash`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `file_name` | `string` | 文件名(不含路径) | 字符集限制:`[a-zA-Z0-9-_]+` + `.` + 三字母扩展;扩展必须在 IANA “RPKI Repository Name Schemes” 注册表中 | RFC 9286 §4.2.2 |
| `hash_bytes` | `bytes` | 文件内容 hash | 由 `file_hash_alg` 指定算法计算 | RFC 9286 §4.2.1(fileHashAlg/fileList) |
## 6.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-rpkiManifest`(OID `1.2.840.113549.1.9.16.1.26`)。RFC 9286 §4.1。
- eContent 必须 DER 编码且符合 `Manifest` ASN.1。RFC 9286 §4.2。
- `version` 必须为 0。RFC 9286 §4.2.1。
- `manifestNumber` 由 issuer 单调递增;RP 必须能处理至 20 octets;issuer 不得超过 20 octets。RFC 9286 §4.2.1。
- `nextUpdate` 必须晚于 `thisUpdate`。RFC 9286 §4.2.1。
- `fileHashAlg` 必须符合算法 profile(SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
- `fileList` 中 `file` 名称字符集与扩展名受限;实现需按 RFC 限制解析并保留大小写语义。RFC 9286 §4.2.2。
## 6.6 与 EE 证书的语义约束(为后续验证准备)
Manifest 使用“one-time-use EE certificate”进行签名验证,规范对该 EE 证书的使用方式给出约束:
- Manifest 相关 EE 证书应为 one-time-use(每次新 manifest 生成新密钥对/新 EE)。RFC 9286 §4(Section 4 前导段落)。
- 用于签名/验证 manifest 的 EE 证书在 RFC 3779 的资源扩展中描述 INRs 时,**MUST** 使用 `inherit`(而不是显式列出资源集合)。RFC 9286 §5.1(生成步骤 2)。
- 若证书包含 **IP Address Delegation Extension**(IP prefix delegation):内容必须为 `inherit`(不得显式列 prefix/range)。RFC 9286 §5.1;RFC 3779。
- 若证书包含 **AS Identifier Delegation Extension**(ASN delegation):内容必须为 `inherit`(不得显式列 ASID/range)。RFC 9286 §5.1;RFC 3779。
- 另外按资源证书 profile:资源证书 **MUST** 至少包含上述两类扩展之一(也可两者都有),且这些扩展 **MUST** 标记为 critical。RFC 6487 §2。
- 用于验证 manifest 的 EE 证书 **MUST** 具有与 `thisUpdate..nextUpdate` 区间一致的有效期,以避免 CRL 无谓增长。RFC 9286 §4.2.1(manifestNumber 段落前的说明)。
- 替换 manifest 时,CA 必须撤销旧 manifest 对应 EE 证书;且若新 manifest 早于旧 manifest 的 nextUpdate 发行,则 CA **MUST** 同时发行新 CRL 撤销旧 manifest EE。RFC 9286 §4.2.1(nextUpdate 段落末);RFC 9286 §5.1(生成步骤)。

159
specs/07_roa.md Normal file
View File

@@ -0,0 +1,159 @@
# 07. ROA(Route Origin Authorization)
## 7.1 对象定位
ROA 是一种 RPKI Signed Object,用于声明“某 AS 被授权起源某些前缀”。RFC 9582 §1;RFC 9582 §4。
ROA 由 CMS 外壳 + ROA eContent 组成:
- 外壳:RFC 6488(更新:RFC 9589)
- eContent:RFC 9582
## 7.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9582 §1(引用 RFC 6488)。
- eContentType:`id-ct-routeOriginAuthz`,OID `1.2.840.113549.1.9.16.1.24`。RFC 9582 §3。
- eContent:DER 编码 ASN.1 `RouteOriginAttestation`。RFC 9582 §4。
### 7.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9582 §3;RFC 9582 §4)
ROA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。RFC 9582 定义了其 `eContentType` 以及 `eContent`(payload)的 ASN.1。RFC 9582 §3-§4。
**eContentType(OID)**:RFC 9582 §3。
```asn1
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
```
**eContent(ROA ASN.1 模块)**:RFC 9582 §4。
```asn1
RPKI-ROA-2023
{ iso(1) member-body(2) us(840) rsadsi(113549)
pkcs(1) pkcs9(9) smime(16) mod(0)
id-mod-rpkiROA-2023(75) }
DEFINITIONS EXPLICIT TAGS ::=
BEGIN
IMPORTS
CONTENT-TYPE
FROM CryptographicMessageSyntax-2010 -- in [RFC6268]
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) smime(16) modules(0) id-mod-cms-2009(58) } ;
ct-routeOriginAttestation CONTENT-TYPE ::=
{ TYPE RouteOriginAttestation
IDENTIFIED BY id-ct-routeOriginAuthz }
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
RouteOriginAttestation ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
asID ASID,
ipAddrBlocks SEQUENCE (SIZE(1..2)) OF ROAIPAddressFamily }
ASID ::= INTEGER (0..4294967295)
ROAIPAddressFamily ::= SEQUENCE {
addressFamily ADDRESS-FAMILY.&afi ({AddressFamilySet}),
addresses ADDRESS-FAMILY.&Addresses
({AddressFamilySet}{@addressFamily}) }
ADDRESS-FAMILY ::= CLASS {
&afi OCTET STRING (SIZE(2)) UNIQUE,
&Addresses
} WITH SYNTAX { AFI &afi ADDRESSES &Addresses }
AddressFamilySet ADDRESS-FAMILY ::=
{ addressFamilyIPv4 | addressFamilyIPv6 }
addressFamilyIPv4 ADDRESS-FAMILY ::=
{ AFI afi-IPv4 ADDRESSES ROAAddressesIPv4 }
addressFamilyIPv6 ADDRESS-FAMILY ::=
{ AFI afi-IPv6 ADDRESSES ROAAddressesIPv6 }
afi-IPv4 OCTET STRING ::= '0001'H
afi-IPv6 OCTET STRING ::= '0002'H
ROAAddressesIPv4 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv4}
ROAAddressesIPv6 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv6}
ub-IPv4 INTEGER ::= 32
ub-IPv6 INTEGER ::= 128
ROAIPAddress {INTEGER: ub} ::= SEQUENCE {
address BIT STRING (SIZE(0..ub)),
maxLength INTEGER (0..ub) OPTIONAL }
END
```
编码/解码要点(与上面 ASN.1 结构直接对应):
- `addressFamily` 仅允许 IPv4/IPv6 两种 AFI,并且每个 AFI 最多出现一次。RFC 9582 §4.3.1。
- `address` 是 BIT STRING 表示的前缀,语义与 RFC 3779 的 `IPAddress` 一致(按前缀长度截断,DER unused bits 置零)。RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8)。
- `maxLength` 为可选字段,出现与否会影响语义与编码规范约束(例如等于前缀长时不建议编码)。RFC 9582 §4.3.2.2。
## 7.3 解析规则(eContent 语义层)
输入:`RpkiSignedObject`。
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.24`。RFC 9582 §3。
3) 将 `econtent_der` 以 DER 解析为 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
4) 将 `ipAddrBlocks` 解析为“前缀集合”的语义结构,并建议按 RFC 9582 给出的 canonicalization 过程做去重/排序/归一化(以便后续处理一致)。RFC 9582 §4.3.3。
## 7.4 抽象数据模型(接口)
### 7.4.1 `RoaObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9582 §1;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.24` | RFC 9582 §3 |
| `roa` | `RoaEContent` | eContent 语义对象 | 见下 | RFC 9582 §4 |
### 7.4.2 `RoaEContent`(RouteOriginAttestation)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | version | MUST 为 0 | RFC 9582 §4.1 |
| `as_id` | `int` | asID | 0..4294967295 | RFC 9582 §4(ASID 定义);RFC 9582 §4.2 |
| `ip_addr_blocks` | `list[RoaIpAddressFamily]` | ipAddrBlocks | `SIZE(1..2)`;最多 IPv4/IPv6 各一个;建议 canonicalize | RFC 9582 §4;RFC 9582 §4.3.1;RFC 9582 §4.3.3 |
### 7.4.3 `RoaIpAddressFamily`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `afi` | `enum { ipv4, ipv6 }` | Address Family | MUST 为 IPv4(0001) 或 IPv6(0002) | RFC 9582 §4.3.1 |
| `addresses` | `list[RoaIpAddress]` | 前缀列表 | `SIZE(1..MAX)`;每项为前缀 + 可选 maxLength | RFC 9582 §4(ROAAddressesIPv4/IPv6);RFC 9582 §4.3.2 |
### 7.4.4 `RoaIpAddress`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `prefix` | `IpPrefix` | 前缀 | address 以 BIT STRING 表示前缀(同 RFC 3779 IPAddress 表示) | RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8) |
| `max_length` | `optional[int]` | 最大允许前缀长 | 若存在:必须 `>= prefix_len` 且 `<= 32/128`;并且 `maxLength == prefix_len` 时 **SHOULD NOT** 编码(未来 RP 可能视为编码错误) | RFC 9582 §4.3.2.2 |
## 7.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-routeOriginAuthz`(OID `1.2.840.113549.1.9.16.1.24`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 9582 §3(引用 RFC 6488)。
- eContent 必须 DER 编码并符合 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
- `version` 必须为 0。RFC 9582 §4.1。
- `ipAddrBlocks` 长度为 1..2;每种 AFI 最多出现一次;仅支持 IPv4/IPv6。RFC 9582 §4;RFC 9582 §4.3.1。
- `maxLength` 若存在必须在范围内,且不应出现“等于前缀长”的冗余编码。RFC 9582 §4.3.2.2。
- 建议按 canonical form 归一化/排序以利一致处理。RFC 9582 §4.3.3。
## 7.6 与 EE 证书的语义约束(为后续验证准备)
ROA 的外壳包含一个 EE 证书,用于验证 ROA 签名;RFC 对该 EE 证书与 ROA payload 的匹配关系提出要求:
- ROA 的 EE 证书必须是有效的 RPKI EE 证书(路径从 TA 到 EE 可建立),并用于验证 CMS 签名。RFC 9582 §1(引用 RFC 6488);RFC 6488 §3(2)-(3)。
- ROA EE 证书中的 IP 资源扩展必须存在且不得使用 inherit。RFC 9582 §5。
- ROA EE 证书中 AS 资源扩展不得出现。RFC 9582 §5。
- ROA payload 中每个前缀必须包含在 EE 证书的 IP 资源集合内(资源包含语义来自 RFC 3779)。RFC 9582 §5;RFC 3779 §2.3。

100
specs/08_aspa.md Normal file
View File

@@ -0,0 +1,100 @@
# 08. ASPA(Autonomous System Provider Authorization)
## 8.1 对象定位
ASPA(Autonomous System Provider Authorization)是一种 RPKI Signed Object,用于由“客户 AS”(Customer AS, CAS)签名声明其上游“提供者 AS”(Provider AS, PAS)集合,以支持路由泄漏(route leak)检测/缓解。`draft-ietf-sidrops-aspa-profile-21`;`draft-ietf-sidrops-aspa-verification`。
ASPA 由 CMS 外壳 + ASPA eContent 组成:
- 外壳:RFC 6488(更新:RFC 9589)
- eContent:`draft-ietf-sidrops-aspa-profile-21`
## 8.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6488 §2-§3;RFC 9589 §4。
- eContentType:`id-ct-ASPA`,OID `1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。
- eContent:DER 编码 ASN.1 `ASProviderAttestation`。`draft-ietf-sidrops-aspa-profile-21` §3。
### 8.2.1 eContentType 与 eContent 的 ASN.1 定义(`draft-ietf-sidrops-aspa-profile-21` §2-§3)
ASPA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent`(payload)的 ASN.1 定义见 `draft-ietf-sidrops-aspa-profile-21` §2-§3。
**eContentType(OID)**:`draft-ietf-sidrops-aspa-profile-21` §2。
```asn1
id-ct-ASPA OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) aspa(49) }
```
**eContent(ASPA ASN.1 模块)**:`draft-ietf-sidrops-aspa-profile-21` §3。
```asn1
ASProviderAttestation ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
customerASID ASID,
providers ProviderASSet }
ProviderASSet ::= SEQUENCE (SIZE(1..MAX)) OF ASID
ASID ::= INTEGER (0..4294967295)
```
编码/解码要点(与上面 ASN.1 结构直接对应):
- `version`:规范要求 **MUST 为 1** 且 **MUST 显式编码**(不得依赖 DEFAULT 省略)。`draft-ietf-sidrops-aspa-profile-21` §3.1。
- `customerASID`:客户 AS 号(CAS)。`draft-ietf-sidrops-aspa-profile-21` §3.2。
- `providers`:授权的提供者 AS 集合(SPAS)。并对“自包含/排序/去重”施加额外约束(见下)。`draft-ietf-sidrops-aspa-profile-21` §3.3。
## 8.3 解析规则(eContent 语义层)
输入:`RpkiSignedObject`。
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。
3) 将 `econtent_der` 以 DER 解析为 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。
4) 将 `providers` 映射为语义字段 `provider_as_ids: list[int]`,并对其执行“约束检查/(可选)归一化”。`draft-ietf-sidrops-aspa-profile-21` §3.3。
## 8.4 抽象数据模型(接口)
### 8.4.1 `AspaObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.49` | `draft-ietf-sidrops-aspa-profile-21` §2 |
| `aspa` | `AspaEContent` | eContent 语义对象 | 见下 | `draft-ietf-sidrops-aspa-profile-21` §3 |
### 8.4.2 `AspaEContent`(ASProviderAttestation)
| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 |
|---|---|---|---|---|
| `version` | `int` | ASPA 版本 | MUST 为 `1` 且 MUST 显式编码(字段不可省略) | `draft-ietf-sidrops-aspa-profile-21` §3.1 |
| `customer_as_id` | `int` | Customer ASID | 0..4294967295 | `draft-ietf-sidrops-aspa-profile-21` §3.2(ASID 定义) |
| `provider_as_ids` | `list[int]` | Provider ASID 列表(SPAS) | 长度 `>= 1`;且满足“不得包含 customer、升序、去重” | `draft-ietf-sidrops-aspa-profile-21` §3.3 |
## 8.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-ASPA`(OID `1.2.840.113549.1.9.16.1.49`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。`draft-ietf-sidrops-aspa-profile-21` §2(引用 RFC 6488)。
- eContent 必须 DER 编码并符合 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。
- `version` 必须为 1,且必须显式编码(缺失视为不合规)。`draft-ietf-sidrops-aspa-profile-21` §3.1。
- `providers`(`provider_as_ids`)必须满足:
- `customer_as_id` **MUST NOT** 出现在 `provider_as_ids` 中;
- `provider_as_ids` 必须按数值 **升序排序**;
- `provider_as_ids` 中每个 ASID 必须 **唯一**。`draft-ietf-sidrops-aspa-profile-21` §3.3。
## 8.6 与 EE 证书的语义约束(为后续验证准备)
ASPA 的外壳包含一个 EE 证书,用于验证 ASPA 签名;规范对该 EE 证书与 ASPA payload 的匹配关系提出要求:
- EE 证书必须包含 AS 资源扩展(Autonomous System Identifier Delegation Extension),且 `customer_as_id` 必须与该扩展中的 ASId 匹配。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。
- EE 证书的 AS 资源扩展 **必须**:
- 恰好包含 1 个 `id` 元素;
- **不得**包含 `inherit` 元素;
- **不得**包含 `range` 元素。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779 §3.2.3.3 / §3.2.3.6 / §3.2.3.7)。
- EE 证书 **不得**包含 IP 资源扩展(IP Address Delegation Extension)。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。
## 8.7 实现建议(非规范约束)
`draft-ietf-sidrops-aspa-profile-21` 给出了一条 RP 侧建议:实现可对单个 `customer_as_id` 的 `provider_as_ids` 数量施加上界(例如 4,000~10,000),超过阈值时建议将该 `customer_as_id` 的所有 ASPA 视为无效并记录错误日志。`draft-ietf-sidrops-aspa-profile-21` §6。

View File

@@ -0,0 +1,87 @@
# 09. Ghostbusters Record(GBR)
## 9.1 对象定位
Ghostbusters Record(GBR)是一个可选的 RPKI Signed Object,用于承载“联系人信息”(人类可读的联系渠道),以便在证书过期、CRL 失效、密钥轮换等事件中能够联系到维护者。RFC 6493 §1。
GBR 由 CMS 外壳 + vCard 载荷组成:
- 外壳:RFC 6488(更新:RFC 9589)
- 载荷(payload):RFC 6493 定义的 vCard profile(基于 RFC 6350 vCard 4.0 的严格子集)。RFC 6493 §5。
## 9.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6493 §6(引用 RFC 6488)。
- eContentType:`id-ct-rpkiGhostbusters`,OID `1.2.840.113549.1.9.16.1.35`。RFC 6493 §6;RFC 6493 §9.1。
- eContent:一个 OCTET STRING,其 octets 是 vCard 文本(vCard 4.0,且受 RFC 6493 的 profile 约束)。RFC 6493 §5;RFC 6493 §6。
> 说明:与 ROA/MFT 这类“eContent 内部再 DER 解码为 ASN.1 结构”的对象不同,GBR 的 eContent 语义上就是“vCard 文本内容本身”(由 Signed Object Template 的 `eContent OCTET STRING` 承载)。RFC 6493 §6。
## 9.3 vCard profile(RFC 6493 §5)
GBR 的 vCard payload 是 RFC 6350 vCard 4.0 的严格子集,仅允许以下属性(properties):
- `BEGIN`:必须为第一行,值必须为 `BEGIN:VCARD`。RFC 6493 §5。
- `VERSION`:必须为第二行,值必须为 `VERSION:4.0`。RFC 6493 §5(引用 RFC 6350 §3.7.9)。
- `FN`:联系人姓名或角色名。RFC 6493 §5(引用 RFC 6350 §6.2.1)。
- `ORG`:组织信息(可选)。RFC 6493 §5(引用 RFC 6350 §6.6.4)。
- `ADR`:邮寄地址(可选)。RFC 6493 §5(引用 RFC 6350 §6.3)。
- `TEL`:语音/传真电话(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.1)。
- `EMAIL`:邮箱(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.2)。
- `END`:必须为最后一行,值必须为 `END:VCARD`。RFC 6493 §5。
额外约束:
- `BEGIN`、`VERSION`、`FN`、`END` 必须包含。RFC 6493 §5。
- 为保证可用性,`ADR`/`TEL`/`EMAIL` 三者中至少一个必须包含。RFC 6493 §5。
- 除上述属性外,**其他属性 MUST NOT** 出现。RFC 6493 §5。
## 9.4 解析规则(payload 语义层)
输入:`RpkiSignedObject`。
1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_bytes`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.35`。RFC 6493 §6。
3) 将 `econtent_bytes` 解析为 vCard 文本,并按 RFC 6493 §5 的 profile 校验(属性集合、必选项、行首/行尾约束)。RFC 6493 §5;RFC 6493 §7。
4) 通过校验后,将允许属性映射为 `GhostbustersVCard` 语义对象(见下)。
## 9.5 抽象数据模型(接口)
### 9.5.1 `GhostbustersObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6493 §6;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.35` | RFC 6493 §6 |
| `vcard` | `GhostbustersVCard` | vCard 语义对象 | 由 eContent 文本解析并校验 profile | RFC 6493 §5;RFC 6493 §7 |
### 9.5.2 `GhostbustersVCard`(vCard 4.0 profile)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_text` | `string` | 原始 vCard 文本 | 由 eContent bytes 解码得到;用于保留原文/诊断 | RFC 6493 §5-§7 |
| `fn` | `string` | 联系人姓名/角色名 | `FN` 必须存在 | RFC 6493 §5 |
| `org` | `optional[string]` | 组织 | `ORG` 可选 | RFC 6493 §5 |
| `adrs` | `list[string]` | 邮寄地址(原始 ADR value) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
| `tels` | `list[Uri]` | 电话 URI(从 TEL 提取的 `tel:` 等 URI) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
| `emails` | `list[string]` | 邮箱地址 | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 |
> 说明:RFC 6493 并未要求 RP 必须完整解析 vCard 参数(例如 `TYPE=WORK`、`VALUE=uri`),因此此处将 `ADR`/`TEL`/`EMAIL` 建模为“足以联络”的最小语义集合;实现可在保留 `raw_text` 的同时按 RFC 6350 扩展解析能力。
## 9.6 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-rpkiGhostbusters`(OID `1.2.840.113549.1.9.16.1.35`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 6493 §6(引用 RFC 6488)。
- eContent 必须是 vCard 4.0 文本,且必须满足 RFC 6493 §5 的 profile:
- 第一行 `BEGIN:VCARD`;
- 第二行 `VERSION:4.0`;
- 末行 `END:VCARD`;
- 必须包含 `FN`;
- `ADR`/`TEL`/`EMAIL` 至少一个存在;
- 除允许集合外不得出现其他属性。RFC 6493 §5;RFC 6493 §7。
## 9.7 与 EE 证书的语义约束(为后续验证准备)
GBR 使用 CMS 外壳内的 EE 证书验证签名。RFC 6493 对该 EE 证书提出一条资源扩展约束:
- 用于验证 GBR 的 EE 证书在描述 Internet Number Resources 时,必须使用 `inherit`,而不是显式资源集合。RFC 6493 §6(引用 RFC 3779)。

View File

@@ -1,186 +0,0 @@
# 10. SLURM(Simplified Local Internet Number Resource Management with the RPKI)
## 10.1 目标与范围
SLURM 用于让 RP(Relying Party)在本地对上游 RPKI 验证结果做“过滤”和“补充断言”,而不修改上游发布对象。
本文档基于:
- RFC 8416(SLURM v1,ROA/BGPsec)
- draft-ietf-sidrops-aspa-slurm-04(SLURM v2,新增 ASPA)
## 10.2 版本与顶层结构
### 10.2.1 SLURM v1(RFC 8416)
`slurmVersion` 必须为 `1`,且顶层 JSON 对象必须包含且仅包含以下成员:
- `slurmVersion`
- `validationOutputFilters`(必须包含 `prefixFilters`、`bgpsecFilters`)
- `locallyAddedAssertions`(必须包含 `prefixAssertions`、`bgpsecAssertions`)
空策略示例:
```json
{
"slurmVersion": 1,
"validationOutputFilters": {
"prefixFilters": [],
"bgpsecFilters": []
},
"locallyAddedAssertions": {
"prefixAssertions": [],
"bgpsecAssertions": []
}
}
```
### 10.2.2 SLURM v2(draft-04)
`slurmVersion` 必须为 `2`,在 v1 基础上扩展 ASPA 两类成员:
- `validationOutputFilters.aspaFilters`
- `locallyAddedAssertions.aspaAssertions`
空策略示例:
```json
{
"slurmVersion": 2,
"validationOutputFilters": {
"prefixFilters": [],
"bgpsecFilters": [],
"aspaFilters": []
},
"locallyAddedAssertions": {
"prefixAssertions": [],
"bgpsecAssertions": [],
"aspaAssertions": []
}
}
```
## 10.3 字段规范(RFC 8416)
### 10.3.1 `prefixFilters`
数组元素每项:
- 必须至少包含一个:`prefix` 或 `asn`
- 可选:`comment`
匹配规则:
- 若配置了 `prefix`:匹配“被该前缀覆盖(encompassed)”的 VRP 前缀
- 若配置了 `asn`:匹配该 ASN
- 同时配置时:两者都要匹配
### 10.3.2 `bgpsecFilters`
数组元素每项:
- 必须至少包含一个:`asn` 或 `SKI`
- 可选:`comment`
匹配规则:
- 按 `asn`/`SKI` 单独或联合匹配 Router Key(BGPsec)
### 10.3.3 `prefixAssertions`
数组元素每项:
- 必须:`prefix`、`asn`
- 可选:`maxPrefixLength`、`comment`
约束:
- 若给出 `maxPrefixLength`,应满足 `prefix 长度 <= maxPrefixLength <= 地址位宽(IPv4=32, IPv6=128)`
### 10.3.4 `bgpsecAssertions`
数组元素每项:
- 必须:`asn`、`SKI`、`routerPublicKey`
- 可选:`comment`
## 10.4 ASPA 扩展(draft-ietf-sidrops-aspa-slurm-04)
### 10.4.1 `aspaFilters`
数组元素每项:
- 必须:`customerAsn`
- 可选:`comment`
匹配规则:
- 当 VAP(Validated ASPA Payload)的 `customerAsn` 等于过滤器 `customerAsn` 时命中并移除。
### 10.4.2 `aspaAssertions`
数组元素每项:
- 必须:`customerAsn`
- 必须:`providerAsns`(ASN 数组)
- 可选:`comment`
关键约束(draft-04):
- `customerAsn` 不得出现在 `providerAsns` 中
- `providerAsns` 必须按升序排列
- `providerAsns` 里的 ASN 必须唯一(无重复)
语义补充(draft-04):
- `aspaAssertions` 仅用于“新增断言”,不构成隐式过滤(不会自动替代 `aspaFilters`)。
- 在 RTRv2 输出阶段,新增的 ASPA 断言应加入 ASPA PDU 集合,并做去重。
## 10.5 应用语义(RFC 8416 Section 4)
### 10.5.1 原子性
SLURM 应用必须是原子的:
- 要么完全不生效(等同未使用 SLURM)
- 要么完整按当前 SLURM 配置生效
### 10.5.2 处理顺序
在同一次计算中:
1. 先执行 `validationOutputFilters`(移除匹配验证结果)
2. 再追加 `locallyAddedAssertions`
### 10.5.3 多文件
实现可以支持多个 SLURM 文件并行使用(取并集),但在启用前应检查断言重叠冲突;若存在冲突,整组文件应被拒绝。
## 10.6 最小可用示例(SLURM v2)
```json
{
"slurmVersion": 2,
"validationOutputFilters": {
"prefixFilters": [
{
"prefix": "203.0.113.0/24",
"comment": "Filter a broken VRP from upstream"
}
],
"bgpsecFilters": [],
"aspaFilters": [
{
"customerAsn": 64496,
"comment": "Filter one customer ASPA"
}
]
},
"locallyAddedAssertions": {
"prefixAssertions": [
{
"asn": 64496,
"prefix": "203.0.113.0/24",
"maxPrefixLength": 24,
"comment": "Local business exception"
}
],
"bgpsecAssertions": [],
"aspaAssertions": [
{
"customerAsn": 64496,
"providerAsns": [64497, 64498],
"comment": "Local ASPA assertion"
}
]
}
}
```
## 10.7 参考文献
- RFC 8416: https://www.rfc-editor.org/rfc/rfc8416.html
- draft-ietf-sidrops-aspa-slurm-04: https://www.ietf.org/archive/id/draft-ietf-sidrops-aspa-slurm-04.html

View File

@@ -1,158 +0,0 @@
# 11. RTR(RPKI to Router Protocol)
## 11.1 目标与文档范围
RTR 用于把 RP/Cache 已完成密码学验证的 RPKI 数据下发给路由器。
本文按以下规范整理:
- RFC 6810(RTR v0)
- RFC 8210(RTR v1,更新 RFC 6810)
- draft-ietf-sidrops-8210bis-25(RTR v2,草案)
## 11.2 协议演进
### 11.2.1 RFC 6810(v0)
- 只定义 Prefix Origin 相关 payload(IPv4/IPv6 Prefix PDU)。
- 主要 PDU:Serial Notify / Serial Query / Reset Query / Cache Response / Prefix / End of Data / Cache Reset / Error Report。
### 11.2.2 RFC 8210(v1)
在 v0 基础上新增/强化:
- 新增 `Router Key PDU`(PDU Type 9,v1 可用,v0 保留)。
- 强化协议版本协商与降级行为。
- `End of Data` 在 v1 中携带 `Refresh/Retry/Expire` 三个计时参数。
### 11.2.3 Version 2(草案)
在 v1 基础上新增/强化:
- 新增 `ASPA PDU`(PDU Type 11,仅 v2)。
- 新增 “Races, Ordering, and Transactions” 章节,要求缓存按规定顺序输出 payload 以降低路由器短暂误判。
- 协议版本提升到 `2`。
- 明确 PDU 最大长度上限为 64k(65535)。
## 11.3 PDU 与版本矩阵
PDU 类型(按规范注册表):
| PDU Type | 名称 | v0 (RFC6810) | v1 (RFC8210) | v2 (8210bis-25) |
|---|---|---|---|---|
| 0 | Serial Notify | 支持 | 支持 | 支持 |
| 1 | Serial Query | 支持 | 支持 | 支持 |
| 2 | Reset Query | 支持 | 支持 | 支持 |
| 3 | Cache Response | 支持 | 支持 | 支持 |
| 4 | IPv4 Prefix | 支持 | 支持 | 支持 |
| 6 | IPv6 Prefix | 支持 | 支持 | 支持 |
| 7 | End of Data | 支持 | 支持(含计时参数) | 支持 |
| 8 | Cache Reset | 支持 | 支持 | 支持 |
| 9 | Router Key | 保留 | 支持 | 支持 |
| 10 | Error Report | 支持 | 支持 | 支持 |
| 11 | ASPA | 保留 | 保留 | 支持 |
通用字段约束:
- `Protocol Version`:8-bit。
- `PDU Type`:8-bit。
- `Session ID`:16-bit。
- `Length`:32-bit。
- 保留位(zero/reserved)发送必须为 0,接收时按规范处理。
## 11.4 关键 PDU 语义
### 11.4.1 Serial Notify(Type 0)
- 由 Cache 主动发送,提示有新序列可拉取。
- 是少数可不由 Router 请求触发的消息。
### 11.4.2 Reset Query(Type 2)与 Cache Response(Type 3)
- Router 启动或失配时发 `Reset Query` 请求全量。
- Cache 回复 `Cache Response`,随后发送全量 payload,最后 `End of Data`。
### 11.4.3 Serial Query(Type 1)
- Router 持有上次 `Session ID + Serial` 时请求增量。
- Cache 若可提供增量:返回变化集。
- Cache 若无法从该 serial 补增量:返回 `Cache Reset`,要求 Router 走全量。
### 11.4.4 Prefix / Router Key / ASPA payload
- `IPv4 Prefix`(Type 4)/ `IPv6 Prefix`(Type 6):表示 VRP 的 announce/withdraw。
- `Router Key`(Type 9,v1+):表示 BGPsec Router Key 的 announce/withdraw。
- `ASPA`(Type 11,v2 草案):表示 ASPA 数据单元的 announce/withdraw。
语义要点(v1 / v2 草案):
- 对同一 payload 键(如 Prefix 四元组、Router Key 三元组、ASPA customer 键)应维护清晰的替换/撤销关系。
- Cache 负责把历史变化“合并简化”后再发给 Router,避免无意义抖动。
### 11.4.5 End of Data(Type 7)
- 标识一次响应结束,并给出当前 serial。
- v0:不含定时器字段。
- v1/v2:携带 `Refresh Interval`、`Retry Interval`、`Expire Interval`。
## 11.5 协议时序
### 11.5.1 初始同步(Full Sync)
1. Router 建连后发 `Reset Query`(带支持的协议版本)。
2. Cache 回 `Cache Response`。
3. Cache 按规范发送 payload 集合。
4. Cache 发 `End of Data` 收尾。
### 11.5.2 增量同步(Incremental Sync)
1. Router 发 `Serial Query(session_id, serial)`。
2. Cache 若可增量,返回变化并以 `End of Data` 收尾。
3. 若不可增量,返回 `Cache Reset`;Router 退回 Full Sync。
## 11.6 版本协商与降级
- Router 每次新连接必须由 `Reset Query` 或 `Serial Query` 启动,携带其协议版本。
- 双方在协商完成后,本连接内版本固定。
- 遇到不支持版本时,可按规范降级(例如 v1 对 v0、v2 对 v1/v0)或返回 `Unsupported Protocol Version` 后断开。
- 协商期若收到 `Serial Notify`,Router 应按规范兼容处理(通常忽略,待协商完成)。
## 11.7 计时器与失效(v1/v2)
`End of Data` 下发三个参数:
- `Refresh Interval`:多久后主动刷新。
- `Retry Interval`:失败后重试间隔。
- `Expire Interval`:本地数据最长可保留时长。
规范边界(RFC 8210):
- Refresh: 1 .. 86400(推荐 3600)
- Retry: 1 .. 7200(推荐 600)
- Expire: 600 .. 172800(推荐 7200)
- 且 `Expire` 必须大于 `Refresh` 和 `Retry`。
## 11.8 Version 2(草案)新增关注点
### 11.8.1 ASPA PDU
- 新增 ASPA 传输能力(Type 11)。
- 针对同一 customer ASN,Cache 需向 Router 提供一致且可替换的 ASPA 视图。
### 11.8.2 排序与事务
- 草案新增 race 条件说明(如前缀替换、撤销先后导致短暂误判)。
- 对 Cache 输出 payload 的顺序提出约束。
- 建议 Router 使用“事务式应用”(例如接收到完整响应后再切换生效)降低中间态影响。
## 11.9 传输与安全
规范定义可承载于多种传输:
- SSH
- TLS
- TCP MD5
- TCP-AO
安全原则:
- Router 与 Cache 之间必须建立可信关系。
- 需要完整性/机密性时优先使用具备认证与加密能力的传输。
- 若使用普通 TCP,部署上应限制在可信受控网络中。
## 11.10 参考文献
- RFC 6810: https://www.rfc-editor.org/rfc/rfc6810.html
- RFC 8210: https://www.rfc-editor.org/rfc/rfc8210.html
- draft-ietf-sidrops-8210bis-25: https://www.ietf.org/archive/id/draft-ietf-sidrops-8210bis-25.html

5532
specs/arch.excalidraw Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

1
src/analysis/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod timing;

308
src/analysis/timing.rs Normal file
View File

@@ -0,0 +1,308 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct TimingHandle {
inner: Arc<Mutex<TimingCollector>>,
}
impl TimingHandle {
pub fn new(meta: TimingMeta) -> Self {
Self {
inner: Arc::new(Mutex::new(TimingCollector::new(meta))),
}
}
pub fn span_phase(&self, phase: &'static str) -> TimingSpanGuard<'_> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::Phase(phase),
start: Instant::now(),
}
}
pub fn span_rrdp_repo<'a>(&self, repo_uri: &'a str) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::RrdpRepo(repo_uri),
start: Instant::now(),
}
}
pub fn span_rrdp_repo_step<'a>(
&self,
repo_uri: &'a str,
step: &'static str,
) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::RrdpRepoStep { repo_uri, step },
start: Instant::now(),
}
}
pub fn span_publication_point<'a>(&self, manifest_rsync_uri: &'a str) -> TimingSpanGuard<'a> {
TimingSpanGuard {
handle: self.clone(),
kind: TimingSpanKind::PublicationPoint(manifest_rsync_uri),
start: Instant::now(),
}
}
pub fn set_meta(&self, update: TimingMetaUpdate<'_>) {
let mut g = self.inner.lock().expect("timing lock");
if let Some(v) = update.tal_url {
g.meta.tal_url = Some(v.to_string());
}
if let Some(v) = update.db_path {
g.meta.db_path = Some(v.to_string());
}
}
pub fn record_count(&self, key: &'static str, inc: u64) {
let mut g = self.inner.lock().expect("timing lock");
g.counts
.entry(key)
.and_modify(|v| *v = v.saturating_add(inc))
.or_insert(inc);
}
/// Record a phase duration directly in nanoseconds.
///
/// This is useful when aggregating sub-phase timings locally (to reduce lock contention)
/// and then emitting a single record per publication point.
pub fn record_phase_nanos(&self, phase: &'static str, nanos: u64) {
let mut g = self.inner.lock().expect("timing lock");
g.phases.record(phase, nanos);
}
pub fn write_json(&self, path: &Path, top_n: usize) -> Result<(), String> {
let report = {
let g = self.inner.lock().expect("timing lock");
g.to_report(top_n)
};
let f = std::fs::File::create(path)
.map_err(|e| format!("create timing json failed: {}: {e}", path.display()))?;
serde_json::to_writer_pretty(f, &report)
.map_err(|e| format!("write timing json failed: {e}"))?;
Ok(())
}
fn record_duration(&self, kind: TimingSpanKind<'_>, duration: Duration) {
let nanos_u64 = duration.as_nanos().min(u128::from(u64::MAX)) as u64;
let mut g = self.inner.lock().expect("timing lock");
match kind {
TimingSpanKind::Phase(name) => g.phases.record(name, nanos_u64),
TimingSpanKind::RrdpRepo(uri) => g.rrdp_repos.record(uri, nanos_u64),
TimingSpanKind::RrdpRepoStep { repo_uri, step } => g
.rrdp_repo_steps
.record(&format!("{repo_uri}::{step}"), nanos_u64),
TimingSpanKind::PublicationPoint(uri) => g.publication_points.record(uri, nanos_u64),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimingMeta {
pub recorded_at_utc_rfc3339: String,
pub validation_time_utc_rfc3339: String,
pub tal_url: Option<String>,
pub db_path: Option<String>,
}
#[derive(Clone, Debug, Default)]
pub struct TimingMetaUpdate<'a> {
pub tal_url: Option<&'a str>,
pub db_path: Option<&'a str>,
}
pub struct TimingSpanGuard<'a> {
handle: TimingHandle,
kind: TimingSpanKind<'a>,
start: Instant,
}
impl Drop for TimingSpanGuard<'_> {
fn drop(&mut self) {
self.handle
.record_duration(self.kind.clone(), self.start.elapsed());
}
}
#[derive(Clone, Debug)]
enum TimingSpanKind<'a> {
Phase(&'static str),
RrdpRepo(&'a str),
RrdpRepoStep {
repo_uri: &'a str,
step: &'static str,
},
PublicationPoint(&'a str),
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DurationStats {
pub count: u64,
pub total_nanos: u64,
}
impl DurationStats {
fn record(&mut self, nanos: u64) {
self.count = self.count.saturating_add(1);
self.total_nanos = self.total_nanos.saturating_add(nanos);
}
}
#[derive(Clone, Debug, Default)]
struct DurationStatsMap {
map: HashMap<String, DurationStats>,
}
impl DurationStatsMap {
fn record(&mut self, key: &str, nanos: u64) {
self.map.entry(key.to_string()).or_default().record(nanos);
}
fn top(&self, n: usize) -> Vec<TopDurationEntry> {
let mut v = self
.map
.iter()
.map(|(k, s)| TopDurationEntry {
key: k.clone(),
count: s.count,
total_nanos: s.total_nanos,
})
.collect::<Vec<_>>();
v.sort_by(|a, b| b.total_nanos.cmp(&a.total_nanos));
v.truncate(n);
v
}
}
struct TimingCollector {
meta: TimingMeta,
counts: HashMap<&'static str, u64>,
phases: DurationStatsMap,
rrdp_repos: DurationStatsMap,
rrdp_repo_steps: DurationStatsMap,
publication_points: DurationStatsMap,
}
impl TimingCollector {
fn new(meta: TimingMeta) -> Self {
Self {
meta,
counts: HashMap::new(),
phases: DurationStatsMap::default(),
rrdp_repos: DurationStatsMap::default(),
rrdp_repo_steps: DurationStatsMap::default(),
publication_points: DurationStatsMap::default(),
}
}
fn to_report(&self, top_n: usize) -> TimingReportV1 {
TimingReportV1 {
format_version: 1,
meta: self.meta.clone(),
counts: self
.counts
.iter()
.map(|(k, v)| ((*k).to_string(), *v))
.collect(),
phases: self
.phases
.map
.iter()
.map(|(k, s)| (k.clone(), s.clone()))
.collect(),
top_rrdp_repos: self.rrdp_repos.top(top_n),
top_rrdp_repo_steps: self.rrdp_repo_steps.top(top_n),
top_publication_points: self.publication_points.top(top_n),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TimingReportV1 {
pub format_version: u64,
pub meta: TimingMeta,
pub counts: HashMap<String, u64>,
pub phases: HashMap<String, DurationStats>,
pub top_rrdp_repos: Vec<TopDurationEntry>,
pub top_rrdp_repo_steps: Vec<TopDurationEntry>,
pub top_publication_points: Vec<TopDurationEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TopDurationEntry {
pub key: String,
pub count: u64,
pub total_nanos: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timing_handle_writes_json_with_phases_and_tops() {
let meta = TimingMeta {
recorded_at_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
validation_time_utc_rfc3339: "2026-02-28T00:00:00Z".to_string(),
tal_url: Some("https://example.test/x.tal".to_string()),
db_path: Some("db".to_string()),
};
let h = TimingHandle::new(meta);
{
let _p = h.span_phase("tal_bootstrap");
}
{
let _r = h.span_rrdp_repo("https://rrdp.example.test/notification.xml");
}
{
let _s = h.span_rrdp_repo_step(
"https://rrdp.example.test/notification.xml",
"fetch_notification",
);
}
{
let _pp = h.span_publication_point("rsync://example.test/repo/manifest.mft");
}
h.record_count("vrps", 42);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("timing.json");
h.write_json(&path, 10).expect("write_json");
let rep: TimingReportV1 =
serde_json::from_slice(&std::fs::read(&path).expect("read timing.json"))
.expect("parse timing.json");
assert_eq!(rep.format_version, 1);
assert!(rep.phases.contains_key("tal_bootstrap"));
assert_eq!(rep.counts.get("vrps").copied(), Some(42));
assert!(
rep.top_rrdp_repos
.iter()
.any(|e| e.key.contains("rrdp.example.test")),
"expected repo in top list"
);
assert!(
rep.top_rrdp_repo_steps
.iter()
.any(|e| e.key.contains("fetch_notification")),
"expected repo step in top list"
);
assert!(
rep.top_publication_points
.iter()
.any(|e| e.key.contains("manifest.mft")),
"expected PP in top list"
);
}
}

224
src/audit.rs Normal file
View File

@@ -0,0 +1,224 @@
use serde::Serialize;
use sha2::Digest;
use crate::policy::Policy;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectKind {
Manifest,
Crl,
Certificate,
Roa,
Aspa,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectResult {
Ok,
Skipped,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ObjectAuditEntry {
pub rsync_uri: String,
pub sha256_hex: String,
pub kind: AuditObjectKind,
pub result: AuditObjectResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditWarning {
pub message: String,
pub rfc_refs: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
impl From<&crate::report::Warning> for AuditWarning {
fn from(w: &crate::report::Warning) -> Self {
Self {
message: w.message.clone(),
rfc_refs: w.rfc_refs.iter().map(|r| r.0.to_string()).collect(),
context: w.context.clone(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct PublicationPointAudit {
/// Monotonic node ID assigned by the traversal engine.
///
/// Present when running via the Stage2 tree engine; may be absent in ad-hoc runs.
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<u64>,
/// Parent node ID in the traversal tree.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_node_id: Option<u64>,
/// Provenance metadata for non-root nodes (how this CA instance was discovered).
#[serde(skip_serializing_if = "Option::is_none")]
pub discovered_from: Option<DiscoveredFrom>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rrdp_notification_uri: Option<String>,
pub source: String,
pub this_update_rfc3339_utc: String,
pub next_update_rfc3339_utc: String,
pub verified_at_rfc3339_utc: String,
pub warnings: Vec<AuditWarning>,
pub objects: Vec<ObjectAuditEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DiscoveredFrom {
pub parent_manifest_rsync_uri: String,
pub child_ca_certificate_rsync_uri: String,
pub child_ca_certificate_sha256_hex: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TreeSummary {
pub instances_processed: usize,
pub instances_failed: usize,
pub warnings: Vec<AuditWarning>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRunMeta {
pub validation_time_rfc3339_utc: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditDownloadKind {
RrdpNotification,
RrdpSnapshot,
RrdpDelta,
Rsync,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadObjectsStat {
pub objects_count: u64,
pub objects_bytes_total: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditDownloadEvent {
pub kind: AuditDownloadKind,
pub uri: String,
pub started_at_rfc3339_utc: String,
pub finished_at_rfc3339_utc: String,
pub duration_ms: u64,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects: Option<AuditDownloadObjectsStat>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadKindStats {
pub ok_total: u64,
pub fail_total: u64,
pub duration_ms_total: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects_count_total: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub objects_bytes_total: Option<u64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AuditDownloadStats {
pub events_total: u64,
/// Statistics keyed by serialized `AuditDownloadKind` string (e.g. "rrdp_snapshot").
pub by_kind: std::collections::BTreeMap<String, AuditDownloadKindStats>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV1 {
pub format_version: u32,
pub meta: AuditRunMeta,
pub policy: Policy,
pub tree: TreeSummary,
pub publication_points: Vec<PublicationPointAudit>,
pub vrps: Vec<VrpOutput>,
pub aspas: Vec<AspaOutput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV2 {
pub format_version: u32,
pub meta: AuditRunMeta,
pub policy: Policy,
pub tree: TreeSummary,
pub publication_points: Vec<PublicationPointAudit>,
pub vrps: Vec<VrpOutput>,
pub aspas: Vec<AspaOutput>,
pub downloads: Vec<AuditDownloadEvent>,
pub download_stats: AuditDownloadStats,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct VrpOutput {
pub asn: u32,
pub prefix: String,
pub max_length: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AspaOutput {
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
pub fn sha256_hex_from_32(bytes: &[u8; 32]) -> String {
hex::encode(bytes)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = sha2::Sha256::digest(bytes);
hex::encode(digest)
}
pub fn format_roa_ip_prefix(p: &crate::data_model::roa::IpPrefix) -> String {
let addr = p.addr_bytes();
match p.afi {
crate::data_model::roa::RoaAfi::Ipv4 => {
format!(
"{}.{}.{}.{}{}",
addr[0],
addr[1],
addr[2],
addr[3],
format!("/{}", p.prefix_len)
)
}
crate::data_model::roa::RoaAfi::Ipv6 => {
let mut parts = Vec::with_capacity(8);
for i in 0..8 {
let hi = addr[i * 2] as u16;
let lo = addr[i * 2 + 1] as u16;
parts.push(format!("{:x}", (hi << 8) | lo));
}
format!("{}{}", parts.join(":"), format!("/{}", p.prefix_len))
}
}
}

168
src/audit_downloads.rs Normal file
View File

@@ -0,0 +1,168 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::audit::{
AuditDownloadEvent, AuditDownloadKind, AuditDownloadKindStats, AuditDownloadObjectsStat,
AuditDownloadStats,
};
#[derive(Clone, Debug, Default)]
pub struct DownloadLogHandle {
inner: Arc<Mutex<Vec<AuditDownloadEvent>>>,
}
impl DownloadLogHandle {
pub fn new() -> Self {
Self::default()
}
pub fn record_event(&self, event: AuditDownloadEvent) {
self.inner.lock().expect("download log lock").push(event);
}
pub fn snapshot_events(&self) -> Vec<AuditDownloadEvent> {
self.inner.lock().expect("download log lock").clone()
}
pub fn stats_from_events(events: &[AuditDownloadEvent]) -> AuditDownloadStats {
let mut out = AuditDownloadStats {
events_total: events.len() as u64,
by_kind: BTreeMap::new(),
};
for e in events {
let kind_key = match e.kind {
AuditDownloadKind::RrdpNotification => "rrdp_notification",
AuditDownloadKind::RrdpSnapshot => "rrdp_snapshot",
AuditDownloadKind::RrdpDelta => "rrdp_delta",
AuditDownloadKind::Rsync => "rsync",
}
.to_string();
let st = out.by_kind.entry(kind_key).or_insert_with(|| AuditDownloadKindStats {
ok_total: 0,
fail_total: 0,
duration_ms_total: 0,
bytes_total: None,
objects_count_total: None,
objects_bytes_total: None,
});
if e.success {
st.ok_total = st.ok_total.saturating_add(1);
} else {
st.fail_total = st.fail_total.saturating_add(1);
}
st.duration_ms_total = st.duration_ms_total.saturating_add(e.duration_ms);
if let Some(b) = e.bytes {
st.bytes_total = Some(st.bytes_total.unwrap_or(0).saturating_add(b));
}
if let Some(objects) = &e.objects {
st.objects_count_total = Some(
st.objects_count_total
.unwrap_or(0)
.saturating_add(objects.objects_count),
);
st.objects_bytes_total = Some(
st.objects_bytes_total
.unwrap_or(0)
.saturating_add(objects.objects_bytes_total),
);
}
}
out
}
pub fn stats(&self) -> AuditDownloadStats {
let events = self.snapshot_events();
Self::stats_from_events(&events)
}
pub fn span_download<'a>(
&'a self,
kind: AuditDownloadKind,
uri: &'a str,
) -> DownloadSpanGuard<'a> {
DownloadSpanGuard {
handle: self,
kind,
uri,
start_instant: Instant::now(),
started_at: time::OffsetDateTime::now_utc(),
bytes: None,
objects: None,
error: None,
success: None,
}
}
}
pub struct DownloadSpanGuard<'a> {
handle: &'a DownloadLogHandle,
kind: AuditDownloadKind,
uri: &'a str,
start_instant: Instant,
started_at: time::OffsetDateTime,
bytes: Option<u64>,
objects: Option<AuditDownloadObjectsStat>,
error: Option<String>,
success: Option<bool>,
}
impl DownloadSpanGuard<'_> {
pub fn set_bytes(&mut self, bytes: u64) {
self.bytes = Some(bytes);
}
pub fn set_objects(&mut self, objects_count: u64, objects_bytes_total: u64) {
self.objects = Some(AuditDownloadObjectsStat {
objects_count,
objects_bytes_total,
});
}
pub fn set_ok(&mut self) {
self.success = Some(true);
}
pub fn set_err(&mut self, msg: impl Into<String>) {
self.success = Some(false);
self.error = Some(msg.into());
}
}
impl Drop for DownloadSpanGuard<'_> {
fn drop(&mut self) {
use time::format_description::well_known::Rfc3339;
let finished_at = time::OffsetDateTime::now_utc();
let dur = self.start_instant.elapsed();
let duration_ms = duration_to_ms(dur);
let started_at_rfc3339_utc = self
.started_at
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.unwrap_or_else(|_| "<format-error>".to_string());
let finished_at_rfc3339_utc = finished_at
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.unwrap_or_else(|_| "<format-error>".to_string());
let success = self.success.unwrap_or(false);
let event = AuditDownloadEvent {
kind: self.kind.clone(),
uri: self.uri.to_string(),
started_at_rfc3339_utc,
finished_at_rfc3339_utc,
duration_ms,
success,
error: if success { None } else { self.error.clone() },
bytes: self.bytes,
objects: self.objects.clone(),
};
self.handle.record_event(event);
}
}
fn duration_to_ms(d: Duration) -> u64 {
let ms = d.as_millis();
ms.min(u128::from(u64::MAX)) as u64
}

View File

@@ -1,290 +0,0 @@
use std::fs;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
const CONTENT_TYPE_OID: &str = "1.2.840.113549.1.9.16.1.54";
const SHA256_OID: &str = "2.16.840.1.101.3.4.2.1";
#[derive(Clone)]
struct Vrp {
addr: IpAddr,
prefix_len: u8,
max_len: u8,
asn: u32,
}
#[derive(Clone)]
struct Vap {
customer_asn: u32,
providers: Vec<u32>,
}
fn main() -> Result<()> {
let out_dir = parse_out_dir_arg();
fs::create_dir_all(&out_dir)
.with_context(|| format!("failed to create output dir {}", out_dir.display()))?;
write_snapshot(
&out_dir.join("20260403T000001Z-mini-a.ccr"),
"20260403000001Z",
vec![
Vrp {
addr: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0)),
prefix_len: 24,
max_len: 24,
asn: 65001,
},
Vrp {
addr: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 0, 0, 0, 0, 0)),
prefix_len: 48,
max_len: 48,
asn: 65002,
},
],
vec![Vap {
customer_asn: 65010,
providers: vec![65011, 65012],
}],
)?;
write_snapshot(
&out_dir.join("20260403T000101Z-mini-b.ccr"),
"20260403000101Z",
vec![
Vrp {
addr: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0)),
prefix_len: 24,
max_len: 24,
asn: 65001,
},
Vrp {
addr: IpAddr::V4(Ipv4Addr::new(10, 0, 1, 0)),
prefix_len: 24,
max_len: 24,
asn: 65003,
},
Vrp {
addr: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 0, 0, 0, 0, 0)),
prefix_len: 48,
max_len: 48,
asn: 65002,
},
],
vec![Vap {
customer_asn: 65010,
providers: vec![65011, 65012, 65013],
}],
)?;
write_snapshot(
&out_dir.join("20260403T000201Z-mini-c.ccr"),
"20260403000201Z",
vec![
Vrp {
addr: IpAddr::V4(Ipv4Addr::new(10, 0, 1, 0)),
prefix_len: 24,
max_len: 24,
asn: 65003,
},
Vrp {
addr: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 2, 0, 0, 0, 0, 0)),
prefix_len: 48,
max_len: 48,
asn: 65004,
},
],
vec![
Vap {
customer_asn: 65010,
providers: vec![65012, 65013],
},
Vap {
customer_asn: 65020,
providers: vec![65021],
},
],
)?;
println!("generated CCR fixtures under {}", out_dir.display());
Ok(())
}
fn parse_out_dir_arg() -> PathBuf {
let mut args = std::env::args().skip(1);
let mut out_dir = PathBuf::from("data");
while let Some(arg) = args.next() {
if arg == "--out-dir" {
if let Some(v) = args.next() {
out_dir = PathBuf::from(v);
}
}
}
out_dir
}
fn write_snapshot(path: &Path, produced_at: &str, vrps: Vec<Vrp>, vaps: Vec<Vap>) -> Result<()> {
let bytes = encode_ccr_snapshot(produced_at, vrps, vaps);
fs::write(path, bytes).with_context(|| format!("failed to write {}", path.display()))?;
println!("wrote {}", path.display());
Ok(())
}
fn encode_ccr_snapshot(produced_at: &str, vrps: Vec<Vrp>, vaps: Vec<Vap>) -> Vec<u8> {
let vrp_sets = vrps
.into_iter()
.map(encode_roa_payload_set)
.collect::<Vec<_>>();
let vap_sets = vaps
.into_iter()
.map(encode_aspa_payload_set)
.collect::<Vec<_>>();
let vrp_set_seq = der_sequence(vrp_sets);
let vap_set_seq = der_sequence(vap_sets);
let vrp_hash = Sha256::digest(&vrp_set_seq).to_vec();
let vap_hash = Sha256::digest(&vap_set_seq).to_vec();
// draft-ietf-sidrops-rpki-ccr-02:
// ROAPayloadState/ASPAPayloadState include payload-set sequence + hash.
let vrp_state = der_sequence(vec![vrp_set_seq, der_octet_string(vrp_hash)]);
let vap_state = der_sequence(vec![vap_set_seq, der_octet_string(vap_hash)]);
// AlgorithmIdentifier for SHA-256.
let hash_alg = der_sequence(vec![der_oid(SHA256_OID), der_null()]);
let payload = der_sequence(vec![
der_integer(0),
hash_alg,
der_generalized_time(produced_at),
der_ctx(2, vrp_state),
der_ctx(3, vap_state),
]);
der_sequence(vec![der_oid(CONTENT_TYPE_OID), der_ctx(0, payload)])
}
fn encode_roa_payload_set(v: Vrp) -> Vec<u8> {
let (afi, addr_bytes) = match v.addr {
IpAddr::V4(ip) => ([0u8, 1u8].to_vec(), ip.octets().to_vec()),
IpAddr::V6(ip) => ([0u8, 2u8].to_vec(), ip.octets().to_vec()),
};
let bit_string = prefix_to_bit_string(&addr_bytes, v.prefix_len);
let roa_ip = der_sequence(vec![
der_bit_string(0, bit_string),
der_integer(u32::from(v.max_len)),
]);
let family = der_sequence(vec![der_octet_string(afi), der_sequence(vec![roa_ip])]);
der_sequence(vec![der_integer(v.asn), der_sequence(vec![family])])
}
fn encode_aspa_payload_set(v: Vap) -> Vec<u8> {
let providers = v.providers.into_iter().map(der_integer).collect::<Vec<_>>();
der_sequence(vec![der_integer(v.customer_asn), der_sequence(providers)])
}
fn prefix_to_bit_string(addr: &[u8], prefix_len: u8) -> Vec<u8> {
let byte_len = usize::from(prefix_len).div_ceil(8);
let mut out = addr[..byte_len].to_vec();
let rem = prefix_len % 8;
if rem != 0 {
let mask = 0xFFu8 << (8 - rem);
let last = out.len() - 1;
out[last] &= mask;
}
out
}
fn der_sequence(items: Vec<Vec<u8>>) -> Vec<u8> {
let content = items.concat();
der_tlv(0x30, content)
}
fn der_integer(v: u32) -> Vec<u8> {
if v == 0 {
return der_tlv(0x02, vec![0]);
}
let mut bytes = v.to_be_bytes().to_vec();
while bytes.len() > 1 && bytes[0] == 0 {
bytes.remove(0);
}
if bytes[0] & 0x80 != 0 {
bytes.insert(0, 0);
}
der_tlv(0x02, bytes)
}
fn der_oid(oid: &str) -> Vec<u8> {
let parts = oid
.split('.')
.map(|s| s.parse::<u32>().unwrap())
.collect::<Vec<_>>();
assert!(parts.len() >= 2);
let mut out = Vec::new();
out.push((parts[0] * 40 + parts[1]) as u8);
for &part in &parts[2..] {
out.extend(base128(part));
}
der_tlv(0x06, out)
}
fn base128(mut n: u32) -> Vec<u8> {
let mut buf = vec![(n & 0x7F) as u8];
n >>= 7;
while n > 0 {
buf.push(((n & 0x7F) as u8) | 0x80);
n >>= 7;
}
buf.reverse();
buf
}
fn der_octet_string(bytes: Vec<u8>) -> Vec<u8> {
der_tlv(0x04, bytes)
}
fn der_null() -> Vec<u8> {
der_tlv(0x05, Vec::new())
}
fn der_bit_string(unused_bits: u8, bytes: Vec<u8>) -> Vec<u8> {
let mut content = Vec::with_capacity(1 + bytes.len());
content.push(unused_bits);
content.extend(bytes);
der_tlv(0x03, content)
}
fn der_generalized_time(v: &str) -> Vec<u8> {
der_tlv(0x18, v.as_bytes().to_vec())
}
fn der_ctx(tag_no: u8, encoded_inner_der: Vec<u8>) -> Vec<u8> {
der_tlv(0xA0 + tag_no, encoded_inner_der)
}
fn der_tlv(tag: u8, content: Vec<u8>) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + content.len());
out.push(tag);
out.extend(der_len(content.len()));
out.extend(content);
out
}
fn der_len(len: usize) -> Vec<u8> {
if len < 128 {
return vec![len as u8];
}
let mut bytes = Vec::new();
let mut n = len;
while n > 0 {
bytes.push((n & 0xFF) as u8);
n >>= 8;
}
bytes.reverse();
let mut out = vec![0x80 | (bytes.len() as u8)];
out.extend(bytes);
out
}

125
src/bin/db_stats.rs Normal file
View File

@@ -0,0 +1,125 @@
use std::path::PathBuf;
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options};
const CF_RAW_OBJECTS: &str = "raw_objects";
const CF_FETCH_CACHE_PP: &str = "fetch_cache_pp";
const CF_RRDP_STATE: &str = "rrdp_state";
const CF_RRDP_OBJECT_INDEX: &str = "rrdp_object_index";
fn enable_blobdb_if_supported(opts: &mut Options) {
// Keep this in sync with `rpki::storage`:
// blob files are CF-level options; readers should open CFs with blob enabled too.
#[allow(dead_code)]
fn _set(opts: &mut Options) {
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
}
_set(opts);
}
fn usage() -> String {
let bin = "db_stats";
format!(
"\
Usage:
{bin} --db <path> [--exact]
Options:
--db <path> RocksDB directory
--exact Iterate to count keys (slower; default uses RocksDB estimates)
--help Show this help
"
)
}
fn cf_descriptors() -> Vec<ColumnFamilyDescriptor> {
let mut cf_opts = Options::default();
enable_blobdb_if_supported(&mut cf_opts);
vec![
ColumnFamilyDescriptor::new(CF_RAW_OBJECTS, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_FETCH_CACHE_PP, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_RRDP_STATE, cf_opts.clone()),
ColumnFamilyDescriptor::new(CF_RRDP_OBJECT_INDEX, cf_opts),
]
}
fn estimate_keys(db: &DB, cf_name: &str) -> Result<Option<u64>, Box<dyn std::error::Error>> {
let cf = db
.cf_handle(cf_name)
.ok_or_else(|| format!("missing column family: {cf_name}"))?;
Ok(db.property_int_value_cf(cf, "rocksdb.estimate-num-keys")?)
}
fn exact_keys(db: &DB, cf_name: &str) -> Result<u64, Box<dyn std::error::Error>> {
let cf = db
.cf_handle(cf_name)
.ok_or_else(|| format!("missing column family: {cf_name}"))?;
let mode = IteratorMode::Start;
let mut count = 0u64;
for res in db.iterator_cf(cf, mode) {
res?;
count += 1;
}
Ok(count)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().collect();
if argv.iter().any(|a| a == "--help" || a == "-h") {
print!("{}", usage());
return Ok(());
}
let mut db_path: Option<PathBuf> = None;
let mut exact = false;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
"--exact" => exact = true,
other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()),
}
i += 1;
}
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
let mut opts = Options::default();
opts.create_if_missing(false);
opts.create_missing_column_families(false);
let db = DB::open_cf_descriptors(&opts, &db_path, cf_descriptors())?;
let cf_names = [
CF_RAW_OBJECTS,
CF_FETCH_CACHE_PP,
CF_RRDP_STATE,
CF_RRDP_OBJECT_INDEX,
];
println!("db={}", db_path.display());
println!("mode={}", if exact { "exact" } else { "estimate" });
let mut total: u64 = 0;
for name in cf_names {
let n = if exact {
exact_keys(&db, name)?
} else {
estimate_keys(&db, name)?.unwrap_or(0)
};
total = total.saturating_add(n);
println!("{name}={n}");
}
println!("total={total}");
// Also print # of SST files (useful sanity signal).
let live = db.live_files()?;
println!("sst_files={}", live.len());
Ok(())
}

12
src/bin/rpki.rs Normal file
View File

@@ -0,0 +1,12 @@
fn main() {
let argv: Vec<String> = std::env::args().collect();
if let Err(e) = rpki::cli::run(&argv) {
// `parse_args` uses `Err(usage())` for `--help`, so treat it as success.
if argv.iter().any(|a| a == "--help" || a == "-h") {
println!("{e}");
return;
}
eprintln!("{e}");
std::process::exit(2);
}
}

View File

@@ -0,0 +1,85 @@
use std::path::PathBuf;
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options};
fn enable_blobdb_if_supported(opts: &mut Options) {
// Keep this in sync with `rpki::storage`:
// blob files are CF-level options; readers should open CFs with blob enabled too.
#[allow(dead_code)]
fn _set(opts: &mut Options) {
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
}
_set(opts);
}
fn usage() -> String {
let bin = "rrdp_state_dump";
format!(
"\
Usage:
{bin} --db <path>
Options:
--db <path> RocksDB directory
--help Show this help
"
)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let argv: Vec<String> = std::env::args().collect();
if argv.iter().any(|a| a == "--help" || a == "-h") {
print!("{}", usage());
return Ok(());
}
let mut db_path: Option<PathBuf> = None;
let mut i = 1usize;
while i < argv.len() {
match argv[i].as_str() {
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
other => return Err(format!("unknown argument: {other}\n\n{}", usage()).into()),
}
i += 1;
}
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
let mut opts = Options::default();
opts.create_if_missing(false);
opts.create_missing_column_families(false);
// Open only the column families we need.
let mut cf_opts = Options::default();
enable_blobdb_if_supported(&mut cf_opts);
let cfs = vec![
ColumnFamilyDescriptor::new("raw_objects", cf_opts.clone()),
ColumnFamilyDescriptor::new("fetch_cache_pp", cf_opts.clone()),
ColumnFamilyDescriptor::new("rrdp_state", cf_opts.clone()),
ColumnFamilyDescriptor::new("rrdp_object_index", cf_opts),
];
let db = DB::open_cf_descriptors(&opts, &db_path, cfs)?;
let cf = db
.cf_handle("rrdp_state")
.ok_or("missing column family: rrdp_state")?;
let mut out: Vec<(String, u64, String)> = Vec::new();
for res in db.iterator_cf(cf, IteratorMode::Start) {
let (k, v) = res?;
let k = String::from_utf8_lossy(&k).to_string();
let st = rpki::sync::rrdp::RrdpState::decode(&v)
.map_err(|e| format!("decode rrdp_state failed for {k}: {e}"))?;
out.push((k, st.serial, st.session_id));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
for (k, serial, session) in out {
println!("{k}\t{serial}\t{session}");
}
Ok(())
}

View File

@@ -1,171 +0,0 @@
# rtr_debug_client
`rtr_debug_client` 是用于 RTR 协议联调的命令行调试客户端,支持 `TCP`、`TLS`、`SSH` 三种传输。
它用于:
- 手动发送 `Reset Query`、`Serial Query`
- 持续接收并打印服务端 PDU
- 观察 `session_id`、`serial`、`EndOfData` timing hint、`ErrorReport` 等状态变化
## 构建
```sh
cargo build --bin rtr_debug_client
```
## 基本用法
```sh
cargo run --bin rtr_debug_client -- <addr> <version> [reset|serial <session_id> <serial>] [options]
```
默认值:
- `addr`: `127.0.0.1:323`
- `version`: `1`
- `mode`: `reset`
- `--timeout`: `30`
- `--poll`: `600`
## TCP 用法
```sh
cargo run --bin rtr_debug_client -- 127.0.0.1:323 1 reset
```
```sh
cargo run --bin rtr_debug_client -- 127.0.0.1:323 1 serial 42 100
```
## TLS 用法
仅校验服务端证书:
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:324 1 reset \
--tls \
--ca-cert tests/fixtures/tls/client-ca.crt \
--server-name localhost
```
双向 TLS:
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:324 1 reset \
--tls \
--ca-cert tests/fixtures/tls/client-ca.crt \
--server-name localhost \
--client-cert tests/fixtures/tls/client-good.crt \
--client-key tests/fixtures/tls/client-good.key
```
## SSH 用法(按 draft-ietf-sidrops-8210bis-25)
`rtr_debug_client --ssh` 采用以下流程:
- SSHv2 连接
- `session` channel
- 请求 `subsystem`,默认 `rpki-rtr`
- 使用 `publickey` 认证
- 强制服务端 host key 校验(`known_hosts` 或 pinned server key 二选一)
### 1. 使用 known_hosts 校验服务端
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-key certs/rtr-client.key \
--ssh-known-hosts certs/known_hosts
```
### 2. 使用固定服务端公钥校验
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-key certs/rtr-client.key \
--ssh-server-key certs/ssh_host_ed25519_key.pub
```
### 3. 自定义 subsystem 名称
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-key certs/rtr-client.key \
--ssh-known-hosts certs/known_hosts \
--ssh-subsystem rpki-rtr
```
## 参数说明
通用参数:
- `--timeout <secs>`:读取 PDU 超时时间(秒)
- `--poll <secs>`:默认自动轮询间隔(秒)
- `--keep-after-error`:收到 `ErrorReport` 后不暂停自动轮询
- `--summary-only`:仅打印摘要,抑制 payload PDU 详细内容
TLS 参数:
- `--tls`
- `--ca-cert <path>`
- `--server-name <name>`
- `--client-cert <path>`
- `--client-key <path>`
SSH 参数:
- `--ssh`
- `--ssh-user <name>`
- `--ssh-key <path>`(OpenSSH 私钥)
- `--ssh-subsystem <name>`(默认 `rpki-rtr`)
- `--ssh-known-hosts <path>` 或 `--ssh-server-key <path>`(二选一,必须提供)
## SSH 连通性测试建议
如果你已经在 Docker 中启动了支持 SSH 的 RTR server,可按以下方式验证:
1. 先用 `ssh` 命令确认认证与 host key 配置正确(可连通)。
2. 再用 `rtr_debug_client --ssh` 发起连接并发送 `reset`。
3. 观察是否收到 `Cache Response` 和 `EndOfData`。
如果 `rtr_debug_client` 报 `failed to request SSH subsystem 'rpki-rtr'`,通常表示服务端未开启对应 subsystem 名称,或名称不一致。
## 运行时交互命令
客户端启动后可在标准输入中使用:
- `help`
- `state`
- `version` / `version <n>`
- `reset`
- `serial` / `serial <sid> <serial>`
- `timeout` / `timeout <secs>`
- `poll` / `poll <secs>` / `poll pause` / `poll resume`
- `keep-after-error`
- `output` / `output verbose` / `output summary`
- `quit`
## SSH Password Auth (Added)
`rtr_debug_client` now supports password auth in SSH mode.
- Use exactly one auth option in SSH mode:
- `--ssh-key <path>`
- `--ssh-password <value>`
- Host key verification is still required:
- `--ssh-known-hosts <path>` or `--ssh-server-key <path>`
Example:
```sh
cargo run --bin rtr_debug_client -- \
127.0.0.1:22 1 reset \
--ssh \
--ssh-user rpki-rtr \
--ssh-password 'your-password' \
--ssh-server-key certs/ssh_host_rsa_key.pub
```

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More