Compare commits
42 Commits
main
...
dev_1.3_xu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4fd0f3128 | ||
|
|
859a56c434 | ||
|
|
5fb79a7c8d | ||
|
|
490b79f22c | ||
|
|
e2bdcd3820 | ||
|
|
b5cbc7fbee | ||
|
|
cc3518897f | ||
|
|
b105b3b7d0 | ||
|
|
303bdffd97 | ||
|
|
3c61fbf152 | ||
|
|
b70bad4721 | ||
|
|
fa19fc8722 | ||
|
|
13f29843db | ||
|
|
cdf9372929 | ||
|
|
a11f2bc864 | ||
|
|
ad76745619 | ||
|
|
9dc69e5f31 | ||
|
|
ff711a2fe1 | ||
|
|
4c6b441753 | ||
|
|
a4d674190a | ||
|
|
b79e25ffea | ||
|
|
84866c1d86 | ||
|
|
ddeff71618 | ||
|
|
ca0cf4800a | ||
|
|
897d168ceb | ||
|
|
19225edaa7 | ||
|
|
6e4b59a208 | ||
|
|
88c11e7a97 | ||
|
|
8a65f639cb | ||
|
|
cef9495534 | ||
|
|
b60d579a38 | ||
|
|
99250f8aa9 | ||
|
|
17c1a02c90 | ||
|
|
c1d3112a45 | ||
|
|
23cdad095d | ||
|
|
b483c2af8b | ||
|
|
cd0330e8ae | ||
|
|
8d6173f605 | ||
|
|
7432fbcac4 | ||
|
|
03c0ab0ec7 | ||
|
|
9cbea4e2d0 | ||
|
|
251dea8e5e |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
target
|
||||
rtr-db
|
||||
tests
|
||||
specs
|
||||
scripts
|
||||
README.md
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,2 +1,5 @@
|
||||
target/
|
||||
Cargo.lock
|
||||
rtr-db/
|
||||
.idea/
|
||||
logs/
|
||||
report/
|
||||
|
||||
3528
Cargo.lock
generated
Normal file
3528
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
Cargo.toml
27
Cargo.toml
@ -1,8 +1,12 @@
|
||||
[package]
|
||||
name = "rpki"
|
||||
version = "0.1.0"
|
||||
version = "1.3.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "rpki_rtr"
|
||||
path = "src/main_rtr.rs"
|
||||
|
||||
[dependencies]
|
||||
der-parser = "10.0.0"
|
||||
hex = "0.4.3"
|
||||
@ -13,3 +17,24 @@ url = "2.5.8"
|
||||
asn1-rs = "0.7.1"
|
||||
asn1-rs-derive = "0.6.0"
|
||||
asn1 = "0.23.0"
|
||||
arc-swap = "1.7.0"
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
chrono-tz = "0.10"
|
||||
bytes = "1.11.1"
|
||||
tokio = { version = "1.49.0", features = ["full"] }
|
||||
rand = "0.10.0"
|
||||
rocksdb = { version = "0.21.0", default-features = false }
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
anyhow = "1"
|
||||
tracing = "0.1.44"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2"
|
||||
rustls-pki-types = "1.14.0"
|
||||
socket2 = "0.5"
|
||||
russh = { version = "0.60.0", default-features = false, features = ["ring", "rsa"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
|
||||
287
README.client.md
Normal file
287
README.client.md
Normal 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
372
README.md
@ -1,11 +1,373 @@
|
||||
# RPKI RTR Server
|
||||
|
||||
这是一个用 Rust 实现的 RPKI-to-Router (RTR) cache server。服务端从 CCR 文件中读取 RPKI 数据,生成 RTR cache snapshot 和 delta,并通过 RTR 协议提供给路由器或调试客户端。
|
||||
|
||||
# 单元测试
|
||||
当前代码重点覆盖:
|
||||
|
||||
```
|
||||
cargo test
|
||||
- RTR server:TCP 默认启用,可选启用 TLS/mTLS 和进程内 SSH transport。
|
||||
- 数据输入:从 CCR 目录选择最新 `.ccr` 文件,解析 VRP 和 VAP/ASPA。
|
||||
- 本地策略:可选加载 SLURM 文件,对 CCR 解析出的 payload 做过滤和本地断言。
|
||||
- 持久化:使用 RocksDB 保存 RTR cache 状态。
|
||||
- 运行报告:输出 source、client、runtime 三类 JSON report。
|
||||
- 运行期管理:可选启用 HTTP Admin API,修改部分 runtime config、触发 source reload、管理 SLURM 文件。
|
||||
- 调试工具:`rtr_debug_client` 支持 TCP、TLS 和 SSH 连接 RTR server。
|
||||
|
||||
# 查看输出
|
||||
cargo test -- --nocapture
|
||||
## 目录
|
||||
|
||||
- [协议参考](#协议参考)
|
||||
- [项目结构](#项目结构)
|
||||
- [构建与测试](#构建与测试)
|
||||
- [数据输入](#数据输入)
|
||||
- [快速启动](#快速启动)
|
||||
- [前置数据生成](#前置数据生成)
|
||||
- [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
|
||||
```
|
||||
|
||||
服务端 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 Origin,v1 包含 Route Origin 和 Router Key,v2 包含 Route Origin、Router Key 和 ASPA。
|
||||
- 当前 source pipeline 从 CCR 生成 Route Origin 和 ASPA,再可选应用 SLURM。
|
||||
- `RtrStore::save_cache_state_versioned(...)` 是 cache 状态写入 RocksDB 的核心入口,相关边界测试在 `tests/test_store_boundary.rs`。
|
||||
- Admin API 的日志 tail 依赖部署入口写出的 stdout/stderr 日志文件;容器入口脚本会把日志写到 `/app/logs`。
|
||||
|
||||
23
data/example.slurm
Normal file
23
data/example.slurm
Normal 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
33
deploy/bird/.env
Normal 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
56
deploy/bird/Dockerfile
Normal 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
90
deploy/bird/README.md
Normal 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
80
deploy/bird/README.zh.md
Normal 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 容器名或宿主机地址。
|
||||
31
deploy/bird/_debug_ssh.conf
Normal file
31
deploy/bird/_debug_ssh.conf
Normal 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
28
deploy/bird/bird.conf
Normal 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;
|
||||
};
|
||||
}
|
||||
28
deploy/bird/bird.conf.example
Normal file
28
deploy/bird/bird.conf.example
Normal 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;
|
||||
};
|
||||
}
|
||||
30
deploy/bird/bird.conf.ssh.example
Normal file
30
deploy/bird/bird.conf.ssh.example
Normal 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";
|
||||
};
|
||||
}
|
||||
30
deploy/bird/bird.conf.ssh.template
Normal file
30
deploy/bird/bird.conf.ssh.template
Normal 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";
|
||||
};
|
||||
}
|
||||
28
deploy/bird/bird.conf.template
Normal file
28
deploy/bird/bird.conf.template
Normal 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;
|
||||
};
|
||||
}
|
||||
17
deploy/bird/docker-compose.ssh.yml
Normal file
17
deploy/bird/docker-compose.ssh.yml
Normal 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
|
||||
41
deploy/bird/docker-compose.yml
Normal file
41
deploy/bird/docker-compose.yml
Normal 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
218
deploy/bird/entrypoint.sh
Normal 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
37
deploy/client/.env
Normal 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
26
deploy/client/Dockerfile
Normal 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
314
deploy/client/build-run.sh
Normal 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
|
||||
52
deploy/client/docker-compose.clients.yml
Normal file
52
deploy/client/docker-compose.clients.yml
Normal 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
|
||||
36
deploy/client/docker-compose.ssh.password.yml
Normal file
36
deploy/client/docker-compose.ssh.password.yml
Normal 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
|
||||
36
deploy/client/docker-compose.ssh.yml
Normal file
36
deploy/client/docker-compose.ssh.yml
Normal 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
|
||||
19
deploy/client/docker-compose.tcp.yml
Normal file
19
deploy/client/docker-compose.tcp.yml
Normal 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
|
||||
38
deploy/client/docker-compose.tls.yml
Normal file
38
deploy/client/docker-compose.tls.yml
Normal 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
|
||||
19
deploy/client/docker-compose.yml
Normal file
19
deploy/client/docker-compose.yml
Normal 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
|
||||
10
deploy/client/entrypoint.sh
Normal file
10
deploy/client/entrypoint.sh
Normal 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
4
deploy/frr/.env
Normal 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
78
deploy/frr/README.md
Normal 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
83
deploy/frr/README.zh.md
Normal 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` 控制
|
||||
7
deploy/frr/daemons.example
Normal file
7
deploy/frr/daemons.example
Normal 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"
|
||||
16
deploy/frr/docker-compose.yml
Normal file
16
deploy/frr/docker-compose.yml
Normal 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
24
deploy/frr/entrypoint.sh
Normal 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
|
||||
22
deploy/frr/frr.conf.example
Normal file
22
deploy/frr/frr.conf.example
Normal 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
|
||||
22
deploy/frr/frr.conf.template
Normal file
22
deploy/frr/frr.conf.template
Normal 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
54
deploy/server/.env
Normal 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
|
||||
99
deploy/server/DEPLOYMENT.md
Normal file
99
deploy/server/DEPLOYMENT.md
Normal 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
85
deploy/server/Dockerfile
Normal 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
292
deploy/server/build-run.sh
Normal 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
|
||||
54
deploy/server/docker-compose.ssh.yml
Normal file
54
deploy/server/docker-compose.ssh.yml
Normal 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
|
||||
44
deploy/server/docker-compose.tcp.yml
Normal file
44
deploy/server/docker-compose.tcp.yml
Normal 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
|
||||
48
deploy/server/docker-compose.tls.yml
Normal file
48
deploy/server/docker-compose.tls.yml
Normal 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
|
||||
58
deploy/server/docker-compose.yml
Normal file
58
deploy/server/docker-compose.yml
Normal 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
|
||||
10
deploy/server/entrypoint.sh
Normal file
10
deploy/server/entrypoint.sh
Normal 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
310
docs/rtr-admin-api.md
Normal 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
203
scripts/00_0_install.sh
Normal 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
155
scripts/00_1_pre_check.sh
Normal 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"
|
||||
54
scripts/00_2_generate_ccr.sh
Normal file
54
scripts/00_2_generate_ccr.sh
Normal 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
|
||||
96
scripts/00_2_pre_check_prepare.sh
Normal file
96
scripts/00_2_pre_check_prepare.sh
Normal 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
|
||||
56
scripts/00_3_pre_check_cleanup.sh
Normal file
56
scripts/00_3_pre_check_cleanup.sh
Normal 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
221
scripts/01_start.sh
Normal 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
184
scripts/02_self_check.sh
Normal 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
92
scripts/03_shutdown.sh
Normal 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
152
scripts/04_cleanup.sh
Normal 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"
|
||||
303
scripts/10_generate_certs.sh
Normal file
303
scripts/10_generate_certs.sh
Normal 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
136
scripts/11_start_client.sh
Normal 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"
|
||||
82
scripts/12_shutdown_client.sh
Normal file
82
scripts/12_shutdown_client.sh
Normal 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
218
scripts/README.md
Normal 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
186
specs/10_slurm.md
Normal file
@ -0,0 +1,186 @@
|
||||
# 10. SLURM(Simplified Local Internet Number Resource Management with the RPKI)
|
||||
|
||||
## 10.1 目标与范围
|
||||
|
||||
SLURM 用于让 RP(Relying Party)在本地对上游 RPKI 验证结果做“过滤”和“补充断言”,而不修改上游发布对象。
|
||||
|
||||
本文档基于:
|
||||
- RFC 8416(SLURM v1,ROA/BGPsec)
|
||||
- draft-ietf-sidrops-aspa-slurm-04(SLURM v2,新增 ASPA)
|
||||
|
||||
## 10.2 版本与顶层结构
|
||||
|
||||
### 10.2.1 SLURM v1(RFC 8416)
|
||||
|
||||
`slurmVersion` 必须为 `1`,且顶层 JSON 对象必须包含且仅包含以下成员:
|
||||
- `slurmVersion`
|
||||
- `validationOutputFilters`(必须包含 `prefixFilters`、`bgpsecFilters`)
|
||||
- `locallyAddedAssertions`(必须包含 `prefixAssertions`、`bgpsecAssertions`)
|
||||
|
||||
空策略示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"slurmVersion": 1,
|
||||
"validationOutputFilters": {
|
||||
"prefixFilters": [],
|
||||
"bgpsecFilters": []
|
||||
},
|
||||
"locallyAddedAssertions": {
|
||||
"prefixAssertions": [],
|
||||
"bgpsecAssertions": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2.2 SLURM v2(draft-04)
|
||||
|
||||
`slurmVersion` 必须为 `2`,在 v1 基础上扩展 ASPA 两类成员:
|
||||
- `validationOutputFilters.aspaFilters`
|
||||
- `locallyAddedAssertions.aspaAssertions`
|
||||
|
||||
空策略示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"slurmVersion": 2,
|
||||
"validationOutputFilters": {
|
||||
"prefixFilters": [],
|
||||
"bgpsecFilters": [],
|
||||
"aspaFilters": []
|
||||
},
|
||||
"locallyAddedAssertions": {
|
||||
"prefixAssertions": [],
|
||||
"bgpsecAssertions": [],
|
||||
"aspaAssertions": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10.3 字段规范(RFC 8416)
|
||||
|
||||
### 10.3.1 `prefixFilters`
|
||||
|
||||
数组元素每项:
|
||||
- 必须至少包含一个:`prefix` 或 `asn`
|
||||
- 可选:`comment`
|
||||
|
||||
匹配规则:
|
||||
- 若配置了 `prefix`:匹配“被该前缀覆盖(encompassed)”的 VRP 前缀
|
||||
- 若配置了 `asn`:匹配该 ASN
|
||||
- 同时配置时:两者都要匹配
|
||||
|
||||
### 10.3.2 `bgpsecFilters`
|
||||
|
||||
数组元素每项:
|
||||
- 必须至少包含一个:`asn` 或 `SKI`
|
||||
- 可选:`comment`
|
||||
|
||||
匹配规则:
|
||||
- 按 `asn`/`SKI` 单独或联合匹配 Router Key(BGPsec)
|
||||
|
||||
### 10.3.3 `prefixAssertions`
|
||||
|
||||
数组元素每项:
|
||||
- 必须:`prefix`、`asn`
|
||||
- 可选:`maxPrefixLength`、`comment`
|
||||
|
||||
约束:
|
||||
- 若给出 `maxPrefixLength`,应满足 `prefix 长度 <= maxPrefixLength <= 地址位宽(IPv4=32, IPv6=128)`
|
||||
|
||||
### 10.3.4 `bgpsecAssertions`
|
||||
|
||||
数组元素每项:
|
||||
- 必须:`asn`、`SKI`、`routerPublicKey`
|
||||
- 可选:`comment`
|
||||
|
||||
## 10.4 ASPA 扩展(draft-ietf-sidrops-aspa-slurm-04)
|
||||
|
||||
### 10.4.1 `aspaFilters`
|
||||
|
||||
数组元素每项:
|
||||
- 必须:`customerAsn`
|
||||
- 可选:`comment`
|
||||
|
||||
匹配规则:
|
||||
- 当 VAP(Validated ASPA Payload)的 `customerAsn` 等于过滤器 `customerAsn` 时命中并移除。
|
||||
|
||||
### 10.4.2 `aspaAssertions`
|
||||
|
||||
数组元素每项:
|
||||
- 必须:`customerAsn`
|
||||
- 必须:`providerAsns`(ASN 数组)
|
||||
- 可选:`comment`
|
||||
|
||||
关键约束(draft-04):
|
||||
- `customerAsn` 不得出现在 `providerAsns` 中
|
||||
- `providerAsns` 必须按升序排列
|
||||
- `providerAsns` 里的 ASN 必须唯一(无重复)
|
||||
|
||||
语义补充(draft-04):
|
||||
- `aspaAssertions` 仅用于“新增断言”,不构成隐式过滤(不会自动替代 `aspaFilters`)。
|
||||
- 在 RTRv2 输出阶段,新增的 ASPA 断言应加入 ASPA PDU 集合,并做去重。
|
||||
|
||||
## 10.5 应用语义(RFC 8416 Section 4)
|
||||
|
||||
### 10.5.1 原子性
|
||||
|
||||
SLURM 应用必须是原子的:
|
||||
- 要么完全不生效(等同未使用 SLURM)
|
||||
- 要么完整按当前 SLURM 配置生效
|
||||
|
||||
### 10.5.2 处理顺序
|
||||
|
||||
在同一次计算中:
|
||||
1. 先执行 `validationOutputFilters`(移除匹配验证结果)
|
||||
2. 再追加 `locallyAddedAssertions`
|
||||
|
||||
### 10.5.3 多文件
|
||||
|
||||
实现可以支持多个 SLURM 文件并行使用(取并集),但在启用前应检查断言重叠冲突;若存在冲突,整组文件应被拒绝。
|
||||
|
||||
## 10.6 最小可用示例(SLURM v2)
|
||||
|
||||
```json
|
||||
{
|
||||
"slurmVersion": 2,
|
||||
"validationOutputFilters": {
|
||||
"prefixFilters": [
|
||||
{
|
||||
"prefix": "203.0.113.0/24",
|
||||
"comment": "Filter a broken VRP from upstream"
|
||||
}
|
||||
],
|
||||
"bgpsecFilters": [],
|
||||
"aspaFilters": [
|
||||
{
|
||||
"customerAsn": 64496,
|
||||
"comment": "Filter one customer ASPA"
|
||||
}
|
||||
]
|
||||
},
|
||||
"locallyAddedAssertions": {
|
||||
"prefixAssertions": [
|
||||
{
|
||||
"asn": 64496,
|
||||
"prefix": "203.0.113.0/24",
|
||||
"maxPrefixLength": 24,
|
||||
"comment": "Local business exception"
|
||||
}
|
||||
],
|
||||
"bgpsecAssertions": [],
|
||||
"aspaAssertions": [
|
||||
{
|
||||
"customerAsn": 64496,
|
||||
"providerAsns": [64497, 64498],
|
||||
"comment": "Local ASPA assertion"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10.7 参考文献
|
||||
|
||||
- RFC 8416: https://www.rfc-editor.org/rfc/rfc8416.html
|
||||
- draft-ietf-sidrops-aspa-slurm-04: https://www.ietf.org/archive/id/draft-ietf-sidrops-aspa-slurm-04.html
|
||||
179
specs/11_rtr.md
Normal file
179
specs/11_rtr.md
Normal file
@ -0,0 +1,179 @@
|
||||
# 11. RTR(RPKI to Router Protocol)
|
||||
|
||||
## 11.1 目标与文档范围
|
||||
|
||||
RTR 用于把 RP/Cache 已完成密码学验证的 RPKI 数据下发给路由器。
|
||||
|
||||
本文按以下规范整理:
|
||||
- RFC 6810(RTR v0)
|
||||
- RFC 8210(RTR v1,更新 RFC 6810)
|
||||
- draft-ietf-sidrops-8210bis-26(RTR v2,草案)
|
||||
|
||||
## 11.2 协议演进
|
||||
|
||||
### 11.2.1 RFC 6810(v0)
|
||||
|
||||
- 只定义 Prefix Origin 相关 payload(IPv4/IPv6 Prefix PDU)。
|
||||
- 主要 PDU:Serial Notify / Serial Query / Reset Query / Cache Response / Prefix / End of Data / Cache Reset / Error Report。
|
||||
|
||||
### 11.2.2 RFC 8210(v1)
|
||||
|
||||
在 v0 基础上新增/强化:
|
||||
- 新增 `Router Key PDU`(PDU Type 9,v1 可用,v0 保留)。
|
||||
- 强化协议版本协商与降级行为。
|
||||
- `End of Data` 在 v1 中携带 `Refresh/Retry/Expire` 三个计时参数。
|
||||
|
||||
### 11.2.3 Version 2(草案)
|
||||
|
||||
在 v1 基础上新增/强化:
|
||||
- 新增 `ASPA PDU`(PDU Type 11,仅 v2)。
|
||||
- 新增 “Races, Ordering, and Transactions” 章节,要求缓存按规定顺序输出 payload 以降低路由器短暂误判。
|
||||
- 协议版本提升到 `2`。
|
||||
- 明确 PDU 最大长度上限为 64k(65535)。
|
||||
|
||||
## 11.3 PDU 与版本矩阵
|
||||
|
||||
PDU 类型(按规范注册表):
|
||||
|
||||
| PDU Type | 名称 | v0 (RFC6810) | v1 (RFC8210) | v2 (8210bis-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 Notify(Type 0)
|
||||
|
||||
- 由 Cache 主动发送,提示有新序列可拉取。
|
||||
- 是少数可不由 Router 请求触发的消息。
|
||||
|
||||
### 11.4.2 Reset Query(Type 2)与 Cache Response(Type 3)
|
||||
|
||||
- Router 启动或失配时发 `Reset Query` 请求全量。
|
||||
- Cache 回复 `Cache Response`,随后发送全量 payload,最后 `End of Data`。
|
||||
|
||||
### 11.4.3 Serial Query(Type 1)
|
||||
|
||||
- Router 持有上次 `Session ID + Serial` 时请求增量。
|
||||
- Cache 若可提供增量:返回变化集。
|
||||
- Cache 若无法从该 serial 补增量:返回 `Cache Reset`,要求 Router 走全量。
|
||||
|
||||
### 11.4.4 Prefix / Router Key / ASPA payload
|
||||
|
||||
- `IPv4 Prefix`(Type 4)/ `IPv6 Prefix`(Type 6):表示 VRP 的 announce/withdraw。
|
||||
- `Router Key`(Type 9,v1+):表示 BGPsec Router Key 的 announce/withdraw。
|
||||
- `ASPA`(Type 11,v2 草案):表示 ASPA 数据单元的 announce/withdraw。
|
||||
|
||||
语义要点(v1 / v2 草案):
|
||||
- 对同一 payload 键(如 Prefix 四元组、Router Key 三元组、ASPA customer 键)应维护清晰的替换/撤销关系。
|
||||
- Cache 负责把历史变化“合并简化”后再发给 Router,避免无意义抖动。
|
||||
|
||||
### 11.4.5 End of Data(Type 7)
|
||||
|
||||
- 标识一次响应结束,并给出当前 serial。
|
||||
- v0:不含定时器字段。
|
||||
- v1/v2:携带 `Refresh Interval`、`Retry Interval`、`Expire Interval`。
|
||||
|
||||
## 11.5 协议时序
|
||||
|
||||
### 11.5.1 初始同步(Full Sync)
|
||||
|
||||
1. Router 建连后发 `Reset Query`(带支持的协议版本)。
|
||||
2. Cache 回 `Cache Response`。
|
||||
3. Cache 按规范发送 payload 集合。
|
||||
4. Cache 发 `End of Data` 收尾。
|
||||
|
||||
### 11.5.2 增量同步(Incremental Sync)
|
||||
|
||||
1. Router 发 `Serial Query(session_id, serial)`。
|
||||
2. Cache 若可增量,返回变化并以 `End of Data` 收尾。
|
||||
3. 若不可增量,返回 `Cache Reset`;Router 退回 Full Sync。
|
||||
|
||||
### 11.5.3 Cache Restart / Cache Shutdown(v2)
|
||||
|
||||
- `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 ASN,Cache 需向 Router 提供一致且可替换的 ASPA 视图。
|
||||
|
||||
### 11.8.2 排序与事务
|
||||
|
||||
- 草案新增 race 条件说明(如前缀替换、撤销先后导致短暂误判)。
|
||||
- 对 Cache 输出 payload 的顺序提出约束。
|
||||
- 建议 Router 使用“事务式应用”(例如接收到完整响应后再切换生效)降低中间态影响。
|
||||
|
||||
### 11.8.3 Error Code 版本范围
|
||||
|
||||
- v0(RFC6810):0..7。
|
||||
- v1(RFC8210):继承 v0,并新增 8 `Unexpected Protocol Version`。
|
||||
- v2(8210bis-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
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
290
src/bin/ccr_fixture_gen.rs
Normal 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
|
||||
}
|
||||
170
src/bin/rtr_debug_client/README.md
Normal file
170
src/bin/rtr_debug_client/README.md
Normal 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
|
||||
```
|
||||
1938
src/bin/rtr_debug_client/main.rs
Normal file
1938
src/bin/rtr_debug_client/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
404
src/bin/rtr_debug_client/pretty.rs
Normal file
404
src/bin/rtr_debug_client/pretty.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
167
src/bin/rtr_debug_client/protocol.rs
Normal file
167
src/bin/rtr_debug_client/protocol.rs
Normal 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
|
||||
}
|
||||
76
src/bin/rtr_debug_client/wire.rs
Normal file
76
src/bin/rtr_debug_client/wire.rs
Normal 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 })
|
||||
}
|
||||
163
src/bin/slurm_apply_client.rs
Normal file
163
src/bin/slurm_apply_client.rs
Normal 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
|
||||
}
|
||||
@ -2,5 +2,5 @@ pub mod crl;
|
||||
mod rc;
|
||||
mod tal;
|
||||
mod ta;
|
||||
mod resources;
|
||||
pub mod resources;
|
||||
mod oids;
|
||||
|
||||
1278
src/data_model/rc.rs
1278
src/data_model/rc.rs
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,4 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
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);
|
||||
|
||||
impl Asn {
|
||||
|
||||
@ -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 ips: Vec<IPAddressFamily>
|
||||
pub ips: Vec<IPAddressFamily>,
|
||||
}
|
||||
|
||||
|
||||
// IP Address Family
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IPAddressFamily {
|
||||
pub address_family: Afi,
|
||||
pub ip_address_choice: IPAddressChoice,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
pub enum Afi {
|
||||
Ipv4,
|
||||
Ipv6,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum IPAddressChoice {
|
||||
Inherit,
|
||||
AddressOrRange(Vec<IPAddressOrRange>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum IPAddressOrRange {
|
||||
AddressPrefix(IPAddressPrefix),
|
||||
AddressRange(IPAddressRange),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
pub struct IPAddressPrefix {
|
||||
pub address: IPAddress,
|
||||
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 min: IPAddress,
|
||||
pub max: IPAddress,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct IPAddress(u128);
|
||||
impl IPAddressRange {
|
||||
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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,3 @@
|
||||
pub(crate) mod ip_resources;
|
||||
pub(crate) mod as_resources;
|
||||
pub mod ip_resources;
|
||||
pub mod as_resources;
|
||||
pub mod resource;
|
||||
|
||||
@ -1 +1,4 @@
|
||||
pub mod data_model;
|
||||
pub mod slurm;
|
||||
pub mod rtr;
|
||||
pub mod source;
|
||||
|
||||
133
src/main_rtr.rs
Normal file
133
src/main_rtr.rs
Normal 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
920
src/rtr/admin.rs
Normal 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
115
src/rtr/bootstrap.rs
Normal 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
900
src/rtr/cache/core.rs
vendored
Normal 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
20
src/rtr/cache/mod.rs
vendored
Normal 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
523
src/rtr/cache/model.rs
vendored
Normal 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
343
src/rtr/cache/ordering.rs
vendored
Normal 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
233
src/rtr/cache/store.rs
vendored
Normal 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
615
src/rtr/config.rs
Normal 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
223
src/rtr/error_type.rs
Normal 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
13
src/rtr/mod.rs
Normal 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
294
src/rtr/payload.rs
Normal 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
1350
src/rtr/pdu.rs
Normal file
File diff suppressed because it is too large
Load Diff
777
src/rtr/report.rs
Normal file
777
src/rtr/report.rs
Normal 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
394
src/rtr/runtime.rs
Normal 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,
|
||||
¬ifier,
|
||||
&service_stats,
|
||||
);
|
||||
report_context.write_clients_or_warn(
|
||||
&report_dir,
|
||||
"startup",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service_stats,
|
||||
);
|
||||
report_context.write_runtime_or_warn(
|
||||
&report_dir,
|
||||
"startup",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&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, ¬ifier, &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, ¬ifier, &service_stats);
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("RTR client connection change channel closed");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ = runtime_interval.tick() => {
|
||||
log_cache_memory_stats("periodic_observe", &shared_cache, ¬ifier);
|
||||
report_context.write_runtime_or_warn(&report_dir, "runtime_periodic", &shared_cache, ¬ifier, &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,
|
||||
¬ifier,
|
||||
&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,
|
||||
¬ifier,
|
||||
&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(¤t_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
26
src/rtr/server/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
240
src/rtr/server/connection.rs
Normal file
240
src/rtr/server/connection.rs
Normal 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
681
src/rtr/server/listener.rs
Normal 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
17
src/rtr/server/mod.rs
Normal 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;
|
||||
20
src/rtr/server/notifier.rs
Normal file
20
src/rtr/server/notifier.rs
Normal 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
422
src/rtr/server/service.rs
Normal 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
136
src/rtr/server/ssh.rs
Normal 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
124
src/rtr/server/tls.rs
Normal 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
Loading…
x
Reference in New Issue
Block a user