增加rtr一些必要的api接口
This commit is contained in:
parent
303bdffd97
commit
b105b3b7d0
18
README.md
18
README.md
@ -96,6 +96,11 @@ RTR Server 运行时从 `CCR` 目录中扫描最新的 `.ccr` 文件作为输入
|
|||||||
| `RPKI_RTR_TCP_KEEPALIVE_SECS` | TCP keepalive 时间,单位秒;设为 `0` 表示禁用。 | `60` | `60` |
|
| `RPKI_RTR_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.
@ -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
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
FROM rust:1.89-bookworm AS builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends clang libclang-dev pkg-config \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY src ./src
|
|
||||||
|
|
||||||
RUN cargo build --release --bin rpki_rs_test_client
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS runtime
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=builder /build/target/release/rpki_rs_test_client /usr/local/bin/rpki_rs_test_client
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/rpki_rs_test_client"]
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
services:
|
|
||||||
rpki-rs-test-client:
|
|
||||||
build:
|
|
||||||
context: ../..
|
|
||||||
dockerfile: deploy/rpki-rs-client/Dockerfile
|
|
||||||
image: rpki-rs-test-client:latest
|
|
||||||
container_name: rpki-rs-test-client
|
|
||||||
network_mode: host
|
|
||||||
command: ["127.0.0.1:323", "2", "serial", "--steps", "2", "--follow"]
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
@ -24,6 +24,8 @@ RPKI_RTR_TIMEZONE=Asia/Shanghai
|
|||||||
RPKI_RTR_MAX_DELTA=10
|
RPKI_RTR_MAX_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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
313
docs/rtr-admin-api.md
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
# RTR Admin API
|
||||||
|
|
||||||
|
本文档描述 RTR server 当前提供的 HTTP 管理接口。
|
||||||
|
|
||||||
|
## 启用方式
|
||||||
|
|
||||||
|
Admin API 默认关闭。设置 `RPKI_RTR_ADMIN_ADDR` 后启用:
|
||||||
|
|
||||||
|
```env
|
||||||
|
RPKI_RTR_ADMIN_ADDR=127.0.0.1:8323
|
||||||
|
RPKI_RTR_ADMIN_TOKEN=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
如果在 Docker 中需要从宿主机访问,容器内建议监听:
|
||||||
|
|
||||||
|
```env
|
||||||
|
RPKI_RTR_ADMIN_ADDR=0.0.0.0:8323
|
||||||
|
RPKI_RTR_ADMIN_TOKEN=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
并在 compose 中映射端口:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8323:8323"
|
||||||
|
```
|
||||||
|
|
||||||
|
安全规则:
|
||||||
|
|
||||||
|
- `RPKI_RTR_ADMIN_ADDR` 为空时,不启动 Admin API。
|
||||||
|
- 非 loopback 地址,例如 `0.0.0.0:8323`,必须配置 `RPKI_RTR_ADMIN_TOKEN`,否则 admin server 会拒绝启动。
|
||||||
|
- 设置 token 后,请求必须带 `Authorization: Bearer <token>`。
|
||||||
|
|
||||||
|
## 通用 Headers
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer change-me
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
GET 请求不需要 `Content-Type`。
|
||||||
|
|
||||||
|
## GET /admin/rtr/health
|
||||||
|
|
||||||
|
用于检查 Admin API 是否可达,以及当前启用了哪些管理能力。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8323/admin/rtr/health \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"config_api": true,
|
||||||
|
"source_reload_api": true,
|
||||||
|
"slurm_api": true,
|
||||||
|
"logs_api": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## GET /admin/rtr/logs/tail
|
||||||
|
|
||||||
|
实时读取 RTR 服务日志,效果类似 `tail -f`。默认读取 stdout 日志,先返回最近 200 行,然后持续输出新增内容。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -N "http://127.0.0.1:8323/admin/rtr/logs/tail?stream=stdout&lines=200&follow=true" \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
Query 参数:
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `stream` | `stdout` | 可选 `stdout` 或 `stderr`。 |
|
||||||
|
| `lines` | `200` | 首次返回的历史行数,范围会限制在 `1..=5000`。 |
|
||||||
|
| `follow` | `true` | `true` 时保持连接并持续输出新增日志;`false` 时只返回当前 tail 内容。 |
|
||||||
|
|
||||||
|
日志文件路径按容器 entrypoint 的规则解析:
|
||||||
|
|
||||||
|
- 目录:`RPKI_RTR_LOG_DIR`,默认 `/app/logs`。
|
||||||
|
- 文件名前缀:`RPKI_RTR_LOG_NAME`,未设置时使用 `HOSTNAME`,再未设置时为 `rpki-rtr`。
|
||||||
|
- stdout 文件:`<name>.stdout.log`。
|
||||||
|
- stderr 文件:`<name>.stderr.log`。
|
||||||
|
|
||||||
|
## GET /admin/rtr/config
|
||||||
|
|
||||||
|
查询当前运行中的 runtime 配置。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8323/admin/rtr/config \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"config": {
|
||||||
|
"max_delta": 10,
|
||||||
|
"prune_delta_by_snapshot_size": true,
|
||||||
|
"source_refresh_interval_seconds": 300,
|
||||||
|
"runtime_report_interval_seconds": 300,
|
||||||
|
"report_history_limit": 10,
|
||||||
|
"strict_ccr_validation": false,
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"timing": {
|
||||||
|
"refresh": 3600,
|
||||||
|
"retry": 600,
|
||||||
|
"expire": 7200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /admin/rtr/config
|
||||||
|
|
||||||
|
运行中动态修改 RTR server 的部分 runtime 配置。请求体是 JSON object,支持部分更新,只需要传要修改的字段。
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `max_delta` | integer | 每个 RTR 版本最多保留的 delta 条数,必须 `>= 1`。 |
|
||||||
|
| `prune_delta_by_snapshot_size` | boolean | 是否按 snapshot wire size 裁剪 delta window。 |
|
||||||
|
| `source_refresh_interval_seconds` | integer | CCR/SLURM source refresh 间隔,单位秒,必须 `>= 1`。 |
|
||||||
|
| `runtime_report_interval_seconds` | integer | `rtr-runtime-*.json` 周期写入间隔,单位秒,必须 `>= 1`。 |
|
||||||
|
| `report_history_limit` | integer | 每类 report 文件滚动保留数量,必须 `>= 1`。 |
|
||||||
|
| `strict_ccr_validation` | boolean | 是否严格校验 CCR 中的非法 VRP/VAP,影响下一次 source refresh。 |
|
||||||
|
| `timezone` | string | IANA 时区名,例如 `Asia/Shanghai`、`UTC`、`Europe/London`。 |
|
||||||
|
| `timing.refresh` | integer | RTR EndOfData `refresh`,单位秒。 |
|
||||||
|
| `timing.retry` | integer | RTR EndOfData `retry`,单位秒。 |
|
||||||
|
| `timing.expire` | integer | RTR EndOfData `expire`,必须大于 `refresh` 和 `retry`。 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8323/admin/rtr/config \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer change-me" \
|
||||||
|
-d '{"max_delta": 6}'
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"config": {
|
||||||
|
"max_delta": 6,
|
||||||
|
"prune_delta_by_snapshot_size": true,
|
||||||
|
"source_refresh_interval_seconds": 300,
|
||||||
|
"runtime_report_interval_seconds": 300,
|
||||||
|
"report_history_limit": 10,
|
||||||
|
"strict_ccr_validation": false,
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"timing": {
|
||||||
|
"refresh": 3600,
|
||||||
|
"retry": 600,
|
||||||
|
"expire": 7200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## GET /admin/rtr/slurm/files
|
||||||
|
|
||||||
|
列出 `RPKI_RTR_SLURM_DIR` 下的 SLURM 文件。只返回合法的 `*.slurm` 和 `*.slurm.disabled` 文件,不返回 `.backup/`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8323/admin/rtr/slurm/files \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "local-policy.slurm",
|
||||||
|
"path": "/app/slurm/local-policy.slurm",
|
||||||
|
"enabled": true,
|
||||||
|
"size_bytes": 168,
|
||||||
|
"modified_unix_seconds": 1782100000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "old-policy.slurm.disabled",
|
||||||
|
"path": "/app/slurm/old-policy.slurm.disabled",
|
||||||
|
"enabled": false,
|
||||||
|
"size_bytes": 168,
|
||||||
|
"modified_unix_seconds": 1782100100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## GET /admin/rtr/slurm/files/{name}
|
||||||
|
|
||||||
|
读取单个 SLURM 文件,前端编辑页面可以直接使用这个接口加载内容。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"file": {
|
||||||
|
"name": "local-policy.slurm",
|
||||||
|
"path": "/app/slurm/local-policy.slurm",
|
||||||
|
"enabled": true,
|
||||||
|
"size_bytes": 168,
|
||||||
|
"modified_unix_seconds": 1782100000,
|
||||||
|
"content": "{ \"slurmVersion\": 1, \"validationOutputFilters\": { \"prefixFilters\": [], \"bgpsecFilters\": [] }, \"locallyAddedAssertions\": { \"prefixAssertions\": [], \"bgpsecAssertions\": [] } }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /admin/rtr/slurm/files
|
||||||
|
|
||||||
|
新增或覆盖一个 SLURM 文件。文件名放在请求体中。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "local-policy.slurm",
|
||||||
|
"content": "{ \"slurmVersion\": 1, \"validationOutputFilters\": { \"prefixFilters\": [], \"bgpsecFilters\": [] }, \"locallyAddedAssertions\": { \"prefixAssertions\": [], \"bgpsecAssertions\": [] } }",
|
||||||
|
"reload": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PUT /admin/rtr/slurm/files/{name}
|
||||||
|
|
||||||
|
新增或覆盖指定 SLURM 文件。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer change-me" \
|
||||||
|
-d '{
|
||||||
|
"content": "{ \"slurmVersion\": 1, \"validationOutputFilters\": { \"prefixFilters\": [], \"bgpsecFilters\": [] }, \"locallyAddedAssertions\": { \"prefixAssertions\": [], \"bgpsecAssertions\": [] } }",
|
||||||
|
"reload": true
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## DELETE /admin/rtr/slurm/files/{name}
|
||||||
|
|
||||||
|
删除指定 SLURM 文件。删除前会先备份。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm?reload=true" \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /admin/rtr/slurm/files/{name}/disable
|
||||||
|
|
||||||
|
将启用文件改名为失效文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local-policy.slurm -> local-policy.slurm.disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm/disable?reload=true" \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /admin/rtr/slurm/files/{name}/enable
|
||||||
|
|
||||||
|
将失效文件改名为启用文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local-policy.slurm.disabled -> local-policy.slurm
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://127.0.0.1:8323/admin/rtr/slurm/files/local-policy.slurm.disabled/enable?reload=true" \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /admin/rtr/slurm/reload
|
||||||
|
|
||||||
|
不修改文件,只立即触发一次 CCR/SLURM source reload。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8323/admin/rtr/slurm/reload \
|
||||||
|
-H "Authorization: Bearer change-me"
|
||||||
|
```
|
||||||
|
|
||||||
|
## SLURM 文件规则
|
||||||
|
|
||||||
|
- `*.slurm`:启用,会被 source refresh 加载。
|
||||||
|
- `*.slurm.disabled`:失效,不会被加载。
|
||||||
|
- `.backup/`:接口自动生成的备份目录。
|
||||||
|
- 文件名只能是 basename,不能包含 `/`、`\` 或 `..`。
|
||||||
|
- 文件名只能包含 ASCII 字母、数字、`.`、`_`、`-`。
|
||||||
|
- 写入内容必须是合法 JSON,并且能被解析为合法 SLURM 文件。
|
||||||
|
- 单个请求体默认限制为 5 MiB。
|
||||||
|
- 覆盖、删除前会写入 `.backup/` 备份。
|
||||||
|
|
||||||
|
## 错误响应
|
||||||
|
|
||||||
|
| HTTP 状态码 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `400 Bad Request` | JSON 非法、配置值非法、SLURM 文件名非法、目标文件不存在、reload 失败等。 |
|
||||||
|
| `401 Unauthorized` | 配置了 token,但请求缺少或使用了错误的 `Authorization`。 |
|
||||||
|
| `404 Not Found` | 路径或方法不支持。 |
|
||||||
|
| `413 Payload Too Large` | 请求体超过限制。 |
|
||||||
@ -1,112 +0,0 @@
|
|||||||
# rpki_rs_test_client
|
|
||||||
|
|
||||||
`rpki_rs_test_client` 是一个基于 `rpki-rs` RTR 客户端接口的测试工具,参数风格对齐 `rtr_debug_client`。
|
|
||||||
|
|
||||||
实现上直接调用外部 crate 的 client API(`Client` / `PayloadTarget`),不重复实现 RTR 客户端状态机。
|
|
||||||
|
|
||||||
## 构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --bin rpki_rs_test_client
|
|
||||||
```
|
|
||||||
|
|
||||||
## 基本用法
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run --bin rpki_rs_test_client -- <addr> <version> [reset|serial|serial <session_id> <serial>] [options]
|
|
||||||
```
|
|
||||||
|
|
||||||
默认值:
|
|
||||||
- `addr`: `127.0.0.1:323`
|
|
||||||
- `version`: `2`
|
|
||||||
- `mode`: `reset`
|
|
||||||
|
|
||||||
## 常用参数
|
|
||||||
|
|
||||||
- `--steps <n>`: 执行 `client.step()` 次数(默认 `1`)
|
|
||||||
- `--follow`: bootstrap 结束后持续执行 `client.step()`(常驻模式)
|
|
||||||
- `--print-records`: 打印当前收敛后的 payload 记录
|
|
||||||
- `--assert-min-records <n>`: 断言收敛记录数下限
|
|
||||||
- `--assert-substr <text>`: 在 payload 的 `Debug` 输出中做字符串断言(可重复)
|
|
||||||
|
|
||||||
TLS 参数:
|
|
||||||
- `--tls`
|
|
||||||
- `--ca-cert <path>`
|
|
||||||
- `--server-name <name>`
|
|
||||||
- `--client-cert <path>`
|
|
||||||
- `--client-key <path>`
|
|
||||||
|
|
||||||
## 限制说明
|
|
||||||
|
|
||||||
- 当前 `rpki-rs v0.18` client API 不支持显式覆盖初始版本,因此这里只接受 `version=2`。
|
|
||||||
- 支持 `serial`(无参数)模式:会基于 client 内部 state 自动走 serial 更新。
|
|
||||||
- 当前不支持 `serial <session_id> <serial>` 显式注入状态(传入会直接报错)。
|
|
||||||
|
|
||||||
## 示例
|
|
||||||
|
|
||||||
TCP 连通 + 最小记录数断言:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run --bin rpki_rs_test_client -- \
|
|
||||||
127.0.0.1:323 \
|
|
||||||
2 reset \
|
|
||||||
--steps 1 \
|
|
||||||
--assert-min-records 1
|
|
||||||
```
|
|
||||||
|
|
||||||
自动 serial(无需传 sid/serial):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run --bin rpki_rs_test_client -- \
|
|
||||||
127.0.0.1:323 \
|
|
||||||
2 serial --steps 2 --follow
|
|
||||||
```
|
|
||||||
|
|
||||||
结合 `mini_data` 的内容做字符串断言:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run --bin rpki_rs_test_client -- \
|
|
||||||
127.0.0.1:323 \
|
|
||||||
2 reset \
|
|
||||||
--assert-substr "10.0.1.0" \
|
|
||||||
--assert-substr "65003"
|
|
||||||
```
|
|
||||||
|
|
||||||
TLS 场景:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo run --bin rpki_rs_test_client -- \
|
|
||||||
127.0.0.1:324 \
|
|
||||||
2 reset \
|
|
||||||
--tls \
|
|
||||||
--ca-cert tests/fixtures/tls/client-ca.crt \
|
|
||||||
--server-name localhost
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker 启动(deploy)
|
|
||||||
|
|
||||||
构建并启动(Linux 服务器,`host` 网络):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
按需覆盖运行参数(覆盖 compose 默认 `command`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml run --rm \
|
|
||||||
rpki-rs-test-client 127.0.0.1:323 2 reset --steps 1 --assert-min-records 1
|
|
||||||
```
|
|
||||||
|
|
||||||
常驻跟进模式(先 bootstrap,再持续 serial/notify):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml run --rm \
|
|
||||||
rpki-rs-test-client 127.0.0.1:323 2 serial --steps 2 --follow
|
|
||||||
```
|
|
||||||
|
|
||||||
停止:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f deploy/rpki-rs-client/docker-compose.yml down
|
|
||||||
```
|
|
||||||
@ -1,843 +0,0 @@
|
|||||||
use std::collections::BTreeSet;
|
|
||||||
use std::env;
|
|
||||||
use std::io;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
|
|
||||||
use rustls_pki_types::{CertificateDer, PrivateKeyDer, ServerName};
|
|
||||||
use tokio::io::{AsyncRead, AsyncWrite};
|
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use tokio::time::{timeout, Duration};
|
|
||||||
use tokio_rustls::TlsConnector;
|
|
||||||
|
|
||||||
use rpki_rs::rtr::client::{Client, PayloadError, PayloadTarget};
|
|
||||||
use rpki_rs::rtr::payload::{Action, Payload, Timing};
|
|
||||||
|
|
||||||
const DEFAULT_STEPS: usize = 1;
|
|
||||||
const DEFAULT_STEP_TIMEOUT_SECS: u64 = 300;
|
|
||||||
const DEFAULT_PROGRESS_EVERY: u64 = 10_000;
|
|
||||||
|
|
||||||
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
|
||||||
impl<T> AsyncStream for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
|
|
||||||
|
|
||||||
type DynStream = Box<dyn AsyncStream>;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct Config {
|
|
||||||
addr: String,
|
|
||||||
steps: usize,
|
|
||||||
follow: bool,
|
|
||||||
transport: TransportConfig,
|
|
||||||
assert_substr: Vec<String>,
|
|
||||||
assert_min_records: Option<usize>,
|
|
||||||
print_records: bool,
|
|
||||||
step_timeout_secs: u64,
|
|
||||||
progress_every: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
fn from_args() -> io::Result<Self> {
|
|
||||||
let mut args = env::args().skip(1);
|
|
||||||
let mut positional = Vec::new();
|
|
||||||
|
|
||||||
let mut steps = DEFAULT_STEPS;
|
|
||||||
let mut follow = false;
|
|
||||||
let mut transport = TransportConfig::Tcp;
|
|
||||||
let mut assert_substr = Vec::new();
|
|
||||||
let mut assert_min_records = None;
|
|
||||||
let mut print_records = false;
|
|
||||||
let mut step_timeout_secs = DEFAULT_STEP_TIMEOUT_SECS;
|
|
||||||
let mut progress_every = DEFAULT_PROGRESS_EVERY;
|
|
||||||
|
|
||||||
while let Some(arg) = args.next() {
|
|
||||||
match arg.as_str() {
|
|
||||||
"-h" | "--help" => {
|
|
||||||
print_usage();
|
|
||||||
std::process::exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
"--steps" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::InvalidInput, "--steps requires value")
|
|
||||||
})?;
|
|
||||||
steps = parse_usize_arg(&v, "--steps")?;
|
|
||||||
if steps == 0 {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--steps must be >= 1",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"--follow" => {
|
|
||||||
follow = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
"--tls" => {
|
|
||||||
ensure_tls(&mut transport)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
"--ca-cert" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::InvalidInput, "--ca-cert requires path")
|
|
||||||
})?;
|
|
||||||
ensure_tls(&mut transport)?.ca_cert = Some(PathBuf::from(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
"--server-name" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--server-name requires value",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
ensure_tls(&mut transport)?.server_name = Some(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
"--client-cert" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--client-cert requires path",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
ensure_tls(&mut transport)?.client_cert = Some(PathBuf::from(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
"--client-key" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--client-key requires path",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
ensure_tls(&mut transport)?.client_key = Some(PathBuf::from(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
"--assert-substr" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--assert-substr requires value",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
assert_substr.push(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
"--assert-min-records" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--assert-min-records requires value",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
assert_min_records = Some(parse_usize_arg(&v, "--assert-min-records")?);
|
|
||||||
}
|
|
||||||
|
|
||||||
"--print-records" => {
|
|
||||||
print_records = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
"--step-timeout-secs" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--step-timeout-secs requires value",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
step_timeout_secs = parse_u64_arg(&v, "--step-timeout-secs")?;
|
|
||||||
if step_timeout_secs == 0 {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--step-timeout-secs must be >= 1",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"--progress-every" => {
|
|
||||||
let v = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--progress-every requires value",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
progress_every = parse_u64_arg(&v, "--progress-every")?;
|
|
||||||
if progress_every == 0 {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--progress-every must be >= 1",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 明确拒绝当前 wrapper 不支持的能力
|
|
||||||
"--version" => {
|
|
||||||
let _ = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::InvalidInput, "--version requires value")
|
|
||||||
})?;
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--version is not exposed by this rpki client wrapper",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
"--timeout" => {
|
|
||||||
let _ = args.next().ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::InvalidInput, "--timeout requires value")
|
|
||||||
})?;
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"--timeout is not exposed by this rpki client wrapper; use --step-timeout-secs instead",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
_ if arg.starts_with("--") => {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("unknown option '{}'", arg),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => positional.push(arg),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut positional = positional.into_iter();
|
|
||||||
let addr = positional
|
|
||||||
.next()
|
|
||||||
.unwrap_or_else(|| "127.0.0.1:323".to_string());
|
|
||||||
|
|
||||||
if let Some(extra) = positional.next() {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!(
|
|
||||||
"unexpected positional argument '{}'; only optional [addr] is supported",
|
|
||||||
extra
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let transport = finalize_transport(transport, &addr)?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
addr,
|
|
||||||
steps,
|
|
||||||
follow,
|
|
||||||
transport,
|
|
||||||
assert_substr,
|
|
||||||
assert_min_records,
|
|
||||||
print_records,
|
|
||||||
step_timeout_secs,
|
|
||||||
progress_every,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_usage() {
|
|
||||||
eprintln!(
|
|
||||||
"\
|
|
||||||
rpki-rs-test-client
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
rpki-rs-test-client [OPTIONS] [ADDR]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
rpki-rs-test-client
|
|
||||||
rpki-rs-test-client 127.0.0.1:323 --steps 1
|
|
||||||
rpki-rs-test-client 127.0.0.1:323 --steps 1 --step-timeout-secs 600
|
|
||||||
rpki-rs-test-client 127.0.0.1:323 --follow
|
|
||||||
rpki-rs-test-client 127.0.0.1:323 --assert-min-records 1 --assert-substr 192.0.2.
|
|
||||||
rpki-rs-test-client 127.0.0.1:3324 --tls --ca-cert certs/ca.pem --server-name localhost
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--steps <N> Number of client.step() calls to perform (default: 1)
|
|
||||||
--follow Keep calling step() forever
|
|
||||||
--tls Enable TLS
|
|
||||||
--ca-cert <PATH> CA certificate PEM file (required in TLS mode)
|
|
||||||
--server-name <NAME> TLS server name; required when ADDR host is an IP
|
|
||||||
--client-cert <PATH> Client certificate PEM file (optional, with --client-key)
|
|
||||||
--client-key <PATH> Client private key PEM file (optional, with --client-cert)
|
|
||||||
--assert-substr <TEXT> Assert final stable record dump contains substring
|
|
||||||
--assert-min-records <N> Assert final record count >= N
|
|
||||||
--print-records Print records after each successful step
|
|
||||||
--step-timeout-secs <N> Timeout for each step() call in seconds (default: 300)
|
|
||||||
--progress-every <N> Print apply progress every N updates (default: 10000)
|
|
||||||
-h, --help Show this help
|
|
||||||
|
|
||||||
Not supported by this wrapper:
|
|
||||||
--version
|
|
||||||
--timeout
|
|
||||||
explicit serial bootstrap via session_id/serial
|
|
||||||
"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
enum TransportConfig {
|
|
||||||
Tcp,
|
|
||||||
Tls(TlsConfig),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
struct TlsConfig {
|
|
||||||
server_name: Option<String>,
|
|
||||||
ca_cert: Option<PathBuf>,
|
|
||||||
client_cert: Option<PathBuf>,
|
|
||||||
client_key: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_tls(transport: &mut TransportConfig) -> io::Result<&mut TlsConfig> {
|
|
||||||
if matches!(transport, TransportConfig::Tcp) {
|
|
||||||
*transport = TransportConfig::Tls(TlsConfig::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
match transport {
|
|
||||||
TransportConfig::Tls(cfg) => Ok(cfg),
|
|
||||||
TransportConfig::Tcp => Err(io::Error::other("tls configuration unavailable")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finalize_transport(transport: TransportConfig, addr: &str) -> io::Result<TransportConfig> {
|
|
||||||
match transport {
|
|
||||||
TransportConfig::Tcp => Ok(TransportConfig::Tcp),
|
|
||||||
TransportConfig::Tls(mut cfg) => {
|
|
||||||
let ca_cert = cfg.ca_cert.take().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"TLS mode requires --ca-cert <path>",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match (&cfg.client_cert, &cfg.client_key) {
|
|
||||||
(Some(_), Some(_)) | (None, None) => {}
|
|
||||||
_ => {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"TLS client auth requires both --client-cert and --client-key",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let server_name = match cfg.server_name.take() {
|
|
||||||
Some(name) => name,
|
|
||||||
None => {
|
|
||||||
let host = parse_host_from_addr(addr).ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"failed to parse host from address",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if host.parse::<IpAddr>().is_ok() {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"TLS with IP address requires explicit --server-name",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
host
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(TransportConfig::Tls(TlsConfig {
|
|
||||||
server_name: Some(server_name),
|
|
||||||
ca_cert: Some(ca_cert),
|
|
||||||
client_cert: cfg.client_cert,
|
|
||||||
client_key: cfg.client_key,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
|
||||||
struct SharedTarget {
|
|
||||||
inner: Arc<Mutex<TargetState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct TargetState {
|
|
||||||
records: BTreeSet<Payload>,
|
|
||||||
timing: Option<Timing>,
|
|
||||||
announced_seen: u64,
|
|
||||||
withdrawn_seen: u64,
|
|
||||||
updates_applied_total: u64,
|
|
||||||
progress_every: u64,
|
|
||||||
apply_batches: u64,
|
|
||||||
last_apply_started_at: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TargetState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
records: BTreeSet::new(),
|
|
||||||
timing: None,
|
|
||||||
announced_seen: 0,
|
|
||||||
withdrawn_seen: 0,
|
|
||||||
updates_applied_total: 0,
|
|
||||||
progress_every: DEFAULT_PROGRESS_EVERY,
|
|
||||||
apply_batches: 0,
|
|
||||||
last_apply_started_at: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct TargetSnapshot {
|
|
||||||
records: Vec<Payload>,
|
|
||||||
timing: Option<Timing>,
|
|
||||||
announced_seen: u64,
|
|
||||||
withdrawn_seen: u64,
|
|
||||||
updates_applied_total: u64,
|
|
||||||
apply_batches: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SharedTarget {
|
|
||||||
fn new(progress_every: u64) -> Self {
|
|
||||||
let state = TargetState {
|
|
||||||
progress_every,
|
|
||||||
..TargetState::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(Mutex::new(state)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot(&self) -> TargetSnapshot {
|
|
||||||
let guard = self.inner.lock().expect("target mutex poisoned");
|
|
||||||
TargetSnapshot {
|
|
||||||
records: guard.records.iter().cloned().collect(),
|
|
||||||
timing: guard.timing,
|
|
||||||
announced_seen: guard.announced_seen,
|
|
||||||
withdrawn_seen: guard.withdrawn_seen,
|
|
||||||
updates_applied_total: guard.updates_applied_total,
|
|
||||||
apply_batches: guard.apply_batches,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn payload_to_stable_text(payload: &Payload) -> String {
|
|
||||||
format!("{:?}", payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TargetSnapshot {
|
|
||||||
fn dump_text(&self) -> String {
|
|
||||||
self.records
|
|
||||||
.iter()
|
|
||||||
.map(SharedTarget::payload_to_stable_text)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PayloadTarget for SharedTarget {
|
|
||||||
type Update = Vec<(Action, Payload)>;
|
|
||||||
|
|
||||||
fn start(&mut self, reset: bool) -> Self::Update {
|
|
||||||
if reset {
|
|
||||||
let mut guard = self.inner.lock().expect("target mutex poisoned");
|
|
||||||
println!(
|
|
||||||
"[target] start reset=true | clearing existing records={}",
|
|
||||||
guard.records.len()
|
|
||||||
);
|
|
||||||
guard.records.clear();
|
|
||||||
} else {
|
|
||||||
let guard = self.inner.lock().expect("target mutex poisoned");
|
|
||||||
println!(
|
|
||||||
"[target] start reset=false | current records={}",
|
|
||||||
guard.records.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(&mut self, update: Self::Update, timing: Timing) -> Result<(), PayloadError> {
|
|
||||||
let total = update.len() as u64;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut guard = self.inner.lock().expect("target mutex poisoned");
|
|
||||||
guard.apply_batches += 1;
|
|
||||||
guard.last_apply_started_at = Some(Instant::now());
|
|
||||||
println!(
|
|
||||||
"[target] apply batch #{} started | updates={} | current records={}",
|
|
||||||
guard.apply_batches,
|
|
||||||
total,
|
|
||||||
guard.records.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let started = Instant::now();
|
|
||||||
let mut local_processed: u64 = 0;
|
|
||||||
|
|
||||||
let progress_every = {
|
|
||||||
let guard = self.inner.lock().expect("target mutex poisoned");
|
|
||||||
guard.progress_every
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut guard = self.inner.lock().expect("target mutex poisoned");
|
|
||||||
|
|
||||||
for (action, payload) in update {
|
|
||||||
match action {
|
|
||||||
Action::Announce => {
|
|
||||||
guard.announced_seen += 1;
|
|
||||||
if !guard.records.insert(payload) {
|
|
||||||
return Err(PayloadError::DuplicateAnnounce);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Action::Withdraw => {
|
|
||||||
guard.withdrawn_seen += 1;
|
|
||||||
if !guard.records.remove(&payload) {
|
|
||||||
return Err(PayloadError::UnknownWithdraw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
local_processed += 1;
|
|
||||||
guard.updates_applied_total += 1;
|
|
||||||
|
|
||||||
if local_processed % progress_every == 0 || local_processed == total {
|
|
||||||
println!(
|
|
||||||
"[target] apply progress | batch_processed={}/{} | total_updates_seen={} | records={} | elapsed={:.2?}",
|
|
||||||
local_processed,
|
|
||||||
total,
|
|
||||||
guard.updates_applied_total,
|
|
||||||
guard.records.len(),
|
|
||||||
started.elapsed(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard.timing = Some(timing);
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"[target] apply batch complete | updates={} | records={} | announced_seen={} | withdrawn_seen={} | elapsed={:.2?}",
|
|
||||||
total,
|
|
||||||
guard.records.len(),
|
|
||||||
guard.announced_seen,
|
|
||||||
guard.withdrawn_seen,
|
|
||||||
started.elapsed(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_step_summary(
|
|
||||||
step_no: usize,
|
|
||||||
before: &TargetSnapshot,
|
|
||||||
after: &TargetSnapshot,
|
|
||||||
print_records: bool,
|
|
||||||
) {
|
|
||||||
println!(
|
|
||||||
"[step] {} ok | records: {} -> {} | delta announce={} withdraw={} | delta updates={} | apply_batches={}",
|
|
||||||
step_no,
|
|
||||||
before.records.len(),
|
|
||||||
after.records.len(),
|
|
||||||
after.announced_seen.saturating_sub(before.announced_seen),
|
|
||||||
after.withdrawn_seen.saturating_sub(before.withdrawn_seen),
|
|
||||||
after
|
|
||||||
.updates_applied_total
|
|
||||||
.saturating_sub(before.updates_applied_total),
|
|
||||||
after.apply_batches,
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(timing) = after.timing {
|
|
||||||
println!(
|
|
||||||
"[step] {} timing | refresh={} retry={} expire={}",
|
|
||||||
step_no, timing.refresh, timing.retry, timing.expire
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if print_records {
|
|
||||||
println!("-- records after step {} --", step_no);
|
|
||||||
if after.records.is_empty() {
|
|
||||||
println!("(empty)");
|
|
||||||
} else {
|
|
||||||
for rec in &after.records {
|
|
||||||
println!("{}", SharedTarget::payload_to_stable_text(rec));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_assertions(config: &Config, snapshot: &TargetSnapshot) -> io::Result<()> {
|
|
||||||
if let Some(min) = config.assert_min_records
|
|
||||||
&& snapshot.records.len() < min
|
|
||||||
{
|
|
||||||
return Err(io::Error::other(format!(
|
|
||||||
"assertion failed: records {} < {}",
|
|
||||||
snapshot.records.len(),
|
|
||||||
min
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !config.assert_substr.is_empty() {
|
|
||||||
let dump = snapshot.dump_text();
|
|
||||||
for needle in &config.assert_substr {
|
|
||||||
if !dump.contains(needle) {
|
|
||||||
return Err(io::Error::other(format!(
|
|
||||||
"assertion failed: missing substring '{}'",
|
|
||||||
needle
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> io::Result<()> {
|
|
||||||
let config = Config::from_args()?;
|
|
||||||
|
|
||||||
println!("== rpki-rs-test-client ==");
|
|
||||||
println!("target : {}", config.addr);
|
|
||||||
println!("steps : {}", config.steps);
|
|
||||||
println!("follow : {}", config.follow);
|
|
||||||
println!("step_timeout_secs : {}", config.step_timeout_secs);
|
|
||||||
println!("progress_every : {}", config.progress_every);
|
|
||||||
|
|
||||||
match &config.transport {
|
|
||||||
TransportConfig::Tcp => {
|
|
||||||
println!("transport : tcp");
|
|
||||||
}
|
|
||||||
TransportConfig::Tls(tls) => {
|
|
||||||
println!("transport : tls");
|
|
||||||
println!(
|
|
||||||
"server_name : {}",
|
|
||||||
tls.server_name.as_deref().unwrap_or("<unset>")
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"ca_cert : {}",
|
|
||||||
tls.ca_cert
|
|
||||||
.as_ref()
|
|
||||||
.map(|p| p.display().to_string())
|
|
||||||
.unwrap_or_else(|| "<unset>".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let stream = connect_stream(&config).await?;
|
|
||||||
let target = SharedTarget::new(config.progress_every);
|
|
||||||
let inspect = target.clone();
|
|
||||||
|
|
||||||
let mut client = Client::new(stream, target, None);
|
|
||||||
|
|
||||||
for idx in 0..config.steps {
|
|
||||||
let step_no = idx + 1;
|
|
||||||
let before = inspect.snapshot();
|
|
||||||
|
|
||||||
println!("[step] {} begin", step_no);
|
|
||||||
let step_started = Instant::now();
|
|
||||||
|
|
||||||
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::TimedOut,
|
|
||||||
format!(
|
|
||||||
"step {} timed out after {}s",
|
|
||||||
step_no, config.step_timeout_secs
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.map_err(|err| io::Error::new(err.kind(), format!("step {} failed: {}", step_no, err)))?;
|
|
||||||
|
|
||||||
let after = inspect.snapshot();
|
|
||||||
print_step_summary(step_no, &before, &after, config.print_records);
|
|
||||||
println!(
|
|
||||||
"[step] {} finished in {:.2?}",
|
|
||||||
step_no,
|
|
||||||
step_started.elapsed()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.follow {
|
|
||||||
let mut step_index = config.steps;
|
|
||||||
println!("[follow] enabled, entering continuous step loop");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
step_index += 1;
|
|
||||||
let before = inspect.snapshot();
|
|
||||||
|
|
||||||
println!("[step] {} begin", step_index);
|
|
||||||
let step_started = Instant::now();
|
|
||||||
|
|
||||||
timeout(Duration::from_secs(config.step_timeout_secs), client.step())
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::TimedOut,
|
|
||||||
format!(
|
|
||||||
"step {} timed out after {}s",
|
|
||||||
step_index, config.step_timeout_secs
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.map_err(|err| {
|
|
||||||
io::Error::new(err.kind(), format!("step {} failed: {}", step_index, err))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let after = inspect.snapshot();
|
|
||||||
print_step_summary(step_index, &before, &after, config.print_records);
|
|
||||||
println!(
|
|
||||||
"[step] {} finished in {:.2?}",
|
|
||||||
step_index,
|
|
||||||
step_started.elapsed()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let state = client.state();
|
|
||||||
let final_snapshot = inspect.snapshot();
|
|
||||||
|
|
||||||
println!("state : {:?}", state);
|
|
||||||
if let Some(timing) = final_snapshot.timing {
|
|
||||||
println!(
|
|
||||||
"timing : refresh={} retry={} expire={}",
|
|
||||||
timing.refresh, timing.retry, timing.expire
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!("records : {}", final_snapshot.records.len());
|
|
||||||
println!(
|
|
||||||
"updates_seen : announce={} withdraw={}",
|
|
||||||
final_snapshot.announced_seen, final_snapshot.withdrawn_seen
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"updates_applied : {}",
|
|
||||||
final_snapshot.updates_applied_total
|
|
||||||
);
|
|
||||||
println!("apply_batches : {}", final_snapshot.apply_batches);
|
|
||||||
|
|
||||||
run_assertions(&config, &final_snapshot)?;
|
|
||||||
println!("[assert] passed");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_stream(config: &Config) -> io::Result<DynStream> {
|
|
||||||
match &config.transport {
|
|
||||||
TransportConfig::Tcp => Ok(Box::new(TcpStream::connect(&config.addr).await?)),
|
|
||||||
TransportConfig::Tls(tls) => connect_tls_stream(&config.addr, tls).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_tls_stream(addr: &str, tls: &TlsConfig) -> io::Result<DynStream> {
|
|
||||||
let stream = TcpStream::connect(addr).await?;
|
|
||||||
let connector = build_tls_connector(tls)?;
|
|
||||||
|
|
||||||
let server_name_str = tls
|
|
||||||
.server_name
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS server name"))?;
|
|
||||||
|
|
||||||
let server_name = ServerName::try_from(server_name_str.clone()).map_err(|err| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("invalid TLS server name '{}': {}", server_name_str, err),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let tls_stream = connector.connect(server_name, stream).await.map_err(|err| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::ConnectionAborted,
|
|
||||||
format!("TLS handshake failed: {}", err),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Box::new(tls_stream))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_tls_connector(tls: &TlsConfig) -> io::Result<TlsConnector> {
|
|
||||||
let ca_cert_path = tls
|
|
||||||
.ca_cert
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing TLS CA cert"))?;
|
|
||||||
|
|
||||||
let ca_certs = load_certs(ca_cert_path)?;
|
|
||||||
|
|
||||||
let mut roots = RootCertStore::empty();
|
|
||||||
let (added, _ignored) = roots.add_parsable_certificates(ca_certs);
|
|
||||||
if added == 0 {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("no valid CA certs in {}", ca_cert_path.display()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let builder = RustlsClientConfig::builder().with_root_certificates(roots);
|
|
||||||
|
|
||||||
let cfg = match (&tls.client_cert, &tls.client_key) {
|
|
||||||
(Some(cert_path), Some(key_path)) => {
|
|
||||||
let certs = load_certs(cert_path)?;
|
|
||||||
let key = load_private_key(key_path)?;
|
|
||||||
builder.with_client_auth_cert(certs, key).map_err(|err| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("invalid client cert/key: {}", err),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
}
|
|
||||||
(None, None) => builder.with_no_client_auth(),
|
|
||||||
_ => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(TlsConnector::from(Arc::new(cfg)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_certs(path: &Path) -> io::Result<Vec<CertificateDer<'static>>> {
|
|
||||||
let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
|
|
||||||
let certs = rustls_pemfile::certs(&mut reader)
|
|
||||||
.collect::<Result<Vec<_>, _>>()
|
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
|
||||||
|
|
||||||
if certs.is_empty() {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
format!("no certs found in {}", path.display()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(certs)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_private_key(path: &Path) -> io::Result<PrivateKeyDer<'static>> {
|
|
||||||
let mut reader = std::io::BufReader::new(std::fs::File::open(path)?);
|
|
||||||
rustls_pemfile::private_key(&mut reader)
|
|
||||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?
|
|
||||||
.ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
format!("no private key in {}", path.display()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_host_from_addr(addr: &str) -> Option<String> {
|
|
||||||
if let Some(rest) = addr.strip_prefix('[') {
|
|
||||||
return rest.split(']').next().map(str::to_string);
|
|
||||||
}
|
|
||||||
addr.rsplit_once(':').map(|(host, _)| host.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_usize_arg(value: &str, name: &str) -> io::Result<usize> {
|
|
||||||
value.parse::<usize>().map_err(|err| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("invalid {} '{}': {}", name, value, err),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_u64_arg(value: &str, name: &str) -> io::Result<u64> {
|
|
||||||
value.parse::<u64>().map_err(|err| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("invalid {} '{}': {}", name, value, err),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
307
src/main_rtr.rs
307
src/main_rtr.rs
@ -5,14 +5,20 @@ use std::time::Instant;
|
|||||||
use anyhow::Result;
|
use 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, ¬ifier, &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,11 +291,60 @@ fn spawn_refresh_task(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_ = interval.tick() => {}
|
_ = interval.tick() => {}
|
||||||
|
command = source_reload_rx.recv() => {
|
||||||
|
let Some(command) = command else {
|
||||||
|
warn!("RTR source reload admin channel closed");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let result = perform_source_refresh(
|
||||||
|
command.phase,
|
||||||
|
command.force,
|
||||||
|
&payload_load_config,
|
||||||
|
&shared_cache,
|
||||||
|
&store,
|
||||||
|
¬ifier,
|
||||||
|
&service_stats,
|
||||||
|
&report_context,
|
||||||
|
&report_dir,
|
||||||
|
&mut last_fingerprint,
|
||||||
|
);
|
||||||
|
let _ = command.respond_to.send(result.map_err(|err| err.to_string()));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let _ = perform_source_refresh(
|
||||||
|
"refresh_complete",
|
||||||
|
false,
|
||||||
|
&payload_load_config,
|
||||||
|
&shared_cache,
|
||||||
|
&store,
|
||||||
|
¬ifier,
|
||||||
|
&service_stats,
|
||||||
|
&report_context,
|
||||||
|
&report_dir,
|
||||||
|
&mut last_fingerprint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn perform_source_refresh(
|
||||||
|
phase: &'static str,
|
||||||
|
force: bool,
|
||||||
|
payload_load_config: &PayloadLoadConfig,
|
||||||
|
shared_cache: &SharedRtrCache,
|
||||||
|
store: &RtrStore,
|
||||||
|
notifier: &RtrNotifier,
|
||||||
|
service_stats: &RtrServiceStats,
|
||||||
|
report_context: &ReportContext,
|
||||||
|
report_dir: &PathBuf,
|
||||||
|
last_fingerprint: &mut Option<SourceFingerprint>,
|
||||||
|
) -> Result<SourceReloadResult> {
|
||||||
let source_to_delta_started = Instant::now();
|
let source_to_delta_started = Instant::now();
|
||||||
let attempted_at = Utc::now();
|
let attempted_at = Utc::now();
|
||||||
|
|
||||||
let current_fingerprint = match latest_sources_fingerprint(&payload_load_config) {
|
let current_fingerprint = match latest_sources_fingerprint(payload_load_config) {
|
||||||
Ok(fp) => fp,
|
Ok(fp) => fp,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
report_context.record_refresh_failure(
|
report_context.record_refresh_failure(
|
||||||
@ -255,63 +359,84 @@ fn spawn_refresh_task(
|
|||||||
source_to_delta_started.elapsed().as_millis()
|
source_to_delta_started.elapsed().as_millis()
|
||||||
);
|
);
|
||||||
report_context.write_source_or_warn(
|
report_context.write_source_or_warn(
|
||||||
&report_dir,
|
report_dir,
|
||||||
"refresh_failed",
|
"refresh_failed",
|
||||||
&shared_cache,
|
shared_cache,
|
||||||
¬ifier,
|
notifier,
|
||||||
&service_stats,
|
service_stats,
|
||||||
);
|
);
|
||||||
continue;
|
return Err(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
report_context.record_source_fingerprint(current_fingerprint.clone());
|
report_context.record_source_fingerprint(current_fingerprint.clone());
|
||||||
|
|
||||||
if last_fingerprint.as_ref() == Some(¤t_fingerprint) {
|
if !force && last_fingerprint.as_ref() == Some(¤t_fingerprint) {
|
||||||
report_context.record_refresh_unchanged(
|
report_context
|
||||||
attempted_at,
|
.record_refresh_unchanged(attempted_at, source_to_delta_started.elapsed().as_millis());
|
||||||
source_to_delta_started.elapsed().as_millis(),
|
|
||||||
);
|
|
||||||
info!(
|
info!(
|
||||||
"RTR source refresh skipped: source files unchanged (ccr_path={}, slurm_file_count={}, elapsed_ms={})",
|
"RTR source refresh skipped: source files unchanged (ccr_path={}, slurm_file_count={}, elapsed_ms={})",
|
||||||
current_fingerprint.ccr.path,
|
current_fingerprint.ccr.path,
|
||||||
current_fingerprint.slurm_files.len(),
|
current_fingerprint.slurm_files.len(),
|
||||||
source_to_delta_started.elapsed().as_millis()
|
source_to_delta_started.elapsed().as_millis()
|
||||||
);
|
);
|
||||||
log_cache_memory_stats("refresh_skipped_unchanged", &shared_cache, ¬ifier);
|
log_cache_memory_stats("refresh_skipped_unchanged", shared_cache, notifier);
|
||||||
report_context.write_source_or_warn(
|
report_context.write_source_or_warn(
|
||||||
&report_dir,
|
report_dir,
|
||||||
"refresh_skipped_unchanged",
|
"refresh_skipped_unchanged",
|
||||||
&shared_cache,
|
shared_cache,
|
||||||
¬ifier,
|
notifier,
|
||||||
&service_stats,
|
service_stats,
|
||||||
);
|
);
|
||||||
continue;
|
return Ok(SourceReloadResult {
|
||||||
|
phase,
|
||||||
|
changed: false,
|
||||||
|
skipped_unchanged: true,
|
||||||
|
payload_count: None,
|
||||||
|
serials: shared_cache.load_full().serials(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match load_payloads_from_latest_sources_with_report(&payload_load_config) {
|
let load = match load_payloads_from_latest_sources_with_report(payload_load_config) {
|
||||||
Ok(load) => {
|
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 source = load.source;
|
||||||
let quality = load.quality;
|
let quality = load.quality;
|
||||||
let payloads = load.payloads;
|
let payloads = load.payloads;
|
||||||
let (payload_count, updated) = {
|
|
||||||
let payload_count = payloads.len();
|
let payload_count = payloads.len();
|
||||||
let source_snapshot = Snapshot::from_payloads(payloads);
|
let source_snapshot = Snapshot::from_payloads(payloads);
|
||||||
let old_cache = shared_cache.load_full();
|
let old_cache = shared_cache.load_full();
|
||||||
let old_serial = old_cache.serial_for_version(2);
|
let old_serial = old_cache.serial_for_version(2);
|
||||||
let mut next_cache = old_cache.as_ref().clone();
|
let mut next_cache = old_cache.as_ref().clone();
|
||||||
|
let updated = match next_cache.update_with_snapshot(source_snapshot, store) {
|
||||||
let updated = match next_cache.update_with_snapshot(source_snapshot, &store)
|
|
||||||
{
|
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let new_serial = next_cache.serial_for_version(2);
|
let new_serial = next_cache.serial_for_version(2);
|
||||||
shared_cache.store(std::sync::Arc::new(next_cache));
|
shared_cache.store(std::sync::Arc::new(next_cache));
|
||||||
if new_serial != old_serial {
|
if new_serial != old_serial {
|
||||||
info!(
|
info!(
|
||||||
"RTR cache refresh applied: ccr_dir={}, payload_count={}, old_serial={}, new_serial={}",
|
"RTR cache refresh applied: ccr_dir={}, payload_count={}, old_serial={}, new_serial={}",
|
||||||
payload_load_config.ccr_dir,
|
payload_load_config.ccr_dir, payload_count, old_serial, new_serial
|
||||||
payload_count,
|
|
||||||
old_serial,
|
|
||||||
new_serial
|
|
||||||
);
|
);
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
@ -330,17 +455,15 @@ fn spawn_refresh_task(
|
|||||||
);
|
);
|
||||||
warn!("RTR cache update failed: {:?}", err);
|
warn!("RTR cache update failed: {:?}", err);
|
||||||
report_context.write_source_or_warn(
|
report_context.write_source_or_warn(
|
||||||
&report_dir,
|
report_dir,
|
||||||
"refresh_failed",
|
"refresh_failed",
|
||||||
&shared_cache,
|
shared_cache,
|
||||||
¬ifier,
|
notifier,
|
||||||
&service_stats,
|
service_stats,
|
||||||
);
|
);
|
||||||
continue;
|
return Err(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(payload_count, updated)
|
|
||||||
};
|
|
||||||
report_context.record_refresh_success(
|
report_context.record_refresh_success(
|
||||||
attempted_at,
|
attempted_at,
|
||||||
source_to_delta_started.elapsed().as_millis(),
|
source_to_delta_started.elapsed().as_millis(),
|
||||||
@ -349,7 +472,8 @@ fn spawn_refresh_task(
|
|||||||
quality,
|
quality,
|
||||||
);
|
);
|
||||||
info!(
|
info!(
|
||||||
"RTR source-to-delta timing: phase=refresh_cache_update_complete, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}",
|
"RTR source-to-delta timing: phase={}, ccr_dir={}, payload_count={}, changed={}, elapsed_ms={}",
|
||||||
|
phase,
|
||||||
payload_load_config.ccr_dir,
|
payload_load_config.ccr_dir,
|
||||||
payload_count,
|
payload_count,
|
||||||
updated,
|
updated,
|
||||||
@ -363,41 +487,76 @@ fn spawn_refresh_task(
|
|||||||
listener_count
|
listener_count
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
log_cache_memory_stats("refresh_complete", &shared_cache, ¬ifier);
|
log_cache_memory_stats(phase, shared_cache, notifier);
|
||||||
report_context.write_source_or_warn(
|
report_context.write_source_or_warn(report_dir, phase, shared_cache, notifier, service_stats);
|
||||||
&report_dir,
|
last_fingerprint.replace(current_fingerprint);
|
||||||
"refresh_complete",
|
|
||||||
&shared_cache,
|
Ok(SourceReloadResult {
|
||||||
¬ifier,
|
phase,
|
||||||
&service_stats,
|
changed: updated,
|
||||||
);
|
skipped_unchanged: false,
|
||||||
last_fingerprint = Some(current_fingerprint);
|
payload_count: Some(payload_count),
|
||||||
}
|
serials: shared_cache.load_full().serials(),
|
||||||
Err(err) => {
|
|
||||||
report_context.record_refresh_failure(
|
|
||||||
attempted_at,
|
|
||||||
source_to_delta_started.elapsed().as_millis(),
|
|
||||||
&err,
|
|
||||||
);
|
|
||||||
warn!(
|
|
||||||
"failed to reload CCR/SLURM payloads from {}: {:?} (source_to_delta_elapsed_ms={})",
|
|
||||||
payload_load_config.ccr_dir,
|
|
||||||
err,
|
|
||||||
source_to_delta_started.elapsed().as_millis()
|
|
||||||
);
|
|
||||||
report_context.write_source_or_warn(
|
|
||||||
&report_dir,
|
|
||||||
"refresh_failed",
|
|
||||||
&shared_cache,
|
|
||||||
¬ifier,
|
|
||||||
&service_stats,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
857
src/rtr/admin.rs
Normal file
@ -0,0 +1,857 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::rtr::config::{RuntimeConfig, RuntimeConfigPatch};
|
||||||
|
use crate::slurm::admin::{
|
||||||
|
SlurmAdmin, SlurmFileActionRequest, SlurmFileOperationResult, SlurmFileWriteRequest,
|
||||||
|
parse_reload_query,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct RuntimeConfigHandle {
|
||||||
|
current: Arc<RwLock<RuntimeConfig>>,
|
||||||
|
tx: watch::Sender<RuntimeConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SourceReloadHandle {
|
||||||
|
tx: mpsc::Sender<SourceReloadCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceReloadHandle {
|
||||||
|
pub fn new(tx: mpsc::Sender<SourceReloadCommand>) -> Self {
|
||||||
|
Self { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reload(&self, phase: &'static str, force: bool) -> Result<SourceReloadResult> {
|
||||||
|
let (respond_to, response) = oneshot::channel();
|
||||||
|
self.tx
|
||||||
|
.send(SourceReloadCommand {
|
||||||
|
phase,
|
||||||
|
force,
|
||||||
|
respond_to,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("source reload task is not available"))?;
|
||||||
|
response
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("source reload task dropped the response"))?
|
||||||
|
.map_err(anyhow::Error::msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SourceReloadCommand {
|
||||||
|
pub phase: &'static str,
|
||||||
|
pub force: bool,
|
||||||
|
pub respond_to: oneshot::Sender<Result<SourceReloadResult, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SourceReloadResult {
|
||||||
|
pub phase: &'static str,
|
||||||
|
pub changed: bool,
|
||||||
|
pub skipped_unchanged: bool,
|
||||||
|
pub payload_count: Option<usize>,
|
||||||
|
pub serials: [u32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminState {
|
||||||
|
runtime_config: RuntimeConfigHandle,
|
||||||
|
source_reload: Option<SourceReloadHandle>,
|
||||||
|
slurm_admin: Option<SlurmAdmin>,
|
||||||
|
log_tail: LogTailConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdminState {
|
||||||
|
pub fn new(
|
||||||
|
runtime_config: RuntimeConfigHandle,
|
||||||
|
source_reload: Option<SourceReloadHandle>,
|
||||||
|
slurm_admin: Option<SlurmAdmin>,
|
||||||
|
log_tail: LogTailConfig,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
runtime_config,
|
||||||
|
source_reload,
|
||||||
|
slurm_admin,
|
||||||
|
log_tail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LogTailConfig {
|
||||||
|
dir: PathBuf,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogTailConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let dir = std::env::var_os("RPKI_RTR_LOG_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/app/logs"));
|
||||||
|
let name = std::env::var("RPKI_RTR_LOG_NAME")
|
||||||
|
.or_else(|_| std::env::var("HOSTNAME"))
|
||||||
|
.unwrap_or_else(|_| "rpki-rtr".to_string());
|
||||||
|
Self { dir, name }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for(&self, stream: LogStream) -> PathBuf {
|
||||||
|
self.dir
|
||||||
|
.join(format!("{}.{}.log", self.name, stream.as_str()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeConfigHandle {
|
||||||
|
pub fn new(config: RuntimeConfig) -> Self {
|
||||||
|
let (tx, _) = watch::channel(config.clone());
|
||||||
|
Self {
|
||||||
|
current: Arc::new(RwLock::new(config)),
|
||||||
|
tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current(&self) -> RuntimeConfig {
|
||||||
|
self.current
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<RuntimeConfig> {
|
||||||
|
self.tx.subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result<RuntimeConfig> {
|
||||||
|
let next = self.current().apply_patch(patch)?;
|
||||||
|
{
|
||||||
|
let mut current = self
|
||||||
|
.current
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
*current = next.clone();
|
||||||
|
}
|
||||||
|
let _ = self.tx.send_replace(next.clone());
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_admin_config_server(
|
||||||
|
addr: SocketAddr,
|
||||||
|
token: Option<String>,
|
||||||
|
state: AdminState,
|
||||||
|
) -> tokio::task::JoinHandle<()> {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = run_admin_config_server(addr, token, state).await {
|
||||||
|
warn!("RTR admin config server exited: {:?}", err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_admin_config_server(
|
||||||
|
addr: SocketAddr,
|
||||||
|
token: Option<String>,
|
||||||
|
state: AdminState,
|
||||||
|
) -> Result<()> {
|
||||||
|
if token.is_none() && !addr.ip().is_loopback() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"RPKI_RTR_ADMIN_TOKEN is required when admin addr is not loopback: {}",
|
||||||
|
addr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("bind RTR admin config server on {addr}"))?;
|
||||||
|
info!("RTR admin config server listening on {}", addr);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, peer_addr) = listener.accept().await?;
|
||||||
|
let token = token.clone();
|
||||||
|
let state = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle_admin_connection(stream, peer_addr, token, state).await {
|
||||||
|
warn!(
|
||||||
|
"RTR admin config request failed from {}: {:?}",
|
||||||
|
peer_addr, err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_admin_connection(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
token: Option<String>,
|
||||||
|
state: AdminState,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut buffer = vec![0u8; 64 * 1024];
|
||||||
|
let mut read = 0usize;
|
||||||
|
let header_end = loop {
|
||||||
|
if read == buffer.len() {
|
||||||
|
write_response(&mut stream, 413, "payload too large", "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let n = stream.read(&mut buffer[read..]).await?;
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
read += n;
|
||||||
|
if let Some(pos) = find_header_end(&buffer[..read]) {
|
||||||
|
break pos;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = std::str::from_utf8(&buffer[..header_end])
|
||||||
|
.map_err(|err| anyhow!("invalid HTTP header from {}: {}", peer_addr, err))?;
|
||||||
|
let request = parse_request_header(header)?;
|
||||||
|
|
||||||
|
if let Some(token) = token.as_deref() {
|
||||||
|
let expected = format!("Bearer {token}");
|
||||||
|
if request.authorization.as_deref() != Some(expected.as_str()) {
|
||||||
|
write_response(&mut stream, 401, "unauthorized", "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_length = request.content_length.unwrap_or(0);
|
||||||
|
let max_body_bytes = if request.path.starts_with("/admin/rtr/slurm/") {
|
||||||
|
state
|
||||||
|
.slurm_admin
|
||||||
|
.as_ref()
|
||||||
|
.map(SlurmAdmin::max_body_bytes)
|
||||||
|
.unwrap_or(32 * 1024)
|
||||||
|
} else {
|
||||||
|
32 * 1024
|
||||||
|
};
|
||||||
|
if content_length > max_body_bytes {
|
||||||
|
write_response(&mut stream, 413, "payload too large", "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_start = header_end + 4;
|
||||||
|
let available_body = read.saturating_sub(body_start);
|
||||||
|
let mut body = Vec::with_capacity(content_length);
|
||||||
|
body.extend_from_slice(&buffer[body_start..read]);
|
||||||
|
if available_body < content_length {
|
||||||
|
let remaining = content_length - available_body;
|
||||||
|
let mut tail = vec![0u8; remaining];
|
||||||
|
stream.read_exact(&mut tail).await?;
|
||||||
|
body.extend_from_slice(&tail);
|
||||||
|
}
|
||||||
|
body.truncate(content_length);
|
||||||
|
|
||||||
|
route_admin_request(&mut stream, request, body, state, peer_addr).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn route_admin_request(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
request: RequestHeader,
|
||||||
|
body: Vec<u8>,
|
||||||
|
state: AdminState,
|
||||||
|
peer_addr: SocketAddr,
|
||||||
|
) -> Result<()> {
|
||||||
|
if request.method == "POST" && request.path == "/admin/rtr/config" {
|
||||||
|
let patch = match serde_json::from_slice::<RuntimeConfigPatch>(&body) {
|
||||||
|
Ok(patch) => patch,
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("invalid json: {err}");
|
||||||
|
write_response(stream, 400, &message, "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match state.runtime_config.apply_patch(patch) {
|
||||||
|
Ok(config) => {
|
||||||
|
info!("RTR admin config updated from {}: {:?}", peer_addr, config);
|
||||||
|
let json = serde_json::to_string_pretty(&AdminConfigResponse {
|
||||||
|
status: "ok",
|
||||||
|
config,
|
||||||
|
})?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("invalid config: {err}");
|
||||||
|
write_response(stream, 400, &message, "text/plain").await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "GET" && request.path == "/admin/rtr/health" {
|
||||||
|
let json = serde_json::to_string_pretty(&AdminHealthResponse {
|
||||||
|
status: "ok",
|
||||||
|
config_api: true,
|
||||||
|
source_reload_api: state.source_reload.is_some(),
|
||||||
|
slurm_api: state.slurm_admin.is_some(),
|
||||||
|
logs_api: true,
|
||||||
|
})?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "GET" && request.path == "/admin/rtr/logs/tail" {
|
||||||
|
return tail_log_stream(stream, request.query.as_deref(), &state.log_tail).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "GET" && request.path == "/admin/rtr/config" {
|
||||||
|
let json = serde_json::to_string_pretty(&AdminConfigResponse {
|
||||||
|
status: "ok",
|
||||||
|
config: state.runtime_config.current(),
|
||||||
|
})?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST" && request.path == "/admin/rtr/slurm/reload" {
|
||||||
|
let reload = reload_source(&state, "admin_slurm_reload", true).await;
|
||||||
|
return write_json_or_error(stream, reload).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.path == "/admin/rtr/slurm/files" && request.method == "GET" {
|
||||||
|
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
|
||||||
|
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&SlurmFileListResponse {
|
||||||
|
status: "ok",
|
||||||
|
files: slurm_admin.list_files()?,
|
||||||
|
})?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.path == "/admin/rtr/slurm/files" && request.method == "POST" {
|
||||||
|
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
|
||||||
|
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let request_body = parse_json::<SlurmFileWriteRequest>(stream, &body).await?;
|
||||||
|
let Some(name) = request_body.name.as_deref() else {
|
||||||
|
write_response(stream, 400, "missing SLURM file name", "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let reload = request_body.reload.unwrap_or(false);
|
||||||
|
let operation = slurm_admin.put_file(name, &request_body.content, "create_or_update");
|
||||||
|
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slurm_file_prefix = "/admin/rtr/slurm/files/";
|
||||||
|
if request.path.starts_with(slurm_file_prefix) {
|
||||||
|
let rest = request.path[slurm_file_prefix.len()..].to_string();
|
||||||
|
return route_slurm_file_request(stream, request, body, state, rest).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_response(stream, 404, "not found", "text/plain").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn route_slurm_file_request(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
request: RequestHeader,
|
||||||
|
body: Vec<u8>,
|
||||||
|
state: AdminState,
|
||||||
|
rest: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(slurm_admin) = state.slurm_admin.as_ref() else {
|
||||||
|
write_response(stream, 400, "SLURM admin is disabled", "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if request.method == "GET" {
|
||||||
|
let file = match slurm_admin.read_file(&rest) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("invalid SLURM file request: {err}");
|
||||||
|
write_response(stream, 400, &message, "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&SlurmFileContentResponse { status: "ok", file })?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "PUT" {
|
||||||
|
let request_body = parse_json::<SlurmFileWriteRequest>(stream, &body).await?;
|
||||||
|
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
|
||||||
|
let operation = slurm_admin.put_file(&rest, &request_body.content, "create_or_update");
|
||||||
|
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "DELETE" {
|
||||||
|
let reload = if body.is_empty() {
|
||||||
|
parse_reload_query(request.query.as_deref(), None)
|
||||||
|
} else {
|
||||||
|
let request_body = parse_json::<SlurmFileActionRequest>(stream, &body).await?;
|
||||||
|
parse_reload_query(request.query.as_deref(), request_body.reload)
|
||||||
|
};
|
||||||
|
let operation = slurm_admin.delete_file(&rest);
|
||||||
|
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST" {
|
||||||
|
if let Some(name) = rest.strip_suffix("/enable") {
|
||||||
|
let request_body: SlurmFileActionRequest = parse_optional_json(stream, &body).await?;
|
||||||
|
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
|
||||||
|
let operation = slurm_admin.enable_file(name);
|
||||||
|
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||||
|
}
|
||||||
|
if let Some(name) = rest.strip_suffix("/disable") {
|
||||||
|
let request_body: SlurmFileActionRequest = parse_optional_json(stream, &body).await?;
|
||||||
|
let reload = parse_reload_query(request.query.as_deref(), request_body.reload);
|
||||||
|
let operation = slurm_admin.disable_file(name);
|
||||||
|
return apply_slurm_operation(stream, &state, operation, reload).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write_response(stream, 404, "not found", "text/plain").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_slurm_operation(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
state: &AdminState,
|
||||||
|
operation: Result<crate::slurm::admin::AppliedSlurmFileOperation>,
|
||||||
|
reload: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let operation = match operation {
|
||||||
|
Ok(operation) => operation,
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("invalid SLURM operation: {err}");
|
||||||
|
write_response(stream, 400, &message, "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !reload {
|
||||||
|
let json = serde_json::to_string_pretty(&SlurmOperationResponse {
|
||||||
|
status: "ok",
|
||||||
|
operation: operation.result,
|
||||||
|
reload: None,
|
||||||
|
rollback: None,
|
||||||
|
})?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match reload_source(state, "admin_slurm_changed", true).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let json = serde_json::to_string_pretty(&SlurmOperationResponse {
|
||||||
|
status: "ok",
|
||||||
|
operation: operation.result,
|
||||||
|
reload: Some(result),
|
||||||
|
rollback: None,
|
||||||
|
})?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let rollback = match operation.rollback() {
|
||||||
|
Ok(()) => match reload_source(state, "admin_slurm_rollback", true).await {
|
||||||
|
Ok(result) => Some(RollbackReport {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
reload: Some(result),
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
Err(reload_err) => Some(RollbackReport {
|
||||||
|
status: "reload_failed".to_string(),
|
||||||
|
reload: None,
|
||||||
|
error: Some(reload_err.to_string()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Err(rollback_err) => Some(RollbackReport {
|
||||||
|
status: "failed".to_string(),
|
||||||
|
reload: None,
|
||||||
|
error: Some(rollback_err.to_string()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string_pretty(&SlurmOperationErrorResponse {
|
||||||
|
status: "reload_failed",
|
||||||
|
error: err.to_string(),
|
||||||
|
rollback,
|
||||||
|
})?;
|
||||||
|
write_response(stream, 400, &json, "application/json").await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_source(
|
||||||
|
state: &AdminState,
|
||||||
|
phase: &'static str,
|
||||||
|
force: bool,
|
||||||
|
) -> Result<SourceReloadResult> {
|
||||||
|
let Some(source_reload) = state.source_reload.as_ref() else {
|
||||||
|
return Err(anyhow!("source reload is not available"));
|
||||||
|
};
|
||||||
|
source_reload.reload(phase, force).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_json<T: for<'de> Deserialize<'de>>(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<T> {
|
||||||
|
match serde_json::from_slice::<T>(body) {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("invalid json: {err}");
|
||||||
|
write_response(stream, 400, &message, "text/plain").await?;
|
||||||
|
Err(anyhow!(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_optional_json<T: for<'de> Deserialize<'de> + Default>(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<T> {
|
||||||
|
if body.is_empty() {
|
||||||
|
return Ok(T::default());
|
||||||
|
}
|
||||||
|
parse_json(stream, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_json_or_error<T: Serialize>(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
result: Result<T>,
|
||||||
|
) -> Result<()> {
|
||||||
|
match result {
|
||||||
|
Ok(value) => {
|
||||||
|
let json = serde_json::to_string_pretty(&value)?;
|
||||||
|
write_response(stream, 200, &json, "application/json").await?;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("reload failed: {err}");
|
||||||
|
write_response(stream, 400, &message, "text/plain").await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum LogStream {
|
||||||
|
Stdout,
|
||||||
|
Stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogStream {
|
||||||
|
fn parse(value: Option<&str>) -> Result<Self> {
|
||||||
|
match value.unwrap_or("stdout") {
|
||||||
|
"stdout" => Ok(Self::Stdout),
|
||||||
|
"stderr" => Ok(Self::Stderr),
|
||||||
|
other => Err(anyhow!(
|
||||||
|
"invalid stream '{}': expected 'stdout' or 'stderr'",
|
||||||
|
other
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Stdout => "stdout",
|
||||||
|
Self::Stderr => "stderr",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LogTailRequest {
|
||||||
|
stream: LogStream,
|
||||||
|
lines: usize,
|
||||||
|
follow: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogTailRequest {
|
||||||
|
fn parse(query: Option<&str>) -> Result<Self> {
|
||||||
|
let stream = LogStream::parse(query_param(query, "stream"))?;
|
||||||
|
let lines = query_param(query, "lines")
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|err| anyhow!("invalid lines '{}': {}", value, err))
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(200)
|
||||||
|
.clamp(1, 5000);
|
||||||
|
let follow = query_param(query, "follow")
|
||||||
|
.map(|value| parse_bool_query(value, "follow"))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(true);
|
||||||
|
Ok(Self {
|
||||||
|
stream,
|
||||||
|
lines,
|
||||||
|
follow,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn tail_log_stream(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
query: Option<&str>,
|
||||||
|
config: &LogTailConfig,
|
||||||
|
) -> Result<()> {
|
||||||
|
let request = match LogTailRequest::parse(query) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("invalid log tail request: {err}");
|
||||||
|
write_response(stream, 400, &message, "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let path = config.path_for(request.stream);
|
||||||
|
if !Path::new(&path).is_file() {
|
||||||
|
let message = format!("log file not found: {}", path.display());
|
||||||
|
write_response(stream, 404, &message, "text/plain").await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
write_chunked_headers(stream, "text/plain; charset=utf-8").await?;
|
||||||
|
let (tail, mut offset) = read_tail(&path, request.lines).await?;
|
||||||
|
if !tail.is_empty() {
|
||||||
|
write_chunk(stream, &tail).await?;
|
||||||
|
}
|
||||||
|
if !request.follow {
|
||||||
|
write_final_chunk(stream).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
let metadata = match tokio::fs::metadata(&path).await {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(err) => {
|
||||||
|
let message = format!("\nlog file unavailable: {err}\n");
|
||||||
|
let _ = write_chunk(stream, message.as_bytes()).await;
|
||||||
|
let _ = write_final_chunk(stream).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let len = metadata.len();
|
||||||
|
if len < offset {
|
||||||
|
offset = 0;
|
||||||
|
write_chunk(stream, b"\nlog file truncated; restarting from beginning\n").await?;
|
||||||
|
}
|
||||||
|
if len == offset {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut file = tokio::fs::File::open(&path).await?;
|
||||||
|
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf).await?;
|
||||||
|
offset = len;
|
||||||
|
if !buf.is_empty() {
|
||||||
|
write_chunk(stream, &buf).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_tail(path: &Path, lines: usize) -> Result<(Vec<u8>, u64)> {
|
||||||
|
const MAX_INITIAL_TAIL_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
let metadata = tokio::fs::metadata(path).await?;
|
||||||
|
let len = metadata.len();
|
||||||
|
let start = len.saturating_sub(MAX_INITIAL_TAIL_BYTES);
|
||||||
|
let mut file = tokio::fs::File::open(path).await?;
|
||||||
|
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf).await?;
|
||||||
|
Ok((tail_log_lines(buf, lines), len))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tail_log_lines(buf: Vec<u8>, lines: usize) -> Vec<u8> {
|
||||||
|
if lines == 0 || buf.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen = 0usize;
|
||||||
|
for (idx, byte) in buf.iter().enumerate().rev() {
|
||||||
|
if *byte == b'\n' {
|
||||||
|
seen += 1;
|
||||||
|
if seen > lines {
|
||||||
|
return buf[idx + 1..].to_vec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_param<'a>(query: Option<&'a str>, key: &str) -> Option<&'a str> {
|
||||||
|
query?.split('&').find_map(|part| {
|
||||||
|
let (name, value) = part.split_once('=').unwrap_or((part, ""));
|
||||||
|
(name == key).then_some(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool_query(value: &str, name: &str) -> Result<bool> {
|
||||||
|
match value {
|
||||||
|
"true" | "1" | "yes" | "on" => Ok(true),
|
||||||
|
"false" | "0" | "no" | "off" => Ok(false),
|
||||||
|
_ => Err(anyhow!("invalid {} '{}': expected true/false", name, value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_chunked_headers(stream: &mut TcpStream, content_type: &str) -> Result<()> {
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ntransfer-encoding: chunked\r\ncache-control: no-store\r\nconnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_chunk(stream: &mut TcpStream, chunk: &[u8]) -> Result<()> {
|
||||||
|
if chunk.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let header = format!("{:x}\r\n", chunk.len());
|
||||||
|
stream.write_all(header.as_bytes()).await?;
|
||||||
|
stream.write_all(chunk).await?;
|
||||||
|
stream.write_all(b"\r\n").await?;
|
||||||
|
stream.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_final_chunk(stream: &mut TcpStream) -> Result<()> {
|
||||||
|
stream.write_all(b"0\r\n\r\n").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AdminHealthResponse {
|
||||||
|
status: &'static str,
|
||||||
|
config_api: bool,
|
||||||
|
source_reload_api: bool,
|
||||||
|
slurm_api: bool,
|
||||||
|
logs_api: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AdminConfigResponse {
|
||||||
|
status: &'static str,
|
||||||
|
config: RuntimeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SlurmFileListResponse {
|
||||||
|
status: &'static str,
|
||||||
|
files: Vec<crate::slurm::admin::SlurmFileListEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SlurmFileContentResponse {
|
||||||
|
status: &'static str,
|
||||||
|
file: crate::slurm::admin::SlurmFileContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SlurmOperationResponse {
|
||||||
|
status: &'static str,
|
||||||
|
operation: SlurmFileOperationResult,
|
||||||
|
reload: Option<SourceReloadResult>,
|
||||||
|
rollback: Option<RollbackReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SlurmOperationErrorResponse {
|
||||||
|
status: &'static str,
|
||||||
|
error: String,
|
||||||
|
rollback: Option<RollbackReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RollbackReport {
|
||||||
|
status: String,
|
||||||
|
reload: Option<SourceReloadResult>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RequestHeader {
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
query: Option<String>,
|
||||||
|
content_length: Option<usize>,
|
||||||
|
authorization: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_request_header(header: &str) -> Result<RequestHeader> {
|
||||||
|
let mut lines = header.lines();
|
||||||
|
let request_line = lines
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow!("missing HTTP request line"))?;
|
||||||
|
let mut parts = request_line.split_whitespace();
|
||||||
|
let method = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow!("missing HTTP method"))?
|
||||||
|
.to_string();
|
||||||
|
let target = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow!("missing HTTP path"))?
|
||||||
|
.to_string();
|
||||||
|
let (path, query) = match target.split_once('?') {
|
||||||
|
Some((path, query)) => (path.to_string(), Some(query.to_string())),
|
||||||
|
None => (target, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut content_length = None;
|
||||||
|
let mut authorization = None;
|
||||||
|
for line in lines {
|
||||||
|
let Some((name, value)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = name.trim().to_ascii_lowercase();
|
||||||
|
let value = value.trim();
|
||||||
|
match name.as_str() {
|
||||||
|
"content-length" => {
|
||||||
|
content_length = Some(
|
||||||
|
value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|err| anyhow!("invalid content-length '{}': {}", value, err))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"authorization" => authorization = Some(value.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RequestHeader {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
content_length,
|
||||||
|
authorization,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_header_end(buffer: &[u8]) -> Option<usize> {
|
||||||
|
buffer.windows(4).position(|window| window == b"\r\n\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_response(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
status: u16,
|
||||||
|
body: &str,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let reason = match status {
|
||||||
|
200 => "OK",
|
||||||
|
400 => "Bad Request",
|
||||||
|
401 => "Unauthorized",
|
||||||
|
404 => "Not Found",
|
||||||
|
413 => "Payload Too Large",
|
||||||
|
_ => "Internal Server Error",
|
||||||
|
};
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 {status} {reason}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
85
src/rtr/cache/core.rs
vendored
85
src/rtr/cache/core.rs
vendored
@ -277,12 +277,20 @@ impl RtrCache {
|
|||||||
max_delta: u8,
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/rtr/cache/model.rs
vendored
10
src/rtr/cache/model.rs
vendored
@ -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
|
||||||
|
|||||||
14
src/rtr/cache/store.rs
vendored
14
src/rtr/cache/store.rs
vendored
@ -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(
|
||||||
|
|||||||
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
|
||||||
¬ifier,
|
|
||||||
&service.stats(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let source_report = read_single_report(&report_dir, "rtr-source");
|
|
||||||
assert_eq!(source_report["schema_version"], 2);
|
|
||||||
assert_eq!(source_report["phase"], "test");
|
|
||||||
assert_report_time_offset(&source_report["generated_at"]);
|
|
||||||
assert_report_time_offset(&source_report["cache"]["created_at"]);
|
|
||||||
assert_eq!(source_report["cache"]["availability"], "ready");
|
|
||||||
assert_eq!(source_report["refresh"]["status"], "not_attempted");
|
|
||||||
assert!(source_report["source"].is_null());
|
|
||||||
assert!(source_report["data_quality"].is_null());
|
|
||||||
|
|
||||||
let runtime_report = read_single_report(&report_dir, "rtr-runtime");
|
|
||||||
assert_report_time_offset(&runtime_report["service"]["started_at"]);
|
|
||||||
assert_eq!(
|
|
||||||
runtime_report["configuration"]["source_refresh_interval_seconds"],
|
|
||||||
300
|
|
||||||
);
|
|
||||||
assert_eq!(runtime_report["configuration"]["report_history_limit"], 10);
|
|
||||||
|
|
||||||
let clients_report = read_single_report(&report_dir, "rtr-clients");
|
|
||||||
assert_eq!(clients_report["service"]["max_connections"], 1024);
|
|
||||||
assert_eq!(clients_report["service"]["active_connections"], 0);
|
|
||||||
assert_eq!(
|
|
||||||
clients_report["service"]["connections_by_transport"]["tcp"],
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
clients_report["service"]["connections_by_transport"]["tls"],
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
clients_report["service"]["connections_by_transport"]["ssh"],
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
source_report["cache"]["versions"].as_array().unwrap().len(),
|
|
||||||
3
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
source_report["cache"]["versions"][2]["snapshot"]["total"],
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
source_report["cache"]["memory"]["delta_payload_counts"][2],
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert!(source_report["source_fingerprint"].is_null());
|
|
||||||
assert_no_temporary_reports(&report_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn refresh_failure_preserves_last_successful_source_data() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let report_dir = temp.path().join("report");
|
|
||||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
|
||||||
let service = RtrService::new(shared_cache.clone());
|
|
||||||
let notifier = service.notifier();
|
|
||||||
let context = ReportContext::new(test_configuration());
|
|
||||||
let source = SourceLoadReport {
|
|
||||||
ccr_file: "data/example.ccr".to_string(),
|
|
||||||
ccr_file_size_bytes: 123,
|
|
||||||
ccr_modified_at: Some(Utc::now()),
|
|
||||||
ccr_produced_at: Some("20260615000000Z".to_string()),
|
|
||||||
slurm_enabled: true,
|
|
||||||
slurm_file_count: 1,
|
|
||||||
slurm_files: vec!["policy.slurm".to_string()],
|
|
||||||
slurm_version: Some(2),
|
|
||||||
};
|
|
||||||
let quality = DataQualityReport {
|
|
||||||
ccr_input: PayloadTypeCounts {
|
|
||||||
total: 11,
|
|
||||||
vrp: 10,
|
|
||||||
router_key: 0,
|
|
||||||
aspa: 1,
|
|
||||||
},
|
|
||||||
invalid: PayloadTypeCounts::default(),
|
|
||||||
before_slurm: PayloadTypeCounts {
|
|
||||||
total: 11,
|
|
||||||
vrp: 10,
|
|
||||||
router_key: 0,
|
|
||||||
aspa: 1,
|
|
||||||
},
|
|
||||||
after_slurm: PayloadTypeCounts {
|
|
||||||
total: 10,
|
|
||||||
vrp: 9,
|
|
||||||
router_key: 0,
|
|
||||||
aspa: 1,
|
|
||||||
},
|
|
||||||
slurm_filters: SlurmRuleCounts {
|
|
||||||
prefix: 1,
|
|
||||||
router_key: 0,
|
|
||||||
aspa: 0,
|
|
||||||
},
|
|
||||||
slurm_assertions: SlurmRuleCounts::default(),
|
|
||||||
};
|
|
||||||
context.record_source_fingerprint(SourceFingerprint {
|
|
||||||
ccr: FileFingerprint {
|
|
||||||
path: "data/example.ccr".to_string(),
|
|
||||||
len: 123,
|
|
||||||
modified_unix_secs: 1_781_404_800,
|
|
||||||
},
|
|
||||||
slurm_files: vec![FileFingerprint {
|
|
||||||
path: "policy.slurm".to_string(),
|
|
||||||
len: 42,
|
|
||||||
modified_unix_secs: 1_781_408_400,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
context.record_refresh_success(Utc::now(), 12, true, source, quality);
|
|
||||||
context.record_refresh_failure(Utc::now(), 5, &anyhow::anyhow!("source unavailable"));
|
|
||||||
context
|
|
||||||
.write(
|
|
||||||
&report_dir,
|
|
||||||
"refresh_failed",
|
|
||||||
&shared_cache,
|
|
||||||
¬ifier,
|
|
||||||
&service.stats(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let report = read_single_report(&report_dir, "rtr-source");
|
|
||||||
assert_eq!(report["source"]["ccr_file"], "data/example.ccr");
|
|
||||||
assert_eq!(
|
|
||||||
report["source_fingerprint"]["ccr"]["path"],
|
|
||||||
"data/example.ccr"
|
|
||||||
);
|
|
||||||
assert_eq!(report["source_fingerprint"]["ccr"]["len"], 123);
|
|
||||||
assert_report_time_offset(&report["source_fingerprint"]["ccr"]["modified_at"]);
|
|
||||||
assert_eq!(
|
|
||||||
report["source_fingerprint"]["slurm_files"][0]["path"],
|
|
||||||
"policy.slurm"
|
|
||||||
);
|
|
||||||
assert_report_time_offset(&report["source"]["ccr_modified_at"]);
|
|
||||||
assert_eq!(report["data_quality"]["after_slurm"]["total"], 10);
|
|
||||||
assert_eq!(report["refresh"]["status"], "failed");
|
|
||||||
assert_eq!(report["refresh"]["consecutive_failures"], 1);
|
|
||||||
assert_eq!(report["refresh"]["last_error"], "source unavailable");
|
|
||||||
assert!(!report["refresh"]["last_success_at"].is_null());
|
|
||||||
assert_report_time_offset(&report["refresh"]["last_success_at"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rolling_reports_keep_latest_files_per_category() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let report_dir = temp.path().join("report");
|
|
||||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
|
||||||
let service = RtrService::new(shared_cache.clone());
|
|
||||||
let notifier = service.notifier();
|
|
||||||
let mut configuration = test_configuration();
|
|
||||||
configuration.report_history_limit = 2;
|
|
||||||
let context = ReportContext::new(configuration);
|
|
||||||
|
|
||||||
for phase in ["one", "two", "three"] {
|
|
||||||
context
|
|
||||||
.write(
|
|
||||||
&report_dir,
|
|
||||||
phase,
|
|
||||||
&shared_cache,
|
|
||||||
¬ifier,
|
|
||||||
&service.stats(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 2);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 2);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 2);
|
|
||||||
assert_no_temporary_reports(&report_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn category_writes_only_create_requested_report_type() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let report_dir = temp.path().join("report");
|
|
||||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
|
||||||
let service = RtrService::new(shared_cache.clone());
|
|
||||||
let notifier = service.notifier();
|
|
||||||
let context = ReportContext::new(test_configuration());
|
|
||||||
|
|
||||||
context
|
|
||||||
.write_source(
|
|
||||||
&report_dir,
|
|
||||||
"source_only",
|
|
||||||
&shared_cache,
|
|
||||||
¬ifier,
|
|
||||||
&service.stats(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 0);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
|
||||||
|
|
||||||
context
|
|
||||||
.write_clients(
|
|
||||||
&report_dir,
|
|
||||||
"clients_only",
|
|
||||||
&shared_cache,
|
|
||||||
¬ifier,
|
|
||||||
&service.stats(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
|
||||||
|
|
||||||
context
|
|
||||||
.write_runtime(
|
|
||||||
&report_dir,
|
|
||||||
"runtime_only",
|
|
||||||
&shared_cache,
|
|
||||||
¬ifier,
|
|
||||||
&service.stats(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
|
||||||
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_report_time_offset(value: &Value) {
|
|
||||||
let value = value.as_str().expect("report time should be a string");
|
|
||||||
assert!(
|
|
||||||
value.ends_with("+08:00"),
|
|
||||||
"report time should use +08:00 offset, got {value}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_single_report(report_dir: &Path, prefix: &str) -> Value {
|
|
||||||
let files = report_files(report_dir, prefix);
|
|
||||||
assert_eq!(files.len(), 1, "expected one {prefix} report");
|
|
||||||
serde_json::from_slice(&fs::read(&files[0]).unwrap()).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn report_files(report_dir: &Path, prefix: &str) -> Vec<PathBuf> {
|
|
||||||
let start = format!("{prefix}-");
|
|
||||||
let mut files = fs::read_dir(report_dir)
|
|
||||||
.unwrap()
|
|
||||||
.map(|entry| entry.unwrap().path())
|
|
||||||
.filter(|path| {
|
|
||||||
path.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.is_some_and(|name| name.starts_with(&start) && name.ends_with(".json"))
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
files.sort();
|
|
||||||
files
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_no_temporary_reports(report_dir: &Path) {
|
|
||||||
let has_temporary = fs::read_dir(report_dir).unwrap().any(|entry| {
|
|
||||||
entry
|
|
||||||
.unwrap()
|
|
||||||
.file_name()
|
|
||||||
.to_str()
|
|
||||||
.is_some_and(|name| name.ends_with(".tmp"))
|
|
||||||
});
|
|
||||||
assert!(!has_temporary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -947,7 +947,12 @@ where
|
|||||||
Ok(())
|
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,
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
391
src/slurm/admin.rs
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::file::SlurmFile;
|
||||||
|
|
||||||
|
const MAX_SLURM_BODY_BYTES: usize = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SlurmFileWriteRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub content: String,
|
||||||
|
pub reload: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct SlurmFileActionRequest {
|
||||||
|
pub reload: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlurmFileOperationResult {
|
||||||
|
pub operation: &'static str,
|
||||||
|
pub file: String,
|
||||||
|
pub backup: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlurmFileListEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub modified_unix_seconds: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SlurmFileContent {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub modified_unix_seconds: Option<u64>,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppliedSlurmFileOperation {
|
||||||
|
pub result: SlurmFileOperationResult,
|
||||||
|
undo: UndoAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppliedSlurmFileOperation {
|
||||||
|
pub fn rollback(self) -> Result<()> {
|
||||||
|
self.undo.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum UndoAction {
|
||||||
|
Delete { path: PathBuf },
|
||||||
|
Restore { path: PathBuf, backup: PathBuf },
|
||||||
|
Rename { from: PathBuf, to: PathBuf },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UndoAction {
|
||||||
|
fn apply(self) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Delete { path } => {
|
||||||
|
if path.exists() {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.with_context(|| format!("remove rollback file {}", path.display()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Restore { path, backup } => {
|
||||||
|
fs::copy(&backup, &path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"restore rollback file {} from {}",
|
||||||
|
path.display(),
|
||||||
|
backup.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Self::Rename { from, to } => {
|
||||||
|
if from.exists() {
|
||||||
|
fs::rename(&from, &to).with_context(|| {
|
||||||
|
format!("rollback rename {} to {}", from.display(), to.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SlurmAdmin {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlurmAdmin {
|
||||||
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { dir: dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_body_bytes(&self) -> usize {
|
||||||
|
MAX_SLURM_BODY_BYTES
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_files(&self) -> Result<Vec<SlurmFileListEntry>> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
if !self.dir.exists() {
|
||||||
|
return Ok(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in fs::read_dir(&self.dir)
|
||||||
|
.with_context(|| format!("read SLURM directory {}", self.dir.display()))?
|
||||||
|
{
|
||||||
|
let entry = entry.with_context(|| format!("read entry in {}", self.dir.display()))?;
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if validate_slurm_file_name(name).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let metadata = entry
|
||||||
|
.metadata()
|
||||||
|
.with_context(|| format!("read SLURM file metadata {}", path.display()))?;
|
||||||
|
files.push(SlurmFileListEntry {
|
||||||
|
name: name.to_string(),
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
enabled: name.ends_with(".slurm"),
|
||||||
|
size_bytes: metadata.len(),
|
||||||
|
modified_unix_seconds: modified_unix_seconds(&metadata),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
files.sort_by(|left, right| left.name.cmp(&right.name));
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_file(&self, name: &str) -> Result<SlurmFileContent> {
|
||||||
|
validate_slurm_file_name(name)?;
|
||||||
|
let path = self.file_path(name);
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(anyhow!("SLURM file '{}' does not exist", name));
|
||||||
|
}
|
||||||
|
let metadata = fs::metadata(&path)
|
||||||
|
.with_context(|| format!("read SLURM file metadata {}", path.display()))?;
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("read SLURM file {}", path.display()))?;
|
||||||
|
Ok(SlurmFileContent {
|
||||||
|
name: name.to_string(),
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
enabled: name.ends_with(".slurm"),
|
||||||
|
size_bytes: metadata.len(),
|
||||||
|
modified_unix_seconds: modified_unix_seconds(&metadata),
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_file(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
content: &str,
|
||||||
|
operation: &'static str,
|
||||||
|
) -> Result<AppliedSlurmFileOperation> {
|
||||||
|
validate_slurm_file_name(name)?;
|
||||||
|
validate_slurm_content(content)?;
|
||||||
|
fs::create_dir_all(&self.dir)
|
||||||
|
.with_context(|| format!("create SLURM directory {}", self.dir.display()))?;
|
||||||
|
|
||||||
|
let path = self.file_path(name);
|
||||||
|
let backup = if path.exists() {
|
||||||
|
Some(self.backup_file(&path)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let temporary = self.temporary_file(name);
|
||||||
|
fs::write(&temporary, content.as_bytes())
|
||||||
|
.with_context(|| format!("write temporary SLURM file {}", temporary.display()))?;
|
||||||
|
replace_file(&temporary, &path)?;
|
||||||
|
|
||||||
|
let undo = match backup.as_ref() {
|
||||||
|
Some(backup) => UndoAction::Restore {
|
||||||
|
path: path.clone(),
|
||||||
|
backup: backup.clone(),
|
||||||
|
},
|
||||||
|
None => UndoAction::Delete { path: path.clone() },
|
||||||
|
};
|
||||||
|
Ok(AppliedSlurmFileOperation {
|
||||||
|
result: SlurmFileOperationResult {
|
||||||
|
operation,
|
||||||
|
file: path.to_string_lossy().to_string(),
|
||||||
|
backup: backup.map(|path| path.to_string_lossy().to_string()),
|
||||||
|
},
|
||||||
|
undo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
|
||||||
|
validate_slurm_file_name(name)?;
|
||||||
|
let path = self.file_path(name);
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(anyhow!("SLURM file '{}' does not exist", name));
|
||||||
|
}
|
||||||
|
let backup = self.backup_file(&path)?;
|
||||||
|
fs::remove_file(&path).with_context(|| format!("delete SLURM file {}", path.display()))?;
|
||||||
|
Ok(AppliedSlurmFileOperation {
|
||||||
|
result: SlurmFileOperationResult {
|
||||||
|
operation: "delete",
|
||||||
|
file: path.to_string_lossy().to_string(),
|
||||||
|
backup: Some(backup.to_string_lossy().to_string()),
|
||||||
|
},
|
||||||
|
undo: UndoAction::Restore {
|
||||||
|
path,
|
||||||
|
backup: backup.clone(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enable_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
|
||||||
|
validate_slurm_file_name(name)?;
|
||||||
|
if !name.ends_with(".slurm.disabled") {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"SLURM file '{}' is not disabled; expected .slurm.disabled",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let enabled_name = name.trim_end_matches(".disabled");
|
||||||
|
self.rename_file(name, enabled_name, "enable")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disable_file(&self, name: &str) -> Result<AppliedSlurmFileOperation> {
|
||||||
|
validate_slurm_file_name(name)?;
|
||||||
|
if !name.ends_with(".slurm") {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"SLURM file '{}' is not enabled; expected .slurm",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let disabled_name = format!("{name}.disabled");
|
||||||
|
self.rename_file(name, &disabled_name, "disable")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_file(
|
||||||
|
&self,
|
||||||
|
from_name: &str,
|
||||||
|
to_name: &str,
|
||||||
|
operation: &'static str,
|
||||||
|
) -> Result<AppliedSlurmFileOperation> {
|
||||||
|
validate_slurm_file_name(to_name)?;
|
||||||
|
let from = self.file_path(from_name);
|
||||||
|
let to = self.file_path(to_name);
|
||||||
|
if !from.is_file() {
|
||||||
|
return Err(anyhow!("SLURM file '{}' does not exist", from_name));
|
||||||
|
}
|
||||||
|
if to.exists() {
|
||||||
|
return Err(anyhow!("target SLURM file '{}' already exists", to_name));
|
||||||
|
}
|
||||||
|
fs::rename(&from, &to)
|
||||||
|
.with_context(|| format!("rename SLURM file {} to {}", from.display(), to.display()))?;
|
||||||
|
Ok(AppliedSlurmFileOperation {
|
||||||
|
result: SlurmFileOperationResult {
|
||||||
|
operation,
|
||||||
|
file: to.to_string_lossy().to_string(),
|
||||||
|
backup: None,
|
||||||
|
},
|
||||||
|
undo: UndoAction::Rename { from: to, to: from },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_path(&self, name: &str) -> PathBuf {
|
||||||
|
self.dir.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backup_file(&self, path: &Path) -> Result<PathBuf> {
|
||||||
|
let backup_dir = self.dir.join(".backup");
|
||||||
|
fs::create_dir_all(&backup_dir)
|
||||||
|
.with_context(|| format!("create SLURM backup directory {}", backup_dir.display()))?;
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.ok_or_else(|| anyhow!("invalid SLURM file name '{}'", path.display()))?;
|
||||||
|
let backup = backup_dir.join(format!("{file_name}.{}.bak", timestamp_suffix()));
|
||||||
|
fs::copy(path, &backup).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"backup SLURM file {} to {}",
|
||||||
|
path.display(),
|
||||||
|
backup.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temporary_file(&self, name: &str) -> PathBuf {
|
||||||
|
self.dir.join(format!(".{name}.{}.tmp", timestamp_suffix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_slurm_content(content: &str) -> Result<()> {
|
||||||
|
if content.len() > MAX_SLURM_BODY_BYTES {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"SLURM content too large: {} bytes, limit {} bytes",
|
||||||
|
content.len(),
|
||||||
|
MAX_SLURM_BODY_BYTES
|
||||||
|
));
|
||||||
|
}
|
||||||
|
serde_json::from_str::<serde_json::Value>(content)
|
||||||
|
.map_err(|err| anyhow!("invalid SLURM JSON: {}", err))?;
|
||||||
|
SlurmFile::from_slice(content.as_bytes())
|
||||||
|
.map_err(|err| anyhow!("invalid SLURM file: {}", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_reload_query(query: Option<&str>, body_reload: Option<bool>) -> bool {
|
||||||
|
if let Some(value) = body_reload {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
query
|
||||||
|
.and_then(|query| {
|
||||||
|
query.split('&').find_map(|part| {
|
||||||
|
let (key, value) = part.split_once('=')?;
|
||||||
|
(key == "reload").then(|| matches!(value, "1" | "true" | "yes" | "on"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slurm_file_name(name: &str) -> Result<()> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(anyhow!("SLURM file name must not be empty"));
|
||||||
|
}
|
||||||
|
if name.contains('/') || name.contains('\\') || name.contains("..") {
|
||||||
|
return Err(anyhow!("invalid SLURM file name '{}'", name));
|
||||||
|
}
|
||||||
|
if !(name.ends_with(".slurm") || name.ends_with(".slurm.disabled")) {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"invalid SLURM file name '{}': expected .slurm or .slurm.disabled",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !name
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
|
||||||
|
{
|
||||||
|
return Err(anyhow!(
|
||||||
|
"invalid SLURM file name '{}': allowed characters are ASCII letters, digits, '.', '_' and '-'",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_file(temporary: &Path, target: &Path) -> Result<()> {
|
||||||
|
if let Err(err) = fs::rename(temporary, target) {
|
||||||
|
if target.exists() {
|
||||||
|
fs::remove_file(target)
|
||||||
|
.with_context(|| format!("replace existing SLURM file {}", target.display()))?;
|
||||||
|
fs::rename(temporary, target)
|
||||||
|
.with_context(|| format!("move SLURM file into {}", target.display()))?;
|
||||||
|
} else {
|
||||||
|
return Err(err).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"move temporary SLURM file {} into {}",
|
||||||
|
temporary.display(),
|
||||||
|
target.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp_suffix() -> String {
|
||||||
|
Utc::now().format("%Y%m%d%H%M%S%9f").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modified_unix_seconds(metadata: &fs::Metadata) -> Option<u64> {
|
||||||
|
metadata
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
pub mod admin;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
mod serde;
|
mod serde;
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,8 +42,7 @@ 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");
|
||||||
|
|||||||
@ -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
13
tests/test_rtr_admin.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use rpki::rtr::admin::tail_log_lines;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tail_log_lines_returns_requested_suffix() {
|
||||||
|
let output = tail_log_lines(b"one\ntwo\nthree\nfour\n".to_vec(), 2);
|
||||||
|
assert_eq!(output, b"three\nfour\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tail_log_lines_returns_whole_buffer_when_shorter() {
|
||||||
|
let output = tail_log_lines(b"one\ntwo\n".to_vec(), 5);
|
||||||
|
assert_eq!(output, b"one\ntwo\n");
|
||||||
|
}
|
||||||
87
tests/test_rtr_config.rs
Normal file
87
tests/test_rtr_config.rs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
use rpki::rtr::config::{AppConfig, RuntimeConfigPatch, RuntimeTimingConfigPatch, parse_timezone};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_timezone_accepts_iana_names_and_common_aliases() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_timezone("Asia/Shanghai", "TEST").unwrap(),
|
||||||
|
chrono_tz::Asia::Shanghai
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_timezone("shanghai", "TEST").unwrap(),
|
||||||
|
chrono_tz::Asia::Shanghai
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_timezone("Europe/London", "TEST").unwrap(),
|
||||||
|
chrono_tz::Europe::London
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_timezone("America/New_York", "TEST").unwrap(),
|
||||||
|
chrono_tz::America::New_York
|
||||||
|
);
|
||||||
|
assert_eq!(parse_timezone("UTC", "TEST").unwrap(), chrono_tz::UTC);
|
||||||
|
assert_eq!(parse_timezone("Z", "TEST").unwrap(), chrono_tz::UTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_timezone_rejects_offset_and_invalid_values() {
|
||||||
|
assert!(parse_timezone("+08:00", "TEST").is_err());
|
||||||
|
assert!(parse_timezone("+0800", "TEST").is_err());
|
||||||
|
assert!(parse_timezone("Mars/Base", "TEST").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_config_patch_validates_and_preserves_unspecified_values() {
|
||||||
|
let current = AppConfig::default().runtime_config();
|
||||||
|
let next = current
|
||||||
|
.apply_patch(RuntimeConfigPatch {
|
||||||
|
max_delta: Some(8),
|
||||||
|
prune_delta_by_snapshot_size: Some(true),
|
||||||
|
source_refresh_interval_seconds: Some(60),
|
||||||
|
runtime_report_interval_seconds: Some(120),
|
||||||
|
report_history_limit: Some(20),
|
||||||
|
strict_ccr_validation: Some(true),
|
||||||
|
timezone: Some("UTC".to_string()),
|
||||||
|
timing: Some(RuntimeTimingConfigPatch {
|
||||||
|
refresh: Some(1800),
|
||||||
|
retry: None,
|
||||||
|
expire: Some(7200),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(next.max_delta, 8);
|
||||||
|
assert!(next.prune_delta_by_snapshot_size);
|
||||||
|
assert_eq!(next.source_refresh_interval_seconds, 60);
|
||||||
|
assert_eq!(next.runtime_report_interval_seconds, 120);
|
||||||
|
assert_eq!(next.report_history_limit, 20);
|
||||||
|
assert!(next.strict_ccr_validation);
|
||||||
|
assert_eq!(next.timezone, "UTC");
|
||||||
|
assert_eq!(next.timing.refresh, 1800);
|
||||||
|
assert_eq!(next.timing.retry, current.timing.retry);
|
||||||
|
assert_eq!(next.timing.expire, 7200);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
current
|
||||||
|
.apply_patch(RuntimeConfigPatch {
|
||||||
|
max_delta: Some(0),
|
||||||
|
..RuntimeConfigPatch::default()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
current
|
||||||
|
.apply_patch(RuntimeConfigPatch {
|
||||||
|
runtime_report_interval_seconds: Some(0),
|
||||||
|
..RuntimeConfigPatch::default()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
current
|
||||||
|
.apply_patch(RuntimeConfigPatch {
|
||||||
|
timezone: Some("+08:00".to_string()),
|
||||||
|
..RuntimeConfigPatch::default()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
290
tests/test_rtr_report.rs
Normal file
290
tests/test_rtr_report.rs
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
use chrono::Utc;
|
||||||
|
use rpki::rtr::cache::RtrCache;
|
||||||
|
use rpki::rtr::report::{ReportConfiguration, ReportContext};
|
||||||
|
use rpki::rtr::server::RtrService;
|
||||||
|
use rpki::source::pipeline::{
|
||||||
|
DataQualityReport, FileFingerprint, PayloadTypeCounts, SlurmRuleCounts, SourceFingerprint,
|
||||||
|
SourceLoadReport,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
fn test_configuration(report_history_limit: usize) -> ReportConfiguration {
|
||||||
|
ReportConfiguration::new(
|
||||||
|
300,
|
||||||
|
300,
|
||||||
|
report_history_limit,
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
chrono_tz::Asia::Shanghai,
|
||||||
|
(3600, 600, 7200),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_report_creates_parseable_json() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let report_dir = temp.path().join("report");
|
||||||
|
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||||
|
let service = RtrService::new(shared_cache.clone());
|
||||||
|
let notifier = service.notifier();
|
||||||
|
let context = ReportContext::new(test_configuration(10));
|
||||||
|
|
||||||
|
context.write_or_warn(
|
||||||
|
&report_dir,
|
||||||
|
"test",
|
||||||
|
&shared_cache,
|
||||||
|
¬ifier,
|
||||||
|
&service.stats(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let source_report = read_single_report(&report_dir, "rtr-source");
|
||||||
|
assert_eq!(source_report["schema_version"], 2);
|
||||||
|
assert_eq!(source_report["phase"], "test");
|
||||||
|
assert_report_time_offset(&source_report["generated_at"]);
|
||||||
|
assert_report_time_offset(&source_report["cache"]["created_at"]);
|
||||||
|
assert_eq!(source_report["cache"]["availability"], "ready");
|
||||||
|
assert_eq!(source_report["refresh"]["status"], "not_attempted");
|
||||||
|
assert!(source_report["source"].is_null());
|
||||||
|
assert!(source_report["data_quality"].is_null());
|
||||||
|
|
||||||
|
let runtime_report = read_single_report(&report_dir, "rtr-runtime");
|
||||||
|
assert_report_time_offset(&runtime_report["service"]["started_at"]);
|
||||||
|
assert_eq!(
|
||||||
|
runtime_report["configuration"]["source_refresh_interval_seconds"],
|
||||||
|
300
|
||||||
|
);
|
||||||
|
assert_eq!(runtime_report["configuration"]["report_history_limit"], 10);
|
||||||
|
|
||||||
|
let clients_report = read_single_report(&report_dir, "rtr-clients");
|
||||||
|
assert_eq!(clients_report["service"]["max_connections"], 1024);
|
||||||
|
assert_eq!(clients_report["service"]["active_connections"], 0);
|
||||||
|
assert_eq!(
|
||||||
|
clients_report["service"]["connections_by_transport"]["tcp"],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clients_report["service"]["connections_by_transport"]["tls"],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clients_report["service"]["connections_by_transport"]["ssh"],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source_report["cache"]["versions"].as_array().unwrap().len(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source_report["cache"]["versions"][2]["snapshot"]["total"],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source_report["cache"]["memory"]["delta_payload_counts"][2],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert!(source_report["source_fingerprint"].is_null());
|
||||||
|
assert_no_temporary_reports(&report_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_failure_preserves_last_successful_source_data() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let report_dir = temp.path().join("report");
|
||||||
|
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||||
|
let service = RtrService::new(shared_cache.clone());
|
||||||
|
let notifier = service.notifier();
|
||||||
|
let context = ReportContext::new(test_configuration(10));
|
||||||
|
let source = SourceLoadReport {
|
||||||
|
ccr_file: "data/example.ccr".to_string(),
|
||||||
|
ccr_file_size_bytes: 123,
|
||||||
|
ccr_modified_at: Some(Utc::now()),
|
||||||
|
ccr_produced_at: Some("20260615000000Z".to_string()),
|
||||||
|
slurm_enabled: true,
|
||||||
|
slurm_file_count: 1,
|
||||||
|
slurm_files: vec!["policy.slurm".to_string()],
|
||||||
|
slurm_version: Some(2),
|
||||||
|
};
|
||||||
|
let quality = DataQualityReport {
|
||||||
|
ccr_input: PayloadTypeCounts {
|
||||||
|
total: 11,
|
||||||
|
vrp: 10,
|
||||||
|
router_key: 0,
|
||||||
|
aspa: 1,
|
||||||
|
},
|
||||||
|
invalid: PayloadTypeCounts::default(),
|
||||||
|
before_slurm: PayloadTypeCounts {
|
||||||
|
total: 11,
|
||||||
|
vrp: 10,
|
||||||
|
router_key: 0,
|
||||||
|
aspa: 1,
|
||||||
|
},
|
||||||
|
after_slurm: PayloadTypeCounts {
|
||||||
|
total: 10,
|
||||||
|
vrp: 9,
|
||||||
|
router_key: 0,
|
||||||
|
aspa: 1,
|
||||||
|
},
|
||||||
|
slurm_filters: SlurmRuleCounts {
|
||||||
|
prefix: 1,
|
||||||
|
router_key: 0,
|
||||||
|
aspa: 0,
|
||||||
|
},
|
||||||
|
slurm_assertions: SlurmRuleCounts::default(),
|
||||||
|
};
|
||||||
|
context.record_source_fingerprint(SourceFingerprint {
|
||||||
|
ccr: FileFingerprint {
|
||||||
|
path: "data/example.ccr".to_string(),
|
||||||
|
len: 123,
|
||||||
|
modified_unix_secs: 1_781_404_800,
|
||||||
|
},
|
||||||
|
slurm_files: vec![FileFingerprint {
|
||||||
|
path: "policy.slurm".to_string(),
|
||||||
|
len: 42,
|
||||||
|
modified_unix_secs: 1_781_408_400,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
context.record_refresh_success(Utc::now(), 12, true, source, quality);
|
||||||
|
context.record_refresh_failure(Utc::now(), 5, &anyhow::anyhow!("source unavailable"));
|
||||||
|
context.write_or_warn(
|
||||||
|
&report_dir,
|
||||||
|
"refresh_failed",
|
||||||
|
&shared_cache,
|
||||||
|
¬ifier,
|
||||||
|
&service.stats(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = read_single_report(&report_dir, "rtr-source");
|
||||||
|
assert_eq!(report["source"]["ccr_file"], "data/example.ccr");
|
||||||
|
assert_eq!(
|
||||||
|
report["source_fingerprint"]["ccr"]["path"],
|
||||||
|
"data/example.ccr"
|
||||||
|
);
|
||||||
|
assert_eq!(report["source_fingerprint"]["ccr"]["len"], 123);
|
||||||
|
assert_report_time_offset(&report["source_fingerprint"]["ccr"]["modified_at"]);
|
||||||
|
assert_eq!(
|
||||||
|
report["source_fingerprint"]["slurm_files"][0]["path"],
|
||||||
|
"policy.slurm"
|
||||||
|
);
|
||||||
|
assert_report_time_offset(&report["source"]["ccr_modified_at"]);
|
||||||
|
assert_eq!(report["data_quality"]["after_slurm"]["total"], 10);
|
||||||
|
assert_eq!(report["refresh"]["status"], "failed");
|
||||||
|
assert_eq!(report["refresh"]["consecutive_failures"], 1);
|
||||||
|
assert_eq!(report["refresh"]["last_error"], "source unavailable");
|
||||||
|
assert!(!report["refresh"]["last_success_at"].is_null());
|
||||||
|
assert_report_time_offset(&report["refresh"]["last_success_at"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rolling_reports_keep_latest_files_per_category() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let report_dir = temp.path().join("report");
|
||||||
|
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||||
|
let service = RtrService::new(shared_cache.clone());
|
||||||
|
let notifier = service.notifier();
|
||||||
|
let context = ReportContext::new(test_configuration(2));
|
||||||
|
|
||||||
|
for phase in ["one", "two", "three"] {
|
||||||
|
context.write_or_warn(
|
||||||
|
&report_dir,
|
||||||
|
phase,
|
||||||
|
&shared_cache,
|
||||||
|
¬ifier,
|
||||||
|
&service.stats(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-source").len(), 2);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 2);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 2);
|
||||||
|
assert_no_temporary_reports(&report_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_writes_only_create_requested_report_type() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let report_dir = temp.path().join("report");
|
||||||
|
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||||
|
let service = RtrService::new(shared_cache.clone());
|
||||||
|
let notifier = service.notifier();
|
||||||
|
let context = ReportContext::new(test_configuration(10));
|
||||||
|
|
||||||
|
context.write_source_or_warn(
|
||||||
|
&report_dir,
|
||||||
|
"source_only",
|
||||||
|
&shared_cache,
|
||||||
|
¬ifier,
|
||||||
|
&service.stats(),
|
||||||
|
);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 0);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||||
|
|
||||||
|
context.write_clients_or_warn(
|
||||||
|
&report_dir,
|
||||||
|
"clients_only",
|
||||||
|
&shared_cache,
|
||||||
|
¬ifier,
|
||||||
|
&service.stats(),
|
||||||
|
);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 0);
|
||||||
|
|
||||||
|
context.write_runtime_or_warn(
|
||||||
|
&report_dir,
|
||||||
|
"runtime_only",
|
||||||
|
&shared_cache,
|
||||||
|
¬ifier,
|
||||||
|
&service.stats(),
|
||||||
|
);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-source").len(), 1);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-clients").len(), 1);
|
||||||
|
assert_eq!(report_files(&report_dir, "rtr-runtime").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_report_time_offset(value: &Value) {
|
||||||
|
let value = value.as_str().expect("report time should be a string");
|
||||||
|
assert!(
|
||||||
|
value.ends_with("+08:00"),
|
||||||
|
"report time should use +08:00 offset, got {value}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_single_report(report_dir: &Path, prefix: &str) -> Value {
|
||||||
|
let files = report_files(report_dir, prefix);
|
||||||
|
assert_eq!(files.len(), 1, "expected one {prefix} report");
|
||||||
|
serde_json::from_slice(&fs::read(&files[0]).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_files(report_dir: &Path, prefix: &str) -> Vec<PathBuf> {
|
||||||
|
let start = format!("{prefix}-");
|
||||||
|
let mut files = fs::read_dir(report_dir)
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().path())
|
||||||
|
.filter(|path| {
|
||||||
|
path.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.starts_with(&start) && name.ends_with(".json"))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_no_temporary_reports(report_dir: &Path) {
|
||||||
|
let has_temporary = fs::read_dir(report_dir).unwrap().any(|entry| {
|
||||||
|
entry
|
||||||
|
.unwrap()
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| name.ends_with(".tmp"))
|
||||||
|
});
|
||||||
|
assert!(!has_temporary);
|
||||||
|
}
|
||||||
@ -16,8 +16,8 @@ use tokio_rustls::TlsConnector;
|
|||||||
use rpki::rtr::cache::{RtrCacheBuilder, SessionIds, SharedRtrCache};
|
use rpki::rtr::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;
|
||||||
|
|||||||
@ -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!(
|
||||||
|
std::str::from_utf8(report.text())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("invalid PDU length"));
|
.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)
|
||||||
|
|||||||
@ -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!(
|
||||||
|
aspa_err
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("providerAsns must not contain customerAsn"));
|
.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
109
tests/test_slurm_admin.rs
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use rpki::slurm::admin::SlurmAdmin;
|
||||||
|
|
||||||
|
fn valid_slurm() -> &'static str {
|
||||||
|
r#"{
|
||||||
|
"slurmVersion": 1,
|
||||||
|
"validationOutputFilters": {
|
||||||
|
"prefixFilters": [],
|
||||||
|
"bgpsecFilters": []
|
||||||
|
},
|
||||||
|
"locallyAddedAssertions": {
|
||||||
|
"prefixAssertions": [],
|
||||||
|
"bgpsecAssertions": []
|
||||||
|
}
|
||||||
|
}"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slurm_admin_rejects_unsafe_file_names() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let admin = SlurmAdmin::new(temp.path());
|
||||||
|
|
||||||
|
for name in ["", "../x.slurm", "a/b.slurm", "a\\b.slurm", "a.json"] {
|
||||||
|
assert!(
|
||||||
|
admin
|
||||||
|
.put_file(name, valid_slurm(), "create_or_update")
|
||||||
|
.is_err(),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
admin
|
||||||
|
.put_file("policy.slurm", valid_slurm(), "create_or_update")
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
admin
|
||||||
|
.put_file("policy.slurm.disabled", valid_slurm(), "create_or_update")
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_backs_up_toggles_deletes_and_rolls_back() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let admin = SlurmAdmin::new(temp.path());
|
||||||
|
|
||||||
|
let create = admin
|
||||||
|
.put_file("policy.slurm", valid_slurm(), "create_or_update")
|
||||||
|
.unwrap();
|
||||||
|
assert!(temp.path().join("policy.slurm").is_file());
|
||||||
|
assert!(create.result.backup.is_none());
|
||||||
|
|
||||||
|
let update = admin
|
||||||
|
.put_file("policy.slurm", valid_slurm(), "create_or_update")
|
||||||
|
.unwrap();
|
||||||
|
assert!(update.result.backup.is_some());
|
||||||
|
|
||||||
|
let disable = admin.disable_file("policy.slurm").unwrap();
|
||||||
|
assert!(!temp.path().join("policy.slurm").exists());
|
||||||
|
assert!(temp.path().join("policy.slurm.disabled").is_file());
|
||||||
|
|
||||||
|
disable.rollback().unwrap();
|
||||||
|
assert!(temp.path().join("policy.slurm").is_file());
|
||||||
|
assert!(!temp.path().join("policy.slurm.disabled").exists());
|
||||||
|
|
||||||
|
admin.disable_file("policy.slurm").unwrap();
|
||||||
|
let enable = admin.enable_file("policy.slurm.disabled").unwrap();
|
||||||
|
assert!(temp.path().join("policy.slurm").is_file());
|
||||||
|
assert!(!temp.path().join("policy.slurm.disabled").exists());
|
||||||
|
enable.rollback().unwrap();
|
||||||
|
assert!(!temp.path().join("policy.slurm").exists());
|
||||||
|
assert!(temp.path().join("policy.slurm.disabled").is_file());
|
||||||
|
|
||||||
|
admin.enable_file("policy.slurm.disabled").unwrap();
|
||||||
|
let delete = admin.delete_file("policy.slurm").unwrap();
|
||||||
|
assert!(!temp.path().join("policy.slurm").exists());
|
||||||
|
assert!(delete.result.backup.is_some());
|
||||||
|
delete.rollback().unwrap();
|
||||||
|
assert!(temp.path().join("policy.slurm").is_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_and_reads_slurm_files() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let admin = SlurmAdmin::new(temp.path());
|
||||||
|
|
||||||
|
admin
|
||||||
|
.put_file("enabled.slurm", valid_slurm(), "create_or_update")
|
||||||
|
.unwrap();
|
||||||
|
admin
|
||||||
|
.put_file("disabled.slurm.disabled", valid_slurm(), "create_or_update")
|
||||||
|
.unwrap();
|
||||||
|
fs::write(temp.path().join("ignore.txt"), "not slurm").unwrap();
|
||||||
|
|
||||||
|
let files = admin.list_files().unwrap();
|
||||||
|
assert_eq!(files.len(), 2);
|
||||||
|
assert_eq!(files[0].name, "disabled.slurm.disabled");
|
||||||
|
assert!(!files[0].enabled);
|
||||||
|
assert_eq!(files[1].name, "enabled.slurm");
|
||||||
|
assert!(files[1].enabled);
|
||||||
|
|
||||||
|
let file = admin.read_file("enabled.slurm").unwrap();
|
||||||
|
assert_eq!(file.name, "enabled.slurm");
|
||||||
|
assert!(file.enabled);
|
||||||
|
assert!(file.content.contains("\"slurmVersion\""));
|
||||||
|
assert!(admin.read_file("missing.slurm").is_err());
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
mod common;
|
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)")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user