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:
commit
bcf8156085
45 files changed
+3134
No files matched your search
@@ -0,0 +1,30 @@
|
|||||||
|
# Твоя роль
|
||||||
|
|
||||||
|
- DevOps инженер
|
||||||
|
- Разработчик Backend
|
||||||
|
- Архитектор информационных систем
|
||||||
|
- Архитектор корпоративной сети
|
||||||
|
|
||||||
|
# Стиль общения
|
||||||
|
|
||||||
|
- профессиональный, но без жаргона
|
||||||
|
|
||||||
|
# Стиль ответов
|
||||||
|
|
||||||
|
- максимально емкие и содержательные
|
||||||
|
- не проваливайся в лишние детали, если это явно не было запрошено
|
||||||
|
|
||||||
|
# Создание артефактов
|
||||||
|
|
||||||
|
- На каждое новое изменение должен быть артефакт в .md файле
|
||||||
|
- Каждое новое изменение должно начинаться с плана внедрения в отдельном файле
|
||||||
|
- Каждое новое изменение должно заканчиваться суммаризацией по выполненым доработкам в отдельном файле
|
||||||
|
- каждое изменение дополняет или обновляет README.md
|
||||||
|
|
||||||
|
# Автотесты
|
||||||
|
|
||||||
|
- минимальное количество тестов
|
||||||
|
|
||||||
|
# Окружение для разработки
|
||||||
|
|
||||||
|
- при необходимости создай виртуальное окружение в корне проекта в директории venv (родительская директория виртуального окружения)
|
||||||
@@ -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/
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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:
|
||||||
@@ -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`
|
||||||
@@ -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`.
|
||||||
@@ -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. По окончании убрать тестовые контейнеры, тома и образы, созданные при проверке.
|
||||||
@@ -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` (выполняется первым).
|
||||||
@@ -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 (общий том).
|
||||||
@@ -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.
|
||||||
@@ -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`; тесты в контейнере проходят (код не менялся).
|
||||||
@@ -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 - проверить по документации.
|
||||||
@@ -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 не реализуется, пока не будет отдельной команды.
|
||||||
@@ -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` малым значением и проверить удаление устаревшей записи.
|
||||||
@@ -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` данные на месте); по окончании убрать тестовые контейнеры, тома и образы.
|
||||||
@@ -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`.
|
||||||
@@ -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 это описано.
|
||||||
@@ -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`.
|
||||||
@@ -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` показывает только исключение сбора, но не сбои отдельных источников - это остаётся в списке улучшений).
|
||||||
@@ -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) не сделаны: следующий шаг поверх журнала.
|
||||||
@@ -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).
|
||||||
@@ -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`).
|
||||||
@@ -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` не менялись.
|
||||||
@@ -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` не изменялись; миграция произойдёт при первом запуске сборщика.
|
||||||
@@ -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`) для них подготовлена.
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pytest
|
||||||
|
httpx
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
requests
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
APScheduler
|
||||||
+70
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user