From bcf81560857740f2dca716c46225c85c496aa2aa Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Mon, 21 Sep 2026 07:29:38 +0300 Subject: [PATCH] Initial commit: RIPE CIDR/FQDN collector Collector daemon, FastAPI server (addresses, diff, collect, sources), SQLite storage with change journal, Docker Compose deployment, tests, documentation and project rules. Co-Authored-By: Claude Sonnet 5 --- .claude/CLAUDE.md | 30 ++ .dockerignore | 17 + .env.example | 6 + .gitignore | 16 + Dockerfile | 23 ++ Dockerfile.test | 10 + README.md | 516 +++++++++++++++++++++++++++ api_server.py | 320 +++++++++++++++++ cidr_collector.py | 271 ++++++++++++++ collector_daemon.py | 167 +++++++++ config.json | 20 ++ db.py | 260 ++++++++++++++ docker-compose.yml | 48 +++ docs/analysis-2026-09-20.md | 63 ++++ docs/analysis-2026-09-21.md | 89 +++++ docs/plan-app-container.md | 62 ++++ docs/plan-asn-fqdn-api.md | 29 ++ docs/plan-collect-endpoint.md | 22 ++ docs/plan-diff-endpoint.md | 27 ++ docs/plan-git-init.md | 20 ++ docs/plan-output-formats.md | 65 ++++ docs/plan-process-split.md | 55 +++ docs/plan-reliability-security.md | 59 +++ docs/plan-sqlite-storage.md | 72 ++++ docs/plan-tests-in-container.md | 16 + docs/summary-app-container.md | 27 ++ docs/summary-asn-fqdn-api.md | 23 ++ docs/summary-collect-endpoint.md | 21 ++ docs/summary-diff-endpoint.md | 24 ++ docs/summary-git-init.md | 18 + docs/summary-output-formats.md | 23 ++ docs/summary-process-split.md | 24 ++ docs/summary-reliability-security.md | 20 ++ docs/summary-sqlite-storage.md | 27 ++ docs/summary-tests-in-container.md | 15 + formatters.py | 115 ++++++ healthcheck.py | 32 ++ requirements-dev.txt | 2 + requirements.txt | 4 + storage.py | 70 ++++ tests/test_core.py | 80 +++++ tests/test_daemon.py | 97 +++++ tests/test_db.py | 78 ++++ tests/test_formats.py | 54 +++ tests/test_sources_api.py | 97 +++++ 45 files changed, 3134 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.test create mode 100644 README.md create mode 100644 api_server.py create mode 100644 cidr_collector.py create mode 100644 collector_daemon.py create mode 100644 config.json create mode 100644 db.py create mode 100644 docker-compose.yml create mode 100644 docs/analysis-2026-09-20.md create mode 100644 docs/analysis-2026-09-21.md create mode 100644 docs/plan-app-container.md create mode 100644 docs/plan-asn-fqdn-api.md create mode 100644 docs/plan-collect-endpoint.md create mode 100644 docs/plan-diff-endpoint.md create mode 100644 docs/plan-git-init.md create mode 100644 docs/plan-output-formats.md create mode 100644 docs/plan-process-split.md create mode 100644 docs/plan-reliability-security.md create mode 100644 docs/plan-sqlite-storage.md create mode 100644 docs/plan-tests-in-container.md create mode 100644 docs/summary-app-container.md create mode 100644 docs/summary-asn-fqdn-api.md create mode 100644 docs/summary-collect-endpoint.md create mode 100644 docs/summary-diff-endpoint.md create mode 100644 docs/summary-git-init.md create mode 100644 docs/summary-output-formats.md create mode 100644 docs/summary-process-split.md create mode 100644 docs/summary-reliability-security.md create mode 100644 docs/summary-sqlite-storage.md create mode 100644 docs/summary-tests-in-container.md create mode 100644 formatters.py create mode 100644 healthcheck.py create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 storage.py create mode 100644 tests/test_core.py create mode 100644 tests/test_daemon.py create mode 100644 tests/test_db.py create mode 100644 tests/test_formats.py create mode 100644 tests/test_sources_api.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..be2e2e2 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,30 @@ +# Твоя роль + +- DevOps инженер +- Разработчик Backend +- Архитектор информационных систем +- Архитектор корпоративной сети + +# Стиль общения + +- профессиональный, но без жаргона + +# Стиль ответов + +- максимально емкие и содержательные +- не проваливайся в лишние детали, если это явно не было запрошено + +# Создание артефактов + +- На каждое новое изменение должен быть артефакт в .md файле +- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле +- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле +- каждое изменение дополняет или обновляет README.md + +# Автотесты + +- минимальное количество тестов + +# Окружение для разработки + +- при необходимости создай виртуальное окружение в корне проекта в директории venv (родительская директория виртуального окружения) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f46e663 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +venv/ +__pycache__/ +.pytest_cache/ +.git/ +data.json +fqdn_data.json +*.lock +*.tmp +*.corrupt-* +status.json +.env +*.db +*.db-wal +*.db-shm +*.migrated-* +collect_request.json +graphify-out/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9aebea5 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Токен для изменяющих запросов API (X-API-Key). Сгенерировать: openssl rand -hex 32 +RIPE_API_TOKEN=change-me +# Часовой пояс для cron-расписаний (по умолчанию UTC) +TZ=UTC +# Порт API на хосте (по умолчанию 8000) +#API_PORT=8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e750be --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +venv/ +__pycache__/ +.pytest_cache/ +*.corrupt-* +*.lock +*.tmp +status.json +.env +*.db +*.db-wal +*.db-shm +*.migrated-* +collect_request.json +graphify-out/ +data.json +fqdn_data.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0e8994b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + RIPE_DATA_DIR=/data + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY api_server.py cidr_collector.py collector_daemon.py db.py formatters.py storage.py healthcheck.py ./ + +# Без root; том /data создаётся с владельцем ripe (именованный том наследует его при первом создании) +RUN useradd --system --uid 10001 --no-create-home ripe \ + && mkdir /data && chown ripe /data +USER ripe +VOLUME /data + +EXPOSE 8000 + +# exec-форма: SIGTERM доходит до процесса. Сборщик переопределяет команду в docker-compose.yml +CMD ["uvicorn", "api_server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..c6562e5 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt requirements-dev.txt ./ +RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt + +COPY . . + +CMD ["pytest", "-q"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bd2c05 --- /dev/null +++ b/README.md @@ -0,0 +1,516 @@ +# RIPE AS CIDR & FQDN IP Collector + +This project collects CIDR prefixes for specified Autonomous Systems (AS) from the RIPE NCC API and resolves IP addresses for specified FQDNs. It accumulates these addresses over time, maintaining a history of discovered prefixes. It also provides a FastAPI-based HTTP interface to retrieve the collected data. + +The system consists of **two independent processes**: the collector daemon (`collector_daemon.py`, runs the schedule) and the API server (`api_server.py`, serves data and edits the config). They communicate only through files: the collected addresses live in the SQLite database `ripe.db`, the rest in JSON (`config.json`, `status.json`). See section 9. + +> **Quick start:** the whole system (API + collector) runs with `docker compose up -d`, see section 8. Sections 1-4 describe the manual installation (venv + systemd/OpenRC), which remains supported. + +> **Upgrading from a version where the scheduler lived in the API:** running only `ripe-api` is no longer enough - without the `ripe-collector` service nothing is collected. `GET /health` reports `collector_alive: false` / `degraded` in that case. + +## 1. Preparation and Installation + +### Prerequisites +- Python 3.8+ +- `pip` and `venv` + +### Installation Steps +1. **Clone the repository** (or copy the files) to your desired location, e.g., `/opt/ripe_collector`. + ```bash + mkdir -p /opt/ripe_collector + cd /opt/ripe_collector + # Copy files: cidr_collector.py, api_server.py, storage.py, requirements.txt, config.json + ``` + +2. **Create a Virtual Environment**: + ```bash + python3 -m venv venv + ``` + +3. **Install Dependencies**: + ```bash + source venv/bin/activate + pip install -r requirements.txt + deactivate + ``` + +4. **Initial Configuration**: + Edit `config.json` to set your initial ASNs and FQDNs. + ```json + { + "asns": [62041], + "fqdns": ["google.com"], + "ttl_days": 90 + } + ``` + `ttl_days` - how long an address is kept after it was last seen (default `90`, `0` = keep forever). + Optional `changes_retention_days` - how long the change journal for `/addresses/diff` is kept (default `30`, `0` = forever). + +5. **Tests** (optional) run in a container, not in the local `venv`: + ```bash + docker build -f Dockerfile.test -t ripe-collector-test . + docker run --rm ripe-collector-test + ``` + The image (`Dockerfile.test`, `python:3.11-slim`) contains only the code and dev dependencies - production data (`ripe.db`, `data.json`, `fqdn_data.json`) is excluded via `.dockerignore`. + +6. **Knowledge graph** (optional): `/graphify .` (Claude Code skill) builds `graphify-out/` - `graph.html` (interactive), `GRAPH_REPORT.md`, `graph.json` - from code and `docs/`; refresh with `/graphify . --update` after changes. The directory is excluded from git and from Docker images. + +7. **Repository:** `main` branch, remote `origin` = `https://artstore.rxmsk.ru/ayurishchev/ripe-cidr-collector.git`. Committed: code, tests, Docker files, `config.json` (initial sources), `.env.example`, `docs/`. Not committed (`.gitignore`): `venv/`, `.env`, databases `*.db*`, collected data `data.json` / `fqdn_data.json`, `graphify-out/`, service files. Every change follows the plan -> implementation -> summary flow in `docs/` and gets its own commit. + +--- + +## 2. Running the Collector + +The collector normally runs as the **daemon** `collector_daemon.py` (see sections 3-4): it keeps the schedule from `config.json` (`schedule.asn`, `schedule.fqdn`) and picks up changes made via `POST /schedule` within 30 seconds, without a restart. Only one daemon instance can run at a time (a second one exits with an error). + +```bash +/opt/ripe_collector/venv/bin/python3 /opt/ripe_collector/collector_daemon.py +``` + +The script `cidr_collector.py` is used for manual runs and for managing the lists (see section 6). + +### Manual Run +```bash +/opt/ripe_collector/venv/bin/python3 /opt/ripe_collector/cidr_collector.py run +``` + +### Alternative: cron instead of the daemon +If you prefer cron, run the collector once per day (do not combine with the daemon - it is redundant). To run daily at 02:00 AM: + +> Run the job as the same user as the API (`crontab -u ripe -e`), otherwise data files may end up with the wrong owner. + +1. Open crontab: + ```bash + crontab -e + ``` +2. Add the line: + ```cron + 0 2 * * * /opt/ripe_collector/venv/bin/python3 /opt/ripe_collector/cidr_collector.py run >> /var/log/ripe_collector.log 2>&1 + ``` + +--- + +## 3. Application Setup: Systemd (Ubuntu, Debian) + +This section describes how to run two system services: the **API Server** (`api_server.py`) and the **collector daemon** (`collector_daemon.py`). + +### Create Service File +Create a dedicated user and the token file (the token protects `POST /schedule`): + +```bash +useradd --system --home /opt/ripe_collector --shell /usr/sbin/nologin ripe +chown -R ripe: /opt/ripe_collector +echo "RIPE_API_TOKEN=$(openssl rand -hex 32)" > /etc/ripe-api.env +chmod 600 /etc/ripe-api.env +``` + +Create `/etc/systemd/system/ripe-api.service`: + +```ini +[Unit] +Description=RIPE CIDR Collector API +After=network.target + +[Service] +User=ripe +EnvironmentFile=/etc/ripe-api.env +WorkingDirectory=/opt/ripe_collector +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/opt/ripe_collector +ExecStart=/opt/ripe_collector/venv/bin/uvicorn api_server:app --host 0.0.0.0 --port 8000 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Create `/etc/systemd/system/ripe-collector.service` (the daemon does not need the token): + +```ini +[Unit] +Description=RIPE CIDR Collector daemon +After=network.target + +[Service] +User=ripe +WorkingDirectory=/opt/ripe_collector +NoNewPrivileges=true +ProtectSystem=strict +ReadWritePaths=/opt/ripe_collector +ExecStart=/opt/ripe_collector/venv/bin/python collector_daemon.py +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +### Enable and Start +```bash +# Reload systemd +sudo systemctl daemon-reload + +# Enable services to start on boot and start them immediately +sudo systemctl enable --now ripe-collector ripe-api + +# Check status +sudo systemctl status ripe-collector ripe-api +``` + +--- + +## 4. Application Setup: RC-Script (Alpine Linux) + +For Alpine Linux using OpenRC. + +### Create Init Script +Create `/etc/init.d/ripe-api`: + +```sh +#!/sbin/openrc-run + +name="ripe-api" +description="RIPE CIDR Collector API" +command="/opt/ripe_collector/venv/bin/uvicorn" +# --host and --port and module:app passed as arguments +command_args="api_server:app --host 0.0.0.0 --port 8000" +command_background="yes" +pidfile="/run/${RC_SVCNAME}.pid" +directory="/opt/ripe_collector" + +depend() { + need net +} +``` + +Add the token and a non-root user (`/etc/conf.d/ripe-api` is read by OpenRC): + +```sh +adduser -S -h /opt/ripe_collector ripe +chown -R ripe /opt/ripe_collector +echo 'export RIPE_API_TOKEN=""' > /etc/conf.d/ripe-api +chmod 600 /etc/conf.d/ripe-api +``` +and add `command_user="ripe"` to the init script. + +Create the collector daemon script `/etc/init.d/ripe-collector`: + +```sh +#!/sbin/openrc-run + +name="ripe-collector" +description="RIPE CIDR Collector daemon" +command="/opt/ripe_collector/venv/bin/python" +command_args="collector_daemon.py" +command_background="yes" +command_user="ripe" +pidfile="/run/${RC_SVCNAME}.pid" +directory="/opt/ripe_collector" + +depend() { + need net +} +``` + +### Make Executable +```bash +chmod +x /etc/init.d/ripe-api /etc/init.d/ripe-collector +``` + +### Enable and Start +```bash +# Add to default runlevel +rc-update add ripe-collector default +rc-update add ripe-api default + +# Start services +service ripe-collector start +service ripe-api start + +# Check status +service ripe-collector status +service ripe-api status +``` + +--- + +## 5. API Usage Documentation + +The API runs by default on port `8000`. It allows retrieving the collected data in a flat JSON list. + +**Security model:** read endpoints (`/addresses`, `/schedule` GET, `/health`) are open, since consumers (routers, firewalls) usually can only do a plain GET - restrict access to port 8000 with a firewall. `POST /schedule` requires the header `X-API-Key: `; if the `RIPE_API_TOKEN` environment variable is not set, `POST` is disabled (503). If the data files are unreadable, the API answers `503`. + +### Base URL +`http://:8000` + +### Endpoint: Get Addresses +**GET** `/addresses` + +Retrieves the list of collected IP addresses/CIDRs. + +| Parameter | Type | Required | Default | Description | +| :--- | :--- | :--- | :--- | :--- | +| `type` | string | No | `all` | Filter by source type. Options: `cidr` (ASNs only), `fqdn` (Domains only), `all` (Both). | +| `format` | string | No | `json` | Output format: `json`, `nftables`, `mikrotik`, `bird`, `frr` (see below). | +| `ip_version` | string | No | `all` | `4`, `6` or `all`. | +| `aggregate` | bool | No | `false` | Collapse overlapping/adjacent prefixes (`/23` + `/24` -> `/23`); a host IP inside a wider prefix is dropped. Applied after `cidr` and `fqdn` sources are merged. | +| `name` | string | No | `ripe` | Name of the list/set in generated configs (`[A-Za-z][A-Za-z0-9_]{0,31}`). | + +With the defaults the response is the same flat JSON list as before. Other formats return `text/plain` scripts that are safe to re-apply (they replace the previous list). + +#### Example 1: Get All Addresses (Default) + +**Request:** +```bash +curl -X GET "http://localhost:8000/addresses" +``` + +**Response (JSON):** +```json +[ + "142.250.1.1", + "149.154.160.0/22", + "149.154.160.0/23", + "2001:4860:4860::8888", + "91.108.4.0/22" +] +``` + +#### Example 2: Get Only CIDRs (from ASNs) + +**Request:** +```bash +curl -X GET "http://localhost:8000/addresses?type=cidr" +``` + +**Response (JSON):** +```json +[ + "149.154.160.0/22", + "149.154.160.0/23", + "91.108.4.0/22" +] +``` + +#### Example 3: Get Only Resolved IPs (from FQDNs) + +**Request:** +```bash +curl -X GET "http://localhost:8000/addresses?type=fqdn" +``` + +**Response (JSON):** +```json +[ + "142.250.1.1", + "2001:4860:4860::8888" +] +``` + +#### Ready-to-use configuration formats + +| `format` | Result | How to apply | +| :--- | :--- | :--- | +| `nftables` | table `inet ` with interval sets `_v4` / `_v6` (`auto-merge`); other objects of the table are untouched | `curl -s "$URL/addresses?format=nftables" \| nft -f -` | +| `mikrotik` | `/ip firewall address-list` and `/ipv6 firewall address-list` named `` (old entries are removed first, so there is a short window with an empty list) | `/tool fetch url="$URL/addresses?format=mikrotik" dst-path=ripe.rsc` then `/import ripe.rsc` | +| `bird` | BIRD 2 prefix sets `define _V4 = [...]`, `_V6` for use in filters (`net ~ RIPE_V4`) | save to a file, `include` it, `birdc configure` | +| `frr` | `ip prefix-list _v4` / `ipv6 prefix-list _v6` with `permit` entries | `curl -s "$URL/addresses?format=frr" > ripe.conf && vtysh -f ripe.conf` | + +Examples: +```bash +# IPv4 only, aggregated, as an nftables set named "tg" +curl -s "http://localhost:8000/addresses?format=nftables&ip_version=4&aggregate=true&name=tg" +``` +A version without addresses is omitted from the script. The `bird` and `nftables` outputs were syntax-checked with `bird -p` and `nft -c`. + +### Endpoint: Changes since the last sync (diff) +**GET** `/addresses/diff?since=[&type=cidr|fqdn|all][&ip_version=all|4|6]` - only what was added and removed, instead of the full list. Read access is open, like `/addresses`. + +```bash +curl "http://localhost:8000/addresses/diff?since=0" +# {"since":"0","now":"2026-09-21T04:09:44Z","cursor":42,"added":["3.0.0.0/24"],"removed":["2.0.0.0/24"]} +curl "http://localhost:8000/addresses/diff?since=42" # next sync: use the returned cursor +``` +- `since` is a **cursor** from the previous answer (recommended: exact, independent of clocks) or an ISO 8601 time (no time zone = UTC; write `+` in a URL as `%2B`). Time is compared with millisecond precision, borders are inclusive, so an entry may be reported twice - repeating it is harmless. +- The result is the net effect: an address added and removed (or the other way) within the interval is not reported; an address that another source still holds is not reported as removed. Both CIDRs and IPs of FQDNs count. Output is JSON only, no aggregation. +- The first sync: take the full `/addresses` list and the cursor from its response header `X-Changes-Cursor` (read before the data), then call `/addresses/diff?since=` regularly. +- `400` - invalid `since`; `410 Gone` - `since` is older than the journal (see `changes_retention_days`) or the cursor does not belong to this database: fetch the full `/addresses` list and continue from the new `cursor`. + +### Endpoint: Manage Schedule +**GET** `/schedule` +Returns the current cron schedules. + +**POST** `/schedule` (requires `X-API-Key`) +Updates the schedule for a specific collector type. +```bash +curl -X POST http://localhost:8000/schedule -H "X-API-Key: $RIPE_API_TOKEN" \ + -H "Content-Type: application/json" -d '{"type": "asn", "cron": "*/15 * * * *"}' +``` +Body: +```json +{ + "type": "asn", + "cron": "*/15 * * * *" +} +``` +*Note: `type` can be `asn` or `fqdn`. The change is written to `config.json`; the collector daemon applies it within 30 seconds (`applied_within_seconds` in the response). An invalid cron expression is rejected by the API (400) and ignored by the daemon.* + +### Endpoints: Manage ASNs and FQDNs +The lists of monitored sources are stored in `config.json` and can be managed through the API. Reading is open; changing requires the `X-API-Key` header (same token as `POST /schedule`). + +| Method and path | Description | +| :--- | :--- | +| `GET /asns`, `GET /fqdns` | Current lists: `{"asns": [...]}`, `{"fqdns": [...]}` | +| `POST /asns` `{"asn": 62041}` | Add an ASN (`1..4294967295`). `201` if added, `200` if it was already there. | +| `POST /fqdns` `{"fqdn": "example.com"}` | Add a domain (lower-cased, trailing dot removed; IP addresses and invalid labels are rejected with `422`). | +| `DELETE /asns/{asn}?purge=false`, `DELETE /fqdns/{fqdn}?purge=false` | Remove a source (`404` if unknown). | + +```bash +curl -X POST http://localhost:8000/asns -H "X-API-Key: $RIPE_API_TOKEN" \ + -H "Content-Type: application/json" -d '{"asn": 62041}' +curl -X DELETE "http://localhost:8000/fqdns/example.com?purge=true" -H "X-API-Key: $RIPE_API_TOKEN" +``` + +- A new source is collected on the next scheduled run of the daemon; to collect it right away call `POST /collect` (see below). +- **What happens to the data of a removed source:** by default it is kept and its addresses expire by `ttl_days` (the collector keeps ageing entries of sources that are no longer configured and deletes the entry when it is empty). With `purge=true` the collected addresses are deleted immediately and disappear from `/addresses`. With `ttl_days = 0` nothing expires - use `purge=true`. +- The CLI commands (`add`, `remove`, `add-fqdn`, `remove-fqdn`) use the same locked, atomic config update, so they are safe to use alongside the API. + +### Endpoint: Collect now +**POST** `/collect` (requires `X-API-Key`) - starts a collection immediately instead of waiting for the schedule. + +```bash +curl -X POST http://localhost:8000/collect -H "X-API-Key: $RIPE_API_TOKEN" \ + -H "Content-Type: application/json" -d '{"type": "asn"}' +``` +Body is optional: `type` is `asn`, `fqdn` or `all` (default). The API does not collect itself: it passes the request to the collector daemon through the file `collect_request.json`, which the daemon checks every 5 seconds, so the collection starts within ~5 s. Answer `202` lists the requested types; follow the progress in `GET /health` (`running`, `last_run`, `last_finished`, `last_error` of each job). + +- Repeated requests are merged into one; if a collection of that type is already running (scheduled or manual) the new start is skipped. +- If the collector daemon is not running (no fresh heartbeat), the answer is `503` and nothing is queued. + +### Endpoint: Health +**GET** `/health` +Reports the state of the collector daemon (read from `status.json`): `collector_alive`, the cron / `running` / last run / `last_finished` / last error / next run of each job, and the number of stored addresses. The daemon writes a heartbeat every 30 seconds; `collector_alive` is `false` if it is older than 120 seconds or the daemon never ran. `status` is `ok` only if the daemon is alive and no job failed in its last run; otherwise `degraded` (HTTP code is still 200). + +--- + +## 6. Advanced CLI Usage + +The collector script supports running modes independently: + +```bash +# Run both (Default) +python3 cidr_collector.py run + +# Run only ASN collection +python3 cidr_collector.py run --mode asn + +# Run only FQDN collection +python3 cidr_collector.py run --mode fqdn +``` + +--- + +## 7. Internal Logic & Architecture + +### Collector Logic +When the collector runs (whether manually or via schedule): +1. **Instantiation**: Creates a new instance of `CIDRCollector` or `FQDNCollector`. This forces a fresh read of `config.json`, ensuring any added ASNs/FQDNs are immediately processed. +2. **Fetching**: + * **ASN**: Queries RIPE NCC API (`stat.ripe.net`). + * **FQDN**: Uses Python's `socket.getaddrinfo` to resolve A and AAAA records. +3. **Merge in one transaction**: the fetched addresses are merged into the SQLite table `addresses` (`db.py`) in a single write transaction, so readers (the API) never see a half-updated state. +4. **Accumulation with TTL**: Each address has `first_seen`/`last_seen`. + * New addresses are inserted; already seen ones get `last_seen` refreshed. + * Addresses not seen for longer than `ttl_days` are removed - but only after a *successful* fetch. A RIPE/DNS failure never deletes anything. + * Sources that are no longer configured are not fetched any more; their addresses simply expire by TTL. +5. **Persistence**: SQLite in WAL mode - the API reads while the collector writes. A corrupted database file is renamed to `ripe.db.corrupt-` and the API answers `503` instead of serving wrong data. + +### Scheduler Logic +`collector_daemon.py` uses `APScheduler` (`BlockingScheduler`) in its own process; the API has no scheduler. + +1. **Startup**: the daemon takes an exclusive lock (`collector.daemon.lock`, single instance), loads the `schedule` block from `config.json` and creates two independent jobs (`asn_job`, `fqdn_job`; defaults `0 2 * * *` and `0 3 * * *`). Overlapping runs of the same job are not allowed. +2. **Runtime updates (POST /schedule)**: the API validates the cron expression and writes `config.json` atomically. Every 30 seconds the daemon compares the `schedule` block with the active one and reschedules the changed job (`reschedule_job`). An invalid cron expression is logged and ignored, the previous schedule stays. +3. **Status**: at the start and end of every run and every 30 seconds the daemon atomically writes `status.json` (jobs state + `updated_at` heartbeat); `GET /health` reads it. + **Manual runs**: every 5 seconds the daemon checks `collect_request.json` (written by `POST /collect`) and starts the requested collections as one-off jobs; one run per type at a time. +4. **Concurrency**: a running job completes normally when the schedule changes; the new schedule applies to the next calculated run time. On `SIGTERM` the daemon exits after the running collection finishes. Restarting the API does not affect collection. + +--- + +## 8. Application Setup: Docker Compose + +One image (`Dockerfile`), two services: `api` (uvicorn) and `collector` (`collector_daemon.py`). They share the named volume `ripe_data` mounted at `/data` (`RIPE_DATA_DIR`), which holds `ripe.db` (with `-wal`/`-shm`), `config.json`, `status.json` and lock files. Containers run as non-root (uid 10001) with a read-only root filesystem, dropped capabilities and rotated logs. Both have healthchecks (`healthcheck.py`). + +### Start +```bash +cp .env.example .env +# set RIPE_API_TOKEN (openssl rand -hex 32) and, if needed, TZ / API_PORT +docker compose up -d +docker compose ps # both services become "healthy" within about a minute +docker compose logs -f collector +``` +Then add sources through the API (section 5), e.g. `POST /asns` with the token. With an empty volume the lists are empty. + +### Settings (`.env`) +| Variable | Default | Description | +| :--- | :--- | :--- | +| `RIPE_API_TOKEN` | - | Token for changing requests (`X-API-Key`). Without it they are disabled (503). | +| `TZ` | `UTC` | Time zone in which the cron schedules are evaluated (e.g. `Europe/Moscow`). | +| `API_PORT` | `8000` | Host port of the API. | + +### Migrating existing data into the volume +Run once, from the directory with your current `config.json`, `data.json`, `fqdn_data.json`, before the first `up` (the JSON data files are imported into `ripe.db` automatically on the first start, see section 9): +```bash +docker compose create +docker volume ls | grep ripe_data # the volume is named _ripe_data, e.g. ripe_cidr_collector_ripe_data +docker run --rm -v _ripe_data:/data -v "$PWD":/src:ro alpine \ + sh -c 'cp /src/config.json /src/data.json /src/fqdn_data.json /data/ && chown 10001 /data/*.json' +docker compose up -d +``` + +### Operations +```bash +docker compose build && docker compose up -d # update after code changes +docker compose stop collector # graceful: a running collection finishes (up to 60s) +docker compose down # stop; data stays in the volume (add -v to delete it) +``` + +### Notes and risks +- **Port 8000 is published without TLS**, so `X-API-Key` travels in clear text. Restrict access with a firewall or put a TLS reverse proxy in front (bind the port to `127.0.0.1` by changing `ports` in `docker-compose.yml`). +- The image installs unpinned dependencies from `requirements.txt`; rebuilds may pick up newer versions. +- Files written by the services in the volume (`config.json`, `status.json`) have mode `600`; both services run as the same user. +- `docker compose` uses the image name `ripe-cidr-collector`; `Dockerfile.test` is used only for running the tests (section 1, step 5). + +--- + +## 9. Data Storage (SQLite) + +Collected addresses are stored in `ripe.db` (in the project directory, or in `RIPE_DATA_DIR` - the `/data` volume in Docker). `config.json` (sources, schedule, `ttl_days`) and `status.json` (collector heartbeat) stay JSON. + +```sql +CREATE TABLE addresses ( + kind TEXT NOT NULL, -- 'asn' or 'fqdn' + source TEXT NOT NULL, -- '62041' or 'example.com' + value TEXT NOT NULL, -- prefix or IP + first_seen TEXT NOT NULL, -- ISO time + last_seen TEXT NOT NULL, + PRIMARY KEY (kind, source, value) +); +``` +The schema version is stored in `PRAGMA user_version`. The database runs in WAL mode (files `ripe.db-wal`, `ripe.db-shm` next to it); API and collector (also two containers sharing one local volume) can work with it concurrently. Do not place it on a network file system. + +### Change journal +For `/addresses/diff` the database keeps the tables `changes(id, ts, kind, value, action add|del)` and `meta` (the journal "horizon"). SQL triggers on `addresses` write to `changes`, so every path (collection, TTL, `purge`, removal of a source) is covered: `add` - the value appeared and no other source had it, `del` - the last entry of the value is gone. `ts` is UTC. The import from JSON is not written to the journal. Old records are deleted by the collector (`changes_retention_days`); the schema version is 2 (a version 1 database is upgraded automatically on the first start, data is kept). + +Inspect the data: +```bash +sqlite3 ripe.db "SELECT kind, source, count(*), max(last_seen) FROM addresses GROUP BY 1, 2" +``` + +### Migration from the JSON storage +Automatic: on the first start of any process (API, collector or CLI) `data.json` and `fqdn_data.json` are imported into `ripe.db` in one transaction (concurrent starts are safe). Existing `first_seen`/`last_seen` are preserved; data in the oldest format without them gets `last_seen` = migration time (TTL starts counting from then). The originals are **not deleted** but renamed to `data.json.migrated-` and `fqdn_data.json.migrated-`. + +### Rollback to the JSON version +Stop both services, rename the `*.migrated-*` files back to `data.json` / `fqdn_data.json`, remove `ripe.db*`, start the previous version of the code. Addresses collected after the migration are lost in that case. + +### Backup +Copy the database consistently with `sqlite3 ripe.db ".backup ripe.db.bak"` (do not copy `ripe.db` alone while the services are running - part of the data may still be in the `-wal` file). diff --git a/api_server.py b/api_server.py new file mode 100644 index 0000000..cda8115 --- /dev/null +++ b/api_server.py @@ -0,0 +1,320 @@ +from datetime import datetime, timezone +from enum import Enum +import ipaddress +import logging +import os +import re +import secrets +import sqlite3 +from typing import List, Literal, Optional + +from apscheduler.triggers.cron import CronTrigger +from fastapi import Depends, FastAPI, Header, HTTPException, Path, Query, Response +from fastapi.responses import JSONResponse, PlainTextResponse +from pydantic import BaseModel, Field, field_validator + +import cidr_collector as cc +import db +import formatters +from cidr_collector import load_full_config +from storage import StorageError, load_json + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +TOKEN_ENV = "RIPE_API_TOKEN" + +app = FastAPI(title="RIPE CIDR/FQDN API") + + +class AddressType(str, Enum): + cidr = "cidr" + fqdn = "fqdn" + all_types = "all" + + +class OutputFormat(str, Enum): + json = "json" + nftables = "nftables" + mikrotik = "mikrotik" + bird = "bird" + frr = "frr" + + +class IPVersion(str, Enum): + all_versions = "all" + v4 = "4" + v6 = "6" + + +class CollectRequest(BaseModel): + type: Literal["asn", "fqdn", "all"] = "all" + + +class ScheduleUpdate(BaseModel): + type: Literal["asn", "fqdn"] + cron: str + + model_config = {"json_schema_extra": {"example": {"type": "asn", "cron": "*/10 * * * *"}}} + + +ASN_MIN, ASN_MAX = 1, 4294967295 +_FQDN_LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") + + +def normalize_fqdn(value: str) -> str: + """Нижний регистр, без завершающей точки; проверка длины и меток, IP-литералы отклоняются.""" + fqdn = value.strip().lower().rstrip(".") + if not fqdn or len(fqdn) > 253: + raise ValueError("FQDN must be 1-253 characters") + try: + ipaddress.ip_address(fqdn) + except ValueError: + pass + else: + raise ValueError("IP addresses are not allowed, use a domain name") + if not all(_FQDN_LABEL.match(label) for label in fqdn.split(".")): + raise ValueError("Invalid FQDN: labels must be 1-63 chars of [a-z0-9-], no leading/trailing hyphen") + return fqdn + + +class ASNBody(BaseModel): + asn: int = Field(ge=ASN_MIN, le=ASN_MAX) + + +class FQDNBody(BaseModel): + fqdn: str + + @field_validator("fqdn") + @classmethod + def _normalize(cls, value): + return normalize_fqdn(value) + + +@app.exception_handler(StorageError) +@app.exception_handler(sqlite3.Error) +def storage_error_handler(request, exc): + return JSONResponse(status_code=503, content={"detail": "Data storage unavailable"}) + + +def verify_token(x_api_key: Optional[str] = Header(None)): + expected = os.environ.get(TOKEN_ENV) + if not expected: + # Fail closed: без заданного токена управление отключено + raise HTTPException(status_code=503, detail=f"{TOKEN_ENV} is not configured; write access disabled.") + if not x_api_key or not secrets.compare_digest(x_api_key, expected): + raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key.") + + +def get_cidrs() -> List[str]: + with db.session() as conn: + return db.get_values(conn, "asn") + + +def get_fqdn_ips() -> List[str]: + with db.session() as conn: + return db.get_values(conn, "fqdn") + + +@app.get("/addresses", response_model=None, responses={ + 200: {"description": "JSON list for format=json, text/plain configuration script for other formats", + "content": {"application/json": {"schema": {"type": "array", "items": {"type": "string"}}}, + "text/plain": {"schema": {"type": "string"}}}}}) +def get_addresses( + type: AddressType = Query(AddressType.all_types, description="Filter by address type"), + format: OutputFormat = Query(OutputFormat.json, description="Output format"), + ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"), + aggregate: bool = Query(False, description="Collapse overlapping/adjacent prefixes"), + name: str = Query("ripe", pattern=r"^[A-Za-z][A-Za-z0-9_]{0,31}$", + description="List/set name used in generated configuration"), + response: Response = None, +): + # Курсор читаем до данных: изменения между чтением курсора и данных повторятся в diff, что безвредно + with db.session() as conn: + headers = {"X-Changes-Cursor": str(db.journal_head(conn))} + results = set() + + if type in [AddressType.cidr, AddressType.all_types]: + results.update(get_cidrs()) + + if type in [AddressType.fqdn, AddressType.all_types]: + results.update(get_fqdn_ips()) + + output = formatters.build_output(results, format.value, ip_version.value, aggregate, name) + if format == OutputFormat.json: + response.headers.update(headers) + return output + return PlainTextResponse(output, headers=headers) + + +def parse_since(raw: str): + """since: целый курсор или время ISO 8601 (без пояса - UTC). Возвращает (курсор, время UTC в формате журнала).""" + if raw.isdigit(): + return int(raw), None + try: + # «+» в адресной строке приходит пробелом; «Z» понимаем явно + moment = datetime.fromisoformat(raw.strip().replace(" ", "+").replace("Z", "+00:00")) + except ValueError: + raise HTTPException(status_code=400, detail="since must be a cursor (integer) or an ISO 8601 time") + moment = moment.replace(tzinfo=timezone.utc) if moment.tzinfo is None else moment.astimezone(timezone.utc) + return None, moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z" + + +@app.get("/addresses/diff", responses={ + 400: {"description": "Invalid since"}, + 410: {"description": "since is older than the change journal: fetch the full /addresses list"}}) +def get_addresses_diff( + since: str = Query(..., description="Cursor from the previous response (recommended) or ISO 8601 time (UTC)"), + type: AddressType = Query(AddressType.all_types, description="Filter by address type"), + ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"), +): + cursor, since_ts = parse_since(since) + kinds = {"asn" if t == AddressType.cidr else "fqdn" + for t in (AddressType.cidr, AddressType.fqdn) if type in (t, AddressType.all_types)} + with db.session() as conn: + changes = db.get_changes(conn, kinds, cursor, since_ts) + if changes is None: + raise HTTPException(status_code=410, detail="since is outside the change journal; fetch the full /addresses list") + added, removed, head = changes + return { + "since": since, + "now": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "cursor": head, + "added": formatters.select_strings(added, ip_version.value), + "removed": formatters.select_strings(removed, ip_version.value), + } + + +def collector_state(): + """Состояние демона по status.json (задания + heartbeat): (жив ли, время heartbeat, задания).""" + try: + status = load_json(cc.STATUS_FILE, None) + except StorageError: + status = None + + alive, updated_at = False, None + if status: + updated_at = status.get("updated_at") + try: + age = (datetime.now() - datetime.fromisoformat(updated_at)).total_seconds() + alive = age < cc.STATUS_STALE_AFTER + except (TypeError, ValueError): + pass + return alive, updated_at, (status.get("jobs", {}) if status else {}) + + +@app.get("/health") +def health(): + """Состояние сборщика: демон пишет status.json (задания + heartbeat), API только читает.""" + alive, updated_at, jobs = collector_state() + + with db.session() as conn: + counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")} + + healthy = alive and not any(j.get("last_error") for j in jobs.values()) + return { + "status": "ok" if healthy else "degraded", + "collector_alive": alive, + "collector_updated_at": updated_at, + "jobs": jobs, + "counts": counts, + } + + +@app.get("/schedule") +def get_schedule(): + return load_full_config().get("schedule", {}) + + +@app.post("/schedule", dependencies=[Depends(verify_token)]) +def update_schedule(schedule_update: ScheduleUpdate): + # Validate cron string by attempting to create trigger + try: + CronTrigger.from_crontab(schedule_update.cron) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid cron string: {e}") + + # Update config file (read-modify-write под блокировкой, запись атомарная) + def mutate(config): + config.setdefault("schedule", {})[schedule_update.type] = schedule_update.cron + cc.update_config(mutate) + + # Демон подхватит новое расписание из config.json при ближайшей сверке + logger.info("Schedule updated: %s -> %s", schedule_update.type, schedule_update.cron) + + return {"message": "Schedule updated", "type": schedule_update.type, "cron": schedule_update.cron, + "applied_within_seconds": cc.SYNC_INTERVAL} + + +def _purge(kind, source): + with db.session() as conn: + return db.purge_source(conn, kind, source) + + +@app.get("/asns") +def list_asns(): + return {"asns": load_full_config().get("asns", [])} + + +@app.post("/asns", dependencies=[Depends(verify_token)]) +def add_asn(body: ASNBody, response: Response): + added, _ = cc.add_to_config_list("asns", body.asn) + response.status_code = 201 if added else 200 + logger.info("ASN %s %s", body.asn, "added" if added else "already present") + return {"asn": body.asn, "added": added} + + +@app.delete("/asns/{asn}", dependencies=[Depends(verify_token)]) +def remove_asn(asn: int = Path(ge=ASN_MIN, le=ASN_MAX), + purge: bool = Query(False, description="Also delete collected prefixes immediately")): + removed, _ = cc.remove_from_config_list("asns", asn) + purged = _purge("asn", str(asn)) if purge else False + if not removed and not purged: + raise HTTPException(status_code=404, detail=f"AS{asn} not found") + logger.info("ASN %s removed (purged=%s)", asn, purged) + return {"asn": asn, "removed": removed, "purged": purged} + + +@app.get("/fqdns") +def list_fqdns(): + return {"fqdns": load_full_config().get("fqdns", [])} + + +@app.post("/fqdns", dependencies=[Depends(verify_token)]) +def add_fqdn(body: FQDNBody, response: Response): + added, _ = cc.add_to_config_list("fqdns", body.fqdn) + response.status_code = 201 if added else 200 + logger.info("FQDN %s %s", body.fqdn, "added" if added else "already present") + return {"fqdn": body.fqdn, "added": added} + + +@app.delete("/fqdns/{fqdn}", dependencies=[Depends(verify_token)]) +def remove_fqdn(fqdn: str, purge: bool = Query(False, description="Also delete collected IPs immediately")): + try: + fqdn = normalize_fqdn(fqdn) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + removed, _ = cc.remove_from_config_list("fqdns", fqdn) + purged = _purge("fqdn", fqdn) if purge else False + if not removed and not purged: + raise HTTPException(status_code=404, detail=f"{fqdn} not found") + logger.info("FQDN %s removed (purged=%s)", fqdn, purged) + return {"fqdn": fqdn, "removed": removed, "purged": purged} + + +@app.post("/collect", status_code=202, dependencies=[Depends(verify_token)]) +def request_collect(body: Optional[CollectRequest] = None): + """Немедленный сбор: запрос передаётся демону (он проверяет его каждые несколько секунд).""" + alive, _, _ = collector_state() + if not alive: + raise HTTPException(status_code=503, detail="Collector daemon is not running; start it first.") + kind = body.type if body else "all" + requested = cc.request_collection(cc.COLLECT_TYPES if kind == "all" else [kind]) + logger.info("Manual collection requested: %s", requested) + return {"requested": requested, "message": "Collection requested; follow progress in GET /health", + "picked_up_within_seconds": cc.TRIGGER_POLL_INTERVAL} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/cidr_collector.py b/cidr_collector.py new file mode 100644 index 0000000..478b46d --- /dev/null +++ b/cidr_collector.py @@ -0,0 +1,271 @@ +import requests +import datetime +import os +import argparse +import logging +import socket +import sys + +import db +from storage import StorageError, file_lock, load_json, save_json_atomic + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# Каталог данных: по умолчанию рядом с кодом, в контейнере - том (RIPE_DATA_DIR=/data) +DATA_DIR = os.environ.get("RIPE_DATA_DIR", BASE_DIR) +CONFIG_FILE = os.path.join(DATA_DIR, "config.json") +DB_FILE = os.path.join(DATA_DIR, "ripe.db") # собранные адреса (SQLite) +# Старые JSON-хранилища: используются только для однократного импорта в DB_FILE (см. db.py) +DATA_FILE = os.path.join(DATA_DIR, "data.json") +FQDN_DATA_FILE = os.path.join(DATA_DIR, "fqdn_data.json") +STATUS_FILE = os.path.join(DATA_DIR, "status.json") # пишет collector_daemon, читает API (/health) +COLLECT_REQUEST_FILE = os.path.join(DATA_DIR, "collect_request.json") # API -> демон: POST /collect +BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json" + +DEFAULT_TTL_DAYS = 90 +DEFAULT_CHANGES_RETENTION_DAYS = 30 # срок хранения журнала изменений для /addresses/diff +# Демон сверяет расписание с config.json и пишет heartbeat в status.json с этим периодом; +# API считает демон живым, пока heartbeat не старше STATUS_STALE_AFTER секунд +SYNC_INTERVAL = 30 +STATUS_STALE_AFTER = 120 +COLLECT_TYPES = ("asn", "fqdn") +TRIGGER_POLL_INTERVAL = 5 # как часто демон проверяет запросы на немедленный сбор, секунды + +logger = logging.getLogger(__name__) + + +def load_full_config(): + return load_json(CONFIG_FILE, {"asns": [], "fqdns": []}) + + +def update_config(mutator): + """Read-modify-write config.json под блокировкой с атомарной записью. + + mutator(config) изменяет словарь на месте и возвращает результат, который отдаётся вызывающему. + Единственный путь записи конфигурации для CLI и API. + """ + with file_lock(CONFIG_FILE): + config = load_full_config() + result = mutator(config) + save_json_atomic(CONFIG_FILE, config) + return result + + +def add_to_config_list(key, value): + """Добавляет значение в список конфигурации ("asns"/"fqdns"). Возвращает (добавлено, актуальный список).""" + def mutate(config): + items = config.setdefault(key, []) + if value in items: + return False, items + items.append(value) + return True, items + return update_config(mutate) + + +def remove_from_config_list(key, value): + """Убирает значение из списка конфигурации. Возвращает (удалено, актуальный список).""" + def mutate(config): + items = config.setdefault(key, []) + if value not in items: + return False, items + items.remove(value) + return True, items + return update_config(mutate) + + +def request_collection(types): + """API: ставит запрос на немедленный сбор. Повторные запросы объединяются. Возвращает список типов.""" + with file_lock(COLLECT_REQUEST_FILE): + pending = set(load_json(COLLECT_REQUEST_FILE, {}).get("types", [])) + merged = sorted(pending | set(types)) + save_json_atomic(COLLECT_REQUEST_FILE, {"types": merged, "requested_at": datetime.datetime.now().isoformat()}) + return merged + + +def pop_collection_requests(): + """Демон: забирает и удаляет запрос на немедленный сбор. Возвращает список типов (возможно пустой).""" + if not os.path.exists(COLLECT_REQUEST_FILE): + return [] + with file_lock(COLLECT_REQUEST_FILE): + types = load_json(COLLECT_REQUEST_FILE, {}).get("types", []) + if os.path.exists(COLLECT_REQUEST_FILE): + os.remove(COLLECT_REQUEST_FILE) + return [t for t in types if t in COLLECT_TYPES] + + +class CIDRCollector: + def __init__(self): + self.config = load_full_config() + self.asns = self.config.get("asns", []) + self.ttl_days = self.config.get("ttl_days", DEFAULT_TTL_DAYS) + + def add_asn(self, asn): + added, self.asns = add_to_config_list("asns", asn) + logger.info("ASN %s added." if added else "ASN %s already in list.", asn) + + def remove_asn(self, asn): + removed, self.asns = remove_from_config_list("asns", asn) + logger.info("ASN %s removed." if removed else "ASN %s not found in list.", asn) + + def list_asns(self): + print("Current ASNs:", self.asns) + + def fetch_prefixes(self, asn): + params = {'resource': f'AS{asn}'} + try: + response = requests.get(BASE_URL, params=params, timeout=10) + response.raise_for_status() + data = response.json() + + prefixes = [] + if 'data' in data and 'prefixes' in data['data']: + for item in data['data']['prefixes']: + if 'prefix' in item: + prefixes.append(item['prefix']) + return prefixes + except Exception as e: + logger.error("Error fetching data for AS%s: %s", asn, e) + return None + + def run_collection(self): + logger.info("Starting ASN CIDR collection...") + # Сетевые запросы выполняем до транзакции, чтобы держать блокировку записи минимально + fetched = {} + for asn in self.asns: + logger.info("Processing AS%s...", asn) + prefixes = self.fetch_prefixes(asn) + if prefixes is not None: + fetched[str(asn)] = set(prefixes) + + now = datetime.datetime.now() + with db.session() as conn, db.transaction(conn): + # Конфиг перечитываем внутри транзакции: источник, удалённый во время сбора, не воскресает + configured = {str(a) for a in load_full_config().get("asns", [])} + for str_asn, prefixes in fetched.items(): + if str_asn not in configured: + continue + added, removed = db.merge_source(conn, "asn", str_asn, prefixes, now, self.ttl_days) + logger.info("AS%s: +%d / -%d prefixes", str_asn, len(added), len(removed)) + swept = db.sweep_unconfigured(conn, "asn", configured, now, self.ttl_days) + if swept: + logger.info("Expired %d prefixes of unconfigured ASNs", swept) + db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS)) + logger.info("CIDR data saved to %s", DB_FILE) + + +class FQDNCollector: + def __init__(self): + self.config = load_full_config() + self.fqdns = self.config.get("fqdns", []) + self.ttl_days = self.config.get("ttl_days", DEFAULT_TTL_DAYS) + + def add_fqdn(self, fqdn): + added, self.fqdns = add_to_config_list("fqdns", fqdn) + logger.info("FQDN %s added." if added else "FQDN %s already in list.", fqdn) + + def remove_fqdn(self, fqdn): + removed, self.fqdns = remove_from_config_list("fqdns", fqdn) + logger.info("FQDN %s removed." if removed else "FQDN %s not found in list.", fqdn) + + def list_fqdns(self): + print("Current FQDNs:", self.fqdns) + + def resolve_fqdn(self, fqdn): + try: + # Family 0 - получаем и IPv4 (A), и IPv6 (AAAA) + results = socket.getaddrinfo(fqdn, None) + # result[4] - sockaddr, для IP-протоколов индекс 0 - строка с адресом + return list({result[4][0] for result in results}) + except socket.gaierror as e: + logger.error("Error resolving %s: %s", fqdn, e) + return [] + + def run_collection(self): + logger.info("Starting FQDN IP collection...") + fetched = {} + for fqdn in self.fqdns: + logger.info("Processing %s...", fqdn) + resolved_ips = self.resolve_fqdn(fqdn) + if not resolved_ips: + # Ошибка DNS: TTL не применяем, чтобы сбой не удалил данные + logger.warning("No IPs resolved for %s", fqdn) + continue + fetched[fqdn] = set(resolved_ips) + + now = datetime.datetime.now() + with db.session() as conn, db.transaction(conn): + configured = set(load_full_config().get("fqdns", [])) + for fqdn, ips in fetched.items(): + if fqdn not in configured: + continue + added, removed = db.merge_source(conn, "fqdn", fqdn, ips, now, self.ttl_days) + logger.info("%s: +%d / -%d IPs", fqdn, len(added), len(removed)) + swept = db.sweep_unconfigured(conn, "fqdn", configured, now, self.ttl_days) + if swept: + logger.info("Expired %d IPs of unconfigured FQDNs", swept) + db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS)) + logger.info("FQDN data saved to %s", DB_FILE) + + +def main(): + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + + parser = argparse.ArgumentParser(description="Collector for RIPE AS CIDRs and FQDN IPs") + subparsers = parser.add_subparsers(dest="command") + + # Command: run (default) + parser_run = subparsers.add_parser("run", help="Run the collection process") + parser_run.add_argument("--mode", choices=["asn", "fqdn", "all"], default="all", help="Collection mode: asn, fqdn, or all (default)") + + # ASN Commands + parser_add = subparsers.add_parser("add", help="Add an ASN") + parser_add.add_argument("asn", type=int, help="ASN to add") + + parser_remove = subparsers.add_parser("remove", help="Remove an ASN") + parser_remove.add_argument("asn", type=int, help="ASN to remove") + + # FQDN Commands + parser_add_fqdn = subparsers.add_parser("add-fqdn", help="Add an FQDN") + parser_add_fqdn.add_argument("fqdn", type=str, help="FQDN to add") + + parser_remove_fqdn = subparsers.add_parser("remove-fqdn", help="Remove an FQDN") + parser_remove_fqdn.add_argument("fqdn", type=str, help="FQDN to remove") + + # Command: list + subparsers.add_parser("list", help="List ASNs and FQDNs") + + args = parser.parse_args() + + try: + asn_collector = CIDRCollector() + fqdn_collector = FQDNCollector() + + if args.command == "add": + asn_collector.add_asn(args.asn) + elif args.command == "remove": + asn_collector.remove_asn(args.asn) + elif args.command == "add-fqdn": + fqdn_collector.add_fqdn(args.fqdn) + elif args.command == "remove-fqdn": + fqdn_collector.remove_fqdn(args.fqdn) + elif args.command == "list": + asn_collector.list_asns() + fqdn_collector.list_fqdns() + elif args.command == "run": + mode = args.mode + if mode in ["asn", "all"]: + asn_collector.run_collection() + + if mode == "all": + print("-" * 20) + + if mode in ["fqdn", "all"]: + fqdn_collector.run_collection() + else: + parser.print_help() + except StorageError as e: + logger.error("Aborted: %s", e) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/collector_daemon.py b/collector_daemon.py new file mode 100644 index 0000000..0506d24 --- /dev/null +++ b/collector_daemon.py @@ -0,0 +1,167 @@ +"""Отдельный процесс-сборщик: держит расписание и запускает сбор ASN/FQDN. + +Общение с API - через файлы: расписание читается из config.json (его меняет POST /schedule), +состояние заданий и heartbeat пишутся в status.json (его читает GET /health). +""" +import datetime +import logging +import os +import signal +import sys +import threading + +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.cron import CronTrigger + +import cidr_collector as cc +from cidr_collector import CIDRCollector, FQDNCollector, load_full_config +from storage import StorageError, save_json_atomic, try_lock + +logger = logging.getLogger("collector_daemon") + +DEFAULT_CRONS = {"asn": "0 2 * * *", "fqdn": "0 3 * * *"} +COLLECTORS = {"asn": CIDRCollector, "fqdn": FQDNCollector} +LOCK_FILE = os.path.join(cc.DATA_DIR, "collector.daemon.lock") + +# Состояние заданий: действующий cron, результат последнего запуска, отклонённый cron (чтобы не спамить логом) +job_state = {name: {"cron": None, "last_run": None, "last_finished": None, "running": False, + "last_error": None, "rejected_cron": None} + for name in COLLECTORS} +_state_lock = threading.Lock() +# Один запуск за раз на тип: наложение планового и ручного сбора пропускается +_run_locks = {name: threading.Lock() for name in COLLECTORS} + + +def write_status(scheduler): + """Атомарно пишет status.json; вызывается после каждого запуска и как heartbeat.""" + with _state_lock: + jobs = {} + for name, state in job_state.items(): + job = scheduler.get_job(f"{name}_job") + next_run = getattr(job, "next_run_time", None) if job else None + jobs[name] = {"cron": state["cron"], "last_run": state["last_run"], + "last_finished": state["last_finished"], "running": state["running"], + "last_error": state["last_error"], + "next_run": next_run.isoformat() if next_run else None} + save_json_atomic(cc.STATUS_FILE, {"updated_at": datetime.datetime.now().isoformat(), "jobs": jobs}) + + +def run_job(name, scheduler): + lock = _run_locks[name] + if not lock.acquire(blocking=False): + logger.warning("%s collection is already running, skipping this start", name) + return + try: + logger.info("Running %s collection...", name) + state = job_state[name] + state["last_run"] = datetime.datetime.now().isoformat() + state["running"] = True + write_status(scheduler) + try: + # Новый экземпляр на каждый запуск - свежий config.json + COLLECTORS[name]().run_collection() + state["last_error"] = None + except Exception as e: + logger.exception("%s collection failed", name) + state["last_error"] = str(e) + finally: + state["running"] = False + state["last_finished"] = datetime.datetime.now().isoformat() + write_status(scheduler) + finally: + lock.release() + + +def check_collect_requests(scheduler): + """Забирает запрос POST /collect и запускает сбор немедленно (разовыми заданиями).""" + try: + types = cc.pop_collection_requests() + except StorageError as e: + logger.error("Cannot read collect request: %s", e) + return + for name in types: + logger.info("Manual %s collection requested", name) + scheduler.add_job(run_job, args=[name, scheduler], id=f"{name}_manual", replace_existing=True) + + +def schedule_jobs(scheduler): + """Создаёт задания по расписанию из config.json (для отсутствующих значений - по умолчанию).""" + schedule = load_full_config().get("schedule", {}) + for name in COLLECTORS: + cron = schedule.get(name, DEFAULT_CRONS[name]) + scheduler.add_job(run_job, CronTrigger.from_crontab(cron), args=[name, scheduler], + id=f"{name}_job", replace_existing=True, max_instances=1, coalesce=True) + job_state[name]["cron"] = cron + logger.info("Job %s scheduled: %s", name, cron) + + +def sync_schedule(scheduler): + """Подхватывает изменения расписания из config.json без перезапуска; пишет heartbeat.""" + try: + schedule = load_full_config().get("schedule", {}) + except StorageError as e: + logger.error("Cannot read config, keeping current schedule: %s", e) + schedule = {} + + for name in COLLECTORS: + cron = schedule.get(name) + state = job_state[name] + if cron is None or cron == state["cron"] or cron == state["rejected_cron"]: + continue + try: + trigger = CronTrigger.from_crontab(cron) + except ValueError as e: + logger.error("Invalid cron for %s (%r), keeping %r: %s", name, cron, state["cron"], e) + state["rejected_cron"] = cron + continue + scheduler.reschedule_job(f"{name}_job", trigger=trigger) + logger.info("Job %s rescheduled: %s -> %s", name, state["cron"], cron) + state["cron"] = cron + state["rejected_cron"] = None + + write_status(scheduler) + + +def build_scheduler(): + scheduler = BlockingScheduler() + schedule_jobs(scheduler) + scheduler.add_job(sync_schedule, "interval", seconds=cc.SYNC_INTERVAL, args=[scheduler], + id="sync_schedule", max_instances=1, coalesce=True) + scheduler.add_job(check_collect_requests, "interval", seconds=cc.TRIGGER_POLL_INTERVAL, args=[scheduler], + id="collect_requests", max_instances=1, coalesce=True) + return scheduler + + +def main(): + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + # Служебные задания (опрос запросов каждые 5 с) не должны засорять лог + logging.getLogger("apscheduler.executors.default").setLevel(logging.WARNING) + + lock = try_lock(LOCK_FILE) + if lock is None: + logger.error("Another collector daemon is already running (%s is locked).", LOCK_FILE) + sys.exit(1) + + try: + scheduler = build_scheduler() + except (StorageError, ValueError) as e: + logger.error("Cannot start: %s", e) + sys.exit(1) + + # SIGTERM -> штатный выход: текущий сбор завершится, файлы пишутся атомарно + signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0)) + + write_status(scheduler) + logger.info("Collector daemon started.") + try: + scheduler.start() + except (KeyboardInterrupt, SystemExit): + pass + finally: + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("Collector daemon stopped.") + + +if __name__ == "__main__": + main() diff --git a/config.json b/config.json new file mode 100644 index 0000000..c88e630 --- /dev/null +++ b/config.json @@ -0,0 +1,20 @@ +{ + "asns": [ + 62014, + 62041, + 59930, + 44907, + 211157, + 11917 + ], + "fqdns": [ + "api.whatsapp.com", + "web.whatsapp.com", + "faq.whatsapp.com" + ], + "schedule": { + "asn": "*/15 * * * *", + "fqdn": "0 3 * * *" + }, + "ttl_days": 90 +} \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..a12f6bc --- /dev/null +++ b/db.py @@ -0,0 +1,260 @@ +"""Хранение собранных адресов в SQLite (ripe.db). Конфигурация и heartbeat остаются в JSON.""" +import datetime +import logging +import os +import sqlite3 +import time +from contextlib import contextmanager + +from storage import StorageError, load_json + +logger = logging.getLogger(__name__) + +SCHEMA_VERSION = 2 + +_UPSERT = ("INSERT INTO addresses (kind, source, value, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT (kind, source, value) DO UPDATE SET last_seen = excluded.last_seen") + + +def _iso(moment): + return moment.isoformat(timespec="seconds") + + +def _quarantine(path): + """Повреждённую базу (вместе с -wal/-shm) убираем в сторону, чтобы её не затёрли.""" + stamp = int(time.time()) + for suffix in ("", "-wal", "-shm"): + if os.path.exists(path + suffix): + os.replace(path + suffix, f"{path}.corrupt-{stamp}{suffix}") + return f"{path}.corrupt-{stamp}" + + +def connect(path=None): + """Открывает базу, создаёт схему и однократно импортирует старые data.json / fqdn_data.json.""" + import cidr_collector as cc # пути читаем при вызове (их подменяют тесты); импорт отложен из-за цикла + + path = path or cc.DB_FILE + conn = sqlite3.connect(path, timeout=5.0, isolation_level=None) # транзакциями управляем явно + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA temp_store=MEMORY") + _ensure_schema(conn, cc) + except sqlite3.OperationalError as e: # например, database is locked: базу не трогаем + conn.close() + raise StorageError(f"Cannot open {path}: {e}") from e + except sqlite3.DatabaseError as e: # not a database / malformed + conn.close() + backup = _quarantine(path) + logger.error("Corrupted database %s (%s); moved to %s", path, e, backup) + raise StorageError(f"{path} is corrupted") from e + return conn + + +@contextmanager +def session(): + """Соединение на время блока (закрывается по выходу).""" + conn = connect() + try: + yield conn + finally: + conn.close() + + +@contextmanager +def transaction(conn): + """Одна транзакция на запись (BEGIN IMMEDIATE: писатель один, читатели не блокируются).""" + conn.execute("BEGIN IMMEDIATE") + try: + yield + except BaseException: + conn.execute("ROLLBACK") + raise + conn.execute("COMMIT") + + +_TS = "strftime('%Y-%m-%dT%H:%M:%fZ', 'now')" # UTC с миллисекундами: сравнивается как строка + +# Журнал изменений итогового набора значений (см. get_changes). Триггеры покрывают все пути записи и удаления. +_JOURNAL_SCHEMA = ( + "CREATE TABLE changes (" + " id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, kind TEXT NOT NULL, value TEXT NOT NULL," + " action TEXT NOT NULL CHECK (action IN ('add', 'del')))", + "CREATE INDEX changes_ts ON changes (ts)", + "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + # Значение появилось, если у других источников его не было + "CREATE TRIGGER addresses_added AFTER INSERT ON addresses" + " WHEN NOT EXISTS (SELECT 1 FROM addresses WHERE kind = NEW.kind AND value = NEW.value AND source <> NEW.source)" + f" BEGIN INSERT INTO changes (ts, kind, value, action) VALUES ({_TS}, NEW.kind, NEW.value, 'add'); END", + # Значение исчезло, если удалена его последняя запись + "CREATE TRIGGER addresses_removed AFTER DELETE ON addresses" + " WHEN NOT EXISTS (SELECT 1 FROM addresses WHERE kind = OLD.kind AND value = OLD.value)" + f" BEGIN INSERT INTO changes (ts, kind, value, action) VALUES ({_TS}, OLD.kind, OLD.value, 'del'); END", +) + + +def _ensure_schema(conn, cc): + if conn.execute("PRAGMA user_version").fetchone()[0] >= SCHEMA_VERSION: + return + imported = [] + conn.execute("BEGIN IMMEDIATE") # API и демон могут стартовать одновременно: второй дождётся первого + try: + version = conn.execute("PRAGMA user_version").fetchone()[0] + if version == 0: + conn.execute( + "CREATE TABLE IF NOT EXISTS addresses (" + " kind TEXT NOT NULL CHECK (kind IN ('asn', 'fqdn'))," + " source TEXT NOT NULL, value TEXT NOT NULL," + " first_seen TEXT NOT NULL, last_seen TEXT NOT NULL," + " PRIMARY KEY (kind, source, value))") + conn.execute("CREATE INDEX IF NOT EXISTS addresses_value ON addresses (value)") + imported = _import_legacy_json(conn, cc) # журнал ещё не создан: импорт в него не попадает + if version < 2: + for statement in _JOURNAL_SCHEMA: + conn.execute(statement) + conn.execute("INSERT INTO meta VALUES ('horizon_id', '0')") + conn.execute(f"INSERT INTO meta SELECT 'horizon_ts', {_TS}") + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + conn.execute("COMMIT") + except BaseException: + conn.execute("ROLLBACK") + raise + # Старые JSON оставляем как резервную копию (и путь отката) + stamp = int(time.time()) + for legacy in imported: + os.replace(legacy, f"{legacy}.migrated-{stamp}") + logger.info("Imported %s into the database; original kept as %s.migrated-%d", legacy, legacy, stamp) + + +def _import_legacy_json(conn, cc): + """Переносит data.json (asn) и fqdn_data.json (fqdn). Возвращает пути реально импортированных файлов.""" + now = _iso(datetime.datetime.now()) + imported = [] + for kind, path, items_key in (("asn", cc.DATA_FILE, "prefixes"), ("fqdn", cc.FQDN_DATA_FILE, "ips")): + if not os.path.exists(path): + continue + rows = [] + for source, entry in load_json(path, {}).items(): + seen = entry.get("seen") + if seen: + rows += [(kind, source, value, info["first_seen"], info["last_seen"]) for value, info in seen.items()] + else: + # Формат до TTL: last_seen = сейчас, чтобы после перехода ничего не истекло сразу + first = entry.get("last_updated", now) + rows += [(kind, source, value, first, now) for value in entry.get(items_key, [])] + conn.executemany("INSERT OR IGNORE INTO addresses VALUES (?, ?, ?, ?, ?)", rows) + imported.append(path) + return imported + + +def merge_source(conn, kind, source, values, now, ttl_days): + """Объединяет найденные значения источника с сохранёнными и применяет TTL. + + Вызывается внутри transaction(). Возвращает (добавленные, удалённые по TTL). + """ + before = {v for (v,) in conn.execute("SELECT value FROM addresses WHERE kind = ? AND source = ?", (kind, source))} + stamp = _iso(now) + conn.executemany(_UPSERT, [(kind, source, value, stamp, stamp) for value in values]) + + removed = set() + if ttl_days > 0: + cutoff = _iso(now - datetime.timedelta(days=ttl_days)) + where = "kind = ? AND source = ? AND last_seen < ?" + removed = {v for (v,) in conn.execute(f"SELECT value FROM addresses WHERE {where}", (kind, source, cutoff))} + conn.execute(f"DELETE FROM addresses WHERE {where}", (kind, source, cutoff)) + return set(values) - before, removed + + +def sweep_unconfigured(conn, kind, configured, now, ttl_days): + """Источники, которых уже нет в конфигурации, не опрашиваются: их адреса истекают по TTL. + + Вызывается внутри transaction(). Возвращает число удалённых адресов. + """ + if ttl_days <= 0: + return 0 + cutoff = _iso(now - datetime.timedelta(days=ttl_days)) + where, params = "kind = ? AND last_seen < ?", [kind, cutoff] + if configured: + where += f" AND source NOT IN ({','.join('?' * len(configured))})" + params += sorted(configured) + return conn.execute(f"DELETE FROM addresses WHERE {where}", params).rowcount + + +def purge_source(conn, kind, source): + """Удаляет все адреса источника. True, если что-то было.""" + return conn.execute("DELETE FROM addresses WHERE kind = ? AND source = ?", (kind, source)).rowcount > 0 + + +def get_values(conn, kind=None): + query, params = "SELECT DISTINCT value FROM addresses", () + if kind: + query, params = query + " WHERE kind = ?", (kind,) + return [v for (v,) in conn.execute(query, params)] + + +def count_values(conn, kind=None): + query, params = "SELECT COUNT(DISTINCT value) FROM addresses", () + if kind: + query, params = query + " WHERE kind = ?", (kind,) + return conn.execute(query, params).fetchone()[0] + + +def prune_changes(conn, now, retention_days): + """Удаляет записи журнала старше срока хранения и сдвигает «горизонт» (граница, глубже которой diff недоступен). + + Вызывается внутри transaction(). retention_days <= 0 - без очистки. Возвращает число удалённых записей. + """ + if retention_days <= 0: + return 0 + cutoff = (now.astimezone(datetime.timezone.utc) - datetime.timedelta(days=retention_days)).strftime( + "%Y-%m-%dT%H:%M:%S.000Z") + last_id = conn.execute("SELECT MAX(id) FROM changes WHERE ts < ?", (cutoff,)).fetchone()[0] + if last_id is None: + return 0 + removed = conn.execute("DELETE FROM changes WHERE id <= ?", (last_id,)).rowcount + conn.execute("UPDATE meta SET value = ? WHERE key = 'horizon_id'", (str(last_id),)) + conn.execute("UPDATE meta SET value = ? WHERE key = 'horizon_ts'", (cutoff,)) + return removed + + +def journal_head(conn): + """Текущий курсор журнала (номер последней записи).""" + return conn.execute("SELECT COALESCE(MAX(id), (SELECT CAST(value AS INTEGER) FROM meta WHERE key = 'horizon_id'))" + " FROM changes").fetchone()[0] + + +def get_changes(conn, kinds, cursor=None, since_ts=None): + """Изменения итогового набора значений после курсора (номер записи журнала) или времени (UTC, строка). + + Берётся первое действие по значению после точки отсчёта и сверяется с текущим состоянием: значение, + добавленное и удалённое (или наоборот) в этом интервале, в результат не попадает. + Возвращает (added, removed, cursor) или None, если журнал не покрывает точку отсчёта (ответ 410). + """ + conn.execute("BEGIN") # один снимок для всех запросов + try: + meta = dict(conn.execute("SELECT key, value FROM meta")) + horizon_id = int(meta["horizon_id"]) + head = conn.execute("SELECT COALESCE(MAX(id), ?) FROM changes", (horizon_id,)).fetchone()[0] + if since_ts is not None: + if since_ts < meta["horizon_ts"]: + return None + cursor = conn.execute("SELECT COALESCE(MAX(id), ?) FROM changes WHERE ts < ?", + (horizon_id, since_ts)).fetchone()[0] + if not horizon_id <= cursor <= head: + return None + first = {} + for kind, value, action in conn.execute( + "SELECT kind, value, action FROM changes WHERE id > ? ORDER BY id", (cursor,)): + if kind in kinds: + first.setdefault((kind, value), action) + added, removed = set(), set() + for (kind, value), action in first.items(): + present = conn.execute("SELECT 1 FROM addresses WHERE kind = ? AND value = ? LIMIT 1", + (kind, value)).fetchone() is not None + if action == "add" and present: + added.add(value) + elif action == "del" and not present: + removed.add(value) + return added, removed, head + finally: + conn.execute("COMMIT") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..51eade0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +# API и сборщик - два независимых сервиса одного образа; общаются только файлами в томе /data +x-common: &common + build: . + image: ripe-cidr-collector + restart: unless-stopped + environment: + TZ: ${TZ:-UTC} # часовой пояс, в котором считаются cron-расписания + volumes: + - ripe_data:/data + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + api: + <<: *common + ports: + - "${API_PORT:-8000}:8000" + env_file: .env # RIPE_API_TOKEN; без него изменяющие запросы отключены (503) + healthcheck: + test: ["CMD", "python", "healthcheck.py", "api"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + collector: + <<: *common + command: ["python", "collector_daemon.py"] + stop_grace_period: 60s # идущий сбор успевает завершиться по SIGTERM + healthcheck: + test: ["CMD", "python", "healthcheck.py", "collector"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + ripe_data: diff --git a/docs/analysis-2026-09-20.md b/docs/analysis-2026-09-20.md new file mode 100644 index 0000000..11ce843 --- /dev/null +++ b/docs/analysis-2026-09-20.md @@ -0,0 +1,63 @@ +# Анализ проекта: сделано и осталось (2026-09-20) + +Состояние проекта после трёх доработок: надёжность и безопасность, тесты в контейнере, форматы вывода. Планы и итоги лежат рядом в `docs/`. + +## Сделано + +| Направление | Результат | +|---|---| +| Токен на `POST /schedule` | Заголовок `X-API-Key`, токен из `RIPE_API_TOKEN`, без токена запись отключена (503). Читающие эндпоинты открыты намеренно. | +| TTL записей | `ttl_days` (по умолчанию 90, `0` = бессрочно), `first_seen`/`last_seen`, авто-миграция старых данных. | +| Надёжность хранения | Атомарная запись, `flock`, битый JSON переименовывается в `*.corrupt-*`, API отвечает 503. | +| Логирование и `/health` | `logging` вместо `print`, `/health` со статусом заданий. | +| Агрегация CIDR, `ip_version` | Параметры `aggregate`, `ip_version` в `GET /addresses`; ответ по умолчанию побайтно прежний. | +| Форматы вывода | `nftables`, `mikrotik`, `bird`, `frr` (`format`, `name`). Проверены `bird -p` и `nft -c`. | +| Тесты | 9 тестов, запуск в контейнере (`Dockerfile.test`). | +| Развёртывание | Запуск не от root в systemd и OpenRC (документация в README). | + +## Не сделано + +| Направление | Комментарий | +|---|---| +| Метрики Prometheus (`/metrics`), алерты | Не начато | +| Управление ASN и FQDN через API | Сейчас только CLI | +| Diff-эндпоинт (`?since=`), уведомления (webhook, Telegram) | Не начато | +| Хранение в SQLite | Не начато | +| Разделение сборщика и API на два процесса | Не начато | +| Контейнер для самого приложения (Dockerfile, compose) | Не начато | +| Форматы `text`, `ipset` | Пользователь не выбрал | + +## Остаточные риски + +| # | Риск | Статус | +|---|---|---| +| 1 | После порчи файла API отдаёт пустой список, резервной копии нет | Не закрыт | +| 2 | Токен только на `POST`, читать список может любой | Осознанный выбор | +| 3 | CLI `add`/`remove` меняет конфиг без общей блокировки | Не закрыт | +| 4 | Версии в `requirements.txt` не закреплены | Не закрыт | +| 5 | Запросы к RIPE без повторов | Не закрыт | +| 6 | Нет репозитория git, истории изменений нет | Не закрыт | +| 7 | MikroTik и FRR не проверены на реальном ПО | Не закрыт | +| 8 | Тесты не покрывают `/health` и параллельную запись | По правилам проекта минимум | + +Для FRR отдельно проверить: не выдаёт ли `no ip prefix-list ` ошибку, если список ещё не создан. + +## Новые наблюдения + +1. Реальные `data.json` и `fqdn_data.json` ещё в старом формате (нет блока `seen`). Миграция и TTL заработают после первого запуска сборщика. +2. Невалидные адреса при `aggregate=true` и в форматах пропускаются с предупреждением в логе, клиент об этом не узнаёт. +3. В данных нет версии схемы, что осложнит следующую миграцию (например, на SQLite). +4. `/addresses` читает и разбирает JSON при каждом запросе. Для текущих объёмов это нормально, при росте списков понадобится кэш. + +## Рекомендуемый порядок + +1. **Гигиена:** `git init` с первым коммитом, закрепление версий (`pip freeze` внутри контейнера), резервная копия `data.json.bak` (риски 1, 4, 6). +2. **Dockerfile и compose для приложения** (продолжение работы с контейнерами). +3. **Управление ASN и FQDN через API с токеном и `/metrics`.** +4. **SQLite и diff/уведомления**, если понадобится история изменений. + +## Связанные документы + +- `plan-reliability-security.md`, `summary-reliability-security.md` +- `plan-tests-in-container.md`, `summary-tests-in-container.md` +- `plan-output-formats.md`, `summary-output-formats.md` diff --git a/docs/analysis-2026-09-21.md b/docs/analysis-2026-09-21.md new file mode 100644 index 0000000..31a9f57 --- /dev/null +++ b/docs/analysis-2026-09-21.md @@ -0,0 +1,89 @@ +# Анализ проекта: сделано и осталось (2026-09-21, сверка с графом) + +Состояние после восьми доработок: надёжность и безопасность, тесты в контейнере, форматы вывода, разделение процессов, управление ASN/FQDN через API, контейнер приложения, SQLite, `POST /collect`, `GET /addresses/diff`. Предыдущий анализ: `analysis-2026-09-20.md`. Планы и итоги лежат рядом в `docs/`. + +Проект: 7 модулей Python (API, сборщик, демон, БД, форматы, хранилище, healthcheck), 1235 строк, 18 тестов (15 функций с параметризацией) в 5 файлах, 20 документов в `docs/`. + +**Граф знаний** (`graphify-out/`, построен по коду и `docs/`): 315 узлов, 643 связи, 13 сообществ. Использован для сверки: узлы-«хабы», связи между модулями и документами, изолированные узлы. Все выводы из графа ниже проверены по исходникам. + +## Что изменилось с предыдущей версии этого файла + +- **Выполнено:** `GET /addresses/diff` (журнал изменений на триггерах SQLite, курсор, `410` за горизонтом, заголовок `X-Changes-Cursor`). Из «Не сделано» убран diff; уведомления остались. +- **Исправлено по сверке:** + - Безымянных образов Docker не 65, а 3 (всего образов 31). Наблюдение 3 снято. + - Предположение, что в демоне не ловится `sqlite3.Error`, не подтвердилось: `run_job` перехватывает любое исключение сбора и пишет его в `last_error`. Пробела нет. + - Метрики: строк кода 1235 (было 1102), тестов 18 (было 16). +- **Новое:** `graphify-out/` (1,2 МБ) не был исключён из `.gitignore` и `.dockerignore`, то есть попадал бы в образ. Исправлено, в README добавлен пункт о графе. + +## Сделано + +| Направление | Результат | +|---|---| +| Токен на изменяющие запросы | `X-API-Key` на `POST`/`DELETE`, без токена запись отключена (503). Чтение открыто намеренно. | +| TTL, атомарная запись, защита от порчи | TTL (`ttl_days`) на SQLite. Битые JSON и база уходят в `*.corrupt-*`, API отвечает 503. | +| Форматы вывода, агрегация CIDR, `ip_version` | nftables, mikrotik, bird, frr. | +| Разделение сборщика и API | Отдельный демон, singleton, heartbeat, `/health` по `status.json`. | +| Управление ASN и FQDN через API | Добавление, удаление, `purge`, старение данных удалённых источников. | +| Контейнер приложения | `Dockerfile` и `docker-compose.yml`: два сервиса, том, non-root, read-only, healthcheck. | +| SQLite | Таблица `addresses`, WAL, автоматическая миграция из JSON (оригиналы сохраняются), транзакции, чтение без блокировок. | +| `POST /collect` | Токен, ответ 202, запрос демону файлом `collect_request.json` (опрос каждые 5 с), объединение запросов, пропуск наложения, `503` без живого демона. | +| `GET /addresses/diff` | Журнал `changes` (триггеры, схема версии 2), итоговый эффект вместо истории, срок хранения `changes_retention_days` (30 дней), курсор или время, `410` за горизонтом. Проверено тестами и на копии базы версии 1. | +| Граф знаний | `graphify-out/`: интерактивный граф, отчёт, JSON; исключён из git и образа. | +| Тесты | 18 проверок, запуск в контейнере. | + +## Не сделано + +| Направление | Статус | +|---|---| +| Автоматическая резервная копия базы | Есть только команда в README (`sqlite3 ".backup"`), заданий в демоне нет | +| Метрики `/metrics`, алерты | Не начато | +| Уведомления о изменениях (webhook, Telegram) | Не начато; основа (журнал) есть | +| Повторные попытки запросов к RIPE | Не начато | +| TLS перед API | Не начато (решение пользователя: порт наружу без TLS) | + +## Остаточные риски + +| # | Риск | Статус | +|---|---|---| +| 1 | Нет репозитория git, истории изменений нет. | Не закрыт | +| 2 | Версии в `requirements.txt` не закреплены, это влияет и на образ. | Не закрыт | +| 3 | После порчи базы API отдаёт пустой список, пока сборщик не наполнит новую базу; автовосстановления из копии нет. Курсоры diff после пересоздания базы недействительны (`410`), клиент делает полную выгрузку. | Не закрыт | +| 4 | MikroTik и FRR не проверены на реальном ПО. | Не закрыт | +| 5 | Токен идёт по HTTP открытым текстом. | Осознанный выбор | +| 6 | Один общий токен, без ротации и аудита. | Не закрыт | +| 7 | Тестов минимум, что соответствует правилам проекта. | Осознанно | +| 8 | `POST /collect` без ограничения частоты: повторные запросы во время идущего сбора пропускаются, но защиты от нагрузки на RIPE нет. | Не закрыт | +| 9 | Журнал diff проверен только на копиях и тестах: размер и нагрузка на реальных данных неизвестны. | Не закрыт (новое) | + +## Наблюдения + +| # | Наблюдение | Состояние | +|---|---|---| +| 1 | **Изменения не применены к реальным данным.** В каталоге проекта нет `ripe.db`, `data.json` и `fqdn_data.json` остались в старом формате; всё проверено только на копиях. Первый запуск на боевых данных выполнит миграцию до схемы версии 2: `last_seen` старых записей станет равным времени миграции, журнал diff начнётся пустым (импорт в него не пишется), клиентам стартовать с `X-Changes-Cursor`. | Без изменений | +| 2 | **`/health` не видит сбоев источников.** Недоступный RIPE или DNS пишется в лог, задание считается успешным (`last_error` отражает только исключение всего сбора). | Без изменений | +| 3 | ~~Мусор от сборок: 65 безымянных образов.~~ Сейчас 3 безымянных образа. | Снято | +| 4 | **Повреждённый `config.json`** переименовывается при чтении; первый `POST /asns` после этого создаст файл без остальных источников, расписания и `ttl_days`. | Без изменений | +| 5 | **Логи:** сообщение «data saved» пишется после каждой транзакции, даже без изменений (`cidr_collector.py:152,206`). | Без изменений | +| 6 | **Структура README:** разделы 8 (Docker) и 9 (SQLite) дописаны в конец, разделы 1-4 описывают ручную установку. Стоит перестроить: Docker в начало, ручная установка ниже. | Без изменений | +| 7 | **`google.com`** в данных не входит в конфигурацию, его адрес удалится через 90 дней после миграции. | Без изменений | +| 8 | **Ручной сбор стартует не мгновенно**, а в пределах 5 секунд (интервал опроса демона). | Без изменений | +| 9 | **Логи APScheduler о плановых запусках скрыты** (чтобы опрос запросов каждые 5 с не засорял лог); остаются сообщения самого приложения. | Без изменений | +| 10 | **Общая точка отказа хранилища (по графу).** Главные узлы: `load_json()` (19 связей), `session()` (17), `StorageError` (16). Это осознанная развязка: одно исключение скрывает JSON и SQLite от API, демона и CLI (проверено по исходникам: `api_server.py:94,192`, `collector_daemon.py:79,102,147`, `cidr_collector.py:265`). Но любое изменение `storage.py`/`db.py` затрагивает все три процесса, покрытие тестами здесь важнее всего. | Новое | +| 11 | **`api_server.py` растёт (320 строк, связность сообщества 0,06 по графу):** схемы, проверки, все эндпоинты в одном модуле; после diff стал больше. Разделять пока не нужно, но при следующем эндпоинте стоит вынести схемы и разбор параметров. | Новое | +| 12 | **Граф не заменяет проверку.** 19 изолированных узлов (например, описания эндпоинтов в README) не связаны с обработчиками в коде: это ограничение семантической выгрузки, а не обязательно пробел в документации. Расход токенов на построение в `cost.json` не записан (нули). Документы `analysis-*.md` сами входят в граф, поэтому он частично отражает выводы анализа, а не независимую оценку. | Новое | + +## Рекомендуемый порядок + +1. **Гигиена и резервные копии:** `git init` и первый коммит (после этого можно поставить хук графа), закрепление версий, ежедневное задание демона `db_backup` (`.backup` с хранением нескольких копий) и автовосстановление из последней копии при порче. +2. **Наблюдаемость:** учёт ошибок по каждому источнику (`last_success`, число ошибок) в `status.json` и `/health`, затем `/metrics` для Prometheus и повторные попытки запросов к RIPE. +3. **Развёртывание на реальных данных:** запуск через compose с миграцией текущих файлов (наблюдение 1), оценка размера журнала (риск 9). +4. **TLS-прокси** перед API, если появится внешний доступ. +5. **Уведомления** (webhook, Telegram): демон после сбора считает diff по журналу и отправляет непустой результат. + +Обновлять граф после доработок: `/graphify . --update`. + +## Связанные документы + +- `plan-*.md` / `summary-*.md`: reliability-security, tests-in-container, output-formats, process-split, asn-fqdn-api, app-container, sqlite-storage, collect-endpoint, diff-endpoint. +- Предыдущий анализ: `analysis-2026-09-20.md`. +- Граф: `graphify-out/GRAPH_REPORT.md`, `graphify-out/graph.html`. diff --git a/docs/plan-app-container.md b/docs/plan-app-container.md new file mode 100644 index 0000000..7a235e5 --- /dev/null +++ b/docs/plan-app-container.md @@ -0,0 +1,62 @@ +# План: контейнер для приложения (Dockerfile и compose) + +## Context +Сейчас приложение ставится вручную: venv, два systemd/OpenRC-сервиса, ручное управление пользователем и правами. Контейнеры используются только для тестов (`Dockerfile.test`). Цель: запускать API и демон-сборщик одной командой `docker compose up -d`, с данными на томе, без root и с проверками состояния. Формат данных и API не меняются. + +Решение пользователя: порт 8000 публикуется наружу без TLS (как сейчас). Риск: токен `X-API-Key` идёт по HTTP открытым текстом, защита только файрволом; это фиксируется в README, TLS - отдельная доработка. + +## Артефакты по правилам проекта (создаются при реализации) +- `docs/plan-app-container.md` - копия этого плана (первым шагом) +- `docs/summary-app-container.md` - итоги (в конце) +- обновить `README.md` (раздел про Docker Compose, миграция существующих данных) + +## Архитектура +Один образ, два сервиса compose (`api`, `collector`) с общим томом `ripe_data`, смонтированным в `/data`. Сервисы по-прежнему общаются только файлами (`config.json`, `data.json`, `fqdn_data.json`, `status.json`, `*.lock`). + +## Изменения + +### 1. Код: настраиваемый каталог данных (минимальная правка) +- `cidr_collector.py`: добавить `DATA_DIR = os.environ.get("RIPE_DATA_DIR", BASE_DIR)`; пути `CONFIG_FILE`, `DATA_FILE`, `FQDN_DATA_FILE`, `STATUS_FILE` строятся от `DATA_DIR`. Без переменной поведение прежнее (systemd/venv-установки не ломаются). +- `collector_daemon.py`: `LOCK_FILE` строится от `cc.DATA_DIR` (сейчас `cc.BASE_DIR`). +- Тесты не меняются (они подменяют атрибуты модуля). + +### 2. `Dockerfile` (новый) +- База `python:3.11-slim`; зависимости отдельным слоем из `requirements.txt` (`pip install --no-cache-dir`). +- Копируются только рабочие файлы: `api_server.py`, `cidr_collector.py`, `collector_daemon.py`, `formatters.py`, `storage.py`, `healthcheck.py`. +- Пользователь `ripe` (uid 10001), `/data` создаётся и принадлежит ему (именованный том при первом создании наследует владельца). +- `ENV RIPE_DATA_DIR=/data PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1`, `VOLUME /data`. +- `CMD` в exec-форме (сигналы доходят до процесса): по умолчанию `uvicorn api_server:app --host 0.0.0.0 --port 8000`; сборщик переопределяет команду в compose. + +### 3. `healthcheck.py` (новый, маленький) +- `python healthcheck.py api` - GET `http://127.0.0.1:8000/health`, успех при HTTP 200. +- `python healthcheck.py collector` - читает `status.json` (`cc.STATUS_FILE`), успех, если `updated_at` моложе `cc.STATUS_STALE_AFTER` (переиспользуем константы и логику heartbeat). + +### 4. `docker-compose.yml` (новый) +- `api`: `build: .`, `ports: "8000:8000"`, `env_file: .env` (`RIPE_API_TOKEN`), `healthcheck` (api), `restart: unless-stopped`. +- `collector`: тот же образ, `command: ["python", "collector_daemon.py"]`, `healthcheck` (collector), `stop_grace_period: 60s` (идущий сбор успевает завершиться по SIGTERM), токен не передаётся. +- Оба: `volumes: ripe_data:/data`, `environment: TZ=${TZ:-UTC}` (расписание cron считается в этом поясе; в README отметить), усиление изоляции: `read_only: true`, `tmpfs: /tmp`, `cap_drop: [ALL]`, `security_opt: [no-new-privileges:true]`, ротация логов `json-file` (`max-size: 10m`, `max-file: 3`). +- `volumes: ripe_data:`. +- `.env.example` с `RIPE_API_TOKEN=change-me` и `TZ=UTC`; `.env` в `.gitignore` и `.dockerignore`. Если токен не задан, `POST` остаётся отключённым (503) - поведение fail closed сохраняется. + +### 5. Документация и миграция (`README.md`) +- Раздел «Docker Compose»: `cp .env.example .env`, генерация токена, `docker compose up -d`, `docker compose ps`, логи, обновление (`docker compose build && docker compose up -d`). +- Миграция существующих данных в том: копирование `config.json`, `data.json`, `fqdn_data.json` во временный контейнер с `chown 10001`. +- Предупреждения: порт 8000 без TLS - токен виден в сети, ограничить файрволом или поставить proxy; часовой пояс `TZ`; `google.com` в текущих данных не входит в конфигурацию и будет стареть по TTL. +- Уточнить: systemd/OpenRC-установка остаётся рабочей альтернативой; `Dockerfile.test` используется только для тестов. +- `.dockerignore`: добавить `.env`. + +### 6. Тесты +Новых автотестов не добавляем (минимум по правилам проекта): `healthcheck.py` и сборка проверяются в контейнерном сценарии ниже; 13 существующих тестов продолжают проходить (и в `Dockerfile.test`). + +## Критичные файлы +Новые: `Dockerfile`, `docker-compose.yml`, `healthcheck.py`, `.env.example`. Правки: `cidr_collector.py` (пути), `collector_daemon.py` (`LOCK_FILE`), `README.md`, `.gitignore`, `.dockerignore`. + +## Проверка +1. `docker build -f Dockerfile.test ...` и запуск: 13 passed (регрессия после правки путей). +2. `docker compose build && docker compose up -d` с тестовым `.env` на копии данных (проект в каталоге scratchpad, реальные данные не трогаем); порт можно временно сменить переменной, чтобы не конфликтовать. +3. `docker compose ps`: оба сервиса `healthy` не позднее чем через ~1 минуту. +4. `curl /health`: `collector_alive: true`; `POST /asns` с токеном (201), без токена (401); `GET /addresses?format=nftables` отдаёт конфиг. +5. Данные переживают `docker compose down && up -d` (том), файлы в томе принадлежат uid 10001; `docker compose exec api id` - не root; запись вне `/data` невозможна (read-only rootfs). +6. `docker compose stop collector` завершается штатно (меньше `stop_grace_period`), остановка демона делает `/health` `degraded` через ~2 минуты. +7. Миграция: скопировать реальные `config.json`/`data.json`/`fqdn_data.json` (копии) в том по инструкции README, убедиться, что API видит те же адреса. +8. По окончании убрать тестовые контейнеры, тома и образы, созданные при проверке. diff --git a/docs/plan-asn-fqdn-api.md b/docs/plan-asn-fqdn-api.md new file mode 100644 index 0000000..ec9eac6 --- /dev/null +++ b/docs/plan-asn-fqdn-api.md @@ -0,0 +1,29 @@ +# План: управление ASN и FQDN через API (реализация - отдельной командой) + +## Часть B. Управление ASN и FQDN через API (только план) + +### Эндпоинты +Изменяющие запросы требуют `X-API-Key` (как `POST /schedule`, тот же `verify_token`); чтение открыто. + +| Метод и путь | Действие | +| :--- | :--- | +| `GET /asns`, `GET /fqdns` | список из `config.json` | +| `POST /asns` `{"asn": 62041}` | добавить (идемпотентно: 201 при добавлении, 200 если уже есть) | +| `POST /fqdns` `{"fqdn": "example.com"}` | то же | +| `DELETE /asns/{asn}?purge=false`, `DELETE /fqdns/{fqdn}?purge=false` | убрать из конфигурации; 404, если нет | + +### Правила +- **Валидация:** ASN - целое `1..4294967295` (pydantic `Field`); FQDN - нормализация (нижний регистр, без завершающей точки), длина до 253, метки до 63 символов из `[a-z0-9-]`, без начального и конечного дефиса, IP-литералы отклоняются. Ошибки -> 422. +- **Запись конфига:** общий хелпер `update_config(mutator)` в `cidr_collector.py` (блокировка `flock` + атомарная запись), используется в `POST /schedule`, новых эндпоинтах и в `CIDRCollector.add_asn/remove_asn`, `FQDNCollector.add_fqdn/remove_fqdn` - заодно закрывает риск «CLI меняет конфиг без блокировки». +- **Данные удалённого источника:** по умолчанию сохраняются. Чтобы не висеть вечно (TTL применяется только к опрашиваемым источникам), в `run_collection` под блокировкой данных записи, которых уже нет в конфигурации, проходят `merge_entry` с пустым набором: адреса истекают по `ttl_days`, пустая запись удаляется. `purge=true` удаляет запись из `data.json`/`fqdn_data.json` сразу (под блокировкой данных, после обновления конфига, без вложенных блокировок). +- В `run_collection` перед слиянием конфигурация перечитывается под блокировкой данных: источник, удалённый во время сбора, не воскресает. +- Новые источники подхватываются на ближайшем запуске сбора (демон читает конфиг при каждом запуске); немедленный сбор по запросу (`POST /collect`) - за рамками этого шага. + +### Тесты (минимум, в контейнере) +1. Добавление/удаление ASN и FQDN: 401 без ключа, 201/200 идемпотентность, 422 на невалидные значения, 404 при удалении несуществующего, конфиг обновлён. +2. `purge=true` удаляет данные источника, без `purge` - данные остаются; при сборе запись без источника в конфигурации истекает по TTL. + +### Проверка +Тесты в контейнере; вручную `curl` с токеном на копии данных (добавить, увидеть в `GET /asns`, запуск сбора демоном, удалить с `purge`, адреса исчезли из `/addresses`). + +Зависит от `docs/plan-process-split.md` (выполняется первым). diff --git a/docs/plan-collect-endpoint.md b/docs/plan-collect-endpoint.md new file mode 100644 index 0000000..7a61418 --- /dev/null +++ b/docs/plan-collect-endpoint.md @@ -0,0 +1,22 @@ +# План: POST /collect (немедленный сбор) + +## Цель +После добавления источника (или по необходимости) запускать сбор сразу, не дожидаясь расписания. API и сборщик - разные процессы, поэтому API не собирает сам, а передаёт демону запрос через файл. + +## Дизайн +- `POST /collect` (токен `X-API-Key`), тело необязательно: `{"type": "asn" | "fqdn" | "all"}`, по умолчанию `all`. Ответ `202` с перечнем запрошенных типов; ход выполнения видно в `GET /health`. +- Если демон не жив (heartbeat старше 120 с или его не было), API отвечает `503`, запрос не ставится в очередь «в пустоту». +- Запрос передаётся файлом `collect_request.json` в `DATA_DIR` (запись атомарная, под блокировкой): повторные `POST` объединяются (типы складываются), лишних сборов не будет. +- Демон: задание `check_collect_requests` каждые 5 секунд (`TRIGGER_POLL_INTERVAL`) забирает и удаляет файл и ставит разовое задание `_manual` (не блокирует опрос). +- Защита от наложения: на каждый тип один запуск за раз (неблокирующая блокировка в `run_job`); если такой сбор уже идёт (по расписанию или ручной), повторный запуск пропускается с записью в лог. +- `status.json`: для каждого задания добавляются `running` и `last_finished`, чтобы клиент мог дождаться завершения через `/health`. + +## Изменения +1. `cidr_collector.py`: `COLLECT_REQUEST_FILE`, `TRIGGER_POLL_INTERVAL`, `request_collection(types)`, `pop_collection_requests()`. +2. `collector_daemon.py`: блокировки запусков, поля `running`/`last_finished`, `check_collect_requests(scheduler)`, регистрация задания в `build_scheduler`. +3. `api_server.py`: общий помощник состояния демона (используется `/health` и `/collect`), эндпоинт `POST /collect`. +4. `README.md`: описание эндпоинта, поля `/health`; `.gitignore`/`.dockerignore`: `collect_request.json`. +5. Тесты (2): API (401 без ключа, 503 без демона, 202 с объединением типов, 422 на неверный тип); демон (запрос превращается в разовые задания, файл удалён, повторный вызов ничего не делает). + +## Проверка +Тесты в контейнере; вручную на копии данных: демон и API отдельными процессами с редким расписанием, `POST /collect` запускает сбор за несколько секунд, `/health` показывает `running` и `last_finished`; при остановленном демоне 503; два быстрых запроса дают один сбор; проверка в Docker Compose (общий том). diff --git a/docs/plan-diff-endpoint.md b/docs/plan-diff-endpoint.md new file mode 100644 index 0000000..cf13c90 --- /dev/null +++ b/docs/plan-diff-endpoint.md @@ -0,0 +1,27 @@ +# План: `GET /addresses/diff?since=` (изменения списка) + +## Цель +Клиент (роутер, файрвол, скрипт) запрашивает только изменения с момента прошлой синхронизации: что добавилось и что исчезло. Полный список каждый раз не скачивается. + +## Дизайн +- `GET /addresses/diff?since=<время | курсор>[&type=cidr|fqdn|all][&ip_version=all|4|6]`, чтение открыто (как у `/addresses`). +- Ответ: `{"since", "now", "cursor", "added": [...], "removed": [...]}`. Форматы конфигураций и агрегация не поддерживаются (агрегированный diff не аддитивен). +- **Журнал изменений** в SQLite: таблица `changes(id, ts, kind, value, action add|del)`. Пишется триггерами на `addresses`, поэтому охватывает все пути удаления (TTL, снятый источник, `purge`) и добавления: + - `add`: значение появилось, а у других источников его не было; + - `del`: удалена последняя запись значения. + Значение, которое есть у нескольких источников, в diff не попадает, пока хотя бы один источник его держит. Первичный импорт из JSON в журнал не пишется. +- **Итоговый эффект, а не история:** по каждому значению берётся первое действие после точки `since` (`add` - значения не было, `del` - было) и сверяется с текущим состоянием. Удалено и возвращено в тот же интервал - в diff не попадает. +- **Точка отсчёта:** `since` - время ISO 8601 (без часового пояса считается UTC) либо целый курсор из прошлого ответа. **Рекомендуется курсор:** порядковый номер записи журнала не зависит от часов и от длительности транзакции сборщика. Время округляется до миллисекунд, границы включительные (доставка «минимум один раз», повтор безвреден). +- **Срок хранения:** `changes_retention_days` в `config.json` (по умолчанию 30, `<= 0` - без очистки). Очистка выполняется в транзакции сбора. Границу («горизонт») хранит таблица `meta`. Если `since` старше горизонта или курсор не из этой базы (больше текущего) - `410 Gone`: клиент забирает полный `/addresses` и продолжает с нового `cursor`. +- Ошибки: `400` при неверном `since`; `503` при недоступной базе (общий обработчик). +- Чтение diff выполняется в одной read-транзакции (согласованный снимок). + +## Изменения +1. `db.py`: `SCHEMA_VERSION = 2` (миграция 1 -> 2: `changes`, `meta`, триггеры; для v0 таблицы создаются после импорта JSON), `prune_changes()`, `get_changes()`. +2. `cidr_collector.py`: `DEFAULT_CHANGES_RETENTION_DAYS`, вызов очистки в обоих `run_collection`. +3. `api_server.py`: эндпоинт `/addresses/diff`, разбор `since`; заголовок `X-Changes-Cursor` в ответах `/addresses` (курсор для первой синхронизации, читается до данных). +4. `README.md`: описание эндпоинта, ключ конфигурации, раздел о хранении. +5. Тесты (2): БД (триггеры: несколько источников, TTL, возврат в тот же интервал, очистка и горизонт); API (400, 410, курсор и время, фильтры). + +## Проверка +Тесты в контейнере; вручную на копии данных: миграция базы версии 1 -> 2 сохраняет данные, `purge`/TTL порождают `removed`, повторный запрос с `cursor` возвращает пустой diff. diff --git a/docs/plan-git-init.md b/docs/plan-git-init.md new file mode 100644 index 0000000..d679c23 --- /dev/null +++ b/docs/plan-git-init.md @@ -0,0 +1,20 @@ +# План: репозиторий git (п. 1.1 рекомендаций) + +## Цель +Появляется история изменений (риск 1 анализа): `git init`, ветка `main`, подключение удалённого репозитория, первый коммит текущего состояния. + +## Решения +- Ветка `main`, remote `origin` = `https://artstore.rxmsk.ru/ayurishchev/ripe-cidr-collector.git` (подготовлен пользователем). +- Автор коммита: существующая глобальная настройка git (`ayurishchev`); настройки не меняются. +- **В репозиторий входят:** код, тесты, `Dockerfile*`, `docker-compose.yml`, `requirements*.txt`, `config.json` (исходная конфигурация источников), `.env.example`, `docs/`, `README.md`, `.claude/CLAUDE.md` (правила проекта). +- **Не входят:** `venv/`, кэши, `.env`, базы `*.db*`, служебные файлы, `graphify-out/` (уже в `.gitignore`) и **боевые данные `data.json`, `fqdn_data.json`** (старый формат, ждут миграции в SQLite; в `.dockerignore` они уже исключены, в `.gitignore` не хватало). +- Проверка перед коммитом: список файлов в индексе, поиск секретов (токены, пароли); `.env.example` содержит только заглушку. +- **Отправка на сервер (`git push`) выполняется только после подтверждения пользователя:** это публикация кода вовне. + +## Изменения +1. `.gitignore`: `data.json`, `fqdn_data.json`. +2. `README.md`: раздел о репозитории (что входит и не входит, как обновлять граф). +3. Артефакты: этот план и `docs/summary-git-init.md`. + +## Проверка +`git status` чистый после коммита; `git ls-files` не содержит данных, баз, `venv`, `.env`; тесты в контейнере проходят (код не менялся). diff --git a/docs/plan-output-formats.md b/docs/plan-output-formats.md new file mode 100644 index 0000000..8e6a71d --- /dev/null +++ b/docs/plan-output-formats.md @@ -0,0 +1,65 @@ +# План: форматы вывода, агрегация CIDR и ip_version + +## Context +`GET /addresses` отдаёт только плоский JSON-список. Потребителям (маршрутизаторы, файрволы) приходится самим конвертировать его в конфигурацию и убирать пересекающиеся префиксы (`/23` + `/24`). Цель: отдавать готовые конфигурации для nftables, MikroTik, BIRD и FRR, добавить агрегацию CIDR и фильтр по версии IP. Текущий ответ по умолчанию остаётся байт-в-байт прежним (старые потребители не ломаются). + +Решения пользователя: форматы `nftables`, `mikrotik`, `bird`, `frr` (плюс текущий JSON); агрегация выключена по умолчанию, включается `aggregate=true`. + +## Артефакты по правилам проекта (создаются при реализации) +- `docs/plan-output-formats.md` - копия этого плана (первым шагом) +- `docs/summary-output-formats.md` - итоги (в конце) +- обновить `README.md` (параметры, примеры интеграции) + +## API +`GET /addresses` получает параметры (существующий `type=cidr|fqdn|all` не меняется): + +| Параметр | Значения | По умолчанию | +| :--- | :--- | :--- | +| `format` | `json`, `nftables`, `mikrotik`, `bird`, `frr` | `json` | +| `ip_version` | `all`, `4`, `6` | `all` | +| `aggregate` | `true`/`false` | `false` | +| `name` | имя списка/набора, `^[A-Za-z][A-Za-z0-9_]{0,31}$` (защита от инъекций в конфиг) | `ripe` | + +Не-JSON форматы отдаются как `text/plain`. Невалидные значения -> 422 (валидация FastAPI). + +## Изменения + +### 1. Новый модуль `formatters.py` (чистые функции, без обращений к диску и сети) +- `select(values, ip_version, aggregate)`: + - Путь по умолчанию (`json`, `aggregate=false`): только фильтр по версии (по наличию `:` в строке), сортировка как сейчас (`sorted(set(...))`) - вывод не меняется. + - Иначе: разбор через `ipaddress.ip_network(v, strict=False)` (одиночные IP из FQDN становятся `/32` и `/128`), невалидные значения пропускаются с `logging.warning`; при `aggregate=true` - `ipaddress.collapse_addresses` отдельно для v4 и v6 (после объединения источников `cidr` и `fqdn`, поэтому IP внутри префикса исчезает); сортировка по (версия, сеть). + - Для `json` с агрегацией `/32` и `/128` печатаются как «голый» IP, как в текущем README. +- `render(fmt, v4, v6, name)`: каждый формат возвращает идемпотентный скрипт; пустая версия пропускается. + - **nftables** (`nft -f`): `add table inet `; `add set inet _v4 { type ipv4_addr; flags interval; auto-merge; }`; `flush set ...`; `add element ... { ... }` (то же для `_v6`, `ipv6_addr`). `auto-merge` нужен, иначе nft отвергает пересекающиеся интервалы. Другие объекты таблицы не затрагиваются. + - **MikroTik** (RouterOS `/import`): `/ip firewall address-list remove [find list=]` затем `add list= address=...`; для v6 - `/ipv6 firewall address-list`. + - **BIRD 2** (`include`): `define _V4 = [ a/len, ... ];` и `..._V6` - префикс-сеты для фильтров (не статические маршруты: next-hop определяет потребитель). + - **FRR** (`vtysh -f`): `no ip prefix-list _v4` затем `ip prefix-list _v4 seq 5|10|... permit `; для v6 - `ipv6 prefix-list`. +- Все скрипты начинаются с комментарием `# generated , ip_version=..., aggregate=...` (в синтаксисе формата), заканчиваются переводом строки. + +### 2. `api_server.py` (`get_addresses`) +- Добавить enum-параметры `format`, `ip_version`, `aggregate: bool`, `name` (Query с regex). +- Собрать множество как сейчас (`get_cidrs`, `get_fqdn_ips`), затем `formatters.select` и `render`. +- Убрать `response_model=List[str]` (ответ бывает текстовым); для JSON возвращать список как раньше; для остальных - `PlainTextResponse`. Описать типы ответов через `responses=` для Swagger. +- Ошибки хранилища по-прежнему -> 503 (существующий обработчик). + +### 3. Документация (`README.md`) +- Таблица параметров и примеры: + - nftables: `curl -s "$URL/addresses?format=nftables&ip_version=4&aggregate=true" | nft -f -` + - MikroTik: `/tool fetch url="$URL/addresses?format=mikrotik" dst-path=ripe.rsc` и `/import ripe.rsc` + - BIRD: сохранить в файл, `include`, `birdc configure` + - FRR: `curl ... > ripe.conf && vtysh -f ripe.conf` +- Примечания: имя `name` определяет имена наборов; у MikroTik замена списка даёт короткое окно без записей; агрегация удаляет `/32` внутри более широкого префикса. + +### 4. Тесты (минимум 3, `tests/test_formats.py`, запуск в контейнере) +1. `select`: по умолчанию вывод равен прежнему (сортировка, без /32), `ip_version=4|6` фильтрует, `aggregate=true` схлопывает `/23`+`/24` и вложенный host-IP. +2. `render`: параметризованный тест по 4 форматам - ключевые строки (для v4 и v6, пустая версия пропущена). +3. API: дефолтный `/addresses` возвращает JSON-список, `format=mikrotik` - `text/plain`, некорректное `name` -> 422 (данные подменены через `cc.DATA_FILE`). + +## Критичные файлы +Новый `formatters.py`; `api_server.py` (`get_addresses`); `tests/test_formats.py`; `README.md`; `Dockerfile.test` не меняется (`COPY . .` подхватит модуль). + +## Проверка +1. `docker build -f Dockerfile.test -t ripe-collector-test . && docker run --rm ripe-collector-test` - все тесты (включая 3 прежних) проходят. +2. На копии реальных данных: `curl /addresses` до и после изменений даёт идентичный ответ (сравнить diff). +3. Вручную: `format=...` для каждого формата; `aggregate=true` уменьшает число строк; `ip_version=6` не содержит IPv4. +4. Проверка синтаксиса генерируемых конфигов в контейнерах, где возможно: BIRD (`bird -p -c`), nftables (`nft -c -f`, если хватит прав). MikroTik и FRR сверить по документации (эмулятора нет); риск: поведение `no ip prefix-list` для несуществующего списка в FRR - проверить по документации. diff --git a/docs/plan-process-split.md b/docs/plan-process-split.md new file mode 100644 index 0000000..214c91f --- /dev/null +++ b/docs/plan-process-split.md @@ -0,0 +1,55 @@ +# План: разделение сборщика и API на два процесса + +## Context +Сейчас планировщик (APScheduler) живёт внутри процесса API: перезапуск или падение API останавливает сбор, а тяжёлый сбор делит процесс с обработкой запросов. Плюс ASN и FQDN можно менять только через CLI и правкой `config.json`. + +Запрос состоит из двух частей, порядок выполнения важен, так как часть B опирается на часть A: +- **Часть A - разделение на два процесса: планируется и выполняется** после утверждения плана. +- **Часть B - управление ASN и FQDN через API: только план.** Реализация начнётся отдельной командой после части A. + +Артефакты по правилам проекта: `docs/plan-process-split.md` и `docs/plan-asn-fqdn-api.md` (копии соответствующих частей плана, первым шагом), затем `docs/summary-process-split.md` (после части A) и обновление `README.md`. + +## Часть A. Разделение на два процесса (выполняется) + +### Архитектура +Два независимых сервиса без IPC, общение через файлы в каталоге проекта (уже атомарные и под `flock`): +- **API** (`uvicorn api_server:app`): только читает данные и пишет `config.json`; планировщика в нём больше нет. +- **Сборщик-демон** (`python collector_daemon.py`, новый): держит расписание, запускает сбор, пишет `status.json`. + +Изменение расписания через `POST /schedule` доходит до демона через `config.json`: демон раз в 30 секунд сверяет секцию `schedule` и перепланирует задания без перезапуска (задержка применения до 30 с, документируется). + +### Изменения +1. **`collector_daemon.py` (новый)** + - `BlockingScheduler`, задания `asn_job` и `fqdn_job` из `schedule` (значения по умолчанию как сейчас: `0 2 * * *` / `0 3 * * *`), `max_instances=1`, `coalesce=True`. + - Задание `sync_schedule` каждые 30 с: читает `load_full_config()`, при изменении cron делает `reschedule_job`; невалидный cron логируется и игнорируется (остаётся прежнее расписание). + - `run_job(name, collector_cls)` переезжает из `api_server.py` (`CIDRCollector`/`FQDNCollector` создаются заново на каждый запуск - свежий конфиг); статусы `last_run`, `last_error`, `next_run` пишутся в `status.json` атомарно вместе с `updated_at` (heartbeat каждые 30 с). + - Единственный экземпляр: неблокирующий `flock` на `collector.daemon.lock`; второй экземпляр завершается с понятной ошибкой. + - Корректное завершение по `SIGTERM`/`SIGINT` (`scheduler.shutdown`). +2. **`storage.py`**: добавить `try_lock(path)` (неблокирующая эксклюзивная блокировка для singleton). +3. **`cidr_collector.py`**: константа `STATUS_FILE` рядом с остальными путями (единая точка для API и тестов). +4. **`api_server.py`** + - Убрать `BackgroundScheduler`, `lifespan`, `job_state`, `run_*_job`, `JOBS`, `start_scheduler`. + - `POST /schedule`: валидация cron через `CronTrigger.from_crontab` (как сейчас), запись в `config.json` под блокировкой; ответ уточняет, что применение демоном до 30 с. + - `GET /health`: читает `status.json`; `collector_alive = now - updated_at < 120 с`; `status = ok` только если демон жив и нет `last_error`, иначе `degraded` (нет файла = демон не запускался). HTTP-код остаётся 200. +5. **Развёртывание (`README.md`, `.gitignore`, `.dockerignore`)** + - Второй сервис `ripe-collector` для systemd и OpenRC (та же учётная запись `ripe`, `ExecStart=.../python collector_daemon.py`, `Restart=always`); юниты API и сборщика независимы. + - Раздел про cron остаётся как альтернатива ручного запуска (`cidr_collector.py run`), но основной путь - демон. + - **Примечание об обновлении:** после разделения одного `ripe-api` недостаточно, без `ripe-collector` сбор не идёт; `/health` покажет `degraded`. + - Переписать раздел «Scheduler Logic» под новую схему; добавить `status.json` в `.gitignore` и `.dockerignore`. + +### Тесты (минимум, в контейнере) +1. `sync_schedule`: смена cron в `config.json` перепланирует задание; невалидный cron игнорируется (планировщик без запуска, проверка триггера задания). +2. `/health`: свежий `status.json` -> `collector_alive: true`, устаревший или отсутствующий -> `false` и `degraded`. + +### Проверка +1. `docker build -f Dockerfile.test -t ripe-collector-test . && docker run --rm ripe-collector-test` - все тесты (прежние 9 + новые) проходят. +2. Вручную на копии данных в scratchpad (реальные данные не трогаем): запустить демон с расписанием `*/1 * * * *` и API в двух процессах; убедиться, что сбор идёт без API; `POST /schedule` с токеном меняет расписание демона не позднее чем через 30 с (по логу и `/health`); остановка демона -> `/health` через 2 минуты `degraded`, `collector_alive: false`; второй запуск демона завершается с ошибкой singleton; `kill -TERM` останавливает демон чисто. +3. Перезапуск API во время работы демона не прерывает сбор. + +## Критичные файлы +`collector_daemon.py` (новый), `api_server.py`, `cidr_collector.py`, `storage.py`, `README.md`, `.gitignore`, `.dockerignore`, `tests/`. Переиспользуем: `load_full_config`, `save_json_atomic`, `file_lock`, `load_json`, `verify_token`, `merge_entry`. + +## Порядок выполнения после утверждения +1. Скопировать планы в `docs/`. +2. Выполнить часть A (код, тесты, проверка, README, `docs/summary-process-split.md`). +3. Часть B не реализуется, пока не будет отдельной команды. diff --git a/docs/plan-reliability-security.md b/docs/plan-reliability-security.md new file mode 100644 index 0000000..c33d38f --- /dev/null +++ b/docs/plan-reliability-security.md @@ -0,0 +1,59 @@ +# План: надёжность и безопасность ripe_cidr_collector + +## Context +Сейчас данные пишутся неатомарно, при повреждении JSON молча превращаются в `{}` (следующий сбор перезапишет хорошие данные), записи копятся бессрочно, `POST /schedule` открыт всем, а сервис слушает `0.0.0.0` без защиты. Цель: сделать хранение устойчивым, ограничить срок жизни адресов (TTL 90 дней), закрыть управляющий эндпоинт токеном. Формат ответа `GET /addresses` не меняется, поэтому потребители не ломаются. + +Решения пользователя: токен только на `POST`; TTL 90 дней, настраиваемый (0 = бессрочно). + +## Артефакты по правилам проекта (создаются при реализации) +- `docs/plan-reliability-security.md` - этот план (копия в проект первым шагом) +- `docs/summary-reliability-security.md` - итоги, в конце +- обновить `README.md` (токен, TTL, /health, non-root systemd, схема данных) + +## Изменения + +### 1. Новый модуль `storage.py` (общий, убирает дублирование load/save из обоих файлов) +- `load_json(path, default)`: при отсутствии файла возвращает default; при битом JSON логирует ошибку, переименовывает файл в `.corrupt-` и бросает `StorageError` (сборщик прерывается, не затирая данные; API отвечает 503). +- `save_json_atomic(path, data)`: запись во временный файл в том же каталоге, `flush` + `fsync`, `os.replace`. +- `file_lock(path)`: контекстный менеджер на `fcntl.flock` по `.lock`. Чтение-изменение-запись в сборщике идёт под блокировкой (защита от одновременного cron и APScheduler). + +### 2. `cidr_collector.py` +- Заменить локальные `load_*/save_*` и `save_full_config` на функции из `storage.py`. +- Схема записи: `prefixes`/`ips` остаются списком (совместимость с API), добавляется `seen: {value: {first_seen, last_seen}}`. +- Слияние при успешном получении данных: обновить `last_seen` у увиденных, добавить новые с `first_seen`, удалить те, у кого `last_seen` старше `ttl_days`. При ошибке RIPE/DNS (`None`/пусто) ничего не удаляется. +- Миграция на лету: если `seen` нет, инициализировать `first_seen = last_seen = last_updated` (старые данные не теряются). +- Частота записи: файл пишется, если изменился состав адресов, либо если `last_seen` у какой-то записи старше 24 ч (иначе при запуске каждые 15 минут файл переписывался бы постоянно). Так TTL остаётся корректным, а лишних записей нет. +- `print` заменить на `logging` (INFO/WARNING/ERROR). +- `ttl_days` читается из `config.json` (ключ `ttl_days`, по умолчанию 90). + +### 3. `api_server.py` +- `POST /schedule`: зависимость `verify_token` - заголовок `X-API-Key`, сравнение через `secrets.compare_digest`. Токен берётся из переменной окружения `RIPE_API_TOKEN` (не из `config.json`). Если переменная не задана, `POST` возвращает 503 (fail closed). +- Запись конфига через `storage.save_json_atomic` под блокировкой вместо прямого `open(...,'w')`. +- Валидация тела через pydantic-модель (`type: Literal["asn","fqdn"]`, `cron: str`) вместо `Dict[str,str]`. +- `@app.on_event` заменить на `lifespan`; в `shutdown` использовать `scheduler.shutdown(wait=False)`. +- Добавить `GET /health`: время последнего успешного сбора по asn/fqdn, число записей, статус планировщика. +- Ошибки чтения данных (`StorageError`) -> 503 вместо тихого пустого списка. +- `__main__`: хост по умолчанию `127.0.0.1`. + +### 4. Развёртывание (только документация в `README.md`) +- systemd: запуск от отдельного пользователя `ripe`, `EnvironmentFile=/etc/ripe-api.env` (`RIPE_API_TOKEN=...`, права 600), опции `NoNewPrivileges=true`, `ProtectSystem=strict`, `ReadWritePaths=/opt/ripe_collector`. +- OpenRC: аналогично (`command_user`, `env` файл). +- Пояснение: `0.0.0.0` оставлен, т.к. потребители удалённые; ограничивать доступ к порту 8000 файрволом. +- Добавить `.gitignore` (`venv/`, `*.corrupt-*`, `*.lock`, `*.tmp`). + +### 5. Тесты (минимум, `tests/test_core.py`, pytest) +1. Слияние и TTL: старый адрес удаляется по истечении срока, новый добавляется, при ошибке источника ничего не удаляется. +2. Битый JSON: `load_json` бросает `StorageError`, файл переименован, исходные данные не перезаписываются. +3. Авторизация: `POST /schedule` без токена -> 401, с верным токеном -> 200, без заданного `RIPE_API_TOKEN` -> 503 (`fastapi.testclient`, сеть замокана). +Добавить `pytest` и `httpx` в `requirements.txt` (или `requirements-dev.txt`). + +## Критичные файлы +`cidr_collector.py`, `api_server.py`, `config.json` (+`ttl_days`), новый `storage.py`, `README.md`, `requirements.txt`. + +## Проверка +1. `source venv/bin/activate && pytest -q` - 3 теста зелёные. +2. На копии текущих `data.json`/`fqdn_data.json`: `python cidr_collector.py run` - миграция без потери адресов (сравнить количество до/после), повторный запуск не переписывает файл. +3. Испортить копию `data.json` - сборщик завершается с ошибкой, файл переименован в `.corrupt-*`, API отвечает 503. +4. Запустить `uvicorn api_server:app`: `curl /addresses` без токена работает; `curl -X POST /schedule` без ключа -> 401, с `X-API-Key` -> 200, `config.json` обновлён; `curl /health` возвращает статусы. +5. Параллельный запуск двух `run --mode asn` - данные не повреждены (lock работает). +6. Выставить `ttl_days` малым значением и проверить удаление устаревшей записи. diff --git a/docs/plan-sqlite-storage.md b/docs/plan-sqlite-storage.md new file mode 100644 index 0000000..0b60335 --- /dev/null +++ b/docs/plan-sqlite-storage.md @@ -0,0 +1,72 @@ +# План: хранение собранных адресов в SQLite + +## Context +Собранные адреса хранятся в `data.json` и `fqdn_data.json`: каждая запись целиком читается и переписывается, доступ между процессами (демон, API, CLI) защищён файловыми блокировками, а повреждение файла ведёт к потере накопленной истории. SQLite даёт транзакции, конкурентное чтение во время записи, запросы по адресам и основу для будущих diff и истории (`first_seen`/`last_seen` уже есть). API, форматы вывода и семантика TTL не меняются. + +Границы: в SQLite переезжают только собранные адреса. `config.json` (источники, расписание, `ttl_days`) и `status.json` (heartbeat) остаются JSON: их редактируют вручную, а демон опрашивает конфиг. Резервное копирование, diff и уведомления в этот шаг не входят. + +## Артефакты по правилам проекта (создаются при реализации) +- `docs/plan-sqlite-storage.md` - копия этого плана (первым шагом) +- `docs/summary-sqlite-storage.md` - итоги (в конце) +- обновить `README.md` (схема хранения, миграция, откат) + +## Схема (`ripe.db` в `DATA_DIR`) +```sql +CREATE TABLE addresses ( + kind TEXT NOT NULL CHECK (kind IN ('asn', 'fqdn')), + source TEXT NOT NULL, -- '62041' или 'example.com' + value TEXT NOT NULL, -- префикс или IP + first_seen TEXT NOT NULL, -- ISO-время + last_seen TEXT NOT NULL, + PRIMARY KEY (kind, source, value) +); +CREATE INDEX addresses_value ON addresses (value); +PRAGMA user_version = 1; -- версия схемы для будущих миграций +``` +Режим `journal_mode=WAL`, `busy_timeout=5000`, `synchronous=NORMAL`, `temp_store=MEMORY`. Время хранится строками ISO (секундная точность для новых записей), сравнение `last_seen < cutoff` корректно лексикографически. + +## Изменения + +### 1. Новый модуль `db.py` +- `connect(path=None)`: открывает базу (`cc.DB_FILE`), применяет PRAGMA, при необходимости создаёт схему и **однократно импортирует старые JSON** (см. ниже). Ошибки уровня `sqlite3.DatabaseError`, кроме `OperationalError` (например, «database is locked»), считаются порчей: файл переименовывается в `ripe.db.corrupt-` (вместе с `-wal`/`-shm`), поднимается `StorageError` (API -> 503, демон записывает `last_error`) - то же поведение, что было для битого JSON. +- `merge_source(conn, kind, source, values, now, ttl_days)`: в одной транзакции upsert найденных значений (`ON CONFLICT DO UPDATE SET last_seen`), затем `DELETE ... WHERE kind=? AND source=? AND last_seen < cutoff` (просроченные, которых сегодня не видели; при `ttl_days = 0` не удаляется ничего). Возвращает добавленные и удалённые значения для лога. Заменяет `merge_entry` и правило «обновлять last_seen раз в сутки» (запись в SQLite дешёвая, обновляем всегда). +- `sweep_unconfigured(conn, kind, configured, now, ttl_days)`: удаляет просроченные значения источников, которых нет в конфигурации (одним `DELETE ... source NOT IN (...)`); пустые записи как сущность больше не нужны. +- `purge_source(conn, kind, source)`, `get_values(conn, kind=None)` (`SELECT DISTINCT value`), `count_values(conn, kind)`. + +### 2. Миграция старых данных (внутри `connect`) +- Условие: `user_version = 0` и таблицы нет. Под `BEGIN IMMEDIATE` (API и демон могут стартовать одновременно; второй ждёт и видит уже выполненную миграцию). +- Импорт `data.json` (kind `asn`) и `fqdn_data.json` (kind `fqdn`): значения с блоком `seen` сохраняют `first_seen`/`last_seen`; записи старого формата без `seen` (реальные данные сейчас именно такие) получают `first_seen = last_updated`, `last_seen = сейчас` (как в прежней миграции: TTL идёт с момента перехода). Сразу пишется `user_version = 1`. +- После успешной транзакции JSON-файлы переименовываются в `data.json.migrated-` и `fqdn_data.json.migrated-` (не удаляются - это и есть резервная копия и путь отката). +- Импорт идемпотентен: повторный запуск (`user_version = 1`) ничего не читает. + +### 3. `cidr_collector.py` (упрощается) +- Константа `DB_FILE = os.path.join(DATA_DIR, "ripe.db")`; `DATA_FILE` и `FQDN_DATA_FILE` остаются только как пути для импорта старых данных. +- `CIDRCollector`/`FQDNCollector.run_collection`: сетевая часть без изменений; затем `with db.connect() as conn` (одна транзакция): читает конфиг, для найденных источников вызывает `db.merge_source`, затем `db.sweep_unconfigured`. Источник, удалённый во время сбора, по-прежнему пропускается (проверка по конфигу внутри транзакции). +- Удаляются `load_data`/`save_data`, `merge_entry`, `sweep_unconfigured` (JSON-версия), `purge_entry`, `LAST_SEEN_REFRESH`, блокировки `file_lock(DATA_FILE)`. Блокировка конфига (`update_config`) остаётся. + +### 4. `api_server.py` +- `get_cidrs()`/`get_fqdn_ips()` и счётчики `/health` читают через `db.get_values`/`db.count_values` (соединение на запрос). +- `DELETE /asns|/fqdns ?purge=true` вызывает `db.purge_source`. +- `StorageError` от `db.connect` обрабатывается существующим обработчиком (503). + +### 5. Развёртывание +- `Dockerfile`: добавить `db.py` в список копируемых файлов (иначе образ не соберёт рабочее приложение). +- `.gitignore`/`.dockerignore`: `*.db`, `*.db-wal`, `*.db-shm`, `*.migrated-*`. +- Docker: оба сервиса используют одну базу в томе `/data` (WAL работает между контейнерами на одном хосте с локальным томом; не рекомендуется на сетевых ФС - отметить в README). `requirements.txt` не меняется (`sqlite3` из стандартной библиотеки; нужна SQLite >= 3.24 для upsert, в `python:3.11-slim` и на текущем хосте выполняется). + +### 6. Документация (`README.md`) +Раздел о хранении: таблица `addresses`, что хранится в JSON, а что в БД, автоматическая миграция, **откат** (остановить сервисы, вернуть `*.migrated-*` в `data.json`/`fqdn_data.json`, запустить прежнюю версию; адреса, собранные после миграции, при откате будут потеряны), просмотр данных (`sqlite3 ripe.db "SELECT ..."`), примечание про сетевые ФС; обновить описание «Collector Logic» и переносимость данных в раздел Docker (миграция теперь копирует и `ripe.db`). + +### 7. Тесты (минимум) +Существующие тесты, использовавшие `data.json`, переводятся на БД (подмена `cc.DB_FILE` на временный файл, заполнение через `db.merge_source`): TTL и безопасность при сбое (`test_core`), форматы API (`test_formats`), purge и старение удалённого источника (`test_sources_api`); `test_daemon` не затрагивается. Добавляется 1 тест миграции: JSON в старом формате (без `seen`) и в новом (с `seen`) импортируются с ожидаемыми `first_seen`/`last_seen`, файлы переименованы, повторное открытие ничего не меняет. Итого 14 тестов. + +## Критичные файлы +Новый `db.py`; правки `cidr_collector.py`, `api_server.py`, `Dockerfile`, `README.md`, `.gitignore`, `.dockerignore`, `tests/test_core.py`, `tests/test_formats.py`, `tests/test_sources_api.py`. Переиспользуем: `StorageError` (`storage.py`), `update_config`, `load_full_config`, `formatters.build_output` (без изменений: принимает множество значений). + +## Проверка +1. `docker build -f Dockerfile.test -t ripe-collector-test . && docker run --rm ripe-collector-test` - 14 тестов. +2. **Эталонное сравнение**: на копии реальных данных (`scratchpad/backup`) запустить API после миграции; ответы `/addresses?type=all|cidr|fqdn` побайтно совпадают с эталонами `before_*.txt`, снятыми старой версией; JSON-файлы переименованы, повторный запуск не импортирует заново. +3. Сбор на копии: демон/CLI `run` создаёт и обновляет строки; повторный запуск не плодит дубликатов; уменьшенный `ttl_days` удаляет просроченное; `purge=true` удаляет адреса источника. +4. Конкурентность: во время идущего сбора цикл запросов `curl /addresses` не даёт ошибок (WAL: чтение не блокируется записью); одновременный старт API и демона на пустой базе выполняет миграцию один раз. +5. Порча: записать мусор в `ripe.db` - API отвечает 503, файл переименован в `*.corrupt-*`, демон фиксирует ошибку. +6. Docker Compose на копии данных (как в прошлый раз, отдельный проект): оба контейнера работают с одной базой в томе (`healthy`, API видит адреса, собранные демоном, после `down`/`up` данные на месте); по окончании убрать тестовые контейнеры, тома и образы. diff --git a/docs/plan-tests-in-container.md b/docs/plan-tests-in-container.md new file mode 100644 index 0000000..bd64248 --- /dev/null +++ b/docs/plan-tests-in-container.md @@ -0,0 +1,16 @@ +# План: запуск тестов в контейнере + +## Цель +Тесты выполняются в изолированном воспроизводимом окружении (Docker), а не в локальном `venv`. + +## Изменения +1. `Dockerfile.test` - образ `python:3.11-slim`, зависимости из `requirements.txt` + `requirements-dev.txt` ставятся отдельным слоем (кэш), затем копируется код; `CMD ["pytest", "-q"]`. +2. `.dockerignore` - исключить `venv/`, данные (`data.json`, `fqdn_data.json`), `*.lock`, `__pycache__`, `.git`, чтобы тесты не зависели от боевых данных и образ был лёгким. +3. `README.md` - раздел про запуск тестов: `docker build -f Dockerfile.test -t ripe-collector-test . && docker run --rm ripe-collector-test`. +4. Убрать из README шаг с локальным `pytest` (пункт 5 установки). + +## Проверка +Сборка образа и запуск контейнера: 3 теста проходят; в образе нет `data.json`. + +## Итоги +`docs/summary-tests-in-container.md`. diff --git a/docs/summary-app-container.md b/docs/summary-app-container.md new file mode 100644 index 0000000..f53a5e1 --- /dev/null +++ b/docs/summary-app-container.md @@ -0,0 +1,27 @@ +# Итоги: контейнер для приложения + +План: `docs/plan-app-container.md`. + +## Сделано +- **`Dockerfile`**: `python:3.11-slim`, зависимости отдельным слоем, копируются только рабочие файлы, пользователь `ripe` (uid 10001), `VOLUME /data`, `CMD` в exec-форме (uvicorn). +- **`docker-compose.yml`**: сервисы `api` (порт `${API_PORT:-8000}`, токен из `.env`) и `collector` (`collector_daemon.py`, `stop_grace_period: 60s`), общий том `ripe_data`, `read_only`, `tmpfs /tmp`, `cap_drop: ALL`, `no-new-privileges`, ротация логов, healthcheck обоих сервисов, `TZ` для расписания. +- **`healthcheck.py`**: `api` (GET `/health`, 200) и `collector` (heartbeat в `status.json` моложе `STATUS_STALE_AFTER`). +- **Код**: `RIPE_DATA_DIR` (`cidr_collector.DATA_DIR`) для путей данных и `LOCK_FILE` демона; без переменной поведение прежнее. +- **`.env.example`**, `.env` в `.gitignore` и `.dockerignore`; README, раздел 8 (запуск, настройки, миграция данных, эксплуатация, риски). + +## Проверка (стенд в scratchpad, реальные данные не менялись) +- Регрессия: 13 тестов в `Dockerfile.test` проходят. +- `docker compose up -d`: оба сервиса `healthy` через ~45 с. +- Миграция копий данных в том по инструкции: API видит те же адреса (24 CIDR), после `down`/`up -d` данные на месте. +- `/health`: `collector_alive: true`; POST без ключа 401, с ключом 201; `format=nftables` работает. +- Пользователь в контейнере `uid=10001`, запись вне `/data` даёт `Read-only file system`; `TZ=Europe/Moscow` применился. +- Демон в контейнере выполнил плановый сбор (`CIDR data saved to /data/data.json`). +- `docker compose stop collector`: штатное завершение за 1 с, через ~2 минуты `/health` = `degraded`, `collector_alive: false`. +- После проверки удалены тестовые контейнеры, том и образы. + +## Замечания +- Порт публикуется наружу без TLS (решение пользователя): токен идёт открытым текстом. +- Файлы `config.json` и `status.json`, записанные сервисами, получают режим 600 (создаются через `mkstemp`); оба сервиса работают под одним пользователем, но сторонние читатели тома этих файлов не прочитают. +- Зависимости в образе не закреплены (риск воспроизводимости сборки). +- Строка `google.com` в текущих данных не входит в конфигурацию и будет стареть по TTL (актуально и при миграции в том). +- Для миграции нужно знать имя тома `<проект>_ripe_data` (`docker volume ls`), в README это описано. diff --git a/docs/summary-asn-fqdn-api.md b/docs/summary-asn-fqdn-api.md new file mode 100644 index 0000000..0bd4297 --- /dev/null +++ b/docs/summary-asn-fqdn-api.md @@ -0,0 +1,23 @@ +# Итоги: управление ASN и FQDN через API + +План: `docs/plan-asn-fqdn-api.md`. + +## Сделано +- **Эндпоинты** (`api_server.py`): `GET /asns`, `GET /fqdns` (открыты), `POST /asns`, `POST /fqdns` (201 при добавлении, 200 если уже есть), `DELETE /asns/{asn}`, `DELETE /fqdns/{fqdn}` (404 для неизвестного, `?purge=true`). Изменения требуют `X-API-Key`. +- **Валидация**: ASN `1..4294967295`; FQDN нормализуется (нижний регистр, без точки в конце), проверяются длина и метки, IP-литералы отклоняются (422). +- **Запись конфига** (`cidr_collector.py`): единый `update_config` (блокировка + атомарная запись) и `add_to_config_list`/`remove_from_config_list`. Через них работают API, `POST /schedule` и CLI (`add`, `remove`, `add-fqdn`, `remove-fqdn`); прежнее чтение-изменение-запись без блокировки в CLI закрыто. +- **Данные удалённого источника**: по умолчанию остаются и истекают по `ttl_days` (`sweep_unconfigured` при сборе, пустая запись удаляется); `purge=true` удаляет сразу (`purge_entry`, под блокировкой данных, без вложенных блокировок). +- **Гонка сбора и удаления**: конфиг перечитывается под блокировкой данных, удалённый во время сбора источник не воскресает. +- **README**: описание эндпоинтов и поведения данных. **Тесты**: `tests/test_sources_api.py` (2 теста), всего 13, в контейнере 13 passed. + +## Проверка (два сценария на копии данных) +- `DELETE /asns/44907?purge=true`: адреса ASN пропали из `/addresses` (24 -> 21); без ключа 401. +- `POST /asns` вернул источник (201), `POST /fqdns` добавил `telegram.org` (нормализован), `8.8.8.8` отклонён. +- Сбор подхватил новые источники (`AS44907: +3`, `telegram.org: +2 IPs`). +- CLI `add`/`remove`/`list` работают через новый общий путь записи. + +## Замечания +- **В реальных данных есть запись `google.com`, которой нет в конфигурации** (осталась от первоначальной настройки). Теперь она стареет: после миграции адреса получат `last_seen` = момент первого сбора и удалятся через 90 дней. Если она нужна, добавьте её: `POST /fqdns {"fqdn": "google.com"}`. Реальные файлы данных в ходе проверки не менялись. +- Сбор для нового источника происходит на ближайшем запуске по расписанию; немедленный сбор по запросу (`POST /collect`) не реализован. +- Изменять списки может любой владелец токена; аудита изменений (кто и когда) нет, только запись в лог. +- При `ttl_days = 0` данные удалённого источника не истекают, нужен `purge=true`. diff --git a/docs/summary-collect-endpoint.md b/docs/summary-collect-endpoint.md new file mode 100644 index 0000000..3c9572e --- /dev/null +++ b/docs/summary-collect-endpoint.md @@ -0,0 +1,21 @@ +# Итоги: POST /collect (немедленный сбор) + +План: `docs/plan-collect-endpoint.md`. Результат анализа сохранён отдельно: `docs/analysis-2026-09-21.md`. + +## Сделано +- **`POST /collect`** (`api_server.py`, токен `X-API-Key`): тело необязательно (`type`: `asn`/`fqdn`/`all`, по умолчанию `all`), ответ `202`. Если демон не жив (нет свежего heartbeat), ответ `503` и запрос не ставится в очередь. +- **Передача запроса демону** (`cidr_collector.py`): `request_collection`/`pop_collection_requests` через файл `collect_request.json` в `DATA_DIR` (атомарная запись под блокировкой); повторные запросы объединяются. +- **Демон** (`collector_daemon.py`): задание `check_collect_requests` каждые 5 с ставит разовые задания `_manual`; на каждый тип один запуск за раз (наложение планового и ручного сбора пропускается с записью в лог); в `status.json` добавлены `running` и `last_finished`; служебные запуски опроса скрыты из лога. +- **`/health`**: общий помощник `collector_state()` (используется и `/collect`), в заданиях новые поля. +- **README**: описание эндпоинта, `/health`, логика планировщика; `collect_request.json` в `.gitignore`/`.dockerignore`. +- **Тесты**: 2 новых (API `/collect`; демон ставит задания и пропускает наложение). Всего 16, в контейнере 16 passed. + +## Проверка +- Отдельные процессы на копии данных (расписание `0 4 * * *`, чтобы плановых запусков не было): без демона `503`; `POST /collect {"type":"asn"}` запустил сбор через ~1 с (24 -> 42 CIDR), `/health` показал `running: true`, затем `last_finished`; два быстрых запроса дали один запуск; запрос без тела запустил `asn` и `fqdn`. +- Docker Compose (API и демон в разных контейнерах, общий том): запрос принят, сбор выполнен, данные сохранены в `/data/ripe.db`, оба сервиса `healthy`; стенд убран. + +## Замечания +- Сбор стартует не мгновенно, а в пределах 5 секунд (интервал опроса демона). +- Любой владелец токена может запускать сбор без ограничения частоты; повторные запросы во время идущего сбора пропускаются, но защиты от частых запросов к RIPE нет. +- Ручной сбор не сбрасывает и не сдвигает расписание. +- `POST /collect` не ждёт результата: итог виден в `/health` (`last_error` показывает только исключение сбора, но не сбои отдельных источников - это остаётся в списке улучшений). diff --git a/docs/summary-diff-endpoint.md b/docs/summary-diff-endpoint.md new file mode 100644 index 0000000..8db00fc --- /dev/null +++ b/docs/summary-diff-endpoint.md @@ -0,0 +1,24 @@ +# Итоги: `GET /addresses/diff?since=` + +План: `docs/plan-diff-endpoint.md`. + +## Сделано +- **Журнал изменений** (`db.py`): таблицы `changes` и `meta`, схема версии 2. Запись ведут SQL-триггеры на `addresses`, поэтому охвачены сбор, TTL, `purge` и снятие источника. Значение попадает в журнал, только если оно появилось или исчезло в итоговом наборе (адрес, который держит другой источник, не считается удалённым). Импорт из JSON в журнал не пишется. База версии 1 обновляется автоматически при первом открытии. +- **`get_changes()`**: в одной read-транзакции берёт первое действие по значению после точки отсчёта и сверяет с текущим состоянием, поэтому удалённое и возвращённое в тот же интервал в результат не попадает. +- **Срок хранения** (`cidr_collector.py`): `changes_retention_days` (по умолчанию 30, `0` - без очистки), очистка внутри транзакции обоих сборов; граница («горизонт») хранится в `meta`. +- **API** (`api_server.py`): `GET /addresses/diff?since=<курсор | время>&type=&ip_version=` с ответом `{since, now, cursor, added, removed}`; `400` при неверном `since`, `410` при выходе за горизонт или чужом курсоре. В ответы `/addresses` добавлен заголовок `X-Changes-Cursor` (курсор читается до данных) для первой синхронизации. +- **README**: описание эндпоинта, ключа `changes_retention_days`, журнала в разделе о хранении. +- **Тесты**: 2 новых (журнал и очистка в БД; API), всего 18, в контейнере 18 passed. + +## Проверка +Вручную на копии базы версии 1: миграция сохранила данные, в журнале после неё пусто; при сборе истёкший по TTL префикс попал в `removed`, новый - в `added`; время с часовым поясом (`+03:00`) разобрано. + +## Отличия от плана +- Курсор рекомендован вместо времени: номер записи не зависит от часов и от длительности транзакции сборщика (иначе запись, зафиксированная позже, могла бы получить метку времени раньше выданного `now` и потеряться). +- Добавлен заголовок `X-Changes-Cursor` (в плане не было), чтобы клиент мог согласованно начать синхронизацию. + +## Замечания +- Diff считается отдельно по типам (`asn`, `fqdn`); одинаковая строка в обоих типах при `type=all` сообщалась бы дважды - на практике не встречается (CIDR и IP различаются записью). +- Только JSON и без агрегации: агрегированный diff не аддитивен. +- Журнал растёт с числом изменений; при 30 днях хранения и редких изменениях RIPE объём мал. На реальных данных не проверялось (наблюдение 1 анализа остаётся в силе). +- Уведомления (webhook, Telegram) не сделаны: следующий шаг поверх журнала. diff --git a/docs/summary-git-init.md b/docs/summary-git-init.md new file mode 100644 index 0000000..cdb38f9 --- /dev/null +++ b/docs/summary-git-init.md @@ -0,0 +1,18 @@ +# Итоги: репозиторий git (п. 1.1) + +План: `docs/plan-git-init.md`. + +## Сделано +- `git init`, ветка `main`, remote `origin` = `https://artstore.rxmsk.ru/ayurishchev/ripe-cidr-collector.git`. +- Первый коммит: 44 файла (код, тесты, Docker-файлы, `config.json`, `.env.example`, `docs/`, `README.md`, `.claude/CLAUDE.md`). Автор - существующая глобальная настройка git, настройки не менялись. +- `.gitignore`: добавлены `data.json` и `fqdn_data.json` (боевые данные в старом формате; в `.dockerignore` они уже были). +- Проверка индекса: баз, `.env`, `venv`, `graphify-out`, кэшей и данных нет; поиск секретов (ключи, токены, пароли со значениями) ничего не нашёл. +- README: пункт о репозитории (что входит и не входит). + +## Не сделано +- **`git push` не выполнен:** отправка кода на сервер ждёт подтверждения пользователя. + +## Замечания +- `config.json` в репозитории содержит рабочий список источников (ASN и FQDN): для внутреннего репозитория допустимо, для публичного стоит заменить примером. +- Код не менялся, тесты не запускались повторно. +- Хук обновления графа после коммита не ставился (отдельное решение, п. 1 плана допускает после git). diff --git a/docs/summary-output-formats.md b/docs/summary-output-formats.md new file mode 100644 index 0000000..e5d24b2 --- /dev/null +++ b/docs/summary-output-formats.md @@ -0,0 +1,23 @@ +# Итоги: форматы вывода, агрегация CIDR, ip_version + +План: `docs/plan-output-formats.md`. + +## Сделано +- **`formatters.py` (новый)**: фильтр по версии IP, агрегация (`ipaddress.collapse_addresses`, отдельно v4/v6, после объединения источников), рендер `nftables`, `mikrotik`, `bird`, `frr`. Все скрипты идемпотентны (замена списка при повторном применении). +- **`GET /addresses`**: новые параметры `format`, `ip_version`, `aggregate`, `name` (валидация имени защищает от подстановки в конфиг, ошибки -> 422). Ответ по умолчанию не изменился. +- **README**: таблица параметров, форматы и способы применения. +- **Тесты**: `tests/test_formats.py` (3 теста, параметризованный на 4 формата). Всего 9 тестов, все в контейнере: 9 passed. + +## Проверка +- На реальных данных ответы `type=all|cidr|fqdn` без параметров побайтно совпадают с прежними. +- Агрегация: 26 -> 14 записей; `ip_version=6` отдаёт только IPv6. +- Генерируемые конфиги: BIRD (`bird -p`, Debian 12) - OK; nftables (`nft -c`, Alpine) - OK. +- В процессе проверки найдена и исправлена ошибка: BIRD не принимает запятую после последнего элемента `[...]` (теперь запятые только между элементами). + +## Не проверено +- MikroTik и FRR не проверялись на реальном ПО (эмулятора нет), синтаксис сверен по документации. В FRR нужно убедиться, что `no ip prefix-list ` для ещё не существующего списка не даёт ошибку при `vtysh -f`; если даёт - убрать эту строку или применять с `-m`. +- Нет тестов на очень большие списки (десятки тысяч записей). + +## Замечания +- Формат `json` с `aggregate=true` печатает `/32` и `/128` как «голый» IP. +- `nftables`: имя таблицы совпадает с `name`; свои правила пользователь добавляет в эту же таблицу (или создаёт набор в своей, изменив `name`). diff --git a/docs/summary-process-split.md b/docs/summary-process-split.md new file mode 100644 index 0000000..6217594 --- /dev/null +++ b/docs/summary-process-split.md @@ -0,0 +1,24 @@ +# Итоги: разделение сборщика и API на два процесса + +План: `docs/plan-process-split.md`. Часть B (управление ASN/FQDN через API) не реализована, план: `docs/plan-asn-fqdn-api.md`. + +## Сделано +- **`collector_daemon.py` (новый)**: отдельный процесс с `BlockingScheduler`. Расписание берёт из `config.json`, раз в 30 с сверяет его и перепланирует задания без перезапуска (невалидный cron игнорируется). Пишет `status.json` (состояние заданий и heartbeat). Один экземпляр гарантируется блокировкой `collector.daemon.lock`. По `SIGTERM` завершается штатно после текущего сбора. +- **`api_server.py`**: планировщик, `lifespan` и состояние заданий удалены. `POST /schedule` только валидирует cron и пишет конфиг (в ответе `applied_within_seconds`). `GET /health` читает `status.json`: `collector_alive` (heartbeat моложе 120 с), задания, счётчики; без демона `status = degraded`. +- **`storage.py`**: `try_lock` (неблокирующая блокировка для singleton). **`cidr_collector.py`**: `STATUS_FILE`, `SYNC_INTERVAL`, `STATUS_STALE_AFTER`. +- **README**: два сервиса (systemd и OpenRC), примечание об обновлении, переписаны «Scheduler Logic» и описание `/health`; cron оставлен как альтернатива демону. `status.json` добавлен в `.gitignore` и `.dockerignore`. +- **Тесты**: `tests/test_daemon.py` (2 теста: перепланирование и heartbeat в `/health`). Всего 11, в контейнере: 11 passed. + +## Проверка (два реальных процесса на копии данных) +- Сбор идёт демоном без участия API; `/health`: `ok`, `collector_alive: true`. +- Второй запуск демона завершается с ошибкой singleton (exit 1). +- `POST /schedule` (`*/1` -> `*/2`): демон перепланировал задание через 19 с, `/health` показал новый cron. +- Перезапуск API не повлиял на демон. +- `SIGTERM`: демон остановился штатно. +- Через ~130 с после остановки: `status = degraded`, `collector_alive = false`. + +## Замечания +- **Обязательное действие при обновлении:** нужно запустить сервис `ripe-collector`, одного `ripe-api` больше недостаточно. +- Изменение расписания применяется с задержкой до 30 с. +- Демон пишет `status.json` каждые 30 с (маленький файл, атомарная запись). +- Реальные `data.json`, `fqdn_data.json`, `config.json` не менялись. diff --git a/docs/summary-reliability-security.md b/docs/summary-reliability-security.md new file mode 100644 index 0000000..b21617f --- /dev/null +++ b/docs/summary-reliability-security.md @@ -0,0 +1,20 @@ +# Итоги: надёжность и безопасность + +План: `docs/plan-reliability-security.md`. + +## Что сделано +- **`storage.py` (новый)**: атомарная запись (temp + fsync + `os.replace`), межпроцессная блокировка `flock`, безопасное чтение: битый JSON переименовывается в `*.corrupt-`, вызывается `StorageError` (раньше молча возвращался `{}` и затирал данные). +- **`cidr_collector.py`**: TTL записей (`ttl_days`, по умолчанию 90, `0` = бессрочно) на основе `first_seen`/`last_seen`; при сбое RIPE/DNS ничего не удаляется; авто-миграция старых данных (TTL идёт с момента миграции); `logging` вместо `print`; сетевые запросы вне блокировки; пути к файлам теперь относительно каталога скрипта (cron больше не зависит от cwd). +- **`api_server.py`**: `POST /schedule` требует `X-API-Key` (токен из `RIPE_API_TOKEN`, без токена - 503, сравнение через `compare_digest`); pydantic-модель тела; атомарная запись конфига под блокировкой; `lifespan` вместо `on_event`; `GET /health`; `StorageError` -> 503; хост по умолчанию для `__main__` - `127.0.0.1`. +- **Документация/окружение**: README (токен, TTL, `/health`, non-root systemd/OpenRC), `.gitignore`, `requirements-dev.txt`, `ttl_days` в `config.json`. +- **Тесты (3)**: `tests/test_core.py` - TTL и безопасность при сбое, битый JSON, авторизация POST. + +## Проверка +- `pytest -q`: 3 passed. +- На копии данных: миграция без потерь (список префиксов не уменьшился), повторный запуск не меняет файлы, параллельный запуск двух сборщиков не портит JSON, битый файл -> API 503, сборщик завершается с ошибкой; POST без ключа 401, с ключом 200 и конфиг обновлён; `/health` возвращает статусы. + +## Замечания +- Старый `venv/` был создан на macOS (`/Users/tstark/...`) и на этом сервере не запускался. Он удалён, создан новый Linux `venv/`. +- После первого 503 битый файл уже переименован, поэтому следующие запросы вернут пустой список, пока сборщик не создаст данные заново. История накопления в этом случае теряется; сам битый файл сохранён как `*.corrupt-*`. +- `0.0.0.0` в systemd оставлен (потребители удалённые): доступ к порту 8000 нужно ограничить файрволом. +- Реальные `data.json`/`fqdn_data.json` не изменялись; миграция произойдёт при первом запуске сборщика. diff --git a/docs/summary-sqlite-storage.md b/docs/summary-sqlite-storage.md new file mode 100644 index 0000000..dcceb41 --- /dev/null +++ b/docs/summary-sqlite-storage.md @@ -0,0 +1,27 @@ +# Итоги: хранение собранных адресов в SQLite + +План: `docs/plan-sqlite-storage.md`. + +## Сделано +- **`db.py` (новый)**: SQLite (`ripe.db`, WAL), таблица `addresses(kind, source, value, first_seen, last_seen)`, `user_version`; `connect`/`session`/`transaction`, `merge_source` (upsert + TTL), `sweep_unconfigured`, `purge_source`, `get_values`, `count_values`. +- **Миграция**: при первом открытии базы `data.json` и `fqdn_data.json` импортируются одной транзакцией (`BEGIN IMMEDIATE`, параллельный старт безопасен), `first_seen`/`last_seen` сохраняются (старый формат без них: `last_seen` = момент миграции); оригиналы переименовываются в `*.migrated-` (резервная копия и путь отката). +- **`cidr_collector.py`**: сбор пишет в БД одной транзакцией (конфиг перечитывается внутри неё, удалённый во время сбора источник не воскресает); удалены JSON-хранилище, `merge_entry`, `purge_entry`, файловые блокировки данных. Блокировка конфига (`update_config`) осталась. +- **`api_server.py`**: чтение адресов, счётчики `/health` и `purge` работают через БД; `sqlite3.Error` и `StorageError` -> 503. +- **Порча базы**: `not a database`/`malformed` -> файл (с `-wal`/`-shm`) переименовывается в `ripe.db.corrupt-`, `StorageError` (503); `database is locked` не считается порчей и файл не трогает. +- **Развёртывание**: `db.py` добавлен в `Dockerfile`; `*.db`, `*.db-wal`, `*.db-shm`, `*.migrated-*` в `.gitignore`/`.dockerignore`; README: раздел 9 (схема, миграция, откат, бэкап), обновлены логика сбора и раздел Docker. +- **Тесты**: тесты на JSON переведены на БД, добавлены проверка порчи базы (в существующем тесте) и `tests/test_db.py` (импорт старого/нового формата, идемпотентность). Всего 14, в контейнере 14 passed. + +## Проверка +- **Эталон**: на копии реальных данных ответы `/addresses?type=all|cidr|fqdn` после миграции побайтно совпадают со снятыми старой JSON-версией; повторный старт ничего не импортирует. +- Сбор: 6 ASN и 3 FQDN обновлены, повторный запуск дубликатов не создаёт (49 = 49 уникальных). +- Конкурентность: 60 запросов `/addresses` во время сбора - все 200; 15 прогонов по 5 процессов, одновременно мигрирующих чистую копию, - без ошибок, ровно один импорт. +- Порча: мусор в `ripe.db` -> 503, файл убран в `*.corrupt-*`. +- Docker Compose (два контейнера, один том): миграция в томе, эталон совпал, демон записывает, API читает, `purge` виден, после `down`/`up` данные на месте; стенд убран. + +## Замечания +- После порчи базы следующие запросы вернут пустой список (создаётся новая пустая база), пока сборщик не наполнит её заново; повреждённый файл сохранён как `*.corrupt-*`. Это осталось прежним поведением, автоматического восстановления из копии нет. +- Резервное копирование не автоматизировано (в README описана команда `sqlite3 ".backup"`), это можно добавить заданием демона. +- Сообщение лога «... data saved» теперь пишется после каждой транзакции, даже если ничего не изменилось. +- Первый запуск на реальных данных: запись `google.com` (нет в конфигурации) станет стареть по TTL, её адрес удалится через 90 дней после миграции; `last_seen` старых записей после миграции = момент миграции. +- WAL не рекомендуется на сетевых ФС. +- Хранение истории изменений и `?since=` в этот шаг не входили; схема (`first_seen`/`last_seen`) для них подготовлена. diff --git a/docs/summary-tests-in-container.md b/docs/summary-tests-in-container.md new file mode 100644 index 0000000..f828919 --- /dev/null +++ b/docs/summary-tests-in-container.md @@ -0,0 +1,15 @@ +# Итоги: запуск тестов в контейнере + +План: `docs/plan-tests-in-container.md`. + +## Сделано +- `Dockerfile.test` (`python:3.11-slim`): зависимости отдельным слоем, `CMD ["pytest", "-q"]`. +- `.dockerignore`: `venv/`, боевые данные, кэши, lock/tmp-файлы. +- `README.md`: шаг «Tests» заменён на запуск через `docker build` / `docker run`. + +## Проверка +Образ собран, `docker run --rm ripe-collector-test` - 3 passed. В образе нет `data.json` и `fqdn_data.json`, тесты не зависят от боевых данных. + +## Замечания +- Локальный `venv/` остаётся для разработки; для тестов он не нужен. +- Образ `ripe-collector-test` остался в локальном Docker (`docker rmi ripe-collector-test` для удаления). diff --git a/formatters.py b/formatters.py new file mode 100644 index 0000000..915005d --- /dev/null +++ b/formatters.py @@ -0,0 +1,115 @@ +"""Подготовка списка адресов к выдаче: фильтр по версии IP, агрегация CIDR, форматы конфигураций.""" +import datetime +import ipaddress +import logging + +logger = logging.getLogger(__name__) + +FORMATS = ("json", "nftables", "mikrotik", "bird", "frr") + + +def select_strings(values, ip_version="all"): + """Путь по умолчанию: строки как есть (без разбора), только фильтр версии и сортировка.""" + if ip_version == "4": + values = [v for v in values if ":" not in v] + elif ip_version == "6": + values = [v for v in values if ":" in v] + return sorted(set(values)) + + +def select_networks(values, ip_version="all", aggregate=False): + """Разбирает значения в сети; возвращает (v4, v6). Одиночные IP становятся /32 и /128.""" + v4, v6 = [], [] + for value in set(values): + try: + net = ipaddress.ip_network(value, strict=False) + except ValueError: + logger.warning("Skipping invalid address: %s", value) + continue + (v4 if net.version == 4 else v6).append(net) + + if ip_version == "4": + v6 = [] + elif ip_version == "6": + v4 = [] + + if aggregate: + v4 = list(ipaddress.collapse_addresses(v4)) + v6 = list(ipaddress.collapse_addresses(v6)) + return sorted(v4), sorted(v6) + + +def networks_to_strings(v4, v6): + """Для JSON: /32 и /128 печатаются как «голый» IP, как и в неагрегированном выводе.""" + return [str(n.network_address) if n.prefixlen == n.max_prefixlen else str(n) for n in [*v4, *v6]] + + +def _block(header, items, footer): + # Без запятой после последнего элемента: BIRD её не принимает + last = len(items) - 1 + return [header, *(f" {item}{',' if i < last else ''}" for i, item in enumerate(items)), footer] + + +def _nftables(v4, v6, name, comment): + lines = [f"# {comment}", f"add table inet {name}"] + for suffix, nets, addr_type in (("v4", v4, "ipv4_addr"), ("v6", v6, "ipv6_addr")): + if not nets: + continue + set_name = f"{name}_{suffix}" + lines += [ + # auto-merge: без него nft отвергает пересекающиеся интервалы + f"add set inet {name} {set_name} {{ type {addr_type}; flags interval; auto-merge; }}", + f"flush set inet {name} {set_name}", + *_block(f"add element inet {name} {set_name} {{", nets, "}"), + ] + return lines + + +def _mikrotik(v4, v6, name, comment): + lines = [f"# {comment}"] + for path, nets in (("/ip firewall address-list", v4), ("/ipv6 firewall address-list", v6)): + if not nets: + continue + lines.append(f"{path} remove [find list={name}]") + lines += [f"{path} add list={name} address={net}" for net in nets] + return lines + + +def _bird(v4, v6, name, comment): + lines = [f"# {comment}"] + for suffix, nets in (("V4", v4), ("V6", v6)): + if nets: + lines += _block(f"define {name.upper()}_{suffix} = [", nets, "];") + return lines + + +def _frr(v4, v6, name, comment): + lines = [f"! {comment}"] + for family, suffix, nets in (("ip", "v4", v4), ("ipv6", "v6", v6)): + if not nets: + continue + list_name = f"{name}_{suffix}" + lines.append(f"no {family} prefix-list {list_name}") + lines += [f"{family} prefix-list {list_name} seq {5 * i} permit {net}" + for i, net in enumerate(nets, start=1)] + return lines + + +_RENDERERS = {"nftables": _nftables, "mikrotik": _mikrotik, "bird": _bird, "frr": _frr} + + +def render(fmt, v4, v6, name, ip_version="all", aggregate=False): + """Возвращает готовый скрипт конфигурации (идемпотентный: повторное применение заменяет список).""" + stamp = datetime.datetime.now().isoformat(timespec="seconds") + comment = f"generated {stamp}, ip_version={ip_version}, aggregate={str(aggregate).lower()}" + return "\n".join(_RENDERERS[fmt](v4, v6, name, comment)) + "\n" + + +def build_output(values, fmt="json", ip_version="all", aggregate=False, name="ripe"): + """JSON -> список строк, остальные форматы -> текст конфигурации.""" + if fmt == "json" and not aggregate: + return select_strings(values, ip_version) + v4, v6 = select_networks(values, ip_version, aggregate) + if fmt == "json": + return networks_to_strings(v4, v6) + return render(fmt, v4, v6, name, ip_version, aggregate) diff --git a/healthcheck.py b/healthcheck.py new file mode 100644 index 0000000..acbe799 --- /dev/null +++ b/healthcheck.py @@ -0,0 +1,32 @@ +"""Проверки состояния для Docker HEALTHCHECK: `healthcheck.py api` или `healthcheck.py collector`.""" +import datetime +import json +import sys +import urllib.request + +import cidr_collector as cc + + +def check_api(url="http://127.0.0.1:8000/health"): + with urllib.request.urlopen(url, timeout=5) as response: + return response.status == 200 + + +def check_collector(): + """Демон жив, пока heartbeat в status.json не старше STATUS_STALE_AFTER.""" + with open(cc.STATUS_FILE) as f: + updated_at = datetime.datetime.fromisoformat(json.load(f)["updated_at"]) + return (datetime.datetime.now() - updated_at).total_seconds() < cc.STATUS_STALE_AFTER + + +CHECKS = {"api": check_api, "collector": check_collector} + + +if __name__ == "__main__": + target = sys.argv[1] if len(sys.argv) > 1 else "" + try: + ok = CHECKS[target]() + except Exception as e: + print(f"{target} unhealthy: {e}") + ok = False + sys.exit(0 if ok else 1) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..5769ad4 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest +httpx diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0b65ed8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +fastapi +uvicorn +APScheduler diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..fd2f3a5 --- /dev/null +++ b/storage.py @@ -0,0 +1,70 @@ +import fcntl +import json +import logging +import os +import tempfile +import time +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + + +class StorageError(Exception): + """Файл данных повреждён или недоступен.""" + + +def load_json(path, default): + """Читает JSON. Битый файл переименовывается в *.corrupt-, чтобы его не затёрли.""" + if not os.path.exists(path): + return default + try: + with open(path, 'r') as f: + return json.load(f) + except json.JSONDecodeError as e: + backup = f"{path}.corrupt-{int(time.time())}" + os.replace(path, backup) + logger.error("Corrupted JSON in %s (%s); moved to %s", path, e, backup) + raise StorageError(f"{path} is corrupted") from e + except OSError as e: + raise StorageError(f"Cannot read {path}: {e}") from e + + +def save_json_atomic(path, data): + """Пишет во временный файл в том же каталоге и подменяет оригинал через os.replace.""" + directory = os.path.dirname(os.path.abspath(path)) + fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=os.path.basename(path) + ".", suffix=".tmp") + try: + with os.fdopen(fd, 'w') as f: + json.dump(data, f, indent=4) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, path) + except BaseException: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + raise + + +@contextmanager +def file_lock(path): + """Межпроцессная блокировка (сборщик, CLI и API) через .lock.""" + with open(path + ".lock", 'w') as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +def try_lock(path): + """Неблокирующая эксклюзивная блокировка (singleton-процесс). + + Возвращает открытый файл (блокировка держится, пока он жив) или None, если занято. + """ + lock_file = open(path, 'w') + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + lock_file.close() + return None + return lock_file diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..359cb28 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,80 @@ +import datetime +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import api_server +import cidr_collector as cc +import db +from storage import StorageError, load_json + +NOW = datetime.datetime.now() + + +@pytest.fixture +def files(tmp_path, monkeypatch): + monkeypatch.setattr(cc, "CONFIG_FILE", str(tmp_path / "config.json")) + monkeypatch.setattr(cc, "DATA_FILE", str(tmp_path / "data.json")) + monkeypatch.setattr(cc, "FQDN_DATA_FILE", str(tmp_path / "fqdn_data.json")) + monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db")) + return tmp_path + + +def stored(kind="asn"): + with db.session() as conn: + return sorted(db.get_values(conn, kind)) + + +def test_ttl_merge_and_failure_safety(files, monkeypatch): + (files / "config.json").write_text('{"asns": [1], "ttl_days": 90}') + collector = cc.CIDRCollector() + old = (NOW - datetime.timedelta(days=100)).isoformat(timespec="seconds") + with db.session() as conn: + conn.execute("INSERT INTO addresses VALUES ('asn', '1', '9.9.9.0/24', ?, ?)", (old, old)) + + # Ошибка источника: ничего не удаляется, даже просроченное + monkeypatch.setattr(collector, "fetch_prefixes", lambda asn: None) + collector.run_collection() + assert stored() == ["9.9.9.0/24"] + + # Успешный ответ: просроченный удаляется, новый добавляется + monkeypatch.setattr(collector, "fetch_prefixes", lambda asn: ["1.1.1.0/24"]) + collector.run_collection() + assert stored() == ["1.1.1.0/24"] + + +def test_corrupted_storage_is_preserved(files): + # Битый JSON (конфигурация) + path = files / "data.json" + path.write_text("{broken") + with pytest.raises(StorageError): + load_json(str(path), {}) + assert not path.exists() + assert len(list(files.glob("data.json.corrupt-*"))) == 1 + + # Битая база: убирается в сторону, ошибка отдаётся как StorageError + (files / "ripe.db").write_bytes(b"this is not a sqlite database" * 100) + with pytest.raises(StorageError): + db.connect() + assert not (files / "ripe.db").exists() + assert len(list(files.glob("ripe.db.corrupt-*"))) == 1 + + +def test_post_schedule_auth(files, monkeypatch): + (files / "config.json").write_text('{"asns": [], "fqdns": []}') + client = TestClient(api_server.app) + body = {"type": "asn", "cron": "*/5 * * * *"} + + monkeypatch.delenv("RIPE_API_TOKEN", raising=False) + assert client.post("/schedule", json=body).status_code == 503 + + monkeypatch.setenv("RIPE_API_TOKEN", "secret") + assert client.post("/schedule", json=body).status_code == 401 + assert client.post("/schedule", json=body, headers={"X-API-Key": "wrong"}).status_code == 401 + assert client.post("/schedule", json=body, headers={"X-API-Key": "secret"}).status_code == 200 + assert load_json(cc.CONFIG_FILE, {})["schedule"]["asn"] == "*/5 * * * *" + assert client.get("/addresses").status_code == 200 diff --git a/tests/test_daemon.py b/tests/test_daemon.py new file mode 100644 index 0000000..ba6b82b --- /dev/null +++ b/tests/test_daemon.py @@ -0,0 +1,97 @@ +import datetime +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import api_server +import cidr_collector as cc +import collector_daemon as daemon +from storage import load_json, save_json_atomic + + +@pytest.fixture +def env(tmp_path, monkeypatch): + for attr, name in (("CONFIG_FILE", "config.json"), ("DATA_FILE", "data.json"), + ("FQDN_DATA_FILE", "fqdn_data.json"), ("STATUS_FILE", "status.json"), + ("DB_FILE", "ripe.db"), ("COLLECT_REQUEST_FILE", "collect_request.json")): + monkeypatch.setattr(cc, attr, str(tmp_path / name)) + monkeypatch.setattr(daemon, "job_state", {name: {"cron": None, "last_run": None, "last_finished": None, + "running": False, "last_error": None, + "rejected_cron": None} for name in daemon.COLLECTORS}) + return tmp_path + + +def test_sync_schedule_reschedules_and_ignores_invalid_cron(env): + save_json_atomic(cc.CONFIG_FILE, {"schedule": {"asn": "0 1 * * *", "fqdn": "0 3 * * *"}}) + scheduler = daemon.build_scheduler() + assert daemon.job_state["asn"]["cron"] == "0 1 * * *" + + save_json_atomic(cc.CONFIG_FILE, {"schedule": {"asn": "*/5 * * * *", "fqdn": "0 3 * * *"}}) + daemon.sync_schedule(scheduler) + assert daemon.job_state["asn"]["cron"] == "*/5 * * * *" + assert "*/5" in str(scheduler.get_job("asn_job").trigger) + + # Невалидный cron не ломает действующее расписание + save_json_atomic(cc.CONFIG_FILE, {"schedule": {"asn": "not a cron", "fqdn": "0 3 * * *"}}) + daemon.sync_schedule(scheduler) + assert daemon.job_state["asn"]["cron"] == "*/5 * * * *" + # Каждая сверка пишет heartbeat + assert load_json(cc.STATUS_FILE, None)["jobs"]["asn"]["cron"] == "*/5 * * * *" + + +def test_health_reflects_collector_heartbeat(env): + client = TestClient(api_server.app) + + # Демон не запускался + body = client.get("/health").json() + assert body["collector_alive"] is False and body["status"] == "degraded" + + now = datetime.datetime.now() + save_json_atomic(cc.STATUS_FILE, {"updated_at": now.isoformat(), "jobs": {}}) + body = client.get("/health").json() + assert body["collector_alive"] is True and body["status"] == "ok" + + stale = now - datetime.timedelta(seconds=cc.STATUS_STALE_AFTER + 60) + save_json_atomic(cc.STATUS_FILE, {"updated_at": stale.isoformat(), "jobs": {}}) + body = client.get("/health").json() + assert body["collector_alive"] is False and body["status"] == "degraded" + + +def test_collect_request_api(env, monkeypatch): + monkeypatch.setenv("RIPE_API_TOKEN", "secret") + client = TestClient(api_server.app) + key = {"X-API-Key": "secret"} + + assert client.post("/collect").status_code == 401 + # Демон не запущен: запрос не ставится в очередь + assert client.post("/collect", headers=key).status_code == 503 + assert not os.path.exists(cc.COLLECT_REQUEST_FILE) + + save_json_atomic(cc.STATUS_FILE, {"updated_at": datetime.datetime.now().isoformat(), "jobs": {}}) + assert client.post("/collect", json={"type": "asn"}, headers=key).json()["requested"] == ["asn"] + # Повторные запросы объединяются; без тела - все типы + assert client.post("/collect", headers=key).json()["requested"] == ["asn", "fqdn"] + assert client.post("/collect", json={"type": "bad"}, headers=key).status_code == 422 + assert load_json(cc.COLLECT_REQUEST_FILE, {})["types"] == ["asn", "fqdn"] + + +def test_daemon_runs_requested_collection_once(env, monkeypatch): + scheduler = daemon.build_scheduler() + cc.request_collection(["asn"]) + + daemon.check_collect_requests(scheduler) + assert scheduler.get_job("asn_manual") is not None and scheduler.get_job("fqdn_manual") is None + assert not os.path.exists(cc.COLLECT_REQUEST_FILE) # запрос забран + daemon.check_collect_requests(scheduler) # повторный вызов ничего не делает + + # Пока сбор этого типа идёт, второй запуск пропускается + monkeypatch.setitem(daemon.COLLECTORS, "asn", lambda: pytest.fail("collection must be skipped")) + daemon._run_locks["asn"].acquire() + try: + daemon.run_job("asn", scheduler) + finally: + daemon._run_locks["asn"].release() diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..daf8cf6 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,78 @@ +import datetime +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import cidr_collector as cc +import db + + +@pytest.fixture +def files(tmp_path, monkeypatch): + for attr, name in (("DB_FILE", "ripe.db"), ("DATA_FILE", "data.json"), ("FQDN_DATA_FILE", "fqdn_data.json")): + monkeypatch.setattr(cc, attr, str(tmp_path / name)) + return tmp_path + + +def test_legacy_json_import(files): + before = datetime.datetime.now().isoformat(timespec="seconds") + (files / "data.json").write_text(json.dumps({ + # Новый формат: first_seen/last_seen сохраняются + "1": {"last_updated": "2026-02-01T00:00:00", "prefixes": ["1.0.0.0/24"], + "seen": {"1.0.0.0/24": {"first_seen": "2026-01-01T00:00:00", "last_seen": "2026-02-01T00:00:00"}}}, + # Старый формат без seen: first_seen = last_updated, last_seen = момент миграции + "2": {"last_updated": "2025-12-01T00:00:00", "prefixes": ["2.0.0.0/24"]}})) + (files / "fqdn_data.json").write_text(json.dumps( + {"example.com": {"last_updated": "2025-12-01T00:00:00", "ips": ["9.9.9.9"]}})) + + with db.session() as conn: + rows = {(k, s, v): (f, l) for k, s, v, f, l in conn.execute("SELECT * FROM addresses")} + assert rows[("asn", "1", "1.0.0.0/24")] == ("2026-01-01T00:00:00", "2026-02-01T00:00:00") + first, last = rows[("asn", "2", "2.0.0.0/24")] + assert first == "2025-12-01T00:00:00" and last >= before + assert ("fqdn", "example.com", "9.9.9.9") in rows + + # Оригиналы переименованы (резервная копия), повторное открытие ничего не импортирует + assert not (files / "data.json").exists() and len(list(files.glob("data.json.migrated-*"))) == 1 + (files / "data.json").write_text(json.dumps({"3": {"prefixes": ["3.0.0.0/24"]}})) + with db.session() as conn: + assert db.count_values(conn) == 3 + + +def test_change_journal(files): + now = datetime.datetime.now() + (files / "data.json").write_text(json.dumps({"9": {"prefixes": ["9.0.0.0/24"]}})) + with db.session() as conn: + # Импорт из JSON в журнал не попадает + assert db.get_changes(conn, {"asn"}, cursor=0) == (set(), set(), 0) + + with db.transaction(conn): + db.merge_source(conn, "asn", "1", {"a", "b"}, now, 90) + db.merge_source(conn, "asn", "2", {"b"}, now, 90) + added, removed, c1 = db.get_changes(conn, {"asn"}, cursor=0) + assert (added, removed) == ({"a", "b"}, set()) + + # Значение b держит второй источник: исчезает только a + with db.transaction(conn): + db.purge_source(conn, "asn", "1") + assert db.get_changes(conn, {"asn"}, cursor=c1)[:2] == (set(), {"a"}) + + # Удалено и возвращено в одном интервале - изменений нет; повтор с новым курсором пуст + c2 = db.get_changes(conn, {"asn"}, cursor=c1)[2] + with db.transaction(conn): + db.purge_source(conn, "asn", "2") + db.merge_source(conn, "asn", "2", {"b"}, now, 90) + added, removed, c3 = db.get_changes(conn, {"asn"}, cursor=c2) + assert (added, removed) == (set(), set()) and c3 > c2 + assert db.get_changes(conn, {"fqdn"}, cursor=0)[:2] == (set(), set()) # фильтр по типу + + # Очистка сдвигает горизонт: старый курсор недействителен, текущий - нет; курсор «из будущего» тоже + with db.transaction(conn): + assert db.prune_changes(conn, now + datetime.timedelta(days=2), 1) > 0 + assert db.get_changes(conn, {"asn"}, cursor=0) is None + assert db.get_changes(conn, {"asn"}, cursor=c3 + 1) is None + assert db.get_changes(conn, {"asn"}, cursor=c3) == (set(), set(), c3) diff --git a/tests/test_formats.py b/tests/test_formats.py new file mode 100644 index 0000000..f35f259 --- /dev/null +++ b/tests/test_formats.py @@ -0,0 +1,54 @@ +import datetime +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import api_server +import cidr_collector as cc +import db +import formatters + +VALUES = ["10.0.0.0/23", "10.0.0.0/24", "10.0.1.5", "2001:db8::/32", "1.1.1.1"] + + +def test_select_default_aggregate_and_version(): + # По умолчанию вывод прежний: строки как есть, лексикографическая сортировка + assert formatters.build_output(VALUES) == sorted(VALUES) + assert formatters.build_output(VALUES, ip_version="6") == ["2001:db8::/32"] + assert "2001:db8::/32" not in formatters.build_output(VALUES, ip_version="4") + # Агрегация: /24 и 10.0.1.5 поглощены /23, одиночный IP печатается без /32 + assert formatters.build_output(VALUES, aggregate=True) == ["1.1.1.1", "10.0.0.0/23", "2001:db8::/32"] + + +@pytest.mark.parametrize("fmt,v4_line,v6_line", [ + ("nftables", "add element inet ripe ripe_v4 {", "add element inet ripe ripe_v6 {"), + ("mikrotik", "/ip firewall address-list add list=ripe address=10.0.0.0/23", + "/ipv6 firewall address-list add list=ripe address=2001:db8::/32"), + ("bird", "define RIPE_V4 = [", "define RIPE_V6 = ["), + ("frr", "ip prefix-list ripe_v4 seq 10 permit 10.0.0.0/23", "ipv6 prefix-list ripe_v6 seq 5 permit 2001:db8::/32"), +]) +def test_render_formats(fmt, v4_line, v6_line): + out = formatters.build_output(VALUES, fmt, aggregate=True) + assert v4_line in out and v6_line in out and out.endswith("\n") + # Пустая версия пропускается + only_v6 = formatters.build_output(VALUES, fmt, ip_version="6") + assert v6_line in only_v6 and v4_line not in only_v6 + + +def test_api_formats(tmp_path, monkeypatch): + monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db")) + monkeypatch.setattr(cc, "DATA_FILE", str(tmp_path / "data.json")) + monkeypatch.setattr(cc, "FQDN_DATA_FILE", str(tmp_path / "fqdn_data.json")) + with db.session() as conn, db.transaction(conn): + db.merge_source(conn, "asn", "1", {"10.0.0.0/23", "2001:db8::/32"}, datetime.datetime.now(), 90) + client = TestClient(api_server.app) + + assert client.get("/addresses").json() == ["10.0.0.0/23", "2001:db8::/32"] + resp = client.get("/addresses", params={"format": "mikrotik", "name": "tg"}) + assert resp.headers["content-type"].startswith("text/plain") + assert "list=tg address=10.0.0.0/23" in resp.text + assert client.get("/addresses", params={"name": "bad name;"}).status_code == 422 diff --git a/tests/test_sources_api.py b/tests/test_sources_api.py new file mode 100644 index 0000000..1986b08 --- /dev/null +++ b/tests/test_sources_api.py @@ -0,0 +1,97 @@ +import datetime +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import api_server +import cidr_collector as cc +import db +from storage import load_json, save_json_atomic + +KEY = {"X-API-Key": "secret"} + + +@pytest.fixture +def env(tmp_path, monkeypatch): + monkeypatch.setattr(cc, "CONFIG_FILE", str(tmp_path / "config.json")) + monkeypatch.setattr(cc, "DATA_FILE", str(tmp_path / "data.json")) + monkeypatch.setattr(cc, "FQDN_DATA_FILE", str(tmp_path / "fqdn_data.json")) + monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db")) + monkeypatch.setenv("RIPE_API_TOKEN", "secret") + save_json_atomic(cc.CONFIG_FILE, {"asns": [], "fqdns": [], "ttl_days": 90}) + return TestClient(api_server.app) + + +def test_manage_asns_and_fqdns(env): + # Без ключа - 401; ASN добавляется один раз (201, затем 200) + assert env.post("/asns", json={"asn": 62041}).status_code == 401 + assert env.post("/asns", json={"asn": 62041}, headers=KEY).status_code == 201 + assert env.post("/asns", json={"asn": 62041}, headers=KEY).status_code == 200 + assert env.get("/asns").json() == {"asns": [62041]} + assert env.post("/asns", json={"asn": 0}, headers=KEY).status_code == 422 + + # FQDN нормализуется; IP-литералы и неверные метки отклоняются + resp = env.post("/fqdns", json={"fqdn": " Example.COM. "}, headers=KEY) + assert resp.status_code == 201 and resp.json()["fqdn"] == "example.com" + for bad in ("1.2.3.4", "bad_name.com", "-a.com", "a..com"): + assert env.post("/fqdns", json={"fqdn": bad}, headers=KEY).status_code == 422 + assert env.get("/fqdns").json() == {"fqdns": ["example.com"]} + + # Удаление: 404 для неизвестного, 200 для существующего, конфиг обновлён + assert env.delete("/asns/1", headers=KEY).status_code == 404 + assert env.delete("/fqdns/example.com", headers=KEY).status_code == 200 + assert env.delete("/asns/62041", headers=KEY).status_code == 200 + assert load_json(cc.CONFIG_FILE, {})["asns"] == [] and load_json(cc.CONFIG_FILE, {})["fqdns"] == [] + + +def sources(): + with db.session() as conn: + return {s for (s,) in conn.execute("SELECT DISTINCT source FROM addresses")} + + +def test_removed_source_data_purge_and_ttl(env, monkeypatch): + now = datetime.datetime.now() + with db.session() as conn: + for source, days_ago in (("1", 1), ("2", 100), ("3", 1)): + stamp = (now - datetime.timedelta(days=days_ago)).isoformat(timespec="seconds") + conn.execute("INSERT INTO addresses VALUES ('asn', ?, '10.0.0.0/24', ?, ?)", (source, stamp, stamp)) + save_json_atomic(cc.CONFIG_FILE, {"asns": [1, 2, 3], "ttl_days": 90}) + + # Без purge данные остаются, purge=true удаляет сразу + assert env.delete("/asns/2", headers=KEY).status_code == 200 + assert env.delete("/asns/3?purge=true", headers=KEY).json() == {"asn": 3, "removed": True, "purged": True} + assert sources() == {"1", "2"} + + # Сбор: у неопрашиваемого источника 2 адрес просрочен (100 > 90 дней) - исчезает + collector = cc.CIDRCollector() + monkeypatch.setattr(collector, "fetch_prefixes", lambda asn: ["10.0.0.0/24"]) + collector.run_collection() + assert sources() == {"1"} + + +def test_addresses_diff(env): + now = datetime.datetime.now() + with db.session() as conn, db.transaction(conn): + db.merge_source(conn, "asn", "1", {"1.0.0.0/24", "2001:db8::/32"}, now, 90) + db.merge_source(conn, "fqdn", "example.com", {"9.9.9.9"}, now, 90) + horizon = dict(conn.execute("SELECT key, value FROM meta"))["horizon_ts"] + + body = env.get("/addresses/diff", params={"since": "0"}).json() + assert body["added"] == ["1.0.0.0/24", "2001:db8::/32", "9.9.9.9"] and body["removed"] == [] + assert env.get("/addresses/diff", params={"since": "0", "type": "cidr", "ip_version": "6"}).json()["added"] == [ + "2001:db8::/32"] + assert env.get("/addresses").headers["X-Changes-Cursor"] == str(body["cursor"]) + assert env.get("/addresses", params={"format": "nftables"}).headers["X-Changes-Cursor"] == str(body["cursor"]) + # Время вместо курсора; с полученным курсором изменений нет + assert env.get("/addresses/diff", params={"since": horizon}).json()["added"] == body["added"] + assert env.get("/addresses/diff", params={"since": body["cursor"]}).json()["added"] == [] + + # Неверное значение - 400; вне журнала (курсор из будущего, время до создания журнала) - 410 + assert env.get("/addresses/diff", params={"since": "yesterday"}).status_code == 400 + assert env.get("/addresses/diff", params={"since": body["cursor"] + 1}).status_code == 410 + assert env.get("/addresses/diff", params={"since": "2000-01-01T00:00:00Z"}).status_code == 410 + assert env.get("/addresses/diff").status_code == 422