增加rtr一些必要的api接口

This commit is contained in:
xiuting.xu 2026-06-23 17:04:00 +08:00
parent 303bdffd97
commit b105b3b7d0
41 changed files with 3078 additions and 1735 deletions

View File

@ -96,6 +96,11 @@ RTR Server 运行时从 `CCR` 目录中扫描最新的 `.ccr` 文件作为输入
| `RPKI_RTR_TCP_KEEPALIVE_SECS` | TCP keepalive 时间,单位秒;设为 `0` 表示禁用。 | `60` | `60` | | `RPKI_RTR_TCP_KEEPALIVE_SECS` | TCP keepalive 时间,单位秒;设为 `0` 表示禁用。 | `60` | `60` |
| `RPKI_RTR_WARN_INSECURE_TCP` | 纯 TCP 模式下是否输出不安全警告。 | `true` | `true` | | `RPKI_RTR_WARN_INSECURE_TCP` | 纯 TCP 模式下是否输出不安全警告。 | `true` | `true` |
| `RPKI_RTR_REQUIRE_TLS_SERVER_DNS_NAME_SAN` | 严格模式TLS 服务端证书不包含 `subjectAltName dNSName` 时拒绝启动。 | `false` | `false` | | `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_TCP_KEEPALIVE_SECS=0` 表示关闭 keepalive非零值表示在整个连接生命周期内启用 keepalive。
- `RPKI_RTR_PRUNE_DELTA_BY_SNAPSHOT_SIZE=true` 时,除了 `RPKI_RTR_MAX_DELTA` 的固定条数裁剪外,还会在累计 delta 估算 wire size 不小于 snapshot 时继续删除最老 delta。 - `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 启动 ### Docker 启动

Binary file not shown.

View File

@ -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 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 ## 2) Debug Client

View File

@ -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"]

View File

@ -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

View File

@ -24,6 +24,8 @@ RPKI_RTR_TIMEZONE=Asia/Shanghai
RPKI_RTR_MAX_DELTA=10 RPKI_RTR_MAX_DELTA=10
RPKI_RTR_MAX_CONNECTIONS=100000 RPKI_RTR_MAX_CONNECTIONS=100000
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES=128 RPKI_RTR_MAX_CONCURRENT_HANDSHAKES=128
RPKI_RTR_ADMIN_ADDR=0.0.0.0:8323
RPKI_RTR_ADMIN_TOKEN=qwert
RUST_LOG=info RUST_LOG=info
# TLS mode knobs. # TLS mode knobs.

View File

@ -29,13 +29,13 @@ The container runs `rpki` directly as PID 1.
- `RPKI_RTR_SLURM_DIR`: in-container SLURM directory path - `RPKI_RTR_SLURM_DIR`: in-container SLURM directory path
- `RPKI_RTR_DB_HOST_DIR`: host RocksDB directory - `RPKI_RTR_DB_HOST_DIR`: host RocksDB directory
- `RPKI_RTR_LOG_HOST_DIR`: host log 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_DB_PATH`: in-container RocksDB directory
- `RPKI_RTR_REPORT_DIR`: in-container report directory - `RPKI_RTR_REPORT_DIR`: in-container report directory
## Runtime Configuration via `.env` ## 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` - 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` - 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` - 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 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 ## Runtime Report
The server writes split JSON reports. Each report file uses a local-time 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`, defaults to `Asia/Shanghai`. Use IANA timezone names such as `Asia/Shanghai`,
`Europe/London`, `America/New_York`, or `UTC`; `Shanghai` is accepted as a `Europe/London`, `America/New_York`, or `UTC`; `Shanghai` is accepted as a
convenience alias for `Asia/Shanghai`. convenience alias for `Asia/Shanghai`.
## Runtime Admin Config
The admin endpoint is disabled by default. Set `RPKI_RTR_ADMIN_ADDR` to enable
`POST /admin/rtr/config`. If the address is not loopback, `RPKI_RTR_ADMIN_TOKEN`
must also be set and requests must include `Authorization: Bearer <token>`.
The endpoint accepts partial JSON updates. See `docs/rtr-admin-api.md` for the
complete request/response schema, examples, and runtime apply semantics.

View File

@ -75,7 +75,9 @@ ENV RPKI_RTR_ENABLE_TLS=false \
RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS=300 \ RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS=300 \
RPKI_RTR_REPORT_HISTORY_LIMIT=10 \ RPKI_RTR_REPORT_HISTORY_LIMIT=10 \
RPKI_RTR_REFRESH_INTERVAL_SECS=300 \ 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 EXPOSE 323 324

View File

@ -11,6 +11,7 @@ services:
ports: ports:
- "323:323" - "323:323"
- "${RPKI_RTR_SSH_HOST_PORT:-2222}:${RPKI_RTR_SSH_CONTAINER_PORT:-22}" - "${RPKI_RTR_SSH_HOST_PORT:-2222}:${RPKI_RTR_SSH_CONTAINER_PORT:-22}"
- "8323:8323"
environment: environment:
RPKI_RTR_ENABLE_TLS: "false" RPKI_RTR_ENABLE_TLS: "false"
RPKI_RTR_ENABLE_SSH: "true" 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_SOURCE_REFRESH_INTERVAL_SECS: "${RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS:-300}"
RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}" RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}" 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}" RUST_LOG: "${RUST_LOG:-info}"
volumes: volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro - ${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_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_KEYS_VOLUME:-/etc/ssh:/host-ssh:ro}
- ${RPKI_RTR_SSH_CERTS_HOST_DIR:-../../certs}:/app/certs:ro - ${RPKI_RTR_SSH_CERTS_HOST_DIR:-../../certs}:/app/certs:ro
- ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs - ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs

View File

@ -10,6 +10,7 @@ services:
restart: no restart: no
ports: ports:
- "323:323" - "323:323"
- "8323:8323"
environment: environment:
RPKI_RTR_ENABLE_TLS: "false" RPKI_RTR_ENABLE_TLS: "false"
RPKI_RTR_ENABLE_SSH: "false" RPKI_RTR_ENABLE_SSH: "false"
@ -26,11 +27,13 @@ services:
RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}" RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONNECTIONS: "${RPKI_RTR_MAX_CONNECTIONS:-100000}" RPKI_RTR_MAX_CONNECTIONS: "${RPKI_RTR_MAX_CONNECTIONS:-100000}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}" 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}" RUST_LOG: "${RUST_LOG:-info}"
volumes: volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro - ${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_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_LOG_HOST_DIR:-../../logs/server}:/app/logs
- ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report} - ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report}
networks: networks:

View File

@ -9,6 +9,7 @@ services:
ports: ports:
# - "323:323" # - "323:323"
- "324:324" - "324:324"
- "8323:8323"
environment: environment:
RPKI_RTR_ENABLE_TLS: "true" RPKI_RTR_ENABLE_TLS: "true"
RPKI_RTR_ENABLE_SSH: "false" 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_SOURCE_REFRESH_INTERVAL_SECS: "${RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS:-300}"
RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}" RPKI_RTR_MAX_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}" 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}" RUST_LOG: "${RUST_LOG:-info}"
volumes: volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro - ${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_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_TLS_CERTS_HOST_DIR:-../../tests/fixtures/tls}:/app/certs:ro
- ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs - ${RPKI_RTR_LOG_HOST_DIR:-../../logs/server}:/app/logs
- ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report} - ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report}

View File

@ -11,6 +11,7 @@ services:
ports: ports:
- "323:323" - "323:323"
- "324:324" - "324:324"
- "8323:8323"
# SSH mode example: # SSH mode example:
# - "22:22" # - "22:22"
environment: environment:
@ -28,6 +29,8 @@ services:
RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS: "${RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS:-300}" 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_DELTA: "${RPKI_RTR_MAX_DELTA:-10}"
RPKI_RTR_MAX_CONCURRENT_HANDSHAKES: "${RPKI_RTR_MAX_CONCURRENT_HANDSHAKES:-128}" 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}" RUST_LOG: "${RUST_LOG:-info}"
# SSH mode example: # SSH mode example:
# RPKI_RTR_ENABLE_SSH: "true" # RPKI_RTR_ENABLE_SSH: "true"
@ -42,7 +45,7 @@ services:
volumes: volumes:
- ${RPKI_RTR_CCR_HOST_DIR:-../../data}:${RPKI_RTR_CCR_DIR:-/app/data}:ro - ${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_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_LOG_HOST_DIR:-../../logs/server}:/app/logs
- ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report} - ${RPKI_RTR_REPORT_HOST_DIR:-../../report}:${RPKI_RTR_REPORT_DIR:-/app/report}
# TLS mode example: # TLS mode example:

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

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

View File

@ -1,112 +0,0 @@
# rpki_rs_test_client
`rpki_rs_test_client` 是一个基于 `rpki-rs` RTR 客户端接口的测试工具,参数风格对齐 `rtr_debug_client`
实现上直接调用外部 crate 的 client API`Client` / `PayloadTarget`),不重复实现 RTR 客户端状态机。
## 构建
```bash
cargo build --bin rpki_rs_test_client
```
## 基本用法
```bash
cargo run --bin rpki_rs_test_client -- <addr> <version> [reset|serial|serial <session_id> <serial>] [options]
```
默认值:
- `addr`: `127.0.0.1:323`
- `version`: `2`
- `mode`: `reset`
## 常用参数
- `--steps <n>`: 执行 `client.step()` 次数(默认 `1`
- `--follow`: bootstrap 结束后持续执行 `client.step()`(常驻模式)
- `--print-records`: 打印当前收敛后的 payload 记录
- `--assert-min-records <n>`: 断言收敛记录数下限
- `--assert-substr <text>`: 在 payload 的 `Debug` 输出中做字符串断言(可重复)
TLS 参数:
- `--tls`
- `--ca-cert <path>`
- `--server-name <name>`
- `--client-cert <path>`
- `--client-key <path>`
## 限制说明
- 当前 `rpki-rs v0.18` client API 不支持显式覆盖初始版本,因此这里只接受 `version=2`
- 支持 `serial`(无参数)模式:会基于 client 内部 state 自动走 serial 更新。
- 当前不支持 `serial <session_id> <serial>` 显式注入状态(传入会直接报错)。
## 示例
TCP 连通 + 最小记录数断言:
```bash
cargo run --bin rpki_rs_test_client -- \
127.0.0.1:323 \
2 reset \
--steps 1 \
--assert-min-records 1
```
自动 serial无需传 sid/serial
```bash
cargo run --bin rpki_rs_test_client -- \
127.0.0.1:323 \
2 serial --steps 2 --follow
```
结合 `mini_data` 的内容做字符串断言:
```bash
cargo run --bin rpki_rs_test_client -- \
127.0.0.1:323 \
2 reset \
--assert-substr "10.0.1.0" \
--assert-substr "65003"
```
TLS 场景:
```bash
cargo run --bin rpki_rs_test_client -- \
127.0.0.1:324 \
2 reset \
--tls \
--ca-cert tests/fixtures/tls/client-ca.crt \
--server-name localhost
```
## Docker 启动deploy
构建并启动Linux 服务器,`host` 网络):
```bash
docker compose -f deploy/rpki-rs-client/docker-compose.yml up --build
```
按需覆盖运行参数(覆盖 compose 默认 `command`
```bash
docker compose -f deploy/rpki-rs-client/docker-compose.yml run --rm \
rpki-rs-test-client 127.0.0.1:323 2 reset --steps 1 --assert-min-records 1
```
常驻跟进模式(先 bootstrap再持续 serial/notify
```bash
docker compose -f deploy/rpki-rs-client/docker-compose.yml run --rm \
rpki-rs-test-client 127.0.0.1:323 2 serial --steps 2 --follow
```
停止:
```bash
docker compose -f deploy/rpki-rs-client/docker-compose.yml down
```

View File

@ -1,843 +0,0 @@
use std::collections::BTreeSet;
use std::env;
use std::io;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio::time::{timeout, Duration};
use tokio_rustls::TlsConnector;
use rpki_rs::rtr::client::{Client, PayloadError, PayloadTarget};
use rpki_rs::rtr::payload::{Action, Payload, Timing};
const DEFAULT_STEPS: usize = 1;
const DEFAULT_STEP_TIMEOUT_SECS: u64 = 300;
const DEFAULT_PROGRESS_EVERY: u64 = 10_000;
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T> AsyncStream for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
type DynStream = Box<dyn AsyncStream>;
#[derive(Debug, Clone)]
struct Config {
addr: String,
steps: usize,
follow: bool,
transport: TransportConfig,
assert_substr: Vec<String>,
assert_min_records: Option<usize>,
print_records: bool,
step_timeout_secs: u64,
progress_every: u64,
}
impl Config {
fn from_args() -> io::Result<Self> {
let mut args = env::args().skip(1);
let mut positional = Vec::new();
let mut steps = DEFAULT_STEPS;
let mut follow = false;
let mut transport = TransportConfig::Tcp;
let mut assert_substr = Vec::new();
let mut assert_min_records = None;
let mut print_records = false;
let mut step_timeout_secs = DEFAULT_STEP_TIMEOUT_SECS;
let mut progress_every = DEFAULT_PROGRESS_EVERY;
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => {
print_usage();
std::process::exit(0);
}
"--steps" => {
let v = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--steps requires value")
})?;
steps = parse_usize_arg(&v, "--steps")?;
if steps == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--steps must be >= 1",
));
}
}
"--follow" => {
follow = true;
}
"--tls" => {
ensure_tls(&mut transport)?;
}
"--ca-cert" => {
let v = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--ca-cert requires path")
})?;
ensure_tls(&mut transport)?.ca_cert = Some(PathBuf::from(v));
}
"--server-name" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--server-name requires value",
)
})?;
ensure_tls(&mut transport)?.server_name = Some(v);
}
"--client-cert" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--client-cert requires path",
)
})?;
ensure_tls(&mut transport)?.client_cert = Some(PathBuf::from(v));
}
"--client-key" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--client-key requires path",
)
})?;
ensure_tls(&mut transport)?.client_key = Some(PathBuf::from(v));
}
"--assert-substr" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--assert-substr requires value",
)
})?;
assert_substr.push(v);
}
"--assert-min-records" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--assert-min-records requires value",
)
})?;
assert_min_records = Some(parse_usize_arg(&v, "--assert-min-records")?);
}
"--print-records" => {
print_records = true;
}
"--step-timeout-secs" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--step-timeout-secs requires value",
)
})?;
step_timeout_secs = parse_u64_arg(&v, "--step-timeout-secs")?;
if step_timeout_secs == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--step-timeout-secs must be >= 1",
));
}
}
"--progress-every" => {
let v = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--progress-every requires value",
)
})?;
progress_every = parse_u64_arg(&v, "--progress-every")?;
if progress_every == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--progress-every must be >= 1",
));
}
}
// 明确拒绝当前 wrapper 不支持的能力
"--version" => {
let _ = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--version requires value")
})?;
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--version is not exposed by this rpki client wrapper",
));
}
"--timeout" => {
let _ = args.next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "--timeout requires value")
})?;
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--timeout is not exposed by this rpki client wrapper; use --step-timeout-secs instead",
));
}
_ if arg.starts_with("--") => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown option '{}'", arg),
));
}
_ => positional.push(arg),
}
}
let mut positional = positional.into_iter();
let addr = positional
.next()
.unwrap_or_else(|| "127.0.0.1:323".to_string());
if let Some(extra) = positional.next() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"unexpected positional argument '{}'; only optional [addr] is supported",
extra
),
));
}
let transport = finalize_transport(transport, &addr)?;
Ok(Self {
addr,
steps,
follow,
transport,
assert_substr,
assert_min_records,
print_records,
step_timeout_secs,
progress_every,
})
}
}
fn print_usage() {
eprintln!(
"\
rpki-rs-test-client
Usage:
rpki-rs-test-client [OPTIONS] [ADDR]
Examples:
rpki-rs-test-client
rpki-rs-test-client 127.0.0.1:323 --steps 1
rpki-rs-test-client 127.0.0.1:323 --steps 1 --step-timeout-secs 600
rpki-rs-test-client 127.0.0.1:323 --follow
rpki-rs-test-client 127.0.0.1:323 --assert-min-records 1 --assert-substr 192.0.2.
rpki-rs-test-client 127.0.0.1:3324 --tls --ca-cert certs/ca.pem --server-name localhost
Options:
--steps <N> Number of client.step() calls to perform (default: 1)
--follow Keep calling step() forever
--tls Enable TLS
--ca-cert <PATH> CA certificate PEM file (required in TLS mode)
--server-name <NAME> TLS server name; required when ADDR host is an IP
--client-cert <PATH> Client certificate PEM file (optional, with --client-key)
--client-key <PATH> Client private key PEM file (optional, with --client-cert)
--assert-substr <TEXT> Assert final stable record dump contains substring
--assert-min-records <N> Assert final record count >= N
--print-records Print records after each successful step
--step-timeout-secs <N> Timeout for each step() call in seconds (default: 300)
--progress-every <N> Print apply progress every N updates (default: 10000)
-h, --help Show this help
Not supported by this wrapper:
--version
--timeout
explicit serial bootstrap via session_id/serial
"
);
}
#[derive(Debug, Clone)]
enum TransportConfig {
Tcp,
Tls(TlsConfig),
}
#[derive(Debug, Clone, Default)]
struct TlsConfig {
server_name: Option<String>,
ca_cert: Option<PathBuf>,
client_cert: Option<PathBuf>,
client_key: Option<PathBuf>,
}
fn ensure_tls(transport: &mut TransportConfig) -> io::Result<&mut TlsConfig> {
if matches!(transport, TransportConfig::Tcp) {
*transport = TransportConfig::Tls(TlsConfig::default());
}
match transport {
TransportConfig::Tls(cfg) => Ok(cfg),
TransportConfig::Tcp => Err(io::Error::other("tls configuration unavailable")),
}
}
fn finalize_transport(transport: TransportConfig, addr: &str) -> io::Result<TransportConfig> {
match transport {
TransportConfig::Tcp => Ok(TransportConfig::Tcp),
TransportConfig::Tls(mut cfg) => {
let ca_cert = cfg.ca_cert.take().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"TLS mode requires --ca-cert <path>",
)
})?;
match (&cfg.client_cert, &cfg.client_key) {
(Some(_), Some(_)) | (None, None) => {}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TLS client auth requires both --client-cert and --client-key",
));
}
}
let server_name = match cfg.server_name.take() {
Some(name) => name,
None => {
let host = parse_host_from_addr(addr).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"failed to parse host from address",
)
})?;
if host.parse::<IpAddr>().is_ok() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"TLS with IP address requires explicit --server-name",
));
}
host
}
};
Ok(TransportConfig::Tls(TlsConfig {
server_name: Some(server_name),
ca_cert: Some(ca_cert),
client_cert: cfg.client_cert,
client_key: cfg.client_key,
}))
}
}
}
#[derive(Debug, Default, Clone)]
struct SharedTarget {
inner: Arc<Mutex<TargetState>>,
}
#[derive(Debug)]
struct TargetState {
records: BTreeSet<Payload>,
timing: Option<Timing>,
announced_seen: u64,
withdrawn_seen: u64,
updates_applied_total: u64,
progress_every: u64,
apply_batches: u64,
last_apply_started_at: Option<Instant>,
}
impl Default for TargetState {
fn default() -> Self {
Self {
records: BTreeSet::new(),
timing: None,
announced_seen: 0,
withdrawn_seen: 0,
updates_applied_total: 0,
progress_every: DEFAULT_PROGRESS_EVERY,
apply_batches: 0,
last_apply_started_at: None,
}
}
}
#[derive(Debug, Clone)]
struct TargetSnapshot {
records: Vec<Payload>,
timing: Option<Timing>,
announced_seen: u64,
withdrawn_seen: u64,
updates_applied_total: u64,
apply_batches: u64,
}
impl SharedTarget {
fn new(progress_every: u64) -> Self {
let state = TargetState {
progress_every,
..TargetState::default()
};
Self {
inner: Arc::new(Mutex::new(state)),
}
}
fn snapshot(&self) -> TargetSnapshot {
let guard = self.inner.lock().expect("target mutex poisoned");
TargetSnapshot {
records: guard.records.iter().cloned().collect(),
timing: guard.timing,
announced_seen: guard.announced_seen,
withdrawn_seen: guard.withdrawn_seen,
updates_applied_total: guard.updates_applied_total,
apply_batches: guard.apply_batches,
}
}
fn payload_to_stable_text(payload: &Payload) -> String {
format!("{:?}", payload)
}
}
impl TargetSnapshot {
fn dump_text(&self) -> String {
self.records
.iter()
.map(SharedTarget::payload_to_stable_text)
.collect::<Vec<_>>()
.join("\n")
}
}
impl PayloadTarget for SharedTarget {
type Update = Vec<(Action, Payload)>;
fn start(&mut self, reset: bool) -> Self::Update {
if reset {
let mut guard = self.inner.lock().expect("target mutex poisoned");
println!(
"[target] start reset=true | clearing existing records={}",
guard.records.len()
);
guard.records.clear();
} else {
let guard = self.inner.lock().expect("target mutex poisoned");
println!(
"[target] start reset=false | current records={}",
guard.records.len()
);
}
Vec::new()
}
fn apply(&mut self, update: Self::Update, timing: Timing) -> Result<(), PayloadError> {
let total = update.len() as u64;
{
let mut guard = self.inner.lock().expect("target mutex poisoned");
guard.apply_batches += 1;
guard.last_apply_started_at = Some(Instant::now());
println!(
"[target] apply batch #{} started | updates={} | current records={}",
guard.apply_batches,
total,
guard.records.len()
);
}
let started = Instant::now();
let mut local_processed: u64 = 0;
let progress_every = {
let guard = self.inner.lock().expect("target mutex poisoned");
guard.progress_every
};
let mut guard = self.inner.lock().expect("target mutex poisoned");
for (action, payload) in update {
match action {
Action::Announce => {
guard.announced_seen += 1;
if !guard.records.insert(payload) {
return Err(PayloadError::DuplicateAnnounce);
}
}
Action::Withdraw => {
guard.withdrawn_seen += 1;
if !guard.records.remove(&payload) {
return Err(PayloadError::UnknownWithdraw);
}
}
}
local_processed += 1;
guard.updates_applied_total += 1;
if local_processed % progress_every == 0 || local_processed == total {
println!(
"[target] apply progress | batch_processed={}/{} | total_updates_seen={} | records={} | elapsed={:.2?}",
local_processed,
total,
guard.updates_applied_total,
guard.records.len(),
started.elapsed(),
);
}
}
guard.timing = Some(timing);
println!(
"[target] apply batch complete | updates={} | records={} | announced_seen={} | withdrawn_seen={} | elapsed={:.2?}",
total,
guard.records.len(),
guard.announced_seen,
guard.withdrawn_seen,
started.elapsed(),
);
Ok(())
}
}
fn print_step_summary(
step_no: usize,
before: &TargetSnapshot,
after: &TargetSnapshot,
print_records: bool,
) {
println!(
"[step] {} ok | records: {} -> {} | delta announce={} withdraw={} | delta updates={} | apply_batches={}",
step_no,
before.records.len(),
after.records.len(),
after.announced_seen.saturating_sub(before.announced_seen),
after.withdrawn_seen.saturating_sub(before.withdrawn_seen),
after
.updates_applied_total
.saturating_sub(before.updates_applied_total),
after.apply_batches,
);
if let Some(timing) = after.timing {
println!(
"[step] {} timing | refresh={} retry={} expire={}",
step_no, timing.refresh, timing.retry, timing.expire
);
}
if print_records {
println!("-- records after step {} --", step_no);
if after.records.is_empty() {
println!("(empty)");
} else {
for rec in &after.records {
println!("{}", SharedTarget::payload_to_stable_text(rec));
}
}
}
}
fn run_assertions(config: &Config, snapshot: &TargetSnapshot) -> io::Result<()> {
if let Some(min) = config.assert_min_records
&& snapshot.records.len() < min
{
return Err(io::Error::other(format!(
"assertion failed: records {} < {}",
snapshot.records.len(),
min
)));
}
if !config.assert_substr.is_empty() {
let dump = snapshot.dump_text();
for needle in &config.assert_substr {
if !dump.contains(needle) {
return Err(io::Error::other(format!(
"assertion failed: missing substring '{}'",
needle
)));
}
}
}
Ok(())
}
#[tokio::main]
async fn main() -> io::Result<()> {
let config = Config::from_args()?;
println!("== rpki-rs-test-client ==");
println!("target : {}", config.addr);
println!("steps : {}", config.steps);
println!("follow : {}", config.follow);
println!("step_timeout_secs : {}", config.step_timeout_secs);
println!("progress_every : {}", config.progress_every);
match &config.transport {
TransportConfig::Tcp => {
println!("transport : tcp");
}
TransportConfig::Tls(tls) => {
println!("transport : tls");
println!(
"server_name : {}",
tls.server_name.as_deref().unwrap_or("<unset>")
);
println!(
"ca_cert : {}",
tls.ca_cert
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<unset>".to_string())
);
}
}
let stream = connect_stream(&config).await?;
let target = SharedTarget::new(config.progress_every);
let inspect = target.clone();
let mut client = Client::new(stream, target, None);
for idx in 0..config.steps {
let step_no = idx + 1;
let before = inspect.snapshot();
println!("[step] {} begin", step_no);
let step_started = Instant::now();
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
format!(
"step {} timed out after {}s",
step_no, config.step_timeout_secs
),
)
})?
.map_err(|err| io::Error::new(err.kind(), format!("step {} failed: {}", step_no, err)))?;
let after = inspect.snapshot();
print_step_summary(step_no, &before, &after, config.print_records);
println!(
"[step] {} finished in {:.2?}",
step_no,
step_started.elapsed()
);
}
if config.follow {
let mut step_index = config.steps;
println!("[follow] enabled, entering continuous step loop");
loop {
step_index += 1;
let before = inspect.snapshot();
println!("[step] {} begin", step_index);
let step_started = Instant::now();
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
format!(
"step {} timed out after {}s",
step_index, config.step_timeout_secs
),
)
})?
.map_err(|err| {
io::Error::new(err.kind(), format!("step {} failed: {}", step_index, err))
})?;
let after = inspect.snapshot();
print_step_summary(step_index, &before, &after, config.print_records);
println!(
"[step] {} finished in {:.2?}",
step_index,
step_started.elapsed()
);
}
}
let state = client.state();
let final_snapshot = inspect.snapshot();
println!("state : {:?}", state);
if let Some(timing) = final_snapshot.timing {
println!(
"timing : refresh={} retry={} expire={}",
timing.refresh, timing.retry, timing.expire
);
}
println!("records : {}", final_snapshot.records.len());
println!(
"updates_seen : announce={} withdraw={}",
final_snapshot.announced_seen, final_snapshot.withdrawn_seen
);
println!(
"updates_applied : {}",
final_snapshot.updates_applied_total
);
println!("apply_batches : {}", final_snapshot.apply_batches);
run_assertions(&config, &final_snapshot)?;
println!("[assert] passed");
Ok(())
}
async fn connect_stream(config: &Config) -> io::Result<DynStream> {
match &config.transport {
TransportConfig::Tcp => Ok(Box::new(TcpStream::connect(&config.addr).await?)),
TransportConfig::Tls(tls) => connect_tls_stream(&config.addr, tls).await,
}
}
async fn connect_tls_stream(addr: &str, tls: &TlsConfig) -> io::Result<DynStream> {
let stream = TcpStream::connect(addr).await?;
let connector = build_tls_connector(tls)?;
let server_name_str = tls
.server_name
.as_ref()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS server name"))?;
let server_name = ServerName::try_from(server_name_str.clone()).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid TLS server name '{}': {}", server_name_str, err),
)
})?;
let tls_stream = connector.connect(server_name, stream).await.map_err(|err| {
io::Error::new(
io::ErrorKind::ConnectionAborted,
format!("TLS handshake failed: {}", err),
)
})?;
Ok(Box::new(tls_stream))
}
fn build_tls_connector(tls: &TlsConfig) -> io::Result<TlsConnector> {
let ca_cert_path = tls
.ca_cert
.as_ref()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS CA cert"))?;
let ca_certs = load_certs(ca_cert_path)?;
let mut roots = RootCertStore::empty();
let (added, _ignored) = roots.add_parsable_certificates(ca_certs);
if added == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("no valid CA certs in {}", ca_cert_path.display()),
));
}
let builder = RustlsClientConfig::builder().with_root_certificates(roots);
let cfg = match (&tls.client_cert, &tls.client_key) {
(Some(cert_path), Some(key_path)) => {
let certs = load_certs(cert_path)?;
let key = load_private_key(key_path)?;
builder.with_client_auth_cert(certs, key).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid client cert/key: {}", err),
)
})?
}
(None, None) => builder.with_no_client_auth(),
_ => unreachable!(),
};
Ok(TlsConnector::from(Arc::new(cfg)))
}
fn load_certs(path: &Path) -> io::Result<Vec<CertificateDer<'static>>> {
let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
let certs = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
if certs.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("no certs found in {}", path.display()),
));
}
Ok(certs)
}
fn load_private_key(path: &Path) -> io::Result<PrivateKeyDer<'static>> {
let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
rustls_pemfile::private_key(&mut reader)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("no private key in {}", path.display()),
)
})
}
fn parse_host_from_addr(addr: &str) -> Option<String> {
if let Some(rest) = addr.strip_prefix('[') {
return rest.split(']').next().map(str::to_string);
}
addr.rsplit_once(':').map(|(host, _)| host.to_string())
}
fn parse_usize_arg(value: &str, name: &str) -> io::Result<usize> {
value.parse::<usize>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {} '{}': {}", name, value, err),
)
})
}
fn parse_u64_arg(value: &str, name: &str) -> io::Result<u64> {
value.parse::<u64>().map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {} '{}': {}", name, value, err),
)
})
}

View File

@ -5,14 +5,20 @@ use std::time::Instant;
use anyhow::Result; use anyhow::Result;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use chrono::Utc; use chrono::Utc;
use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::{info, warn}; 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::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::report::{ReportConfiguration, ReportContext, current_rss_mib};
use rpki::rtr::server::{RtrNotifier, RtrService, RtrServiceStats, RunningRtrService}; use rpki::rtr::server::{RtrNotifier, RtrService, RtrServiceStats, RunningRtrService};
use rpki::rtr::store::RtrStore; use rpki::rtr::store::RtrStore;
use rpki::slurm::admin::SlurmAdmin;
use rpki::source::pipeline::{ use rpki::source::pipeline::{
PayloadLoadConfig, SourceFingerprint, latest_sources_fingerprint, PayloadLoadConfig, SourceFingerprint, latest_sources_fingerprint,
load_payloads_from_latest_sources_with_report, load_payloads_from_latest_sources_with_report,
@ -40,14 +46,29 @@ async fn main() -> Result<()> {
)); ));
let store = open_store(&config)?; let store = open_store(&config)?;
let shared_cache = init_shared_cache(&config, &store, &report_context)?; 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 service = RtrService::with_config(shared_cache.clone(), config.service_config.clone());
let notifier = service.notifier(); let notifier = service.notifier();
let service_stats = service.stats(); 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 running = start_servers(&config, &service);
let refresh_task = spawn_refresh_task( let refresh_task = spawn_refresh_task(
&config, &config,
runtime_config,
source_reload_rx,
shared_cache.clone(), shared_cache.clone(),
store.clone(), store.clone(),
notifier, notifier,
@ -62,6 +83,10 @@ async fn main() -> Result<()> {
refresh_task.abort(); refresh_task.abort();
let _ = refresh_task.await; let _ = refresh_task.await;
if let Some(admin_task) = admin_task {
admin_task.abort();
let _ = admin_task.await;
}
info!("RTR service stopped"); info!("RTR service stopped");
Ok(()) Ok(())
@ -171,23 +196,28 @@ fn start_servers(config: &AppConfig, service: &RtrService) -> RunningRtrService
fn spawn_refresh_task( fn spawn_refresh_task(
config: &AppConfig, config: &AppConfig,
runtime_config: RuntimeConfigHandle,
mut source_reload_rx: mpsc::Receiver<rpki::rtr::admin::SourceReloadCommand>,
shared_cache: SharedRtrCache, shared_cache: SharedRtrCache,
store: RtrStore, store: RtrStore,
notifier: RtrNotifier, notifier: RtrNotifier,
service_stats: RtrServiceStats, service_stats: RtrServiceStats,
report_context: ReportContext, report_context: ReportContext,
) -> JoinHandle<()> { ) -> 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 report_dir = PathBuf::from(&config.report_dir);
let payload_load_config = PayloadLoadConfig { let mut payload_load_config = PayloadLoadConfig {
ccr_dir: config.ccr_dir.clone(), ccr_dir: config.ccr_dir.clone(),
slurm_dir: config.slurm_dir.clone(), slurm_dir: config.slurm_dir.clone(),
strict_ccr_validation: config.strict_ccr_validation, strict_ccr_validation: config.strict_ccr_validation,
}; };
let initial_runtime_config = runtime_config.current();
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(refresh_interval); let mut active_runtime_config = initial_runtime_config;
let mut config_rx = runtime_config.subscribe();
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
active_runtime_config.source_refresh_interval_seconds,
));
let mut last_fingerprint: Option<SourceFingerprint> = None; let mut last_fingerprint: Option<SourceFingerprint> = None;
report_context.write_source_or_warn( report_context.write_source_or_warn(
&report_dir, &report_dir,
@ -211,14 +241,39 @@ fn spawn_refresh_task(
&service_stats, &service_stats,
); );
let mut runtime_interval = tokio::time::interval_at( let mut runtime_interval = tokio::time::interval_at(
tokio::time::Instant::now() + runtime_report_interval, tokio::time::Instant::now()
runtime_report_interval, + 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); runtime_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut client_change_rx = service_stats.subscribe_connection_changes(); let mut client_change_rx = service_stats.subscribe_connection_changes();
loop { loop {
tokio::select! { 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, &notifier, &service_stats);
}
Err(_) => {
warn!("RTR runtime config change channel closed");
}
}
continue;
}
changed = client_change_rx.changed() => { changed = client_change_rx.changed() => {
match changed { match changed {
Ok(()) => { Ok(()) => {
@ -236,168 +291,272 @@ fn spawn_refresh_task(
continue; continue;
} }
_ = interval.tick() => {} _ = interval.tick() => {}
} command = source_reload_rx.recv() => {
let source_to_delta_started = Instant::now(); let Some(command) = command else {
let attempted_at = Utc::now(); warn!("RTR source reload admin channel closed");
continue;
let current_fingerprint = match latest_sources_fingerprint(&payload_load_config) { };
Ok(fp) => fp, let result = perform_source_refresh(
Err(err) => { command.phase,
report_context.record_refresh_failure( command.force,
attempted_at, &payload_load_config,
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, &shared_cache,
&store,
&notifier, &notifier,
&service_stats, &service_stats,
&report_context,
&report_dir,
&mut last_fingerprint,
); );
let _ = command.respond_to.send(result.map_err(|err| err.to_string()));
continue; continue;
} }
};
report_context.record_source_fingerprint(current_fingerprint.clone());
if last_fingerprint.as_ref() == Some(&current_fingerprint) {
report_context.record_refresh_unchanged(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
);
info!(
"RTR source refresh skipped: source files unchanged (ccr_path={}, slurm_file_count={}, elapsed_ms={})",
current_fingerprint.ccr.path,
current_fingerprint.slurm_files.len(),
source_to_delta_started.elapsed().as_millis()
);
log_cache_memory_stats("refresh_skipped_unchanged", &shared_cache, &notifier);
report_context.write_source_or_warn(
&report_dir,
"refresh_skipped_unchanged",
&shared_cache,
&notifier,
&service_stats,
);
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,
&notifier,
&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, &notifier);
report_context.write_source_or_warn(
&report_dir,
"refresh_complete",
&shared_cache,
&notifier,
&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,
&notifier,
&service_stats,
);
}
} }
let _ = perform_source_refresh(
"refresh_complete",
false,
&payload_load_config,
&shared_cache,
&store,
&notifier,
&service_stats,
&report_context,
&report_dir,
&mut last_fingerprint,
);
} }
}) })
} }
#[allow(clippy::too_many_arguments)]
fn perform_source_refresh(
phase: &'static str,
force: bool,
payload_load_config: &PayloadLoadConfig,
shared_cache: &SharedRtrCache,
store: &RtrStore,
notifier: &RtrNotifier,
service_stats: &RtrServiceStats,
report_context: &ReportContext,
report_dir: &PathBuf,
last_fingerprint: &mut Option<SourceFingerprint>,
) -> Result<SourceReloadResult> {
let source_to_delta_started = Instant::now();
let attempted_at = Utc::now();
let current_fingerprint = match latest_sources_fingerprint(payload_load_config) {
Ok(fp) => fp,
Err(err) => {
report_context.record_refresh_failure(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
&err,
);
warn!(
"failed to fingerprint CCR/SLURM sources from {}: {:?} (source_to_delta_elapsed_ms={})",
payload_load_config.ccr_dir,
err,
source_to_delta_started.elapsed().as_millis()
);
report_context.write_source_or_warn(
report_dir,
"refresh_failed",
shared_cache,
notifier,
service_stats,
);
return Err(err);
}
};
report_context.record_source_fingerprint(current_fingerprint.clone());
if !force && last_fingerprint.as_ref() == Some(&current_fingerprint) {
report_context
.record_refresh_unchanged(attempted_at, source_to_delta_started.elapsed().as_millis());
info!(
"RTR source refresh skipped: source files unchanged (ccr_path={}, slurm_file_count={}, elapsed_ms={})",
current_fingerprint.ccr.path,
current_fingerprint.slurm_files.len(),
source_to_delta_started.elapsed().as_millis()
);
log_cache_memory_stats("refresh_skipped_unchanged", shared_cache, notifier);
report_context.write_source_or_warn(
report_dir,
"refresh_skipped_unchanged",
shared_cache,
notifier,
service_stats,
);
return Ok(SourceReloadResult {
phase,
changed: false,
skipped_unchanged: true,
payload_count: None,
serials: shared_cache.load_full().serials(),
});
}
let load = match load_payloads_from_latest_sources_with_report(payload_load_config) {
Ok(load) => load,
Err(err) => {
report_context.record_refresh_failure(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
&err,
);
warn!(
"failed to reload CCR/SLURM payloads from {}: {:?} (source_to_delta_elapsed_ms={})",
payload_load_config.ccr_dir,
err,
source_to_delta_started.elapsed().as_millis()
);
report_context.write_source_or_warn(
report_dir,
"refresh_failed",
shared_cache,
notifier,
service_stats,
);
return Err(err);
}
};
let source = load.source;
let quality = load.quality;
let payloads = load.payloads;
let payload_count = payloads.len();
let source_snapshot = Snapshot::from_payloads(payloads);
let old_cache = shared_cache.load_full();
let old_serial = old_cache.serial_for_version(2);
let mut next_cache = old_cache.as_ref().clone();
let updated = match next_cache.update_with_snapshot(source_snapshot, store) {
Ok(()) => {
let new_serial = next_cache.serial_for_version(2);
shared_cache.store(std::sync::Arc::new(next_cache));
if new_serial != old_serial {
info!(
"RTR cache refresh applied: ccr_dir={}, payload_count={}, old_serial={}, new_serial={}",
payload_load_config.ccr_dir, payload_count, old_serial, new_serial
);
true
} else {
info!(
"RTR cache refresh found no change: ccr_dir={}, payload_count={}, serial={}",
payload_load_config.ccr_dir, payload_count, old_serial
);
false
}
}
Err(err) => {
report_context.record_refresh_failure(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
&err,
);
warn!("RTR cache update failed: {:?}", err);
report_context.write_source_or_warn(
report_dir,
"refresh_failed",
shared_cache,
notifier,
service_stats,
);
return Err(err);
}
};
report_context.record_refresh_success(
attempted_at,
source_to_delta_started.elapsed().as_millis(),
updated,
source,
quality,
);
info!(
"RTR source-to-delta timing: phase={}, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}",
phase,
payload_load_config.ccr_dir,
payload_count,
updated,
source_to_delta_started.elapsed().as_millis()
);
if updated {
let listener_count = notifier.notify_cache_updated();
info!(
"RTR cache updated, notify signal emitted to session listeners: listener_count={}",
listener_count
);
}
log_cache_memory_stats(phase, shared_cache, notifier);
report_context.write_source_or_warn(report_dir, phase, shared_cache, notifier, service_stats);
last_fingerprint.replace(current_fingerprint);
Ok(SourceReloadResult {
phase,
changed: updated,
skipped_unchanged: false,
payload_count: Some(payload_count),
serials: shared_cache.load_full().serials(),
})
}
fn apply_runtime_config_update(
active: &mut RuntimeConfig,
next: RuntimeConfig,
shared_cache: &SharedRtrCache,
store: &RtrStore,
report_context: &ReportContext,
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) { fn log_cache_memory_stats(phase: &str, shared_cache: &SharedRtrCache, notifier: &RtrNotifier) {
let cache = shared_cache.load_full(); let cache = shared_cache.load_full();
let stats = cache.memory_stats(); let stats = cache.memory_stats();

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

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

85
src/rtr/cache/core.rs vendored
View File

@ -277,12 +277,20 @@ impl RtrCache {
max_delta: u8, max_delta: u8,
prune_delta_by_snapshot_size: bool, prune_delta_by_snapshot_size: bool,
delta: Arc<Delta>, delta: Arc<Delta>,
) {
state.deltas.push_back(delta);
Self::prune_delta_window(state, max_delta, prune_delta_by_snapshot_size);
}
fn prune_delta_window(
state: &mut VersionState,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
) { ) {
let max_keep = usize::from(max_delta.max(1)); 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.pop_front();
} }
state.deltas.push_back(delta);
let mut dropped_serials = Vec::new(); let mut dropped_serials = Vec::new();
if prune_delta_by_snapshot_size { if prune_delta_by_snapshot_size {
let snapshot_wire_size = estimate_snapshot_payload_wire_size(state.snapshot.as_ref()); 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 { pub fn is_data_available(&self) -> bool {
self.availability == CacheAvailability::Ready self.availability == CacheAvailability::Ready
} }
@ -469,6 +494,62 @@ impl RtrCache {
self.timing self.timing
} }
pub fn max_delta(&self) -> u8 {
self.max_delta
}
pub fn prune_delta_by_snapshot_size(&self) -> bool {
self.prune_delta_by_snapshot_size
}
pub(super) fn apply_runtime_config(
&mut self,
max_delta: u8,
prune_delta_by_snapshot_size: bool,
timing: Timing,
) -> Option<AppliedUpdate> {
let old_delta_lengths = self.delta_lengths();
let old_max_delta = self.max_delta;
let old_prune_delta_by_snapshot_size = self.prune_delta_by_snapshot_size;
let old_timing = self.timing;
self.max_delta = max_delta.max(1);
self.prune_delta_by_snapshot_size = prune_delta_by_snapshot_size;
self.timing = timing;
for state in &mut self.versions {
Self::prune_delta_window(state, self.max_delta, self.prune_delta_by_snapshot_size);
}
let config_changed = old_max_delta != self.max_delta
|| old_prune_delta_by_snapshot_size != self.prune_delta_by_snapshot_size
|| old_timing.refresh != self.timing.refresh
|| old_timing.retry != self.timing.retry
|| old_timing.expire != self.timing.expire;
let delta_changed = old_delta_lengths != self.delta_lengths();
if config_changed {
info!(
"RTR cache runtime config updated: old_max_delta={}, new_max_delta={}, old_prune_delta_by_snapshot_size={}, new_prune_delta_by_snapshot_size={}, old_delta_lengths={:?}, new_delta_lengths={:?}, timing=({}, {}, {})",
old_max_delta,
self.max_delta,
old_prune_delta_by_snapshot_size,
self.prune_delta_by_snapshot_size,
old_delta_lengths,
self.delta_lengths(),
self.timing.refresh,
self.timing.retry,
self.timing.expire
);
}
if delta_changed {
Some(self.applied_update_with_existing_windows())
} else {
None
}
}
pub fn last_update_begin(&self) -> DualTime { pub fn last_update_begin(&self) -> DualTime {
self.last_update_begin.clone() self.last_update_begin.clone()
} }

View File

@ -82,11 +82,7 @@ pub struct Snapshot {
} }
impl Snapshot { impl Snapshot {
pub fn new( pub fn new(origins: Vec<RouteOrigin>, router_keys: Vec<RouterKey>, aspas: Vec<Aspa>) -> Self {
origins: Vec<RouteOrigin>,
router_keys: Vec<RouterKey>,
aspas: Vec<Aspa>,
) -> Self {
Self::from_shared_parts( Self::from_shared_parts(
Arc::new(sorted_dedup(origins)), Arc::new(sorted_dedup(origins)),
Arc::new(sorted_dedup(router_keys)), Arc::new(sorted_dedup(router_keys)),
@ -443,9 +439,7 @@ where
let mut normalized = by_customer let mut normalized = by_customer
.into_iter() .into_iter()
.map(|(customer_asn, providers)| { .map(|(customer_asn, providers)| Aspa::new(customer_asn.into(), providers))
Aspa::new(customer_asn.into(), providers)
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
normalized.sort(); normalized.sort();
normalized normalized

View File

@ -106,6 +106,20 @@ impl RtrCache {
} }
Ok(()) 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( fn try_restore_from_store(

View File

@ -4,6 +4,7 @@ use std::time::Duration;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use chrono_tz::Tz; use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::rtr::payload::Timing; use crate::rtr::payload::Timing;
@ -40,10 +41,50 @@ pub struct AppConfig {
pub report_history_limit: usize, pub report_history_limit: usize,
pub timezone: Tz, pub timezone: Tz,
pub timing: Timing, pub timing: Timing,
pub admin_addr: Option<SocketAddr>,
pub admin_token: Option<String>,
pub service_config: RtrServiceConfig, pub service_config: RtrServiceConfig,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeConfig {
pub max_delta: u8,
pub prune_delta_by_snapshot_size: bool,
pub source_refresh_interval_seconds: u64,
pub runtime_report_interval_seconds: u64,
pub report_history_limit: usize,
pub strict_ccr_validation: bool,
pub timezone: String,
pub timing: RuntimeTimingConfig,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeTimingConfig {
pub refresh: u32,
pub retry: u32,
pub expire: u32,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RuntimeConfigPatch {
pub max_delta: Option<u8>,
pub prune_delta_by_snapshot_size: Option<bool>,
pub source_refresh_interval_seconds: Option<u64>,
pub runtime_report_interval_seconds: Option<u64>,
pub report_history_limit: Option<usize>,
pub strict_ccr_validation: Option<bool>,
pub timezone: Option<String>,
pub timing: Option<RuntimeTimingConfigPatch>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
pub struct RuntimeTimingConfigPatch {
pub refresh: Option<u32>,
pub retry: Option<u32>,
pub expire: Option<u32>,
}
impl Default for AppConfig { impl Default for AppConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -75,6 +116,8 @@ impl Default for AppConfig {
report_history_limit: 10, report_history_limit: 10,
timezone: default_timezone(), timezone: default_timezone(),
timing: Timing::default(), timing: Timing::default(),
admin_addr: None,
admin_token: None,
service_config: RtrServiceConfig { service_config: RtrServiceConfig {
max_connections: 512, max_connections: 512,
@ -201,6 +244,19 @@ impl AppConfig {
if let Some(value) = env_var("RPKI_RTR_TIMEZONE")? { if let Some(value) = env_var("RPKI_RTR_TIMEZONE")? {
config.timezone = parse_timezone(&value, "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_new = env_var("RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?;
let source_refresh_interval_legacy = env_var("RPKI_RTR_REFRESH_INTERVAL_SECS")?; let source_refresh_interval_legacy = env_var("RPKI_RTR_REFRESH_INTERVAL_SECS")?;
@ -307,6 +363,108 @@ impl AppConfig {
Ok(config) Ok(config)
} }
pub fn runtime_config(&self) -> RuntimeConfig {
RuntimeConfig {
max_delta: self.max_delta,
prune_delta_by_snapshot_size: self.prune_delta_by_snapshot_size,
source_refresh_interval_seconds: self.source_refresh_interval.as_secs(),
runtime_report_interval_seconds: self.runtime_report_interval.as_secs(),
report_history_limit: self.report_history_limit,
strict_ccr_validation: self.strict_ccr_validation,
timezone: format_timezone(self.timezone),
timing: RuntimeTimingConfig::from(self.timing),
}
}
}
impl RuntimeConfig {
pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result<Self> {
let mut next = self.clone();
if let Some(max_delta) = patch.max_delta {
if max_delta == 0 {
return Err(anyhow!("invalid max_delta '{}': must be >= 1", max_delta));
}
next.max_delta = max_delta;
}
if let Some(value) = patch.prune_delta_by_snapshot_size {
next.prune_delta_by_snapshot_size = value;
}
if let Some(value) = patch.source_refresh_interval_seconds {
if value == 0 {
return Err(anyhow!(
"invalid source_refresh_interval_seconds '{}': must be >= 1",
value
));
}
next.source_refresh_interval_seconds = value;
}
if let Some(value) = patch.runtime_report_interval_seconds {
if value == 0 {
return Err(anyhow!(
"invalid runtime_report_interval_seconds '{}': must be >= 1",
value
));
}
next.runtime_report_interval_seconds = value;
}
if let Some(value) = patch.report_history_limit {
if value == 0 {
return Err(anyhow!(
"invalid report_history_limit '{}': must be >= 1",
value
));
}
next.report_history_limit = value;
}
if let Some(value) = patch.strict_ccr_validation {
next.strict_ccr_validation = value;
}
if let Some(value) = patch.timezone {
next.timezone = format_timezone(parse_timezone(&value, "timezone")?);
}
if let Some(timing) = patch.timing {
let mut next_timing = Timing::from(next.timing);
if let Some(value) = timing.refresh {
next_timing.refresh = value;
}
if let Some(value) = timing.retry {
next_timing.retry = value;
}
if let Some(value) = timing.expire {
next_timing.expire = value;
}
next_timing
.validate()
.map_err(|err| anyhow!("invalid timing: {}", err))?;
next.timing = RuntimeTimingConfig::from(next_timing);
}
Ok(next)
}
pub fn timezone(&self) -> Result<Tz> {
parse_timezone(&self.timezone, "timezone")
}
pub fn timing(&self) -> Timing {
Timing::from(self.timing)
}
}
impl From<Timing> for RuntimeTimingConfig {
fn from(timing: Timing) -> Self {
Self {
refresh: timing.refresh,
retry: timing.retry,
expire: timing.expire,
}
}
}
impl From<RuntimeTimingConfig> for Timing {
fn from(timing: RuntimeTimingConfig) -> Self {
Self::new(timing.refresh, timing.retry, timing.expire)
}
} }
pub fn log_startup_config(config: &AppConfig) { 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_refresh_secs={}", config.timing.refresh);
info!("rtr_timing_retry_secs={}", config.timing.retry); info!("rtr_timing_retry_secs={}", config.timing.retry);
info!("rtr_timing_expire_secs={}", config.timing.expire); 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_connections={}", config.service_config.max_connections);
info!( info!(
"max_concurrent_handshakes={}", "max_concurrent_handshakes={}",
@ -441,7 +607,7 @@ fn parse_positive_u32(value: &str, name: &str) -> Result<u32> {
Ok(parsed) Ok(parsed)
} }
fn parse_timezone(value: &str, name: &str) -> Result<Tz> { pub fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
let value = value.trim(); let value = value.trim();
let normalized = match value.to_ascii_lowercase().as_str() { let normalized = match value.to_ascii_lowercase().as_str() {
"shanghai" | "beijing" | "peking" => "Asia/Shanghai", "shanghai" | "beijing" | "peking" => "Asia/Shanghai",
@ -458,37 +624,3 @@ fn parse_timezone(value: &str, name: &str) -> Result<Tz> {
) )
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_timezone_accepts_iana_names_and_common_aliases() {
assert_eq!(
parse_timezone("Asia/Shanghai", "TEST").unwrap(),
chrono_tz::Asia::Shanghai
);
assert_eq!(
parse_timezone("shanghai", "TEST").unwrap(),
chrono_tz::Asia::Shanghai
);
assert_eq!(
parse_timezone("Europe/London", "TEST").unwrap(),
chrono_tz::Europe::London
);
assert_eq!(
parse_timezone("America/New_York", "TEST").unwrap(),
chrono_tz::America::New_York
);
assert_eq!(parse_timezone("UTC", "TEST").unwrap(), chrono_tz::UTC);
assert_eq!(parse_timezone("Z", "TEST").unwrap(), chrono_tz::UTC);
}
#[test]
fn parse_timezone_rejects_offset_and_invalid_values() {
assert!(parse_timezone("+08:00", "TEST").is_err());
assert!(parse_timezone("+0800", "TEST").is_err());
assert!(parse_timezone("Mars/Base", "TEST").is_err());
}
}

View File

@ -1,3 +1,4 @@
pub mod admin;
pub mod cache; pub mod cache;
pub mod config; pub mod config;
pub mod error_type; pub mod error_type;

View File

@ -10,7 +10,7 @@ use serde::Serialize;
use tracing::warn; use tracing::warn;
use crate::rtr::cache::{CacheAvailability, CacheMemoryStats, SharedRtrCache, VersionReportStats}; 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::rtr::server::{RtrNotifier, RtrServiceStats, RtrTransportConnectionCounts};
use crate::source::pipeline::{ use crate::source::pipeline::{
DataQualityReport, FileFingerprint, SourceFingerprint, SourceLoadReport, DataQualityReport, FileFingerprint, SourceFingerprint, SourceLoadReport,
@ -73,8 +73,7 @@ impl ReportConfiguration {
pub struct ReportContext { pub struct ReportContext {
started_at: DateTime<Utc>, started_at: DateTime<Utc>,
started_instant: Instant, started_instant: Instant,
timezone: Tz, configuration: Arc<RwLock<ReportConfiguration>>,
configuration: ReportConfiguration,
runtime: Arc<RwLock<RuntimeReportState>>, runtime: Arc<RwLock<RuntimeReportState>>,
} }
@ -148,16 +147,38 @@ impl Default for RefreshReport {
impl ReportContext { impl ReportContext {
pub fn new(configuration: ReportConfiguration) -> Self { pub fn new(configuration: ReportConfiguration) -> Self {
let timezone = configuration.timezone();
Self { Self {
started_at: Utc::now(), started_at: Utc::now(),
started_instant: Instant::now(), started_instant: Instant::now(),
timezone, configuration: Arc::new(RwLock::new(configuration)),
configuration,
runtime: Arc::new(RwLock::new(RuntimeReportState::default())), 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( pub fn record_refresh_success(
&self, &self,
attempted_at: DateTime<Utc>, attempted_at: DateTime<Utc>,
@ -365,6 +386,12 @@ impl ReportContext {
service_stats: &RtrServiceStats, service_stats: &RtrServiceStats,
) -> ReportParts { ) -> ReportParts {
let cache = shared_cache.load_full(); 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 let runtime = self
.runtime .runtime
.read() .read()
@ -377,14 +404,14 @@ impl ReportContext {
let active_connections = service_stats.active_connections(); let active_connections = service_stats.active_connections();
let connections_by_transport = service_stats.transport_connections(); let connections_by_transport = service_stats.transport_connections();
let max_connections = service_stats.max_connections(); let max_connections = service_stats.max_connections();
let generated_at = self.report_now(); let generated_at = self.report_now(timezone);
let metadata = ReportMetadata { let metadata = ReportMetadata {
schema_version: 2, schema_version: 2,
generated_at, generated_at,
phase: phase.to_string(), phase: phase.to_string(),
}; };
let service = ServiceReport { 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(), uptime_seconds: self.started_instant.elapsed().as_secs(),
active_connections, active_connections,
connections_by_transport: TransportConnectionReport::from(connections_by_transport), connections_by_transport: TransportConnectionReport::from(connections_by_transport),
@ -397,13 +424,13 @@ impl ReportContext {
}; };
let source = runtime let source = runtime
.source .source
.map(|source| SourceLoadReportView::from_report(source, self.timezone)); .map(|source| SourceLoadReportView::from_report(source, timezone));
let refresh = RefreshReportView::from_report(runtime.refresh, self.timezone); let refresh = RefreshReportView::from_report(runtime.refresh, timezone);
let cache = CacheReport { let cache = CacheReport {
availability, availability,
created_at: self.to_report_time(cache.created_at().utc()), created_at: self.to_report_time(cache.created_at().utc(), timezone),
last_update_begin: self.to_report_time(cache.last_update_begin().utc()), 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()), last_update_end: self.to_report_time(cache.last_update_end().utc(), timezone),
memory: cache.memory_stats(), memory: cache.memory_stats(),
versions: cache.version_report_stats(), versions: cache.version_report_stats(),
}; };
@ -414,7 +441,7 @@ impl ReportContext {
phase: metadata.phase.clone(), phase: metadata.phase.clone(),
source, source,
source_fingerprint: runtime.source_fingerprint.map(|fingerprint| { source_fingerprint: runtime.source_fingerprint.map(|fingerprint| {
SourceFingerprintReport::from_fingerprint(fingerprint, self.timezone) SourceFingerprintReport::from_fingerprint(fingerprint, timezone)
}), }),
refresh, refresh,
data_quality: runtime.data_quality, data_quality: runtime.data_quality,
@ -440,7 +467,7 @@ impl ReportContext {
phase: metadata.phase, phase: metadata.phase,
service, service,
process, process,
configuration: self.configuration.clone(), configuration,
}; };
let suffix = report_file_suffix(generated_at); let suffix = report_file_suffix(generated_at);
@ -462,7 +489,7 @@ impl ReportContext {
report_dir, report_dir,
"rtr-source", "rtr-source",
suffix, suffix,
self.configuration.report_history_limit, self.report_history_limit(),
report, report,
) )
} }
@ -477,7 +504,7 @@ impl ReportContext {
report_dir, report_dir,
"rtr-clients", "rtr-clients",
suffix, suffix,
self.configuration.report_history_limit, self.report_history_limit(),
report, report,
) )
} }
@ -492,17 +519,24 @@ impl ReportContext {
report_dir, report_dir,
"rtr-runtime", "rtr-runtime",
suffix, suffix,
self.configuration.report_history_limit, self.report_history_limit(),
report, report,
) )
} }
fn report_now(&self) -> DateTime<Tz> { fn report_history_limit(&self) -> usize {
self.to_report_time(Utc::now()) self.configuration
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.report_history_limit
} }
fn to_report_time(&self, time: DateTime<Utc>) -> DateTime<Tz> { fn report_now(&self, timezone: Tz) -> DateTime<Tz> {
time.with_timezone(&self.timezone) self.to_report_time(Utc::now(), timezone)
}
fn to_report_time(&self, time: DateTime<Utc>, timezone: Tz) -> DateTime<Tz> {
time.with_timezone(&timezone)
} }
} }
@ -745,308 +779,3 @@ fn prune_rolling_reports(report_dir: &Path, prefix: &str, keep: usize) -> Result
} }
Ok(()) 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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&service.stats(),
)
.unwrap();
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 1);
}
fn assert_report_time_offset(value: &Value) {
let value = value.as_str().expect("report time should be a string");
assert!(
value.ends_with("+08:00"),
"report time should use +08:00 offset, got {value}"
);
}
fn read_single_report(report_dir: &Path, prefix: &str) -> Value {
let files = report_files(report_dir, prefix);
assert_eq!(files.len(), 1, "expected one {prefix} report");
serde_json::from_slice(&fs::read(&files[0]).unwrap()).unwrap()
}
fn report_files(report_dir: &Path, prefix: &str) -> Vec<PathBuf> {
let start = format!("{prefix}-");
let mut files = fs::read_dir(report_dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(&start) && name.ends_with(".json"))
})
.collect::<Vec<_>>();
files.sort();
files
}
fn assert_no_temporary_reports(report_dir: &Path) {
let has_temporary = fs::read_dir(report_dir).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.to_str()
.is_some_and(|name| name.ends_with(".tmp"))
});
assert!(!has_temporary);
}
}

View File

@ -947,7 +947,12 @@ where
Ok(()) 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 version = self.version()?;
let end = EndOfData::new(version, session_id, serial, timing)?; let end = EndOfData::new(version, session_id, serial, timing)?;
@ -1117,12 +1122,7 @@ where
Ok(()) Ok(())
} }
async fn send_aspa_to<W>( async fn send_aspa_to<W>(writer: &mut W, aspa: &Aspa, announce: bool, version: u8) -> Result<()>
writer: &mut W,
aspa: &Aspa,
announce: bool,
version: u8,
) -> Result<()>
where where
W: AsyncWrite + Unpin, W: AsyncWrite + Unpin,
{ {

View File

@ -1,5 +1,5 @@
use anyhow::{anyhow, Result}; use anyhow::{Result, anyhow};
use rocksdb::{ColumnFamilyDescriptor, IteratorMode, Options, WriteBatch, DB}; use rocksdb::{ColumnFamilyDescriptor, DB, IteratorMode, Options, WriteBatch};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use std::borrow::Borrow; use std::borrow::Borrow;
use std::path::Path; use std::path::Path;
@ -397,82 +397,3 @@ fn validate_delta_window(deltas: &[Delta], min_serial: u32, max_serial: u32) ->
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use std::net::Ipv4Addr;
use crate::data_model::resources::as_resources::Asn;
use crate::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
use crate::rtr::cache::Snapshot;
use crate::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski};
use super::*;
fn mixed_snapshot() -> Snapshot {
Snapshot::from_payloads(vec![
Payload::RouteOrigin(RouteOrigin::new(
IPAddressPrefix::new(IPAddress::from_ipv4(Ipv4Addr::new(192, 0, 2, 0)), 24),
24,
Asn::from(64496),
)),
Payload::RouterKey(RouterKey::new(
Ski::default(),
Asn::from(64497),
vec![1, 2, 3],
)),
Payload::Aspa(Aspa::new(Asn::from(64498), vec![Asn::from(64499)])),
])
}
#[test]
fn save_cache_state_persists_single_canonical_snapshot() {
let temp = tempfile::tempdir().unwrap();
let store = RtrStore::open(temp.path()).unwrap();
let source = mixed_snapshot();
let snapshots = std::array::from_fn(|version| source.project_for_version(version as u8));
let deltas: [Option<&Delta>; 3] = [None, None, None];
let windows: [Option<(u32, u32)>; 3] = [None, None, None];
let clear = [true, true, true];
store
.save_cache_state_versioned(
CacheAvailability::Ready,
&snapshots,
&[1, 2, 3],
&[10, 11, 12],
&deltas,
&windows,
&clear,
)
.unwrap();
assert!(store
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(0))
.unwrap()
.is_none());
assert!(store
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(1))
.unwrap()
.is_none());
assert!(store
.get_cf::<Snapshot>(CF_SNAPSHOT, &snapshot_key(2))
.unwrap()
.is_some());
let restored_v0 = store.get_snapshot_for_version(0).unwrap().unwrap();
assert_eq!(restored_v0.origins().len(), 1);
assert_eq!(restored_v0.router_keys().len(), 0);
assert_eq!(restored_v0.aspas().len(), 0);
let restored_v1 = store.get_snapshot_for_version(1).unwrap().unwrap();
assert_eq!(restored_v1.origins().len(), 1);
assert_eq!(restored_v1.router_keys().len(), 1);
assert_eq!(restored_v1.aspas().len(), 0);
let restored_v2 = store.get_snapshot_for_version(2).unwrap().unwrap();
assert_eq!(restored_v2.origins().len(), 1);
assert_eq!(restored_v2.router_keys().len(), 1);
assert_eq!(restored_v2.aspas().len(), 1);
}
}

391
src/slurm/admin.rs Normal file
View File

@ -0,0 +1,391 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use super::file::SlurmFile;
const MAX_SLURM_BODY_BYTES: usize = 5 * 1024 * 1024;
#[derive(Debug, Deserialize)]
pub struct SlurmFileWriteRequest {
pub name: Option<String>,
pub content: String,
pub reload: Option<bool>,
}
#[derive(Debug, Deserialize, Default)]
pub struct SlurmFileActionRequest {
pub reload: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct SlurmFileOperationResult {
pub operation: &'static str,
pub file: String,
pub backup: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SlurmFileListEntry {
pub name: String,
pub path: String,
pub enabled: bool,
pub size_bytes: u64,
pub modified_unix_seconds: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct SlurmFileContent {
pub name: String,
pub path: String,
pub enabled: bool,
pub size_bytes: u64,
pub modified_unix_seconds: Option<u64>,
pub content: String,
}
pub struct AppliedSlurmFileOperation {
pub result: SlurmFileOperationResult,
undo: UndoAction,
}
impl AppliedSlurmFileOperation {
pub fn rollback(self) -> Result<()> {
self.undo.apply()
}
}
#[derive(Debug)]
enum UndoAction {
Delete { path: PathBuf },
Restore { path: PathBuf, backup: PathBuf },
Rename { from: PathBuf, to: PathBuf },
}
impl UndoAction {
fn apply(self) -> Result<()> {
match self {
Self::Delete { path } => {
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("remove rollback file {}", path.display()))?;
}
}
Self::Restore { path, backup } => {
fs::copy(&backup, &path).with_context(|| {
format!(
"restore rollback file {} from {}",
path.display(),
backup.display()
)
})?;
}
Self::Rename { from, to } => {
if from.exists() {
fs::rename(&from, &to).with_context(|| {
format!("rollback rename {} to {}", from.display(), to.display())
})?;
}
}
}
Ok(())
}
}
#[derive(Clone)]
pub struct SlurmAdmin {
dir: PathBuf,
}
impl SlurmAdmin {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn max_body_bytes(&self) -> usize {
MAX_SLURM_BODY_BYTES
}
pub fn list_files(&self) -> Result<Vec<SlurmFileListEntry>> {
let mut files = Vec::new();
if !self.dir.exists() {
return Ok(files);
}
for entry in fs::read_dir(&self.dir)
.with_context(|| format!("read SLURM directory {}", self.dir.display()))?
{
let entry = entry.with_context(|| format!("read entry in {}", self.dir.display()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if validate_slurm_file_name(name).is_err() {
continue;
}
let metadata = entry
.metadata()
.with_context(|| format!("read SLURM file metadata {}", path.display()))?;
files.push(SlurmFileListEntry {
name: name.to_string(),
path: path.to_string_lossy().to_string(),
enabled: name.ends_with(".slurm"),
size_bytes: metadata.len(),
modified_unix_seconds: modified_unix_seconds(&metadata),
});
}
files.sort_by(|left, right| left.name.cmp(&right.name));
Ok(files)
}
pub fn read_file(&self, name: &str) -> Result<SlurmFileContent> {
validate_slurm_file_name(name)?;
let path = self.file_path(name);
if !path.is_file() {
return Err(anyhow!("SLURM file '{}' does not exist", name));
}
let metadata = fs::metadata(&path)
.with_context(|| format!("read SLURM file metadata {}", path.display()))?;
let content = fs::read_to_string(&path)
.with_context(|| format!("read SLURM file {}", path.display()))?;
Ok(SlurmFileContent {
name: name.to_string(),
path: path.to_string_lossy().to_string(),
enabled: name.ends_with(".slurm"),
size_bytes: metadata.len(),
modified_unix_seconds: modified_unix_seconds(&metadata),
content,
})
}
pub fn put_file(
&self,
name: &str,
content: &str,
operation: &'static str,
) -> Result<AppliedSlurmFileOperation> {
validate_slurm_file_name(name)?;
validate_slurm_content(content)?;
fs::create_dir_all(&self.dir)
.with_context(|| format!("create SLURM directory {}", self.dir.display()))?;
let path = self.file_path(name);
let backup = if path.exists() {
Some(self.backup_file(&path)?)
} else {
None
};
let temporary = self.temporary_file(name);
fs::write(&temporary, content.as_bytes())
.with_context(|| format!("write temporary SLURM file {}", temporary.display()))?;
replace_file(&temporary, &path)?;
let undo = match backup.as_ref() {
Some(backup) => UndoAction::Restore {
path: path.clone(),
backup: backup.clone(),
},
None => UndoAction::Delete { path: path.clone() },
};
Ok(AppliedSlurmFileOperation {
result: SlurmFileOperationResult {
operation,
file: path.to_string_lossy().to_string(),
backup: backup.map(|path| path.to_string_lossy().to_string()),
},
undo,
})
}
pub fn delete_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
validate_slurm_file_name(name)?;
let path = self.file_path(name);
if !path.is_file() {
return Err(anyhow!("SLURM file '{}' does not exist", name));
}
let backup = self.backup_file(&path)?;
fs::remove_file(&path).with_context(|| format!("delete SLURM file {}", path.display()))?;
Ok(AppliedSlurmFileOperation {
result: SlurmFileOperationResult {
operation: "delete",
file: path.to_string_lossy().to_string(),
backup: Some(backup.to_string_lossy().to_string()),
},
undo: UndoAction::Restore {
path,
backup: backup.clone(),
},
})
}
pub fn enable_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
validate_slurm_file_name(name)?;
if !name.ends_with(".slurm.disabled") {
return Err(anyhow!(
"SLURM file '{}' is not disabled; expected .slurm.disabled",
name
));
}
let enabled_name = name.trim_end_matches(".disabled");
self.rename_file(name, enabled_name, "enable")
}
pub fn disable_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
validate_slurm_file_name(name)?;
if !name.ends_with(".slurm") {
return Err(anyhow!(
"SLURM file '{}' is not enabled; expected .slurm",
name
));
}
let disabled_name = format!("{name}.disabled");
self.rename_file(name, &disabled_name, "disable")
}
fn rename_file(
&self,
from_name: &str,
to_name: &str,
operation: &'static str,
) -> Result<AppliedSlurmFileOperation> {
validate_slurm_file_name(to_name)?;
let from = self.file_path(from_name);
let to = self.file_path(to_name);
if !from.is_file() {
return Err(anyhow!("SLURM file '{}' does not exist", from_name));
}
if to.exists() {
return Err(anyhow!("target SLURM file '{}' already exists", to_name));
}
fs::rename(&from, &to)
.with_context(|| format!("rename SLURM file {} to {}", from.display(), to.display()))?;
Ok(AppliedSlurmFileOperation {
result: SlurmFileOperationResult {
operation,
file: to.to_string_lossy().to_string(),
backup: None,
},
undo: UndoAction::Rename { from: to, to: from },
})
}
fn file_path(&self, name: &str) -> PathBuf {
self.dir.join(name)
}
fn backup_file(&self, path: &Path) -> Result<PathBuf> {
let backup_dir = self.dir.join(".backup");
fs::create_dir_all(&backup_dir)
.with_context(|| format!("create SLURM backup directory {}", backup_dir.display()))?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow!("invalid SLURM file name '{}'", path.display()))?;
let backup = backup_dir.join(format!("{file_name}.{}.bak", timestamp_suffix()));
fs::copy(path, &backup).with_context(|| {
format!(
"backup SLURM file {} to {}",
path.display(),
backup.display()
)
})?;
Ok(backup)
}
fn temporary_file(&self, name: &str) -> PathBuf {
self.dir.join(format!(".{name}.{}.tmp", timestamp_suffix()))
}
}
pub fn validate_slurm_content(content: &str) -> Result<()> {
if content.len() > MAX_SLURM_BODY_BYTES {
return Err(anyhow!(
"SLURM content too large: {} bytes, limit {} bytes",
content.len(),
MAX_SLURM_BODY_BYTES
));
}
serde_json::from_str::<serde_json::Value>(content)
.map_err(|err| anyhow!("invalid SLURM JSON: {}", err))?;
SlurmFile::from_slice(content.as_bytes())
.map_err(|err| anyhow!("invalid SLURM file: {}", err))?;
Ok(())
}
pub fn parse_reload_query(query: Option<&str>, body_reload: Option<bool>) -> bool {
if let Some(value) = body_reload {
return value;
}
query
.and_then(|query| {
query.split('&').find_map(|part| {
let (key, value) = part.split_once('=')?;
(key == "reload").then(|| matches!(value, "1" | "true" | "yes" | "on"))
})
})
.unwrap_or(false)
}
fn validate_slurm_file_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(anyhow!("SLURM file name must not be empty"));
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(anyhow!("invalid SLURM file name '{}'", name));
}
if !(name.ends_with(".slurm") || name.ends_with(".slurm.disabled")) {
return Err(anyhow!(
"invalid SLURM file name '{}': expected .slurm or .slurm.disabled",
name
));
}
if !name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
{
return Err(anyhow!(
"invalid SLURM file name '{}': allowed characters are ASCII letters, digits, '.', '_' and '-'",
name
));
}
Ok(())
}
fn replace_file(temporary: &Path, target: &Path) -> Result<()> {
if let Err(err) = fs::rename(temporary, target) {
if target.exists() {
fs::remove_file(target)
.with_context(|| format!("replace existing SLURM file {}", target.display()))?;
fs::rename(temporary, target)
.with_context(|| format!("move SLURM file into {}", target.display()))?;
} else {
return Err(err).with_context(|| {
format!(
"move temporary SLURM file {} into {}",
temporary.display(),
target.display()
)
});
}
}
Ok(())
}
fn timestamp_suffix() -> String {
Utc::now().format("%Y%m%d%H%M%S%9f").to_string()
}
fn modified_unix_seconds(metadata: &fs::Metadata) -> Option<u64> {
metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
}

View File

@ -1,3 +1,4 @@
pub mod admin;
pub mod file; pub mod file;
pub mod policy; pub mod policy;
mod serde; mod serde;

View File

@ -204,6 +204,20 @@ fn apply_slurm_to_payloads_from_dir(
SlurmRuleCounts, SlurmRuleCounts,
)> { )> {
let files = read_slurm_files(slurm_dir)?; 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_count = files.len();
let file_names = files let file_names = files
.iter() .iter()
@ -285,12 +299,6 @@ fn slurm_paths(slurm_dir: &str) -> Result<Vec<PathBuf>> {
.map(|name| name.to_ascii_lowercase()) .map(|name| name.to_ascii_lowercase())
.unwrap_or_default() .unwrap_or_default()
}); });
if paths.is_empty() {
return Err(anyhow!(
"SLURM directory '{}' does not contain .slurm files",
slurm_dir
));
}
Ok(paths) Ok(paths)
} }

View File

@ -1,7 +1,7 @@
use std::fmt::Write; use std::fmt::Write;
use std::net::{Ipv4Addr, Ipv6Addr}; 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::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
use rpki::rtr::cache::SerialResult; use rpki::rtr::cache::SerialResult;

View File

@ -79,17 +79,10 @@ fn version_report_stats_separate_snapshot_and_delta_payload_types() {
Asn::from(64496u32), Asn::from(64496u32),
vec![1, 2, 3], vec![1, 2, 3],
)); ));
let aspa = Payload::Aspa(Aspa::new( let aspa = Payload::Aspa(Aspa::new(Asn::from(64496u32), vec![Asn::from(64497u32)]));
Asn::from(64496u32),
vec![Asn::from(64497u32)],
));
let snapshot = Snapshot::from_payloads(vec![vrp.clone(), router_key.clone(), aspa.clone()]); let snapshot = Snapshot::from_payloads(vec![vrp.clone(), router_key.clone(), aspa.clone()]);
let mut deltas = VecDeque::new(); let mut deltas = VecDeque::new();
deltas.push_back(Arc::new(Delta::new( deltas.push_back(Arc::new(Delta::new(101, vec![vrp, aspa], vec![router_key])));
101,
vec![vrp, aspa],
vec![router_key],
)));
let cache = RtrCacheBuilder::new() let cache = RtrCacheBuilder::new()
.session_ids(SessionIds::from_array([40, 41, 42])) .session_ids(SessionIds::from_array([40, 41, 42]))
.serials([99, 100, 101]) .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 result = cache.get_deltas_since_for_version(2, 100);
let input = let input = get_deltas_since_input_to_string(
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 100); cache.session_id_for_version(1),
cache.serial_for_version(2),
100,
);
let output = serial_result_detail_to_string(&result); let output = serial_result_detail_to_string(&result);
test_report( test_report(
@ -615,7 +611,11 @@ fn get_deltas_since_returns_reset_required_when_client_serial_is_too_old() {
let input = format!( let input = format!(
"{}delta_window:\n{}", "{}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), indent_block(&deltas_window_to_string(&deltas), 2),
); );
let output = serial_result_detail_to_string(&result); let output = serial_result_detail_to_string(&result);
@ -680,7 +680,11 @@ fn get_deltas_since_returns_minimal_merged_delta() {
let input = format!( let input = format!(
"{}delta_window:\n{}", "{}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), indent_block(&deltas_window_to_string(&deltas), 2),
); );
let output = serial_result_detail_to_string(&result); 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 result = cache.get_deltas_since_for_version(2, 101);
let input = let input = get_deltas_since_input_to_string(
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 101); cache.session_id_for_version(1),
cache.serial_for_version(2),
101,
);
let output = serial_result_detail_to_string(&result); let output = serial_result_detail_to_string(&result);
test_report( 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 result = cache.get_deltas_since_for_version(2, 0);
let input = let input = get_deltas_since_input_to_string(
get_deltas_since_input_to_string(cache.session_id_for_version(1), cache.serial_for_version(2), 0); cache.session_id_for_version(1),
cache.serial_for_version(2),
0,
);
let output = serial_result_detail_to_string(&result); let output = serial_result_detail_to_string(&result);
test_report( test_report(
@ -1227,7 +1237,11 @@ fn get_deltas_since_cancels_announce_then_withdraw_for_same_prefix() {
let input = format!( let input = format!(
"{}delta_window:\n{}", "{}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), indent_block(&deltas_window_to_string(&deltas), 2),
); );
let output = serial_result_detail_to_string(&result); 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!( let input = format!(
"{}delta_window:\n{}", "{}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), indent_block(&deltas_window_to_string(&deltas), 2),
); );
let output = serial_result_detail_to_string(&result); 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!( let input = format!(
"{}delta_window:\n{}", "{}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), indent_block(&deltas_window_to_string(&deltas), 2),
); );
let output = serial_result_detail_to_string(&result); 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!( let input = format!(
"{}delta_window:\n{}", "{}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), indent_block(&deltas_window_to_string(&deltas), 2),
); );
let output = serial_result_detail_to_string(&result); 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])) .session_ids(SessionIds::from_array([42, 42, 42]))
.serials(serials_all(102)) .serials(serials_all(102))
.timing(Timing::default()) .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)) .deltas_by_version(deltas_all(deltas))
.build(); .build();

View File

@ -1,14 +1,16 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use tempfile::tempdir;
use rpki::source::ccr::{ use rpki::source::ccr::{
ParsedAspa, ParsedCcrSnapshot, ParsedVrp, find_latest_ccr_file, load_ccr_snapshot_from_file, ParsedAspa, ParsedCcrSnapshot, ParsedVrp, find_latest_ccr_file, load_ccr_snapshot_from_file,
snapshot_to_payloads_with_options, snapshot_to_payloads_with_options,
}; };
use tempfile::tempdir;
fn fixture_path(name: &str) -> PathBuf { 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] #[test]
@ -17,8 +19,7 @@ fn ccr_loader_smoke() {
// let path = "./mini_data/20260403T000001Z-mini-a.ccr"; // let path = "./mini_data/20260403T000001Z-mini-a.ccr";
// let path = "20260403T000101Z-mini-b.ccr"; // let path = "20260403T000101Z-mini-b.ccr";
let path = "20260403T000201Z-mini-c.ccr"; let path = "20260403T000201Z-mini-c.ccr";
let snapshot = load_ccr_snapshot_from_file(fixture_path(path)) let snapshot = load_ccr_snapshot_from_file(fixture_path(path)).expect("load CCR snapshot");
.expect("load CCR snapshot");
println!("content_type_oid: {}", snapshot.content_type_oid); println!("content_type_oid: {}", snapshot.content_type_oid);
println!("produced_at : {:?}", snapshot.produced_at); 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 { for (name, expect_vrps, expect_vaps) in cases {
let snapshot = load_ccr_snapshot_from_file(fixture_path(name)) let snapshot = load_ccr_snapshot_from_file(fixture_path(name))
.unwrap_or_else(|e| panic!("failed to parse {}: {:?}", name, e)); .unwrap_or_else(|e| panic!("failed to parse {}: {:?}", name, e));
assert_eq!(snapshot.vrps.len(), expect_vrps, "vrp count mismatch for {name}"); assert_eq!(
assert_eq!(snapshot.vaps.len(), expect_vaps, "vap count mismatch for {name}"); snapshot.vrps.len(),
expect_vrps,
"vrp count mismatch for {name}"
);
assert_eq!(
snapshot.vaps.len(),
expect_vaps,
"vap count mismatch for {name}"
);
} }
} }

View File

@ -1,7 +1,7 @@
use std::path::PathBuf; use std::path::PathBuf;
use rpki::data_model::crl::RpkixCrl;
use rpki::data_model::crl::Asn1TimeEncoding; use rpki::data_model::crl::Asn1TimeEncoding;
use rpki::data_model::crl::RpkixCrl;
#[test] #[test]
fn decode_and_validate_crl_fixture() { fn decode_and_validate_crl_fixture() {
@ -42,9 +42,8 @@ fn crl_signature_verification_succeeds_with_issuer_cert() {
#[test] #[test]
fn decode_crl_with_revoked_entries() { fn decode_crl_with_revoked_entries() {
let der = let der = std::fs::read("tests/fixtures/0099DEAB073EFD74C250C0A382B25012B5082AEE.crl")
std::fs::read("tests/fixtures/0099DEAB073EFD74C250C0A382B25012B5082AEE.crl") .expect("read CRL fixture with revoked entries");
.expect("read CRL fixture with revoked entries");
let crl = RpkixCrl::decode_der(&der).expect("decode CRL"); let crl = RpkixCrl::decode_der(&der).expect("decode CRL");

View File

@ -1,13 +1,13 @@
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use tokio::io::{duplex, AsyncWriteExt}; use tokio::io::{AsyncWriteExt, duplex};
use rpki::data_model::resources::as_resources::Asn; use rpki::data_model::resources::as_resources::Asn;
use rpki::rtr::error_type::ErrorCode; use rpki::rtr::error_type::ErrorCode;
use rpki::rtr::payload::{Aspa as PayloadAspa, Ski, Timing}; use rpki::rtr::payload::{Aspa as PayloadAspa, Ski, Timing};
use rpki::rtr::pdu::{ use rpki::rtr::pdu::{
Aspa, EndOfDataV1, ErrorReport, Flags, Header, IPv4Prefix, RouterKey, SerialNotify, Aspa, END_OF_DATA_V1_LEN, EndOfDataV1, ErrorReport, Flags, Header, IPv4Prefix, MAX_PDU_LEN,
END_OF_DATA_V1_LEN, MAX_PDU_LEN, RouterKey, SerialNotify,
}; };
const ERROR_REPORT_FIXED_PART_LEN: usize = 16; 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();
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_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(err.to_string().contains("expire interval")); assert!(err.to_string().contains("expire interval"));
} }

13
tests/test_rtr_admin.rs Normal file
View File

@ -0,0 +1,13 @@
use rpki::rtr::admin::tail_log_lines;
#[test]
fn tail_log_lines_returns_requested_suffix() {
let output = tail_log_lines(b"one\ntwo\nthree\nfour\n".to_vec(), 2);
assert_eq!(output, b"three\nfour\n");
}
#[test]
fn tail_log_lines_returns_whole_buffer_when_shorter() {
let output = tail_log_lines(b"one\ntwo\n".to_vec(), 5);
assert_eq!(output, b"one\ntwo\n");
}

87
tests/test_rtr_config.rs Normal file
View File

@ -0,0 +1,87 @@
use rpki::rtr::config::{AppConfig, RuntimeConfigPatch, RuntimeTimingConfigPatch, parse_timezone};
#[test]
fn parse_timezone_accepts_iana_names_and_common_aliases() {
assert_eq!(
parse_timezone("Asia/Shanghai", "TEST").unwrap(),
chrono_tz::Asia::Shanghai
);
assert_eq!(
parse_timezone("shanghai", "TEST").unwrap(),
chrono_tz::Asia::Shanghai
);
assert_eq!(
parse_timezone("Europe/London", "TEST").unwrap(),
chrono_tz::Europe::London
);
assert_eq!(
parse_timezone("America/New_York", "TEST").unwrap(),
chrono_tz::America::New_York
);
assert_eq!(parse_timezone("UTC", "TEST").unwrap(), chrono_tz::UTC);
assert_eq!(parse_timezone("Z", "TEST").unwrap(), chrono_tz::UTC);
}
#[test]
fn parse_timezone_rejects_offset_and_invalid_values() {
assert!(parse_timezone("+08:00", "TEST").is_err());
assert!(parse_timezone("+0800", "TEST").is_err());
assert!(parse_timezone("Mars/Base", "TEST").is_err());
}
#[test]
fn runtime_config_patch_validates_and_preserves_unspecified_values() {
let current = AppConfig::default().runtime_config();
let next = current
.apply_patch(RuntimeConfigPatch {
max_delta: Some(8),
prune_delta_by_snapshot_size: Some(true),
source_refresh_interval_seconds: Some(60),
runtime_report_interval_seconds: Some(120),
report_history_limit: Some(20),
strict_ccr_validation: Some(true),
timezone: Some("UTC".to_string()),
timing: Some(RuntimeTimingConfigPatch {
refresh: Some(1800),
retry: None,
expire: Some(7200),
}),
})
.unwrap();
assert_eq!(next.max_delta, 8);
assert!(next.prune_delta_by_snapshot_size);
assert_eq!(next.source_refresh_interval_seconds, 60);
assert_eq!(next.runtime_report_interval_seconds, 120);
assert_eq!(next.report_history_limit, 20);
assert!(next.strict_ccr_validation);
assert_eq!(next.timezone, "UTC");
assert_eq!(next.timing.refresh, 1800);
assert_eq!(next.timing.retry, current.timing.retry);
assert_eq!(next.timing.expire, 7200);
assert!(
current
.apply_patch(RuntimeConfigPatch {
max_delta: Some(0),
..RuntimeConfigPatch::default()
})
.is_err()
);
assert!(
current
.apply_patch(RuntimeConfigPatch {
runtime_report_interval_seconds: Some(0),
..RuntimeConfigPatch::default()
})
.is_err()
);
assert!(
current
.apply_patch(RuntimeConfigPatch {
timezone: Some("+08:00".to_string()),
..RuntimeConfigPatch::default()
})
.is_err()
);
}

290
tests/test_rtr_report.rs Normal file
View File

@ -0,0 +1,290 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use arc_swap::ArcSwap;
use chrono::Utc;
use rpki::rtr::cache::RtrCache;
use rpki::rtr::report::{ReportConfiguration, ReportContext};
use rpki::rtr::server::RtrService;
use rpki::source::pipeline::{
DataQualityReport, FileFingerprint, PayloadTypeCounts, SlurmRuleCounts, SourceFingerprint,
SourceLoadReport,
};
use serde_json::Value;
fn test_configuration(report_history_limit: usize) -> ReportConfiguration {
ReportConfiguration::new(
300,
300,
report_history_limit,
100,
false,
false,
chrono_tz::Asia::Shanghai,
(3600, 600, 7200),
)
}
#[test]
fn write_report_creates_parseable_json() {
let temp = tempfile::tempdir().unwrap();
let report_dir = temp.path().join("report");
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
let service = RtrService::new(shared_cache.clone());
let notifier = service.notifier();
let context = ReportContext::new(test_configuration(10));
context.write_or_warn(
&report_dir,
"test",
&shared_cache,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&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,
&notifier,
&service.stats(),
);
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 1);
}
fn assert_report_time_offset(value: &Value) {
let value = value.as_str().expect("report time should be a string");
assert!(
value.ends_with("+08:00"),
"report time should use +08:00 offset, got {value}"
);
}
fn read_single_report(report_dir: &Path, prefix: &str) -> Value {
let files = report_files(report_dir, prefix);
assert_eq!(files.len(), 1, "expected one {prefix} report");
serde_json::from_slice(&fs::read(&files[0]).unwrap()).unwrap()
}
fn report_files(report_dir: &Path, prefix: &str) -> Vec<PathBuf> {
let start = format!("{prefix}-");
let mut files = fs::read_dir(report_dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(&start) && name.ends_with(".json"))
})
.collect::<Vec<_>>();
files.sort();
files
}
fn assert_no_temporary_reports(report_dir: &Path) {
let has_temporary = fs::read_dir(report_dir).unwrap().any(|entry| {
entry
.unwrap()
.file_name()
.to_str()
.is_some_and(|name| name.ends_with(".tmp"))
});
assert!(!has_temporary);
}

View File

@ -16,8 +16,8 @@ use tokio_rustls::TlsConnector;
use rpki::rtr::cache::{RtrCacheBuilder, SessionIds, SharedRtrCache}; use rpki::rtr::cache::{RtrCacheBuilder, SessionIds, SharedRtrCache};
use rpki::rtr::payload::Timing; use rpki::rtr::payload::Timing;
use rpki::rtr::pdu::{CacheResponse, EndOfDataV1, ResetQuery}; use rpki::rtr::pdu::{CacheResponse, EndOfDataV1, ResetQuery};
use rpki::rtr::server::ssh::SshAuthMode;
use rpki::rtr::server::RtrService; use rpki::rtr::server::RtrService;
use rpki::rtr::server::ssh::SshAuthMode;
use russh::client; use russh::client;
use russh::keys; use russh::keys;
use russh::keys::ssh_key::LineEnding; use russh::keys::ssh_key::LineEnding;

View File

@ -15,16 +15,16 @@ use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, watch}; use tokio::sync::{broadcast, watch};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration}; use tokio::time::{Duration, timeout};
use tokio_rustls::{TlsAcceptor, TlsConnector}; use tokio_rustls::{TlsAcceptor, TlsConnector};
use common::test_helper::{ use common::test_helper::{
dump_cache_reset, dump_cache_response, dump_eod_v1, dump_ipv4_prefix, dump_ipv6_prefix, RtrDebugDumper, dump_cache_reset, dump_cache_response, dump_eod_v1, dump_ipv4_prefix,
RtrDebugDumper, 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::as_resources::Asn;
use rpki::data_model::resources::ip_resources::{IPAddress, IPAddressPrefix};
use rpki::rtr::cache::{Delta, RtrCacheBuilder, SessionIds, SharedRtrCache, Snapshot}; use rpki::rtr::cache::{Delta, RtrCacheBuilder, SessionIds, SharedRtrCache, Snapshot};
use rpki::rtr::error_type::ErrorCode; use rpki::rtr::error_type::ErrorCode;
use rpki::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski, Timing}; 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, Aspa as AspaPdu, CacheReset, CacheResponse, EndOfDataV1, ErrorReport, Header, IPv4Prefix,
IPv6Prefix, ResetQuery, RouterKey as RouterKeyPdu, SerialNotify, SerialQuery, IPv6Prefix, ResetQuery, RouterKey as RouterKeyPdu, SerialNotify, SerialQuery,
}; };
use rpki::rtr::store::RtrStore;
use rpki::rtr::server::connection::handle_tls_connection; use rpki::rtr::server::connection::handle_tls_connection;
use rpki::rtr::server::tls::load_rustls_server_config_with_options; use rpki::rtr::server::tls::load_rustls_server_config_with_options;
use rpki::rtr::session::RtrSession; use rpki::rtr::session::RtrSession;
use rpki::rtr::store::RtrStore;
fn shared_cache(cache: rpki::rtr::cache::RtrCache) -> SharedRtrCache { fn shared_cache(cache: rpki::rtr::cache::RtrCache) -> SharedRtrCache {
Arc::new(ArcSwap::from_pointee(cache)) Arc::new(ArcSwap::from_pointee(cache))
@ -119,11 +119,7 @@ async fn start_session_server_returning_result(
async fn start_tls_session_server( async fn start_tls_session_server(
cache: SharedRtrCache, cache: SharedRtrCache,
) -> ( ) -> (SocketAddr, watch::Sender<bool>, JoinHandle<()>) {
SocketAddr,
watch::Sender<bool>,
JoinHandle<()>,
) {
start_tls_session_server_with_cert(cache, "server.crt", "server.key").await 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, cache: SharedRtrCache,
cert_name: &str, cert_name: &str,
key_name: &str, key_name: &str,
) -> ( ) -> (SocketAddr, watch::Sender<bool>, JoinHandle<()>) {
SocketAddr,
watch::Sender<bool>,
JoinHandle<()>,
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
@ -181,11 +173,8 @@ async fn shutdown_server(
shutdown_io(&mut client, shutdown_tx, server_handle).await; shutdown_io(&mut client, shutdown_tx, server_handle).await;
} }
async fn shutdown_io<S>( async fn shutdown_io<S>(io: &mut S, shutdown_tx: watch::Sender<bool>, server_handle: JoinHandle<()>)
io: &mut S, where
shutdown_tx: watch::Sender<bool>,
server_handle: JoinHandle<()>,
) where
S: AsyncWrite + Unpin, S: AsyncWrite + Unpin,
{ {
let _ = io.shutdown().await; 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 origin = Payload::RouteOrigin(RouteOrigin::new(prefix, 24, 64496u32.into()));
let valid_spki = vec![ let valid_spki = vec![
0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
0x01, 0x05, 0x00, 0x03, 0x02, 0x00, 0x00, 0x05, 0x00, 0x03, 0x02, 0x00, 0x00,
]; ];
let router_key = Payload::RouterKey(RouterKey::new( let router_key = Payload::RouterKey(RouterKey::new(
Ski::default(), 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); let expected_sid_v0 = shared.load_full().session_id_for_version(0);
assert_eq!(response.session_id(), expected_sid_v0); assert_eq!(response.session_id(), expected_sid_v0);
let _v4 = IPv4Prefix::read(&mut client).await.unwrap(); 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); assert_eq!(eod_v0.serial_number(), 1);
shutdown_server(client, shutdown_tx, server_handle).await; 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 (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(shared.clone()).await;
let mut client = TcpStream::connect(addr).await.unwrap(); let mut client = TcpStream::connect(addr).await.unwrap();
let sid_v2 = shared.load_full().session_id_for_version(2); 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(); let response = CacheResponse::read(&mut client).await.unwrap();
assert_eq!(response.version(), 2); assert_eq!(response.version(), 2);
assert_eq!(response.session_id(), sid_v2); 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 (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
let mut client = TcpStream::connect(addr).await.unwrap(); 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(); 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 (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
let mut client = TcpStream::connect(addr).await.unwrap(); 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(); 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 (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
let mut client = TcpStream::connect(addr).await.unwrap(); 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(); 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.pdu(), 6);
assert_eq!(third.version(), 1); assert_eq!(third.version(), 1);
assert!(third.flag().is_announce()); 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.prefix_len(), 32);
assert_eq!(third.max_len(), 48); assert_eq!(third.max_len(), 48);
assert_eq!(third.asn(), 64498u32.into()); 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 (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
let mut client = TcpStream::connect(addr).await.unwrap(); 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(); 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 (addr, _notify_tx, shutdown_tx, server_handle) = start_session_server(server_cache).await;
let mut client = TcpStream::connect(addr).await.unwrap(); 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(); let report = ErrorReport::read(&mut client).await.unwrap();
assert_error_report_matches( 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).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(); let second = ErrorReport::read(&mut client).await.unwrap();
assert_error_report_matches( assert_error_report_matches(
&second, &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_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)) let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
.await .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_error_report_matches(&report, 1, ErrorCode::CorruptData, invalid_header.as_ref());
assert!(std::str::from_utf8(report.text()) assert!(
.unwrap() std::str::from_utf8(report.text())
.contains("invalid PDU length")); .unwrap()
.contains("invalid PDU length")
);
let read_res = Header::read(&mut client).await; let read_res = Header::read(&mut client).await;
assert!(read_res.is_err()); 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; let read_res = Header::read(&mut client).await;
assert!(read_res.is_err()); assert!(read_res.is_err());
@ -1525,11 +1551,12 @@ async fn established_session_unknown_pdu_returns_unsupported_pdu_type() {
#[tokio::test] #[tokio::test]
async fn version_zero_does_not_send_router_key_or_aspa() { 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 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 aspa = Aspa::new(
let snapshot = Snapshot::from_payloads(vec![ Asn::from(64496u32),
Payload::RouterKey(router_key), vec![Asn::from(64497u32), Asn::from(64498u32)],
Payload::Aspa(aspa), );
]); let snapshot =
Snapshot::from_payloads(vec![Payload::RouterKey(router_key), Payload::Aspa(aspa)]);
let cache = RtrCacheBuilder::new() let cache = RtrCacheBuilder::new()
.session_ids(SessionIds::from_array([42, 42, 42])) .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(); let response = CacheResponse::read(&mut client).await.unwrap();
dump.push_value(response.pdu(), dump_cache_response(&response)); 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( dump.push_value(
eod.pdu(), eod.pdu(),
json!({ 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; 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"); dump.print_pretty("version_zero_does_not_send_router_key_or_aspa");
shutdown_server(client, shutdown_tx, server_handle).await; 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] #[tokio::test]
async fn version_two_aspa_withdraw_has_empty_provider_list() { 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 delta = Arc::new(Delta::new(101, vec![], vec![Payload::Aspa(aspa)]));
let mut deltas = VecDeque::new(); let mut deltas = VecDeque::new();
deltas.push_back(delta); 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 client = TcpStream::connect(addr).await.unwrap();
let mut dump = RtrDebugDumper::new(); 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(); let response = CacheResponse::read(&mut client).await.unwrap();
dump.push_value(response.pdu(), dump_cache_response(&response)); 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] #[tokio::test]
async fn version_one_sends_router_key_but_not_aspa() { async fn version_one_sends_router_key_but_not_aspa() {
let valid_spki = vec![ let valid_spki = vec![
0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x30, 0x13, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
0x01, 0x05, 0x00, 0x03, 0x02, 0x00, 0x00, 0x05, 0x00, 0x03, 0x02, 0x00, 0x00,
]; ];
let router_key = RouterKey::new(Ski::default(), Asn::from(64496u32), valid_spki); let router_key = RouterKey::new(Ski::default(), Asn::from(64496u32), valid_spki);
let aspa = Aspa::new(Asn::from(64496u32), vec![Asn::from(64497u32)]); let aspa = Aspa::new(Asn::from(64496u32), vec![Asn::from(64497u32)]);
let snapshot = Snapshot::from_payloads(vec![ let snapshot =
Payload::RouterKey(router_key), Snapshot::from_payloads(vec![Payload::RouterKey(router_key), Payload::Aspa(aspa)]);
Payload::Aspa(aspa),
]);
let cache = RtrCacheBuilder::new() let cache = RtrCacheBuilder::new()
.session_ids(SessionIds::from_array([42, 42, 42])) .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)); dump.push_value(eod.pdu(), dump_eod_v1(&eod));
let res = timeout(Duration::from_millis(100), Header::read(&mut client)).await; 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"); dump.print_pretty("version_one_sends_router_key_but_not_aspa");
shutdown_server(client, shutdown_tx, server_handle).await; 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(); .unwrap_err();
assert!(err assert!(
.to_string() err.to_string()
.contains("does not contain a subjectAltName dNSName entry")); .contains("does not contain a subjectAltName dNSName entry")
);
let _ = cache; 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)) let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
.await .await
.expect("timed out waiting for TLS session close"); .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( dump.push_value(
0, 0,
json!({ json!({
@ -1847,7 +1890,8 @@ async fn invalid_timing_prevents_end_of_data_response() {
.build(); .build();
let server_cache = shared_cache(cache); 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(); let mut client = TcpStream::connect(addr).await.unwrap();
ResetQuery::new(1).write(&mut client).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)) let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
.await .await
.expect("timed out waiting for server close"); .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 _ = shutdown_tx.send(true);
let join = timeout(Duration::from_secs(1), server_handle) let join = timeout(Duration::from_secs(1), server_handle)
@ -1872,10 +1919,8 @@ async fn invalid_timing_prevents_end_of_data_response() {
#[tokio::test] #[tokio::test]
async fn invalid_aspa_prevents_snapshot_response() { async fn invalid_aspa_prevents_snapshot_response() {
let snapshot = Snapshot::from_payloads(vec![Payload::Aspa(Aspa::new( let snapshot =
Asn::from(64496u32), Snapshot::from_payloads(vec![Payload::Aspa(Aspa::new(Asn::from(64496u32), vec![]))]);
vec![],
))]);
let cache = RtrCacheBuilder::new() let cache = RtrCacheBuilder::new()
.session_ids(SessionIds::from_array([42, 42, 42])) .session_ids(SessionIds::from_array([42, 42, 42]))
.serials(serials_all(100)) .serials(serials_all(100))
@ -1884,7 +1929,8 @@ async fn invalid_aspa_prevents_snapshot_response() {
.build(); .build();
let server_cache = shared_cache(cache); 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(); let mut client = TcpStream::connect(addr).await.unwrap();
ResetQuery::new(2).write(&mut client).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)) let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
.await .await
.expect("timed out waiting for server close"); .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 _ = shutdown_tx.send(true);
let join = timeout(Duration::from_secs(1), server_handle) let join = timeout(Duration::from_secs(1), server_handle)
@ -1922,7 +1971,8 @@ async fn invalid_router_key_prevents_snapshot_response() {
.build(); .build();
let server_cache = shared_cache(cache); 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(); let mut client = TcpStream::connect(addr).await.unwrap();
ResetQuery::new(1).write(&mut client).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)) let read_res = timeout(Duration::from_secs(1), Header::read(&mut client))
.await .await
.expect("timed out waiting for server close"); .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 _ = shutdown_tx.send(true);
let join = timeout(Duration::from_secs(1), server_handle) let join = timeout(Duration::from_secs(1), server_handle)

View File

@ -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(); let slurm = SlurmFile::from_slice(json.as_bytes()).unwrap();
log_slurm_ok( log_slurm_ok(
"applies_filters_before_assertions_and_excludes_duplicates", "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", "rejects_hex_encoded_ski_and_aspa_customer_in_providers.invalid_aspa",
&aspa_err, &aspa_err,
); );
assert!(aspa_err assert!(
.to_string() aspa_err
.contains("providerAsns must not contain customerAsn")); .to_string()
.contains("providerAsns must not contain customerAsn")
);
} }
#[test] #[test]
@ -840,7 +845,10 @@ fn merges_multiple_slurm_files_without_conflict() {
), ),
]) ])
.unwrap(); .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.version(), SlurmVersion::V2);
assert_eq!(merged.locally_added_assertions().prefix_assertions.len(), 1); assert_eq!(merged.locally_added_assertions().prefix_assertions.len(), 1);

109
tests/test_slurm_admin.rs Normal file
View File

@ -0,0 +1,109 @@
use std::fs;
use rpki::slurm::admin::SlurmAdmin;
fn valid_slurm() -> &'static str {
r#"{
"slurmVersion": 1,
"validationOutputFilters": {
"prefixFilters": [],
"bgpsecFilters": []
},
"locallyAddedAssertions": {
"prefixAssertions": [],
"bgpsecAssertions": []
}
}"#
}
#[test]
fn slurm_admin_rejects_unsafe_file_names() {
let temp = tempfile::tempdir().unwrap();
let admin = SlurmAdmin::new(temp.path());
for name in ["", "../x.slurm", "a/b.slurm", "a\\b.slurm", "a.json"] {
assert!(
admin
.put_file(name, valid_slurm(), "create_or_update")
.is_err(),
"{name}"
);
}
assert!(
admin
.put_file("policy.slurm", valid_slurm(), "create_or_update")
.is_ok()
);
assert!(
admin
.put_file("policy.slurm.disabled", valid_slurm(), "create_or_update")
.is_ok()
);
}
#[test]
fn writes_backs_up_toggles_deletes_and_rolls_back() {
let temp = tempfile::tempdir().unwrap();
let admin = SlurmAdmin::new(temp.path());
let create = admin
.put_file("policy.slurm", valid_slurm(), "create_or_update")
.unwrap();
assert!(temp.path().join("policy.slurm").is_file());
assert!(create.result.backup.is_none());
let update = admin
.put_file("policy.slurm", valid_slurm(), "create_or_update")
.unwrap();
assert!(update.result.backup.is_some());
let disable = admin.disable_file("policy.slurm").unwrap();
assert!(!temp.path().join("policy.slurm").exists());
assert!(temp.path().join("policy.slurm.disabled").is_file());
disable.rollback().unwrap();
assert!(temp.path().join("policy.slurm").is_file());
assert!(!temp.path().join("policy.slurm.disabled").exists());
admin.disable_file("policy.slurm").unwrap();
let enable = admin.enable_file("policy.slurm.disabled").unwrap();
assert!(temp.path().join("policy.slurm").is_file());
assert!(!temp.path().join("policy.slurm.disabled").exists());
enable.rollback().unwrap();
assert!(!temp.path().join("policy.slurm").exists());
assert!(temp.path().join("policy.slurm.disabled").is_file());
admin.enable_file("policy.slurm.disabled").unwrap();
let delete = admin.delete_file("policy.slurm").unwrap();
assert!(!temp.path().join("policy.slurm").exists());
assert!(delete.result.backup.is_some());
delete.rollback().unwrap();
assert!(temp.path().join("policy.slurm").is_file());
}
#[test]
fn lists_and_reads_slurm_files() {
let temp = tempfile::tempdir().unwrap();
let admin = SlurmAdmin::new(temp.path());
admin
.put_file("enabled.slurm", valid_slurm(), "create_or_update")
.unwrap();
admin
.put_file("disabled.slurm.disabled", valid_slurm(), "create_or_update")
.unwrap();
fs::write(temp.path().join("ignore.txt"), "not slurm").unwrap();
let files = admin.list_files().unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].name, "disabled.slurm.disabled");
assert!(!files[0].enabled);
assert_eq!(files[1].name, "enabled.slurm");
assert!(files[1].enabled);
let file = admin.read_file("enabled.slurm").unwrap();
assert_eq!(file.name, "enabled.slurm");
assert!(file.enabled);
assert!(file.content.contains("\"slurmVersion\""));
assert!(admin.read_file("missing.slurm").is_err());
}

View File

@ -1,11 +1,13 @@
mod common; mod common;
use std::net::Ipv6Addr; use std::net::{Ipv4Addr, Ipv6Addr};
use common::test_helper::{v4_origin, v6_origin}; 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::cache::{CacheAvailability, Delta, Snapshot};
use rpki::rtr::payload::Payload; use rpki::rtr::payload::{Aspa, Payload, RouteOrigin, RouterKey, Ski};
use rpki::rtr::store::RtrStore; use rpki::rtr::store::RtrStore;
#[test] #[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] #[test]
fn store_db_versioned_delta_window_wraparound_is_isolated_by_version() { fn store_db_versioned_delta_window_wraparound_is_isolated_by_version() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -371,7 +427,8 @@ fn store_db_versioned_load_delta_window_requires_complete_range() {
.unwrap(); .unwrap();
let err = store.load_delta_window_for_version(1, 10, 11).unwrap_err(); let err = store.load_delta_window_for_version(1, 10, 11).unwrap_err();
assert!(err assert!(
.to_string() err.to_string()
.contains("delta window starts at 10, but first persisted delta is Some(11)")); .contains("delta window starts at 10, but first persisted delta is Some(11)")
);
} }