增加rtr一些必要的api接口
This commit is contained in:
parent
303bdffd97
commit
b105b3b7d0
18
README.md
18
README.md
@ -96,6 +96,11 @@ RTR Server 运行时从 `CCR` 目录中扫描最新的 `.ccr` 文件作为输入
|
||||
| `RPKI_RTR_TCP_KEEPALIVE_SECS` | TCP keepalive 时间,单位秒;设为 `0` 表示禁用。 | `60` | `60` |
|
||||
| `RPKI_RTR_WARN_INSECURE_TCP` | 纯 TCP 模式下是否输出不安全警告。 | `true` | `true` |
|
||||
| `RPKI_RTR_REQUIRE_TLS_SERVER_DNS_NAME_SAN` | 严格模式:TLS 服务端证书不包含 `subjectAltName dNSName` 时拒绝启动。 | `false` | `false` |
|
||||
| `RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS` | `rtr-runtime-*.json` 周期写入间隔,单位秒,必须 `>= 1`。 | `300` | `300` |
|
||||
| `RPKI_RTR_REPORT_HISTORY_LIMIT` | `report/` 目录下每类报告文件的滚动保留数量。 | `10` | `10` |
|
||||
| `RPKI_RTR_TIMEZONE` | 日志和 JSON report 时间使用的 IANA 时区。 | `Asia/Shanghai` | `Asia/Shanghai` |
|
||||
| `RPKI_RTR_ADMIN_ADDR` | Runtime admin config HTTP 监听地址;空值表示不启动。 | 未设置(禁用) | `127.0.0.1:8323` |
|
||||
| `RPKI_RTR_ADMIN_TOKEN` | Admin config 接口 Bearer token。非 loopback 监听地址必须设置。 | 未设置 | `change-me` |
|
||||
|
||||
### 说明
|
||||
|
||||
@ -109,6 +114,19 @@ RTR Server 运行时从 `CCR` 目录中扫描最新的 `.ccr` 文件作为输入
|
||||
- `RPKI_RTR_TCP_KEEPALIVE_SECS=0` 表示关闭 keepalive;非零值表示在整个连接生命周期内启用 keepalive。
|
||||
- `RPKI_RTR_PRUNE_DELTA_BY_SNAPSHOT_SIZE=true` 时,除了 `RPKI_RTR_MAX_DELTA` 的固定条数裁剪外,还会在累计 delta 估算 wire size 不小于 snapshot 时继续删除最老 delta。
|
||||
|
||||
### Runtime Admin Config
|
||||
|
||||
`POST /admin/rtr/config` 用于在服务运行中动态修改一部分运行时配置。接口默认关闭;设置 `RPKI_RTR_ADMIN_ADDR` 后才会启动。完整接口说明见 [`docs/rtr-admin-api.md`](docs/rtr-admin-api.md)。
|
||||
|
||||
### Runtime Reports
|
||||
|
||||
服务会在 `report/` 目录下写入分类型 JSON 文件,并按
|
||||
`RPKI_RTR_REPORT_HISTORY_LIMIT` 循环保留:
|
||||
|
||||
- `rtr-source-*.json`:CCR/SLURM source、fingerprint、refresh 状态、数据质量、cache snapshot/delta 统计。
|
||||
- `rtr-clients-*.json`:client 连接数、连接方式统计。启动时写一次,连接数变化时更新。
|
||||
- `rtr-runtime-*.json`:进程 RSS、服务状态、当前生效 runtime configuration。启动时写一次,并按 `RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS` 周期更新。
|
||||
|
||||
## 快速启动
|
||||
|
||||
### Docker 启动
|
||||
|
||||
Binary file not shown.
@ -40,6 +40,30 @@ docker compose -f deploy/server/docker-compose.yml down
|
||||
docker compose -f deploy/server/docker-compose.yml logs -f rpki-rtr
|
||||
```
|
||||
|
||||
报告文件:
|
||||
|
||||
- `report/rtr-source-*.json`:CCR/SLURM source、fingerprint、refresh 状态、数据质量、cache snapshot/delta 统计。
|
||||
- `report/rtr-clients-*.json`:client 连接数和连接方式统计,启动时和连接变化时写入。
|
||||
- `report/rtr-runtime-*.json`:进程 RSS、服务状态、当前生效 runtime configuration,启动时和周期性写入。
|
||||
|
||||
Admin config 接口默认关闭。需要运行中动态修改 `max_delta`、delta 裁剪策略、refresh/report interval、timezone 或 RTR timing 时,设置:
|
||||
|
||||
```env
|
||||
RPKI_RTR_ADMIN_ADDR=127.0.0.1:8323
|
||||
RPKI_RTR_ADMIN_TOKEN=change-me
|
||||
```
|
||||
|
||||
调用示例:
|
||||
|
||||
```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, "prune_delta_by_snapshot_size": true}'
|
||||
```
|
||||
|
||||
完整 API 说明见 `docs/rtr-admin-api.md`,更完整的 server 配置见 `deploy/server/DEPLOYMENT.md`。
|
||||
|
||||
---
|
||||
|
||||
## 2) Debug Client
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
FROM rust:1.89-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends clang libclang-dev pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release --bin rpki_rs_test_client
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/rpki_rs_test_client /usr/local/bin/rpki_rs_test_client
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/rpki_rs_test_client"]
|
||||
@ -1,11 +0,0 @@
|
||||
services:
|
||||
rpki-rs-test-client:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/rpki-rs-client/Dockerfile
|
||||
image: rpki-rs-test-client:latest
|
||||
container_name: rpki-rs-test-client
|
||||
network_mode: host
|
||||
command: ["127.0.0.1:323", "2", "serial", "--steps", "2", "--follow"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@ -24,6 +24,8 @@ 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.
|
||||
|
||||
@ -29,13 +29,13 @@ The container runs `rpki` directly as PID 1.
|
||||
- `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 `rtr-server.json`
|
||||
- `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_STRICT_CCR_VALIDATION`, `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`, `RUST_LOG`
|
||||
- Core: `RPKI_RTR_STRICT_CCR_VALIDATION`, `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_KEYS_VOLUME`, `RPKI_RTR_SSH_CERTS_HOST_DIR`
|
||||
@ -58,6 +58,16 @@ docker compose -f deploy/server/docker-compose.yml down
|
||||
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
|
||||
@ -78,3 +88,12 @@ 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.
|
||||
|
||||
@ -75,7 +75,9 @@ ENV RPKI_RTR_ENABLE_TLS=false \
|
||||
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS=300 \
|
||||
RPKI_RTR_REPORT_HISTORY_LIMIT=10 \
|
||||
RPKI_RTR_REFRESH_INTERVAL_SECS=300 \
|
||||
RPKI_RTR_STRICT_CCR_VALIDATION=false
|
||||
RPKI_RTR_STRICT_CCR_VALIDATION=false \
|
||||
RPKI_RTR_ADMIN_ADDR="" \
|
||||
RPKI_RTR_ADMIN_TOKEN=""
|
||||
|
||||
EXPOSE 323 324
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ services:
|
||||
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"
|
||||
@ -35,11 +36,13 @@ services:
|
||||
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}:ro
|
||||
- ${RPKI_RTR_SLURM_HOST_DIR:-../../data}:${RPKI_RTR_SLURM_DIR:-/app/slurm}
|
||||
- ${RPKI_RTR_SSH_KEYS_VOLUME:-/etc/ssh:/host-ssh:ro}
|
||||
- ${RPKI_RTR_SSH_CERTS_HOST_DIR:-../../certs}:/app/certs:ro
|
||||
- ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs
|
||||
|
||||
@ -10,6 +10,7 @@ services:
|
||||
restart: no
|
||||
ports:
|
||||
- "323:323"
|
||||
- "8323:8323"
|
||||
environment:
|
||||
RPKI_RTR_ENABLE_TLS: "false"
|
||||
RPKI_RTR_ENABLE_SSH: "false"
|
||||
@ -26,11 +27,13 @@ services:
|
||||
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}:ro
|
||||
- ${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:
|
||||
|
||||
@ -9,6 +9,7 @@ services:
|
||||
ports:
|
||||
# - "323:323"
|
||||
- "324:324"
|
||||
- "8323:8323"
|
||||
environment:
|
||||
RPKI_RTR_ENABLE_TLS: "true"
|
||||
RPKI_RTR_ENABLE_SSH: "false"
|
||||
@ -29,11 +30,13 @@ services:
|
||||
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}:ro
|
||||
- ${RPKI_RTR_SLURM_HOST_DIR:-../../data}:${RPKI_RTR_SLURM_DIR:-/app/slurm}
|
||||
- ${RPKI_RTR_TLS_CERTS_HOST_DIR:-../../tests/fixtures/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}
|
||||
|
||||
@ -11,6 +11,7 @@ services:
|
||||
ports:
|
||||
- "323:323"
|
||||
- "324:324"
|
||||
- "8323:8323"
|
||||
# SSH mode example:
|
||||
# - "22:22"
|
||||
environment:
|
||||
@ -28,6 +29,8 @@ services:
|
||||
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"
|
||||
@ -42,7 +45,7 @@ services:
|
||||
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}:ro
|
||||
- ${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:
|
||||
|
||||
313
docs/rtr-admin-api.md
Normal file
313
docs/rtr-admin-api.md
Normal file
@ -0,0 +1,313 @@
|
||||
# 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,
|
||||
"strict_ccr_validation": false,
|
||||
"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`。 |
|
||||
| `strict_ccr_validation` | boolean | 是否严格校验 CCR 中的非法 VRP/VAP,影响下一次 source refresh。 |
|
||||
| `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,
|
||||
"strict_ccr_validation": false,
|
||||
"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` | 请求体超过限制。 |
|
||||
@ -1,112 +0,0 @@
|
||||
# rpki_rs_test_client
|
||||
|
||||
`rpki_rs_test_client` 是一个基于 `rpki-rs` RTR 客户端接口的测试工具,参数风格对齐 `rtr_debug_client`。
|
||||
|
||||
实现上直接调用外部 crate 的 client API(`Client` / `PayloadTarget`),不重复实现 RTR 客户端状态机。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cargo build --bin rpki_rs_test_client
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
```bash
|
||||
cargo run --bin rpki_rs_test_client -- <addr> <version> [reset|serial|serial <session_id> <serial>] [options]
|
||||
```
|
||||
|
||||
默认值:
|
||||
- `addr`: `127.0.0.1:323`
|
||||
- `version`: `2`
|
||||
- `mode`: `reset`
|
||||
|
||||
## 常用参数
|
||||
|
||||
- `--steps <n>`: 执行 `client.step()` 次数(默认 `1`)
|
||||
- `--follow`: bootstrap 结束后持续执行 `client.step()`(常驻模式)
|
||||
- `--print-records`: 打印当前收敛后的 payload 记录
|
||||
- `--assert-min-records <n>`: 断言收敛记录数下限
|
||||
- `--assert-substr <text>`: 在 payload 的 `Debug` 输出中做字符串断言(可重复)
|
||||
|
||||
TLS 参数:
|
||||
- `--tls`
|
||||
- `--ca-cert <path>`
|
||||
- `--server-name <name>`
|
||||
- `--client-cert <path>`
|
||||
- `--client-key <path>`
|
||||
|
||||
## 限制说明
|
||||
|
||||
- 当前 `rpki-rs v0.18` client API 不支持显式覆盖初始版本,因此这里只接受 `version=2`。
|
||||
- 支持 `serial`(无参数)模式:会基于 client 内部 state 自动走 serial 更新。
|
||||
- 当前不支持 `serial <session_id> <serial>` 显式注入状态(传入会直接报错)。
|
||||
|
||||
## 示例
|
||||
|
||||
TCP 连通 + 最小记录数断言:
|
||||
|
||||
```bash
|
||||
cargo run --bin rpki_rs_test_client -- \
|
||||
127.0.0.1:323 \
|
||||
2 reset \
|
||||
--steps 1 \
|
||||
--assert-min-records 1
|
||||
```
|
||||
|
||||
自动 serial(无需传 sid/serial):
|
||||
|
||||
```bash
|
||||
cargo run --bin rpki_rs_test_client -- \
|
||||
127.0.0.1:323 \
|
||||
2 serial --steps 2 --follow
|
||||
```
|
||||
|
||||
结合 `mini_data` 的内容做字符串断言:
|
||||
|
||||
```bash
|
||||
cargo run --bin rpki_rs_test_client -- \
|
||||
127.0.0.1:323 \
|
||||
2 reset \
|
||||
--assert-substr "10.0.1.0" \
|
||||
--assert-substr "65003"
|
||||
```
|
||||
|
||||
TLS 场景:
|
||||
|
||||
```bash
|
||||
cargo run --bin rpki_rs_test_client -- \
|
||||
127.0.0.1:324 \
|
||||
2 reset \
|
||||
--tls \
|
||||
--ca-cert tests/fixtures/tls/client-ca.crt \
|
||||
--server-name localhost
|
||||
```
|
||||
|
||||
## Docker 启动(deploy)
|
||||
|
||||
构建并启动(Linux 服务器,`host` 网络):
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml up --build
|
||||
```
|
||||
|
||||
按需覆盖运行参数(覆盖 compose 默认 `command`):
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml run --rm \
|
||||
rpki-rs-test-client 127.0.0.1:323 2 reset --steps 1 --assert-min-records 1
|
||||
```
|
||||
|
||||
常驻跟进模式(先 bootstrap,再持续 serial/notify):
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml run --rm \
|
||||
rpki-rs-test-client 127.0.0.1:323 2 serial --steps 2 --follow
|
||||
```
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml down
|
||||
```
|
||||
@ -1,843 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
|
||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use rpki_rs::rtr::client::{Client, PayloadError, PayloadTarget};
|
||||
use rpki_rs::rtr::payload::{Action, Payload, Timing};
|
||||
|
||||
const DEFAULT_STEPS: usize = 1;
|
||||
const DEFAULT_STEP_TIMEOUT_SECS: u64 = 300;
|
||||
const DEFAULT_PROGRESS_EVERY: u64 = 10_000;
|
||||
|
||||
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T> AsyncStream for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
|
||||
type DynStream = Box<dyn AsyncStream>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
addr: String,
|
||||
steps: usize,
|
||||
follow: bool,
|
||||
transport: TransportConfig,
|
||||
assert_substr: Vec<String>,
|
||||
assert_min_records: Option<usize>,
|
||||
print_records: bool,
|
||||
step_timeout_secs: u64,
|
||||
progress_every: u64,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn from_args() -> io::Result<Self> {
|
||||
let mut args = env::args().skip(1);
|
||||
let mut positional = Vec::new();
|
||||
|
||||
let mut steps = DEFAULT_STEPS;
|
||||
let mut follow = false;
|
||||
let mut transport = TransportConfig::Tcp;
|
||||
let mut assert_substr = Vec::new();
|
||||
let mut assert_min_records = None;
|
||||
let mut print_records = false;
|
||||
let mut step_timeout_secs = DEFAULT_STEP_TIMEOUT_SECS;
|
||||
let mut progress_every = DEFAULT_PROGRESS_EVERY;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"-h" | "--help" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
"--steps" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "--steps requires value")
|
||||
})?;
|
||||
steps = parse_usize_arg(&v, "--steps")?;
|
||||
if steps == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--steps must be >= 1",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
"--follow" => {
|
||||
follow = true;
|
||||
}
|
||||
|
||||
"--tls" => {
|
||||
ensure_tls(&mut transport)?;
|
||||
}
|
||||
|
||||
"--ca-cert" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "--ca-cert requires path")
|
||||
})?;
|
||||
ensure_tls(&mut transport)?.ca_cert = Some(PathBuf::from(v));
|
||||
}
|
||||
|
||||
"--server-name" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--server-name requires value",
|
||||
)
|
||||
})?;
|
||||
ensure_tls(&mut transport)?.server_name = Some(v);
|
||||
}
|
||||
|
||||
"--client-cert" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--client-cert requires path",
|
||||
)
|
||||
})?;
|
||||
ensure_tls(&mut transport)?.client_cert = Some(PathBuf::from(v));
|
||||
}
|
||||
|
||||
"--client-key" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--client-key requires path",
|
||||
)
|
||||
})?;
|
||||
ensure_tls(&mut transport)?.client_key = Some(PathBuf::from(v));
|
||||
}
|
||||
|
||||
"--assert-substr" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--assert-substr requires value",
|
||||
)
|
||||
})?;
|
||||
assert_substr.push(v);
|
||||
}
|
||||
|
||||
"--assert-min-records" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--assert-min-records requires value",
|
||||
)
|
||||
})?;
|
||||
assert_min_records = Some(parse_usize_arg(&v, "--assert-min-records")?);
|
||||
}
|
||||
|
||||
"--print-records" => {
|
||||
print_records = true;
|
||||
}
|
||||
|
||||
"--step-timeout-secs" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--step-timeout-secs requires value",
|
||||
)
|
||||
})?;
|
||||
step_timeout_secs = parse_u64_arg(&v, "--step-timeout-secs")?;
|
||||
if step_timeout_secs == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--step-timeout-secs must be >= 1",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
"--progress-every" => {
|
||||
let v = args.next().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--progress-every requires value",
|
||||
)
|
||||
})?;
|
||||
progress_every = parse_u64_arg(&v, "--progress-every")?;
|
||||
if progress_every == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--progress-every must be >= 1",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 明确拒绝当前 wrapper 不支持的能力
|
||||
"--version" => {
|
||||
let _ = args.next().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "--version requires value")
|
||||
})?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--version is not exposed by this rpki client wrapper",
|
||||
));
|
||||
}
|
||||
|
||||
"--timeout" => {
|
||||
let _ = args.next().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "--timeout requires value")
|
||||
})?;
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"--timeout is not exposed by this rpki client wrapper; use --step-timeout-secs instead",
|
||||
));
|
||||
}
|
||||
|
||||
_ if arg.starts_with("--") => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("unknown option '{}'", arg),
|
||||
));
|
||||
}
|
||||
|
||||
_ => positional.push(arg),
|
||||
}
|
||||
}
|
||||
|
||||
let mut positional = positional.into_iter();
|
||||
let addr = positional
|
||||
.next()
|
||||
.unwrap_or_else(|| "127.0.0.1:323".to_string());
|
||||
|
||||
if let Some(extra) = positional.next() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"unexpected positional argument '{}'; only optional [addr] is supported",
|
||||
extra
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let transport = finalize_transport(transport, &addr)?;
|
||||
|
||||
Ok(Self {
|
||||
addr,
|
||||
steps,
|
||||
follow,
|
||||
transport,
|
||||
assert_substr,
|
||||
assert_min_records,
|
||||
print_records,
|
||||
step_timeout_secs,
|
||||
progress_every,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"\
|
||||
rpki-rs-test-client
|
||||
|
||||
Usage:
|
||||
rpki-rs-test-client [OPTIONS] [ADDR]
|
||||
|
||||
Examples:
|
||||
rpki-rs-test-client
|
||||
rpki-rs-test-client 127.0.0.1:323 --steps 1
|
||||
rpki-rs-test-client 127.0.0.1:323 --steps 1 --step-timeout-secs 600
|
||||
rpki-rs-test-client 127.0.0.1:323 --follow
|
||||
rpki-rs-test-client 127.0.0.1:323 --assert-min-records 1 --assert-substr 192.0.2.
|
||||
rpki-rs-test-client 127.0.0.1:3324 --tls --ca-cert certs/ca.pem --server-name localhost
|
||||
|
||||
Options:
|
||||
--steps <N> Number of client.step() calls to perform (default: 1)
|
||||
--follow Keep calling step() forever
|
||||
--tls Enable TLS
|
||||
--ca-cert <PATH> CA certificate PEM file (required in TLS mode)
|
||||
--server-name <NAME> TLS server name; required when ADDR host is an IP
|
||||
--client-cert <PATH> Client certificate PEM file (optional, with --client-key)
|
||||
--client-key <PATH> Client private key PEM file (optional, with --client-cert)
|
||||
--assert-substr <TEXT> Assert final stable record dump contains substring
|
||||
--assert-min-records <N> Assert final record count >= N
|
||||
--print-records Print records after each successful step
|
||||
--step-timeout-secs <N> Timeout for each step() call in seconds (default: 300)
|
||||
--progress-every <N> Print apply progress every N updates (default: 10000)
|
||||
-h, --help Show this help
|
||||
|
||||
Not supported by this wrapper:
|
||||
--version
|
||||
--timeout
|
||||
explicit serial bootstrap via session_id/serial
|
||||
"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum TransportConfig {
|
||||
Tcp,
|
||||
Tls(TlsConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct TlsConfig {
|
||||
server_name: Option<String>,
|
||||
ca_cert: Option<PathBuf>,
|
||||
client_cert: Option<PathBuf>,
|
||||
client_key: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn ensure_tls(transport: &mut TransportConfig) -> io::Result<&mut TlsConfig> {
|
||||
if matches!(transport, TransportConfig::Tcp) {
|
||||
*transport = TransportConfig::Tls(TlsConfig::default());
|
||||
}
|
||||
|
||||
match transport {
|
||||
TransportConfig::Tls(cfg) => Ok(cfg),
|
||||
TransportConfig::Tcp => Err(io::Error::other("tls configuration unavailable")),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_transport(transport: TransportConfig, addr: &str) -> io::Result<TransportConfig> {
|
||||
match transport {
|
||||
TransportConfig::Tcp => Ok(TransportConfig::Tcp),
|
||||
TransportConfig::Tls(mut cfg) => {
|
||||
let ca_cert = cfg.ca_cert.take().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"TLS mode requires --ca-cert <path>",
|
||||
)
|
||||
})?;
|
||||
|
||||
match (&cfg.client_cert, &cfg.client_key) {
|
||||
(Some(_), Some(_)) | (None, None) => {}
|
||||
_ => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"TLS client auth requires both --client-cert and --client-key",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let server_name = match cfg.server_name.take() {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
let host = parse_host_from_addr(addr).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"failed to parse host from address",
|
||||
)
|
||||
})?;
|
||||
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"TLS with IP address requires explicit --server-name",
|
||||
));
|
||||
}
|
||||
|
||||
host
|
||||
}
|
||||
};
|
||||
|
||||
Ok(TransportConfig::Tls(TlsConfig {
|
||||
server_name: Some(server_name),
|
||||
ca_cert: Some(ca_cert),
|
||||
client_cert: cfg.client_cert,
|
||||
client_key: cfg.client_key,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct SharedTarget {
|
||||
inner: Arc<Mutex<TargetState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TargetState {
|
||||
records: BTreeSet<Payload>,
|
||||
timing: Option<Timing>,
|
||||
announced_seen: u64,
|
||||
withdrawn_seen: u64,
|
||||
updates_applied_total: u64,
|
||||
progress_every: u64,
|
||||
apply_batches: u64,
|
||||
last_apply_started_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for TargetState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
records: BTreeSet::new(),
|
||||
timing: None,
|
||||
announced_seen: 0,
|
||||
withdrawn_seen: 0,
|
||||
updates_applied_total: 0,
|
||||
progress_every: DEFAULT_PROGRESS_EVERY,
|
||||
apply_batches: 0,
|
||||
last_apply_started_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TargetSnapshot {
|
||||
records: Vec<Payload>,
|
||||
timing: Option<Timing>,
|
||||
announced_seen: u64,
|
||||
withdrawn_seen: u64,
|
||||
updates_applied_total: u64,
|
||||
apply_batches: u64,
|
||||
}
|
||||
|
||||
impl SharedTarget {
|
||||
fn new(progress_every: u64) -> Self {
|
||||
let state = TargetState {
|
||||
progress_every,
|
||||
..TargetState::default()
|
||||
};
|
||||
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(state)),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> TargetSnapshot {
|
||||
let guard = self.inner.lock().expect("target mutex poisoned");
|
||||
TargetSnapshot {
|
||||
records: guard.records.iter().cloned().collect(),
|
||||
timing: guard.timing,
|
||||
announced_seen: guard.announced_seen,
|
||||
withdrawn_seen: guard.withdrawn_seen,
|
||||
updates_applied_total: guard.updates_applied_total,
|
||||
apply_batches: guard.apply_batches,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_to_stable_text(payload: &Payload) -> String {
|
||||
format!("{:?}", payload)
|
||||
}
|
||||
}
|
||||
|
||||
impl TargetSnapshot {
|
||||
fn dump_text(&self) -> String {
|
||||
self.records
|
||||
.iter()
|
||||
.map(SharedTarget::payload_to_stable_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl PayloadTarget for SharedTarget {
|
||||
type Update = Vec<(Action, Payload)>;
|
||||
|
||||
fn start(&mut self, reset: bool) -> Self::Update {
|
||||
if reset {
|
||||
let mut guard = self.inner.lock().expect("target mutex poisoned");
|
||||
println!(
|
||||
"[target] start reset=true | clearing existing records={}",
|
||||
guard.records.len()
|
||||
);
|
||||
guard.records.clear();
|
||||
} else {
|
||||
let guard = self.inner.lock().expect("target mutex poisoned");
|
||||
println!(
|
||||
"[target] start reset=false | current records={}",
|
||||
guard.records.len()
|
||||
);
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn apply(&mut self, update: Self::Update, timing: Timing) -> Result<(), PayloadError> {
|
||||
let total = update.len() as u64;
|
||||
|
||||
{
|
||||
let mut guard = self.inner.lock().expect("target mutex poisoned");
|
||||
guard.apply_batches += 1;
|
||||
guard.last_apply_started_at = Some(Instant::now());
|
||||
println!(
|
||||
"[target] apply batch #{} started | updates={} | current records={}",
|
||||
guard.apply_batches,
|
||||
total,
|
||||
guard.records.len()
|
||||
);
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let mut local_processed: u64 = 0;
|
||||
|
||||
let progress_every = {
|
||||
let guard = self.inner.lock().expect("target mutex poisoned");
|
||||
guard.progress_every
|
||||
};
|
||||
|
||||
let mut guard = self.inner.lock().expect("target mutex poisoned");
|
||||
|
||||
for (action, payload) in update {
|
||||
match action {
|
||||
Action::Announce => {
|
||||
guard.announced_seen += 1;
|
||||
if !guard.records.insert(payload) {
|
||||
return Err(PayloadError::DuplicateAnnounce);
|
||||
}
|
||||
}
|
||||
Action::Withdraw => {
|
||||
guard.withdrawn_seen += 1;
|
||||
if !guard.records.remove(&payload) {
|
||||
return Err(PayloadError::UnknownWithdraw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
local_processed += 1;
|
||||
guard.updates_applied_total += 1;
|
||||
|
||||
if local_processed % progress_every == 0 || local_processed == total {
|
||||
println!(
|
||||
"[target] apply progress | batch_processed={}/{} | total_updates_seen={} | records={} | elapsed={:.2?}",
|
||||
local_processed,
|
||||
total,
|
||||
guard.updates_applied_total,
|
||||
guard.records.len(),
|
||||
started.elapsed(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
guard.timing = Some(timing);
|
||||
|
||||
println!(
|
||||
"[target] apply batch complete | updates={} | records={} | announced_seen={} | withdrawn_seen={} | elapsed={:.2?}",
|
||||
total,
|
||||
guard.records.len(),
|
||||
guard.announced_seen,
|
||||
guard.withdrawn_seen,
|
||||
started.elapsed(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn print_step_summary(
|
||||
step_no: usize,
|
||||
before: &TargetSnapshot,
|
||||
after: &TargetSnapshot,
|
||||
print_records: bool,
|
||||
) {
|
||||
println!(
|
||||
"[step] {} ok | records: {} -> {} | delta announce={} withdraw={} | delta updates={} | apply_batches={}",
|
||||
step_no,
|
||||
before.records.len(),
|
||||
after.records.len(),
|
||||
after.announced_seen.saturating_sub(before.announced_seen),
|
||||
after.withdrawn_seen.saturating_sub(before.withdrawn_seen),
|
||||
after
|
||||
.updates_applied_total
|
||||
.saturating_sub(before.updates_applied_total),
|
||||
after.apply_batches,
|
||||
);
|
||||
|
||||
if let Some(timing) = after.timing {
|
||||
println!(
|
||||
"[step] {} timing | refresh={} retry={} expire={}",
|
||||
step_no, timing.refresh, timing.retry, timing.expire
|
||||
);
|
||||
}
|
||||
|
||||
if print_records {
|
||||
println!("-- records after step {} --", step_no);
|
||||
if after.records.is_empty() {
|
||||
println!("(empty)");
|
||||
} else {
|
||||
for rec in &after.records {
|
||||
println!("{}", SharedTarget::payload_to_stable_text(rec));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_assertions(config: &Config, snapshot: &TargetSnapshot) -> io::Result<()> {
|
||||
if let Some(min) = config.assert_min_records
|
||||
&& snapshot.records.len() < min
|
||||
{
|
||||
return Err(io::Error::other(format!(
|
||||
"assertion failed: records {} < {}",
|
||||
snapshot.records.len(),
|
||||
min
|
||||
)));
|
||||
}
|
||||
|
||||
if !config.assert_substr.is_empty() {
|
||||
let dump = snapshot.dump_text();
|
||||
for needle in &config.assert_substr {
|
||||
if !dump.contains(needle) {
|
||||
return Err(io::Error::other(format!(
|
||||
"assertion failed: missing substring '{}'",
|
||||
needle
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
let config = Config::from_args()?;
|
||||
|
||||
println!("== rpki-rs-test-client ==");
|
||||
println!("target : {}", config.addr);
|
||||
println!("steps : {}", config.steps);
|
||||
println!("follow : {}", config.follow);
|
||||
println!("step_timeout_secs : {}", config.step_timeout_secs);
|
||||
println!("progress_every : {}", config.progress_every);
|
||||
|
||||
match &config.transport {
|
||||
TransportConfig::Tcp => {
|
||||
println!("transport : tcp");
|
||||
}
|
||||
TransportConfig::Tls(tls) => {
|
||||
println!("transport : tls");
|
||||
println!(
|
||||
"server_name : {}",
|
||||
tls.server_name.as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
println!(
|
||||
"ca_cert : {}",
|
||||
tls.ca_cert
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "<unset>".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let stream = connect_stream(&config).await?;
|
||||
let target = SharedTarget::new(config.progress_every);
|
||||
let inspect = target.clone();
|
||||
|
||||
let mut client = Client::new(stream, target, None);
|
||||
|
||||
for idx in 0..config.steps {
|
||||
let step_no = idx + 1;
|
||||
let before = inspect.snapshot();
|
||||
|
||||
println!("[step] {} begin", step_no);
|
||||
let step_started = Instant::now();
|
||||
|
||||
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"step {} timed out after {}s",
|
||||
step_no, config.step_timeout_secs
|
||||
),
|
||||
)
|
||||
})?
|
||||
.map_err(|err| io::Error::new(err.kind(), format!("step {} failed: {}", step_no, err)))?;
|
||||
|
||||
let after = inspect.snapshot();
|
||||
print_step_summary(step_no, &before, &after, config.print_records);
|
||||
println!(
|
||||
"[step] {} finished in {:.2?}",
|
||||
step_no,
|
||||
step_started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
if config.follow {
|
||||
let mut step_index = config.steps;
|
||||
println!("[follow] enabled, entering continuous step loop");
|
||||
|
||||
loop {
|
||||
step_index += 1;
|
||||
let before = inspect.snapshot();
|
||||
|
||||
println!("[step] {} begin", step_index);
|
||||
let step_started = Instant::now();
|
||||
|
||||
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
format!(
|
||||
"step {} timed out after {}s",
|
||||
step_index, config.step_timeout_secs
|
||||
),
|
||||
)
|
||||
})?
|
||||
.map_err(|err| {
|
||||
io::Error::new(err.kind(), format!("step {} failed: {}", step_index, err))
|
||||
})?;
|
||||
|
||||
let after = inspect.snapshot();
|
||||
print_step_summary(step_index, &before, &after, config.print_records);
|
||||
println!(
|
||||
"[step] {} finished in {:.2?}",
|
||||
step_index,
|
||||
step_started.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let state = client.state();
|
||||
let final_snapshot = inspect.snapshot();
|
||||
|
||||
println!("state : {:?}", state);
|
||||
if let Some(timing) = final_snapshot.timing {
|
||||
println!(
|
||||
"timing : refresh={} retry={} expire={}",
|
||||
timing.refresh, timing.retry, timing.expire
|
||||
);
|
||||
}
|
||||
println!("records : {}", final_snapshot.records.len());
|
||||
println!(
|
||||
"updates_seen : announce={} withdraw={}",
|
||||
final_snapshot.announced_seen, final_snapshot.withdrawn_seen
|
||||
);
|
||||
println!(
|
||||
"updates_applied : {}",
|
||||
final_snapshot.updates_applied_total
|
||||
);
|
||||
println!("apply_batches : {}", final_snapshot.apply_batches);
|
||||
|
||||
run_assertions(&config, &final_snapshot)?;
|
||||
println!("[assert] passed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_stream(config: &Config) -> io::Result<DynStream> {
|
||||
match &config.transport {
|
||||
TransportConfig::Tcp => Ok(Box::new(TcpStream::connect(&config.addr).await?)),
|
||||
TransportConfig::Tls(tls) => connect_tls_stream(&config.addr, tls).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_tls_stream(addr: &str, tls: &TlsConfig) -> io::Result<DynStream> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
let connector = build_tls_connector(tls)?;
|
||||
|
||||
let server_name_str = tls
|
||||
.server_name
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS server name"))?;
|
||||
|
||||
let server_name = ServerName::try_from(server_name_str.clone()).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid TLS server name '{}': {}", server_name_str, err),
|
||||
)
|
||||
})?;
|
||||
|
||||
let tls_stream = connector.connect(server_name, stream).await.map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::ConnectionAborted,
|
||||
format!("TLS handshake failed: {}", err),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Box::new(tls_stream))
|
||||
}
|
||||
|
||||
fn build_tls_connector(tls: &TlsConfig) -> io::Result<TlsConnector> {
|
||||
let ca_cert_path = tls
|
||||
.ca_cert
|
||||
.as_ref()
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS CA cert"))?;
|
||||
|
||||
let ca_certs = load_certs(ca_cert_path)?;
|
||||
|
||||
let mut roots = RootCertStore::empty();
|
||||
let (added, _ignored) = roots.add_parsable_certificates(ca_certs);
|
||||
if added == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("no valid CA certs in {}", ca_cert_path.display()),
|
||||
));
|
||||
}
|
||||
|
||||
let builder = RustlsClientConfig::builder().with_root_certificates(roots);
|
||||
|
||||
let cfg = match (&tls.client_cert, &tls.client_key) {
|
||||
(Some(cert_path), Some(key_path)) => {
|
||||
let certs = load_certs(cert_path)?;
|
||||
let key = load_private_key(key_path)?;
|
||||
builder.with_client_auth_cert(certs, key).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid client cert/key: {}", err),
|
||||
)
|
||||
})?
|
||||
}
|
||||
(None, None) => builder.with_no_client_auth(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
Ok(TlsConnector::from(Arc::new(cfg)))
|
||||
}
|
||||
|
||||
fn load_certs(path: &Path) -> io::Result<Vec<CertificateDer<'static>>> {
|
||||
let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
let certs = rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
|
||||
if certs.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("no certs found in {}", path.display()),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(certs)
|
||||
}
|
||||
|
||||
fn load_private_key(path: &Path) -> io::Result<PrivateKeyDer<'static>> {
|
||||
let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
|
||||
rustls_pemfile::private_key(&mut reader)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("no private key in {}", path.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_host_from_addr(addr: &str) -> Option<String> {
|
||||
if let Some(rest) = addr.strip_prefix('[') {
|
||||
return rest.split(']').next().map(str::to_string);
|
||||
}
|
||||
addr.rsplit_once(':').map(|(host, _)| host.to_string())
|
||||
}
|
||||
|
||||
fn parse_usize_arg(value: &str, name: &str) -> io::Result<usize> {
|
||||
value.parse::<usize>().map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid {} '{}': {}", name, value, err),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64_arg(value: &str, name: &str) -> io::Result<u64> {
|
||||
value.parse::<u64>().map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("invalid {} '{}': {}", name, value, err),
|
||||
)
|
||||
})
|
||||
}
|
||||
307
src/main_rtr.rs
307
src/main_rtr.rs
@ -5,14 +5,20 @@ use std::time::Instant;
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
use chrono::Utc;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use rpki::rtr::admin::{
|
||||
AdminState, LogTailConfig, RuntimeConfigHandle, SourceReloadHandle, SourceReloadResult,
|
||||
spawn_admin_config_server,
|
||||
};
|
||||
use rpki::rtr::cache::{RtrCache, SharedRtrCache, Snapshot};
|
||||
use rpki::rtr::config::{AppConfig, log_startup_config};
|
||||
use rpki::rtr::config::{AppConfig, RuntimeConfig, log_startup_config};
|
||||
use rpki::rtr::report::{ReportConfiguration, ReportContext, current_rss_mib};
|
||||
use rpki::rtr::server::{RtrNotifier, RtrService, RtrServiceStats, RunningRtrService};
|
||||
use rpki::rtr::store::RtrStore;
|
||||
use rpki::slurm::admin::SlurmAdmin;
|
||||
use rpki::source::pipeline::{
|
||||
PayloadLoadConfig, SourceFingerprint, latest_sources_fingerprint,
|
||||
load_payloads_from_latest_sources_with_report,
|
||||
@ -40,14 +46,29 @@ async fn main() -> Result<()> {
|
||||
));
|
||||
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 (source_reload_tx, source_reload_rx) = mpsc::channel(8);
|
||||
let source_reload = SourceReloadHandle::new(source_reload_tx);
|
||||
|
||||
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()),
|
||||
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,
|
||||
@ -62,6 +83,10 @@ async fn main() -> Result<()> {
|
||||
|
||||
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(())
|
||||
@ -171,23 +196,28 @@ fn start_servers(config: &AppConfig, service: &RtrService) -> RunningRtrService
|
||||
|
||||
fn spawn_refresh_task(
|
||||
config: &AppConfig,
|
||||
runtime_config: RuntimeConfigHandle,
|
||||
mut source_reload_rx: mpsc::Receiver<rpki::rtr::admin::SourceReloadCommand>,
|
||||
shared_cache: SharedRtrCache,
|
||||
store: RtrStore,
|
||||
notifier: RtrNotifier,
|
||||
service_stats: RtrServiceStats,
|
||||
report_context: ReportContext,
|
||||
) -> JoinHandle<()> {
|
||||
let refresh_interval = config.source_refresh_interval;
|
||||
let runtime_report_interval = config.runtime_report_interval;
|
||||
let report_dir = PathBuf::from(&config.report_dir);
|
||||
let payload_load_config = PayloadLoadConfig {
|
||||
let mut payload_load_config = PayloadLoadConfig {
|
||||
ccr_dir: config.ccr_dir.clone(),
|
||||
slurm_dir: config.slurm_dir.clone(),
|
||||
strict_ccr_validation: config.strict_ccr_validation,
|
||||
};
|
||||
let initial_runtime_config = runtime_config.current();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(refresh_interval);
|
||||
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,
|
||||
@ -211,14 +241,39 @@ fn spawn_refresh_task(
|
||||
&service_stats,
|
||||
);
|
||||
let mut runtime_interval = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + runtime_report_interval,
|
||||
runtime_report_interval,
|
||||
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 payload_load_config,
|
||||
&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(()) => {
|
||||
@ -236,11 +291,60 @@ fn spawn_refresh_task(
|
||||
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) {
|
||||
let current_fingerprint = match latest_sources_fingerprint(payload_load_config) {
|
||||
Ok(fp) => fp,
|
||||
Err(err) => {
|
||||
report_context.record_refresh_failure(
|
||||
@ -255,63 +359,84 @@ fn spawn_refresh_task(
|
||||
source_to_delta_started.elapsed().as_millis()
|
||||
);
|
||||
report_context.write_source_or_warn(
|
||||
&report_dir,
|
||||
report_dir,
|
||||
"refresh_failed",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service_stats,
|
||||
shared_cache,
|
||||
notifier,
|
||||
service_stats,
|
||||
);
|
||||
continue;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
report_context.record_source_fingerprint(current_fingerprint.clone());
|
||||
|
||||
if last_fingerprint.as_ref() == Some(¤t_fingerprint) {
|
||||
report_context.record_refresh_unchanged(
|
||||
attempted_at,
|
||||
source_to_delta_started.elapsed().as_millis(),
|
||||
);
|
||||
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, ¬ifier);
|
||||
log_cache_memory_stats("refresh_skipped_unchanged", shared_cache, notifier);
|
||||
report_context.write_source_or_warn(
|
||||
&report_dir,
|
||||
report_dir,
|
||||
"refresh_skipped_unchanged",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service_stats,
|
||||
shared_cache,
|
||||
notifier,
|
||||
service_stats,
|
||||
);
|
||||
continue;
|
||||
return Ok(SourceReloadResult {
|
||||
phase,
|
||||
changed: false,
|
||||
skipped_unchanged: true,
|
||||
payload_count: None,
|
||||
serials: shared_cache.load_full().serials(),
|
||||
});
|
||||
}
|
||||
|
||||
match load_payloads_from_latest_sources_with_report(&payload_load_config) {
|
||||
Ok(load) => {
|
||||
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, updated) = {
|
||||
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)
|
||||
{
|
||||
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
|
||||
payload_load_config.ccr_dir, payload_count, old_serial, new_serial
|
||||
);
|
||||
true
|
||||
} else {
|
||||
@ -330,17 +455,15 @@ fn spawn_refresh_task(
|
||||
);
|
||||
warn!("RTR cache update failed: {:?}", err);
|
||||
report_context.write_source_or_warn(
|
||||
&report_dir,
|
||||
report_dir,
|
||||
"refresh_failed",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service_stats,
|
||||
shared_cache,
|
||||
notifier,
|
||||
service_stats,
|
||||
);
|
||||
continue;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
(payload_count, updated)
|
||||
};
|
||||
report_context.record_refresh_success(
|
||||
attempted_at,
|
||||
source_to_delta_started.elapsed().as_millis(),
|
||||
@ -349,7 +472,8 @@ fn spawn_refresh_task(
|
||||
quality,
|
||||
);
|
||||
info!(
|
||||
"RTR source-to-delta timing: phase=refresh_cache_update_complete, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}",
|
||||
"RTR source-to-delta timing: phase={}, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}",
|
||||
phase,
|
||||
payload_load_config.ccr_dir,
|
||||
payload_count,
|
||||
updated,
|
||||
@ -363,41 +487,76 @@ fn spawn_refresh_task(
|
||||
listener_count
|
||||
);
|
||||
}
|
||||
log_cache_memory_stats("refresh_complete", &shared_cache, ¬ifier);
|
||||
report_context.write_source_or_warn(
|
||||
&report_dir,
|
||||
"refresh_complete",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service_stats,
|
||||
);
|
||||
last_fingerprint = Some(current_fingerprint);
|
||||
}
|
||||
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,
|
||||
¬ifier,
|
||||
&service_stats,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
payload_load_config: &mut PayloadLoadConfig,
|
||||
refresh_interval: &mut tokio::time::Interval,
|
||||
runtime_interval: &mut tokio::time::Interval,
|
||||
) {
|
||||
let old = active.clone();
|
||||
*active = next.clone();
|
||||
payload_load_config.strict_ccr_validation = next.strict_ccr_validation;
|
||||
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={}, strict_ccr_validation={}, 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.strict_ccr_validation,
|
||||
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();
|
||||
|
||||
857
src/rtr/admin.rs
Normal file
857
src/rtr/admin.rs
Normal file
@ -0,0 +1,857 @@
|
||||
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::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>,
|
||||
slurm_admin: Option<SlurmAdmin>,
|
||||
log_tail: LogTailConfig,
|
||||
}
|
||||
|
||||
impl AdminState {
|
||||
pub fn new(
|
||||
runtime_config: RuntimeConfigHandle,
|
||||
source_reload: Option<SourceReloadHandle>,
|
||||
slurm_admin: Option<SlurmAdmin>,
|
||||
log_tail: LogTailConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime_config,
|
||||
source_reload,
|
||||
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(),
|
||||
slurm_api: state.slurm_admin.is_some(),
|
||||
logs_api: true,
|
||||
})?;
|
||||
write_response(stream, 200, &json, "application/json").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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 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,
|
||||
slurm_api: bool,
|
||||
logs_api: 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(())
|
||||
}
|
||||
85
src/rtr/cache/core.rs
vendored
85
src/rtr/cache/core.rs
vendored
@ -277,12 +277,20 @@ impl RtrCache {
|
||||
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 {
|
||||
while state.deltas.len() > max_keep {
|
||||
state.deltas.pop_front();
|
||||
}
|
||||
state.deltas.push_back(delta);
|
||||
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());
|
||||
@ -426,6 +434,23 @@ impl RtrCache {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -469,6 +494,62 @@ impl RtrCache {
|
||||
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()
|
||||
}
|
||||
|
||||
10
src/rtr/cache/model.rs
vendored
10
src/rtr/cache/model.rs
vendored
@ -82,11 +82,7 @@ pub struct Snapshot {
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub fn new(
|
||||
origins: Vec<RouteOrigin>,
|
||||
router_keys: Vec<RouterKey>,
|
||||
aspas: Vec<Aspa>,
|
||||
) -> Self {
|
||||
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)),
|
||||
@ -443,9 +439,7 @@ where
|
||||
|
||||
let mut normalized = by_customer
|
||||
.into_iter()
|
||||
.map(|(customer_asn, providers)| {
|
||||
Aspa::new(customer_asn.into(), providers)
|
||||
})
|
||||
.map(|(customer_asn, providers)| Aspa::new(customer_asn.into(), providers))
|
||||
.collect::<Vec<_>>();
|
||||
normalized.sort();
|
||||
normalized
|
||||
|
||||
14
src/rtr/cache/store.rs
vendored
14
src/rtr/cache/store.rs
vendored
@ -106,6 +106,20 @@ impl RtrCache {
|
||||
}
|
||||
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(
|
||||
|
||||
@ -4,6 +4,7 @@ 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;
|
||||
@ -40,10 +41,50 @@ pub struct AppConfig {
|
||||
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 strict_ccr_validation: bool,
|
||||
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 strict_ccr_validation: Option<bool>,
|
||||
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 {
|
||||
@ -75,6 +116,8 @@ impl Default for AppConfig {
|
||||
report_history_limit: 10,
|
||||
timezone: default_timezone(),
|
||||
timing: Timing::default(),
|
||||
admin_addr: None,
|
||||
admin_token: None,
|
||||
|
||||
service_config: RtrServiceConfig {
|
||||
max_connections: 512,
|
||||
@ -201,6 +244,19 @@ impl AppConfig {
|
||||
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")?;
|
||||
@ -307,6 +363,108 @@ impl AppConfig {
|
||||
|
||||
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,
|
||||
strict_ccr_validation: self.strict_ccr_validation,
|
||||
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.strict_ccr_validation {
|
||||
next.strict_ccr_validation = 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) {
|
||||
@ -356,6 +514,14 @@ pub fn log_startup_config(config: &AppConfig) {
|
||||
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={}",
|
||||
@ -441,7 +607,7 @@ fn parse_positive_u32(value: &str, name: &str) -> Result<u32> {
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
|
||||
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",
|
||||
@ -458,37 +624,3 @@ fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_timezone_accepts_iana_names_and_common_aliases() {
|
||||
assert_eq!(
|
||||
parse_timezone("Asia/Shanghai", "TEST").unwrap(),
|
||||
chrono_tz::Asia::Shanghai
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("shanghai", "TEST").unwrap(),
|
||||
chrono_tz::Asia::Shanghai
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("Europe/London", "TEST").unwrap(),
|
||||
chrono_tz::Europe::London
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("America/New_York", "TEST").unwrap(),
|
||||
chrono_tz::America::New_York
|
||||
);
|
||||
assert_eq!(parse_timezone("UTC", "TEST").unwrap(), chrono_tz::UTC);
|
||||
assert_eq!(parse_timezone("Z", "TEST").unwrap(), chrono_tz::UTC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timezone_rejects_offset_and_invalid_values() {
|
||||
assert!(parse_timezone("+08:00", "TEST").is_err());
|
||||
assert!(parse_timezone("+0800", "TEST").is_err());
|
||||
assert!(parse_timezone("Mars/Base", "TEST").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod error_type;
|
||||
|
||||
@ -10,7 +10,7 @@ use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::rtr::cache::{CacheAvailability, CacheMemoryStats, SharedRtrCache, VersionReportStats};
|
||||
use crate::rtr::config::format_timezone;
|
||||
use crate::rtr::config::{RuntimeConfig, format_timezone};
|
||||
use crate::rtr::server::{RtrNotifier, RtrServiceStats, RtrTransportConnectionCounts};
|
||||
use crate::source::pipeline::{
|
||||
DataQualityReport, FileFingerprint, SourceFingerprint, SourceLoadReport,
|
||||
@ -73,8 +73,7 @@ impl ReportConfiguration {
|
||||
pub struct ReportContext {
|
||||
started_at: DateTime<Utc>,
|
||||
started_instant: Instant,
|
||||
timezone: Tz,
|
||||
configuration: ReportConfiguration,
|
||||
configuration: Arc<RwLock<ReportConfiguration>>,
|
||||
runtime: Arc<RwLock<RuntimeReportState>>,
|
||||
}
|
||||
|
||||
@ -148,16 +147,38 @@ impl Default for RefreshReport {
|
||||
|
||||
impl ReportContext {
|
||||
pub fn new(configuration: ReportConfiguration) -> Self {
|
||||
let timezone = configuration.timezone();
|
||||
Self {
|
||||
started_at: Utc::now(),
|
||||
started_instant: Instant::now(),
|
||||
timezone,
|
||||
configuration,
|
||||
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,
|
||||
config.strict_ccr_validation,
|
||||
timezone,
|
||||
(
|
||||
config.timing.refresh,
|
||||
config.timing.retry,
|
||||
config.timing.expire,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn record_refresh_success(
|
||||
&self,
|
||||
attempted_at: DateTime<Utc>,
|
||||
@ -365,6 +386,12 @@ impl ReportContext {
|
||||
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()
|
||||
@ -377,14 +404,14 @@ impl ReportContext {
|
||||
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();
|
||||
let generated_at = self.report_now(timezone);
|
||||
let metadata = ReportMetadata {
|
||||
schema_version: 2,
|
||||
generated_at,
|
||||
phase: phase.to_string(),
|
||||
};
|
||||
let service = ServiceReport {
|
||||
started_at: self.to_report_time(self.started_at),
|
||||
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),
|
||||
@ -397,13 +424,13 @@ impl ReportContext {
|
||||
};
|
||||
let source = runtime
|
||||
.source
|
||||
.map(|source| SourceLoadReportView::from_report(source, self.timezone));
|
||||
let refresh = RefreshReportView::from_report(runtime.refresh, self.timezone);
|
||||
.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()),
|
||||
last_update_begin: self.to_report_time(cache.last_update_begin().utc()),
|
||||
last_update_end: self.to_report_time(cache.last_update_end().utc()),
|
||||
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(),
|
||||
};
|
||||
@ -414,7 +441,7 @@ impl ReportContext {
|
||||
phase: metadata.phase.clone(),
|
||||
source,
|
||||
source_fingerprint: runtime.source_fingerprint.map(|fingerprint| {
|
||||
SourceFingerprintReport::from_fingerprint(fingerprint, self.timezone)
|
||||
SourceFingerprintReport::from_fingerprint(fingerprint, timezone)
|
||||
}),
|
||||
refresh,
|
||||
data_quality: runtime.data_quality,
|
||||
@ -440,7 +467,7 @@ impl ReportContext {
|
||||
phase: metadata.phase,
|
||||
service,
|
||||
process,
|
||||
configuration: self.configuration.clone(),
|
||||
configuration,
|
||||
};
|
||||
let suffix = report_file_suffix(generated_at);
|
||||
|
||||
@ -462,7 +489,7 @@ impl ReportContext {
|
||||
report_dir,
|
||||
"rtr-source",
|
||||
suffix,
|
||||
self.configuration.report_history_limit,
|
||||
self.report_history_limit(),
|
||||
report,
|
||||
)
|
||||
}
|
||||
@ -477,7 +504,7 @@ impl ReportContext {
|
||||
report_dir,
|
||||
"rtr-clients",
|
||||
suffix,
|
||||
self.configuration.report_history_limit,
|
||||
self.report_history_limit(),
|
||||
report,
|
||||
)
|
||||
}
|
||||
@ -492,17 +519,24 @@ impl ReportContext {
|
||||
report_dir,
|
||||
"rtr-runtime",
|
||||
suffix,
|
||||
self.configuration.report_history_limit,
|
||||
self.report_history_limit(),
|
||||
report,
|
||||
)
|
||||
}
|
||||
|
||||
fn report_now(&self) -> DateTime<Tz> {
|
||||
self.to_report_time(Utc::now())
|
||||
fn report_history_limit(&self) -> usize {
|
||||
self.configuration
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.report_history_limit
|
||||
}
|
||||
|
||||
fn to_report_time(&self, time: DateTime<Utc>) -> DateTime<Tz> {
|
||||
time.with_timezone(&self.timezone)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -745,308 +779,3 @@ fn prune_rolling_reports(report_dir: &Path, prefix: &str, keep: usize) -> Result
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::rtr::cache::RtrCache;
|
||||
use crate::rtr::server::RtrService;
|
||||
use crate::source::pipeline::{
|
||||
DataQualityReport, PayloadTypeCounts, SlurmRuleCounts, SourceLoadReport,
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_configuration() -> ReportConfiguration {
|
||||
ReportConfiguration::new(
|
||||
300,
|
||||
300,
|
||||
10,
|
||||
100,
|
||||
false,
|
||||
false,
|
||||
chrono_tz::Asia::Shanghai,
|
||||
(3600, 600, 7200),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_report_creates_parseable_json() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
"test",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let source_report = read_single_report(&report_dir, "rtr-source");
|
||||
assert_eq!(source_report["schema_version"], 2);
|
||||
assert_eq!(source_report["phase"], "test");
|
||||
assert_report_time_offset(&source_report["generated_at"]);
|
||||
assert_report_time_offset(&source_report["cache"]["created_at"]);
|
||||
assert_eq!(source_report["cache"]["availability"], "ready");
|
||||
assert_eq!(source_report["refresh"]["status"], "not_attempted");
|
||||
assert!(source_report["source"].is_null());
|
||||
assert!(source_report["data_quality"].is_null());
|
||||
|
||||
let runtime_report = read_single_report(&report_dir, "rtr-runtime");
|
||||
assert_report_time_offset(&runtime_report["service"]["started_at"]);
|
||||
assert_eq!(
|
||||
runtime_report["configuration"]["source_refresh_interval_seconds"],
|
||||
300
|
||||
);
|
||||
assert_eq!(runtime_report["configuration"]["report_history_limit"], 10);
|
||||
|
||||
let clients_report = read_single_report(&report_dir, "rtr-clients");
|
||||
assert_eq!(clients_report["service"]["max_connections"], 1024);
|
||||
assert_eq!(clients_report["service"]["active_connections"], 0);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["tcp"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["tls"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["ssh"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["versions"].as_array().unwrap().len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["versions"][2]["snapshot"]["total"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["memory"]["delta_payload_counts"][2],
|
||||
0
|
||||
);
|
||||
assert!(source_report["source_fingerprint"].is_null());
|
||||
assert_no_temporary_reports(&report_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_failure_preserves_last_successful_source_data() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
let source = SourceLoadReport {
|
||||
ccr_file: "data/example.ccr".to_string(),
|
||||
ccr_file_size_bytes: 123,
|
||||
ccr_modified_at: Some(Utc::now()),
|
||||
ccr_produced_at: Some("20260615000000Z".to_string()),
|
||||
slurm_enabled: true,
|
||||
slurm_file_count: 1,
|
||||
slurm_files: vec!["policy.slurm".to_string()],
|
||||
slurm_version: Some(2),
|
||||
};
|
||||
let quality = DataQualityReport {
|
||||
ccr_input: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
invalid: PayloadTypeCounts::default(),
|
||||
before_slurm: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
after_slurm: PayloadTypeCounts {
|
||||
total: 10,
|
||||
vrp: 9,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
slurm_filters: SlurmRuleCounts {
|
||||
prefix: 1,
|
||||
router_key: 0,
|
||||
aspa: 0,
|
||||
},
|
||||
slurm_assertions: SlurmRuleCounts::default(),
|
||||
};
|
||||
context.record_source_fingerprint(SourceFingerprint {
|
||||
ccr: FileFingerprint {
|
||||
path: "data/example.ccr".to_string(),
|
||||
len: 123,
|
||||
modified_unix_secs: 1_781_404_800,
|
||||
},
|
||||
slurm_files: vec![FileFingerprint {
|
||||
path: "policy.slurm".to_string(),
|
||||
len: 42,
|
||||
modified_unix_secs: 1_781_408_400,
|
||||
}],
|
||||
});
|
||||
|
||||
context.record_refresh_success(Utc::now(), 12, true, source, quality);
|
||||
context.record_refresh_failure(Utc::now(), 5, &anyhow::anyhow!("source unavailable"));
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
"refresh_failed",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = read_single_report(&report_dir, "rtr-source");
|
||||
assert_eq!(report["source"]["ccr_file"], "data/example.ccr");
|
||||
assert_eq!(
|
||||
report["source_fingerprint"]["ccr"]["path"],
|
||||
"data/example.ccr"
|
||||
);
|
||||
assert_eq!(report["source_fingerprint"]["ccr"]["len"], 123);
|
||||
assert_report_time_offset(&report["source_fingerprint"]["ccr"]["modified_at"]);
|
||||
assert_eq!(
|
||||
report["source_fingerprint"]["slurm_files"][0]["path"],
|
||||
"policy.slurm"
|
||||
);
|
||||
assert_report_time_offset(&report["source"]["ccr_modified_at"]);
|
||||
assert_eq!(report["data_quality"]["after_slurm"]["total"], 10);
|
||||
assert_eq!(report["refresh"]["status"], "failed");
|
||||
assert_eq!(report["refresh"]["consecutive_failures"], 1);
|
||||
assert_eq!(report["refresh"]["last_error"], "source unavailable");
|
||||
assert!(!report["refresh"]["last_success_at"].is_null());
|
||||
assert_report_time_offset(&report["refresh"]["last_success_at"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_reports_keep_latest_files_per_category() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let mut configuration = test_configuration();
|
||||
configuration.report_history_limit = 2;
|
||||
let context = ReportContext::new(configuration);
|
||||
|
||||
for phase in ["one", "two", "three"] {
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
phase,
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 2);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 2);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 2);
|
||||
assert_no_temporary_reports(&report_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_writes_only_create_requested_report_type() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
|
||||
context
|
||||
.write_source(
|
||||
&report_dir,
|
||||
"source_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 0);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||
|
||||
context
|
||||
.write_clients(
|
||||
&report_dir,
|
||||
"clients_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||
|
||||
context
|
||||
.write_runtime(
|
||||
&report_dir,
|
||||
"runtime_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 1);
|
||||
}
|
||||
|
||||
fn assert_report_time_offset(value: &Value) {
|
||||
let value = value.as_str().expect("report time should be a string");
|
||||
assert!(
|
||||
value.ends_with("+08:00"),
|
||||
"report time should use +08:00 offset, got {value}"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_single_report(report_dir: &Path, prefix: &str) -> Value {
|
||||
let files = report_files(report_dir, prefix);
|
||||
assert_eq!(files.len(), 1, "expected one {prefix} report");
|
||||
serde_json::from_slice(&fs::read(&files[0]).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn report_files(report_dir: &Path, prefix: &str) -> Vec<PathBuf> {
|
||||
let start = format!("{prefix}-");
|
||||
let mut files = fs::read_dir(report_dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with(&start) && name.ends_with(".json"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn assert_no_temporary_reports(report_dir: &Path) {
|
||||
let has_temporary = fs::read_dir(report_dir).unwrap().any(|entry| {
|
||||
entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.ends_with(".tmp"))
|
||||
});
|
||||
assert!(!has_temporary);
|
||||
}
|
||||
}
|
||||
|
||||
@ -947,7 +947,12 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_end_of_data(&mut self, session_id: u16, serial: u32, timing: Timing) -> Result<()> {
|
||||
async fn write_end_of_data(
|
||||
&mut self,
|
||||
session_id: u16,
|
||||
serial: u32,
|
||||
timing: Timing,
|
||||
) -> Result<()> {
|
||||
let version = self.version()?;
|
||||
|
||||
let end = EndOfData::new(version, session_id, serial, timing)?;
|
||||
@ -1117,12 +1122,7 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_aspa_to<W>(
|
||||
writer: &mut W,
|
||||
aspa: &Aspa,
|
||||
announce: bool,
|
||||
version: u8,
|
||||
) -> Result<()>
|
||||
async fn send_aspa_to<W>(writer: &mut W, aspa: &Aspa, announce: bool, version: u8) -> Result<()>
|
||||
where
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use rocksdb::{ColumnFamilyDescriptor, IteratorMode, Options, WriteBatch, DB};
|
||||
use anyhow::{Result, anyhow};
|
||||
use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options, WriteBatch};
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::borrow::Borrow;
|
||||
use std::path::Path;
|
||||
@ -397,82 +397,3 @@ fn validate_delta_window(deltas: &[Delta], min_serial: u32, max_serial: u32) ->
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use crate::data_model::resources::as_resources::Asn;
|
||||
use crate::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
|
||||
use crate::rtr::cache::Snapshot;
|
||||
use crate::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn mixed_snapshot() -> Snapshot {
|
||||
Snapshot::from_payloads(vec![
|
||||
Payload::RouteOrigin(RouteOrigin::new(
|
||||
IPAddressPrefix::new(IPAddress::from_ipv4(Ipv4Addr::new(192, 0, 2, 0)), 24),
|
||||
24,
|
||||
Asn::from(64496),
|
||||
)),
|
||||
Payload::RouterKey(RouterKey::new(
|
||||
Ski::default(),
|
||||
Asn::from(64497),
|
||||
vec![1, 2, 3],
|
||||
)),
|
||||
Payload::Aspa(Aspa::new(Asn::from(64498), vec![Asn::from(64499)])),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_cache_state_persists_single_canonical_snapshot() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = RtrStore::open(temp.path()).unwrap();
|
||||
let source = mixed_snapshot();
|
||||
let snapshots = std::array::from_fn(|version| source.project_for_version(version as u8));
|
||||
let deltas: [Option<&Delta>; 3] = [None, None, None];
|
||||
let windows: [Option<(u32, u32)>; 3] = [None, None, None];
|
||||
let clear = [true, true, true];
|
||||
|
||||
store
|
||||
.save_cache_state_versioned(
|
||||
CacheAvailability::Ready,
|
||||
&snapshots,
|
||||
&[1, 2, 3],
|
||||
&[10, 11, 12],
|
||||
&deltas,
|
||||
&windows,
|
||||
&clear,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(store
|
||||
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(0))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(store
|
||||
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(1))
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(store
|
||||
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(2))
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
let restored_v0 = store.get_snapshot_for_version(0).unwrap().unwrap();
|
||||
assert_eq!(restored_v0.origins().len(), 1);
|
||||
assert_eq!(restored_v0.router_keys().len(), 0);
|
||||
assert_eq!(restored_v0.aspas().len(), 0);
|
||||
|
||||
let restored_v1 = store.get_snapshot_for_version(1).unwrap().unwrap();
|
||||
assert_eq!(restored_v1.origins().len(), 1);
|
||||
assert_eq!(restored_v1.router_keys().len(), 1);
|
||||
assert_eq!(restored_v1.aspas().len(), 0);
|
||||
|
||||
let restored_v2 = store.get_snapshot_for_version(2).unwrap().unwrap();
|
||||
assert_eq!(restored_v2.origins().len(), 1);
|
||||
assert_eq!(restored_v2.router_keys().len(), 1);
|
||||
assert_eq!(restored_v2.aspas().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
391
src/slurm/admin.rs
Normal file
391
src/slurm/admin.rs
Normal file
@ -0,0 +1,391 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::file::SlurmFile;
|
||||
|
||||
const MAX_SLURM_BODY_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SlurmFileWriteRequest {
|
||||
pub name: Option<String>,
|
||||
pub content: String,
|
||||
pub reload: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct SlurmFileActionRequest {
|
||||
pub reload: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SlurmFileOperationResult {
|
||||
pub operation: &'static str,
|
||||
pub file: String,
|
||||
pub backup: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SlurmFileListEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
pub size_bytes: u64,
|
||||
pub modified_unix_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SlurmFileContent {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub enabled: bool,
|
||||
pub size_bytes: u64,
|
||||
pub modified_unix_seconds: Option<u64>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub struct AppliedSlurmFileOperation {
|
||||
pub result: SlurmFileOperationResult,
|
||||
undo: UndoAction,
|
||||
}
|
||||
|
||||
impl AppliedSlurmFileOperation {
|
||||
pub fn rollback(self) -> Result<()> {
|
||||
self.undo.apply()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum UndoAction {
|
||||
Delete { path: PathBuf },
|
||||
Restore { path: PathBuf, backup: PathBuf },
|
||||
Rename { from: PathBuf, to: PathBuf },
|
||||
}
|
||||
|
||||
impl UndoAction {
|
||||
fn apply(self) -> Result<()> {
|
||||
match self {
|
||||
Self::Delete { path } => {
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("remove rollback file {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
Self::Restore { path, backup } => {
|
||||
fs::copy(&backup, &path).with_context(|| {
|
||||
format!(
|
||||
"restore rollback file {} from {}",
|
||||
path.display(),
|
||||
backup.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Self::Rename { from, to } => {
|
||||
if from.exists() {
|
||||
fs::rename(&from, &to).with_context(|| {
|
||||
format!("rollback rename {} to {}", from.display(), to.display())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SlurmAdmin {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SlurmAdmin {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
pub fn max_body_bytes(&self) -> usize {
|
||||
MAX_SLURM_BODY_BYTES
|
||||
}
|
||||
|
||||
pub fn list_files(&self) -> Result<Vec<SlurmFileListEntry>> {
|
||||
let mut files = Vec::new();
|
||||
if !self.dir.exists() {
|
||||
return Ok(files);
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&self.dir)
|
||||
.with_context(|| format!("read SLURM directory {}", self.dir.display()))?
|
||||
{
|
||||
let entry = entry.with_context(|| format!("read entry in {}", self.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 validate_slurm_file_name(name).is_err() {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry
|
||||
.metadata()
|
||||
.with_context(|| format!("read SLURM file metadata {}", path.display()))?;
|
||||
files.push(SlurmFileListEntry {
|
||||
name: name.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
enabled: name.ends_with(".slurm"),
|
||||
size_bytes: metadata.len(),
|
||||
modified_unix_seconds: modified_unix_seconds(&metadata),
|
||||
});
|
||||
}
|
||||
files.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn read_file(&self, name: &str) -> Result<SlurmFileContent> {
|
||||
validate_slurm_file_name(name)?;
|
||||
let path = self.file_path(name);
|
||||
if !path.is_file() {
|
||||
return Err(anyhow!("SLURM file '{}' does not exist", name));
|
||||
}
|
||||
let metadata = fs::metadata(&path)
|
||||
.with_context(|| format!("read SLURM file metadata {}", path.display()))?;
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("read SLURM file {}", path.display()))?;
|
||||
Ok(SlurmFileContent {
|
||||
name: name.to_string(),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
enabled: name.ends_with(".slurm"),
|
||||
size_bytes: metadata.len(),
|
||||
modified_unix_seconds: modified_unix_seconds(&metadata),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn put_file(
|
||||
&self,
|
||||
name: &str,
|
||||
content: &str,
|
||||
operation: &'static str,
|
||||
) -> Result<AppliedSlurmFileOperation> {
|
||||
validate_slurm_file_name(name)?;
|
||||
validate_slurm_content(content)?;
|
||||
fs::create_dir_all(&self.dir)
|
||||
.with_context(|| format!("create SLURM directory {}", self.dir.display()))?;
|
||||
|
||||
let path = self.file_path(name);
|
||||
let backup = if path.exists() {
|
||||
Some(self.backup_file(&path)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let temporary = self.temporary_file(name);
|
||||
fs::write(&temporary, content.as_bytes())
|
||||
.with_context(|| format!("write temporary SLURM file {}", temporary.display()))?;
|
||||
replace_file(&temporary, &path)?;
|
||||
|
||||
let undo = match backup.as_ref() {
|
||||
Some(backup) => UndoAction::Restore {
|
||||
path: path.clone(),
|
||||
backup: backup.clone(),
|
||||
},
|
||||
None => UndoAction::Delete { path: path.clone() },
|
||||
};
|
||||
Ok(AppliedSlurmFileOperation {
|
||||
result: SlurmFileOperationResult {
|
||||
operation,
|
||||
file: path.to_string_lossy().to_string(),
|
||||
backup: backup.map(|path| path.to_string_lossy().to_string()),
|
||||
},
|
||||
undo,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
|
||||
validate_slurm_file_name(name)?;
|
||||
let path = self.file_path(name);
|
||||
if !path.is_file() {
|
||||
return Err(anyhow!("SLURM file '{}' does not exist", name));
|
||||
}
|
||||
let backup = self.backup_file(&path)?;
|
||||
fs::remove_file(&path).with_context(|| format!("delete SLURM file {}", path.display()))?;
|
||||
Ok(AppliedSlurmFileOperation {
|
||||
result: SlurmFileOperationResult {
|
||||
operation: "delete",
|
||||
file: path.to_string_lossy().to_string(),
|
||||
backup: Some(backup.to_string_lossy().to_string()),
|
||||
},
|
||||
undo: UndoAction::Restore {
|
||||
path,
|
||||
backup: backup.clone(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn enable_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
|
||||
validate_slurm_file_name(name)?;
|
||||
if !name.ends_with(".slurm.disabled") {
|
||||
return Err(anyhow!(
|
||||
"SLURM file '{}' is not disabled; expected .slurm.disabled",
|
||||
name
|
||||
));
|
||||
}
|
||||
let enabled_name = name.trim_end_matches(".disabled");
|
||||
self.rename_file(name, enabled_name, "enable")
|
||||
}
|
||||
|
||||
pub fn disable_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
|
||||
validate_slurm_file_name(name)?;
|
||||
if !name.ends_with(".slurm") {
|
||||
return Err(anyhow!(
|
||||
"SLURM file '{}' is not enabled; expected .slurm",
|
||||
name
|
||||
));
|
||||
}
|
||||
let disabled_name = format!("{name}.disabled");
|
||||
self.rename_file(name, &disabled_name, "disable")
|
||||
}
|
||||
|
||||
fn rename_file(
|
||||
&self,
|
||||
from_name: &str,
|
||||
to_name: &str,
|
||||
operation: &'static str,
|
||||
) -> Result<AppliedSlurmFileOperation> {
|
||||
validate_slurm_file_name(to_name)?;
|
||||
let from = self.file_path(from_name);
|
||||
let to = self.file_path(to_name);
|
||||
if !from.is_file() {
|
||||
return Err(anyhow!("SLURM file '{}' does not exist", from_name));
|
||||
}
|
||||
if to.exists() {
|
||||
return Err(anyhow!("target SLURM file '{}' already exists", to_name));
|
||||
}
|
||||
fs::rename(&from, &to)
|
||||
.with_context(|| format!("rename SLURM file {} to {}", from.display(), to.display()))?;
|
||||
Ok(AppliedSlurmFileOperation {
|
||||
result: SlurmFileOperationResult {
|
||||
operation,
|
||||
file: to.to_string_lossy().to_string(),
|
||||
backup: None,
|
||||
},
|
||||
undo: UndoAction::Rename { from: to, to: from },
|
||||
})
|
||||
}
|
||||
|
||||
fn file_path(&self, name: &str) -> PathBuf {
|
||||
self.dir.join(name)
|
||||
}
|
||||
|
||||
fn backup_file(&self, path: &Path) -> Result<PathBuf> {
|
||||
let backup_dir = self.dir.join(".backup");
|
||||
fs::create_dir_all(&backup_dir)
|
||||
.with_context(|| format!("create SLURM backup directory {}", backup_dir.display()))?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("invalid SLURM file name '{}'", path.display()))?;
|
||||
let backup = backup_dir.join(format!("{file_name}.{}.bak", timestamp_suffix()));
|
||||
fs::copy(path, &backup).with_context(|| {
|
||||
format!(
|
||||
"backup SLURM file {} to {}",
|
||||
path.display(),
|
||||
backup.display()
|
||||
)
|
||||
})?;
|
||||
Ok(backup)
|
||||
}
|
||||
|
||||
fn temporary_file(&self, name: &str) -> PathBuf {
|
||||
self.dir.join(format!(".{name}.{}.tmp", timestamp_suffix()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_slurm_content(content: &str) -> Result<()> {
|
||||
if content.len() > MAX_SLURM_BODY_BYTES {
|
||||
return Err(anyhow!(
|
||||
"SLURM content too large: {} bytes, limit {} bytes",
|
||||
content.len(),
|
||||
MAX_SLURM_BODY_BYTES
|
||||
));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(content)
|
||||
.map_err(|err| anyhow!("invalid SLURM JSON: {}", err))?;
|
||||
SlurmFile::from_slice(content.as_bytes())
|
||||
.map_err(|err| anyhow!("invalid SLURM file: {}", err))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_reload_query(query: Option<&str>, body_reload: Option<bool>) -> bool {
|
||||
if let Some(value) = body_reload {
|
||||
return value;
|
||||
}
|
||||
query
|
||||
.and_then(|query| {
|
||||
query.split('&').find_map(|part| {
|
||||
let (key, value) = part.split_once('=')?;
|
||||
(key == "reload").then(|| matches!(value, "1" | "true" | "yes" | "on"))
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn validate_slurm_file_name(name: &str) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
return Err(anyhow!("SLURM file name must not be empty"));
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||
return Err(anyhow!("invalid SLURM file name '{}'", name));
|
||||
}
|
||||
if !(name.ends_with(".slurm") || name.ends_with(".slurm.disabled")) {
|
||||
return Err(anyhow!(
|
||||
"invalid SLURM file name '{}': expected .slurm or .slurm.disabled",
|
||||
name
|
||||
));
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid SLURM file name '{}': allowed characters are ASCII letters, digits, '.', '_' and '-'",
|
||||
name
|
||||
));
|
||||
}
|
||||
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 SLURM file {}", target.display()))?;
|
||||
fs::rename(temporary, target)
|
||||
.with_context(|| format!("move SLURM file into {}", target.display()))?;
|
||||
} else {
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"move temporary SLURM file {} into {}",
|
||||
temporary.display(),
|
||||
target.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn timestamp_suffix() -> String {
|
||||
Utc::now().format("%Y%m%d%H%M%S%9f").to_string()
|
||||
}
|
||||
|
||||
fn modified_unix_seconds(metadata: &fs::Metadata) -> Option<u64> {
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod file;
|
||||
pub mod policy;
|
||||
mod serde;
|
||||
|
||||
@ -204,6 +204,20 @@ fn apply_slurm_to_payloads_from_dir(
|
||||
SlurmRuleCounts,
|
||||
)> {
|
||||
let files = read_slurm_files(slurm_dir)?;
|
||||
if files.is_empty() {
|
||||
info!(
|
||||
"SLURM directory has no enabled .slurm files: slurm_dir={}, input_payload_count={}",
|
||||
slurm_dir,
|
||||
payloads.len()
|
||||
);
|
||||
return Ok((
|
||||
payloads,
|
||||
Vec::new(),
|
||||
None,
|
||||
SlurmRuleCounts::default(),
|
||||
SlurmRuleCounts::default(),
|
||||
));
|
||||
}
|
||||
let file_count = files.len();
|
||||
let file_names = files
|
||||
.iter()
|
||||
@ -285,12 +299,6 @@ fn slurm_paths(slurm_dir: &str) -> Result<Vec<PathBuf>> {
|
||||
.map(|name| name.to_ascii_lowercase())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
if paths.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"SLURM directory '{}' does not contain .slurm files",
|
||||
slurm_dir
|
||||
));
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use std::fmt::Write;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use rpki::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
|
||||
use rpki::rtr::cache::SerialResult;
|
||||
|
||||
@ -79,17 +79,10 @@ fn version_report_stats_separate_snapshot_and_delta_payload_types() {
|
||||
Asn::from(64496u32),
|
||||
vec![1, 2, 3],
|
||||
));
|
||||
let aspa = Payload::Aspa(Aspa::new(
|
||||
Asn::from(64496u32),
|
||||
vec![Asn::from(64497u32)],
|
||||
));
|
||||
let aspa = Payload::Aspa(Aspa::new(Asn::from(64496u32), vec![Asn::from(64497u32)]));
|
||||
let snapshot = Snapshot::from_payloads(vec![vrp.clone(), router_key.clone(), aspa.clone()]);
|
||||
let mut deltas = VecDeque::new();
|
||||
deltas.push_back(Arc::new(Delta::new(
|
||||
101,
|
||||
vec![vrp, aspa],
|
||||
vec![router_key],
|
||||
)));
|
||||
deltas.push_back(Arc::new(Delta::new(101, vec![vrp, aspa], vec![router_key])));
|
||||
let cache = RtrCacheBuilder::new()
|
||||
.session_ids(SessionIds::from_array([40, 41, 42]))
|
||||
.serials([99, 100, 101])
|
||||
@ -568,8 +561,11 @@ fn get_deltas_since_returns_up_to_date_when_client_serial_matches_current() {
|
||||
|
||||
let result = cache.get_deltas_since_for_version(2, 100);
|
||||
|
||||
let input =
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 100);
|
||||
let input = get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
100,
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
|
||||
test_report(
|
||||
@ -615,7 +611,11 @@ fn get_deltas_since_returns_reset_required_when_client_serial_is_too_old() {
|
||||
|
||||
let input = format!(
|
||||
"{}delta_window:\n{}",
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 99),
|
||||
get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
99
|
||||
),
|
||||
indent_block(&deltas_window_to_string(&deltas), 2),
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
@ -680,7 +680,11 @@ fn get_deltas_since_returns_minimal_merged_delta() {
|
||||
|
||||
let input = format!(
|
||||
"{}delta_window:\n{}",
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 101),
|
||||
get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
101
|
||||
),
|
||||
indent_block(&deltas_window_to_string(&deltas), 2),
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
@ -726,8 +730,11 @@ fn get_deltas_since_returns_reset_required_when_client_serial_is_in_future() {
|
||||
|
||||
let result = cache.get_deltas_since_for_version(2, 101);
|
||||
|
||||
let input =
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 101);
|
||||
let input = get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
101,
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
|
||||
test_report(
|
||||
@ -888,8 +895,11 @@ fn get_deltas_since_returns_reset_required_when_client_serial_is_in_future_acros
|
||||
|
||||
let result = cache.get_deltas_since_for_version(2, 0);
|
||||
|
||||
let input =
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 0);
|
||||
let input = get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
0,
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
|
||||
test_report(
|
||||
@ -1227,7 +1237,11 @@ fn get_deltas_since_cancels_announce_then_withdraw_for_same_prefix() {
|
||||
|
||||
let input = format!(
|
||||
"{}delta_window:\n{}",
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 100),
|
||||
get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
100
|
||||
),
|
||||
indent_block(&deltas_window_to_string(&deltas), 2),
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
@ -1281,7 +1295,11 @@ fn get_deltas_since_cancels_withdraw_then_announce_for_same_prefix() {
|
||||
|
||||
let input = format!(
|
||||
"{}delta_window:\n{}",
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 100),
|
||||
get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
100
|
||||
),
|
||||
indent_block(&deltas_window_to_string(&deltas), 2),
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
@ -1335,7 +1353,11 @@ fn get_deltas_since_merges_replacement_into_withdraw_and_announce() {
|
||||
|
||||
let input = format!(
|
||||
"{}delta_window:\n{}",
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 100),
|
||||
get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
100
|
||||
),
|
||||
indent_block(&deltas_window_to_string(&deltas), 2),
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
@ -1414,7 +1436,11 @@ fn get_deltas_since_merges_multiple_deltas_to_final_minimal_view() {
|
||||
|
||||
let input = format!(
|
||||
"{}delta_window:\n{}",
|
||||
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 100),
|
||||
get_deltas_since_input_to_string(
|
||||
cache.session_id_for_version(1),
|
||||
cache.serial_for_version(2),
|
||||
100
|
||||
),
|
||||
indent_block(&deltas_window_to_string(&deltas), 2),
|
||||
);
|
||||
let output = serial_result_detail_to_string(&result);
|
||||
@ -1501,7 +1527,9 @@ fn get_deltas_since_merges_aspa_replacement_into_single_announcement() {
|
||||
.session_ids(SessionIds::from_array([42, 42, 42]))
|
||||
.serials(serials_all(102))
|
||||
.timing(Timing::default())
|
||||
.snapshots(snapshots_all(Snapshot::from_payloads(vec![Payload::Aspa(new.clone())])))
|
||||
.snapshots(snapshots_all(Snapshot::from_payloads(vec![Payload::Aspa(
|
||||
new.clone(),
|
||||
)])))
|
||||
.deltas_by_version(deltas_all(deltas))
|
||||
.build();
|
||||
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tempfile::tempdir;
|
||||
use rpki::source::ccr::{
|
||||
ParsedAspa, ParsedCcrSnapshot, ParsedVrp, find_latest_ccr_file, load_ccr_snapshot_from_file,
|
||||
snapshot_to_payloads_with_options,
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data").join(name)
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("data")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -17,8 +19,7 @@ fn ccr_loader_smoke() {
|
||||
// let path = "./mini_data/20260403T000001Z-mini-a.ccr";
|
||||
// let path = "20260403T000101Z-mini-b.ccr";
|
||||
let path = "20260403T000201Z-mini-c.ccr";
|
||||
let snapshot = load_ccr_snapshot_from_file(fixture_path(path))
|
||||
.expect("load CCR snapshot");
|
||||
let snapshot = load_ccr_snapshot_from_file(fixture_path(path)).expect("load CCR snapshot");
|
||||
|
||||
println!("content_type_oid: {}", snapshot.content_type_oid);
|
||||
println!("produced_at : {:?}", snapshot.produced_at);
|
||||
@ -115,7 +116,15 @@ fn generated_mini_ccr_files_are_parseable() {
|
||||
for (name, expect_vrps, expect_vaps) in cases {
|
||||
let snapshot = load_ccr_snapshot_from_file(fixture_path(name))
|
||||
.unwrap_or_else(|e| panic!("failed to parse {}: {:?}", name, e));
|
||||
assert_eq!(snapshot.vrps.len(), expect_vrps, "vrp count mismatch for {name}");
|
||||
assert_eq!(snapshot.vaps.len(), expect_vaps, "vap count mismatch for {name}");
|
||||
assert_eq!(
|
||||
snapshot.vrps.len(),
|
||||
expect_vrps,
|
||||
"vrp count mismatch for {name}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.vaps.len(),
|
||||
expect_vaps,
|
||||
"vap count mismatch for {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rpki::data_model::crl::RpkixCrl;
|
||||
use rpki::data_model::crl::Asn1TimeEncoding;
|
||||
use rpki::data_model::crl::RpkixCrl;
|
||||
|
||||
#[test]
|
||||
fn decode_and_validate_crl_fixture() {
|
||||
@ -42,8 +42,7 @@ fn crl_signature_verification_succeeds_with_issuer_cert() {
|
||||
|
||||
#[test]
|
||||
fn decode_crl_with_revoked_entries() {
|
||||
let der =
|
||||
std::fs::read("tests/fixtures/0099DEAB073EFD74C250C0A382B25012B5082AEE.crl")
|
||||
let der = std::fs::read("tests/fixtures/0099DEAB073EFD74C250C0A382B25012B5082AEE.crl")
|
||||
.expect("read CRL fixture with revoked entries");
|
||||
|
||||
let crl = RpkixCrl::decode_der(&der).expect("decode CRL");
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use tokio::io::{duplex, AsyncWriteExt};
|
||||
use tokio::io::{AsyncWriteExt, duplex};
|
||||
|
||||
use rpki::data_model::resources::as_resources::Asn;
|
||||
use rpki::rtr::error_type::ErrorCode;
|
||||
use rpki::rtr::payload::{Aspa as PayloadAspa, Ski, Timing};
|
||||
use rpki::rtr::pdu::{
|
||||
Aspa, EndOfDataV1, ErrorReport, Flags, Header, IPv4Prefix, RouterKey, SerialNotify,
|
||||
END_OF_DATA_V1_LEN, MAX_PDU_LEN,
|
||||
Aspa, END_OF_DATA_V1_LEN, EndOfDataV1, ErrorReport, Flags, Header, IPv4Prefix, MAX_PDU_LEN,
|
||||
RouterKey, SerialNotify,
|
||||
};
|
||||
|
||||
const ERROR_REPORT_FIXED_PART_LEN: usize = 16;
|
||||
@ -271,7 +271,9 @@ async fn end_of_data_v1_read_payload_rejects_invalid_timing() {
|
||||
client.write_all(&600u32.to_be_bytes()).await.unwrap();
|
||||
client.write_all(&600u32.to_be_bytes()).await.unwrap();
|
||||
|
||||
let err = EndOfDataV1::read_payload(header, &mut server).await.unwrap_err();
|
||||
let err = EndOfDataV1::read_payload(header, &mut server)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(err.to_string().contains("expire interval"));
|
||||
}
|
||||
|
||||
13
tests/test_rtr_admin.rs
Normal file
13
tests/test_rtr_admin.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use rpki::rtr::admin::tail_log_lines;
|
||||
|
||||
#[test]
|
||||
fn tail_log_lines_returns_requested_suffix() {
|
||||
let output = tail_log_lines(b"one\ntwo\nthree\nfour\n".to_vec(), 2);
|
||||
assert_eq!(output, b"three\nfour\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_log_lines_returns_whole_buffer_when_shorter() {
|
||||
let output = tail_log_lines(b"one\ntwo\n".to_vec(), 5);
|
||||
assert_eq!(output, b"one\ntwo\n");
|
||||
}
|
||||
87
tests/test_rtr_config.rs
Normal file
87
tests/test_rtr_config.rs
Normal file
@ -0,0 +1,87 @@
|
||||
use rpki::rtr::config::{AppConfig, RuntimeConfigPatch, RuntimeTimingConfigPatch, parse_timezone};
|
||||
|
||||
#[test]
|
||||
fn parse_timezone_accepts_iana_names_and_common_aliases() {
|
||||
assert_eq!(
|
||||
parse_timezone("Asia/Shanghai", "TEST").unwrap(),
|
||||
chrono_tz::Asia::Shanghai
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("shanghai", "TEST").unwrap(),
|
||||
chrono_tz::Asia::Shanghai
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("Europe/London", "TEST").unwrap(),
|
||||
chrono_tz::Europe::London
|
||||
);
|
||||
assert_eq!(
|
||||
parse_timezone("America/New_York", "TEST").unwrap(),
|
||||
chrono_tz::America::New_York
|
||||
);
|
||||
assert_eq!(parse_timezone("UTC", "TEST").unwrap(), chrono_tz::UTC);
|
||||
assert_eq!(parse_timezone("Z", "TEST").unwrap(), chrono_tz::UTC);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_timezone_rejects_offset_and_invalid_values() {
|
||||
assert!(parse_timezone("+08:00", "TEST").is_err());
|
||||
assert!(parse_timezone("+0800", "TEST").is_err());
|
||||
assert!(parse_timezone("Mars/Base", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_config_patch_validates_and_preserves_unspecified_values() {
|
||||
let current = AppConfig::default().runtime_config();
|
||||
let next = current
|
||||
.apply_patch(RuntimeConfigPatch {
|
||||
max_delta: Some(8),
|
||||
prune_delta_by_snapshot_size: Some(true),
|
||||
source_refresh_interval_seconds: Some(60),
|
||||
runtime_report_interval_seconds: Some(120),
|
||||
report_history_limit: Some(20),
|
||||
strict_ccr_validation: Some(true),
|
||||
timezone: Some("UTC".to_string()),
|
||||
timing: Some(RuntimeTimingConfigPatch {
|
||||
refresh: Some(1800),
|
||||
retry: None,
|
||||
expire: Some(7200),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(next.max_delta, 8);
|
||||
assert!(next.prune_delta_by_snapshot_size);
|
||||
assert_eq!(next.source_refresh_interval_seconds, 60);
|
||||
assert_eq!(next.runtime_report_interval_seconds, 120);
|
||||
assert_eq!(next.report_history_limit, 20);
|
||||
assert!(next.strict_ccr_validation);
|
||||
assert_eq!(next.timezone, "UTC");
|
||||
assert_eq!(next.timing.refresh, 1800);
|
||||
assert_eq!(next.timing.retry, current.timing.retry);
|
||||
assert_eq!(next.timing.expire, 7200);
|
||||
|
||||
assert!(
|
||||
current
|
||||
.apply_patch(RuntimeConfigPatch {
|
||||
max_delta: Some(0),
|
||||
..RuntimeConfigPatch::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
current
|
||||
.apply_patch(RuntimeConfigPatch {
|
||||
runtime_report_interval_seconds: Some(0),
|
||||
..RuntimeConfigPatch::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
current
|
||||
.apply_patch(RuntimeConfigPatch {
|
||||
timezone: Some("+08:00".to_string()),
|
||||
..RuntimeConfigPatch::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
290
tests/test_rtr_report.rs
Normal file
290
tests/test_rtr_report.rs
Normal file
@ -0,0 +1,290 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use chrono::Utc;
|
||||
use rpki::rtr::cache::RtrCache;
|
||||
use rpki::rtr::report::{ReportConfiguration, ReportContext};
|
||||
use rpki::rtr::server::RtrService;
|
||||
use rpki::source::pipeline::{
|
||||
DataQualityReport, FileFingerprint, PayloadTypeCounts, SlurmRuleCounts, SourceFingerprint,
|
||||
SourceLoadReport,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
fn test_configuration(report_history_limit: usize) -> ReportConfiguration {
|
||||
ReportConfiguration::new(
|
||||
300,
|
||||
300,
|
||||
report_history_limit,
|
||||
100,
|
||||
false,
|
||||
false,
|
||||
chrono_tz::Asia::Shanghai,
|
||||
(3600, 600, 7200),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_report_creates_parseable_json() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration(10));
|
||||
|
||||
context.write_or_warn(
|
||||
&report_dir,
|
||||
"test",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
);
|
||||
|
||||
let source_report = read_single_report(&report_dir, "rtr-source");
|
||||
assert_eq!(source_report["schema_version"], 2);
|
||||
assert_eq!(source_report["phase"], "test");
|
||||
assert_report_time_offset(&source_report["generated_at"]);
|
||||
assert_report_time_offset(&source_report["cache"]["created_at"]);
|
||||
assert_eq!(source_report["cache"]["availability"], "ready");
|
||||
assert_eq!(source_report["refresh"]["status"], "not_attempted");
|
||||
assert!(source_report["source"].is_null());
|
||||
assert!(source_report["data_quality"].is_null());
|
||||
|
||||
let runtime_report = read_single_report(&report_dir, "rtr-runtime");
|
||||
assert_report_time_offset(&runtime_report["service"]["started_at"]);
|
||||
assert_eq!(
|
||||
runtime_report["configuration"]["source_refresh_interval_seconds"],
|
||||
300
|
||||
);
|
||||
assert_eq!(runtime_report["configuration"]["report_history_limit"], 10);
|
||||
|
||||
let clients_report = read_single_report(&report_dir, "rtr-clients");
|
||||
assert_eq!(clients_report["service"]["max_connections"], 1024);
|
||||
assert_eq!(clients_report["service"]["active_connections"], 0);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["tcp"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["tls"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
clients_report["service"]["connections_by_transport"]["ssh"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["versions"].as_array().unwrap().len(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["versions"][2]["snapshot"]["total"],
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
source_report["cache"]["memory"]["delta_payload_counts"][2],
|
||||
0
|
||||
);
|
||||
assert!(source_report["source_fingerprint"].is_null());
|
||||
assert_no_temporary_reports(&report_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_failure_preserves_last_successful_source_data() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration(10));
|
||||
let source = SourceLoadReport {
|
||||
ccr_file: "data/example.ccr".to_string(),
|
||||
ccr_file_size_bytes: 123,
|
||||
ccr_modified_at: Some(Utc::now()),
|
||||
ccr_produced_at: Some("20260615000000Z".to_string()),
|
||||
slurm_enabled: true,
|
||||
slurm_file_count: 1,
|
||||
slurm_files: vec!["policy.slurm".to_string()],
|
||||
slurm_version: Some(2),
|
||||
};
|
||||
let quality = DataQualityReport {
|
||||
ccr_input: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
invalid: PayloadTypeCounts::default(),
|
||||
before_slurm: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
after_slurm: PayloadTypeCounts {
|
||||
total: 10,
|
||||
vrp: 9,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
slurm_filters: SlurmRuleCounts {
|
||||
prefix: 1,
|
||||
router_key: 0,
|
||||
aspa: 0,
|
||||
},
|
||||
slurm_assertions: SlurmRuleCounts::default(),
|
||||
};
|
||||
context.record_source_fingerprint(SourceFingerprint {
|
||||
ccr: FileFingerprint {
|
||||
path: "data/example.ccr".to_string(),
|
||||
len: 123,
|
||||
modified_unix_secs: 1_781_404_800,
|
||||
},
|
||||
slurm_files: vec![FileFingerprint {
|
||||
path: "policy.slurm".to_string(),
|
||||
len: 42,
|
||||
modified_unix_secs: 1_781_408_400,
|
||||
}],
|
||||
});
|
||||
|
||||
context.record_refresh_success(Utc::now(), 12, true, source, quality);
|
||||
context.record_refresh_failure(Utc::now(), 5, &anyhow::anyhow!("source unavailable"));
|
||||
context.write_or_warn(
|
||||
&report_dir,
|
||||
"refresh_failed",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
);
|
||||
|
||||
let report = read_single_report(&report_dir, "rtr-source");
|
||||
assert_eq!(report["source"]["ccr_file"], "data/example.ccr");
|
||||
assert_eq!(
|
||||
report["source_fingerprint"]["ccr"]["path"],
|
||||
"data/example.ccr"
|
||||
);
|
||||
assert_eq!(report["source_fingerprint"]["ccr"]["len"], 123);
|
||||
assert_report_time_offset(&report["source_fingerprint"]["ccr"]["modified_at"]);
|
||||
assert_eq!(
|
||||
report["source_fingerprint"]["slurm_files"][0]["path"],
|
||||
"policy.slurm"
|
||||
);
|
||||
assert_report_time_offset(&report["source"]["ccr_modified_at"]);
|
||||
assert_eq!(report["data_quality"]["after_slurm"]["total"], 10);
|
||||
assert_eq!(report["refresh"]["status"], "failed");
|
||||
assert_eq!(report["refresh"]["consecutive_failures"], 1);
|
||||
assert_eq!(report["refresh"]["last_error"], "source unavailable");
|
||||
assert!(!report["refresh"]["last_success_at"].is_null());
|
||||
assert_report_time_offset(&report["refresh"]["last_success_at"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_reports_keep_latest_files_per_category() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration(2));
|
||||
|
||||
for phase in ["one", "two", "three"] {
|
||||
context.write_or_warn(
|
||||
&report_dir,
|
||||
phase,
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 2);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 2);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 2);
|
||||
assert_no_temporary_reports(&report_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_writes_only_create_requested_report_type() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration(10));
|
||||
|
||||
context.write_source_or_warn(
|
||||
&report_dir,
|
||||
"source_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
);
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 0);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||
|
||||
context.write_clients_or_warn(
|
||||
&report_dir,
|
||||
"clients_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
);
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||
|
||||
context.write_runtime_or_warn(
|
||||
&report_dir,
|
||||
"runtime_only",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
);
|
||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 1);
|
||||
}
|
||||
|
||||
fn assert_report_time_offset(value: &Value) {
|
||||
let value = value.as_str().expect("report time should be a string");
|
||||
assert!(
|
||||
value.ends_with("+08:00"),
|
||||
"report time should use +08:00 offset, got {value}"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_single_report(report_dir: &Path, prefix: &str) -> Value {
|
||||
let files = report_files(report_dir, prefix);
|
||||
assert_eq!(files.len(), 1, "expected one {prefix} report");
|
||||
serde_json::from_slice(&fs::read(&files[0]).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn report_files(report_dir: &Path, prefix: &str) -> Vec<PathBuf> {
|
||||
let start = format!("{prefix}-");
|
||||
let mut files = fs::read_dir(report_dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with(&start) && name.ends_with(".json"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn assert_no_temporary_reports(report_dir: &Path) {
|
||||
let has_temporary = fs::read_dir(report_dir).unwrap().any(|entry| {
|
||||
entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.ends_with(".tmp"))
|
||||
});
|
||||
assert!(!has_temporary);
|
||||
}
|
||||
@ -16,8 +16,8 @@ use tokio_rustls::TlsConnector;
|
||||
use rpki::rtr::cache::{RtrCacheBuilder, SessionIds, SharedRtrCache};
|
||||
use rpki::rtr::payload::Timing;
|
||||
use rpki::rtr::pdu::{CacheResponse, EndOfDataV1, ResetQuery};
|
||||
use rpki::rtr::server::ssh::SshAuthMode;
|
||||
use rpki::rtr::server::RtrService;
|
||||
use rpki::rtr::server::ssh::SshAuthMode;
|
||||
use russh::client;
|
||||
use russh::keys;
|
||||
use russh::keys::ssh_key::LineEnding;
|
||||
|
||||
@ -15,16 +15,16 @@ use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio::time::{Duration, timeout};
|
||||
use tokio_rustls::{TlsAcceptor, TlsConnector};
|
||||
|
||||
use common::test_helper::{
|
||||
dump_cache_reset, dump_cache_response, dump_eod_v1, dump_ipv4_prefix, dump_ipv6_prefix,
|
||||
RtrDebugDumper,
|
||||
RtrDebugDumper, dump_cache_reset, dump_cache_response, dump_eod_v1, dump_ipv4_prefix,
|
||||
dump_ipv6_prefix,
|
||||
};
|
||||
|
||||
use rpki::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
|
||||
use rpki::data_model::resources::as_resources::Asn;
|
||||
use rpki::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
|
||||
use rpki::rtr::cache::{Delta, RtrCacheBuilder, SessionIds, SharedRtrCache, Snapshot};
|
||||
use rpki::rtr::error_type::ErrorCode;
|
||||
use rpki::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski, Timing};
|
||||
@ -32,10 +32,10 @@ use rpki::rtr::pdu::{
|
||||
Aspa as AspaPdu, CacheReset, CacheResponse, EndOfDataV1, ErrorReport, Header, IPv4Prefix,
|
||||
IPv6Prefix, ResetQuery, RouterKey as RouterKeyPdu, SerialNotify, SerialQuery,
|
||||
};
|
||||
use rpki::rtr::store::RtrStore;
|
||||
use rpki::rtr::server::connection::handle_tls_connection;
|
||||
use rpki::rtr::server::tls::load_rustls_server_config_with_options;
|
||||
use rpki::rtr::session::RtrSession;
|
||||
use rpki::rtr::store::RtrStore;
|
||||
|
||||
fn shared_cache(cache: rpki::rtr::cache::RtrCache) -> SharedRtrCache {
|
||||
Arc::new(ArcSwap::from_pointee(cache))
|
||||
@ -119,11 +119,7 @@ async fn start_session_server_returning_result(
|
||||
|
||||
async fn start_tls_session_server(
|
||||
cache: SharedRtrCache,
|
||||
) -> (
|
||||
SocketAddr,
|
||||
watch::Sender<bool>,
|
||||
JoinHandle<()>,
|
||||
) {
|
||||
) -> (SocketAddr, watch::Sender<bool>, JoinHandle<()>) {
|
||||
start_tls_session_server_with_cert(cache, "server.crt", "server.key").await
|
||||
}
|
||||
|
||||
@ -131,11 +127,7 @@ async fn start_tls_session_server_with_cert(
|
||||
cache: SharedRtrCache,
|
||||
cert_name: &str,
|
||||
key_name: &str,
|
||||
) -> (
|
||||
SocketAddr,
|
||||
watch::Sender<bool>,
|
||||
JoinHandle<()>,
|
||||
) {
|
||||
) -> (SocketAddr, watch::Sender<bool>, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
@ -181,11 +173,8 @@ async fn shutdown_server(
|
||||
shutdown_io(&mut client, shutdown_tx, server_handle).await;
|
||||
}
|
||||
|
||||
async fn shutdown_io<S>(
|
||||
io: &mut S,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
server_handle: JoinHandle<()>,
|
||||
) where
|
||||
async fn shutdown_io<S>(io: &mut S, shutdown_tx: watch::Sender<bool>, server_handle: JoinHandle<()>)
|
||||
where
|
||||
S: AsyncWrite + Unpin,
|
||||
{
|
||||
let _ = io.shutdown().await;
|
||||
@ -419,8 +408,8 @@ async fn restart_restores_versioned_state_and_serves_queries() {
|
||||
};
|
||||
let origin = Payload::RouteOrigin(RouteOrigin::new(prefix, 24, 64496u32.into()));
|
||||
let valid_spki = vec![
|
||||
0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
|
||||
0x01, 0x05, 0x00, 0x03, 0x02, 0x00, 0x00,
|
||||
0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
|
||||
0x05, 0x00, 0x03, 0x02, 0x00, 0x00,
|
||||
];
|
||||
let router_key = Payload::RouterKey(RouterKey::new(
|
||||
Ski::default(),
|
||||
@ -464,7 +453,9 @@ async fn restart_restores_versioned_state_and_serves_queries() {
|
||||
let expected_sid_v0 = shared.load_full().session_id_for_version(0);
|
||||
assert_eq!(response.session_id(), expected_sid_v0);
|
||||
let _v4 = IPv4Prefix::read(&mut client).await.unwrap();
|
||||
let eod_v0 = rpki::rtr::pdu::EndOfDataV0::read(&mut client).await.unwrap();
|
||||
let eod_v0 = rpki::rtr::pdu::EndOfDataV0::read(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(eod_v0.serial_number(), 1);
|
||||
shutdown_server(client, shutdown_tx, server_handle).await;
|
||||
|
||||
@ -484,7 +475,10 @@ async fn restart_restores_versioned_state_and_serves_queries() {
|
||||
let (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(shared.clone()).await;
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
let sid_v2 = shared.load_full().session_id_for_version(2);
|
||||
SerialQuery::new(2, sid_v2, 1).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(2, sid_v2, 1)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = CacheResponse::read(&mut client).await.unwrap();
|
||||
assert_eq!(response.version(), 2);
|
||||
assert_eq!(response.session_id(), sid_v2);
|
||||
@ -511,7 +505,10 @@ async fn serial_query_returns_end_of_data_when_up_to_date() {
|
||||
let (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut dump = RtrDebugDumper::new();
|
||||
|
||||
@ -554,7 +551,10 @@ async fn serial_query_returns_corrupt_data_when_session_id_mismatch() {
|
||||
let (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
SerialQuery::new(1, 999, 100).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(1, 999, 100)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut dump = RtrDebugDumper::new();
|
||||
|
||||
@ -610,7 +610,10 @@ async fn serial_query_returns_deltas_when_incremental_update_available() {
|
||||
let (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut dump = RtrDebugDumper::new();
|
||||
|
||||
@ -823,7 +826,10 @@ async fn reset_query_returns_payloads_in_rtr_order() {
|
||||
assert_eq!(third.pdu(), 6);
|
||||
assert_eq!(third.version(), 1);
|
||||
assert!(third.flag().is_announce());
|
||||
assert_eq!(third.prefix(), Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0));
|
||||
assert_eq!(
|
||||
third.prefix(),
|
||||
Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0)
|
||||
);
|
||||
assert_eq!(third.prefix_len(), 32);
|
||||
assert_eq!(third.max_len(), 48);
|
||||
assert_eq!(third.asn(), 64498u32.into());
|
||||
@ -901,7 +907,10 @@ async fn serial_query_returns_announcements_before_withdrawals() {
|
||||
let (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut dump = RtrDebugDumper::new();
|
||||
|
||||
@ -1256,7 +1265,10 @@ async fn serial_query_returns_no_data_available_when_cache_is_unavailable() {
|
||||
let (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = ErrorReport::read(&mut client).await.unwrap();
|
||||
assert_error_report_matches(
|
||||
@ -1266,7 +1278,10 @@ async fn serial_query_returns_no_data_available_when_cache_is_unavailable() {
|
||||
SerialQuery::new(1, 42, 100).as_ref(),
|
||||
);
|
||||
|
||||
SerialQuery::new(1, 42, 100).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(1, 42, 100)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
let second = ErrorReport::read(&mut client).await.unwrap();
|
||||
assert_error_report_matches(
|
||||
&second,
|
||||
@ -1327,7 +1342,11 @@ async fn first_pdu_with_invalid_length_returns_corrupt_data() {
|
||||
);
|
||||
|
||||
assert_error_report_matches(&report, 1, ErrorCode::CorruptData, &request);
|
||||
assert!(std::str::from_utf8(report.text()).unwrap().contains("invalid length"));
|
||||
assert!(
|
||||
std::str::from_utf8(report.text())
|
||||
.unwrap()
|
||||
.contains("invalid length")
|
||||
);
|
||||
|
||||
let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
|
||||
.await
|
||||
@ -1442,9 +1461,11 @@ async fn established_session_invalid_header_returns_corrupt_data() {
|
||||
}),
|
||||
);
|
||||
assert_error_report_matches(&report, 1, ErrorCode::CorruptData, invalid_header.as_ref());
|
||||
assert!(std::str::from_utf8(report.text())
|
||||
assert!(
|
||||
std::str::from_utf8(report.text())
|
||||
.unwrap()
|
||||
.contains("invalid PDU length"));
|
||||
.contains("invalid PDU length")
|
||||
);
|
||||
|
||||
let read_res = Header::read(&mut client).await;
|
||||
assert!(read_res.is_err());
|
||||
@ -1506,7 +1527,12 @@ async fn established_session_unknown_pdu_returns_unsupported_pdu_type() {
|
||||
}),
|
||||
);
|
||||
|
||||
assert_error_report_matches(&report, 1, ErrorCode::UnsupportedPduType, unknown_pdu.as_ref());
|
||||
assert_error_report_matches(
|
||||
&report,
|
||||
1,
|
||||
ErrorCode::UnsupportedPduType,
|
||||
unknown_pdu.as_ref(),
|
||||
);
|
||||
|
||||
let read_res = Header::read(&mut client).await;
|
||||
assert!(read_res.is_err());
|
||||
@ -1525,11 +1551,12 @@ async fn established_session_unknown_pdu_returns_unsupported_pdu_type() {
|
||||
#[tokio::test]
|
||||
async fn version_zero_does_not_send_router_key_or_aspa() {
|
||||
let router_key = RouterKey::new(Ski::default(), Asn::from(64496u32), vec![1u8; 32]);
|
||||
let aspa = Aspa::new(Asn::from(64496u32), vec![Asn::from(64497u32), Asn::from(64498u32)]);
|
||||
let snapshot = Snapshot::from_payloads(vec![
|
||||
Payload::RouterKey(router_key),
|
||||
Payload::Aspa(aspa),
|
||||
]);
|
||||
let aspa = Aspa::new(
|
||||
Asn::from(64496u32),
|
||||
vec![Asn::from(64497u32), Asn::from(64498u32)],
|
||||
);
|
||||
let snapshot =
|
||||
Snapshot::from_payloads(vec![Payload::RouterKey(router_key), Payload::Aspa(aspa)]);
|
||||
|
||||
let cache = RtrCacheBuilder::new()
|
||||
.session_ids(SessionIds::from_array([42, 42, 42]))
|
||||
@ -1548,7 +1575,9 @@ async fn version_zero_does_not_send_router_key_or_aspa() {
|
||||
let response = CacheResponse::read(&mut client).await.unwrap();
|
||||
dump.push_value(response.pdu(), dump_cache_response(&response));
|
||||
|
||||
let eod = rpki::rtr::pdu::EndOfDataV0::read(&mut client).await.unwrap();
|
||||
let eod = rpki::rtr::pdu::EndOfDataV0::read(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
dump.push_value(
|
||||
eod.pdu(),
|
||||
json!({
|
||||
@ -1561,7 +1590,10 @@ async fn version_zero_does_not_send_router_key_or_aspa() {
|
||||
);
|
||||
|
||||
let res = timeout(Duration::from_millis(100), Header::read(&mut client)).await;
|
||||
assert!(res.is_err(), "version 0 response should not contain RouterKey or ASPA PDUs");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"version 0 response should not contain RouterKey or ASPA PDUs"
|
||||
);
|
||||
dump.print_pretty("version_zero_does_not_send_router_key_or_aspa");
|
||||
|
||||
shutdown_server(client, shutdown_tx, server_handle).await;
|
||||
@ -1569,7 +1601,10 @@ async fn version_zero_does_not_send_router_key_or_aspa() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_two_aspa_withdraw_has_empty_provider_list() {
|
||||
let aspa = Aspa::new(Asn::from(64496u32), vec![Asn::from(64497u32), Asn::from(64498u32)]);
|
||||
let aspa = Aspa::new(
|
||||
Asn::from(64496u32),
|
||||
vec![Asn::from(64497u32), Asn::from(64498u32)],
|
||||
);
|
||||
let delta = Arc::new(Delta::new(101, vec![], vec![Payload::Aspa(aspa)]));
|
||||
let mut deltas = VecDeque::new();
|
||||
deltas.push_back(delta);
|
||||
@ -1586,7 +1621,10 @@ async fn version_two_aspa_withdraw_has_empty_provider_list() {
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
let mut dump = RtrDebugDumper::new();
|
||||
SerialQuery::new(2, 42, 100).write(&mut client).await.unwrap();
|
||||
SerialQuery::new(2, 42, 100)
|
||||
.write(&mut client)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let response = CacheResponse::read(&mut client).await.unwrap();
|
||||
dump.push_value(response.pdu(), dump_cache_response(&response));
|
||||
@ -1618,15 +1656,13 @@ async fn version_two_aspa_withdraw_has_empty_provider_list() {
|
||||
#[tokio::test]
|
||||
async fn version_one_sends_router_key_but_not_aspa() {
|
||||
let valid_spki = vec![
|
||||
0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
|
||||
0x01, 0x05, 0x00, 0x03, 0x02, 0x00, 0x00,
|
||||
0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
|
||||
0x05, 0x00, 0x03, 0x02, 0x00, 0x00,
|
||||
];
|
||||
let router_key = RouterKey::new(Ski::default(), Asn::from(64496u32), valid_spki);
|
||||
let aspa = Aspa::new(Asn::from(64496u32), vec![Asn::from(64497u32)]);
|
||||
let snapshot = Snapshot::from_payloads(vec![
|
||||
Payload::RouterKey(router_key),
|
||||
Payload::Aspa(aspa),
|
||||
]);
|
||||
let snapshot =
|
||||
Snapshot::from_payloads(vec![Payload::RouterKey(router_key), Payload::Aspa(aspa)]);
|
||||
|
||||
let cache = RtrCacheBuilder::new()
|
||||
.session_ids(SessionIds::from_array([42, 42, 42]))
|
||||
@ -1664,7 +1700,10 @@ async fn version_one_sends_router_key_but_not_aspa() {
|
||||
dump.push_value(eod.pdu(), dump_eod_v1(&eod));
|
||||
|
||||
let res = timeout(Duration::from_millis(100), Header::read(&mut client)).await;
|
||||
assert!(res.is_err(), "version 1 response should not contain ASPA PDUs");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"version 1 response should not contain ASPA PDUs"
|
||||
);
|
||||
dump.print_pretty("version_one_sends_router_key_but_not_aspa");
|
||||
|
||||
shutdown_server(client, shutdown_tx, server_handle).await;
|
||||
@ -1793,9 +1832,10 @@ async fn tls_server_dns_name_san_strict_mode_rejects_ip_only_certificate() {
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("does not contain a subjectAltName dNSName entry"));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("does not contain a subjectAltName dNSName entry")
|
||||
);
|
||||
|
||||
let _ = cache;
|
||||
}
|
||||
@ -1825,7 +1865,10 @@ async fn tls_client_with_mismatched_san_ip_is_rejected() {
|
||||
let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
|
||||
.await
|
||||
.expect("timed out waiting for TLS session close");
|
||||
assert!(read_res.is_err(), "server should close TLS session when client SAN IP mismatches");
|
||||
assert!(
|
||||
read_res.is_err(),
|
||||
"server should close TLS session when client SAN IP mismatches"
|
||||
);
|
||||
dump.push_value(
|
||||
0,
|
||||
json!({
|
||||
@ -1847,7 +1890,8 @@ async fn invalid_timing_prevents_end_of_data_response() {
|
||||
.build();
|
||||
|
||||
let server_cache = shared_cache(cache);
|
||||
let (addr, shutdown_tx, server_handle) = start_session_server_returning_result(server_cache).await;
|
||||
let (addr, shutdown_tx, server_handle) =
|
||||
start_session_server_returning_result(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
ResetQuery::new(1).write(&mut client).await.unwrap();
|
||||
@ -1859,7 +1903,10 @@ async fn invalid_timing_prevents_end_of_data_response() {
|
||||
let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
|
||||
.await
|
||||
.expect("timed out waiting for server close");
|
||||
assert!(read_res.is_err(), "server should close instead of sending invalid EndOfData");
|
||||
assert!(
|
||||
read_res.is_err(),
|
||||
"server should close instead of sending invalid EndOfData"
|
||||
);
|
||||
|
||||
let _ = shutdown_tx.send(true);
|
||||
let join = timeout(Duration::from_secs(1), server_handle)
|
||||
@ -1872,10 +1919,8 @@ async fn invalid_timing_prevents_end_of_data_response() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_aspa_prevents_snapshot_response() {
|
||||
let snapshot = Snapshot::from_payloads(vec![Payload::Aspa(Aspa::new(
|
||||
Asn::from(64496u32),
|
||||
vec![],
|
||||
))]);
|
||||
let snapshot =
|
||||
Snapshot::from_payloads(vec![Payload::Aspa(Aspa::new(Asn::from(64496u32), vec![]))]);
|
||||
let cache = RtrCacheBuilder::new()
|
||||
.session_ids(SessionIds::from_array([42, 42, 42]))
|
||||
.serials(serials_all(100))
|
||||
@ -1884,7 +1929,8 @@ async fn invalid_aspa_prevents_snapshot_response() {
|
||||
.build();
|
||||
|
||||
let server_cache = shared_cache(cache);
|
||||
let (addr, shutdown_tx, server_handle) = start_session_server_returning_result(server_cache).await;
|
||||
let (addr, shutdown_tx, server_handle) =
|
||||
start_session_server_returning_result(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
ResetQuery::new(2).write(&mut client).await.unwrap();
|
||||
@ -1896,7 +1942,10 @@ async fn invalid_aspa_prevents_snapshot_response() {
|
||||
let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
|
||||
.await
|
||||
.expect("timed out waiting for server close");
|
||||
assert!(read_res.is_err(), "server should close instead of sending invalid ASPA");
|
||||
assert!(
|
||||
read_res.is_err(),
|
||||
"server should close instead of sending invalid ASPA"
|
||||
);
|
||||
|
||||
let _ = shutdown_tx.send(true);
|
||||
let join = timeout(Duration::from_secs(1), server_handle)
|
||||
@ -1922,7 +1971,8 @@ async fn invalid_router_key_prevents_snapshot_response() {
|
||||
.build();
|
||||
|
||||
let server_cache = shared_cache(cache);
|
||||
let (addr, shutdown_tx, server_handle) = start_session_server_returning_result(server_cache).await;
|
||||
let (addr, shutdown_tx, server_handle) =
|
||||
start_session_server_returning_result(server_cache).await;
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||
ResetQuery::new(1).write(&mut client).await.unwrap();
|
||||
@ -1934,7 +1984,10 @@ async fn invalid_router_key_prevents_snapshot_response() {
|
||||
let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
|
||||
.await
|
||||
.expect("timed out waiting for server close");
|
||||
assert!(read_res.is_err(), "server should close instead of sending invalid RouterKey");
|
||||
assert!(
|
||||
read_res.is_err(),
|
||||
"server should close instead of sending invalid RouterKey"
|
||||
);
|
||||
|
||||
let _ = shutdown_tx.send(true);
|
||||
let join = timeout(Duration::from_secs(1), server_handle)
|
||||
|
||||
@ -676,7 +676,10 @@ fn applies_filters_before_assertions_and_excludes_duplicates() {
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
log_slurm_input("applies_filters_before_assertions_and_excludes_duplicates", &json);
|
||||
log_slurm_input(
|
||||
"applies_filters_before_assertions_and_excludes_duplicates",
|
||||
&json,
|
||||
);
|
||||
let slurm = SlurmFile::from_slice(json.as_bytes()).unwrap();
|
||||
log_slurm_ok(
|
||||
"applies_filters_before_assertions_and_excludes_duplicates",
|
||||
@ -788,9 +791,11 @@ fn rejects_hex_encoded_ski_and_aspa_customer_in_providers() {
|
||||
"rejects_hex_encoded_ski_and_aspa_customer_in_providers.invalid_aspa",
|
||||
&aspa_err,
|
||||
);
|
||||
assert!(aspa_err
|
||||
assert!(
|
||||
aspa_err
|
||||
.to_string()
|
||||
.contains("providerAsns must not contain customerAsn"));
|
||||
.contains("providerAsns must not contain customerAsn")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -840,7 +845,10 @@ fn merges_multiple_slurm_files_without_conflict() {
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
log_slurm_ok("merges_multiple_slurm_files_without_conflict.merged", &merged);
|
||||
log_slurm_ok(
|
||||
"merges_multiple_slurm_files_without_conflict.merged",
|
||||
&merged,
|
||||
);
|
||||
|
||||
assert_eq!(merged.version(), SlurmVersion::V2);
|
||||
assert_eq!(merged.locally_added_assertions().prefix_assertions.len(), 1);
|
||||
|
||||
109
tests/test_slurm_admin.rs
Normal file
109
tests/test_slurm_admin.rs
Normal file
@ -0,0 +1,109 @@
|
||||
use std::fs;
|
||||
|
||||
use rpki::slurm::admin::SlurmAdmin;
|
||||
|
||||
fn valid_slurm() -> &'static str {
|
||||
r#"{
|
||||
"slurmVersion": 1,
|
||||
"validationOutputFilters": {
|
||||
"prefixFilters": [],
|
||||
"bgpsecFilters": []
|
||||
},
|
||||
"locallyAddedAssertions": {
|
||||
"prefixAssertions": [],
|
||||
"bgpsecAssertions": []
|
||||
}
|
||||
}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slurm_admin_rejects_unsafe_file_names() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let admin = SlurmAdmin::new(temp.path());
|
||||
|
||||
for name in ["", "../x.slurm", "a/b.slurm", "a\\b.slurm", "a.json"] {
|
||||
assert!(
|
||||
admin
|
||||
.put_file(name, valid_slurm(), "create_or_update")
|
||||
.is_err(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
admin
|
||||
.put_file("policy.slurm", valid_slurm(), "create_or_update")
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
admin
|
||||
.put_file("policy.slurm.disabled", valid_slurm(), "create_or_update")
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_backs_up_toggles_deletes_and_rolls_back() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let admin = SlurmAdmin::new(temp.path());
|
||||
|
||||
let create = admin
|
||||
.put_file("policy.slurm", valid_slurm(), "create_or_update")
|
||||
.unwrap();
|
||||
assert!(temp.path().join("policy.slurm").is_file());
|
||||
assert!(create.result.backup.is_none());
|
||||
|
||||
let update = admin
|
||||
.put_file("policy.slurm", valid_slurm(), "create_or_update")
|
||||
.unwrap();
|
||||
assert!(update.result.backup.is_some());
|
||||
|
||||
let disable = admin.disable_file("policy.slurm").unwrap();
|
||||
assert!(!temp.path().join("policy.slurm").exists());
|
||||
assert!(temp.path().join("policy.slurm.disabled").is_file());
|
||||
|
||||
disable.rollback().unwrap();
|
||||
assert!(temp.path().join("policy.slurm").is_file());
|
||||
assert!(!temp.path().join("policy.slurm.disabled").exists());
|
||||
|
||||
admin.disable_file("policy.slurm").unwrap();
|
||||
let enable = admin.enable_file("policy.slurm.disabled").unwrap();
|
||||
assert!(temp.path().join("policy.slurm").is_file());
|
||||
assert!(!temp.path().join("policy.slurm.disabled").exists());
|
||||
enable.rollback().unwrap();
|
||||
assert!(!temp.path().join("policy.slurm").exists());
|
||||
assert!(temp.path().join("policy.slurm.disabled").is_file());
|
||||
|
||||
admin.enable_file("policy.slurm.disabled").unwrap();
|
||||
let delete = admin.delete_file("policy.slurm").unwrap();
|
||||
assert!(!temp.path().join("policy.slurm").exists());
|
||||
assert!(delete.result.backup.is_some());
|
||||
delete.rollback().unwrap();
|
||||
assert!(temp.path().join("policy.slurm").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_reads_slurm_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let admin = SlurmAdmin::new(temp.path());
|
||||
|
||||
admin
|
||||
.put_file("enabled.slurm", valid_slurm(), "create_or_update")
|
||||
.unwrap();
|
||||
admin
|
||||
.put_file("disabled.slurm.disabled", valid_slurm(), "create_or_update")
|
||||
.unwrap();
|
||||
fs::write(temp.path().join("ignore.txt"), "not slurm").unwrap();
|
||||
|
||||
let files = admin.list_files().unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert_eq!(files[0].name, "disabled.slurm.disabled");
|
||||
assert!(!files[0].enabled);
|
||||
assert_eq!(files[1].name, "enabled.slurm");
|
||||
assert!(files[1].enabled);
|
||||
|
||||
let file = admin.read_file("enabled.slurm").unwrap();
|
||||
assert_eq!(file.name, "enabled.slurm");
|
||||
assert!(file.enabled);
|
||||
assert!(file.content.contains("\"slurmVersion\""));
|
||||
assert!(admin.read_file("missing.slurm").is_err());
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
mod common;
|
||||
|
||||
use std::net::Ipv6Addr;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use common::test_helper::{v4_origin, v6_origin};
|
||||
|
||||
use rpki::data_model::resources::as_resources::Asn;
|
||||
use rpki::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
|
||||
use rpki::rtr::cache::{CacheAvailability, Delta, Snapshot};
|
||||
use rpki::rtr::payload::Payload;
|
||||
use rpki::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski};
|
||||
use rpki::rtr::store::RtrStore;
|
||||
|
||||
#[test]
|
||||
@ -107,6 +109,60 @@ fn store_db_versioned_state_persists_and_restores_all_versions() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_cache_state_persists_single_canonical_snapshot_for_all_versions() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let store = RtrStore::open(temp.path()).unwrap();
|
||||
let source = mixed_snapshot();
|
||||
let snapshots = std::array::from_fn(|version| source.project_for_version(version as u8));
|
||||
let deltas: [Option<&Delta>; 3] = [None, None, None];
|
||||
let windows: [Option<(u32, u32)>; 3] = [None, None, None];
|
||||
let clear = [true, true, true];
|
||||
|
||||
store
|
||||
.save_cache_state_versioned(
|
||||
CacheAvailability::Ready,
|
||||
&snapshots,
|
||||
&[1, 2, 3],
|
||||
&[10, 11, 12],
|
||||
&deltas,
|
||||
&windows,
|
||||
&clear,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let restored_v0 = store.get_snapshot_for_version(0).unwrap().unwrap();
|
||||
assert_eq!(restored_v0.origins().len(), 1);
|
||||
assert_eq!(restored_v0.router_keys().len(), 0);
|
||||
assert_eq!(restored_v0.aspas().len(), 0);
|
||||
|
||||
let restored_v1 = store.get_snapshot_for_version(1).unwrap().unwrap();
|
||||
assert_eq!(restored_v1.origins().len(), 1);
|
||||
assert_eq!(restored_v1.router_keys().len(), 1);
|
||||
assert_eq!(restored_v1.aspas().len(), 0);
|
||||
|
||||
let restored_v2 = store.get_snapshot_for_version(2).unwrap().unwrap();
|
||||
assert_eq!(restored_v2.origins().len(), 1);
|
||||
assert_eq!(restored_v2.router_keys().len(), 1);
|
||||
assert_eq!(restored_v2.aspas().len(), 1);
|
||||
}
|
||||
|
||||
fn mixed_snapshot() -> Snapshot {
|
||||
Snapshot::from_payloads(vec![
|
||||
Payload::RouteOrigin(RouteOrigin::new(
|
||||
IPAddressPrefix::new(IPAddress::from_ipv4(Ipv4Addr::new(192, 0, 2, 0)), 24),
|
||||
24,
|
||||
Asn::from(64496),
|
||||
)),
|
||||
Payload::RouterKey(RouterKey::new(
|
||||
Ski::default(),
|
||||
Asn::from(64497),
|
||||
vec![1, 2, 3],
|
||||
)),
|
||||
Payload::Aspa(Aspa::new(Asn::from(64498), vec![Asn::from(64499)])),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_db_versioned_delta_window_wraparound_is_isolated_by_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@ -371,7 +427,8 @@ fn store_db_versioned_load_delta_window_requires_complete_range() {
|
||||
.unwrap();
|
||||
|
||||
let err = store.load_delta_window_for_version(1, 10, 11).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("delta window starts at 10, but first persisted delta is Some(11)"));
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("delta window starts at 10, but first persisted delta is Some(11)")
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user