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 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-21 07:29:38 +03:00
commit bcf8156085
45 files changed
+3134

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Твоя роль
- DevOps инженер
- Разработчик Backend
- Архитектор информационных систем
- Архитектор корпоративной сети
# Стиль общения
- профессиональный, но без жаргона
# Стиль ответов
- максимально емкие и содержательные
- не проваливайся в лишние детали, если это явно не было запрошено
# Создание артефактов
- На каждое новое изменение должен быть артефакт в .md файле
- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле
- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле
- каждое изменение дополняет или обновляет README.md
# Автотесты
- минимальное количество тестов
# Окружение для разработки
- при необходимости создай виртуальное окружение в корне проекта в директории venv (родительская директория виртуального окружения)
+17
View File
@@ -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/
+6
View File
@@ -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
+16
View File
@@ -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
+23
View File
@@ -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"]
+10
View File
@@ -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"]
+516
View File
@@ -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="<random-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: <RIPE_API_TOKEN>`; 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://<server-ip>: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 <name>` with interval sets `<name>_v4` / `<name>_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 `<name>` (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 <NAME>_V4 = [...]`, `<NAME>_V6` for use in filters (`net ~ RIPE_V4`) | save to a file, `include` it, `birdc configure` |
| `frr` | `ip prefix-list <name>_v4` / `ipv6 prefix-list <name>_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=<cursor | time>[&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=<that cursor>` 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-<timestamp>` 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 <project>_ripe_data, e.g. ripe_cidr_collector_ripe_data
docker run --rm -v <project>_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-<timestamp>` and `fqdn_data.json.migrated-<timestamp>`.
### 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).
+320
View File
@@ -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)
+271
View File
@@ -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()
+167
View File
@@ -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()
+20
View File
@@ -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
}
+260
View File
@@ -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")
+48
View File
@@ -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:
+63
View File
@@ -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 <name>` ошибку, если список ещё не создан.
## Новые наблюдения
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`
+89
View File
@@ -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`.
+62
View File
@@ -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. По окончании убрать тестовые контейнеры, тома и образы, созданные при проверке.
+29
View File
@@ -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` (выполняется первым).
+22
View File
@@ -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`) забирает и удаляет файл и ставит разовое задание `<type>_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 (общий том).
+27
View File
@@ -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.
+20
View File
@@ -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`; тесты в контейнере проходят (код не менялся).
+65
View File
@@ -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 <name>`; `add set inet <name> <name>_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=<name>]` затем `add list=<name> address=...`; для v6 - `/ipv6 firewall address-list`.
- **BIRD 2** (`include`): `define <NAME>_V4 = [ a/len, ... ];` и `..._V6` - префикс-сеты для фильтров (не статические маршруты: next-hop определяет потребитель).
- **FRR** (`vtysh -f`): `no ip prefix-list <name>_v4` затем `ip prefix-list <name>_v4 seq 5|10|... permit <prefix>`; для v6 - `ipv6 prefix-list`.
- Все скрипты начинаются с комментарием `# generated <ts>, 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 - проверить по документации.
+55
View File
@@ -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 не реализуется, пока не будет отдельной команды.
+59
View File
@@ -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 логирует ошибку, переименовывает файл в `<name>.corrupt-<ts>` и бросает `StorageError` (сборщик прерывается, не затирая данные; API отвечает 503).
- `save_json_atomic(path, data)`: запись во временный файл в том же каталоге, `flush` + `fsync`, `os.replace`.
- `file_lock(path)`: контекстный менеджер на `fcntl.flock` по `<path>.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` малым значением и проверить удаление устаревшей записи.
+72
View File
@@ -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-<ts>` (вместе с `-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-<ts>` и `fqdn_data.json.migrated-<ts>` (не удаляются - это и есть резервная копия и путь отката).
- Импорт идемпотентен: повторный запуск (`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` данные на месте); по окончании убрать тестовые контейнеры, тома и образы.
+16
View File
@@ -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`.
+27
View File
@@ -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 это описано.
+23
View File
@@ -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`.
+21
View File
@@ -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 с ставит разовые задания `<type>_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` показывает только исключение сбора, но не сбои отдельных источников - это остаётся в списке улучшений).
+24
View File
@@ -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) не сделаны: следующий шаг поверх журнала.
+18
View File
@@ -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).
+23
View File
@@ -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 <name>` для ещё не существующего списка не даёт ошибку при `vtysh -f`; если даёт - убрать эту строку или применять с `-m`.
- Нет тестов на очень большие списки (десятки тысяч записей).
## Замечания
- Формат `json` с `aggregate=true` печатает `/32` и `/128` как «голый» IP.
- `nftables`: имя таблицы совпадает с `name`; свои правила пользователь добавляет в эту же таблицу (или создаёт набор в своей, изменив `name`).
+24
View File
@@ -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` не менялись.
+20
View File
@@ -0,0 +1,20 @@
# Итоги: надёжность и безопасность
План: `docs/plan-reliability-security.md`.
## Что сделано
- **`storage.py` (новый)**: атомарная запись (temp + fsync + `os.replace`), межпроцессная блокировка `flock`, безопасное чтение: битый JSON переименовывается в `*.corrupt-<ts>`, вызывается `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` не изменялись; миграция произойдёт при первом запуске сборщика.
+27
View File
@@ -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-<ts>` (резервная копия и путь отката).
- **`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-<ts>`, `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`) для них подготовлена.
+15
View File
@@ -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` для удаления).
+115
View File
@@ -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)
+32
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
pytest
httpx
+4
View File
@@ -0,0 +1,4 @@
requests
fastapi
uvicorn
APScheduler
+70
View File
@@ -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-<ts>, чтобы его не затёрли."""
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) через <path>.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
+80
View File
@@ -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
+97
View File
@@ -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()
+78
View File
@@ -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)
+54
View File
@@ -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
+97
View File
@@ -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