Compare commits

..

42 Commits

Author SHA1 Message Date
xiuting.xu
a4fd0f3128 调整version 2026-07-28 14:21:08 +08:00
xiuting.xu
859a56c434 增加cache restart和cache shutdown 2026-07-24 11:10:32 +08:00
xiuting.xu
5fb79a7c8d modify scripts 2026-07-17 09:51:53 +08:00
xiuting.xu
490b79f22c add scripts 2026-07-14 14:44:40 +08:00
xiuting.xu
e2bdcd3820 Server和client部署整理 2026-07-09 17:14:21 +08:00
xiuting.xu
b5cbc7fbee arm构建 2026-07-06 11:01:30 +08:00
xiuting.xu
cc3518897f 删除无用字段 2026-06-25 14:37:19 +08:00
xiuting.xu
b105b3b7d0 增加rtr一些必要的api接口 2026-06-23 17:04:00 +08:00
xiuting.xu
303bdffd97 remove unused data file 2026-06-18 09:53:03 +08:00
xiuting.xu
3c61fbf152 modify rtr server metrics data 2026-06-18 09:44:55 +08:00
xiuting.xu
b70bad4721 增加必要的数据记录 2026-06-16 14:05:26 +08:00
xiuting.xu
fa19fc8722 add Cargo.lock 2026-06-15 15:14:18 +08:00
xiuting.xu
13f29843db 删除无用代码 2026-05-27 13:44:31 +08:00
xiuting.xu
cdf9372929 增加环境变量 2026-05-18 11:42:33 +08:00
xiuting.xu
a11f2bc864 增加观测日志 2026-05-13 16:49:10 +08:00
xiuting.xu
ad76745619 内存优化 2026-05-13 14:57:43 +08:00
xiuting.xu
9dc69e5f31 内存优化,优化随着更新ccr文件增多,内存增长的情况 2026-05-13 14:08:07 +08:00
xiuting.xu
ff711a2fe1 内存优化 2026-05-12 16:45:56 +08:00
xiuting.xu
4c6b441753 优化部署 2026-05-12 15:23:09 +08:00
xiuting.xu
a4d674190a ccr的读取换成读取文件目录 2026-05-11 14:06:38 +08:00
xiuting.xu
b79e25ffea 修改serial query的没有更新情况下的pdu 2026-05-09 14:49:21 +08:00
xiuting.xu
84866c1d86 修改部署文件 2026-05-09 10:12:51 +08:00
xiuting.xu
ddeff71618 修改bird的docker-compose 2026-05-08 10:17:59 +08:00
xiuting.xu
ca0cf4800a 修改bird配置 2026-05-08 09:54:00 +08:00
xiuting.xu
897d168ceb 修改部署文件; 2026-05-07 16:47:08 +08:00
xiuting.xu
19225edaa7 内存优化 2026-05-06 15:27:16 +08:00
xiuting.xu
6e4b59a208 增加bird部署;
优化内存占用
2026-05-06 09:49:41 +08:00
xiuting.xu
88c11e7a97 修改deploy 2026-04-24 11:33:54 +08:00
xiuting.xu
8a65f639cb 修改dockerfile及docker-compose,失败后不要重新启动,方便测试 2026-04-24 10:50:11 +08:00
xiuting.xu
cef9495534 add bird deploy
add data
add certs
2026-04-23 15:13:50 +08:00
xiuting.xu
b60d579a38 增加ssh
增加deploy下细分的tcp、tls、ssh
2026-04-22 16:02:42 +08:00
xiuting.xu
99250f8aa9 增加timing环境变量 2026-04-15 16:26:09 +08:00
xiuting.xu
17c1a02c90 rtr client 开发 2026-04-15 15:43:59 +08:00
xiuting.xu
c1d3112a45 add dockerfile 2026-04-10 14:29:40 +08:00
xiuting.xu
23cdad095d 优化rtr 2026-04-08 14:03:23 +08:00
xiuting.xu
b483c2af8b remove unused code 2026-04-01 16:31:52 +08:00
xiuting.xu
cd0330e8ae 新增slurm功能 2026-04-01 16:24:01 +08:00
xiuting.xu
8d6173f605 rtr的输入换成ccr文件 2026-03-30 10:03:43 +08:00
xiuting.xu
7432fbcac4 完善routerkey和aspa功能 2026-03-26 10:04:09 +08:00
xiuting.xu
03c0ab0ec7 补充rtr-client,完善tls连接等 2026-03-25 10:08:40 +08:00
xiuting.xu
9cbea4e2d0 RTR server以及client的开发、test的补充 2026-03-17 16:36:42 +08:00
xiuting.xu
251dea8e5e RTR server 2026-03-09 11:25:42 +08:00
143 changed files with 32855 additions and 665 deletions

9
.dockerignore Normal file
View File

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

5
.gitignore vendored
View File

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

3528
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,12 @@
[package] [package]
name = "rpki" name = "rpki"
version = "0.1.0" version = "1.3.0"
edition = "2024" edition = "2024"
[[bin]]
name = "rpki_rtr"
path = "src/main_rtr.rs"
[dependencies] [dependencies]
der-parser = "10.0.0" der-parser = "10.0.0"
hex = "0.4.3" hex = "0.4.3"
@ -13,3 +17,24 @@ url = "2.5.8"
asn1-rs = "0.7.1" asn1-rs = "0.7.1"
asn1-rs-derive = "0.6.0" asn1-rs-derive = "0.6.0"
asn1 = "0.23.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"] }

287
README.client.md Normal file
View File

@ -0,0 +1,287 @@
# 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)
- [Docker attach 交互命令](#docker-attach-交互命令)
- [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 --summary-only
```
Docker 镜像的 `entrypoint.sh` 会把 stdout/stderr 重定向到 `logs/client/*.stdout.log``logs/client/*.stderr.log`。因此 `docker attach` 后可以输入交互命令,但命令响应和收到的 PDU 仍然主要从日志文件查看:
```bash
tail -f logs/client/*.stdout.log
tail -f logs/client/*.stderr.log
```
### Docker attach 交互命令
如果 debug client 已经通过 compose 启动,可以 attach 到容器后输入命令:
```bash
docker attach --sig-proxy=false <rtr-debug-client-container>
```
脱离 attach 会话时使用 `Ctrl-P` 后接 `Ctrl-Q`,避免 `Ctrl-C` 直接停止客户端进程。
可用交互命令如下:
| 命令 | 作用 |
| --- | --- |
| `help` | 显示可用命令列表。 |
| `state` | 打印当前客户端状态,包括 `session_id``serial``refresh/retry/expire`、输出模式、自动轮询状态等。 |
| `version` | 查看当前 RTR 协议版本。 |
| `version <n>` | 修改后续查询使用的 RTR 协议版本,例如 `version 2`。 |
| `reset` | 发送 Reset Query通常用于让 server 重新下发完整数据集。 |
| `serial` | 使用当前已记录的 `session_id``serial` 发送 Serial Query。需要客户端已经从 EndOfData 中拿到状态。 |
| `serial <session_id> <serial>` | 显式指定参数发送 Serial Query例如 `serial 31320 156`。 |
| `timeout` | 查看当前读取 PDU 的超时时间。 |
| `timeout <secs>` | 修改读取 PDU 的超时时间。超时只打印提示,连接保持打开。 |
| `poll` | 查看当前自动轮询间隔、来源、refresh hint、错误状态和暂停状态。 |
| `poll <secs>` | 修改自动轮询间隔。 |
| `poll pause` | 暂停自动轮询。 |
| `poll resume` | 恢复自动轮询。 |
| `output` | 查看当前输出模式,以及 summary 模式下跳过的 payload PDU 数量。 |
| `output verbose` | 打印所有收到的 PDU包括 IPv4 Prefix、IPv6 Prefix、Router Key、ASPA 等 payload PDU。 |
| `output summary` | 只打印摘要,跳过 payload PDU 详情。compose 默认使用该模式。 |
| `quit` / `exit` | 退出客户端进程。 |
常用调试流程:
```text
output verbose
reset
state
serial
```
### 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 能力一致。

372
README.md
View File

@ -1,11 +1,373 @@
# RPKI RTR Server
这是一个用 Rust 实现的 RPKI-to-Router (RTR) cache server。服务端从 CCR 文件中读取 RPKI 数据,生成 RTR cache snapshot 和 delta并通过 RTR 协议提供给路由器或调试客户端。
# 单元测试 当前代码重点覆盖:
``` - RTR serverTCP 默认启用,可选启用 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。
## 目录
- [协议参考](#协议参考)
- [项目结构](#项目结构)
- [构建与测试](#构建与测试)
- [数据输入](#数据输入)
- [快速启动](#快速启动)
- [前置数据生成](#前置数据生成)
- [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), [draft-ietf-sidrops-8210bis-26](https://www.ietf.org/archive/id/draft-ietf-sidrops-8210bis-26.html)
- SLURM: [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)已过期Expired 2026-05-20
- 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
# 查看输出
cargo test -- --nocapture
``` ```
服务端 binary 名称是 `rpki_rtr`
```bash
cargo run --bin rpki_rtr
```
默认配置会监听 `0.0.0.0:323`,从 `./data` 查找 CCR 文件,并使用 `./rtr-db` 保存 RocksDB 数据。启动前需要确保 CCR 目录里存在可解析的 `.ccr` 文件。
## 数据输入
服务端启动和刷新时会读取 `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
```
如需覆盖已有 `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 Originv1 包含 Route Origin 和 Router Keyv2 包含 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`

23
data/example.slurm Normal file
View File

@ -0,0 +1,23 @@
{
"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": []
}
}

33
deploy/bird/.env Normal file
View File

@ -0,0 +1,33 @@
# 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

56
deploy/bird/Dockerfile Normal file
View File

@ -0,0 +1,56 @@
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"]

90
deploy/bird/README.md Normal file
View File

@ -0,0 +1,90 @@
# 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.

80
deploy/bird/README.zh.md Normal file
View File

@ -0,0 +1,80 @@
# 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

@ -0,0 +1,31 @@
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";
};
}

28
deploy/bird/bird.conf Normal file
View File

@ -0,0 +1,28 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,30 @@
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

@ -0,0 +1,30 @@
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

@ -0,0 +1,28 @@
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,41 @@
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

218
deploy/bird/entrypoint.sh Normal file
View File

@ -0,0 +1,218 @@
#!/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"

37
deploy/client/.env Normal file
View File

@ -0,0 +1,37 @@
# Target RTR server addresses for client compose files
# build-run.sh auto-detects amd64/arm64 unless IMAGE_ARCH/RPKI_RTR_IMAGE_ARCH is set before running it.
# Direct docker compose uses RPKI_RTR_IMAGE_ARCH from this file; set it to arm64 on ARM hosts.
RPKI_RTR_CLIENT_IMAGE_BASE=rpki-rtr-debug-client
RPKI_RTR_CLIENT_IMAGE_TAG=v1.3
RPKI_RTR_IMAGE_ARCH=arm64
# RPKI_RTR_TARGET_PLATFORM=linux/amd64
# RPKI_RTR_CLIENT_IMAGE=rpki-rtr-debug-client-amd64:v1.3
# 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=

26
deploy/client/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
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"]

314
deploy/client/build-run.sh Normal file
View File

@ -0,0 +1,314 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
CALLER_TARGET_PLATFORM="${TARGET_PLATFORM:-${RPKI_RTR_TARGET_PLATFORM:-}}"
CALLER_IMAGE_ARCH="${IMAGE_ARCH:-${RPKI_RTR_IMAGE_ARCH:-}}"
CALLER_CLIENT_IMAGE="${CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE:-}}"
load_env_defaults() {
local line key value
[[ -f "${SCRIPT_DIR}/.env" ]] || return
while IFS= read -r line || [[ -n "${line}" ]]; do
line="${line%$'\r'}"
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
[[ "${line}" == *"="* ]] || continue
key="${line%%=*}"
value="${line#*=}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ -z "${!key+x}" ]]; then
export "${key}=${value}"
fi
done < "${SCRIPT_DIR}/.env"
}
detect_image_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' amd64 ;;
aarch64|arm64) printf '%s\n' arm64 ;;
*) die "unsupported host architecture '$(uname -m)'; set RPKI_RTR_IMAGE_ARCH=amd64 or arm64" ;;
esac
}
platform_for_image_arch() {
case "$1" in
amd64) printf '%s\n' linux/amd64 ;;
arm64) printf '%s\n' linux/arm64 ;;
*) die "unsupported image arch '$1', expected amd64 or arm64" ;;
esac
}
image_arch_from_platform() {
case "$1" in
linux/amd64|amd64) printf '%s\n' amd64 ;;
linux/arm64|arm64) printf '%s\n' arm64 ;;
*) return 1 ;;
esac
}
load_env_defaults
MODE="${MODE:-tcp}"
ACTION="up"
DETACH="${DETACH:-1}"
BUILD="${BUILD:-1}"
CLIENTS="${CLIENTS:-single}"
TARGET_PLATFORM="${CALLER_TARGET_PLATFORM}"
IMAGE_ARCH="${CALLER_IMAGE_ARCH}"
CLIENT_IMAGE="${CALLER_CLIENT_IMAGE}"
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: host architecture)
--image-arch VALUE
Image architecture suffix for default tag: amd64|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
;;
--image-arch)
[[ $# -ge 2 ]] || die "--image-arch requires a value"
IMAGE_ARCH="$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
if [[ -z "${TARGET_PLATFORM}" ]]; then
[[ -n "${IMAGE_ARCH}" ]] || IMAGE_ARCH="$(detect_image_arch)"
TARGET_PLATFORM="$(platform_for_image_arch "${IMAGE_ARCH}")"
elif [[ -z "${IMAGE_ARCH}" ]]; then
if [[ "${TARGET_PLATFORM}" != *,* ]] && IMAGE_ARCH="$(image_arch_from_platform "${TARGET_PLATFORM}")"; then
:
else
IMAGE_ARCH="$(detect_image_arch)"
fi
fi
if [[ -z "${CLIENT_IMAGE}" ]]; then
if [[ "${TARGET_PLATFORM}" == *,* ]]; then
CLIENT_IMAGE="${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}:${RPKI_RTR_CLIENT_IMAGE_TAG:-latest}"
else
CLIENT_IMAGE="${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${IMAGE_ARCH}:${RPKI_RTR_CLIENT_IMAGE_TAG:-latest}"
fi
fi
[[ "${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

@ -0,0 +1,52 @@
version: "3.9"
services:
rtr-client-1:
image: ${RPKI_RTR_CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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

@ -0,0 +1,36 @@
version: "3.9"
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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

@ -0,0 +1,36 @@
version: "3.9"
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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

@ -0,0 +1,19 @@
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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

@ -0,0 +1,38 @@
version: "3.9"
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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

@ -0,0 +1,19 @@
services:
rtr-debug-client:
build:
context: ../..
dockerfile: deploy/client/Dockerfile
image: ${RPKI_RTR_CLIENT_IMAGE:-${RPKI_RTR_CLIENT_IMAGE_BASE:-rpki-rtr-debug-client}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_CLIENT_IMAGE_TAG:-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

@ -0,0 +1,10 @@
#!/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"

4
deploy/frr/.env Normal file
View File

@ -0,0 +1,4 @@
# 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

78
deploy/frr/README.md Normal file
View File

@ -0,0 +1,78 @@
# 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`.

83
deploy/frr/README.zh.md Normal file
View File

@ -0,0 +1,83 @@
# 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

@ -0,0 +1,7 @@
# 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

@ -0,0 +1,16 @@
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

24
deploy/frr/entrypoint.sh Normal file
View File

@ -0,0 +1,24 @@
#!/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

@ -0,0 +1,22 @@
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

@ -0,0 +1,22 @@
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

54
deploy/server/.env Normal file
View File

@ -0,0 +1,54 @@
# Core runtime knobs.
# build-run.sh auto-detects amd64/arm64 unless IMAGE_ARCH/RPKI_RTR_IMAGE_ARCH is set before running it.
# Direct docker compose uses RPKI_RTR_IMAGE_ARCH from this file; set it to arm64 on ARM hosts.
RPKI_RTR_SERVER_IMAGE_BASE=rpki-rtr
RPKI_RTR_SERVER_IMAGE_TAG=v1.3
RPKI_RTR_IMAGE_ARCH=arm64
# RPKI_RTR_TARGET_PLATFORM=linux/amd64
# RPKI_RTR_SERVER_IMAGE=rpki-rtr-amd64:v1.3
# 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

@ -0,0 +1,99 @@
# 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.

85
deploy/server/Dockerfile Normal file
View File

@ -0,0 +1,85 @@
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"]

292
deploy/server/build-run.sh Normal file
View File

@ -0,0 +1,292 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
CALLER_TARGET_PLATFORM="${TARGET_PLATFORM:-${RPKI_RTR_TARGET_PLATFORM:-}}"
CALLER_IMAGE_ARCH="${IMAGE_ARCH:-${RPKI_RTR_IMAGE_ARCH:-}}"
CALLER_SERVER_IMAGE="${SERVER_IMAGE:-${RPKI_RTR_SERVER_IMAGE:-}}"
load_env_defaults() {
local line key value
[[ -f "${SCRIPT_DIR}/.env" ]] || return
while IFS= read -r line || [[ -n "${line}" ]]; do
line="${line%$'\r'}"
[[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue
[[ "${line}" == *"="* ]] || continue
key="${line%%=*}"
value="${line#*=}"
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
[[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [[ -z "${!key+x}" ]]; then
export "${key}=${value}"
fi
done < "${SCRIPT_DIR}/.env"
}
detect_image_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' amd64 ;;
aarch64|arm64) printf '%s\n' arm64 ;;
*) die "unsupported host architecture '$(uname -m)'; set RPKI_RTR_IMAGE_ARCH=amd64 or arm64" ;;
esac
}
platform_for_image_arch() {
case "$1" in
amd64) printf '%s\n' linux/amd64 ;;
arm64) printf '%s\n' linux/arm64 ;;
*) die "unsupported image arch '$1', expected amd64 or arm64" ;;
esac
}
image_arch_from_platform() {
case "$1" in
linux/amd64|amd64) printf '%s\n' amd64 ;;
linux/arm64|arm64) printf '%s\n' arm64 ;;
*) return 1 ;;
esac
}
load_env_defaults
MODE="${MODE:-tcp}"
ACTION="up"
DETACH="${DETACH:-1}"
BUILD="${BUILD:-1}"
TARGET_PLATFORM="${CALLER_TARGET_PLATFORM}"
IMAGE_ARCH="${CALLER_IMAGE_ARCH}"
SERVER_IMAGE="${CALLER_SERVER_IMAGE}"
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: host architecture)
--image-arch VALUE
Image architecture suffix for default tag: amd64|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
;;
--image-arch)
[[ $# -ge 2 ]] || die "--image-arch requires a value"
IMAGE_ARCH="$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
if [[ -z "${TARGET_PLATFORM}" ]]; then
[[ -n "${IMAGE_ARCH}" ]] || IMAGE_ARCH="$(detect_image_arch)"
TARGET_PLATFORM="$(platform_for_image_arch "${IMAGE_ARCH}")"
elif [[ -z "${IMAGE_ARCH}" ]]; then
if [[ "${TARGET_PLATFORM}" != *,* ]] && IMAGE_ARCH="$(image_arch_from_platform "${TARGET_PLATFORM}")"; then
:
else
IMAGE_ARCH="$(detect_image_arch)"
fi
fi
if [[ -z "${SERVER_IMAGE}" ]]; then
if [[ "${TARGET_PLATFORM}" == *,* ]]; then
SERVER_IMAGE="${RPKI_RTR_SERVER_IMAGE_BASE:-rpki-rtr}:${RPKI_RTR_SERVER_IMAGE_TAG:-latest}"
else
SERVER_IMAGE="${RPKI_RTR_SERVER_IMAGE_BASE:-rpki-rtr}-${IMAGE_ARCH}:${RPKI_RTR_SERVER_IMAGE_TAG:-latest}"
fi
fi
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

@ -0,0 +1,54 @@
version: "3.9"
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-${RPKI_RTR_SERVER_IMAGE_BASE:-rpki-rtr}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_SERVER_IMAGE_TAG:-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

@ -0,0 +1,44 @@
version: "3.9"
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-${RPKI_RTR_SERVER_IMAGE_BASE:-rpki-rtr}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_SERVER_IMAGE_TAG:-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

@ -0,0 +1,48 @@
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-${RPKI_RTR_SERVER_IMAGE_BASE:-rpki-rtr}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_SERVER_IMAGE_TAG:-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

@ -0,0 +1,58 @@
version: "3.9"
services:
rpki-rtr:
build:
context: ../..
dockerfile: deploy/server/Dockerfile
image: ${RPKI_RTR_SERVER_IMAGE:-${RPKI_RTR_SERVER_IMAGE_BASE:-rpki-rtr}-${RPKI_RTR_IMAGE_ARCH:-amd64}:${RPKI_RTR_SERVER_IMAGE_TAG:-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

@ -0,0 +1,10 @@
#!/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"

310
docs/rtr-admin-api.md Normal file
View File

@ -0,0 +1,310 @@
# 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` | 请求体超过限制。 |

203
scripts/00_0_install.sh Normal file
View File

@ -0,0 +1,203 @@
#!/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"

155
scripts/00_1_pre_check.sh Normal file
View File

@ -0,0 +1,155 @@
#!/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

@ -0,0 +1,54 @@
#!/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

@ -0,0 +1,96 @@
#!/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

@ -0,0 +1,56 @@
#!/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

221
scripts/01_start.sh Normal file
View File

@ -0,0 +1,221 @@
#!/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"

184
scripts/02_self_check.sh Normal file
View File

@ -0,0 +1,184 @@
#!/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"

92
scripts/03_shutdown.sh Normal file
View File

@ -0,0 +1,92 @@
#!/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"

152
scripts/04_cleanup.sh Normal file
View File

@ -0,0 +1,152 @@
#!/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

@ -0,0 +1,303 @@
#!/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

136
scripts/11_start_client.sh Normal file
View File

@ -0,0 +1,136 @@
#!/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

@ -0,0 +1,82 @@
#!/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"

218
scripts/README.md Normal file
View File

@ -0,0 +1,218 @@
# 脚本说明
`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。

186
specs/10_slurm.md Normal file
View File

@ -0,0 +1,186 @@
# 10. SLURMSimplified Local Internet Number Resource Management with the RPKI
## 10.1 目标与范围
SLURM 用于让 RPRelying Party在本地对上游 RPKI 验证结果做“过滤”和“补充断言”,而不修改上游发布对象。
本文档基于:
- RFC 8416SLURM v1ROA/BGPsec
- draft-ietf-sidrops-aspa-slurm-04SLURM v2新增 ASPA
## 10.2 版本与顶层结构
### 10.2.1 SLURM v1RFC 8416
`slurmVersion` 必须为 `1`,且顶层 JSON 对象必须包含且仅包含以下成员:
- `slurmVersion`
- `validationOutputFilters`(必须包含 `prefixFilters``bgpsecFilters`
- `locallyAddedAssertions`(必须包含 `prefixAssertions``bgpsecAssertions`
空策略示例:
```json
{
"slurmVersion": 1,
"validationOutputFilters": {
"prefixFilters": [],
"bgpsecFilters": []
},
"locallyAddedAssertions": {
"prefixAssertions": [],
"bgpsecAssertions": []
}
}
```
### 10.2.2 SLURM v2draft-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 KeyBGPsec
### 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`
匹配规则:
- 当 VAPValidated 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

179
specs/11_rtr.md Normal file
View File

@ -0,0 +1,179 @@
# 11. RTRRPKI to Router Protocol
## 11.1 目标与文档范围
RTR 用于把 RP/Cache 已完成密码学验证的 RPKI 数据下发给路由器。
本文按以下规范整理:
- RFC 6810RTR v0
- RFC 8210RTR v1更新 RFC 6810
- draft-ietf-sidrops-8210bis-26RTR v2草案
## 11.2 协议演进
### 11.2.1 RFC 6810v0
- 只定义 Prefix Origin 相关 payloadIPv4/IPv6 Prefix PDU
- 主要 PDUSerial Notify / Serial Query / Reset Query / Cache Response / Prefix / End of Data / Cache Reset / Error Report。
### 11.2.2 RFC 8210v1
在 v0 基础上新增/强化:
- 新增 `Router Key PDU`PDU Type 9v1 可用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 最大长度上限为 64k65535
## 11.3 PDU 与版本矩阵
PDU 类型(按规范注册表):
| PDU Type | 名称 | v0 (RFC6810) | v1 (RFC8210) | v2 (8210bis-26) |
|---|---|---|---|---|
| 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 NotifyType 0
- 由 Cache 主动发送,提示有新序列可拉取。
- 是少数可不由 Router 请求触发的消息。
### 11.4.2 Reset QueryType 2与 Cache ResponseType 3
- Router 启动或失配时发 `Reset Query` 请求全量。
- Cache 回复 `Cache Response`,随后发送全量 payload最后 `End of Data`
### 11.4.3 Serial QueryType 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 9v1+):表示 BGPsec Router Key 的 announce/withdraw。
- `ASPA`Type 11v2 草案):表示 ASPA 数据单元的 announce/withdraw。
语义要点v1 / v2 草案):
- 对同一 payload 键(如 Prefix 四元组、Router Key 三元组、ASPA customer 键)应维护清晰的替换/撤销关系。
- Cache 负责把历史变化“合并简化”后再发给 Router避免无意义抖动。
### 11.4.5 End of DataType 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.5.3 Cache Restart / Cache Shutdownv2
- `Cache Restart`Cache 即将对已连接客户端不可用,但预计会在客户端达到 Expire Interval 前恢复v2 session 应发送 `Error Report` code 12随后关闭连接。
- `Cache Shutdown`Cache 即将有意下线,并希望客户端 flush 从该 cache 学到的数据v2 session 应发送 `Error Report` code 13随后关闭连接。
- 这两个错误码只属于 v2。v0/v1 session 不得发送 code 12/13只能关闭连接。
## 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 ASNCache 需向 Router 提供一致且可替换的 ASPA 视图。
### 11.8.2 排序与事务
- 草案新增 race 条件说明(如前缀替换、撤销先后导致短暂误判)。
- 对 Cache 输出 payload 的顺序提出约束。
- 建议 Router 使用“事务式应用”(例如接收到完整响应后再切换生效)降低中间态影响。
### 11.8.3 Error Code 版本范围
- v0RFC68100..7。
- v1RFC8210继承 v0并新增 8 `Unexpected Protocol Version`
- v28210bis-26继承 v1并新增 9 `ASPA Provider List Error`、10 `Transport Failure`、11 `Ordering Error`、12 `Cache Restart`、13 `Cache Shutdown`
- 实现发送 Error Report 时,只能发送当前 session 协议版本定义过的错误码。
### 11.8.4 本实现的生命周期控制
- `RPKI_RTR_STOP_REASON=cache-restart|cache-shutdown|close-only` 控制进程收到 Ctrl-C 后的服务停止语义,默认 `cache-restart`
- Admin API:
- `POST /admin/rtr/lifecycle/restart`:向 v2 established clients 发送 `Cache Restart`,然后走进程退出路径;实际重新拉起由 systemd/Docker/Kubernetes/Windows Service 等 supervisor 完成。若没有外部机制在客户端 `Expire Interval` 到期前恢复服务,不应使用该接口。
- `POST /admin/rtr/lifecycle/shutdown`:向 v2 established clients 发送 `Cache Shutdown`,然后退出进程。
- v0/v1 或尚未完成版本协商的连接不会收到 code 12/13。
## 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-26: https://www.ietf.org/archive/id/draft-ietf-sidrops-8210bis-26.html

BIN
specs/img/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

290
src/bin/ccr_fixture_gen.rs Normal file
View File

@ -0,0 +1,290 @@
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
}

View File

@ -0,0 +1,170 @@
# 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`:兼容旧脚本的保留参数;默认已按 RFC/draft 处理 `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`
- `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

View File

@ -0,0 +1,404 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use crate::protocol::{
ASPA_FIXED_BODY_LEN, END_OF_DATA_V0_BODY_LEN, END_OF_DATA_V1_BODY_LEN, IPV4_PREFIX_BODY_LEN,
IPV6_PREFIX_BODY_LEN, PduHeader, PduType, ROUTER_KEY_FIXED_BODY_LEN, flag_meaning, hex_bytes,
};
pub fn print_pdu(header: &PduHeader, body: &[u8]) {
println!("--------------------------------------------------");
println!("PDU: {}", header.pdu_type());
println!("version : {}", header.version);
println!("length : {}", header.length);
match header.pdu_type() {
PduType::CacheResponse => {
println!("session_id : {}", header.session_id());
}
PduType::CacheReset => {
println!("cache reset");
}
PduType::Ipv4Prefix => {
print_ipv4_prefix(header, body);
}
PduType::Ipv6Prefix => {
print_ipv6_prefix(header, body);
}
PduType::RouterKey => {
print_router_key(header, body);
}
PduType::EndOfData => {
print_end_of_data(header, body);
}
PduType::ErrorReport => {
print_error_report(header, body);
}
PduType::SerialNotify => {
print_serial_notify(header, body);
}
PduType::SerialQuery => {
print_serial_query(header, body);
}
PduType::Aspa => {
print_aspa(header, body);
}
PduType::ResetQuery => {
println!("reset query");
}
PduType::Unknown(_) => {
println!("field1 : {}", header.field1);
println!("body : {}", hex_bytes(body));
}
}
}
fn print_ipv4_prefix(header: &PduHeader, body: &[u8]) {
if body.len() != IPV4_PREFIX_BODY_LEN {
println!("invalid IPv4 Prefix body length: {}", body.len());
println!("raw body: {}", hex_bytes(body));
return;
}
let flags = body[0];
let prefix_len = body[1];
let max_len = body[2];
let zero = body[3];
let prefix = Ipv4Addr::new(body[4], body[5], body[6], body[7]);
let asn = u32::from_be_bytes([body[8], body[9], body[10], body[11]]);
println!("session_id : {}", header.session_id());
println!("flags : 0x{:02x} ({})", flags, flag_meaning(flags));
println!("prefix_len : {}", prefix_len);
println!("max_len : {}", max_len);
println!("zero : {}", zero);
println!("prefix : {}", prefix);
println!("asn : {}", asn);
}
fn print_ipv6_prefix(header: &PduHeader, body: &[u8]) {
if body.len() != IPV6_PREFIX_BODY_LEN {
println!("invalid IPv6 Prefix body length: {}", body.len());
println!("raw body: {}", hex_bytes(body));
return;
}
let flags = body[0];
let prefix_len = body[1];
let max_len = body[2];
let zero = body[3];
let mut addr = [0u8; 16];
addr.copy_from_slice(&body[4..20]);
let prefix = Ipv6Addr::from(addr);
let asn = u32::from_be_bytes([body[20], body[21], body[22], body[23]]);
println!("session_id : {}", header.session_id());
println!("flags : 0x{:02x} ({})", flags, flag_meaning(flags));
println!("prefix_len : {}", prefix_len);
println!("max_len : {}", max_len);
println!("zero : {}", zero);
println!("prefix : {}", prefix);
println!("asn : {}", asn);
}
fn print_end_of_data(header: &PduHeader, body: &[u8]) {
println!("session_id : {}", header.session_id());
match body.len() {
END_OF_DATA_V0_BODY_LEN => {
let serial = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
println!("serial : {}", serial);
println!("variant : v0");
}
END_OF_DATA_V1_BODY_LEN => {
let serial = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
let refresh = u32::from_be_bytes([body[4], body[5], body[6], body[7]]);
let retry = u32::from_be_bytes([body[8], body[9], body[10], body[11]]);
let expire = u32::from_be_bytes([body[12], body[13], body[14], body[15]]);
println!("serial : {}", serial);
println!("refresh : {}", refresh);
println!("retry : {}", retry);
println!("expire : {}", expire);
println!("variant : v1/v2");
}
_ => {
println!("invalid EndOfData body length: {}", body.len());
println!("raw body : {}", hex_bytes(body));
}
}
}
fn print_error_report(header: &PduHeader, body: &[u8]) {
println!(
"error_code : {} ({})",
header.error_code(),
error_code_name(header.error_code())
);
if body.len() < 8 {
println!("invalid ErrorReport body length: {}", body.len());
println!("raw body : {}", hex_bytes(body));
return;
}
let encapsulated_len = u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
if body.len() < 4 + encapsulated_len + 4 {
println!("invalid ErrorReport: truncated encapsulated PDU");
println!("raw body : {}", hex_bytes(body));
return;
}
let encapsulated = &body[4..4 + encapsulated_len];
let text_len_offset = 4 + encapsulated_len;
let text_len = u32::from_be_bytes([
body[text_len_offset],
body[text_len_offset + 1],
body[text_len_offset + 2],
body[text_len_offset + 3],
]) as usize;
if body.len() < text_len_offset + 4 + text_len {
println!("invalid ErrorReport: truncated text");
println!("raw body : {}", hex_bytes(body));
return;
}
let text_bytes = &body[text_len_offset + 4..text_len_offset + 4 + text_len];
let text = String::from_utf8_lossy(text_bytes);
println!("encap_len : {}", encapsulated_len);
if let Some(encap_header) = parse_encapsulated_header(encapsulated) {
println!("encap_pdu_type : {}", encap_header.pdu_type());
println!("encap_version : {}", encap_header.version);
println!("encap_length : {}", encap_header.length);
match encap_header.pdu_type() {
PduType::ErrorReport => {
println!("encap_field1 : error_code={}", encap_header.error_code());
}
PduType::Unknown(_) => {
println!("encap_field1 : {}", encap_header.field1);
}
_ => {
println!("encap_field1 : session_id={}", encap_header.session_id());
}
}
} else if encapsulated_len > 0 {
println!("encap_header : <truncated or unavailable>");
}
println!("encap_pdu : {}", hex_bytes(encapsulated));
println!("text_len : {}", text_len);
println!("text_utf8 : {}", std::str::from_utf8(text_bytes).is_ok());
println!("text : {}", text);
}
fn print_serial_notify(header: &PduHeader, body: &[u8]) {
if body.len() != 4 {
println!("invalid Serial Notify body length: {}", body.len());
println!("raw body : {}", hex_bytes(body));
return;
}
let serial = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
println!("session_id : {}", header.session_id());
println!("serial : {}", serial);
}
fn print_serial_query(header: &PduHeader, body: &[u8]) {
if body.len() != 4 {
println!("invalid Serial Query body length: {}", body.len());
println!("raw body : {}", hex_bytes(body));
return;
}
let serial = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
println!("session_id : {}", header.session_id());
println!("serial : {}", serial);
}
fn print_router_key(_header: &PduHeader, body: &[u8]) {
if body.len() < ROUTER_KEY_FIXED_BODY_LEN {
println!("invalid Router Key body length: {}", body.len());
println!("raw body : {}", hex_bytes(body));
return;
}
let flags = _header.flags();
let zero = _header.zero();
let ski = &body[0..20];
let asn = u32::from_be_bytes([body[20], body[21], body[22], body[23]]);
let spki = &body[24..];
println!("flags : 0x{:02x} ({})", flags, flag_meaning(flags));
println!("zero : {}", zero);
println!("ski : {}", hex_bytes(ski));
println!("asn : {}", asn);
println!("spki_len : {}", spki.len());
println!("spki : {}", hex_bytes(spki));
if flags & !0x01 != 0 {
println!("warning : Router Key flags use reserved bits");
}
if zero != 0 {
println!("warning : Router Key reserved zero octet is non-zero");
}
if asn == 0 {
println!("warning : Router Key ASN is AS0");
}
if spki.is_empty() {
println!("warning : Router Key SPKI is empty");
}
}
pub fn print_raw_pdu(header: &PduHeader, body: &[u8]) {
let mut raw = Vec::with_capacity(8 + body.len());
raw.push(header.version);
raw.push(header.pdu_type_raw);
raw.extend_from_slice(&header.field1.to_be_bytes());
raw.extend_from_slice(&header.length.to_be_bytes());
raw.extend_from_slice(body);
println!("--------------------------------------------------");
println!("[raw] pdu_type : {}", header.pdu_type());
println!("[raw] octets : {}", hex_bytes(&raw));
}
fn error_code_name(code: u16) -> &'static str {
match code {
0 => "Corrupt Data",
1 => "Internal Error",
2 => "No Data Available",
3 => "Invalid Request",
4 => "Unsupported Protocol Version",
5 => "Unsupported PDU Type",
6 => "Withdrawal of Unknown Record",
7 => "Duplicate Announcement Received",
8 => "Unexpected Protocol Version",
9 => "ASPA Provider List Error",
10 => "Transport Failure",
11 => "Ordering Error",
12 => "Cache Restart",
13 => "Cache Shutdown",
_ => "Unknown Error Code",
}
}
fn parse_encapsulated_header(encapsulated: &[u8]) -> Option<PduHeader> {
if encapsulated.len() < 8 {
return None;
}
let mut header = [0u8; 8];
header.copy_from_slice(&encapsulated[..8]);
Some(PduHeader::from_bytes(header))
}
fn print_aspa(_header: &PduHeader, body: &[u8]) {
if body.len() < ASPA_FIXED_BODY_LEN {
println!("invalid ASPA body length: {}", body.len());
println!("raw body : {}", hex_bytes(body));
return;
}
let flags = _header.flags();
let zero = _header.zero();
let customer_asn = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
println!("flags : 0x{:02x} ({})", flags, flag_meaning(flags));
println!("reserved : {}", zero);
println!("customer_as : {}", customer_asn);
let providers_raw = &body[4..];
if providers_raw.len() % 4 != 0 {
println!("invalid ASPA providers length: {}", providers_raw.len());
println!("providers : {}", hex_bytes(providers_raw));
return;
}
let mut providers = Vec::new();
for chunk in providers_raw.chunks_exact(4) {
providers.push(u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
}
println!("providers : {:?}", providers);
if flags & !0x01 != 0 {
println!("warning : ASPA flags use reserved bits");
}
if zero != 0 {
println!("warning : ASPA reserved zero octet is non-zero");
}
if customer_asn == 0 {
println!("warning : ASPA customer ASN is AS0");
}
let is_announcement = flags & 0x01 == 0x01;
if is_announcement {
if providers.is_empty() {
println!("warning : ASPA announcement has empty provider list");
}
} else if !providers.is_empty() {
println!("warning : ASPA withdrawal must not contain providers");
}
if providers.iter().any(|asn| *asn == 0) {
println!("warning : ASPA provider list contains AS0");
}
let strictly_increasing = providers.windows(2).all(|pair| pair[0] < pair[1]);
if !strictly_increasing && providers.len() > 1 {
println!("warning : ASPA providers are not in strictly increasing order");
}
}
pub fn parse_serial_notify_serial(body: &[u8]) -> Option<u32> {
if body.len() != 4 {
return None;
}
Some(u32::from_be_bytes([body[0], body[1], body[2], body[3]]))
}
pub fn parse_end_of_data_serial(body: &[u8]) -> Option<u32> {
match body.len() {
4 | 16 => Some(u32::from_be_bytes([body[0], body[1], body[2], body[3]])),
_ => None,
}
}
#[derive(Debug, Clone, Copy)]
pub struct EndOfDataInfo {
pub serial: u32,
pub refresh: Option<u32>,
pub retry: Option<u32>,
pub expire: Option<u32>,
}
pub fn parse_end_of_data_info(body: &[u8]) -> Option<EndOfDataInfo> {
match body.len() {
4 => {
let serial = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
Some(EndOfDataInfo {
serial,
refresh: None,
retry: None,
expire: None,
})
}
16 => {
let serial = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
let refresh = u32::from_be_bytes([body[4], body[5], body[6], body[7]]);
let retry = u32::from_be_bytes([body[8], body[9], body[10], body[11]]);
let expire = u32::from_be_bytes([body[12], body[13], body[14], body[15]]);
Some(EndOfDataInfo {
serial,
refresh: Some(refresh),
retry: Some(retry),
expire: Some(expire),
})
}
_ => None,
}
}

View File

@ -0,0 +1,167 @@
use std::fmt;
pub const HEADER_LEN: usize = 8;
pub const SERIAL_QUERY_LEN: usize = 12;
pub const MAX_PDU_LEN: u32 = 65_535;
pub const IPV4_PREFIX_BODY_LEN: usize = 12;
pub const IPV6_PREFIX_BODY_LEN: usize = 24;
pub const END_OF_DATA_V0_BODY_LEN: usize = 4;
pub const END_OF_DATA_V1_BODY_LEN: usize = 16;
pub const ROUTER_KEY_FIXED_BODY_LEN: usize = 24;
pub const ASPA_FIXED_BODY_LEN: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryMode {
Reset,
Serial { session_id: u16, serial: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PduType {
SerialNotify,
SerialQuery,
ResetQuery,
CacheResponse,
Ipv4Prefix,
Ipv6Prefix,
RouterKey,
EndOfData,
CacheReset,
ErrorReport,
Aspa,
Unknown(u8),
}
impl PduType {
pub fn code(self) -> u8 {
match self {
Self::SerialNotify => 0,
Self::SerialQuery => 1,
Self::ResetQuery => 2,
Self::CacheResponse => 3,
Self::Ipv4Prefix => 4,
Self::Ipv6Prefix => 6,
Self::RouterKey => 9,
Self::EndOfData => 7,
Self::CacheReset => 8,
Self::ErrorReport => 10,
Self::Aspa => 11,
Self::Unknown(v) => v,
}
}
pub fn name(self) -> &'static str {
match self {
Self::SerialNotify => "Serial Notify",
Self::SerialQuery => "Serial Query",
Self::ResetQuery => "Reset Query",
Self::CacheResponse => "Cache Response",
Self::Ipv4Prefix => "IPv4 Prefix",
Self::Ipv6Prefix => "IPv6 Prefix",
Self::RouterKey => "Router Key",
Self::EndOfData => "End of Data",
Self::CacheReset => "Cache Reset",
Self::ErrorReport => "Error Report",
Self::Aspa => "ASPA",
Self::Unknown(_) => "Unknown",
}
}
}
impl From<u8> for PduType {
fn from(value: u8) -> Self {
match value {
0 => Self::SerialNotify,
1 => Self::SerialQuery,
2 => Self::ResetQuery,
3 => Self::CacheResponse,
4 => Self::Ipv4Prefix,
6 => Self::Ipv6Prefix,
9 => Self::RouterKey,
7 => Self::EndOfData,
8 => Self::CacheReset,
10 => Self::ErrorReport,
11 => Self::Aspa,
x => Self::Unknown(x),
}
}
}
impl fmt::Display for PduType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unknown(v) => write!(f, "{} ({})", self.name(), v),
_ => write!(f, "{} ({})", self.name(), self.code()),
}
}
}
#[derive(Debug, Clone)]
pub struct PduHeader {
pub version: u8,
pub pdu_type_raw: u8,
pub field1: u16,
pub length: u32,
}
impl PduHeader {
pub fn from_bytes(buf: [u8; HEADER_LEN]) -> Self {
Self {
version: buf[0],
pdu_type_raw: buf[1],
field1: u16::from_be_bytes([buf[2], buf[3]]),
length: u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
}
}
pub fn pdu_type(&self) -> PduType {
self.pdu_type_raw.into()
}
pub fn session_id(&self) -> u16 {
self.field1
}
pub fn error_code(&self) -> u16 {
self.field1
}
pub fn flags(&self) -> u8 {
self.field1.to_be_bytes()[0]
}
pub fn zero(&self) -> u8 {
self.field1.to_be_bytes()[1]
}
}
#[derive(Debug, Clone)]
pub struct RawPdu {
pub header: PduHeader,
pub body: Vec<u8>,
}
pub fn flag_meaning(flags: u8) -> &'static str {
if flags & 0x01 == 0x01 {
"announcement"
} else {
"withdrawal"
}
}
pub fn hex_bytes(data: &[u8]) -> String {
if data.is_empty() {
return "<empty>".to_string();
}
let mut out = String::with_capacity(data.len() * 3 - 1);
for (idx, b) in data.iter().enumerate() {
if idx > 0 {
out.push(' ');
}
use std::fmt::Write as _;
let _ = write!(out, "{:02x}", b);
}
out
}

View File

@ -0,0 +1,76 @@
use std::io;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::protocol::{HEADER_LEN, MAX_PDU_LEN, PduHeader, PduType, RawPdu, SERIAL_QUERY_LEN};
pub async fn send_reset_query<S>(stream: &mut S, version: u8) -> io::Result<()>
where
S: AsyncWrite + Unpin,
{
let mut buf = [0u8; HEADER_LEN];
buf[0] = version;
buf[1] = PduType::ResetQuery.code();
buf[2..4].copy_from_slice(&0u16.to_be_bytes());
buf[4..8].copy_from_slice(&(HEADER_LEN as u32).to_be_bytes());
stream.write_all(&buf).await?;
stream.flush().await
}
pub async fn send_serial_query<S>(
stream: &mut S,
version: u8,
session_id: u16,
serial: u32,
) -> io::Result<()>
where
S: AsyncWrite + Unpin,
{
let mut buf = [0u8; SERIAL_QUERY_LEN];
buf[0] = version;
buf[1] = PduType::SerialQuery.code();
buf[2..4].copy_from_slice(&session_id.to_be_bytes());
buf[4..8].copy_from_slice(&(SERIAL_QUERY_LEN as u32).to_be_bytes());
buf[8..12].copy_from_slice(&serial.to_be_bytes());
stream.write_all(&buf).await?;
stream.flush().await
}
pub async fn read_header<S>(stream: &mut S) -> io::Result<PduHeader>
where
S: AsyncRead + Unpin,
{
let mut buf = [0u8; HEADER_LEN];
stream.read_exact(&mut buf).await?;
Ok(PduHeader::from_bytes(buf))
}
pub async fn read_pdu<S>(stream: &mut S) -> io::Result<RawPdu>
where
S: AsyncRead + Unpin,
{
let header = read_header(stream).await?;
if header.length < HEADER_LEN as u32 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid PDU length {} < {}", header.length, HEADER_LEN),
));
}
if header.length > MAX_PDU_LEN {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"PDU length {} exceeds max allowed {}",
header.length, MAX_PDU_LEN
),
));
}
let body_len = header.length as usize - HEADER_LEN;
let mut body = vec![0u8; body_len];
stream.read_exact(&mut body).await?;
Ok(RawPdu { header, body })
}

View File

@ -0,0 +1,163 @@
use std::env;
use std::fs::File;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use serde::Serialize;
use rpki::rtr::payload::Payload;
use rpki::slurm::file::SlurmFile;
use rpki::source::ccr::{
find_latest_ccr_file, load_ccr_snapshot_from_file, snapshot_to_payloads_with_options,
};
#[derive(Debug)]
struct Cli {
ccr_path: PathBuf,
slurm_path: PathBuf,
dump_payloads: bool,
}
#[derive(Debug, Serialize)]
struct Output {
ccr_path: String,
slurm_path: String,
produced_at: Option<String>,
slurm_version: u32,
input_payload_count: usize,
input_vrp_count: usize,
input_vap_count: usize,
output_payload_count: usize,
output_vrp_count: usize,
output_vap_count: usize,
invalid_vrps: Vec<String>,
invalid_vaps: Vec<String>,
sample_output_aspa_customers: Vec<u32>,
payloads: Option<Vec<Payload>>,
}
fn main() -> Result<()> {
let cli = parse_args(env::args().skip(1))?;
let snapshot = load_ccr_snapshot_from_file(&cli.ccr_path)
.with_context(|| format!("failed to load CCR snapshot: {}", cli.ccr_path.display()))?;
let slurm = load_slurm(&cli.slurm_path)?;
let conversion = snapshot_to_payloads_with_options(&snapshot, true)?;
let payloads = slurm.apply(&conversion.payloads);
let (input_vrp_count, input_vap_count) = count_vrps_and_vaps(&conversion.payloads);
let (output_vrp_count, output_vap_count) = count_vrps_and_vaps(&payloads);
let output = Output {
ccr_path: cli.ccr_path.display().to_string(),
slurm_path: cli.slurm_path.display().to_string(),
produced_at: snapshot.produced_at.clone(),
slurm_version: slurm.version().as_u32(),
input_payload_count: conversion.payloads.len(),
input_vrp_count,
input_vap_count,
output_payload_count: payloads.len(),
output_vrp_count,
output_vap_count,
invalid_vrps: conversion.invalid_vrps,
invalid_vaps: conversion.invalid_vaps,
sample_output_aspa_customers: sample_aspa_customers(&payloads, 8),
payloads: cli.dump_payloads.then_some(payloads),
};
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn load_slurm(path: &PathBuf) -> Result<SlurmFile> {
let file = File::open(path)
.with_context(|| format!("failed to open SLURM file: {}", path.display()))?;
SlurmFile::from_reader(file)
.with_context(|| format!("failed to parse SLURM file: {}", path.display()))
}
fn parse_args(args: impl Iterator<Item = String>) -> Result<Cli> {
let mut dump_payloads = false;
let mut positionals = Vec::new();
for arg in args {
match arg.as_str() {
"--dump-payloads" => dump_payloads = true,
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
_ if arg.starts_with('-') => {
return Err(anyhow!("unknown option: {}", arg));
}
_ => positionals.push(arg),
}
}
if positionals.is_empty() {
return Ok(Cli {
ccr_path: find_latest_ccr_file("data")
.context("failed to find latest .ccr in ./data for default run")?,
slurm_path: PathBuf::from("data/example.slurm"),
dump_payloads,
});
}
if positionals.len() != 2 {
print_help();
return Err(anyhow!(
"expected: slurm_apply_client <snapshot.ccr> <policy.slurm>"
));
}
Ok(Cli {
ccr_path: PathBuf::from(&positionals[0]),
slurm_path: PathBuf::from(&positionals[1]),
dump_payloads,
})
}
fn print_help() {
eprintln!(
"Usage: cargo run --bin slurm_apply_client -- [--dump-payloads] <snapshot.ccr> <policy.slurm>"
);
eprintln!();
eprintln!("Reads a CCR snapshot, converts it into payloads, applies SLURM, and prints JSON.");
eprintln!(
"When no arguments are provided, it defaults to the latest .ccr under ./data and ./data/example.slurm."
);
eprintln!("Use --dump-payloads to include the full payload list in the JSON output.");
}
fn count_vrps_and_vaps(payloads: &[Payload]) -> (usize, usize) {
let mut vrps = 0;
let mut vaps = 0;
for payload in payloads {
match payload {
Payload::RouteOrigin(_) => vrps += 1,
Payload::Aspa(_) => vaps += 1,
Payload::RouterKey(_) => {}
}
}
(vrps, vaps)
}
fn sample_aspa_customers(payloads: &[Payload], limit: usize) -> Vec<u32> {
let mut customers = Vec::new();
for payload in payloads {
if let Payload::Aspa(aspa) = payload {
let customer = aspa.customer_asn().into_u32();
if !customers.contains(&customer) {
customers.push(customer);
if customers.len() == limit {
break;
}
}
}
}
customers
}

View File

@ -2,5 +2,5 @@ pub mod crl;
mod rc; mod rc;
mod tal; mod tal;
mod ta; mod ta;
mod resources; pub mod resources;
mod oids; mod oids;

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ASIdentifiers { pub struct ASIdentifiers {
@ -54,7 +55,7 @@ impl ASRange {
} }
} }
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Default, Serialize, Deserialize)]
pub struct Asn(u32); pub struct Asn(u32);
impl Asn { impl Asn {

View File

@ -1,47 +1,157 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IPAddrBlocks { pub struct IPAddrBlocks {
pub ips: Vec<IPAddressFamily> pub ips: Vec<IPAddressFamily>,
} }
// IP Address Family // IP Address Family
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct IPAddressFamily { pub struct IPAddressFamily {
pub address_family: Afi, pub address_family: Afi,
pub ip_address_choice: IPAddressChoice, pub ip_address_choice: IPAddressChoice,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
pub enum Afi { pub enum Afi {
Ipv4, Ipv4,
Ipv6, Ipv6,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IPAddressChoice { pub enum IPAddressChoice {
Inherit, Inherit,
AddressOrRange(Vec<IPAddressOrRange>), AddressOrRange(Vec<IPAddressOrRange>),
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IPAddressOrRange { pub enum IPAddressOrRange {
AddressPrefix(IPAddressPrefix), AddressPrefix(IPAddressPrefix),
AddressRange(IPAddressRange), AddressRange(IPAddressRange),
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct IPAddressPrefix { pub struct IPAddressPrefix {
pub address: IPAddress, pub address: IPAddress,
pub prefix_length: u8, pub prefix_length: u8,
} }
#[derive(Debug, Clone, PartialEq, Eq)] impl IPAddressPrefix {
pub fn new(address: IPAddress, prefix_length: u8) -> Self {
Self {
address,
prefix_length,
}
}
pub fn is_ipv4(&self) -> bool {
self.address.is_ipv4()
}
pub fn is_ipv6(&self) -> bool {
self.address.is_ipv6()
}
pub fn afi(&self) -> Afi {
self.address.afi()
}
pub fn address(&self) -> IPAddress {
self.address
}
pub fn prefix_length(&self) -> u8 {
self.prefix_length
}
}
#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct IPAddressRange { pub struct IPAddressRange {
pub min: IPAddress, pub min: IPAddress,
pub max: IPAddress, pub max: IPAddress,
} }
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] impl IPAddressRange {
pub struct IPAddress(u128); pub fn new(min: IPAddress, max: IPAddress) -> Self {
Self { min, max }
}
pub fn min(&self) -> IPAddress {
self.min
}
pub fn max(&self) -> IPAddress {
self.max
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
pub enum IPAddress {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
impl IPAddress {
pub fn from_ipv4(addr: Ipv4Addr) -> Self {
Self::V4(addr)
}
pub fn from_ipv6(addr: Ipv6Addr) -> Self {
Self::V6(addr)
}
pub fn to_ipv4(self) -> Option<Ipv4Addr> {
match self {
Self::V4(addr) => Some(addr),
Self::V6(_) => None,
}
}
pub fn to_ipv6(self) -> Option<Ipv6Addr> {
match self {
Self::V4(_) => None,
Self::V6(addr) => Some(addr),
}
}
pub fn is_ipv4(self) -> bool {
matches!(self, Self::V4(_))
}
pub fn is_ipv6(self) -> bool {
matches!(self, Self::V6(_))
}
pub fn afi(self) -> Afi {
match self {
Self::V4(_) => Afi::Ipv4,
Self::V6(_) => Afi::Ipv6,
}
}
/// Returns the numeric address value.
///
/// For IPv4, this is the 32-bit address widened to u128.
/// For IPv6, this is the full 128-bit address.
pub fn as_u128(self) -> u128 {
match self {
Self::V4(addr) => u32::from(addr) as u128,
Self::V6(addr) => u128::from(addr),
}
}
pub fn as_v4_u32(self) -> Option<u32> {
match self {
Self::V4(addr) => Some(u32::from(addr)),
Self::V6(_) => None,
}
}
pub fn as_v6_u128(self) -> Option<u128> {
match self {
Self::V4(_) => None,
Self::V6(addr) => Some(u128::from(addr)),
}
}
}

View File

@ -1,3 +1,3 @@
pub(crate) mod ip_resources; pub mod ip_resources;
pub(crate) mod as_resources; pub mod as_resources;
pub mod resource; pub mod resource;

View File

@ -1 +1,4 @@
pub mod data_model; pub mod data_model;
pub mod slurm;
pub mod rtr;
pub mod source;

133
src/main_rtr.rs Normal file
View File

@ -0,0 +1,133 @@
use anyhow::Result;
use chrono::Utc;
use tokio::sync::mpsc;
use tracing::info;
use rpki::rtr::admin::{
AdminState, LogTailConfig, RuntimeConfigHandle, SourceReloadHandle, spawn_admin_config_server,
};
use rpki::rtr::bootstrap::{init_shared_cache, open_store, start_servers};
use rpki::rtr::config::{AppConfig, log_startup_config};
use rpki::rtr::report::{ReportConfiguration, ReportContext};
use rpki::rtr::runtime::spawn_refresh_task;
use rpki::rtr::server::{RtrService, RtrShutdownReason};
use rpki::slurm::admin::SlurmAdmin;
#[tokio::main]
async fn main() -> Result<()> {
let config = AppConfig::from_env()?;
init_tracing(config.timezone);
log_startup_config(&config);
let report_context = ReportContext::new(ReportConfiguration::new(
config.source_refresh_interval.as_secs(),
config.runtime_report_interval.as_secs(),
config.report_history_limit,
config.max_delta,
config.prune_delta_by_snapshot_size,
config.timezone,
(
config.timing.refresh,
config.timing.retry,
config.timing.expire,
),
));
let store = open_store(&config)?;
let shared_cache = init_shared_cache(&config, &store, &report_context)?;
let runtime_config = RuntimeConfigHandle::new(config.runtime_config());
let service = RtrService::with_config(shared_cache.clone(), config.service_config.clone());
let notifier = service.notifier();
let service_stats = service.stats();
let shutdown_handle = service.shutdown_handle();
let (source_reload_tx, source_reload_rx) = mpsc::channel(8);
let source_reload = SourceReloadHandle::new(source_reload_tx);
let (process_shutdown_tx, process_shutdown_rx) = mpsc::channel(1);
let admin_task = config.admin_addr.map(|addr| {
let slurm_admin = config.slurm_dir.as_ref().map(SlurmAdmin::new);
let admin_state = AdminState::new(
runtime_config.clone(),
Some(source_reload.clone()),
Some(shutdown_handle.clone()),
Some(process_shutdown_tx.clone()),
slurm_admin,
LogTailConfig::from_env(),
);
spawn_admin_config_server(addr, config.admin_token.clone(), admin_state)
});
let running = start_servers(&config, &service);
let refresh_task = spawn_refresh_task(
&config,
runtime_config,
source_reload_rx,
shared_cache.clone(),
store.clone(),
notifier,
service_stats,
report_context,
);
let stop_reason = wait_for_shutdown(process_shutdown_rx).await?;
info!("stopping RTR service: reason={}", stop_reason.as_str());
running.shutdown(stop_reason);
running.wait().await;
refresh_task.abort();
let _ = refresh_task.await;
if let Some(admin_task) = admin_task {
admin_task.abort();
let _ = admin_task.await;
}
info!("RTR service stopped");
Ok(())
}
async fn wait_for_shutdown(
mut process_shutdown_rx: mpsc::Receiver<RtrShutdownReason>,
) -> Result<RtrShutdownReason> {
tokio::select! {
signal = tokio::signal::ctrl_c() => {
signal?;
let reason = RtrShutdownReason::from_env();
info!("shutdown signal received: reason={}", reason.as_str());
Ok(reason)
}
reason = process_shutdown_rx.recv() => {
let reason = reason.unwrap_or_else(RtrShutdownReason::from_env);
info!("admin process lifecycle request received: reason={}", reason.as_str());
Ok(reason)
}
}
}
fn init_tracing(timezone: chrono_tz::Tz) {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
struct LocalTimer {
timezone: chrono_tz::Tz,
}
impl tracing_subscriber::fmt::time::FormatTime for LocalTimer {
fn format_time(
&self,
w: &mut tracing_subscriber::fmt::format::Writer<'_>,
) -> std::fmt::Result {
let now = Utc::now().with_timezone(&self.timezone);
write!(w, "{}", now.format("%Y-%m-%d %H:%M:%S%.3f %:z"))
}
}
if let Err(err) = tracing_subscriber::fmt()
.with_timer(LocalTimer { timezone })
.with_env_filter(filter)
.with_target(true)
.with_thread_ids(true)
.with_level(true)
.try_init()
{
eprintln!("failed to initialize tracing subscriber: {err}");
}
}

920
src/rtr/admin.rs Normal file
View File

@ -0,0 +1,920 @@
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot, watch};
use tokio::time::sleep;
use tracing::{info, warn};
use crate::rtr::config::{RuntimeConfig, RuntimeConfigPatch};
use crate::rtr::server::{RtrShutdownHandle, RtrShutdownReason};
use crate::slurm::admin::{
SlurmAdmin, SlurmFileActionRequest, SlurmFileOperationResult, SlurmFileWriteRequest,
parse_reload_query,
};
#[derive(Clone)]
pub struct RuntimeConfigHandle {
current: Arc<RwLock<RuntimeConfig>>,
tx: watch::Sender<RuntimeConfig>,
}
#[derive(Clone)]
pub struct SourceReloadHandle {
tx: mpsc::Sender<SourceReloadCommand>,
}
impl SourceReloadHandle {
pub fn new(tx: mpsc::Sender<SourceReloadCommand>) -> Self {
Self { tx }
}
pub async fn reload(&self, phase: &'static str, force: bool) -> Result<SourceReloadResult> {
let (respond_to, response) = oneshot::channel();
self.tx
.send(SourceReloadCommand {
phase,
force,
respond_to,
})
.await
.map_err(|_| anyhow!("source reload task is not available"))?;
response
.await
.map_err(|_| anyhow!("source reload task dropped the response"))?
.map_err(anyhow::Error::msg)
}
}
pub struct SourceReloadCommand {
pub phase: &'static str,
pub force: bool,
pub respond_to: oneshot::Sender<Result<SourceReloadResult, String>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SourceReloadResult {
pub phase: &'static str,
pub changed: bool,
pub skipped_unchanged: bool,
pub payload_count: Option<usize>,
pub serials: [u32; 3],
}
#[derive(Clone)]
pub struct AdminState {
runtime_config: RuntimeConfigHandle,
source_reload: Option<SourceReloadHandle>,
shutdown: Option<RtrShutdownHandle>,
process_shutdown: Option<mpsc::Sender<RtrShutdownReason>>,
slurm_admin: Option<SlurmAdmin>,
log_tail: LogTailConfig,
}
impl AdminState {
pub fn new(
runtime_config: RuntimeConfigHandle,
source_reload: Option<SourceReloadHandle>,
shutdown: Option<RtrShutdownHandle>,
process_shutdown: Option<mpsc::Sender<RtrShutdownReason>>,
slurm_admin: Option<SlurmAdmin>,
log_tail: LogTailConfig,
) -> Self {
Self {
runtime_config,
source_reload,
shutdown,
process_shutdown,
slurm_admin,
log_tail,
}
}
}
#[derive(Clone)]
pub struct LogTailConfig {
dir: PathBuf,
name: String,
}
impl LogTailConfig {
pub fn from_env() -> Self {
let dir = std::env::var_os("RPKI_RTR_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/app/logs"));
let name = std::env::var("RPKI_RTR_LOG_NAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "rpki-rtr".to_string());
Self { dir, name }
}
fn path_for(&self, stream: LogStream) -> PathBuf {
self.dir
.join(format!("{}.{}.log", self.name, stream.as_str()))
}
}
impl RuntimeConfigHandle {
pub fn new(config: RuntimeConfig) -> Self {
let (tx, _) = watch::channel(config.clone());
Self {
current: Arc::new(RwLock::new(config)),
tx,
}
}
pub fn current(&self) -> RuntimeConfig {
self.current
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
pub fn subscribe(&self) -> watch::Receiver<RuntimeConfig> {
self.tx.subscribe()
}
pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result<RuntimeConfig> {
let next = self.current().apply_patch(patch)?;
{
let mut current = self
.current
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*current = next.clone();
}
let _ = self.tx.send_replace(next.clone());
Ok(next)
}
}
pub fn spawn_admin_config_server(
addr: SocketAddr,
token: Option<String>,
state: AdminState,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
if let Err(err) = run_admin_config_server(addr, token, state).await {
warn!("RTR admin config server exited: {:?}", err);
}
})
}
async fn run_admin_config_server(
addr: SocketAddr,
token: Option<String>,
state: AdminState,
) -> Result<()> {
if token.is_none() && !addr.ip().is_loopback() {
return Err(anyhow!(
"RPKI_RTR_ADMIN_TOKEN is required when admin addr is not loopback: {}",
addr
));
}
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("bind RTR admin config server on {addr}"))?;
info!("RTR admin config server listening on {}", addr);
loop {
let (stream, peer_addr) = listener.accept().await?;
let token = token.clone();
let state = state.clone();
tokio::spawn(async move {
if let Err(err) = handle_admin_connection(stream, peer_addr, token, state).await {
warn!(
"RTR admin config request failed from {}: {:?}",
peer_addr, err
);
}
});
}
}
async fn handle_admin_connection(
mut stream: TcpStream,
peer_addr: SocketAddr,
token: Option<String>,
state: AdminState,
) -> Result<()> {
let mut buffer = vec![0u8; 64 * 1024];
let mut read = 0usize;
let header_end = loop {
if read == buffer.len() {
write_response(&mut stream, 413, "payload too large", "text/plain").await?;
return Ok(());
}
let n = stream.read(&mut buffer[read..]).await?;
if n == 0 {
return Ok(());
}
read += n;
if let Some(pos) = find_header_end(&buffer[..read]) {
break pos;
}
};
let header = std::str::from_utf8(&buffer[..header_end])
.map_err(|err| anyhow!("invalid HTTP header from {}: {}", peer_addr, err))?;
let request = parse_request_header(header)?;
if let Some(token) = token.as_deref() {
let expected = format!("Bearer {token}");
if request.authorization.as_deref() != Some(expected.as_str()) {
write_response(&mut stream, 401, "unauthorized", "text/plain").await?;
return Ok(());
}
}
let content_length = request.content_length.unwrap_or(0);
let max_body_bytes = if request.path.starts_with("/admin/rtr/slurm/") {
state
.slurm_admin
.as_ref()
.map(SlurmAdmin::max_body_bytes)
.unwrap_or(32 * 1024)
} else {
32 * 1024
};
if content_length > max_body_bytes {
write_response(&mut stream, 413, "payload too large", "text/plain").await?;
return Ok(());
}
let body_start = header_end + 4;
let available_body = read.saturating_sub(body_start);
let mut body = Vec::with_capacity(content_length);
body.extend_from_slice(&buffer[body_start..read]);
if available_body < content_length {
let remaining = content_length - available_body;
let mut tail = vec![0u8; remaining];
stream.read_exact(&mut tail).await?;
body.extend_from_slice(&tail);
}
body.truncate(content_length);
route_admin_request(&mut stream, request, body, state, peer_addr).await
}
async fn route_admin_request(
stream: &mut TcpStream,
request: RequestHeader,
body: Vec<u8>,
state: AdminState,
peer_addr: SocketAddr,
) -> Result<()> {
if request.method == "POST" && request.path == "/admin/rtr/config" {
let patch = match serde_json::from_slice::<RuntimeConfigPatch>(&body) {
Ok(patch) => patch,
Err(err) => {
let message = format!("invalid json: {err}");
write_response(stream, 400, &message, "text/plain").await?;
return Ok(());
}
};
match state.runtime_config.apply_patch(patch) {
Ok(config) => {
info!("RTR admin config updated from {}: {:?}", peer_addr, config);
let json = serde_json::to_string_pretty(&AdminConfigResponse {
status: "ok",
config,
})?;
write_response(stream, 200, &json, "application/json").await?;
}
Err(err) => {
let message = format!("invalid config: {err}");
write_response(stream, 400, &message, "text/plain").await?;
}
}
return Ok(());
}
if request.method == "GET" && request.path == "/admin/rtr/health" {
let json = serde_json::to_string_pretty(&AdminHealthResponse {
status: "ok",
config_api: true,
source_reload_api: state.source_reload.is_some(),
lifecycle_api: state.shutdown.is_some(),
process_lifecycle_api: state.process_shutdown.is_some(),
slurm_api: state.slurm_admin.is_some(),
logs_api: true,
})?;
write_response(stream, 200, &json, "application/json").await?;
return Ok(());
}
if request.method == "POST" && request.path == "/admin/rtr/lifecycle/restart" {
return trigger_process_lifecycle(stream, &state, RtrShutdownReason::CacheRestart).await;
}
if request.method == "POST" && request.path == "/admin/rtr/lifecycle/shutdown" {
return trigger_process_lifecycle(stream, &state, RtrShutdownReason::CacheShutdown).await;
}
if request.method == "GET" && request.path == "/admin/rtr/logs/tail" {
return tail_log_stream(stream, request.query.as_deref(), &state.log_tail).await;
}
if request.method == "GET" && request.path == "/admin/rtr/config" {
let json = serde_json::to_string_pretty(&AdminConfigResponse {
status: "ok",
config: state.runtime_config.current(),
})?;
write_response(stream, 200, &json, "application/json").await?;
return Ok(());
}
if request.method == "POST" && request.path == "/admin/rtr/slurm/reload" {
let reload = reload_source(&state, "admin_slurm_reload", true).await;
return write_json_or_error(stream, reload).await;
}
if request.path == "/admin/rtr/slurm/files" && request.method == "GET" {
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
return Ok(());
};
let json = serde_json::to_string_pretty(&SlurmFileListResponse {
status: "ok",
files: slurm_admin.list_files()?,
})?;
write_response(stream, 200, &json, "application/json").await?;
return Ok(());
}
if request.path == "/admin/rtr/slurm/files" && request.method == "POST" {
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
return Ok(());
};
let request_body = parse_json::<SlurmFileWriteRequest>(stream, &body).await?;
let Some(name) = request_body.name.as_deref() else {
write_response(stream, 400, "missing SLURM file name", "text/plain").await?;
return Ok(());
};
let reload = request_body.reload.unwrap_or(false);
let operation = slurm_admin.put_file(name, &request_body.content, "create_or_update");
return apply_slurm_operation(stream, &state, operation, reload).await;
}
let slurm_file_prefix = "/admin/rtr/slurm/files/";
if request.path.starts_with(slurm_file_prefix) {
let rest = request.path[slurm_file_prefix.len()..].to_string();
return route_slurm_file_request(stream, request, body, state, rest).await;
}
write_response(stream, 404, "not found", "text/plain").await?;
Ok(())
}
async fn route_slurm_file_request(
stream: &mut TcpStream,
request: RequestHeader,
body: Vec<u8>,
state: AdminState,
rest: String,
) -> Result<()> {
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
return Ok(());
};
if request.method == "GET" {
let file = match slurm_admin.read_file(&rest) {
Ok(file) => file,
Err(err) => {
let message = format!("invalid SLURM file request: {err}");
write_response(stream, 400, &message, "text/plain").await?;
return Ok(());
}
};
let json = serde_json::to_string_pretty(&SlurmFileContentResponse { status: "ok", file })?;
write_response(stream, 200, &json, "application/json").await?;
return Ok(());
}
if request.method == "PUT" {
let request_body = parse_json::<SlurmFileWriteRequest>(stream, &body).await?;
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
let operation = slurm_admin.put_file(&rest, &request_body.content, "create_or_update");
return apply_slurm_operation(stream, &state, operation, reload).await;
}
if request.method == "DELETE" {
let reload = if body.is_empty() {
parse_reload_query(request.query.as_deref(), None)
} else {
let request_body = parse_json::<SlurmFileActionRequest>(stream, &body).await?;
parse_reload_query(request.query.as_deref(), request_body.reload)
};
let operation = slurm_admin.delete_file(&rest);
return apply_slurm_operation(stream, &state, operation, reload).await;
}
if request.method == "POST" {
if let Some(name) = rest.strip_suffix("/enable") {
let request_body: SlurmFileActionRequest = parse_optional_json(stream, &body).await?;
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
let operation = slurm_admin.enable_file(name);
return apply_slurm_operation(stream, &state, operation, reload).await;
}
if let Some(name) = rest.strip_suffix("/disable") {
let request_body: SlurmFileActionRequest = parse_optional_json(stream, &body).await?;
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
let operation = slurm_admin.disable_file(name);
return apply_slurm_operation(stream, &state, operation, reload).await;
}
}
write_response(stream, 404, "not found", "text/plain").await?;
Ok(())
}
async fn apply_slurm_operation(
stream: &mut TcpStream,
state: &AdminState,
operation: Result<crate::slurm::admin::AppliedSlurmFileOperation>,
reload: bool,
) -> Result<()> {
let operation = match operation {
Ok(operation) => operation,
Err(err) => {
let message = format!("invalid SLURM operation: {err}");
write_response(stream, 400, &message, "text/plain").await?;
return Ok(());
}
};
if !reload {
let json = serde_json::to_string_pretty(&SlurmOperationResponse {
status: "ok",
operation: operation.result,
reload: None,
rollback: None,
})?;
write_response(stream, 200, &json, "application/json").await?;
return Ok(());
}
match reload_source(state, "admin_slurm_changed", true).await {
Ok(result) => {
let json = serde_json::to_string_pretty(&SlurmOperationResponse {
status: "ok",
operation: operation.result,
reload: Some(result),
rollback: None,
})?;
write_response(stream, 200, &json, "application/json").await?;
}
Err(err) => {
let rollback = match operation.rollback() {
Ok(()) => match reload_source(state, "admin_slurm_rollback", true).await {
Ok(result) => Some(RollbackReport {
status: "ok".to_string(),
reload: Some(result),
error: None,
}),
Err(reload_err) => Some(RollbackReport {
status: "reload_failed".to_string(),
reload: None,
error: Some(reload_err.to_string()),
}),
},
Err(rollback_err) => Some(RollbackReport {
status: "failed".to_string(),
reload: None,
error: Some(rollback_err.to_string()),
}),
};
let json = serde_json::to_string_pretty(&SlurmOperationErrorResponse {
status: "reload_failed",
error: err.to_string(),
rollback,
})?;
write_response(stream, 400, &json, "application/json").await?;
}
}
Ok(())
}
async fn reload_source(
state: &AdminState,
phase: &'static str,
force: bool,
) -> Result<SourceReloadResult> {
let Some(source_reload) = state.source_reload.as_ref() else {
return Err(anyhow!("source reload is not available"));
};
source_reload.reload(phase, force).await
}
async fn trigger_process_lifecycle(
stream: &mut TcpStream,
state: &AdminState,
reason: RtrShutdownReason,
) -> Result<()> {
let Some(process_shutdown) = state.process_shutdown.as_ref() else {
write_response(
stream,
400,
"process lifecycle admin is disabled",
"text/plain",
)
.await?;
return Ok(());
};
let action = match reason {
RtrShutdownReason::CacheRestart => "restart",
RtrShutdownReason::CacheShutdown => "shutdown",
RtrShutdownReason::CloseOnly => {
unreachable!("close-only is not a process lifecycle action")
}
};
let json = serde_json::to_string_pretty(&LifecycleResponse {
status: "ok",
action,
reason: reason.as_str(),
process_exit: true,
})?;
write_response(stream, 200, &json, "application/json").await?;
if process_shutdown.send(reason).await.is_err() {
warn!("process lifecycle request accepted but shutdown receiver is gone");
}
Ok(())
}
async fn parse_json<T: for<'de> Deserialize<'de>>(
stream: &mut TcpStream,
body: &[u8],
) -> Result<T> {
match serde_json::from_slice::<T>(body) {
Ok(value) => Ok(value),
Err(err) => {
let message = format!("invalid json: {err}");
write_response(stream, 400, &message, "text/plain").await?;
Err(anyhow!(message))
}
}
}
async fn parse_optional_json<T: for<'de> Deserialize<'de> + Default>(
stream: &mut TcpStream,
body: &[u8],
) -> Result<T> {
if body.is_empty() {
return Ok(T::default());
}
parse_json(stream, body).await
}
async fn write_json_or_error<T: Serialize>(
stream: &mut TcpStream,
result: Result<T>,
) -> Result<()> {
match result {
Ok(value) => {
let json = serde_json::to_string_pretty(&value)?;
write_response(stream, 200, &json, "application/json").await?;
}
Err(err) => {
let message = format!("reload failed: {err}");
write_response(stream, 400, &message, "text/plain").await?;
}
}
Ok(())
}
#[derive(Clone, Copy)]
enum LogStream {
Stdout,
Stderr,
}
impl LogStream {
fn parse(value: Option<&str>) -> Result<Self> {
match value.unwrap_or("stdout") {
"stdout" => Ok(Self::Stdout),
"stderr" => Ok(Self::Stderr),
other => Err(anyhow!(
"invalid stream '{}': expected 'stdout' or 'stderr'",
other
)),
}
}
fn as_str(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::Stderr => "stderr",
}
}
}
struct LogTailRequest {
stream: LogStream,
lines: usize,
follow: bool,
}
impl LogTailRequest {
fn parse(query: Option<&str>) -> Result<Self> {
let stream = LogStream::parse(query_param(query, "stream"))?;
let lines = query_param(query, "lines")
.map(|value| {
value
.parse::<usize>()
.map_err(|err| anyhow!("invalid lines '{}': {}", value, err))
})
.transpose()?
.unwrap_or(200)
.clamp(1, 5000);
let follow = query_param(query, "follow")
.map(|value| parse_bool_query(value, "follow"))
.transpose()?
.unwrap_or(true);
Ok(Self {
stream,
lines,
follow,
})
}
}
async fn tail_log_stream(
stream: &mut TcpStream,
query: Option<&str>,
config: &LogTailConfig,
) -> Result<()> {
let request = match LogTailRequest::parse(query) {
Ok(request) => request,
Err(err) => {
let message = format!("invalid log tail request: {err}");
write_response(stream, 400, &message, "text/plain").await?;
return Ok(());
}
};
let path = config.path_for(request.stream);
if !Path::new(&path).is_file() {
let message = format!("log file not found: {}", path.display());
write_response(stream, 404, &message, "text/plain").await?;
return Ok(());
}
write_chunked_headers(stream, "text/plain; charset=utf-8").await?;
let (tail, mut offset) = read_tail(&path, request.lines).await?;
if !tail.is_empty() {
write_chunk(stream, &tail).await?;
}
if !request.follow {
write_final_chunk(stream).await?;
return Ok(());
}
loop {
sleep(Duration::from_secs(1)).await;
let metadata = match tokio::fs::metadata(&path).await {
Ok(metadata) => metadata,
Err(err) => {
let message = format!("\nlog file unavailable: {err}\n");
let _ = write_chunk(stream, message.as_bytes()).await;
let _ = write_final_chunk(stream).await;
return Ok(());
}
};
let len = metadata.len();
if len < offset {
offset = 0;
write_chunk(stream, b"\nlog file truncated; restarting from beginning\n").await?;
}
if len == offset {
continue;
}
let mut file = tokio::fs::File::open(&path).await?;
file.seek(std::io::SeekFrom::Start(offset)).await?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).await?;
offset = len;
if !buf.is_empty() {
write_chunk(stream, &buf).await?;
}
}
}
async fn read_tail(path: &Path, lines: usize) -> Result<(Vec<u8>, u64)> {
const MAX_INITIAL_TAIL_BYTES: u64 = 1024 * 1024;
let metadata = tokio::fs::metadata(path).await?;
let len = metadata.len();
let start = len.saturating_sub(MAX_INITIAL_TAIL_BYTES);
let mut file = tokio::fs::File::open(path).await?;
file.seek(std::io::SeekFrom::Start(start)).await?;
let mut buf = Vec::new();
file.read_to_end(&mut buf).await?;
Ok((tail_log_lines(buf, lines), len))
}
pub fn tail_log_lines(buf: Vec<u8>, lines: usize) -> Vec<u8> {
if lines == 0 || buf.is_empty() {
return Vec::new();
}
let mut seen = 0usize;
for (idx, byte) in buf.iter().enumerate().rev() {
if *byte == b'\n' {
seen += 1;
if seen > lines {
return buf[idx + 1..].to_vec();
}
}
}
buf
}
fn query_param<'a>(query: Option<&'a str>, key: &str) -> Option<&'a str> {
query?.split('&').find_map(|part| {
let (name, value) = part.split_once('=').unwrap_or((part, ""));
(name == key).then_some(value)
})
}
fn parse_bool_query(value: &str, name: &str) -> Result<bool> {
match value {
"true" | "1" | "yes" | "on" => Ok(true),
"false" | "0" | "no" | "off" => Ok(false),
_ => Err(anyhow!("invalid {} '{}': expected true/false", name, value)),
}
}
async fn write_chunked_headers(stream: &mut TcpStream, content_type: &str) -> Result<()> {
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ntransfer-encoding: chunked\r\ncache-control: no-store\r\nconnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).await?;
Ok(())
}
async fn write_chunk(stream: &mut TcpStream, chunk: &[u8]) -> Result<()> {
if chunk.is_empty() {
return Ok(());
}
let header = format!("{:x}\r\n", chunk.len());
stream.write_all(header.as_bytes()).await?;
stream.write_all(chunk).await?;
stream.write_all(b"\r\n").await?;
stream.flush().await?;
Ok(())
}
async fn write_final_chunk(stream: &mut TcpStream) -> Result<()> {
stream.write_all(b"0\r\n\r\n").await?;
Ok(())
}
#[derive(Serialize)]
struct AdminHealthResponse {
status: &'static str,
config_api: bool,
source_reload_api: bool,
lifecycle_api: bool,
process_lifecycle_api: bool,
slurm_api: bool,
logs_api: bool,
}
#[derive(Serialize)]
struct LifecycleResponse {
status: &'static str,
action: &'static str,
reason: &'static str,
process_exit: bool,
}
#[derive(Serialize)]
struct AdminConfigResponse {
status: &'static str,
config: RuntimeConfig,
}
#[derive(Serialize)]
struct SlurmFileListResponse {
status: &'static str,
files: Vec<crate::slurm::admin::SlurmFileListEntry>,
}
#[derive(Serialize)]
struct SlurmFileContentResponse {
status: &'static str,
file: crate::slurm::admin::SlurmFileContent,
}
#[derive(Serialize)]
struct SlurmOperationResponse {
status: &'static str,
operation: SlurmFileOperationResult,
reload: Option<SourceReloadResult>,
rollback: Option<RollbackReport>,
}
#[derive(Serialize)]
struct SlurmOperationErrorResponse {
status: &'static str,
error: String,
rollback: Option<RollbackReport>,
}
#[derive(Serialize)]
struct RollbackReport {
status: String,
reload: Option<SourceReloadResult>,
error: Option<String>,
}
struct RequestHeader {
method: String,
path: String,
query: Option<String>,
content_length: Option<usize>,
authorization: Option<String>,
}
fn parse_request_header(header: &str) -> Result<RequestHeader> {
let mut lines = header.lines();
let request_line = lines
.next()
.ok_or_else(|| anyhow!("missing HTTP request line"))?;
let mut parts = request_line.split_whitespace();
let method = parts
.next()
.ok_or_else(|| anyhow!("missing HTTP method"))?
.to_string();
let target = parts
.next()
.ok_or_else(|| anyhow!("missing HTTP path"))?
.to_string();
let (path, query) = match target.split_once('?') {
Some((path, query)) => (path.to_string(), Some(query.to_string())),
None => (target, None),
};
let mut content_length = None;
let mut authorization = None;
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
let name = name.trim().to_ascii_lowercase();
let value = value.trim();
match name.as_str() {
"content-length" => {
content_length = Some(
value
.parse::<usize>()
.map_err(|err| anyhow!("invalid content-length '{}': {}", value, err))?,
);
}
"authorization" => authorization = Some(value.to_string()),
_ => {}
}
}
Ok(RequestHeader {
method,
path,
query,
content_length,
authorization,
})
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
async fn write_response(
stream: &mut TcpStream,
status: u16,
body: &str,
content_type: &str,
) -> Result<()> {
let reason = match status {
200 => "OK",
400 => "Bad Request",
401 => "Unauthorized",
404 => "Not Found",
413 => "Payload Too Large",
_ => "Internal Server Error",
};
let response = format!(
"HTTP/1.1 {status} {reason}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await?;
Ok(())
}

115
src/rtr/bootstrap.rs Normal file
View File

@ -0,0 +1,115 @@
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use arc_swap::ArcSwap;
use chrono::Utc;
use tracing::info;
use crate::rtr::cache::{RtrCache, SharedRtrCache};
use crate::rtr::config::AppConfig;
use crate::rtr::report::ReportContext;
use crate::rtr::server::{RtrService, RunningRtrService};
use crate::rtr::store::RtrStore;
use crate::source::pipeline::{PayloadLoadConfig, load_payloads_from_latest_sources_with_report};
pub fn open_store(config: &AppConfig) -> Result<RtrStore> {
info!("opening RTR store: {}", config.db_path);
RtrStore::open(&config.db_path)
}
pub fn init_shared_cache(
config: &AppConfig,
store: &RtrStore,
report_context: &ReportContext,
) -> Result<SharedRtrCache> {
let payload_load_config = PayloadLoadConfig {
ccr_dir: config.ccr_dir.clone(),
slurm_dir: config.slurm_dir.clone(),
};
let source_to_delta_started = Instant::now();
let report_context_for_loader = report_context.clone();
let initial_cache = RtrCache::default().init(
store,
config.max_delta,
config.prune_delta_by_snapshot_size,
config.timing,
|| {
let load = load_payloads_from_latest_sources_with_report(&payload_load_config)?;
info!(
"RTR source-to-delta timing: phase=startup_load_complete, ccr_dir={}, payload_count={}, elapsed_ms={}",
payload_load_config.ccr_dir,
load.payloads.len(),
source_to_delta_started.elapsed().as_millis()
);
report_context_for_loader.record_refresh_success(
Utc::now(),
source_to_delta_started.elapsed().as_millis(),
true,
load.source,
load.quality,
);
Ok(load.payloads)
},
)?;
info!(
"RTR source-to-delta timing: phase=startup_cache_init_complete, ccr_dir={}, serials={:?}, elapsed_ms={}",
payload_load_config.ccr_dir,
initial_cache.serials(),
source_to_delta_started.elapsed().as_millis()
);
let shared_cache: SharedRtrCache = Arc::new(ArcSwap::from_pointee(initial_cache));
let cache = shared_cache.load_full();
info!(
"cache initialized: session_ids={:?}, serials={:?}",
cache.session_ids(),
cache.serials()
);
Ok(shared_cache)
}
pub fn start_servers(config: &AppConfig, service: &RtrService) -> RunningRtrService {
if config.enable_tls && config.enable_ssh {
info!("starting TCP, TLS and SSH RTR servers");
service.spawn_tcp_tls_and_ssh_from_pem_and_openssh(
config.tcp_addr,
config.tls_addr,
config.ssh_addr,
&config.tls_cert_path,
&config.tls_key_path,
&config.tls_client_ca_path,
&config.ssh_host_key_path,
&config.ssh_authorized_keys_path,
&config.ssh_username,
&config.ssh_subsystem_name,
config.ssh_auth_mode,
config.ssh_password.as_deref(),
)
} else if config.enable_tls {
info!("starting TCP and TLS RTR servers");
service.spawn_tcp_and_tls_from_pem(
config.tcp_addr,
config.tls_addr,
&config.tls_cert_path,
&config.tls_key_path,
&config.tls_client_ca_path,
)
} else if config.enable_ssh {
info!("starting TCP and SSH RTR servers");
service.spawn_tcp_and_ssh_from_openssh(
config.tcp_addr,
config.ssh_addr,
&config.ssh_host_key_path,
&config.ssh_authorized_keys_path,
&config.ssh_username,
&config.ssh_subsystem_name,
config.ssh_auth_mode,
config.ssh_password.as_deref(),
)
} else {
info!("starting TCP RTR server");
service.spawn_tcp_only(config.tcp_addr)
}
}

900
src/rtr/cache/core.rs vendored Normal file
View File

@ -0,0 +1,900 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::{BTreeMap, VecDeque};
use std::sync::Arc;
use tracing::{debug, info, warn};
use crate::rtr::payload::{Payload, Timing};
use super::model::{Delta, DualTime, Snapshot};
use super::ordering::{ChangeKey, change_key};
const SERIAL_HALF_RANGE: u32 = 1 << 31;
const VERSION_COUNT: usize = 3;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
pub enum CacheAvailability {
Ready,
NoDataAvailable,
}
#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct SessionIds {
ids: [u16; VERSION_COUNT],
}
impl SessionIds {
pub fn from_array(ids: [u16; VERSION_COUNT]) -> Self {
Self { ids }
}
pub fn random_distinct() -> Self {
let mut ids = [0u16; VERSION_COUNT];
for idx in 0..ids.len() {
loop {
let candidate: u16 = rand::random();
if ids[..idx].iter().all(|existing| *existing != candidate) {
ids[idx] = candidate;
break;
}
}
}
Self { ids }
}
pub fn get(&self, version: u8) -> u16 {
self.ids[version_index(version)]
}
pub fn as_array(&self) -> [u16; VERSION_COUNT] {
self.ids
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionState {
session_id: u16,
serial: u32,
snapshot: Arc<Snapshot>,
#[serde(skip)]
rtr_payloads: Arc<Vec<Payload>>,
#[serde(skip)]
deltas: VecDeque<Arc<Delta>>,
}
impl VersionState {
fn new(session_id: u16, serial: u32, snapshot: Snapshot, max_delta: u8) -> Self {
let rtr_payloads = snapshot.rtr_payloads_for_rtr_arc();
Self {
session_id,
serial,
snapshot: Arc::new(snapshot),
rtr_payloads,
deltas: VecDeque::with_capacity(max_delta as usize),
}
}
}
#[derive(Debug, Clone)]
pub struct RtrCache {
availability: CacheAvailability,
versions: [VersionState; VERSION_COUNT],
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timing: Timing,
last_update_begin: DualTime,
last_update_end: DualTime,
created_at: DualTime,
}
impl Default for RtrCache {
fn default() -> Self {
let now = DualTime::now();
let session_ids = SessionIds::random_distinct();
let versions = std::array::from_fn(|idx| {
VersionState::new(session_ids.as_array()[idx], 0, Snapshot::empty(), 100)
});
Self {
availability: CacheAvailability::Ready,
versions,
max_delta: 100,
prune_delta_by_snapshot_size: false,
timing: Timing::default(),
last_update_begin: now.clone(),
last_update_end: now.clone(),
created_at: now,
}
}
}
pub struct RtrCacheBuilder {
availability: Option<CacheAvailability>,
session_ids: Option<SessionIds>,
max_delta: Option<u8>,
prune_delta_by_snapshot_size: Option<bool>,
timing: Option<Timing>,
serials: Option<[u32; VERSION_COUNT]>,
snapshots: Option<[Snapshot; VERSION_COUNT]>,
deltas: Option<[VecDeque<Arc<Delta>>; VERSION_COUNT]>,
created_at: Option<DualTime>,
}
impl RtrCacheBuilder {
pub fn new() -> Self {
Self {
availability: None,
session_ids: None,
max_delta: None,
prune_delta_by_snapshot_size: None,
timing: None,
serials: None,
snapshots: None,
deltas: None,
created_at: None,
}
}
pub fn session_ids(mut self, v: SessionIds) -> Self {
self.session_ids = Some(v);
self
}
pub fn availability(mut self, v: CacheAvailability) -> Self {
self.availability = Some(v);
self
}
pub fn max_delta(mut self, v: u8) -> Self {
self.max_delta = Some(v);
self
}
pub fn prune_delta_by_snapshot_size(mut self, v: bool) -> Self {
self.prune_delta_by_snapshot_size = Some(v);
self
}
pub fn timing(mut self, v: Timing) -> Self {
self.timing = Some(v);
self
}
pub fn serials(mut self, v: [u32; VERSION_COUNT]) -> Self {
self.serials = Some(v);
self
}
pub fn snapshots(mut self, v: [Snapshot; VERSION_COUNT]) -> Self {
self.snapshots = Some(v);
self
}
pub fn deltas_by_version(mut self, v: [VecDeque<Arc<Delta>>; VERSION_COUNT]) -> Self {
self.deltas = Some(v);
self
}
pub fn created_at(mut self, v: DualTime) -> Self {
self.created_at = Some(v);
self
}
pub fn build(self) -> RtrCache {
let now = DualTime::now();
let max_delta = self.max_delta.unwrap_or(100);
let prune_delta_by_snapshot_size = self.prune_delta_by_snapshot_size.unwrap_or(false);
let timing = self.timing.unwrap_or_default();
let session_ids = self.session_ids.unwrap_or_else(SessionIds::random_distinct);
let serials = self.serials.unwrap_or([0; VERSION_COUNT]);
let snapshots = self
.snapshots
.unwrap_or_else(|| std::array::from_fn(|_| Snapshot::empty()));
let deltas = self.deltas.unwrap_or_else(|| {
std::array::from_fn(|_| VecDeque::with_capacity(max_delta as usize))
});
let versions = std::array::from_fn(|idx| {
let snapshot = snapshots[idx].clone();
VersionState {
session_id: session_ids.as_array()[idx],
serial: serials[idx],
rtr_payloads: snapshot.rtr_payloads_for_rtr_arc(),
snapshot: Arc::new(snapshot),
deltas: deltas[idx].clone(),
}
});
let created_at = self.created_at.unwrap_or_else(|| now.clone());
let availability = self.availability.unwrap_or(CacheAvailability::Ready);
RtrCache {
availability,
versions,
max_delta,
prune_delta_by_snapshot_size,
timing,
last_update_begin: now.clone(),
last_update_end: now,
created_at,
}
}
}
impl RtrCache {
fn set_unavailable(&mut self) {
warn!(
"RTR cache entering NoDataAvailable: serials={:?}",
self.serials()
);
self.availability = CacheAvailability::NoDataAvailable;
for version_state in &mut self.versions {
version_state.snapshot = Arc::new(Snapshot::empty());
version_state.rtr_payloads = version_state.snapshot.rtr_payloads_for_rtr_arc();
version_state.deltas.clear();
}
}
fn reinitialize_from_snapshot(&mut self, source_snapshot: &Snapshot) -> AppliedUpdate {
let old_serials = self.serials();
let old_session_ids = self.session_ids();
let new_session_ids = SessionIds::random_distinct();
self.availability = CacheAvailability::Ready;
for version in 0..VERSION_COUNT {
let v = version as u8;
let state = &mut self.versions[version];
state.session_id = new_session_ids.get(v);
state.serial = 1;
state.snapshot = Arc::new(project_snapshot_for_version(source_snapshot, v));
state.rtr_payloads = state.snapshot.rtr_payloads_for_rtr_arc();
state.deltas.clear();
}
self.last_update_end = DualTime::now();
info!(
"RTR cache reinitialized from usable snapshot: old_serials={:?}, new_serials={:?}, old_session_ids={:?}, new_session_ids={:?}",
old_serials,
self.serials(),
old_session_ids,
new_session_ids
);
self.applied_update_with_clear()
}
fn next_serial(state: &mut VersionState) -> u32 {
let old = state.serial;
state.serial = state.serial.wrapping_add(1);
debug!(
"RTR cache advanced serial for version state: old_serial={}, new_serial={}",
old, state.serial
);
state.serial
}
fn push_delta(
state: &mut VersionState,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
delta: Arc<Delta>,
) {
state.deltas.push_back(delta);
Self::prune_delta_window(state, max_delta, prune_delta_by_snapshot_size);
}
fn prune_delta_window(
state: &mut VersionState,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
) {
let max_keep = usize::from(max_delta.max(1));
while state.deltas.len() > max_keep {
state.deltas.pop_front();
}
let mut dropped_serials = Vec::new();
if prune_delta_by_snapshot_size {
let snapshot_wire_size = estimate_snapshot_payload_wire_size(state.snapshot.as_ref());
let mut cumulative_delta_wire_size =
estimate_delta_window_payload_wire_size(&state.deltas);
while !state.deltas.is_empty() && cumulative_delta_wire_size >= snapshot_wire_size {
if let Some(oldest) = state.deltas.pop_front() {
dropped_serials.push(oldest.serial());
cumulative_delta_wire_size =
estimate_delta_window_payload_wire_size(&state.deltas);
}
}
debug!(
"RTR cache delta-size pruning evaluated: snapshot_wire_size={}, cumulative_delta_wire_size={}, dropped_serials={:?}",
snapshot_wire_size, cumulative_delta_wire_size, dropped_serials
);
}
}
fn delta_window(state: &VersionState) -> Option<(u32, u32)> {
let min = state.deltas.front().map(|d| d.serial());
let max = state.deltas.back().map(|d| d.serial());
match (min, max) {
(Some(min), Some(max)) => Some((min, max)),
_ => None,
}
}
pub(super) fn apply_update(
&mut self,
new_payloads: Vec<Payload>,
) -> Result<Option<AppliedUpdate>> {
let source_snapshot = Snapshot::from_payloads(new_payloads);
self.apply_update_from_snapshot(source_snapshot)
}
pub(super) fn apply_update_from_snapshot(
&mut self,
source_snapshot: Snapshot,
) -> Result<Option<AppliedUpdate>> {
self.last_update_begin = DualTime::now();
info!(
"RTR cache applying update: availability={:?}, current_serials={:?}, incoming_snapshot_sizes=(origins={}, router_keys={}, aspas={})",
self.availability,
self.serials(),
source_snapshot.origins().len(),
source_snapshot.router_keys().len(),
source_snapshot.aspas().len()
);
if source_snapshot.is_empty() {
let changed = self.availability != CacheAvailability::NoDataAvailable
|| self.versions.iter().any(|state| !state.snapshot.is_empty())
|| self.versions.iter().any(|state| !state.deltas.is_empty());
self.set_unavailable();
self.last_update_end = DualTime::now();
if !changed {
return Ok(None);
}
return Ok(Some(self.applied_update_with_clear()));
}
if self.availability == CacheAvailability::NoDataAvailable {
return Ok(Some(self.reinitialize_from_snapshot(&source_snapshot)));
}
let mut changed_any = false;
for version in 0..VERSION_COUNT {
let v = version as u8;
let projected = project_snapshot_for_version(&source_snapshot, v);
let state = &mut self.versions[version];
if state.snapshot.same_content(&projected) {
continue;
}
let (announced, withdrawn) = state.snapshot.diff(&projected);
if announced.is_empty() && withdrawn.is_empty() {
continue;
}
let new_serial = Self::next_serial(state);
let delta = Arc::new(Delta::new(new_serial, announced, withdrawn));
if delta.is_empty() {
continue;
}
state.snapshot = Arc::new(projected);
state.rtr_payloads = state.snapshot.rtr_payloads_for_rtr_arc();
Self::push_delta(
state,
self.max_delta,
self.prune_delta_by_snapshot_size,
delta,
);
changed_any = true;
}
self.last_update_end = DualTime::now();
if !changed_any {
return Ok(None);
}
info!(
"RTR cache applied update: serials={:?}, session_ids={:?}, delta_lengths={:?}",
self.serials(),
self.session_ids(),
self.delta_lengths()
);
Ok(Some(self.applied_update_with_windows()))
}
fn applied_update_with_clear(&self) -> AppliedUpdate {
let snapshots = std::array::from_fn(|idx| self.versions[idx].snapshot.clone());
let serials = std::array::from_fn(|idx| self.versions[idx].serial);
let session_ids = std::array::from_fn(|idx| self.versions[idx].session_id);
AppliedUpdate {
availability: self.availability,
snapshots,
serials,
session_ids,
deltas: [None, None, None],
delta_windows: [None, None, None],
clear_delta_windows: [true, true, true],
}
}
fn applied_update_with_windows(&self) -> AppliedUpdate {
let snapshots = std::array::from_fn(|idx| self.versions[idx].snapshot.clone());
let serials = std::array::from_fn(|idx| self.versions[idx].serial);
let session_ids = std::array::from_fn(|idx| self.versions[idx].session_id);
let deltas = std::array::from_fn(|idx| self.versions[idx].deltas.back().cloned());
let delta_windows = std::array::from_fn(|idx| Self::delta_window(&self.versions[idx]));
AppliedUpdate {
availability: self.availability,
snapshots,
serials,
session_ids,
deltas,
delta_windows,
clear_delta_windows: [false, false, false],
}
}
fn applied_update_with_existing_windows(&self) -> AppliedUpdate {
let snapshots = std::array::from_fn(|idx| self.versions[idx].snapshot.clone());
let serials = std::array::from_fn(|idx| self.versions[idx].serial);
let session_ids = std::array::from_fn(|idx| self.versions[idx].session_id);
let delta_windows = std::array::from_fn(|idx| Self::delta_window(&self.versions[idx]));
let clear_delta_windows = std::array::from_fn(|idx| self.versions[idx].deltas.is_empty());
AppliedUpdate {
availability: self.availability,
snapshots,
serials,
session_ids,
deltas: [None, None, None],
delta_windows,
clear_delta_windows,
}
}
pub fn is_data_available(&self) -> bool {
self.availability == CacheAvailability::Ready
}
pub fn availability(&self) -> CacheAvailability {
self.availability
}
pub fn session_id_for_version(&self, version: u8) -> u16 {
self.versions[version_index(version)].session_id
}
pub fn session_ids(&self) -> SessionIds {
SessionIds::from_array(std::array::from_fn(|idx| self.versions[idx].session_id))
}
pub fn snapshot_for_version(&self, version: u8) -> Snapshot {
self.versions[version_index(version)]
.snapshot
.as_ref()
.clone()
}
pub fn rtr_payloads_for_version(&self, version: u8) -> Arc<Vec<Payload>> {
self.versions[version_index(version)].rtr_payloads.clone()
}
pub fn serial_for_version(&self, version: u8) -> u32 {
self.versions[version_index(version)].serial
}
pub fn serials(&self) -> [u32; VERSION_COUNT] {
std::array::from_fn(|idx| self.versions[idx].serial)
}
pub fn delta_lengths(&self) -> [usize; VERSION_COUNT] {
std::array::from_fn(|idx| self.versions[idx].deltas.len())
}
pub fn timing(&self) -> Timing {
self.timing
}
pub fn max_delta(&self) -> u8 {
self.max_delta
}
pub fn prune_delta_by_snapshot_size(&self) -> bool {
self.prune_delta_by_snapshot_size
}
pub(super) fn apply_runtime_config(
&mut self,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timing: Timing,
) -> Option<AppliedUpdate> {
let old_delta_lengths = self.delta_lengths();
let old_max_delta = self.max_delta;
let old_prune_delta_by_snapshot_size = self.prune_delta_by_snapshot_size;
let old_timing = self.timing;
self.max_delta = max_delta.max(1);
self.prune_delta_by_snapshot_size = prune_delta_by_snapshot_size;
self.timing = timing;
for state in &mut self.versions {
Self::prune_delta_window(state, self.max_delta, self.prune_delta_by_snapshot_size);
}
let config_changed = old_max_delta != self.max_delta
|| old_prune_delta_by_snapshot_size != self.prune_delta_by_snapshot_size
|| old_timing.refresh != self.timing.refresh
|| old_timing.retry != self.timing.retry
|| old_timing.expire != self.timing.expire;
let delta_changed = old_delta_lengths != self.delta_lengths();
if config_changed {
info!(
"RTR cache runtime config updated: old_max_delta={}, new_max_delta={}, old_prune_delta_by_snapshot_size={}, new_prune_delta_by_snapshot_size={}, old_delta_lengths={:?}, new_delta_lengths={:?}, timing=({}, {}, {})",
old_max_delta,
self.max_delta,
old_prune_delta_by_snapshot_size,
self.prune_delta_by_snapshot_size,
old_delta_lengths,
self.delta_lengths(),
self.timing.refresh,
self.timing.retry,
self.timing.expire
);
}
if delta_changed {
Some(self.applied_update_with_existing_windows())
} else {
None
}
}
pub fn last_update_begin(&self) -> DualTime {
self.last_update_begin.clone()
}
pub fn last_update_end(&self) -> DualTime {
self.last_update_end.clone()
}
pub fn created_at(&self) -> DualTime {
self.created_at.clone()
}
pub fn get_deltas_since_for_version(&self, version: u8, client_serial: u32) -> SerialResult {
let state = &self.versions[version_index(version)];
if client_serial == state.serial {
return SerialResult::UpToDate;
}
if matches!(
serial_cmp(client_serial, state.serial),
Some(Ordering::Greater) | None
) {
return SerialResult::ResetRequired;
}
let deltas = match collect_deltas_since(state, client_serial) {
Some(deltas) => deltas,
None => return SerialResult::ResetRequired,
};
if deltas.is_empty() {
return SerialResult::UpToDate;
}
let merged = merge_deltas_minimally(state.serial, &deltas);
if merged.is_empty() {
SerialResult::UpToDate
} else {
SerialResult::Delta(merged)
}
}
}
fn collect_deltas_since(state: &VersionState, client_serial: u32) -> Option<Vec<Arc<Delta>>> {
if state.deltas.is_empty() {
return None;
}
let oldest_serial = state.deltas.front().unwrap().serial();
let min_supported = oldest_serial.wrapping_sub(1);
if matches!(
serial_cmp(client_serial, min_supported),
Some(Ordering::Less) | None
) {
return None;
}
let mut result = Vec::new();
for delta in &state.deltas {
if serial_gt(delta.serial(), client_serial) {
result.push(delta.clone());
}
}
if let Some(first) = result.first() {
if first.serial() != client_serial.wrapping_add(1) {
return None;
}
}
Some(result)
}
fn merge_deltas_minimally(current_serial: u32, deltas: &[Arc<Delta>]) -> Delta {
let mut states = BTreeMap::<ChangeKey, LogicalStateRef<'_>>::new();
for delta in deltas {
for payload in delta.withdrawn() {
let key = change_key(payload);
let state = states.entry(key).or_insert_with(LogicalStateRef::new);
if state.before.is_none() && state.after.is_none() {
state.before = Some(payload);
}
state.after = None;
}
for payload in delta.announced() {
let key = change_key(payload);
let state = states.entry(key).or_insert_with(LogicalStateRef::new);
state.after = Some(payload);
}
}
let mut announced = Vec::new();
let mut withdrawn = Vec::new();
for (_key, state) in states {
match (state.before, state.after) {
(None, None) => {}
(None, Some(new_payload)) => announced.push(new_payload.clone()),
(Some(old_payload), None) => withdrawn.push(old_payload.clone()),
(Some(old_payload), Some(new_payload)) => {
if old_payload != new_payload {
if matches!(old_payload, Payload::Aspa(_))
&& matches!(new_payload, Payload::Aspa(_))
{
announced.push(new_payload.clone());
} else {
withdrawn.push(old_payload.clone());
announced.push(new_payload.clone());
}
}
}
}
}
Delta::new(current_serial, announced, withdrawn)
}
fn project_snapshot_for_version(snapshot: &Snapshot, version: u8) -> Snapshot {
snapshot.project_for_version(version)
}
fn estimate_snapshot_payload_wire_size(snapshot: &Snapshot) -> usize {
snapshot
.rtr_payloads_for_rtr_arc()
.iter()
.map(|payload| estimate_payload_wire_size(payload, true))
.sum()
}
fn estimate_delta_window_payload_wire_size(deltas: &VecDeque<Arc<Delta>>) -> usize {
deltas
.iter()
.map(|delta| estimate_delta_wire_size(delta))
.sum()
}
fn estimate_delta_wire_size(delta: &Delta) -> usize {
delta
.payload_updates_for_rtr()
.iter()
.map(|(announce, payload)| estimate_payload_wire_size(payload, *announce))
.sum()
}
fn estimate_payload_wire_size(payload: &Payload, announce: bool) -> usize {
match payload {
Payload::RouteOrigin(origin) => match origin.prefix().address {
crate::data_model::resources::ip_resources::IPAddress::V4(_) => 20,
crate::data_model::resources::ip_resources::IPAddress::V6(_) => 32,
},
Payload::RouterKey(key) => 8 + 20 + 4 + key.spki().len(),
Payload::Aspa(aspa) => {
let providers = if announce {
aspa.provider_asns().len()
} else {
0
};
8 + 4 + providers * 4
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct LogicalStateRef<'a> {
before: Option<&'a Payload>,
after: Option<&'a Payload>,
}
impl<'a> LogicalStateRef<'a> {
fn new() -> Self {
Self {
before: None,
after: None,
}
}
}
pub enum SerialResult {
UpToDate,
Delta(Delta),
ResetRequired,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct CacheMemoryStats {
pub serials: [u32; VERSION_COUNT],
pub snapshot_payload_counts: [usize; VERSION_COUNT],
pub delta_lengths: [usize; VERSION_COUNT],
pub delta_payload_counts: [usize; VERSION_COUNT],
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct PayloadCounts {
pub total: usize,
pub vrp: usize,
pub router_key: usize,
pub aspa: usize,
}
impl PayloadCounts {
fn add_payload(&mut self, payload: &Payload) {
self.total += 1;
match payload {
Payload::RouteOrigin(_) => self.vrp += 1,
Payload::RouterKey(_) => self.router_key += 1,
Payload::Aspa(_) => self.aspa += 1,
}
}
fn add_counts(&mut self, other: Self) {
self.total += other.total;
self.vrp += other.vrp;
self.router_key += other.router_key;
self.aspa += other.aspa;
}
fn from_payloads(payloads: &[Payload]) -> Self {
let mut counts = Self::default();
for payload in payloads {
counts.add_payload(payload);
}
counts
}
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct DeltaReportStats {
pub serial: u32,
pub announced: PayloadCounts,
pub withdrawn: PayloadCounts,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct DeltaWindowReportStats {
pub length: usize,
pub oldest_serial: Option<u32>,
pub newest_serial: Option<u32>,
pub announced: PayloadCounts,
pub withdrawn: PayloadCounts,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct VersionReportStats {
pub version: u8,
pub session_id: u16,
pub serial: u32,
pub snapshot: PayloadCounts,
pub latest_delta: Option<DeltaReportStats>,
pub delta_window: DeltaWindowReportStats,
}
impl RtrCache {
pub fn memory_stats(&self) -> CacheMemoryStats {
let snapshot_payload_counts =
std::array::from_fn(|idx| self.versions[idx].rtr_payloads.len());
let delta_lengths = std::array::from_fn(|idx| self.versions[idx].deltas.len());
let delta_payload_counts = std::array::from_fn(|idx| {
self.versions[idx]
.deltas
.iter()
.map(|delta| delta.announced().len() + delta.withdrawn().len())
.sum()
});
CacheMemoryStats {
serials: self.serials(),
snapshot_payload_counts,
delta_lengths,
delta_payload_counts,
}
}
pub fn version_report_stats(&self) -> [VersionReportStats; VERSION_COUNT] {
std::array::from_fn(|idx| {
let state = &self.versions[idx];
let snapshot = PayloadCounts {
total: state.rtr_payloads.len(),
vrp: state.snapshot.origins().len(),
router_key: state.snapshot.router_keys().len(),
aspa: state.snapshot.aspas().len(),
};
let latest_delta = state.deltas.back().map(|delta| DeltaReportStats {
serial: delta.serial(),
announced: PayloadCounts::from_payloads(delta.announced()),
withdrawn: PayloadCounts::from_payloads(delta.withdrawn()),
});
let mut window_announced = PayloadCounts::default();
let mut window_withdrawn = PayloadCounts::default();
for delta in &state.deltas {
window_announced.add_counts(PayloadCounts::from_payloads(delta.announced()));
window_withdrawn.add_counts(PayloadCounts::from_payloads(delta.withdrawn()));
}
VersionReportStats {
version: idx as u8,
session_id: state.session_id,
serial: state.serial,
snapshot,
latest_delta,
delta_window: DeltaWindowReportStats {
length: state.deltas.len(),
oldest_serial: state.deltas.front().map(|delta| delta.serial()),
newest_serial: state.deltas.back().map(|delta| delta.serial()),
announced: window_announced,
withdrawn: window_withdrawn,
},
}
})
}
}
#[derive(Clone)]
pub(super) struct AppliedUpdate {
pub(super) availability: CacheAvailability,
pub(super) snapshots: [Arc<Snapshot>; VERSION_COUNT],
pub(super) serials: [u32; VERSION_COUNT],
pub(super) session_ids: [u16; VERSION_COUNT],
pub(super) deltas: [Option<Arc<Delta>>; VERSION_COUNT],
pub(super) delta_windows: [Option<(u32, u32)>; VERSION_COUNT],
pub(super) clear_delta_windows: [bool; VERSION_COUNT],
}
fn serial_cmp(a: u32, b: u32) -> Option<Ordering> {
if a == b {
return Some(Ordering::Equal);
}
let diff = a.wrapping_sub(b);
if diff == SERIAL_HALF_RANGE {
None
} else if diff < SERIAL_HALF_RANGE {
Some(Ordering::Greater)
} else {
Some(Ordering::Less)
}
}
fn serial_gt(a: u32, b: u32) -> bool {
matches!(serial_cmp(a, b), Some(Ordering::Greater))
}
fn version_index(version: u8) -> usize {
match version {
0..=2 => version as usize,
_ => panic!("unsupported RTR protocol version: {}", version),
}
}

20
src/rtr/cache/mod.rs vendored Normal file
View File

@ -0,0 +1,20 @@
mod core;
mod model;
mod ordering;
mod store;
pub use core::{
CacheAvailability, CacheMemoryStats, DeltaReportStats, DeltaWindowReportStats, PayloadCounts,
RtrCache, RtrCacheBuilder, SerialResult, SessionIds, VersionReportStats,
};
pub use model::{Delta, DualTime, Snapshot};
pub use ordering::{
OrderingViolation, validate_payload_update_refs_for_rtr, validate_payload_updates_for_rtr,
validate_payloads_for_rtr,
};
use std::sync::Arc;
use arc_swap::ArcSwap;
pub type SharedRtrCache = Arc<ArcSwap<RtrCache>>;

523
src/rtr/cache/model.rs vendored Normal file
View File

@ -0,0 +1,523 @@
use std::collections::BTreeMap;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey};
use super::ordering::{compare_payload_update_for_rtr, sort_payloads_for_rtr};
#[derive(Debug, Clone)]
pub struct DualTime {
instant: Instant,
utc: DateTime<Utc>,
}
impl DualTime {
pub fn now() -> Self {
Self {
instant: Instant::now(),
utc: Utc::now(),
}
}
pub fn utc(&self) -> DateTime<Utc> {
self.utc
}
pub fn elapsed(&self) -> Duration {
self.instant.elapsed()
}
pub fn is_expired(&self, duration: Duration) -> bool {
self.elapsed() >= duration
}
pub fn reset(&mut self) {
self.instant = Instant::now();
self.utc = Utc::now();
}
}
impl Serialize for DualTime {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.utc.timestamp_millis().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for DualTime {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let millis = i64::deserialize(deserializer)?;
let utc = DateTime::from_timestamp_millis(millis)
.ok_or_else(|| serde::de::Error::custom("invalid timestamp"))?;
Ok(Self {
instant: Instant::now(),
utc,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
origins: Arc<Vec<RouteOrigin>>,
router_keys: Arc<Vec<RouterKey>>,
aspas: Arc<Vec<Aspa>>,
created_at: DualTime,
origins_hash: [u8; 32],
router_keys_hash: [u8; 32],
aspas_hash: [u8; 32],
snapshot_hash: [u8; 32],
#[serde(skip)]
rtr_payloads_cache: OnceLock<Arc<Vec<Payload>>>,
}
impl Snapshot {
pub fn new(origins: Vec<RouteOrigin>, router_keys: Vec<RouterKey>, aspas: Vec<Aspa>) -> Self {
Self::from_shared_parts(
Arc::new(sorted_dedup(origins)),
Arc::new(sorted_dedup(router_keys)),
Arc::new(normalize_aspas(aspas)),
)
}
fn from_shared_parts(
origins: Arc<Vec<RouteOrigin>>,
router_keys: Arc<Vec<RouterKey>>,
aspas: Arc<Vec<Aspa>>,
) -> Self {
let mut snapshot = Snapshot {
origins,
router_keys,
aspas,
created_at: DualTime::now(),
origins_hash: [0u8; 32],
router_keys_hash: [0u8; 32],
aspas_hash: [0u8; 32],
snapshot_hash: [0u8; 32],
rtr_payloads_cache: OnceLock::new(),
};
snapshot.recompute_hashes();
let cached = build_snapshot_payloads_for_rtr(&snapshot);
let _ = snapshot.rtr_payloads_cache.set(Arc::new(cached));
snapshot
}
pub fn empty() -> Self {
Self::new(Vec::new(), Vec::new(), Vec::new())
}
pub fn from_payloads(payloads: Vec<Payload>) -> Self {
let mut builder = SnapshotBuilder::new();
builder.extend(payloads);
builder.finish()
}
pub fn builder() -> SnapshotBuilder {
SnapshotBuilder::new()
}
pub fn project_for_version(&self, version: u8) -> Self {
let router_keys = if version >= 1 {
self.router_keys.clone()
} else {
Arc::new(Vec::new())
};
let aspas = if version >= 2 {
self.aspas.clone()
} else {
Arc::new(Vec::new())
};
Self::from_shared_parts(self.origins.clone(), router_keys, aspas)
}
pub fn recompute_hashes(&mut self) {
self.origins_hash = self.compute_origins_hash();
self.router_keys_hash = self.compute_router_keys_hash();
self.aspas_hash = self.compute_aspas_hash();
self.snapshot_hash = self.compute_snapshot_hash();
}
fn compute_origins_hash(&self) -> [u8; 32] {
Self::hash_ordered_iter(self.origins.iter())
}
fn compute_router_keys_hash(&self) -> [u8; 32] {
Self::hash_ordered_iter(self.router_keys.iter())
}
fn compute_aspas_hash(&self) -> [u8; 32] {
Self::hash_ordered_iter(self.aspas.iter())
}
fn compute_snapshot_hash(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(b"snapshot:v1");
hasher.update(self.origins_hash);
hasher.update(self.router_keys_hash);
hasher.update(self.aspas_hash);
hasher.finalize().into()
}
fn hash_ordered_iter<'a, T, I>(iter: I) -> [u8; 32]
where
T: Serialize + 'a,
I: IntoIterator<Item = &'a T>,
{
let mut hasher = Sha256::new();
hasher.update(b"set:v1");
for item in iter {
let encoded =
serde_json::to_vec(item).expect("serialize snapshot item for hashing failed");
let len = (encoded.len() as u32).to_be_bytes();
hasher.update(len);
hasher.update(encoded);
}
hasher.finalize().into()
}
pub fn diff(&self, new_snapshot: &Snapshot) -> (Vec<Payload>, Vec<Payload>) {
let mut announced = Vec::new();
let mut withdrawn = Vec::new();
if !self.same_origins(new_snapshot) {
diff_sorted(
self.origins.as_slice(),
new_snapshot.origins.as_slice(),
&mut announced,
&mut withdrawn,
Payload::RouteOrigin,
);
}
if !self.same_router_keys(new_snapshot) {
diff_sorted(
self.router_keys.as_slice(),
new_snapshot.router_keys.as_slice(),
&mut announced,
&mut withdrawn,
Payload::RouterKey,
);
}
if !self.same_aspas(new_snapshot) {
diff_aspas(
&self.aspas,
&new_snapshot.aspas,
&mut announced,
&mut withdrawn,
);
}
(announced, withdrawn)
}
pub fn created_at(&self) -> DualTime {
self.created_at.clone()
}
pub fn payloads(&self) -> Vec<Payload> {
let mut v =
Vec::with_capacity(self.origins.len() + self.router_keys.len() + self.aspas.len());
v.extend(self.origins.iter().cloned().map(Payload::RouteOrigin));
v.extend(self.router_keys.iter().cloned().map(Payload::RouterKey));
v.extend(self.aspas.iter().cloned().map(Payload::Aspa));
v
}
pub fn payloads_for_rtr(&self) -> Vec<Payload> {
self.rtr_payloads_for_rtr_arc().as_ref().clone()
}
pub fn rtr_payloads_for_rtr_arc(&self) -> Arc<Vec<Payload>> {
self.rtr_payloads_cache
.get_or_init(|| Arc::new(build_snapshot_payloads_for_rtr(self)))
.clone()
}
pub fn origins_hash(&self) -> [u8; 32] {
self.origins_hash
}
pub fn router_keys_hash(&self) -> [u8; 32] {
self.router_keys_hash
}
pub fn aspas_hash(&self) -> [u8; 32] {
self.aspas_hash
}
pub fn snapshot_hash(&self) -> [u8; 32] {
self.snapshot_hash
}
pub fn same_origins(&self, other: &Self) -> bool {
self.origins_hash == other.origins_hash
}
pub fn same_router_keys(&self, other: &Self) -> bool {
self.router_keys_hash == other.router_keys_hash
}
pub fn same_aspas(&self, other: &Self) -> bool {
self.aspas_hash == other.aspas_hash
}
pub fn same_content(&self, other: &Self) -> bool {
self.snapshot_hash == other.snapshot_hash
}
pub fn origins(&self) -> &[RouteOrigin] {
self.origins.as_ref()
}
pub fn router_keys(&self) -> &[RouterKey] {
self.router_keys.as_ref()
}
pub fn aspas(&self) -> &[Aspa] {
self.aspas.as_ref()
}
pub fn is_empty(&self) -> bool {
self.origins.is_empty() && self.router_keys.is_empty() && self.aspas.is_empty()
}
}
#[derive(Debug, Default)]
pub struct SnapshotBuilder {
origins: Vec<RouteOrigin>,
router_keys: Vec<RouterKey>,
aspas: Vec<Aspa>,
}
impl SnapshotBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, payload: Payload) {
match payload {
Payload::RouteOrigin(o) => self.origins.push(o),
Payload::RouterKey(k) => self.router_keys.push(k),
Payload::Aspa(a) => self.aspas.push(a),
}
}
pub fn extend<I>(&mut self, payloads: I)
where
I: IntoIterator<Item = Payload>,
{
for payload in payloads {
self.push(payload);
}
}
pub fn finish(self) -> Snapshot {
Snapshot::new(self.origins, self.router_keys, normalize_aspas(self.aspas))
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Delta {
serial: u32,
announced: Vec<Payload>,
withdrawn: Vec<Payload>,
created_at: DualTime,
}
impl Delta {
pub fn new(serial: u32, mut announced: Vec<Payload>, mut withdrawn: Vec<Payload>) -> Self {
dedup_payloads(&mut announced);
dedup_payloads(&mut withdrawn);
sort_payloads_for_rtr(&mut announced, true);
sort_payloads_for_rtr(&mut withdrawn, false);
Delta {
serial,
announced,
withdrawn,
created_at: DualTime::now(),
}
}
pub fn serial(&self) -> u32 {
self.serial
}
pub fn announced(&self) -> &[Payload] {
&self.announced
}
pub fn withdrawn(&self) -> &[Payload] {
&self.withdrawn
}
pub fn created_at(&self) -> DualTime {
self.created_at.clone()
}
pub fn is_empty(&self) -> bool {
self.announced.is_empty() && self.withdrawn.is_empty()
}
pub fn payload_updates_for_rtr(&self) -> Vec<(bool, &Payload)> {
build_payload_updates_for_rtr_refs(&self.announced, &self.withdrawn)
}
}
fn build_payload_updates_for_rtr_refs<'a>(
announced: &'a [Payload],
withdrawn: &'a [Payload],
) -> Vec<(bool, &'a Payload)> {
let mut updates = Vec::with_capacity(announced.len() + withdrawn.len());
updates.extend(announced.iter().map(|p| (true, p)));
updates.extend(withdrawn.iter().map(|p| (false, p)));
updates.sort_by(|(a_upd, a_payload), (b_upd, b_payload)| {
compare_payload_update_for_rtr(a_payload, *a_upd, b_payload, *b_upd)
});
updates
}
fn build_payload_updates_for_rtr(
announced: &[Payload],
withdrawn: &[Payload],
) -> Vec<(bool, Payload)> {
let mut updates = Vec::with_capacity(announced.len() + withdrawn.len());
updates.extend(announced.iter().cloned().map(|p| (true, p)));
updates.extend(withdrawn.iter().cloned().map(|p| (false, p)));
updates.sort_by(|(a_upd, a_payload), (b_upd, b_payload)| {
compare_payload_update_for_rtr(a_payload, *a_upd, b_payload, *b_upd)
});
updates
}
fn build_snapshot_payloads_for_rtr(snapshot: &Snapshot) -> Vec<Payload> {
let mut payloads = snapshot.payloads();
sort_payloads_for_rtr(&mut payloads, true);
payloads
}
fn dedup_payloads(payloads: &mut Vec<Payload>) {
payloads.sort();
payloads.dedup();
}
fn normalize_aspas<I>(aspas: I) -> Vec<Aspa>
where
I: IntoIterator<Item = Aspa>,
{
let mut by_customer = BTreeMap::<u32, Vec<_>>::new();
for aspa in aspas {
let providers = by_customer
.entry(aspa.customer_asn().into_u32())
.or_default();
providers.extend(aspa.provider_asns().iter().copied());
}
let mut normalized = by_customer
.into_iter()
.map(|(customer_asn, providers)| Aspa::new(customer_asn.into(), providers))
.collect::<Vec<_>>();
normalized.sort();
normalized
}
fn diff_aspas(
current: &[Aspa],
next: &[Aspa],
announced: &mut Vec<Payload>,
withdrawn: &mut Vec<Payload>,
) {
let current = current
.iter()
.map(|aspa| (aspa.customer_asn().into_u32(), aspa))
.collect::<BTreeMap<_, _>>();
let next = next
.iter()
.map(|aspa| (aspa.customer_asn().into_u32(), aspa))
.collect::<BTreeMap<_, _>>();
let customers = current
.keys()
.chain(next.keys())
.copied()
.collect::<std::collections::BTreeSet<_>>();
for customer in customers {
match (current.get(&customer), next.get(&customer)) {
(None, Some(new_aspa)) => announced.push(Payload::Aspa((*new_aspa).clone())),
(Some(old_aspa), None) => withdrawn.push(Payload::Aspa((*old_aspa).clone())),
(Some(old_aspa), Some(new_aspa)) if old_aspa != new_aspa => {
announced.push(Payload::Aspa((*new_aspa).clone()));
}
_ => {}
}
}
}
fn sorted_dedup<T: Ord>(mut items: Vec<T>) -> Vec<T> {
items.sort();
items.dedup();
items
}
fn diff_sorted<T, F>(
current: &[T],
next: &[T],
announced: &mut Vec<Payload>,
withdrawn: &mut Vec<Payload>,
wrap: F,
) where
T: Ord + Clone,
F: Fn(T) -> Payload,
{
let mut i = 0usize;
let mut j = 0usize;
while i < current.len() && j < next.len() {
match current[i].cmp(&next[j]) {
std::cmp::Ordering::Less => {
withdrawn.push(wrap(current[i].clone()));
i += 1;
}
std::cmp::Ordering::Greater => {
announced.push(wrap(next[j].clone()));
j += 1;
}
std::cmp::Ordering::Equal => {
i += 1;
j += 1;
}
}
}
while i < current.len() {
withdrawn.push(wrap(current[i].clone()));
i += 1;
}
while j < next.len() {
announced.push(wrap(next[j].clone()));
j += 1;
}
}

343
src/rtr/cache/ordering.rs vendored Normal file
View File

@ -0,0 +1,343 @@
use std::cmp::Ordering;
use std::fmt;
use crate::data_model::resources::ip_resources::IPAddress;
use crate::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski};
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct OrderingViolation {
index: usize,
left: String,
right: String,
}
impl OrderingViolation {
fn new(index: usize, left: &Payload, right: &Payload) -> Self {
Self {
index,
left: payload_brief(left),
right: payload_brief(right),
}
}
fn new_update(index: usize, left: (bool, &Payload), right: (bool, &Payload)) -> Self {
Self {
index,
left: payload_update_brief(left.0, left.1),
right: payload_update_brief(right.0, right.1),
}
}
}
impl fmt::Display for OrderingViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"RTR payload ordering violation at positions {} and {}: {} should not appear before {}",
self.index,
self.index + 1,
self.left,
self.right
)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub(crate) enum RouterKeyKey {
Key { ski: Ski, spki: Vec<u8>, asn: u32 },
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub(crate) enum ChangeKey {
RouteOrigin(RouteOriginKey),
RouterKey(RouterKeyKey),
AspaCustomer(u32),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
enum PayloadPduType {
Ipv4Prefix = 4,
Ipv6Prefix = 6,
RouterKey = 9,
Aspa = 11,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
pub(crate) enum RouteOriginKey {
V4 {
addr: u32,
plen: u8,
mlen: u8,
asn: u32,
},
V6 {
addr: u128,
plen: u8,
mlen: u8,
asn: u32,
},
}
pub(crate) fn change_key(payload: &Payload) -> ChangeKey {
match payload {
Payload::RouteOrigin(ro) => ChangeKey::RouteOrigin(route_origin_key(ro)),
Payload::RouterKey(rk) => ChangeKey::RouterKey(router_key_key(rk)),
Payload::Aspa(aspa) => ChangeKey::AspaCustomer(aspa.customer_asn().into_u32()),
}
}
pub(crate) fn compare_payload_update_for_rtr(
a_payload: &Payload,
a_announce: bool,
b_payload: &Payload,
b_announce: bool,
) -> Ordering {
let type_a = payload_pdu_type(a_payload);
let type_b = payload_pdu_type(b_payload);
match type_a.cmp(&type_b) {
Ordering::Equal => {}
other => return other,
}
match b_announce.cmp(&a_announce) {
Ordering::Equal => {}
other => return other,
}
match (a_payload, b_payload) {
(Payload::RouteOrigin(a), Payload::RouteOrigin(b)) => {
compare_route_origin_for_rtr(a, b, a_announce)
}
(Payload::RouterKey(a), Payload::RouterKey(b)) => compare_router_key_for_rtr(a, b),
(Payload::Aspa(a), Payload::Aspa(b)) => compare_aspa_for_rtr(a, b),
_ => Ordering::Equal,
}
}
pub(crate) fn sort_payloads_for_rtr(payloads: &mut [Payload], announce: bool) {
payloads.sort_by(|a, b| compare_payload_for_rtr(a, b, announce));
}
pub fn validate_payloads_for_rtr(
payloads: &[Payload],
announce: bool,
) -> Result<(), OrderingViolation> {
for (index, pair) in payloads.windows(2).enumerate() {
if compare_payload_for_rtr(&pair[0], &pair[1], announce) == Ordering::Greater {
return Err(OrderingViolation::new(index, &pair[0], &pair[1]));
}
}
Ok(())
}
pub fn validate_payload_updates_for_rtr(
updates: &[(bool, Payload)],
) -> Result<(), OrderingViolation> {
for (index, pair) in updates.windows(2).enumerate() {
if compare_payload_update_for_rtr(&pair[0].1, pair[0].0, &pair[1].1, pair[1].0)
== Ordering::Greater
{
return Err(OrderingViolation::new_update(
index,
(pair[0].0, &pair[0].1),
(pair[1].0, &pair[1].1),
));
}
}
Ok(())
}
pub fn validate_payload_update_refs_for_rtr(
updates: &[(bool, &Payload)],
) -> Result<(), OrderingViolation> {
for (index, pair) in updates.windows(2).enumerate() {
if compare_payload_update_for_rtr(pair[0].1, pair[0].0, pair[1].1, pair[1].0)
== Ordering::Greater
{
return Err(OrderingViolation::new_update(
index,
(pair[0].0, pair[0].1),
(pair[1].0, pair[1].1),
));
}
}
Ok(())
}
fn router_key_key(rk: &RouterKey) -> RouterKeyKey {
RouterKeyKey::Key {
ski: rk.ski(),
spki: rk.spki().as_ref().to_vec(),
asn: rk.asn().into_u32(),
}
}
fn compare_payload_for_rtr(a: &Payload, b: &Payload, announce: bool) -> Ordering {
let type_a = payload_pdu_type(a);
let type_b = payload_pdu_type(b);
match type_a.cmp(&type_b) {
Ordering::Equal => {}
other => return other,
}
match (a, b) {
(Payload::RouteOrigin(a), Payload::RouteOrigin(b)) => {
compare_route_origin_for_rtr(a, b, announce)
}
(Payload::RouterKey(a), Payload::RouterKey(b)) => compare_router_key_for_rtr(a, b),
(Payload::Aspa(a), Payload::Aspa(b)) => compare_aspa_for_rtr(a, b),
_ => Ordering::Equal,
}
}
fn payload_pdu_type(payload: &Payload) -> PayloadPduType {
match payload {
Payload::RouteOrigin(ro) => {
if route_origin_is_ipv4(ro) {
PayloadPduType::Ipv4Prefix
} else {
PayloadPduType::Ipv6Prefix
}
}
Payload::RouterKey(_) => PayloadPduType::RouterKey,
Payload::Aspa(_) => PayloadPduType::Aspa,
}
}
fn route_origin_is_ipv4(ro: &RouteOrigin) -> bool {
ro.prefix().address.is_ipv4()
}
fn route_origin_key(ro: &RouteOrigin) -> RouteOriginKey {
let prefix = ro.prefix();
let plen = prefix.prefix_length;
let mlen = ro.max_length();
let asn = ro.asn().into_u32();
match prefix.address {
IPAddress::V4(addr) => RouteOriginKey::V4 {
addr: u32::from(addr),
plen,
mlen,
asn,
},
IPAddress::V6(addr) => RouteOriginKey::V6 {
addr: u128::from(addr),
plen,
mlen,
asn,
},
}
}
fn compare_route_origin_for_rtr(a: &RouteOrigin, b: &RouteOrigin, announce: bool) -> Ordering {
match (route_origin_key(a), route_origin_key(b)) {
(
RouteOriginKey::V4 {
addr: addr_a,
plen: plen_a,
mlen: mlen_a,
asn: asn_a,
},
RouteOriginKey::V4 {
addr: addr_b,
plen: plen_b,
mlen: mlen_b,
asn: asn_b,
},
) => {
if announce {
addr_b
.cmp(&addr_a)
.then_with(|| mlen_b.cmp(&mlen_a))
.then_with(|| plen_b.cmp(&plen_a))
.then_with(|| asn_b.cmp(&asn_a))
} else {
addr_a
.cmp(&addr_b)
.then_with(|| mlen_a.cmp(&mlen_b))
.then_with(|| plen_a.cmp(&plen_b))
.then_with(|| asn_a.cmp(&asn_b))
}
}
(
RouteOriginKey::V6 {
addr: addr_a,
plen: plen_a,
mlen: mlen_a,
asn: asn_a,
},
RouteOriginKey::V6 {
addr: addr_b,
plen: plen_b,
mlen: mlen_b,
asn: asn_b,
},
) => {
if announce {
addr_b
.cmp(&addr_a)
.then_with(|| mlen_b.cmp(&mlen_a))
.then_with(|| plen_b.cmp(&plen_a))
.then_with(|| asn_b.cmp(&asn_a))
} else {
addr_a
.cmp(&addr_b)
.then_with(|| mlen_a.cmp(&mlen_b))
.then_with(|| plen_a.cmp(&plen_b))
.then_with(|| asn_a.cmp(&asn_b))
}
}
_ => Ordering::Equal,
}
}
fn compare_router_key_for_rtr(a: &RouterKey, b: &RouterKey) -> Ordering {
a.ski()
.cmp(&b.ski())
.then_with(|| a.spki().len().cmp(&b.spki().len()))
.then_with(|| a.spki().cmp(b.spki()))
.then_with(|| a.asn().into_u32().cmp(&b.asn().into_u32()))
}
fn compare_aspa_for_rtr(a: &Aspa, b: &Aspa) -> Ordering {
a.customer_asn()
.into_u32()
.cmp(&b.customer_asn().into_u32())
}
fn payload_brief(payload: &Payload) -> String {
match payload {
Payload::RouteOrigin(origin) => format!(
"{} prefix {:?}/{} max={} asn={}",
if route_origin_is_ipv4(origin) {
"IPv4"
} else {
"IPv6"
},
origin.prefix().address,
origin.prefix().prefix_length,
origin.max_length(),
origin.asn().into_u32()
),
Payload::RouterKey(key) => format!(
"RouterKey ski={:02x?} asn={}",
key.ski().as_ref(),
key.asn().into_u32()
),
Payload::Aspa(aspa) => format!("ASPA customer_asn={}", aspa.customer_asn().into_u32()),
}
}
fn payload_update_brief(announce: bool, payload: &Payload) -> String {
format!(
"{} {}",
if announce { "announce" } else { "withdraw" },
payload_brief(payload)
)
}

233
src/rtr/cache/store.rs vendored Normal file
View File

@ -0,0 +1,233 @@
use std::collections::VecDeque;
use std::sync::{Arc, OnceLock};
use anyhow::Result;
use tokio::sync::watch;
use crate::rtr::payload::{Payload, Timing};
use crate::rtr::store::RtrStore;
use super::core::{AppliedUpdate, CacheAvailability, RtrCache, RtrCacheBuilder, SessionIds};
use super::model::{Delta, Snapshot};
const VERSION_COUNT: usize = 3;
static STORE_SYNC_WORKER: OnceLock<watch::Sender<Option<StoreSyncJob>>> = OnceLock::new();
#[derive(Clone)]
struct StoreSyncJob {
store: RtrStore,
update: AppliedUpdate,
}
impl RtrCache {
pub fn init(
self,
store: &RtrStore,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timing: Timing,
file_loader: impl Fn() -> Result<Vec<Payload>>,
) -> Result<Self> {
if let Some(cache) =
try_restore_from_store(store, max_delta, prune_delta_by_snapshot_size, timing)?
{
tracing::info!(
"RTR cache restored from store: availability={:?}, session_ids={:?}, serials={:?}",
cache.availability(),
cache.session_ids(),
cache.serials()
);
return Ok(cache);
}
tracing::warn!("RTR cache store unavailable or invalid, fallback to file loader");
let payloads = file_loader()?;
let source_snapshot = Snapshot::from_payloads(payloads);
let availability = if source_snapshot.is_empty() {
CacheAvailability::NoDataAvailable
} else {
CacheAvailability::Ready
};
let session_ids = SessionIds::random_distinct();
let serial = if source_snapshot.is_empty() { 0 } else { 1 };
let snapshots = std::array::from_fn(|version| {
project_snapshot_for_version(&source_snapshot, version as u8)
});
let serials = [serial; VERSION_COUNT];
let deltas =
std::array::from_fn(|_| VecDeque::<Arc<Delta>>::with_capacity(max_delta as usize));
tokio::spawn({
let store = store.clone();
let snapshots_for_store = snapshots.clone();
let session_ids_for_store = session_ids.as_array();
async move {
let deltas_none: [Option<&Delta>; 3] = [None, None, None];
let windows_none: [Option<(u32, u32)>; 3] = [None, None, None];
let clear = [true, true, true];
if let Err(e) = store.save_cache_state_versioned(
availability,
&snapshots_for_store,
&session_ids_for_store,
&serials,
&deltas_none,
&windows_none,
&clear,
) {
tracing::error!("persist cache state failed: {:?}", e);
}
}
});
Ok(RtrCacheBuilder::new()
.availability(availability)
.session_ids(session_ids)
.max_delta(max_delta)
.prune_delta_by_snapshot_size(prune_delta_by_snapshot_size)
.timing(timing)
.serials(serials)
.snapshots(snapshots)
.deltas_by_version(deltas)
.build())
}
pub fn update(&mut self, new_payloads: Vec<Payload>, store: &RtrStore) -> Result<()> {
if let Some(update) = self.apply_update(new_payloads)? {
spawn_store_sync(store, update);
}
Ok(())
}
pub fn update_with_snapshot(&mut self, snapshot: Snapshot, store: &RtrStore) -> Result<()> {
if let Some(update) = self.apply_update_from_snapshot(snapshot)? {
spawn_store_sync(store, update);
}
Ok(())
}
pub fn update_runtime_config(
&mut self,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timing: Timing,
store: &RtrStore,
) {
if let Some(update) =
self.apply_runtime_config(max_delta, prune_delta_by_snapshot_size, timing)
{
spawn_store_sync(store, update);
}
}
}
fn try_restore_from_store(
store: &RtrStore,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timing: Timing,
) -> Result<Option<RtrCache>> {
let availability = store.get_availability()?;
let mut snapshots = std::array::from_fn(|_| Snapshot::empty());
let mut session_ids = [0u16; VERSION_COUNT];
let mut serials = [0u32; VERSION_COUNT];
let mut deltas =
std::array::from_fn(|_| VecDeque::<Arc<Delta>>::with_capacity(max_delta as usize));
for version in 0u8..=2 {
let idx = version as usize;
let snapshot = store.get_snapshot_for_version(version)?;
let session_id = store.get_session_id_for_version(version)?;
let serial = store.get_serial_for_version(version)?;
let (snapshot, session_id, serial) = match (snapshot, session_id, serial) {
(Some(snapshot), Some(session_id), Some(serial)) => (snapshot, session_id, serial),
_ => return Ok(None),
};
snapshots[idx] = snapshot;
session_ids[idx] = session_id;
serials[idx] = serial;
if availability == Some(CacheAvailability::NoDataAvailable) {
continue;
}
if let Some((min_serial, max_serial)) = store.get_delta_window_for_version(version)? {
let mut loaded =
store.load_delta_window_for_version(version, min_serial, max_serial)?;
let max_keep = usize::from(max_delta.max(1));
if loaded.len() > max_keep {
let drop_count = loaded.len() - max_keep;
let dropped_serials = loaded
.iter()
.take(drop_count)
.map(Delta::serial)
.collect::<Vec<_>>();
loaded.drain(..drop_count);
tracing::warn!(
"RTR cache restore truncated persisted deltas to max_delta: version={}, max_delta={}, dropped_count={}, dropped_serials={:?}",
version,
max_delta,
drop_count,
dropped_serials
);
}
deltas[idx] = loaded.into_iter().map(Arc::new).collect();
}
}
let availability = availability.unwrap_or(CacheAvailability::Ready);
Ok(Some(
RtrCacheBuilder::new()
.availability(availability)
.session_ids(SessionIds::from_array(session_ids))
.max_delta(max_delta)
.prune_delta_by_snapshot_size(prune_delta_by_snapshot_size)
.timing(timing)
.serials(serials)
.snapshots(snapshots)
.deltas_by_version(deltas)
.build(),
))
}
fn spawn_store_sync(store: &RtrStore, update: AppliedUpdate) {
let tx = STORE_SYNC_WORKER.get_or_init(|| {
let (tx, mut rx) = watch::channel::<Option<StoreSyncJob>>(None);
tokio::spawn(async move {
while rx.changed().await.is_ok() {
let Some(job) = rx.borrow().clone() else {
continue;
};
persist_update_job(job);
}
});
tx
});
let _ = tx.send_replace(Some(StoreSyncJob {
store: store.clone(),
update,
}));
}
fn persist_update_job(job: StoreSyncJob) {
let delta_refs: [Option<&Delta>; 3] =
std::array::from_fn(|idx| job.update.deltas[idx].as_deref());
if let Err(e) = job.store.save_cache_state_versioned(
job.update.availability,
&job.update.snapshots,
&job.update.session_ids,
&job.update.serials,
&delta_refs,
&job.update.delta_windows,
&job.update.clear_delta_windows,
) {
tracing::error!("persist cache state failed: {:?}", e);
}
}
fn project_snapshot_for_version(snapshot: &Snapshot, version: u8) -> Snapshot {
snapshot.project_for_version(version)
}

615
src/rtr/config.rs Normal file
View File

@ -0,0 +1,615 @@
use std::env;
use std::net::SocketAddr;
use std::time::Duration;
use anyhow::{Result, anyhow};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::rtr::payload::Timing;
use crate::rtr::server::RtrServiceConfig;
use crate::rtr::server::ssh::SshAuthMode;
#[derive(Debug, Clone)]
pub struct AppConfig {
pub enable_tls: bool,
pub enable_ssh: bool,
pub tcp_addr: SocketAddr,
pub tls_addr: SocketAddr,
pub ssh_addr: SocketAddr,
pub db_path: String,
pub ccr_dir: String,
pub slurm_dir: Option<String>,
pub tls_cert_path: String,
pub tls_key_path: String,
pub tls_client_ca_path: String,
pub ssh_host_key_path: String,
pub ssh_authorized_keys_path: String,
pub ssh_username: String,
pub ssh_subsystem_name: String,
pub ssh_auth_mode: SshAuthMode,
pub ssh_password: Option<String>,
pub max_delta: u8,
pub prune_delta_by_snapshot_size: bool,
pub source_refresh_interval: Duration,
pub report_dir: String,
pub runtime_report_interval: Duration,
pub report_history_limit: usize,
pub timezone: Tz,
pub timing: Timing,
pub admin_addr: Option<SocketAddr>,
pub admin_token: Option<String>,
pub service_config: RtrServiceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeConfig {
pub max_delta: u8,
pub prune_delta_by_snapshot_size: bool,
pub source_refresh_interval_seconds: u64,
pub runtime_report_interval_seconds: u64,
pub report_history_limit: usize,
pub timezone: String,
pub timing: RuntimeTimingConfig,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeTimingConfig {
pub refresh: u32,
pub retry: u32,
pub expire: u32,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RuntimeConfigPatch {
pub max_delta: Option<u8>,
pub prune_delta_by_snapshot_size: Option<bool>,
pub source_refresh_interval_seconds: Option<u64>,
pub runtime_report_interval_seconds: Option<u64>,
pub report_history_limit: Option<usize>,
pub timezone: Option<String>,
pub timing: Option<RuntimeTimingConfigPatch>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
pub struct RuntimeTimingConfigPatch {
pub refresh: Option<u32>,
pub retry: Option<u32>,
pub expire: Option<u32>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
enable_tls: false,
enable_ssh: false,
tcp_addr: "0.0.0.0:323".parse().expect("invalid default tcp_addr"),
tls_addr: "0.0.0.0:324".parse().expect("invalid default tls_addr"),
ssh_addr: "0.0.0.0:22".parse().expect("invalid default ssh_addr"),
db_path: "./rtr-db".to_string(),
ccr_dir: "./data".to_string(),
slurm_dir: None,
tls_cert_path: "./certs/tls/server-dns.crt".to_string(),
tls_key_path: "./certs/tls/server-dns.key".to_string(),
tls_client_ca_path: "./certs/tls/client-ca.crt".to_string(),
ssh_host_key_path: "./certs/ssh/ssh_host_rsa_key".to_string(),
ssh_authorized_keys_path: "./certs/ssh/rtr-authorized_keys".to_string(),
ssh_username: "rpki-rtr".to_string(),
ssh_subsystem_name: "rpki-rtr".to_string(),
ssh_auth_mode: SshAuthMode::Key,
ssh_password: None,
max_delta: 100,
prune_delta_by_snapshot_size: false,
source_refresh_interval: Duration::from_secs(300),
report_dir: "./report".to_string(),
runtime_report_interval: Duration::from_secs(300),
report_history_limit: 10,
timezone: default_timezone(),
timing: Timing::default(),
admin_addr: None,
admin_token: None,
service_config: RtrServiceConfig {
max_connections: 512,
max_concurrent_handshakes: 128,
notify_queue_size: 1024,
tcp_keepalive: Some(Duration::from_secs(60)),
warn_insecure_tcp: true,
require_tls_server_dns_name_san: false,
enforce_tls_client_san_ip_match: true,
},
}
}
}
impl AppConfig {
pub fn from_env() -> Result<Self> {
let mut config = Self::default();
if let Some(value) = env_var("RPKI_RTR_ENABLE_TLS")? {
config.enable_tls = parse_bool(&value, "RPKI_RTR_ENABLE_TLS")?;
}
if let Some(value) = env_var("RPKI_RTR_ENABLE_SSH")? {
config.enable_ssh = parse_bool(&value, "RPKI_RTR_ENABLE_SSH")?;
}
if let Some(value) = env_var("RPKI_RTR_TCP_ADDR")? {
config.tcp_addr = value
.parse()
.map_err(|err| anyhow!("invalid RPKI_RTR_TCP_ADDR '{}': {}", value, err))?;
}
if let Some(value) = env_var("RPKI_RTR_TLS_ADDR")? {
config.tls_addr = value
.parse()
.map_err(|err| anyhow!("invalid RPKI_RTR_TLS_ADDR '{}': {}", value, err))?;
}
if let Some(value) = env_var("RPKI_RTR_SSH_ADDR")? {
config.ssh_addr = value
.parse()
.map_err(|err| anyhow!("invalid RPKI_RTR_SSH_ADDR '{}': {}", value, err))?;
}
if let Some(value) = env_var("RPKI_RTR_SSH_PORT")? {
let port: u16 = value
.parse()
.map_err(|err| anyhow!("invalid RPKI_RTR_SSH_PORT '{}': {}", value, err))?;
config.ssh_addr.set_port(port);
}
if let Some(value) = env_var("RPKI_RTR_DB_PATH")? {
config.db_path = value;
}
if let Some(value) = env_var("RPKI_RTR_CCR_DIR")? {
config.ccr_dir = value;
}
if let Some(value) = env_var("RPKI_RTR_SLURM_DIR")? {
let value = value.trim();
config.slurm_dir = if value.is_empty() {
None
} else {
Some(value.to_string())
};
}
if let Some(value) = env_var("RPKI_RTR_TLS_CERT_PATH")? {
config.tls_cert_path = value;
}
if let Some(value) = env_var("RPKI_RTR_TLS_KEY_PATH")? {
config.tls_key_path = value;
}
if let Some(value) = env_var("RPKI_RTR_TLS_CLIENT_CA_PATH")? {
config.tls_client_ca_path = value;
}
if let Some(value) = env_var("RPKI_RTR_SSH_HOST_KEY_PATH")? {
config.ssh_host_key_path = value;
}
if let Some(value) = env_var("RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH")? {
config.ssh_authorized_keys_path = value;
}
if let Some(value) = env_var("RPKI_RTR_SSH_USERNAME")? {
config.ssh_username = value;
}
if let Some(value) = env_var("RPKI_RTR_SSH_SUBSYSTEM_NAME")? {
config.ssh_subsystem_name = value;
}
if let Some(value) = env_var("RPKI_RTR_SSH_AUTH_MODE")? {
config.ssh_auth_mode = SshAuthMode::parse(&value).ok_or_else(|| {
anyhow!(
"invalid RPKI_RTR_SSH_AUTH_MODE '{}': expected key|password|both",
value
)
})?;
}
if let Some(value) = env_var("RPKI_RTR_SSH_PASSWORD")? {
let value = value.trim().to_string();
config.ssh_password = if value.is_empty() { None } else { Some(value) };
}
if let Some(value) = env_var("RPKI_RTR_MAX_DELTA")? {
let parsed: u8 = value
.parse()
.map_err(|err| anyhow!("invalid RPKI_RTR_MAX_DELTA '{}': {}", value, err))?;
if parsed == 0 {
return Err(anyhow!(
"invalid RPKI_RTR_MAX_DELTA '{}': must be >= 1",
value
));
}
config.max_delta = parsed;
}
if let Some(value) = env_var("RPKI_RTR_PRUNE_DELTA_BY_SNAPSHOT_SIZE")? {
config.prune_delta_by_snapshot_size =
parse_bool(&value, "RPKI_RTR_PRUNE_DELTA_BY_SNAPSHOT_SIZE")?;
}
if let Some(value) = env_var("RPKI_RTR_REPORT_DIR")? {
config.report_dir = value;
}
if let Some(value) = env_var("RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS")? {
let secs = parse_positive_u64(&value, "RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS")?;
config.runtime_report_interval = Duration::from_secs(secs);
}
if let Some(value) = env_var("RPKI_RTR_REPORT_HISTORY_LIMIT")? {
config.report_history_limit =
parse_positive_usize(&value, "RPKI_RTR_REPORT_HISTORY_LIMIT")?;
}
if let Some(value) = env_var("RPKI_RTR_TIMEZONE")? {
config.timezone = parse_timezone(&value, "RPKI_RTR_TIMEZONE")?;
}
if let Some(value) = env_var("RPKI_RTR_ADMIN_ADDR")? {
let value = value.trim();
if !value.is_empty() {
config.admin_addr =
Some(value.parse().map_err(|err| {
anyhow!("invalid RPKI_RTR_ADMIN_ADDR '{}': {}", value, err)
})?);
}
}
if let Some(value) = env_var("RPKI_RTR_ADMIN_TOKEN")? {
let value = value.trim().to_string();
config.admin_token = if value.is_empty() { None } else { Some(value) };
}
let source_refresh_interval_new = env_var("RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?;
let source_refresh_interval_legacy = env_var("RPKI_RTR_REFRESH_INTERVAL_SECS")?;
match (
source_refresh_interval_new.as_deref(),
source_refresh_interval_legacy.as_deref(),
) {
(Some(new_value), Some(_)) => {
let secs = parse_positive_u64(new_value, "RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?;
config.source_refresh_interval = Duration::from_secs(secs);
warn!(
"both RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS and legacy RPKI_RTR_REFRESH_INTERVAL_SECS are set; using RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS"
);
}
(Some(new_value), None) => {
let secs = parse_positive_u64(new_value, "RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?;
config.source_refresh_interval = Duration::from_secs(secs);
}
(None, Some(legacy_value)) => {
let secs = parse_positive_u64(legacy_value, "RPKI_RTR_REFRESH_INTERVAL_SECS")?;
config.source_refresh_interval = Duration::from_secs(secs);
warn!(
"RPKI_RTR_REFRESH_INTERVAL_SECS is deprecated; use RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS"
);
}
(None, None) => {}
}
if let Some(value) = env_var("RPKI_RTR_TIMING_REFRESH_SECS")? {
config.timing.refresh = parse_positive_u32(&value, "RPKI_RTR_TIMING_REFRESH_SECS")?;
}
if let Some(value) = env_var("RPKI_RTR_TIMING_RETRY_SECS")? {
config.timing.retry = parse_positive_u32(&value, "RPKI_RTR_TIMING_RETRY_SECS")?;
}
if let Some(value) = env_var("RPKI_RTR_TIMING_EXPIRE_SECS")? {
config.timing.expire = parse_positive_u32(&value, "RPKI_RTR_TIMING_EXPIRE_SECS")?;
}
config
.timing
.validate()
.map_err(|err| anyhow!("invalid RTR timing configuration: {}", err))?;
if let Some(value) = env_var("RPKI_RTR_MAX_CONNECTIONS")? {
config.service_config.max_connections = value
.parse()
.map_err(|err| anyhow!("invalid RPKI_RTR_MAX_CONNECTIONS '{}': {}", value, err))?;
}
if let Some(value) = env_var("RPKI_RTR_MAX_CONCURRENT_HANDSHAKES")? {
config.service_config.max_concurrent_handshakes = value.parse().map_err(|err| {
anyhow!(
"invalid RPKI_RTR_MAX_CONCURRENT_HANDSHAKES '{}': {}",
value,
err
)
})?;
}
if let Some(value) = env_var("RPKI_RTR_NOTIFY_QUEUE_SIZE")? {
config.service_config.notify_queue_size = value.parse().map_err(|err| {
anyhow!("invalid RPKI_RTR_NOTIFY_QUEUE_SIZE '{}': {}", value, err)
})?;
}
if let Some(value) = env_var("RPKI_RTR_TCP_KEEPALIVE_SECS")? {
let secs: u64 = value.parse().map_err(|err| {
anyhow!("invalid RPKI_RTR_TCP_KEEPALIVE_SECS '{}': {}", value, err)
})?;
config.service_config.tcp_keepalive = if secs == 0 {
None
} else {
Some(Duration::from_secs(secs))
};
}
if let Some(value) = env_var("RPKI_RTR_WARN_INSECURE_TCP")? {
config.service_config.warn_insecure_tcp =
parse_bool(&value, "RPKI_RTR_WARN_INSECURE_TCP")?;
}
if let Some(value) = env_var("RPKI_RTR_REQUIRE_TLS_SERVER_DNS_NAME_SAN")? {
config.service_config.require_tls_server_dns_name_san =
parse_bool(&value, "RPKI_RTR_REQUIRE_TLS_SERVER_DNS_NAME_SAN")?;
}
if let Some(value) = env_var("RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH")? {
config.service_config.enforce_tls_client_san_ip_match =
parse_bool(&value, "RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH")?;
}
if config.service_config.max_connections == 0 {
return Err(anyhow!(
"invalid RPKI_RTR_MAX_CONNECTIONS '{}': must be >= 1",
config.service_config.max_connections
));
}
if config.service_config.max_concurrent_handshakes == 0 {
return Err(anyhow!(
"invalid RPKI_RTR_MAX_CONCURRENT_HANDSHAKES '{}': must be >= 1",
config.service_config.max_concurrent_handshakes
));
}
if config.service_config.max_concurrent_handshakes > config.service_config.max_connections {
return Err(anyhow!(
"invalid handshake/connection limits: RPKI_RTR_MAX_CONCURRENT_HANDSHAKES ({}) must be <= RPKI_RTR_MAX_CONNECTIONS ({})",
config.service_config.max_concurrent_handshakes,
config.service_config.max_connections
));
}
Ok(config)
}
pub fn runtime_config(&self) -> RuntimeConfig {
RuntimeConfig {
max_delta: self.max_delta,
prune_delta_by_snapshot_size: self.prune_delta_by_snapshot_size,
source_refresh_interval_seconds: self.source_refresh_interval.as_secs(),
runtime_report_interval_seconds: self.runtime_report_interval.as_secs(),
report_history_limit: self.report_history_limit,
timezone: format_timezone(self.timezone),
timing: RuntimeTimingConfig::from(self.timing),
}
}
}
impl RuntimeConfig {
pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result<Self> {
let mut next = self.clone();
if let Some(max_delta) = patch.max_delta {
if max_delta == 0 {
return Err(anyhow!("invalid max_delta '{}': must be >= 1", max_delta));
}
next.max_delta = max_delta;
}
if let Some(value) = patch.prune_delta_by_snapshot_size {
next.prune_delta_by_snapshot_size = value;
}
if let Some(value) = patch.source_refresh_interval_seconds {
if value == 0 {
return Err(anyhow!(
"invalid source_refresh_interval_seconds '{}': must be >= 1",
value
));
}
next.source_refresh_interval_seconds = value;
}
if let Some(value) = patch.runtime_report_interval_seconds {
if value == 0 {
return Err(anyhow!(
"invalid runtime_report_interval_seconds '{}': must be >= 1",
value
));
}
next.runtime_report_interval_seconds = value;
}
if let Some(value) = patch.report_history_limit {
if value == 0 {
return Err(anyhow!(
"invalid report_history_limit '{}': must be >= 1",
value
));
}
next.report_history_limit = value;
}
if let Some(value) = patch.timezone {
next.timezone = format_timezone(parse_timezone(&value, "timezone")?);
}
if let Some(timing) = patch.timing {
let mut next_timing = Timing::from(next.timing);
if let Some(value) = timing.refresh {
next_timing.refresh = value;
}
if let Some(value) = timing.retry {
next_timing.retry = value;
}
if let Some(value) = timing.expire {
next_timing.expire = value;
}
next_timing
.validate()
.map_err(|err| anyhow!("invalid timing: {}", err))?;
next.timing = RuntimeTimingConfig::from(next_timing);
}
Ok(next)
}
pub fn timezone(&self) -> Result<Tz> {
parse_timezone(&self.timezone, "timezone")
}
pub fn timing(&self) -> Timing {
Timing::from(self.timing)
}
}
impl From<Timing> for RuntimeTimingConfig {
fn from(timing: Timing) -> Self {
Self {
refresh: timing.refresh,
retry: timing.retry,
expire: timing.expire,
}
}
}
impl From<RuntimeTimingConfig> for Timing {
fn from(timing: RuntimeTimingConfig) -> Self {
Self::new(timing.refresh, timing.retry, timing.expire)
}
}
pub fn log_startup_config(config: &AppConfig) {
info!("starting RTR service");
info!("db_path={}", config.db_path);
info!("tcp_addr={}", config.tcp_addr);
info!("tls_enabled={}", config.enable_tls);
info!("ssh_enabled={}", config.enable_ssh);
if config.enable_tls {
info!("tls_addr={}", config.tls_addr);
info!("tls_cert_path={}", config.tls_cert_path);
info!("tls_key_path={}", config.tls_key_path);
info!("tls_client_ca_path={}", config.tls_client_ca_path);
}
if config.enable_ssh {
info!("ssh_addr={}", config.ssh_addr);
info!("ssh_host_key_path={}", config.ssh_host_key_path);
info!(
"ssh_authorized_keys_path={}",
config.ssh_authorized_keys_path
);
info!("ssh_username={}", config.ssh_username);
info!("ssh_subsystem_name={}", config.ssh_subsystem_name);
info!("ssh_auth_mode={}", config.ssh_auth_mode.as_str());
info!("ssh_password_enabled={}", config.ssh_password.is_some());
}
info!("ccr_dir={}", config.ccr_dir);
info!(
"slurm_dir={}",
config.slurm_dir.as_deref().unwrap_or("disabled")
);
info!("max_delta={}", config.max_delta);
info!("strict_ccr_validation=true");
info!(
"source_refresh_interval_secs={}",
config.source_refresh_interval.as_secs()
);
info!("report_dir={}", config.report_dir);
info!(
"runtime_report_interval_secs={}",
config.runtime_report_interval.as_secs()
);
info!("report_history_limit={}", config.report_history_limit);
info!("timezone={}", format_timezone(config.timezone));
info!("rtr_timing_refresh_secs={}", config.timing.refresh);
info!("rtr_timing_retry_secs={}", config.timing.retry);
info!("rtr_timing_expire_secs={}", config.timing.expire);
info!(
"admin_addr={}",
config
.admin_addr
.map(|addr| addr.to_string())
.unwrap_or_else(|| "disabled".to_string())
);
info!("admin_token_enabled={}", config.admin_token.is_some());
info!("max_connections={}", config.service_config.max_connections);
info!(
"max_concurrent_handshakes={}",
config.service_config.max_concurrent_handshakes
);
info!(
"notify_queue_size={}",
config.service_config.notify_queue_size
);
info!(
"tcp_keepalive_secs={}",
config
.service_config
.tcp_keepalive
.map(|duration| duration.as_secs().to_string())
.unwrap_or_else(|| "disabled".to_string())
);
info!(
"warn_insecure_tcp={}",
config.service_config.warn_insecure_tcp
);
info!(
"require_tls_server_dns_name_san={}",
config.service_config.require_tls_server_dns_name_san
);
info!(
"enforce_tls_client_san_ip_match={}",
config.service_config.enforce_tls_client_san_ip_match
);
}
pub fn default_timezone() -> Tz {
chrono_tz::Asia::Shanghai
}
pub fn format_timezone(timezone: Tz) -> String {
timezone.name().to_string()
}
fn env_var(name: &str) -> Result<Option<String>> {
match env::var(name) {
Ok(value) => Ok(Some(value)),
Err(env::VarError::NotPresent) => Ok(None),
Err(err) => Err(anyhow!("failed to read {}: {}", name, err)),
}
}
fn parse_bool(value: &str, name: &str) -> Result<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(anyhow!("invalid {} '{}': expected boolean", name, value)),
}
}
fn parse_positive_u64(value: &str, name: &str) -> Result<u64> {
let parsed = value
.parse::<u64>()
.map_err(|err| anyhow!("invalid {} '{}': {}", name, value, err))?;
if parsed == 0 {
return Err(anyhow!("invalid {} '{}': must be >= 1", name, value));
}
Ok(parsed)
}
fn parse_positive_usize(value: &str, name: &str) -> Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|err| anyhow!("invalid {} '{}': {}", name, value, err))?;
if parsed == 0 {
return Err(anyhow!("invalid {} '{}': must be >= 1", name, value));
}
Ok(parsed)
}
fn parse_positive_u32(value: &str, name: &str) -> Result<u32> {
let parsed = value
.parse::<u32>()
.map_err(|err| anyhow!("invalid {} '{}': {}", name, value, err))?;
if parsed == 0 {
return Err(anyhow!("invalid {} '{}': must be >= 1", name, value));
}
Ok(parsed)
}
pub fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
let value = value.trim();
let normalized = match value.to_ascii_lowercase().as_str() {
"shanghai" | "beijing" | "peking" => "Asia/Shanghai",
"utc" | "z" => "UTC",
_ => value,
};
normalized.parse::<Tz>().map_err(|err| {
anyhow!(
"invalid {} '{}': expected IANA timezone like Asia/Shanghai, Europe/London, America/New_York, or UTC: {}",
name,
value,
err
)
})
}

223
src/rtr/error_type.rs Normal file
View File

@ -0,0 +1,223 @@
use std::convert::TryFrom;
use std::fmt;
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
CorruptData = 0,
InternalError = 1,
NoDataAvailable = 2,
InvalidRequest = 3,
UnsupportedProtocolVersion = 4,
UnsupportedPduType = 5,
WithdrawalOfUnknownRecord = 6,
DuplicateAnnouncement = 7,
UnexpectedProtocolVersion = 8,
AspaProviderListError = 9,
TransportFailed = 10,
OrderingError = 11,
CacheRestart = 12,
CacheShutdown = 13,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCodeDisposition {
Fatal,
NonFatal,
}
impl ErrorCodeDisposition {
#[inline]
pub fn is_fatal(self) -> bool {
self == Self::Fatal
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ErrorCodeVersionSpec {
pub code: ErrorCode,
pub min_version: u8,
pub max_version: u8,
pub disposition: ErrorCodeDisposition,
}
impl ErrorCodeVersionSpec {
pub fn contains(self, code: ErrorCode, version: u8) -> bool {
self.code == code && (self.min_version..=self.max_version).contains(&version)
}
}
pub const ERROR_CODE_VERSION_SPECS: &[ErrorCodeVersionSpec] = &[
ErrorCodeVersionSpec {
code: ErrorCode::CorruptData,
min_version: 0,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::InternalError,
min_version: 0,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::NoDataAvailable,
min_version: 0,
max_version: 2,
disposition: ErrorCodeDisposition::NonFatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::InvalidRequest,
min_version: 0,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::UnsupportedProtocolVersion,
min_version: 0,
max_version: 1,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::UnsupportedProtocolVersion,
min_version: 2,
max_version: 2,
disposition: ErrorCodeDisposition::NonFatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::UnsupportedPduType,
min_version: 0,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::WithdrawalOfUnknownRecord,
min_version: 0,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::DuplicateAnnouncement,
min_version: 0,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::UnexpectedProtocolVersion,
min_version: 1,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::AspaProviderListError,
min_version: 2,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::TransportFailed,
min_version: 2,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::OrderingError,
min_version: 2,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::CacheRestart,
min_version: 2,
max_version: 2,
disposition: ErrorCodeDisposition::NonFatal,
},
ErrorCodeVersionSpec {
code: ErrorCode::CacheShutdown,
min_version: 2,
max_version: 2,
disposition: ErrorCodeDisposition::Fatal,
},
];
impl ErrorCode {
#[inline]
pub fn as_u16(self) -> u16 {
self as u16
}
pub fn description(self) -> &'static str {
match self {
ErrorCode::CorruptData => "Corrupt Data",
ErrorCode::InternalError => "Internal Error",
ErrorCode::NoDataAvailable => "No Data Available",
ErrorCode::InvalidRequest => "Invalid Request",
ErrorCode::UnsupportedProtocolVersion => "Unsupported Protocol Version",
ErrorCode::UnsupportedPduType => "Unsupported PDU Type",
ErrorCode::WithdrawalOfUnknownRecord => "Withdrawal of Unknown Record",
ErrorCode::DuplicateAnnouncement => "Duplicate Announcement Received",
ErrorCode::UnexpectedProtocolVersion => "Unexpected Protocol Version",
ErrorCode::AspaProviderListError => "ASPA Provider List Error",
ErrorCode::TransportFailed => "Transport Failure",
ErrorCode::OrderingError => "Ordering Error",
ErrorCode::CacheRestart => "Cache Restart",
ErrorCode::CacheShutdown => "Cache Shutdown",
}
}
pub fn is_defined_in_version(self, version: u8) -> bool {
ERROR_CODE_VERSION_SPECS
.iter()
.any(|spec| spec.contains(self, version))
}
pub fn disposition_in_version(self, version: u8) -> Option<ErrorCodeDisposition> {
ERROR_CODE_VERSION_SPECS
.iter()
.find(|spec| spec.contains(self, version))
.map(|spec| spec.disposition)
}
}
impl TryFrom<u16> for ErrorCode {
type Error = ();
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
0 => Ok(ErrorCode::CorruptData),
1 => Ok(ErrorCode::InternalError),
2 => Ok(ErrorCode::NoDataAvailable),
3 => Ok(ErrorCode::InvalidRequest),
4 => Ok(ErrorCode::UnsupportedProtocolVersion),
5 => Ok(ErrorCode::UnsupportedPduType),
6 => Ok(ErrorCode::WithdrawalOfUnknownRecord),
7 => Ok(ErrorCode::DuplicateAnnouncement),
8 => Ok(ErrorCode::UnexpectedProtocolVersion),
9 => Ok(ErrorCode::AspaProviderListError),
10 => Ok(ErrorCode::TransportFailed),
11 => Ok(ErrorCode::OrderingError),
12 => Ok(ErrorCode::CacheRestart),
13 => Ok(ErrorCode::CacheShutdown),
_ => Err(()),
}
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.description(), *self as u16)
}
}

13
src/rtr/mod.rs Normal file
View File

@ -0,0 +1,13 @@
pub mod admin;
pub mod bootstrap;
pub mod cache;
pub mod config;
pub mod error_type;
pub mod payload;
pub mod pdu;
pub mod report;
pub mod runtime;
pub mod server;
pub mod session;
pub mod state;
pub mod store;

294
src/rtr/payload.rs Normal file
View File

@ -0,0 +1,294 @@
use crate::data_model::resources::as_resources::Asn;
use crate::data_model::resources::ip_resources::IPAddressPrefix;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
use std::io;
use std::time::Duration;
use x509_parser::prelude::FromDer;
use x509_parser::x509::SubjectPublicKeyInfo;
#[derive(
Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Ord, PartialOrd, Serialize, Deserialize,
)]
pub struct Ski([u8; 20]);
impl AsRef<[u8]> for Ski {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl Ski {
pub fn from_bytes(bytes: [u8; 20]) -> Self {
Self(bytes)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct RouteOrigin {
prefix: IPAddressPrefix,
max_length: u8,
asn: Asn,
}
impl RouteOrigin {
pub fn new(prefix: IPAddressPrefix, max_length: u8, asn: Asn) -> Self {
Self {
prefix,
max_length,
asn,
}
}
pub fn prefix(&self) -> &IPAddressPrefix {
&self.prefix
}
pub fn max_length(&self) -> u8 {
self.max_length
}
pub fn asn(&self) -> Asn {
self.asn
}
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct RouterKey {
subject_key_identifier: Ski,
asn: Asn,
subject_public_key_info: Vec<u8>,
}
impl RouterKey {
pub fn new(subject_key_identifier: Ski, asn: Asn, subject_public_key_info: Vec<u8>) -> Self {
Self {
subject_key_identifier,
asn,
subject_public_key_info,
}
}
pub fn ski(&self) -> Ski {
self.subject_key_identifier
}
pub fn asn(&self) -> Asn {
self.asn
}
pub fn spki(&self) -> &[u8] {
&self.subject_public_key_info
}
pub fn validate(&self) -> Result<(), io::Error> {
if self.asn.into_u32() == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"RouterKey ASN must not be AS0",
));
}
if self.subject_public_key_info.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"RouterKey SPKI must not be empty",
));
}
let (rem, _) =
SubjectPublicKeyInfo::from_der(&self.subject_public_key_info).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("RouterKey SPKI is not valid DER: {err}"),
)
})?;
if !rem.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("RouterKey SPKI DER has trailing bytes: {}", rem.len()),
));
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
pub struct Aspa {
customer_asn: Asn,
provider_asns: Vec<Asn>,
}
impl Aspa {
pub fn new(customer_asn: Asn, mut provider_asns: Vec<Asn>) -> Self {
provider_asns.sort();
provider_asns.dedup();
Self {
customer_asn,
provider_asns,
}
}
pub fn customer_asn(&self) -> Asn {
self.customer_asn
}
pub fn provider_asns(&self) -> &[Asn] {
&self.provider_asns
}
pub fn validate_announcement(&self) -> Result<(), io::Error> {
if self.customer_asn.into_u32() == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ASPA customer ASN must not be AS0",
));
}
if self.provider_asns.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ASPA announcement must contain at least one provider ASN",
));
}
if self.provider_asns.iter().any(|asn| asn.into_u32() == 0)
&& !(self.provider_asns.len() == 1 && self.provider_asns[0].into_u32() == 0)
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ASPA provider list containing AS0 must be exactly [0]",
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub enum Payload {
/// A route origin.
RouteOrigin(RouteOrigin),
/// A BGPsec router key.
RouterKey(RouterKey),
/// An ASPA unit.
Aspa(Aspa),
}
// Timing
#[derive(Clone, Copy, Debug)]
pub struct Timing {
/// The number of seconds until a client should refresh its data.
pub refresh: u32,
/// The number of seconds a client whould wait before retrying to connect.
pub retry: u32,
/// The number of secionds before data expires if not refreshed.
pub expire: u32,
}
impl Timing {
pub const MIN_REFRESH: u32 = 1;
pub const MAX_REFRESH: u32 = 86_400;
pub const MIN_RETRY: u32 = 1;
pub const MAX_RETRY: u32 = 7_200;
pub const MIN_EXPIRE: u32 = 600;
pub const MAX_EXPIRE: u32 = 172_800;
pub const fn new(refresh: u32, retry: u32, expire: u32) -> Self {
Self {
refresh,
retry,
expire,
}
}
pub fn validate(self) -> Result<(), io::Error> {
if !(Self::MIN_REFRESH..=Self::MAX_REFRESH).contains(&self.refresh) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"refresh interval {} out of range {}..={}",
self.refresh,
Self::MIN_REFRESH,
Self::MAX_REFRESH
),
));
}
if !(Self::MIN_RETRY..=Self::MAX_RETRY).contains(&self.retry) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"retry interval {} out of range {}..={}",
self.retry,
Self::MIN_RETRY,
Self::MAX_RETRY
),
));
}
if !(Self::MIN_EXPIRE..=Self::MAX_EXPIRE).contains(&self.expire) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"expire interval {} out of range {}..={}",
self.expire,
Self::MIN_EXPIRE,
Self::MAX_EXPIRE
),
));
}
if self.expire <= self.refresh {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"expire interval {} must be greater than refresh interval {}",
self.expire, self.refresh
),
));
}
if self.expire <= self.retry {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"expire interval {} must be greater than retry interval {}",
self.expire, self.retry
),
));
}
Ok(())
}
pub fn refresh(self) -> Duration {
Duration::from_secs(u64::from(self.refresh))
}
pub fn retry(self) -> Duration {
Duration::from_secs(u64::from(self.retry))
}
pub fn expire(self) -> Duration {
Duration::from_secs(u64::from(self.expire))
}
}
impl Default for Timing {
fn default() -> Self {
Self {
refresh: 3600,
retry: 600,
expire: 7200,
}
}
}

1350
src/rtr/pdu.rs Normal file

File diff suppressed because it is too large Load Diff

777
src/rtr/report.rs Normal file
View File

@ -0,0 +1,777 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::time::Instant;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use serde::Serialize;
use tracing::warn;
use crate::rtr::cache::{CacheAvailability, CacheMemoryStats, SharedRtrCache, VersionReportStats};
use crate::rtr::config::{RuntimeConfig, format_timezone};
use crate::rtr::server::{RtrNotifier, RtrServiceStats, RtrTransportConnectionCounts};
use crate::source::pipeline::{
DataQualityReport, FileFingerprint, SourceFingerprint, SourceLoadReport,
};
#[derive(Clone, Serialize)]
pub struct ReportConfiguration {
source_refresh_interval_seconds: u64,
runtime_report_interval_seconds: u64,
report_history_limit: usize,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timezone: String,
timing: TimingReport,
}
#[derive(Clone, Copy, Serialize)]
struct TimingReport {
refresh: u32,
retry: u32,
expire: u32,
}
impl ReportConfiguration {
pub fn new(
source_refresh_interval_seconds: u64,
runtime_report_interval_seconds: u64,
report_history_limit: usize,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timezone: Tz,
timing: (u32, u32, u32),
) -> Self {
Self {
source_refresh_interval_seconds,
runtime_report_interval_seconds,
report_history_limit,
max_delta,
prune_delta_by_snapshot_size,
timezone: format_timezone(timezone),
timing: TimingReport {
refresh: timing.0,
retry: timing.1,
expire: timing.2,
},
}
}
pub fn timezone(&self) -> Tz {
self.timezone
.parse::<Tz>()
.expect("serialized timezone should be a valid IANA timezone")
}
}
#[derive(Clone)]
pub struct ReportContext {
started_at: DateTime<Utc>,
started_instant: Instant,
configuration: Arc<RwLock<ReportConfiguration>>,
runtime: Arc<RwLock<RuntimeReportState>>,
}
#[derive(Debug, Clone, Default)]
struct RuntimeReportState {
source: Option<SourceLoadReport>,
source_fingerprint: Option<SourceFingerprint>,
data_quality: Option<DataQualityReport>,
refresh: RefreshReport,
}
#[derive(Debug, Clone, Serialize)]
struct RefreshReport {
last_attempt_at: Option<DateTime<Utc>>,
last_success_at: Option<DateTime<Utc>>,
last_changed_at: Option<DateTime<Utc>>,
status: &'static str,
changed: Option<bool>,
duration_ms: Option<u128>,
consecutive_failures: u64,
last_error: Option<String>,
}
#[derive(Serialize)]
struct RefreshReportView {
last_attempt_at: Option<DateTime<Tz>>,
last_success_at: Option<DateTime<Tz>>,
last_changed_at: Option<DateTime<Tz>>,
status: &'static str,
changed: Option<bool>,
duration_ms: Option<u128>,
consecutive_failures: u64,
last_error: Option<String>,
}
impl RefreshReportView {
fn from_report(report: RefreshReport, timezone: Tz) -> Self {
Self {
last_attempt_at: report
.last_attempt_at
.map(|time| to_report_time(time, timezone)),
last_success_at: report
.last_success_at
.map(|time| to_report_time(time, timezone)),
last_changed_at: report
.last_changed_at
.map(|time| to_report_time(time, timezone)),
status: report.status,
changed: report.changed,
duration_ms: report.duration_ms,
consecutive_failures: report.consecutive_failures,
last_error: report.last_error,
}
}
}
impl Default for RefreshReport {
fn default() -> Self {
Self {
last_attempt_at: None,
last_success_at: None,
last_changed_at: None,
status: "not_attempted",
changed: None,
duration_ms: None,
consecutive_failures: 0,
last_error: None,
}
}
}
impl ReportContext {
pub fn new(configuration: ReportConfiguration) -> Self {
Self {
started_at: Utc::now(),
started_instant: Instant::now(),
configuration: Arc::new(RwLock::new(configuration)),
runtime: Arc::new(RwLock::new(RuntimeReportState::default())),
}
}
pub fn update_runtime_config(&self, config: &RuntimeConfig) {
let timezone = config
.timezone()
.expect("runtime config timezone should be validated before report update");
let mut configuration = self
.configuration
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
*configuration = ReportConfiguration::new(
config.source_refresh_interval_seconds,
config.runtime_report_interval_seconds,
config.report_history_limit,
config.max_delta,
config.prune_delta_by_snapshot_size,
timezone,
(
config.timing.refresh,
config.timing.retry,
config.timing.expire,
),
);
}
pub fn record_refresh_success(
&self,
attempted_at: DateTime<Utc>,
duration_ms: u128,
changed: bool,
source: SourceLoadReport,
data_quality: DataQualityReport,
) {
let mut state = self
.runtime
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.source = Some(source);
state.data_quality = Some(data_quality);
state.refresh.last_attempt_at = Some(attempted_at);
state.refresh.last_success_at = Some(Utc::now());
if changed {
state.refresh.last_changed_at = Some(Utc::now());
}
state.refresh.status = "success";
state.refresh.changed = Some(changed);
state.refresh.duration_ms = Some(duration_ms);
state.refresh.consecutive_failures = 0;
state.refresh.last_error = None;
}
pub fn record_source_fingerprint(&self, fingerprint: SourceFingerprint) {
let mut state = self
.runtime
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.source_fingerprint = Some(fingerprint);
}
pub fn record_refresh_unchanged(&self, attempted_at: DateTime<Utc>, duration_ms: u128) {
let mut state = self
.runtime
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.refresh.last_attempt_at = Some(attempted_at);
state.refresh.last_success_at = Some(Utc::now());
state.refresh.status = "success";
state.refresh.changed = Some(false);
state.refresh.duration_ms = Some(duration_ms);
state.refresh.consecutive_failures = 0;
state.refresh.last_error = None;
}
pub fn record_refresh_failure(
&self,
attempted_at: DateTime<Utc>,
duration_ms: u128,
error: &anyhow::Error,
) {
let mut state = self
.runtime
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.refresh.last_attempt_at = Some(attempted_at);
state.refresh.status = "failed";
state.refresh.changed = None;
state.refresh.duration_ms = Some(duration_ms);
state.refresh.consecutive_failures = state.refresh.consecutive_failures.saturating_add(1);
state.refresh.last_error = Some(error.to_string());
}
pub fn write_or_warn(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) {
if let Err(err) = self.write(report_dir, phase, shared_cache, notifier, service_stats) {
warn!(
"failed to write RTR report to {}: {:?}",
report_dir.display(),
err
);
}
}
pub fn write_source_or_warn(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) {
if let Err(err) =
self.write_source(report_dir, phase, shared_cache, notifier, service_stats)
{
warn!(
"failed to write RTR source report to {}: {:?}",
report_dir.display(),
err
);
}
}
pub fn write_clients_or_warn(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) {
if let Err(err) =
self.write_clients(report_dir, phase, shared_cache, notifier, service_stats)
{
warn!(
"failed to write RTR clients report to {}: {:?}",
report_dir.display(),
err
);
}
}
pub fn write_runtime_or_warn(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) {
if let Err(err) =
self.write_runtime(report_dir, phase, shared_cache, notifier, service_stats)
{
warn!(
"failed to write RTR runtime report to {}: {:?}",
report_dir.display(),
err
);
}
}
fn write(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) -> Result<()> {
let parts = self.build_reports(phase, shared_cache, notifier, service_stats);
fs::create_dir_all(report_dir)
.with_context(|| format!("create report directory {}", report_dir.display()))?;
self.write_source_report(report_dir, &parts.suffix, &parts.source)?;
self.write_clients_report(report_dir, &parts.suffix, &parts.clients)?;
self.write_runtime_report(report_dir, &parts.suffix, &parts.runtime)?;
Ok(())
}
fn write_source(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) -> Result<()> {
let parts = self.build_reports(phase, shared_cache, notifier, service_stats);
fs::create_dir_all(report_dir)
.with_context(|| format!("create report directory {}", report_dir.display()))?;
self.write_source_report(report_dir, &parts.suffix, &parts.source)
}
fn write_clients(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) -> Result<()> {
let parts = self.build_reports(phase, shared_cache, notifier, service_stats);
fs::create_dir_all(report_dir)
.with_context(|| format!("create report directory {}", report_dir.display()))?;
self.write_clients_report(report_dir, &parts.suffix, &parts.clients)
}
fn write_runtime(
&self,
report_dir: &Path,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) -> Result<()> {
let parts = self.build_reports(phase, shared_cache, notifier, service_stats);
fs::create_dir_all(report_dir)
.with_context(|| format!("create report directory {}", report_dir.display()))?;
self.write_runtime_report(report_dir, &parts.suffix, &parts.runtime)
}
fn build_reports(
&self,
phase: &str,
shared_cache: &SharedRtrCache,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
) -> ReportParts {
let cache = shared_cache.load_full();
let configuration = self
.configuration
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
let timezone = configuration.timezone();
let runtime = self
.runtime
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
let availability = match cache.availability() {
CacheAvailability::Ready => "ready",
CacheAvailability::NoDataAvailable => "no_data_available",
};
let active_connections = service_stats.active_connections();
let connections_by_transport = service_stats.transport_connections();
let max_connections = service_stats.max_connections();
let generated_at = self.report_now(timezone);
let metadata = ReportMetadata {
schema_version: 1,
generated_at,
phase: phase.to_string(),
};
let service = ServiceReport {
started_at: self.to_report_time(self.started_at, timezone),
uptime_seconds: self.started_instant.elapsed().as_secs(),
active_connections,
connections_by_transport: TransportConnectionReport::from(connections_by_transport),
session_listeners: notifier.listener_count(),
max_connections,
connection_utilization: active_connections as f64 / max_connections as f64,
};
let process = ProcessReport {
rss_mib: current_rss_mib(),
};
let source = runtime
.source
.map(|source| SourceLoadReportView::from_report(source, timezone));
let refresh = RefreshReportView::from_report(runtime.refresh, timezone);
let cache = CacheReport {
availability,
created_at: self.to_report_time(cache.created_at().utc(), timezone),
last_update_begin: self.to_report_time(cache.last_update_begin().utc(), timezone),
last_update_end: self.to_report_time(cache.last_update_end().utc(), timezone),
memory: cache.memory_stats(),
versions: cache.version_report_stats(),
};
let source_report = SourceReport {
schema_version: metadata.schema_version,
generated_at: metadata.generated_at,
phase: metadata.phase.clone(),
source,
source_fingerprint: runtime.source_fingerprint.map(|fingerprint| {
SourceFingerprintReport::from_fingerprint(fingerprint, timezone)
}),
refresh,
data_quality: runtime.data_quality,
cache,
};
let clients_report = ClientsReport {
schema_version: metadata.schema_version,
generated_at: metadata.generated_at,
phase: metadata.phase.clone(),
service: ServiceReport {
started_at: service.started_at,
uptime_seconds: service.uptime_seconds,
active_connections: service.active_connections,
connections_by_transport: service.connections_by_transport,
session_listeners: service.session_listeners,
max_connections: service.max_connections,
connection_utilization: service.connection_utilization,
},
};
let runtime_report = RuntimeReport {
schema_version: metadata.schema_version,
generated_at: metadata.generated_at,
phase: metadata.phase,
service,
process,
configuration,
};
let suffix = report_file_suffix(generated_at);
ReportParts {
suffix,
source: source_report,
clients: clients_report,
runtime: runtime_report,
}
}
fn write_source_report(
&self,
report_dir: &Path,
suffix: &str,
report: &SourceReport,
) -> Result<()> {
write_rolling_report(
report_dir,
"rtr-source",
suffix,
self.report_history_limit(),
report,
)
}
fn write_clients_report(
&self,
report_dir: &Path,
suffix: &str,
report: &ClientsReport,
) -> Result<()> {
write_rolling_report(
report_dir,
"rtr-clients",
suffix,
self.report_history_limit(),
report,
)
}
fn write_runtime_report(
&self,
report_dir: &Path,
suffix: &str,
report: &RuntimeReport,
) -> Result<()> {
write_rolling_report(
report_dir,
"rtr-runtime",
suffix,
self.report_history_limit(),
report,
)
}
fn report_history_limit(&self) -> usize {
self.configuration
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.report_history_limit
}
fn report_now(&self, timezone: Tz) -> DateTime<Tz> {
self.to_report_time(Utc::now(), timezone)
}
fn to_report_time(&self, time: DateTime<Utc>, timezone: Tz) -> DateTime<Tz> {
time.with_timezone(&timezone)
}
}
#[derive(Serialize, Clone)]
struct ReportMetadata {
schema_version: u16,
generated_at: DateTime<Tz>,
phase: String,
}
struct ReportParts {
suffix: String,
source: SourceReport,
clients: ClientsReport,
runtime: RuntimeReport,
}
#[derive(Serialize)]
struct SourceReport {
schema_version: u16,
generated_at: DateTime<Tz>,
phase: String,
source: Option<SourceLoadReportView>,
source_fingerprint: Option<SourceFingerprintReport>,
refresh: RefreshReportView,
data_quality: Option<DataQualityReport>,
cache: CacheReport,
}
#[derive(Serialize)]
struct ClientsReport {
schema_version: u16,
generated_at: DateTime<Tz>,
phase: String,
service: ServiceReport,
}
#[derive(Serialize)]
struct RuntimeReport {
schema_version: u16,
generated_at: DateTime<Tz>,
phase: String,
service: ServiceReport,
process: ProcessReport,
configuration: ReportConfiguration,
}
#[derive(Clone, Copy, Serialize)]
struct ServiceReport {
started_at: DateTime<Tz>,
uptime_seconds: u64,
active_connections: usize,
connections_by_transport: TransportConnectionReport,
session_listeners: usize,
max_connections: usize,
connection_utilization: f64,
}
#[derive(Serialize)]
struct SourceLoadReportView {
ccr_file: String,
ccr_file_size_bytes: u64,
ccr_modified_at: Option<DateTime<Tz>>,
ccr_produced_at: Option<String>,
slurm_enabled: bool,
slurm_file_count: usize,
slurm_files: Vec<String>,
slurm_version: Option<u32>,
}
#[derive(Serialize)]
struct SourceFingerprintReport {
ccr: FileFingerprintReport,
slurm_files: Vec<FileFingerprintReport>,
}
impl SourceFingerprintReport {
fn from_fingerprint(fingerprint: SourceFingerprint, timezone: Tz) -> Self {
Self {
ccr: FileFingerprintReport::from_fingerprint(fingerprint.ccr, timezone),
slurm_files: fingerprint
.slurm_files
.into_iter()
.map(|fingerprint| FileFingerprintReport::from_fingerprint(fingerprint, timezone))
.collect(),
}
}
}
#[derive(Serialize)]
struct FileFingerprintReport {
path: String,
len: u64,
modified_unix_secs: u64,
modified_at: Option<DateTime<Tz>>,
}
impl FileFingerprintReport {
fn from_fingerprint(fingerprint: FileFingerprint, timezone: Tz) -> Self {
Self {
path: fingerprint.path,
len: fingerprint.len,
modified_unix_secs: fingerprint.modified_unix_secs,
modified_at: DateTime::from_timestamp(fingerprint.modified_unix_secs as i64, 0)
.map(|time| to_report_time(time, timezone)),
}
}
}
impl SourceLoadReportView {
fn from_report(report: SourceLoadReport, timezone: Tz) -> Self {
Self {
ccr_file: report.ccr_file,
ccr_file_size_bytes: report.ccr_file_size_bytes,
ccr_modified_at: report
.ccr_modified_at
.map(|time| to_report_time(time, timezone)),
ccr_produced_at: report.ccr_produced_at,
slurm_enabled: report.slurm_enabled,
slurm_file_count: report.slurm_file_count,
slurm_files: report.slurm_files,
slurm_version: report.slurm_version,
}
}
}
#[derive(Clone, Copy, Serialize)]
struct TransportConnectionReport {
tcp: usize,
tls: usize,
ssh: usize,
}
impl From<RtrTransportConnectionCounts> for TransportConnectionReport {
fn from(counts: RtrTransportConnectionCounts) -> Self {
Self {
tcp: counts.tcp,
tls: counts.tls,
ssh: counts.ssh,
}
}
}
#[derive(Serialize)]
struct ProcessReport {
rss_mib: Option<u64>,
}
#[derive(Serialize)]
struct CacheReport {
availability: &'static str,
created_at: DateTime<Tz>,
last_update_begin: DateTime<Tz>,
last_update_end: DateTime<Tz>,
memory: CacheMemoryStats,
versions: [VersionReportStats; 3],
}
pub fn to_report_time(time: DateTime<Utc>, timezone: Tz) -> DateTime<Tz> {
time.with_timezone(&timezone)
}
pub fn current_rss_mib() -> Option<u64> {
let status = fs::read_to_string("/proc/self/status").ok()?;
let vmrss_line = status.lines().find(|line| line.starts_with("VmRSS:"))?;
let kb = vmrss_line
.split_whitespace()
.nth(1)
.and_then(|value| value.parse::<u64>().ok())?;
Some(kb / 1024)
}
fn report_file_suffix(time: DateTime<Tz>) -> String {
time.format("%Y%m%d%H%M%S%9f").to_string()
}
fn write_rolling_report<T: Serialize>(
report_dir: &Path,
prefix: &str,
suffix: &str,
keep: usize,
report: &T,
) -> Result<()> {
let file_name = format!("{prefix}-{suffix}.json");
let target = report_dir.join(&file_name);
let temporary = report_dir.join(format!(".{file_name}.tmp"));
let json = serde_json::to_vec_pretty(report)
.with_context(|| format!("serialize {prefix} RTR report"))?;
fs::write(&temporary, json)
.with_context(|| format!("write temporary report {}", temporary.display()))?;
replace_file(&temporary, &target)?;
prune_rolling_reports(report_dir, prefix, keep)?;
Ok(())
}
fn replace_file(temporary: &Path, target: &Path) -> Result<()> {
if let Err(err) = fs::rename(temporary, target) {
if target.exists() {
fs::remove_file(target)
.with_context(|| format!("replace existing report {}", target.display()))?;
fs::rename(temporary, target)
.with_context(|| format!("move report into {}", target.display()))?;
} else {
return Err(err).with_context(|| {
format!(
"move temporary report {} into {}",
temporary.display(),
target.display()
)
});
}
}
Ok(())
}
fn prune_rolling_reports(report_dir: &Path, prefix: &str, keep: usize) -> Result<()> {
let start = format!("{prefix}-");
let mut files = Vec::<PathBuf>::new();
for entry in fs::read_dir(report_dir)
.with_context(|| format!("read report directory {}", report_dir.display()))?
{
let entry =
entry.with_context(|| format!("iterate report directory {}", report_dir.display()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if name.starts_with(&start) && name.ends_with(".json") {
files.push(path);
}
}
files.sort();
let remove_count = files.len().saturating_sub(keep);
for path in files.into_iter().take(remove_count) {
fs::remove_file(&path)
.with_context(|| format!("remove old rolling report {}", path.display()))?;
}
Ok(())
}

394
src/rtr/runtime.rs Normal file
View File

@ -0,0 +1,394 @@
use std::path::PathBuf;
use std::time::Instant;
use anyhow::Result;
use chrono::Utc;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::{info, warn};
use crate::rtr::admin::{RuntimeConfigHandle, SourceReloadCommand, SourceReloadResult};
use crate::rtr::cache::{SharedRtrCache, Snapshot};
use crate::rtr::config::{AppConfig, RuntimeConfig};
use crate::rtr::report::{ReportContext, current_rss_mib};
use crate::rtr::server::{RtrNotifier, RtrServiceStats};
use crate::rtr::store::RtrStore;
use crate::source::pipeline::{
PayloadLoadConfig, SourceFingerprint, latest_sources_fingerprint,
load_payloads_from_latest_sources_with_report,
};
pub fn spawn_refresh_task(
config: &AppConfig,
runtime_config: RuntimeConfigHandle,
mut source_reload_rx: mpsc::Receiver<SourceReloadCommand>,
shared_cache: SharedRtrCache,
store: RtrStore,
notifier: RtrNotifier,
service_stats: RtrServiceStats,
report_context: ReportContext,
) -> JoinHandle<()> {
let report_dir = PathBuf::from(&config.report_dir);
let payload_load_config = PayloadLoadConfig {
ccr_dir: config.ccr_dir.clone(),
slurm_dir: config.slurm_dir.clone(),
};
let initial_runtime_config = runtime_config.current();
tokio::spawn(async move {
let mut active_runtime_config = initial_runtime_config;
let mut config_rx = runtime_config.subscribe();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
active_runtime_config.source_refresh_interval_seconds,
));
let mut last_fingerprint: Option<SourceFingerprint> = None;
report_context.write_source_or_warn(
&report_dir,
"startup",
&shared_cache,
&notifier,
&service_stats,
);
report_context.write_clients_or_warn(
&report_dir,
"startup",
&shared_cache,
&notifier,
&service_stats,
);
report_context.write_runtime_or_warn(
&report_dir,
"startup",
&shared_cache,
&notifier,
&service_stats,
);
let mut runtime_interval = tokio::time::interval_at(
tokio::time::Instant::now()
+ std::time::Duration::from_secs(
active_runtime_config.runtime_report_interval_seconds,
),
std::time::Duration::from_secs(active_runtime_config.runtime_report_interval_seconds),
);
runtime_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut client_change_rx = service_stats.subscribe_connection_changes();
loop {
tokio::select! {
changed = config_rx.changed() => {
match changed {
Ok(()) => {
let next_config = config_rx.borrow().clone();
apply_runtime_config_update(
&mut active_runtime_config,
next_config,
&shared_cache,
&store,
&report_context,
&mut interval,
&mut runtime_interval,
);
report_context.write_runtime_or_warn(&report_dir, "admin_config_changed", &shared_cache, &notifier, &service_stats);
}
Err(_) => {
warn!("RTR runtime config change channel closed");
}
}
continue;
}
changed = client_change_rx.changed() => {
match changed {
Ok(()) => {
report_context.write_clients_or_warn(&report_dir, "clients_changed", &shared_cache, &notifier, &service_stats);
}
Err(_) => {
warn!("RTR client connection change channel closed");
}
}
continue;
}
_ = runtime_interval.tick() => {
log_cache_memory_stats("periodic_observe", &shared_cache, &notifier);
report_context.write_runtime_or_warn(&report_dir, "runtime_periodic", &shared_cache, &notifier, &service_stats);
continue;
}
_ = interval.tick() => {}
command = source_reload_rx.recv() => {
let Some(command) = command else {
warn!("RTR source reload admin channel closed");
continue;
};
let result = perform_source_refresh(
command.phase,
command.force,
&payload_load_config,
&shared_cache,
&store,
&notifier,
&service_stats,
&report_context,
&report_dir,
&mut last_fingerprint,
);
let _ = command.respond_to.send(result.map_err(|err| err.to_string()));
continue;
}
}
let _ = perform_source_refresh(
"refresh_complete",
false,
&payload_load_config,
&shared_cache,
&store,
&notifier,
&service_stats,
&report_context,
&report_dir,
&mut last_fingerprint,
);
}
})
}
#[allow(clippy::too_many_arguments)]
fn perform_source_refresh(
phase: &'static str,
force: bool,
payload_load_config: &PayloadLoadConfig,
shared_cache: &SharedRtrCache,
store: &RtrStore,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
report_context: &ReportContext,
report_dir: &PathBuf,
last_fingerprint: &mut Option<SourceFingerprint>,
) -> Result<SourceReloadResult> {
let source_to_delta_started = Instant::now();
let attempted_at = Utc::now();
let current_fingerprint = match latest_sources_fingerprint(payload_load_config) {
Ok(fp) => fp,
Err(err) => {
report_context.record_refresh_failure(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
&err,
);
warn!(
"failed to fingerprint CCR/SLURM sources from {}: {:?} (source_to_delta_elapsed_ms={})",
payload_load_config.ccr_dir,
err,
source_to_delta_started.elapsed().as_millis()
);
report_context.write_source_or_warn(
report_dir,
"refresh_failed",
shared_cache,
notifier,
service_stats,
);
return Err(err);
}
};
report_context.record_source_fingerprint(current_fingerprint.clone());
if !force && last_fingerprint.as_ref() == Some(&current_fingerprint) {
report_context
.record_refresh_unchanged(attempted_at, source_to_delta_started.elapsed().as_millis());
info!(
"RTR source refresh skipped: source files unchanged (ccr_path={}, slurm_file_count={}, elapsed_ms={})",
current_fingerprint.ccr.path,
current_fingerprint.slurm_files.len(),
source_to_delta_started.elapsed().as_millis()
);
log_cache_memory_stats("refresh_skipped_unchanged", shared_cache, notifier);
report_context.write_source_or_warn(
report_dir,
"refresh_skipped_unchanged",
shared_cache,
notifier,
service_stats,
);
return Ok(SourceReloadResult {
phase,
changed: false,
skipped_unchanged: true,
payload_count: None,
serials: shared_cache.load_full().serials(),
});
}
let load = match load_payloads_from_latest_sources_with_report(payload_load_config) {
Ok(load) => load,
Err(err) => {
report_context.record_refresh_failure(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
&err,
);
warn!(
"failed to reload CCR/SLURM payloads from {}: {:?} (source_to_delta_elapsed_ms={})",
payload_load_config.ccr_dir,
err,
source_to_delta_started.elapsed().as_millis()
);
report_context.write_source_or_warn(
report_dir,
"refresh_failed",
shared_cache,
notifier,
service_stats,
);
return Err(err);
}
};
let source = load.source;
let quality = load.quality;
let payloads = load.payloads;
let payload_count = payloads.len();
let source_snapshot = Snapshot::from_payloads(payloads);
let old_cache = shared_cache.load_full();
let old_serial = old_cache.serial_for_version(2);
let mut next_cache = old_cache.as_ref().clone();
let updated = match next_cache.update_with_snapshot(source_snapshot, store) {
Ok(()) => {
let new_serial = next_cache.serial_for_version(2);
shared_cache.store(std::sync::Arc::new(next_cache));
if new_serial != old_serial {
info!(
"RTR cache refresh applied: ccr_dir={}, payload_count={}, old_serial={}, new_serial={}",
payload_load_config.ccr_dir, payload_count, old_serial, new_serial
);
true
} else {
info!(
"RTR cache refresh found no change: ccr_dir={}, payload_count={}, serial={}",
payload_load_config.ccr_dir, payload_count, old_serial
);
false
}
}
Err(err) => {
report_context.record_refresh_failure(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
&err,
);
warn!("RTR cache update failed: {:?}", err);
report_context.write_source_or_warn(
report_dir,
"refresh_failed",
shared_cache,
notifier,
service_stats,
);
return Err(err);
}
};
report_context.record_refresh_success(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
updated,
source,
quality,
);
info!(
"RTR source-to-delta timing: phase={}, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}",
phase,
payload_load_config.ccr_dir,
payload_count,
updated,
source_to_delta_started.elapsed().as_millis()
);
if updated {
let listener_count = notifier.notify_cache_updated();
info!(
"RTR cache updated, notify signal emitted to session listeners: listener_count={}",
listener_count
);
}
log_cache_memory_stats(phase, shared_cache, notifier);
report_context.write_source_or_warn(report_dir, phase, shared_cache, notifier, service_stats);
last_fingerprint.replace(current_fingerprint);
Ok(SourceReloadResult {
phase,
changed: updated,
skipped_unchanged: false,
payload_count: Some(payload_count),
serials: shared_cache.load_full().serials(),
})
}
fn apply_runtime_config_update(
active: &mut RuntimeConfig,
next: RuntimeConfig,
shared_cache: &SharedRtrCache,
store: &RtrStore,
report_context: &ReportContext,
refresh_interval: &mut tokio::time::Interval,
runtime_interval: &mut tokio::time::Interval,
) {
let old = active.clone();
*active = next.clone();
report_context.update_runtime_config(&next);
{
let old_cache = shared_cache.load_full();
let mut next_cache = old_cache.as_ref().clone();
next_cache.update_runtime_config(
next.max_delta,
next.prune_delta_by_snapshot_size,
next.timing(),
store,
);
shared_cache.store(std::sync::Arc::new(next_cache));
}
if old.source_refresh_interval_seconds != next.source_refresh_interval_seconds {
*refresh_interval = tokio::time::interval(std::time::Duration::from_secs(
next.source_refresh_interval_seconds,
));
refresh_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
if old.runtime_report_interval_seconds != next.runtime_report_interval_seconds {
*runtime_interval = tokio::time::interval_at(
tokio::time::Instant::now()
+ std::time::Duration::from_secs(next.runtime_report_interval_seconds),
std::time::Duration::from_secs(next.runtime_report_interval_seconds),
);
runtime_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
}
info!(
"RTR runtime config applied: max_delta={}, prune_delta_by_snapshot_size={}, source_refresh_interval_seconds={}, runtime_report_interval_seconds={}, report_history_limit={}, timezone={}, timing=({}, {}, {})",
next.max_delta,
next.prune_delta_by_snapshot_size,
next.source_refresh_interval_seconds,
next.runtime_report_interval_seconds,
next.report_history_limit,
next.timezone,
next.timing.refresh,
next.timing.retry,
next.timing.expire
);
}
fn log_cache_memory_stats(phase: &str, shared_cache: &SharedRtrCache, notifier: &RtrNotifier) {
let cache = shared_cache.load_full();
let stats = cache.memory_stats();
let rss_mib = current_rss_mib();
info!(
"RTR memory observe: phase={}, listener_count={}, serials={:?}, snapshot_payload_counts={:?}, delta_lengths={:?}, delta_payload_counts={:?}, rss_mib={:?}",
phase,
notifier.listener_count(),
stats.serials,
stats.snapshot_payload_counts,
stats.delta_lengths,
stats.delta_payload_counts,
rss_mib
);
}

26
src/rtr/server/config.rs Normal file
View File

@ -0,0 +1,26 @@
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct RtrServiceConfig {
pub max_connections: usize,
pub max_concurrent_handshakes: usize,
pub notify_queue_size: usize,
pub tcp_keepalive: Option<Duration>,
pub warn_insecure_tcp: bool,
pub require_tls_server_dns_name_san: bool,
pub enforce_tls_client_san_ip_match: bool,
}
impl Default for RtrServiceConfig {
fn default() -> Self {
Self {
max_connections: 1024,
max_concurrent_handshakes: 128,
notify_queue_size: 1024,
tcp_keepalive: Some(Duration::from_secs(60)),
warn_insecure_tcp: true,
require_tls_server_dns_name_san: false,
enforce_tls_client_san_ip_match: true,
}
}
}

View File

@ -0,0 +1,240 @@
use std::net::{IpAddr, SocketAddr};
use std::sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
};
use anyhow::{Context, Result, anyhow};
use tokio::net::TcpStream;
use tokio::sync::{OwnedSemaphorePermit, broadcast, watch};
use tracing::{info, warn};
use x509_parser::extensions::GeneralName;
use x509_parser::prelude::{FromDer, X509Certificate};
use tokio_rustls::TlsAcceptor;
use crate::rtr::cache::SharedRtrCache;
use crate::rtr::server::service::RtrServiceControl;
use crate::rtr::session::RtrSession;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RtrTransportKind {
Tcp,
Tls,
Ssh,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct RtrTransportConnectionCounts {
pub tcp: usize,
pub tls: usize,
pub ssh: usize,
}
#[derive(Default)]
pub struct RtrTransportConnectionCounters {
tcp: AtomicUsize,
tls: AtomicUsize,
ssh: AtomicUsize,
}
impl RtrTransportConnectionCounters {
pub fn increment(&self, transport: RtrTransportKind) {
self.counter(transport).fetch_add(1, Ordering::Relaxed);
}
pub fn decrement(&self, transport: RtrTransportKind) {
self.counter(transport).fetch_sub(1, Ordering::Relaxed);
}
pub fn snapshot(&self) -> RtrTransportConnectionCounts {
RtrTransportConnectionCounts {
tcp: self.tcp.load(Ordering::Relaxed),
tls: self.tls.load(Ordering::Relaxed),
ssh: self.ssh.load(Ordering::Relaxed),
}
}
fn counter(&self, transport: RtrTransportKind) -> &AtomicUsize {
match transport {
RtrTransportKind::Tcp => &self.tcp,
RtrTransportKind::Tls => &self.tls,
RtrTransportKind::Ssh => &self.ssh,
}
}
}
pub struct ConnectionGuard {
active_connections: Arc<AtomicUsize>,
transport_connections: Arc<RtrTransportConnectionCounters>,
connection_change_tx: watch::Sender<u64>,
connection_change_seq: Arc<AtomicU64>,
transport: RtrTransportKind,
_permit: OwnedSemaphorePermit,
}
impl ConnectionGuard {
pub fn new(
active_connections: Arc<AtomicUsize>,
transport_connections: Arc<RtrTransportConnectionCounters>,
connection_change_tx: watch::Sender<u64>,
connection_change_seq: Arc<AtomicU64>,
transport: RtrTransportKind,
permit: OwnedSemaphorePermit,
) -> Self {
active_connections.fetch_add(1, Ordering::Relaxed);
transport_connections.increment(transport);
notify_connection_change(&connection_change_tx, &connection_change_seq);
Self {
active_connections,
transport_connections,
connection_change_tx,
connection_change_seq,
transport,
_permit: permit,
}
}
pub fn active_count(&self) -> usize {
self.active_connections.load(Ordering::Relaxed)
}
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
self.active_connections.fetch_sub(1, Ordering::Relaxed);
self.transport_connections.decrement(self.transport);
notify_connection_change(&self.connection_change_tx, &self.connection_change_seq);
}
}
fn notify_connection_change(tx: &watch::Sender<u64>, seq: &AtomicU64) {
let next = seq.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
let _ = tx.send_replace(next);
}
pub async fn handle_tcp_connection(
cache: SharedRtrCache,
stream: TcpStream,
peer_addr: SocketAddr,
notify_rx: broadcast::Receiver<()>,
shutdown_rx: watch::Receiver<RtrServiceControl>,
) -> Result<()> {
let session = RtrSession::new(cache, stream, notify_rx, shutdown_rx);
session.run().await?;
info!("RTR TCP session completed normally for {}", peer_addr);
Ok(())
}
pub async fn handle_tls_connection(
cache: SharedRtrCache,
stream: TcpStream,
peer_addr: SocketAddr,
acceptor: TlsAcceptor,
enforce_client_san_ip_match: bool,
notify_rx: broadcast::Receiver<()>,
shutdown_rx: watch::Receiver<RtrServiceControl>,
handshake_permit: Option<OwnedSemaphorePermit>,
) -> Result<()> {
info!("RTR TLS handshake started for {}", peer_addr);
let tls_stream = acceptor
.accept(stream)
.await
.with_context(|| format!("TLS handshake failed for {}", peer_addr))?;
info!("RTR TLS handshake completed for {}", peer_addr);
match verify_peer_certificate_ip(&tls_stream, peer_addr.ip()) {
Ok(()) => info!(
"RTR TLS client certificate SAN IP validated for {}",
peer_addr
),
Err(err) => {
if enforce_client_san_ip_match {
return Err(err).with_context(|| {
format!(
"TLS client certificate SAN IP validation failed for {}",
peer_addr
)
});
}
warn!(
"RTR TLS client certificate SAN IP validation failed but allowed by configuration for {}: {}",
peer_addr, err
);
}
}
drop(handshake_permit);
let session = RtrSession::new(cache, tls_stream, notify_rx, shutdown_rx);
session.run().await?;
info!("RTR TLS session completed normally for {}", peer_addr);
Ok(())
}
pub fn is_expected_disconnect(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
return matches!(
io_err.kind(),
std::io::ErrorKind::BrokenPipe
| std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::NotConnected
) || io_err.raw_os_error() == Some(10054);
}
false
})
}
fn verify_peer_certificate_ip(
tls_stream: &tokio_rustls::server::TlsStream<TcpStream>,
peer_ip: IpAddr,
) -> Result<()> {
let (_, server_connection) = tls_stream.get_ref();
let peer_certs = server_connection
.peer_certificates()
.ok_or_else(|| anyhow!("missing peer certificate after TLS client authentication"))?;
let end_entity = peer_certs
.first()
.ok_or_else(|| anyhow!("peer did not present an end-entity certificate"))?;
let (_, cert) = X509Certificate::from_der(end_entity.as_ref())
.map_err(|err| anyhow!("failed to parse peer certificate: {:?}", err))?;
let san = cert
.subject_alternative_name()
.map_err(|err| anyhow!("failed to parse peer certificate SAN: {:?}", err))?
.ok_or_else(|| anyhow!("peer certificate is missing subjectAltName"))?;
let matched = san.value.general_names.iter().any(|name| match name {
GeneralName::IPAddress(bytes) => {
let bytes = *bytes;
match (peer_ip, bytes.len()) {
(IpAddr::V4(ip), 4) => <[u8; 4]>::try_from(bytes)
.map(IpAddr::from)
.map(|cert_ip| cert_ip == IpAddr::V4(ip))
.unwrap_or(false),
(IpAddr::V6(ip), 16) => <[u8; 16]>::try_from(bytes)
.map(IpAddr::from)
.map(|cert_ip| cert_ip == IpAddr::V6(ip))
.unwrap_or(false),
_ => false,
}
}
_ => false,
});
if matched {
Ok(())
} else {
warn!(
"RTR TLS client certificate SAN IP mismatch for peer_ip={}",
peer_ip
);
Err(anyhow!(
"peer certificate subjectAltName iPAddress does not match {}",
peer_ip
))
}
}

681
src/rtr/server/listener.rs Normal file
View File

@ -0,0 +1,681 @@
use std::collections::HashMap;
use std::future::Future;
use std::net::SocketAddr;
use std::path::Path;
use std::pin::Pin;
use std::sync::{
Arc,
atomic::{AtomicU64, AtomicUsize},
};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use russh::server::{self, Msg, Session};
use russh::{Channel, ChannelId, Disconnect};
use socket2::{SockRef, TcpKeepalive};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{OwnedSemaphorePermit, Semaphore, broadcast, watch};
use tokio_rustls::TlsAcceptor;
use tracing::{debug, info, warn};
use rustls::ServerConfig;
use crate::rtr::cache::SharedRtrCache;
use crate::rtr::server::config::RtrServiceConfig;
use crate::rtr::server::connection::{
ConnectionGuard, RtrTransportConnectionCounters, RtrTransportKind, handle_tcp_connection,
handle_tls_connection, is_expected_disconnect,
};
use crate::rtr::server::service::RtrServiceControl;
use crate::rtr::server::ssh::RtrSshRuntimeConfig;
use crate::rtr::server::tls::load_rustls_server_config_with_options;
use crate::rtr::session::RtrSession;
type TransportFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
pub trait TransportAcceptor: Clone + Send + Sync + 'static {
fn name(&self) -> &'static str;
fn kind(&self) -> RtrTransportKind;
fn requires_handshake_limit(&self) -> bool {
false
}
fn handle_connection(
&self,
cache: SharedRtrCache,
stream: TcpStream,
peer_addr: SocketAddr,
notify_tx: broadcast::Sender<()>,
shutdown_tx: watch::Sender<RtrServiceControl>,
handshake_permit: Option<OwnedSemaphorePermit>,
) -> TransportFuture;
}
#[derive(Clone)]
struct TcpTransport;
impl TransportAcceptor for TcpTransport {
fn name(&self) -> &'static str {
"TCP"
}
fn kind(&self) -> RtrTransportKind {
RtrTransportKind::Tcp
}
fn handle_connection(
&self,
cache: SharedRtrCache,
stream: TcpStream,
peer_addr: SocketAddr,
notify_tx: broadcast::Sender<()>,
shutdown_tx: watch::Sender<RtrServiceControl>,
_handshake_permit: Option<OwnedSemaphorePermit>,
) -> TransportFuture {
Box::pin(async move {
handle_tcp_connection(
cache,
stream,
peer_addr,
notify_tx.subscribe(),
shutdown_tx.subscribe(),
)
.await
})
}
}
#[derive(Clone)]
struct TlsTransport {
acceptor: TlsAcceptor,
enforce_client_san_ip_match: bool,
}
impl TransportAcceptor for TlsTransport {
fn name(&self) -> &'static str {
"TLS"
}
fn kind(&self) -> RtrTransportKind {
RtrTransportKind::Tls
}
fn requires_handshake_limit(&self) -> bool {
true
}
fn handle_connection(
&self,
cache: SharedRtrCache,
stream: TcpStream,
peer_addr: SocketAddr,
notify_tx: broadcast::Sender<()>,
shutdown_tx: watch::Sender<RtrServiceControl>,
handshake_permit: Option<OwnedSemaphorePermit>,
) -> TransportFuture {
let acceptor = self.acceptor.clone();
let enforce_client_san_ip_match = self.enforce_client_san_ip_match;
Box::pin(async move {
handle_tls_connection(
cache,
stream,
peer_addr,
acceptor,
enforce_client_san_ip_match,
notify_tx.subscribe(),
shutdown_tx.subscribe(),
handshake_permit,
)
.await
})
}
}
#[derive(Clone)]
struct SshTransport {
runtime: Arc<RtrSshRuntimeConfig>,
}
impl TransportAcceptor for SshTransport {
fn name(&self) -> &'static str {
"SSH"
}
fn kind(&self) -> RtrTransportKind {
RtrTransportKind::Ssh
}
fn handle_connection(
&self,
cache: SharedRtrCache,
stream: TcpStream,
peer_addr: SocketAddr,
notify_tx: broadcast::Sender<()>,
shutdown_tx: watch::Sender<RtrServiceControl>,
_handshake_permit: Option<OwnedSemaphorePermit>,
) -> TransportFuture {
let runtime = self.runtime.clone();
Box::pin(async move {
let handler = RtrSshHandler::new(
cache,
notify_tx.subscribe(),
shutdown_tx.subscribe(),
peer_addr,
runtime.authorized_keys.clone(),
runtime.username.clone(),
runtime.subsystem_name.clone(),
runtime.password.clone(),
);
let running = server::run_stream(runtime.server_config.clone(), stream, handler)
.await
.with_context(|| format!("failed to start SSH session for {}", peer_addr))?;
let handle = running.handle();
let mut connection_shutdown_rx = shutdown_tx.subscribe();
tokio::select! {
session_res = running => {
session_res.map_err(|err| anyhow!(err))
}
changed = connection_shutdown_rx.changed() => {
match changed {
Ok(()) if connection_shutdown_rx.borrow().shutdown_reason().is_some() => {
tokio::time::sleep(Duration::from_secs(2)).await;
let _ = handle
.disconnect(
Disconnect::ByApplication,
"RTR service shutdown".to_string(),
"".to_string(),
)
.await;
}
Ok(()) | Err(_) => {}
}
Ok(())
}
}
})
}
}
pub struct RtrServer {
bind_addr: SocketAddr,
cache: SharedRtrCache,
notify_tx: broadcast::Sender<()>,
shutdown_tx: watch::Sender<RtrServiceControl>,
connection_limiter: Arc<Semaphore>,
handshake_limiter: Arc<Semaphore>,
active_connections: Arc<AtomicUsize>,
transport_connections: Arc<RtrTransportConnectionCounters>,
connection_change_tx: watch::Sender<u64>,
connection_change_seq: Arc<AtomicU64>,
config: RtrServiceConfig,
}
impl RtrServer {
pub fn new(
bind_addr: SocketAddr,
cache: SharedRtrCache,
notify_tx: broadcast::Sender<()>,
shutdown_tx: watch::Sender<RtrServiceControl>,
connection_limiter: Arc<Semaphore>,
handshake_limiter: Arc<Semaphore>,
active_connections: Arc<AtomicUsize>,
transport_connections: Arc<RtrTransportConnectionCounters>,
connection_change_tx: watch::Sender<u64>,
connection_change_seq: Arc<AtomicU64>,
config: RtrServiceConfig,
) -> Self {
Self {
bind_addr,
cache,
notify_tx,
shutdown_tx,
connection_limiter,
handshake_limiter,
active_connections,
transport_connections,
connection_change_tx,
connection_change_seq,
config,
}
}
pub fn bind_addr(&self) -> SocketAddr {
self.bind_addr
}
pub fn cache(&self) -> SharedRtrCache {
self.cache.clone()
}
pub fn active_connections(&self) -> usize {
self.active_connections
.load(std::sync::atomic::Ordering::Relaxed)
}
pub async fn run_tcp(self) -> Result<()> {
self.run_with_transport(TcpTransport).await
}
pub async fn run_tls_from_pem(
self,
cert_path: impl AsRef<Path>,
key_path: impl AsRef<Path>,
client_ca_path: impl AsRef<Path>,
) -> Result<()> {
let tls_config = Arc::new(load_rustls_server_config_with_options(
cert_path,
key_path,
client_ca_path,
self.config.require_tls_server_dns_name_san,
)?);
self.run_tls(tls_config).await
}
pub async fn run_tls(self, tls_config: Arc<ServerConfig>) -> Result<()> {
let transport = TlsTransport {
acceptor: TlsAcceptor::from(tls_config),
enforce_client_san_ip_match: self.config.enforce_tls_client_san_ip_match,
};
self.run_with_transport(transport).await
}
pub async fn run_ssh(self, runtime_config: Arc<RtrSshRuntimeConfig>) -> Result<()> {
let transport = SshTransport {
runtime: runtime_config,
};
self.run_with_transport(transport).await
}
async fn run_with_transport<T>(self, transport: T) -> Result<()>
where
T: TransportAcceptor,
{
let listener = TcpListener::bind(self.bind_addr).await.with_context(|| {
format!(
"failed to bind {} RTR server on {}",
transport.name(),
self.bind_addr
)
})?;
let mut shutdown_rx = self.shutdown_tx.subscribe();
info!(
"RTR {} server listening on {}",
transport.name(),
self.bind_addr
);
loop {
tokio::select! {
changed = shutdown_rx.changed() => {
match changed {
Ok(()) => {
if let Some(reason) = shutdown_rx.borrow().shutdown_reason() {
info!(
"RTR {} listener {} shutting down: reason={}",
transport.name(),
self.bind_addr,
reason.as_str()
);
return Ok(());
}
}
Err(_) => {
info!(
"RTR {} listener {} shutdown channel closed",
transport.name(),
self.bind_addr
);
return Ok(());
}
}
}
accept_res = listener.accept() => {
let (stream, peer_addr) = match accept_res {
Ok(v) => v,
Err(err) => {
warn!("RTR {} accept failed: {}", transport.name(), err);
continue;
}
};
if let Err(err) = apply_keepalive(&stream, self.config.tcp_keepalive) {
warn!(
"failed to configure TCP keepalive for {} peer {}: {}",
transport.name(),
peer_addr,
err
);
}
let permit = match self.connection_limiter.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
warn!(
"RTR {} connection rejected for {}: max connections reached ({})",
transport.name(),
peer_addr,
self.config.max_connections
);
drop(stream);
continue;
}
};
let handshake_permit = if transport.requires_handshake_limit() {
match self.handshake_limiter.clone().try_acquire_owned() {
Ok(permit) => Some(permit),
Err(_) => {
warn!(
"RTR {} connection rejected for {}: max concurrent handshakes reached ({})",
transport.name(),
peer_addr,
self.config.max_concurrent_handshakes
);
drop(stream);
drop(permit);
continue;
}
}
} else {
None
};
let cache = self.cache.clone();
let notify_tx = self.notify_tx.clone();
let shutdown_tx = self.shutdown_tx.clone();
let active_connections = self.active_connections.clone();
let transport_connections = self.transport_connections.clone();
let connection_change_tx = self.connection_change_tx.clone();
let connection_change_seq = self.connection_change_seq.clone();
let transport_instance = transport.clone();
let transport_kind = transport_instance.kind();
debug!(
"RTR {} client connected: peer_addr={}, active_connections(before_spawn)={}",
transport_instance.name(),
peer_addr,
self.active_connections()
);
tokio::spawn(async move {
let guard = ConnectionGuard::new(
active_connections,
transport_connections,
connection_change_tx,
connection_change_seq,
transport_kind,
permit,
);
info!(
"RTR {} connection established: peer_addr={}, active_connections={}",
transport_instance.name(),
peer_addr,
guard.active_count()
);
if let Err(err) = transport_instance
.handle_connection(
cache,
stream,
peer_addr,
notify_tx,
shutdown_tx,
handshake_permit,
)
.await
{
let active_after_close = guard.active_count().saturating_sub(1);
if is_expected_disconnect(&err) {
info!(
"RTR {} session closed by peer: peer_addr={}, active_connections={}, err={}",
transport_instance.name(),
peer_addr,
active_after_close,
err
);
} else {
warn!(
"RTR {} session closed with error: peer_addr={}, active_connections={}, err={}",
transport_instance.name(),
peer_addr,
active_after_close,
err
);
}
} else {
let active_after_close = guard.active_count().saturating_sub(1);
info!(
"RTR {} session closed cleanly: peer_addr={}, active_connections={}",
transport_instance.name(),
peer_addr,
active_after_close
);
}
});
}
}
}
}
}
struct RtrSshHandler {
cache: SharedRtrCache,
notify_rx: broadcast::Receiver<()>,
shutdown_rx: watch::Receiver<RtrServiceControl>,
peer_addr: SocketAddr,
authorized_keys: Arc<Vec<russh::keys::ssh_key::PublicKey>>,
username: Arc<str>,
subsystem_name: Arc<str>,
password: Option<Arc<str>>,
channels: HashMap<ChannelId, Channel<Msg>>,
subsystem_started: bool,
}
impl RtrSshHandler {
fn new(
cache: SharedRtrCache,
notify_rx: broadcast::Receiver<()>,
shutdown_rx: watch::Receiver<RtrServiceControl>,
peer_addr: SocketAddr,
authorized_keys: Arc<Vec<russh::keys::ssh_key::PublicKey>>,
username: Arc<str>,
subsystem_name: Arc<str>,
password: Option<Arc<str>>,
) -> Self {
Self {
cache,
notify_rx,
shutdown_rx,
peer_addr,
authorized_keys,
username,
subsystem_name,
password,
channels: HashMap::new(),
subsystem_started: false,
}
}
fn is_authorized_key(&self, key: &russh::keys::ssh_key::PublicKey) -> bool {
self.authorized_keys
.iter()
.any(|allowed| allowed.key_data() == key.key_data())
}
fn is_expected_user(&self, user: &str) -> bool {
user == self.username.as_ref()
}
}
impl server::Handler for RtrSshHandler {
type Error = anyhow::Error;
async fn auth_none(&mut self, _user: &str) -> Result<server::Auth, Self::Error> {
Ok(server::Auth::reject())
}
async fn auth_password(
&mut self,
user: &str,
password: &str,
) -> Result<server::Auth, Self::Error> {
let accepted = self.is_expected_user(user)
&& self
.password
.as_deref()
.map(|expected| expected == password)
.unwrap_or(false);
if accepted {
info!(
"RTR SSH password auth accepted: peer_addr={}, user={}",
self.peer_addr, user
);
Ok(server::Auth::Accept)
} else {
warn!(
"RTR SSH password auth rejected: peer_addr={}, user={}",
self.peer_addr, user
);
Ok(server::Auth::reject())
}
}
async fn auth_publickey_offered(
&mut self,
user: &str,
public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<server::Auth, Self::Error> {
if self.is_expected_user(user) && self.is_authorized_key(public_key) {
Ok(server::Auth::Accept)
} else {
Ok(server::Auth::reject())
}
}
async fn auth_publickey(
&mut self,
user: &str,
public_key: &russh::keys::ssh_key::PublicKey,
) -> Result<server::Auth, Self::Error> {
if self.is_expected_user(user) && self.is_authorized_key(public_key) {
info!(
"RTR SSH publickey auth accepted: peer_addr={}, user={}",
self.peer_addr, user
);
Ok(server::Auth::Accept)
} else {
warn!(
"RTR SSH publickey auth rejected: peer_addr={}, user={}",
self.peer_addr, user
);
Ok(server::Auth::reject())
}
}
async fn channel_open_session(
&mut self,
channel: Channel<Msg>,
_session: &mut Session,
) -> Result<bool, Self::Error> {
if self.subsystem_started {
return Ok(false);
}
self.channels.insert(channel.id(), channel);
Ok(true)
}
async fn subsystem_request(
&mut self,
channel: ChannelId,
name: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
if name != self.subsystem_name.as_ref() {
let _ = session.channel_failure(channel);
warn!(
"RTR SSH subsystem rejected: peer_addr={}, requested={}, expected={}",
self.peer_addr, name, self.subsystem_name
);
return Ok(());
}
let Some(channel) = self.channels.remove(&channel) else {
let _ = session.channel_failure(channel);
return Ok(());
};
session.channel_success(channel.id())?;
self.subsystem_started = true;
let cache = self.cache.clone();
let notify_rx = self.notify_rx.resubscribe();
let shutdown_rx = self.shutdown_rx.clone();
let peer_addr = self.peer_addr;
let subsystem = self.subsystem_name.clone();
tokio::spawn(async move {
let stream = channel.into_stream();
let session = RtrSession::new(cache, stream, notify_rx, shutdown_rx);
if let Err(err) = session.run().await {
warn!(
"RTR SSH subsystem session closed with error: peer_addr={}, subsystem={}, err={}",
peer_addr, subsystem, err
);
} else {
info!(
"RTR SSH subsystem session completed: peer_addr={}, subsystem={}",
peer_addr, subsystem
);
}
});
Ok(())
}
async fn shell_request(
&mut self,
channel: ChannelId,
session: &mut Session,
) -> Result<(), Self::Error> {
let _ = session.channel_failure(channel);
Ok(())
}
async fn exec_request(
&mut self,
channel: ChannelId,
_data: &[u8],
session: &mut Session,
) -> Result<(), Self::Error> {
let _ = session.channel_failure(channel);
Ok(())
}
async fn pty_request(
&mut self,
channel: ChannelId,
_term: &str,
_col_width: u32,
_row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
let _ = session.channel_failure(channel);
Ok(())
}
}
fn apply_keepalive(stream: &tokio::net::TcpStream, keepalive: Option<Duration>) -> Result<()> {
let Some(keepalive) = keepalive else {
return Ok(());
};
let socket = SockRef::from(stream);
let keepalive = TcpKeepalive::new().with_time(keepalive);
socket
.set_tcp_keepalive(&keepalive)
.context("unable to apply TCP keepalive settings")?;
Ok(())
}

17
src/rtr/server/mod.rs Normal file
View File

@ -0,0 +1,17 @@
pub mod config;
pub mod connection;
pub mod listener;
pub mod notifier;
pub mod service;
pub mod ssh;
pub mod tls;
pub use config::RtrServiceConfig;
pub use connection::RtrTransportConnectionCounts;
pub use listener::RtrServer;
pub use notifier::RtrNotifier;
pub use service::{
RtrService, RtrServiceControl, RtrServiceStats, RtrShutdownHandle, RtrShutdownReason,
RunningRtrService,
};
pub use tls::load_rustls_server_config;

View File

@ -0,0 +1,20 @@
use tokio::sync::broadcast;
#[derive(Clone)]
pub struct RtrNotifier {
tx: broadcast::Sender<()>,
}
impl RtrNotifier {
pub fn new(tx: broadcast::Sender<()>) -> Self {
Self { tx }
}
pub fn notify_cache_updated(&self) -> usize {
self.tx.send(()).unwrap_or_default()
}
pub fn listener_count(&self) -> usize {
self.tx.receiver_count()
}
}

422
src/rtr/server/service.rs Normal file
View File

@ -0,0 +1,422 @@
use std::env;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{
Arc,
atomic::{AtomicU64, AtomicUsize, Ordering},
};
use tokio::sync::{Semaphore, broadcast, watch};
use tokio::task::JoinHandle;
use tracing::{error, warn};
use crate::rtr::cache::SharedRtrCache;
use crate::rtr::server::config::RtrServiceConfig;
use crate::rtr::server::connection::{
RtrTransportConnectionCounters, RtrTransportConnectionCounts,
};
use crate::rtr::server::listener::RtrServer;
use crate::rtr::server::notifier::RtrNotifier;
use crate::rtr::server::ssh::{SshAuthMode, load_rtr_ssh_runtime_config};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RtrShutdownReason {
CloseOnly,
CacheRestart,
CacheShutdown,
}
impl RtrShutdownReason {
pub fn as_str(self) -> &'static str {
match self {
Self::CloseOnly => "close-only",
Self::CacheRestart => "cache-restart",
Self::CacheShutdown => "cache-shutdown",
}
}
pub fn from_env() -> Self {
match env::var("RPKI_RTR_STOP_REASON") {
Ok(value) => Self::parse(&value).unwrap_or(Self::CacheRestart),
Err(_) => Self::CacheRestart,
}
}
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"close-only" | "close_only" | "close" => Some(Self::CloseOnly),
"restart" | "cache-restart" | "cache_restart" => Some(Self::CacheRestart),
"shutdown" | "cache-shutdown" | "cache_shutdown" => Some(Self::CacheShutdown),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RtrServiceControl {
Running,
Shutdown(RtrShutdownReason),
}
impl RtrServiceControl {
pub fn shutdown_reason(self) -> Option<RtrShutdownReason> {
match self {
Self::Running => None,
Self::Shutdown(reason) => Some(reason),
}
}
}
#[derive(Clone)]
pub struct RtrShutdownHandle {
shutdown_tx: watch::Sender<RtrServiceControl>,
}
impl RtrShutdownHandle {
pub fn shutdown(&self, reason: RtrShutdownReason) {
let _ = self.shutdown_tx.send(RtrServiceControl::Shutdown(reason));
}
}
pub struct RtrService {
cache: SharedRtrCache,
notify_tx: broadcast::Sender<()>,
shutdown_tx: watch::Sender<RtrServiceControl>,
connection_limiter: Arc<Semaphore>,
handshake_limiter: Arc<Semaphore>,
active_connections: Arc<AtomicUsize>,
transport_connections: Arc<RtrTransportConnectionCounters>,
connection_change_tx: watch::Sender<u64>,
connection_change_seq: Arc<AtomicU64>,
config: RtrServiceConfig,
}
#[derive(Clone)]
pub struct RtrServiceStats {
active_connections: Arc<AtomicUsize>,
transport_connections: Arc<RtrTransportConnectionCounters>,
connection_change_tx: watch::Sender<u64>,
max_connections: usize,
}
impl RtrServiceStats {
pub fn active_connections(&self) -> usize {
self.active_connections.load(Ordering::Relaxed)
}
pub fn transport_connections(&self) -> RtrTransportConnectionCounts {
self.transport_connections.snapshot()
}
pub fn max_connections(&self) -> usize {
self.max_connections
}
pub fn subscribe_connection_changes(&self) -> watch::Receiver<u64> {
self.connection_change_tx.subscribe()
}
}
impl RtrService {
pub fn new(cache: SharedRtrCache) -> Self {
Self::with_config(cache, RtrServiceConfig::default())
}
pub fn with_config(cache: SharedRtrCache, config: RtrServiceConfig) -> Self {
let (notify_tx, _) = broadcast::channel(config.notify_queue_size);
let (shutdown_tx, _) = watch::channel(RtrServiceControl::Running);
let (connection_change_tx, _) = watch::channel(0);
Self {
cache,
notify_tx,
shutdown_tx,
connection_limiter: Arc::new(Semaphore::new(config.max_connections)),
handshake_limiter: Arc::new(Semaphore::new(config.max_concurrent_handshakes)),
active_connections: Arc::new(AtomicUsize::new(0)),
transport_connections: Arc::new(RtrTransportConnectionCounters::default()),
connection_change_tx,
connection_change_seq: Arc::new(AtomicU64::new(0)),
config,
}
}
pub fn cache(&self) -> SharedRtrCache {
self.cache.clone()
}
pub fn notifier(&self) -> RtrNotifier {
RtrNotifier::new(self.notify_tx.clone())
}
pub fn shutdown_handle(&self) -> RtrShutdownHandle {
RtrShutdownHandle {
shutdown_tx: self.shutdown_tx.clone(),
}
}
pub fn notify_cache_updated(&self) {
let _ = self.notify_tx.send(());
}
pub fn active_connections(&self) -> usize {
self.active_connections.load(Ordering::Relaxed)
}
pub fn transport_connections(&self) -> RtrTransportConnectionCounts {
self.transport_connections.snapshot()
}
pub fn max_connections(&self) -> usize {
self.config.max_connections
}
pub fn stats(&self) -> RtrServiceStats {
RtrServiceStats {
active_connections: self.active_connections.clone(),
transport_connections: self.transport_connections.clone(),
connection_change_tx: self.connection_change_tx.clone(),
max_connections: self.config.max_connections,
}
}
pub fn tcp_server(&self, bind_addr: SocketAddr) -> RtrServer {
RtrServer::new(
bind_addr,
self.cache.clone(),
self.notify_tx.clone(),
self.shutdown_tx.clone(),
self.connection_limiter.clone(),
self.handshake_limiter.clone(),
self.active_connections.clone(),
self.transport_connections.clone(),
self.connection_change_tx.clone(),
self.connection_change_seq.clone(),
self.config.clone(),
)
}
pub fn tls_server(&self, bind_addr: SocketAddr) -> RtrServer {
RtrServer::new(
bind_addr,
self.cache.clone(),
self.notify_tx.clone(),
self.shutdown_tx.clone(),
self.connection_limiter.clone(),
self.handshake_limiter.clone(),
self.active_connections.clone(),
self.transport_connections.clone(),
self.connection_change_tx.clone(),
self.connection_change_seq.clone(),
self.config.clone(),
)
}
pub fn ssh_server(&self, bind_addr: SocketAddr) -> RtrServer {
RtrServer::new(
bind_addr,
self.cache.clone(),
self.notify_tx.clone(),
self.shutdown_tx.clone(),
self.connection_limiter.clone(),
self.handshake_limiter.clone(),
self.active_connections.clone(),
self.transport_connections.clone(),
self.connection_change_tx.clone(),
self.connection_change_seq.clone(),
self.config.clone(),
)
}
pub fn spawn_tcp(&self, bind_addr: SocketAddr) -> JoinHandle<()> {
if self.config.warn_insecure_tcp {
warn!(
"starting plain TCP RTR service on {}. Per draft-ietf-sidrops-8210bis-26 Section 9, unsecured TCP must only be used on a trusted and controlled network",
bind_addr
);
}
let server = self.tcp_server(bind_addr);
tokio::spawn(async move {
if let Err(err) = server.run_tcp().await {
error!("RTR TCP server {} exited with error: {:?}", bind_addr, err);
}
})
}
pub fn spawn_tls_from_pem(
&self,
bind_addr: SocketAddr,
cert_path: impl AsRef<Path>,
key_path: impl AsRef<Path>,
client_ca_path: impl AsRef<Path>,
) -> JoinHandle<()> {
let cert_path = cert_path.as_ref().to_path_buf();
let key_path = key_path.as_ref().to_path_buf();
let client_ca_path = client_ca_path.as_ref().to_path_buf();
let server = self.tls_server(bind_addr);
tokio::spawn(async move {
if let Err(err) = server
.run_tls_from_pem(cert_path, key_path, client_ca_path)
.await
{
error!("RTR TLS server {} exited with error: {:?}", bind_addr, err);
}
})
}
pub fn spawn_tcp_and_tls_from_pem(
&self,
tcp_bind_addr: SocketAddr,
tls_bind_addr: SocketAddr,
cert_path: impl AsRef<Path>,
key_path: impl AsRef<Path>,
client_ca_path: impl AsRef<Path>,
) -> RunningRtrService {
let tcp_handle = self.spawn_tcp(tcp_bind_addr);
let tls_handle =
self.spawn_tls_from_pem(tls_bind_addr, cert_path, key_path, client_ca_path);
RunningRtrService {
shutdown_tx: self.shutdown_tx.clone(),
handles: vec![tcp_handle, tls_handle],
}
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_ssh_from_openssh(
&self,
bind_addr: SocketAddr,
host_key_path: impl AsRef<Path>,
authorized_keys_path: impl AsRef<Path>,
username: &str,
subsystem_name: &str,
auth_mode: SshAuthMode,
password: Option<&str>,
) -> JoinHandle<()> {
let host_key_path = host_key_path.as_ref().to_path_buf();
let authorized_keys_path = authorized_keys_path.as_ref().to_path_buf();
let username = username.to_string();
let subsystem_name = subsystem_name.to_string();
let password = password.map(ToString::to_string);
let inactivity_timeout = Some(std::time::Duration::from_secs(3600));
let keepalive_interval = self.config.tcp_keepalive;
let server = self.ssh_server(bind_addr);
tokio::spawn(async move {
let runtime_config = match load_rtr_ssh_runtime_config(
&host_key_path,
&authorized_keys_path,
&username,
&subsystem_name,
auth_mode,
password.as_deref(),
inactivity_timeout,
keepalive_interval,
) {
Ok(cfg) => Arc::new(cfg),
Err(err) => {
error!(
"RTR SSH server {} failed to load configuration: {:?}",
bind_addr, err
);
return;
}
};
if let Err(err) = server.run_ssh(runtime_config).await {
error!("RTR SSH server {} exited with error: {:?}", bind_addr, err);
}
})
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_tcp_and_ssh_from_openssh(
&self,
tcp_bind_addr: SocketAddr,
ssh_bind_addr: SocketAddr,
host_key_path: impl AsRef<Path>,
authorized_keys_path: impl AsRef<Path>,
username: &str,
subsystem_name: &str,
auth_mode: SshAuthMode,
password: Option<&str>,
) -> RunningRtrService {
let tcp_handle = self.spawn_tcp(tcp_bind_addr);
let ssh_handle = self.spawn_ssh_from_openssh(
ssh_bind_addr,
host_key_path,
authorized_keys_path,
username,
subsystem_name,
auth_mode,
password,
);
RunningRtrService {
shutdown_tx: self.shutdown_tx.clone(),
handles: vec![tcp_handle, ssh_handle],
}
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_tcp_tls_and_ssh_from_pem_and_openssh(
&self,
tcp_bind_addr: SocketAddr,
tls_bind_addr: SocketAddr,
ssh_bind_addr: SocketAddr,
cert_path: impl AsRef<Path>,
key_path: impl AsRef<Path>,
client_ca_path: impl AsRef<Path>,
host_key_path: impl AsRef<Path>,
authorized_keys_path: impl AsRef<Path>,
username: &str,
subsystem_name: &str,
auth_mode: SshAuthMode,
password: Option<&str>,
) -> RunningRtrService {
let tcp_handle = self.spawn_tcp(tcp_bind_addr);
let tls_handle =
self.spawn_tls_from_pem(tls_bind_addr, cert_path, key_path, client_ca_path);
let ssh_handle = self.spawn_ssh_from_openssh(
ssh_bind_addr,
host_key_path,
authorized_keys_path,
username,
subsystem_name,
auth_mode,
password,
);
RunningRtrService {
shutdown_tx: self.shutdown_tx.clone(),
handles: vec![tcp_handle, tls_handle, ssh_handle],
}
}
pub fn spawn_tcp_only(&self, tcp_bind_addr: SocketAddr) -> RunningRtrService {
let tcp_handle = self.spawn_tcp(tcp_bind_addr);
RunningRtrService {
shutdown_tx: self.shutdown_tx.clone(),
handles: vec![tcp_handle],
}
}
}
pub struct RunningRtrService {
shutdown_tx: watch::Sender<RtrServiceControl>,
handles: Vec<JoinHandle<()>>,
}
impl RunningRtrService {
pub fn shutdown(&self, reason: RtrShutdownReason) {
let _ = self.shutdown_tx.send(RtrServiceControl::Shutdown(reason));
}
pub async fn wait(self) {
for handle in self.handles {
let _ = handle.await;
}
}
}

136
src/rtr/server/ssh.rs Normal file
View File

@ -0,0 +1,136 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use russh::keys;
use russh::keys::PrivateKey;
use russh::keys::ssh_key::{self, AuthorizedKeys};
use russh::server::Config as RusshServerConfig;
use russh::{MethodKind, MethodSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SshAuthMode {
Key,
Password,
Both,
}
impl SshAuthMode {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"key" | "publickey" => Some(Self::Key),
"password" => Some(Self::Password),
"both" => Some(Self::Both),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Key => "key",
Self::Password => "password",
Self::Both => "both",
}
}
}
#[derive(Debug, Clone)]
pub struct RtrSshRuntimeConfig {
pub server_config: Arc<RusshServerConfig>,
pub authorized_keys: Arc<Vec<ssh_key::PublicKey>>,
pub username: Arc<str>,
pub subsystem_name: Arc<str>,
pub password: Option<Arc<str>>,
}
pub fn load_rtr_ssh_runtime_config(
host_key_path: impl AsRef<Path>,
authorized_keys_path: impl AsRef<Path>,
username: &str,
subsystem_name: &str,
auth_mode: SshAuthMode,
password: Option<&str>,
inactivity_timeout: Option<Duration>,
keepalive_interval: Option<Duration>,
) -> Result<RtrSshRuntimeConfig> {
if username.trim().is_empty() {
bail!("SSH username must not be empty");
}
if subsystem_name.trim().is_empty() {
bail!("SSH subsystem name must not be empty");
}
let host_key = load_host_key(host_key_path.as_ref())?;
let authorized_keys = if matches!(auth_mode, SshAuthMode::Key | SshAuthMode::Both) {
load_authorized_keys(authorized_keys_path.as_ref())?
} else {
Vec::new()
};
let password = password.map(str::trim).filter(|value| !value.is_empty());
if matches!(auth_mode, SshAuthMode::Password | SshAuthMode::Both) && password.is_none() {
bail!(
"SSH auth mode '{}' requires non-empty password",
auth_mode.as_str()
);
}
let mut methods = MethodSet::empty();
if matches!(auth_mode, SshAuthMode::Key | SshAuthMode::Both) {
methods.push(MethodKind::PublicKey);
}
if matches!(auth_mode, SshAuthMode::Password | SshAuthMode::Both) {
methods.push(MethodKind::Password);
}
let server_config = RusshServerConfig {
methods,
keys: vec![host_key],
inactivity_timeout,
keepalive_interval,
keepalive_max: 3,
auth_rejection_time: Duration::from_secs(1),
auth_rejection_time_initial: Some(Duration::from_secs(0)),
..Default::default()
};
Ok(RtrSshRuntimeConfig {
server_config: Arc::new(server_config),
authorized_keys: Arc::new(authorized_keys),
username: Arc::from(username.trim()),
subsystem_name: Arc::from(subsystem_name.trim()),
password: password.map(Arc::from),
})
}
fn load_host_key(path: &Path) -> Result<PrivateKey> {
keys::load_secret_key(path, None).with_context(|| {
format!(
"failed to load SSH host private key from {} (OpenSSH private key expected)",
path.display()
)
})
}
fn load_authorized_keys(path: &Path) -> Result<Vec<ssh_key::PublicKey>> {
let entries = AuthorizedKeys::read_file(path).with_context(|| {
format!(
"failed to read SSH authorized_keys file from {}",
path.display()
)
})?;
let mut keys = Vec::with_capacity(entries.len());
for entry in entries {
keys.push(entry.public_key().clone());
}
if keys.is_empty() {
return Err(anyhow!(
"SSH authorized_keys file {} does not contain any usable keys",
path.display()
));
}
Ok(keys)
}

124
src/rtr/server/tls.rs Normal file
View File

@ -0,0 +1,124 @@
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, anyhow};
use rustls::server::WebPkiClientVerifier;
use rustls::{RootCertStore, ServerConfig};
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use tracing::warn;
use x509_parser::extensions::GeneralName;
use x509_parser::prelude::{FromDer, X509Certificate};
pub fn load_rustls_server_config(
cert_path: impl AsRef<Path>,
key_path: impl AsRef<Path>,
client_ca_path: impl AsRef<Path>,
) -> Result<ServerConfig> {
load_rustls_server_config_with_options(cert_path, key_path, client_ca_path, false)
}
pub fn load_rustls_server_config_with_options(
cert_path: impl AsRef<Path>,
key_path: impl AsRef<Path>,
client_ca_path: impl AsRef<Path>,
require_dns_name_san: bool,
) -> Result<ServerConfig> {
let cert_path: PathBuf = cert_path.as_ref().to_path_buf();
let key_path: PathBuf = key_path.as_ref().to_path_buf();
let client_ca_path: PathBuf = client_ca_path.as_ref().to_path_buf();
let certs = load_certs(&cert_path)
.with_context(|| format!("failed to load certs from {}", cert_path.display()))?;
validate_server_certificate_dns_name_san(&certs, &cert_path, require_dns_name_san)?;
let key = load_private_key(&key_path)
.with_context(|| format!("failed to load private key from {}", key_path.display()))?;
let client_ca_certs = load_certs(&client_ca_path).with_context(|| {
format!(
"failed to load client CA certs from {}",
client_ca_path.display()
)
})?;
let mut client_roots = RootCertStore::empty();
let (added, _) = client_roots.add_parsable_certificates(client_ca_certs);
if added == 0 {
return Err(anyhow!(
"no valid client CA certificates found in {}",
client_ca_path.display()
));
}
let client_verifier = WebPkiClientVerifier::builder(Arc::new(client_roots))
.build()
.map_err(|e| anyhow!("invalid client certificate verifier configuration: {}", e))?;
let config = ServerConfig::builder()
.with_client_cert_verifier(client_verifier)
.with_single_cert(certs, key)
.map_err(|e| anyhow!("invalid certificate/key pair: {}", e))?;
Ok(config)
}
fn validate_server_certificate_dns_name_san(
certs: &[CertificateDer<'static>],
cert_path: &Path,
require_dns_name_san: bool,
) -> Result<()> {
let leaf = certs
.first()
.ok_or_else(|| anyhow!("missing end-entity certificate in {}", cert_path.display()))?;
let (_, cert) = X509Certificate::from_der(leaf.as_ref())
.map_err(|err| anyhow!("failed to parse server certificate: {:?}", err))?;
let has_dns_name_san = cert
.subject_alternative_name()
.map_err(|err| anyhow!("failed to parse server certificate SAN: {:?}", err))?
.map(|san| {
san.value
.general_names
.iter()
.any(|name| matches!(name, GeneralName::DNSName(_)))
})
.unwrap_or(false);
if has_dns_name_san {
return Ok(());
}
let message = format!(
"server certificate {} does not contain a subjectAltName dNSName entry; draft-ietf-sidrops-8210bis-25 Section 9.2 requires routers to authenticate the cache using DNS-ID rather than CN-ID",
cert_path.display()
);
if require_dns_name_san {
Err(anyhow!(message))
} else {
warn!("{}", message);
Ok(())
}
}
fn load_certs(path: &Path) -> Result<Vec<CertificateDer<'static>>> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let certs = rustls_pemfile::certs(&mut reader).collect::<std::result::Result<Vec<_>, _>>()?;
if certs.is_empty() {
return Err(anyhow!("no certificates found in {}", path.display()));
}
Ok(certs)
}
fn load_private_key(path: &Path) -> Result<PrivateKeyDer<'static>> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let key = rustls_pemfile::private_key(&mut reader)?
.ok_or_else(|| anyhow!("no private key found in {}", path.display()))?;
Ok(key)
}

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