diff --git a/README.md b/README.md index 92de252..9b809be 100644 --- a/README.md +++ b/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 启动 diff --git a/data/20260415-apnic.ccr b/data/20260415-apnic.ccr deleted file mode 100644 index effaf49..0000000 Binary files a/data/20260415-apnic.ccr and /dev/null differ diff --git a/deploy/README.md b/deploy/README.md index daea8aa..3adfc11 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 diff --git a/deploy/rpki-rs-client/Dockerfile b/deploy/rpki-rs-client/Dockerfile deleted file mode 100644 index b2bbb8f..0000000 --- a/deploy/rpki-rs-client/Dockerfile +++ /dev/null @@ -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"] diff --git a/deploy/rpki-rs-client/docker-compose.yml b/deploy/rpki-rs-client/docker-compose.yml deleted file mode 100644 index 75c9979..0000000 --- a/deploy/rpki-rs-client/docker-compose.yml +++ /dev/null @@ -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 diff --git a/deploy/server/.env b/deploy/server/.env index 4c7e533..516bbb9 100644 --- a/deploy/server/.env +++ b/deploy/server/.env @@ -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. diff --git a/deploy/server/DEPLOYMENT.md b/deploy/server/DEPLOYMENT.md index c025549..be83ca1 100644 --- a/deploy/server/DEPLOYMENT.md +++ b/deploy/server/DEPLOYMENT.md @@ -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 `. + +The endpoint accepts partial JSON updates. See `docs/rtr-admin-api.md` for the +complete request/response schema, examples, and runtime apply semantics. diff --git a/deploy/server/Dockerfile b/deploy/server/Dockerfile index 8f1b23b..565b2bc 100644 --- a/deploy/server/Dockerfile +++ b/deploy/server/Dockerfile @@ -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 diff --git a/deploy/server/docker-compose.ssh.yml b/deploy/server/docker-compose.ssh.yml index 989466b..c156c79 100644 --- a/deploy/server/docker-compose.ssh.yml +++ b/deploy/server/docker-compose.ssh.yml @@ -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 diff --git a/deploy/server/docker-compose.tcp.yml b/deploy/server/docker-compose.tcp.yml index a4dd888..a407873 100644 --- a/deploy/server/docker-compose.tcp.yml +++ b/deploy/server/docker-compose.tcp.yml @@ -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: diff --git a/deploy/server/docker-compose.tls.yml b/deploy/server/docker-compose.tls.yml index 36aa2e8..56063e3 100644 --- a/deploy/server/docker-compose.tls.yml +++ b/deploy/server/docker-compose.tls.yml @@ -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} diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index 3502e08..f8fa6e5 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -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: diff --git a/docs/rtr-admin-api.md b/docs/rtr-admin-api.md new file mode 100644 index 0000000..56079c4 --- /dev/null +++ b/docs/rtr-admin-api.md @@ -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 `。 + +## 通用 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 文件:`.stdout.log`。 +- stderr 文件:`.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` | 请求体超过限制。 | diff --git a/src/bin/rpki_rs_test_client/README.md b/src/bin/rpki_rs_test_client/README.md deleted file mode 100644 index 588faad..0000000 --- a/src/bin/rpki_rs_test_client/README.md +++ /dev/null @@ -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 -- [reset|serial|serial ] [options] -``` - -默认值: -- `addr`: `127.0.0.1:323` -- `version`: `2` -- `mode`: `reset` - -## 常用参数 - -- `--steps `: 执行 `client.step()` 次数(默认 `1`) -- `--follow`: bootstrap 结束后持续执行 `client.step()`(常驻模式) -- `--print-records`: 打印当前收敛后的 payload 记录 -- `--assert-min-records `: 断言收敛记录数下限 -- `--assert-substr `: 在 payload 的 `Debug` 输出中做字符串断言(可重复) - -TLS 参数: -- `--tls` -- `--ca-cert ` -- `--server-name ` -- `--client-cert ` -- `--client-key ` - -## 限制说明 - -- 当前 `rpki-rs v0.18` client API 不支持显式覆盖初始版本,因此这里只接受 `version=2`。 -- 支持 `serial`(无参数)模式:会基于 client 内部 state 自动走 serial 更新。 -- 当前不支持 `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 -``` diff --git a/src/bin/rpki_rs_test_client/main.rs b/src/bin/rpki_rs_test_client/main.rs deleted file mode 100644 index c4ab1dc..0000000 --- a/src/bin/rpki_rs_test_client/main.rs +++ /dev/null @@ -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 AsyncStream for T where T: AsyncRead + AsyncWrite + Unpin + Send {} - -type DynStream = Box; - -#[derive(Debug, Clone)] -struct Config { - addr: String, - steps: usize, - follow: bool, - transport: TransportConfig, - assert_substr: Vec, - assert_min_records: Option, - print_records: bool, - step_timeout_secs: u64, - progress_every: u64, -} - -impl Config { - fn from_args() -> io::Result { - 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 Number of client.step() calls to perform (default: 1) - --follow Keep calling step() forever - --tls Enable TLS - --ca-cert CA certificate PEM file (required in TLS mode) - --server-name TLS server name; required when ADDR host is an IP - --client-cert Client certificate PEM file (optional, with --client-key) - --client-key Client private key PEM file (optional, with --client-cert) - --assert-substr Assert final stable record dump contains substring - --assert-min-records Assert final record count >= N - --print-records Print records after each successful step - --step-timeout-secs Timeout for each step() call in seconds (default: 300) - --progress-every 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, - ca_cert: Option, - client_cert: Option, - client_key: Option, -} - -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 { - 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 ", - ) - })?; - - 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::().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>, -} - -#[derive(Debug)] -struct TargetState { - records: BTreeSet, - timing: Option, - announced_seen: u64, - withdrawn_seen: u64, - updates_applied_total: u64, - progress_every: u64, - apply_batches: u64, - last_apply_started_at: Option, -} - -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, - timing: Option, - 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::>() - .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("") - ); - println!( - "ca_cert : {}", - tls.ca_cert - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_else(|| "".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 { - 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 { - 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 { - 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>> { - let mut reader = std::io::BufReader::new(std::fs::File::open(path)?); - let certs = rustls_pemfile::certs(&mut reader) - .collect::, _>>() - .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> { - 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 { - 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 { - value.parse::().map_err(|err| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid {} '{}': {}", name, value, err), - ) - }) -} - -fn parse_u64_arg(value: &str, name: &str) -> io::Result { - value.parse::().map_err(|err| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("invalid {} '{}': {}", name, value, err), - ) - }) -} \ No newline at end of file diff --git a/src/main_rtr.rs b/src/main_rtr.rs index ad05a7d..3467dcc 100644 --- a/src/main_rtr.rs +++ b/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, 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 = 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,168 +291,272 @@ fn spawn_refresh_task( continue; } _ = interval.tick() => {} - } - let source_to_delta_started = Instant::now(); - let attempted_at = Utc::now(); - - let current_fingerprint = match latest_sources_fingerprint(&payload_load_config) { - Ok(fp) => fp, - Err(err) => { - report_context.record_refresh_failure( - attempted_at, - source_to_delta_started.elapsed().as_millis(), - &err, - ); - warn!( - "failed to fingerprint CCR/SLURM sources from {}: {:?} (source_to_delta_elapsed_ms={})", - payload_load_config.ccr_dir, - err, - source_to_delta_started.elapsed().as_millis() - ); - report_context.write_source_or_warn( - &report_dir, - "refresh_failed", + 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; } - }; - 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(), - ); - 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); - report_context.write_source_or_warn( - &report_dir, - "refresh_skipped_unchanged", - &shared_cache, - ¬ifier, - &service_stats, - ); - continue; - } - - match load_payloads_from_latest_sources_with_report(&payload_load_config) { - Ok(load) => { - 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) - { - Ok(()) => { - let new_serial = next_cache.serial_for_version(2); - shared_cache.store(std::sync::Arc::new(next_cache)); - if new_serial != old_serial { - info!( - "RTR cache refresh applied: ccr_dir={}, payload_count={}, old_serial={}, new_serial={}", - payload_load_config.ccr_dir, - payload_count, - old_serial, - new_serial - ); - true - } else { - info!( - "RTR cache refresh found no change: ccr_dir={}, payload_count={}, serial={}", - payload_load_config.ccr_dir, payload_count, old_serial - ); - false - } - } - Err(err) => { - report_context.record_refresh_failure( - attempted_at, - source_to_delta_started.elapsed().as_millis(), - &err, - ); - warn!("RTR cache update failed: {:?}", err); - report_context.write_source_or_warn( - &report_dir, - "refresh_failed", - &shared_cache, - ¬ifier, - &service_stats, - ); - continue; - } - }; - (payload_count, updated) - }; - report_context.record_refresh_success( - attempted_at, - source_to_delta_started.elapsed().as_millis(), - updated, - source, - quality, - ); - info!( - "RTR source-to-delta timing: phase=refresh_cache_update_complete, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}", - payload_load_config.ccr_dir, - payload_count, - updated, - source_to_delta_started.elapsed().as_millis() - ); - - if updated { - let listener_count = notifier.notify_cache_updated(); - info!( - "RTR cache updated, notify signal emitted to session listeners: listener_count={}", - listener_count - ); - } - log_cache_memory_stats("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, - ); - } } + 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, +) -> Result { + let source_to_delta_started = Instant::now(); + let attempted_at = Utc::now(); + + let current_fingerprint = match latest_sources_fingerprint(payload_load_config) { + Ok(fp) => fp, + Err(err) => { + report_context.record_refresh_failure( + attempted_at, + source_to_delta_started.elapsed().as_millis(), + &err, + ); + warn!( + "failed to fingerprint CCR/SLURM sources from {}: {:?} (source_to_delta_elapsed_ms={})", + payload_load_config.ccr_dir, + err, + source_to_delta_started.elapsed().as_millis() + ); + report_context.write_source_or_warn( + report_dir, + "refresh_failed", + shared_cache, + notifier, + service_stats, + ); + return Err(err); + } + }; + report_context.record_source_fingerprint(current_fingerprint.clone()); + + if !force && last_fingerprint.as_ref() == Some(¤t_fingerprint) { + report_context + .record_refresh_unchanged(attempted_at, source_to_delta_started.elapsed().as_millis()); + info!( + "RTR source refresh skipped: source files unchanged (ccr_path={}, slurm_file_count={}, elapsed_ms={})", + current_fingerprint.ccr.path, + current_fingerprint.slurm_files.len(), + source_to_delta_started.elapsed().as_millis() + ); + log_cache_memory_stats("refresh_skipped_unchanged", shared_cache, notifier); + report_context.write_source_or_warn( + report_dir, + "refresh_skipped_unchanged", + shared_cache, + notifier, + service_stats, + ); + return Ok(SourceReloadResult { + phase, + changed: false, + skipped_unchanged: true, + payload_count: None, + serials: shared_cache.load_full().serials(), + }); + } + + let load = match load_payloads_from_latest_sources_with_report(payload_load_config) { + Ok(load) => load, + Err(err) => { + report_context.record_refresh_failure( + attempted_at, + source_to_delta_started.elapsed().as_millis(), + &err, + ); + warn!( + "failed to reload CCR/SLURM payloads from {}: {:?} (source_to_delta_elapsed_ms={})", + payload_load_config.ccr_dir, + err, + source_to_delta_started.elapsed().as_millis() + ); + report_context.write_source_or_warn( + report_dir, + "refresh_failed", + shared_cache, + notifier, + service_stats, + ); + return Err(err); + } + }; + + let source = load.source; + let quality = load.quality; + let payloads = load.payloads; + let payload_count = payloads.len(); + let source_snapshot = Snapshot::from_payloads(payloads); + let old_cache = shared_cache.load_full(); + let old_serial = old_cache.serial_for_version(2); + let mut next_cache = old_cache.as_ref().clone(); + let updated = match next_cache.update_with_snapshot(source_snapshot, store) { + Ok(()) => { + let new_serial = next_cache.serial_for_version(2); + shared_cache.store(std::sync::Arc::new(next_cache)); + if new_serial != old_serial { + info!( + "RTR cache refresh applied: ccr_dir={}, payload_count={}, old_serial={}, new_serial={}", + payload_load_config.ccr_dir, payload_count, old_serial, new_serial + ); + true + } else { + info!( + "RTR cache refresh found no change: ccr_dir={}, payload_count={}, serial={}", + payload_load_config.ccr_dir, payload_count, old_serial + ); + false + } + } + Err(err) => { + report_context.record_refresh_failure( + attempted_at, + source_to_delta_started.elapsed().as_millis(), + &err, + ); + warn!("RTR cache update failed: {:?}", err); + report_context.write_source_or_warn( + report_dir, + "refresh_failed", + shared_cache, + notifier, + service_stats, + ); + return Err(err); + } + }; + report_context.record_refresh_success( + attempted_at, + source_to_delta_started.elapsed().as_millis(), + updated, + source, + quality, + ); + info!( + "RTR source-to-delta timing: phase={}, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}", + phase, + payload_load_config.ccr_dir, + payload_count, + updated, + source_to_delta_started.elapsed().as_millis() + ); + + if updated { + let listener_count = notifier.notify_cache_updated(); + info!( + "RTR cache updated, notify signal emitted to session listeners: listener_count={}", + listener_count + ); + } + log_cache_memory_stats(phase, shared_cache, notifier); + report_context.write_source_or_warn(report_dir, phase, shared_cache, notifier, service_stats); + last_fingerprint.replace(current_fingerprint); + + Ok(SourceReloadResult { + phase, + changed: updated, + skipped_unchanged: false, + payload_count: Some(payload_count), + serials: shared_cache.load_full().serials(), + }) +} + +fn apply_runtime_config_update( + active: &mut RuntimeConfig, + next: RuntimeConfig, + shared_cache: &SharedRtrCache, + store: &RtrStore, + report_context: &ReportContext, + 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(); diff --git a/src/rtr/admin.rs b/src/rtr/admin.rs new file mode 100644 index 0000000..3bf0419 --- /dev/null +++ b/src/rtr/admin.rs @@ -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>, + tx: watch::Sender, +} + +#[derive(Clone)] +pub struct SourceReloadHandle { + tx: mpsc::Sender, +} + +impl SourceReloadHandle { + pub fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + pub async fn reload(&self, phase: &'static str, force: bool) -> Result { + 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>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SourceReloadResult { + pub phase: &'static str, + pub changed: bool, + pub skipped_unchanged: bool, + pub payload_count: Option, + pub serials: [u32; 3], +} + +#[derive(Clone)] +pub struct AdminState { + runtime_config: RuntimeConfigHandle, + source_reload: Option, + slurm_admin: Option, + log_tail: LogTailConfig, +} + +impl AdminState { + pub fn new( + runtime_config: RuntimeConfigHandle, + source_reload: Option, + slurm_admin: Option, + 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 { + self.tx.subscribe() + } + + pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result { + 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, + 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, + 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, + 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, + state: AdminState, + peer_addr: SocketAddr, +) -> Result<()> { + if request.method == "POST" && request.path == "/admin/rtr/config" { + let patch = match serde_json::from_slice::(&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::(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, + 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::(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::(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, + 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 { + 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 Deserialize<'de>>( + stream: &mut TcpStream, + body: &[u8], +) -> Result { + match serde_json::from_slice::(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 Deserialize<'de> + Default>( + stream: &mut TcpStream, + body: &[u8], +) -> Result { + if body.is_empty() { + return Ok(T::default()); + } + parse_json(stream, body).await +} + +async fn write_json_or_error( + stream: &mut TcpStream, + result: Result, +) -> 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 { + 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 { + let stream = LogStream::parse(query_param(query, "stream"))?; + let lines = query_param(query, "lines") + .map(|value| { + value + .parse::() + .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, 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, lines: usize) -> Vec { + 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 { + 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, +} + +#[derive(Serialize)] +struct SlurmFileContentResponse { + status: &'static str, + file: crate::slurm::admin::SlurmFileContent, +} + +#[derive(Serialize)] +struct SlurmOperationResponse { + status: &'static str, + operation: SlurmFileOperationResult, + reload: Option, + rollback: Option, +} + +#[derive(Serialize)] +struct SlurmOperationErrorResponse { + status: &'static str, + error: String, + rollback: Option, +} + +#[derive(Serialize)] +struct RollbackReport { + status: String, + reload: Option, + error: Option, +} + +struct RequestHeader { + method: String, + path: String, + query: Option, + content_length: Option, + authorization: Option, +} + +fn parse_request_header(header: &str) -> Result { + 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::() + .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 { + 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(()) +} diff --git a/src/rtr/cache/core.rs b/src/rtr/cache/core.rs index 5de14a4..7933c03 100644 --- a/src/rtr/cache/core.rs +++ b/src/rtr/cache/core.rs @@ -277,12 +277,20 @@ impl RtrCache { max_delta: u8, prune_delta_by_snapshot_size: bool, delta: Arc, + ) { + 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 { + 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() } diff --git a/src/rtr/cache/model.rs b/src/rtr/cache/model.rs index 620a686..6754385 100644 --- a/src/rtr/cache/model.rs +++ b/src/rtr/cache/model.rs @@ -82,11 +82,7 @@ pub struct Snapshot { } impl Snapshot { - pub fn new( - origins: Vec, - router_keys: Vec, - aspas: Vec, - ) -> Self { + pub fn new(origins: Vec, router_keys: Vec, aspas: Vec) -> 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::>(); normalized.sort(); normalized diff --git a/src/rtr/cache/store.rs b/src/rtr/cache/store.rs index 8ecbdfd..5165174 100644 --- a/src/rtr/cache/store.rs +++ b/src/rtr/cache/store.rs @@ -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( diff --git a/src/rtr/config.rs b/src/rtr/config.rs index b9182e9..7a916c4 100644 --- a/src/rtr/config.rs +++ b/src/rtr/config.rs @@ -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, + pub admin_token: Option, 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, + pub prune_delta_by_snapshot_size: Option, + pub source_refresh_interval_seconds: Option, + pub runtime_report_interval_seconds: Option, + pub report_history_limit: Option, + pub strict_ccr_validation: Option, + pub timezone: Option, + pub timing: Option, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +pub struct RuntimeTimingConfigPatch { + pub refresh: Option, + pub retry: Option, + pub expire: Option, +} + 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 { + 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 { + parse_timezone(&self.timezone, "timezone") + } + + pub fn timing(&self) -> Timing { + Timing::from(self.timing) + } +} + +impl From for RuntimeTimingConfig { + fn from(timing: Timing) -> Self { + Self { + refresh: timing.refresh, + retry: timing.retry, + expire: timing.expire, + } + } +} + +impl From 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 { Ok(parsed) } -fn parse_timezone(value: &str, name: &str) -> Result { +pub fn parse_timezone(value: &str, name: &str) -> Result { 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 { ) }) } - -#[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()); - } -} diff --git a/src/rtr/mod.rs b/src/rtr/mod.rs index 01304a7..0356dd8 100644 --- a/src/rtr/mod.rs +++ b/src/rtr/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod cache; pub mod config; pub mod error_type; diff --git a/src/rtr/report.rs b/src/rtr/report.rs index dac021e..0fe198f 100644 --- a/src/rtr/report.rs +++ b/src/rtr/report.rs @@ -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, started_instant: Instant, - timezone: Tz, - configuration: ReportConfiguration, + configuration: Arc>, runtime: Arc>, } @@ -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, @@ -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 { - 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) -> DateTime { - time.with_timezone(&self.timezone) + fn report_now(&self, timezone: Tz) -> DateTime { + self.to_report_time(Utc::now(), timezone) + } + + fn to_report_time(&self, time: DateTime, timezone: Tz) -> DateTime { + 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 { - 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::>(); - 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); - } -} diff --git a/src/rtr/session.rs b/src/rtr/session.rs index a34d27e..4709924 100644 --- a/src/rtr/session.rs +++ b/src/rtr/session.rs @@ -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( - writer: &mut W, - aspa: &Aspa, - announce: bool, - version: u8, - ) -> Result<()> + async fn send_aspa_to(writer: &mut W, aspa: &Aspa, announce: bool, version: u8) -> Result<()> where W: AsyncWrite + Unpin, { diff --git a/src/rtr/store.rs b/src/rtr/store.rs index 75cef81..bbc2ec1 100644 --- a/src/rtr/store.rs +++ b/src/rtr/store.rs @@ -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::(CF_SNAPSHOT, &snapshot_key(0)) - .unwrap() - .is_none()); - assert!(store - .get_cf::(CF_SNAPSHOT, &snapshot_key(1)) - .unwrap() - .is_none()); - assert!(store - .get_cf::(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); - } -} diff --git a/src/slurm/admin.rs b/src/slurm/admin.rs new file mode 100644 index 0000000..a4a1fc5 --- /dev/null +++ b/src/slurm/admin.rs @@ -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, + pub content: String, + pub reload: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct SlurmFileActionRequest { + pub reload: Option, +} + +#[derive(Debug, Serialize)] +pub struct SlurmFileOperationResult { + pub operation: &'static str, + pub file: String, + pub backup: Option, +} + +#[derive(Debug, Serialize)] +pub struct SlurmFileListEntry { + pub name: String, + pub path: String, + pub enabled: bool, + pub size_bytes: u64, + pub modified_unix_seconds: Option, +} + +#[derive(Debug, Serialize)] +pub struct SlurmFileContent { + pub name: String, + pub path: String, + pub enabled: bool, + pub size_bytes: u64, + pub modified_unix_seconds: Option, + 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) -> Self { + Self { dir: dir.into() } + } + + pub fn max_body_bytes(&self) -> usize { + MAX_SLURM_BODY_BYTES + } + + pub fn list_files(&self) -> Result> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::(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 { + 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 { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) +} diff --git a/src/slurm/mod.rs b/src/slurm/mod.rs index 481cd21..1caeb7f 100644 --- a/src/slurm/mod.rs +++ b/src/slurm/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod file; pub mod policy; mod serde; diff --git a/src/source/pipeline.rs b/src/source/pipeline.rs index 79c79ef..c2cd94e 100644 --- a/src/source/pipeline.rs +++ b/src/source/pipeline.rs @@ -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> { .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) } diff --git a/tests/common/test_helper.rs b/tests/common/test_helper.rs index 7b8d136..de1467e 100644 --- a/tests/common/test_helper.rs +++ b/tests/common/test_helper.rs @@ -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; diff --git a/tests/test_cache.rs b/tests/test_cache.rs index e2995b4..231629e 100644 --- a/tests/test_cache.rs +++ b/tests/test_cache.rs @@ -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(); diff --git a/tests/test_ccr.rs b/tests/test_ccr.rs index 270cdb1..0f09c77 100644 --- a/tests/test_ccr.rs +++ b/tests/test_ccr.rs @@ -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}" + ); } } diff --git a/tests/test_crl_decode.rs b/tests/test_crl_decode.rs index f5055ff..c2bf037 100644 --- a/tests/test_crl_decode.rs +++ b/tests/test_crl_decode.rs @@ -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,9 +42,8 @@ fn crl_signature_verification_succeeds_with_issuer_cert() { #[test] fn decode_crl_with_revoked_entries() { - let der = - std::fs::read("tests/fixtures/0099DEAB073EFD74C250C0A382B25012B5082AEE.crl") - .expect("read CRL fixture with revoked entries"); + 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"); diff --git a/tests/test_pdu.rs b/tests/test_pdu.rs index 23185f8..5a138d6 100644 --- a/tests/test_pdu.rs +++ b/tests/test_pdu.rs @@ -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")); } diff --git a/tests/test_rtr_admin.rs b/tests/test_rtr_admin.rs new file mode 100644 index 0000000..7a9e84f --- /dev/null +++ b/tests/test_rtr_admin.rs @@ -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"); +} diff --git a/tests/test_rtr_config.rs b/tests/test_rtr_config.rs new file mode 100644 index 0000000..a515a10 --- /dev/null +++ b/tests/test_rtr_config.rs @@ -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() + ); +} diff --git a/tests/test_rtr_report.rs b/tests/test_rtr_report.rs new file mode 100644 index 0000000..7ef284a --- /dev/null +++ b/tests/test_rtr_report.rs @@ -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 { + 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::>(); + 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); +} diff --git a/tests/test_server_transports.rs b/tests/test_server_transports.rs index a7e1544..c75017d 100644 --- a/tests/test_server_transports.rs +++ b/tests/test_server_transports.rs @@ -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; diff --git a/tests/test_session.rs b/tests/test_session.rs index e1b1b85..2a1b881 100644 --- a/tests/test_session.rs +++ b/tests/test_session.rs @@ -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, - JoinHandle<()>, -) { +) -> (SocketAddr, watch::Sender, 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, - JoinHandle<()>, -) { +) -> (SocketAddr, watch::Sender, 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( - io: &mut S, - shutdown_tx: watch::Sender, - server_handle: JoinHandle<()>, -) where +async fn shutdown_io(io: &mut S, shutdown_tx: watch::Sender, 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()) - .unwrap() - .contains("invalid PDU length")); + assert!( + std::str::from_utf8(report.text()) + .unwrap() + .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) diff --git a/tests/test_slurm.rs b/tests/test_slurm.rs index 86af6a4..296d001 100644 --- a/tests/test_slurm.rs +++ b/tests/test_slurm.rs @@ -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 - .to_string() - .contains("providerAsns must not contain customerAsn")); + assert!( + aspa_err + .to_string() + .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); diff --git a/tests/test_slurm_admin.rs b/tests/test_slurm_admin.rs new file mode 100644 index 0000000..e745bc1 --- /dev/null +++ b/tests/test_slurm_admin.rs @@ -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()); +} diff --git a/tests/test_store_db.rs b/tests/test_store_db.rs index d3601f4..c15e23e 100644 --- a/tests/test_store_db.rs +++ b/tests/test_store_db.rs @@ -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)") + ); }