Withhold addresses after the database is recreated without a backup
When ripe.db is corrupted and no valid backup exists, a new empty database is created with a db_recreated.json marker; /addresses and /addresses/diff answer 503 until the collector gathers data again, /health reports db_recreated. Tests now isolate all state files via conftest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46d56654d8
commit
0155fd3f38
13 files changed
+217
-23
No files matched your search
@@ -17,3 +17,4 @@ collect_request.json
|
|||||||
graphify-out/
|
graphify-out/
|
||||||
backups/
|
backups/
|
||||||
last_restore.json
|
last_restore.json
|
||||||
|
db_recreated.json
|
||||||
@@ -16,3 +16,4 @@ data.json
|
|||||||
fqdn_data.json
|
fqdn_data.json
|
||||||
backups/
|
backups/
|
||||||
last_restore.json
|
last_restore.json
|
||||||
|
db_recreated.json
|
||||||
@@ -396,7 +396,7 @@ Body is optional: `type` is `asn`, `fqdn` or `all` (default). The API does not c
|
|||||||
|
|
||||||
### Endpoint: Health
|
### Endpoint: Health
|
||||||
**GET** `/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, the number of stored addresses and `last_restore` (`null`, or the record of the last automatic restore of the database from a backup, see section 9). 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).
|
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, the number of stored addresses and `last_restore` (`null`, or the record of the last automatic restore of the database from a backup) and `db_recreated` (`null`, or `{at, pending}` after the database was recreated without a backup; `pending: true` makes `status` `degraded`), see section 9. 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -524,7 +524,7 @@ Stop both services, rename the `*.migrated-*` files back to `data.json` / `fqdn_
|
|||||||
|
|
||||||
Copies are named `ripe-<UTC time>.db` and stored in `RIPE_BACKUP_DIR` (default `<RIPE_DATA_DIR>/backups`, i.e. `/data/backups` in Docker). **By default they are on the same volume as the database**: this protects against a corrupted file, not against losing the volume. For that, mount a separate volume/host directory and set `RIPE_BACKUP_DIR` to it, or copy the directory elsewhere regularly (e.g. `docker compose cp collector:/data/backups ./backups`).
|
Copies are named `ripe-<UTC time>.db` and stored in `RIPE_BACKUP_DIR` (default `<RIPE_DATA_DIR>/backups`, i.e. `/data/backups` in Docker). **By default they are on the same volume as the database**: this protects against a corrupted file, not against losing the volume. For that, mount a separate volume/host directory and set `RIPE_BACKUP_DIR` to it, or copy the directory elsewhere regularly (e.g. `docker compose cp collector:/data/backups ./backups`).
|
||||||
|
|
||||||
**Automatic restore.** If `ripe.db` cannot be opened as a database (any process: API, daemon, CLI), the file is moved to `ripe.db.corrupt-<timestamp>` and the newest copy that passes the integrity check is put in its place; concurrent processes are serialized with a lock. The event is logged (ERROR), written to `last_restore.json` and shown in `GET /health` as `last_restore`. Data collected after that copy is lost; the collector gathers it again on the next runs. If there is no valid copy, the behaviour is as before: the API answers `503`.
|
**Automatic restore.** If `ripe.db` cannot be opened as a database (any process: API, daemon, CLI), the file is moved to `ripe.db.corrupt-<timestamp>` and the newest copy that passes the integrity check is put in its place; concurrent processes are serialized with a lock. The event is logged (ERROR), written to `last_restore.json` and shown in `GET /health` as `last_restore`. Data collected after that copy is lost; the collector gathers it again on the next runs. If there is no valid copy, a new empty database is created and the marker file `db_recreated.json` is written. **While the data is not gathered again, `GET /addresses` and `GET /addresses/diff` answer `503` with `Retry-After: 300`** instead of an empty list (a consumer could take it for the truth and wipe its rules); a type without configured sources (e.g. no FQDNs) is not blocked. The collector removes the marker after a run when every configured type has data again; to accept an empty result earlier, delete `db_recreated.json` by hand. `GET /health` shows `degraded` and `db_recreated` (`at`, `pending`) meanwhile. A fresh installation (no database yet) and a manually deleted database file are not treated as a loss and return an empty list until the first collection.
|
||||||
|
|
||||||
- The change journal goes back with the copy, so after a restore all cursors and times issued earlier answer `410` on `/addresses/diff` (the client makes a full download). The journal counter is shifted by 1,000,000 for that (a heuristic: it assumes fewer changes than that between two copies).
|
- The change journal goes back with the copy, so after a restore all cursors and times issued earlier answer `410` on `/addresses/diff` (the client makes a full download). The journal counter is shifted by 1,000,000 for that (a heuristic: it assumes fewer changes than that between two copies).
|
||||||
- Only corruption detected **when the database is opened** is restored automatically. Damage inside the file shows up as `503` on reads and is caught by the `backup` job (`quick_check`); restore it by hand: stop both services, keep the damaged `ripe.db*`, copy the chosen `backups/ripe-*.db` to `ripe.db`, start the services.
|
- Only corruption detected **when the database is opened** is restored automatically. Damage inside the file shows up as `503` on reads and is caught by the `backup` job (`quick_check`); restore it by hand: stop both services, keep the damaged `ripe.db*`, copy the chosen `backups/ripe-*.db` to `ripe.db`, start the services.
|
||||||
|
|||||||
+25
-3
@@ -108,6 +108,19 @@ def verify_token(x_api_key: Optional[str] = Header(None)):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key.")
|
raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key.")
|
||||||
|
|
||||||
|
|
||||||
|
def kinds_of(address_type):
|
||||||
|
"""Типы данных в базе для значения параметра type."""
|
||||||
|
return {"asn" if t == AddressType.cidr else "fqdn"
|
||||||
|
for t in (AddressType.cidr, AddressType.fqdn) if address_type in (t, AddressType.all_types)}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_data_ready(conn, kinds):
|
||||||
|
"""503, пока база пересоздана после порчи и данные не собраны заново: пустой список ввёл бы потребителя в заблуждение."""
|
||||||
|
if db.recreated_pending(conn, kinds):
|
||||||
|
raise HTTPException(status_code=503, headers={"Retry-After": "300"},
|
||||||
|
detail="The database was recreated after corruption; data is being collected again.")
|
||||||
|
|
||||||
|
|
||||||
def get_cidrs() -> List[str]:
|
def get_cidrs() -> List[str]:
|
||||||
with db.session() as conn:
|
with db.session() as conn:
|
||||||
return db.get_values(conn, "asn")
|
return db.get_values(conn, "asn")
|
||||||
@@ -133,6 +146,7 @@ def get_addresses(
|
|||||||
):
|
):
|
||||||
# Курсор читаем до данных: изменения между чтением курсора и данных повторятся в diff, что безвредно
|
# Курсор читаем до данных: изменения между чтением курсора и данных повторятся в diff, что безвредно
|
||||||
with db.session() as conn:
|
with db.session() as conn:
|
||||||
|
ensure_data_ready(conn, kinds_of(type))
|
||||||
headers = {"X-Changes-Cursor": str(db.journal_head(conn))}
|
headers = {"X-Changes-Cursor": str(db.journal_head(conn))}
|
||||||
results = set()
|
results = set()
|
||||||
|
|
||||||
@@ -175,9 +189,9 @@ def get_addresses_diff(
|
|||||||
ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"),
|
ip_version: IPVersion = Query(IPVersion.all_versions, description="Filter by IP version"),
|
||||||
):
|
):
|
||||||
cursor, since_ts = parse_since(since)
|
cursor, since_ts = parse_since(since)
|
||||||
kinds = {"asn" if t == AddressType.cidr else "fqdn"
|
kinds = kinds_of(type)
|
||||||
for t in (AddressType.cidr, AddressType.fqdn) if type in (t, AddressType.all_types)}
|
|
||||||
with db.session() as conn:
|
with db.session() as conn:
|
||||||
|
ensure_data_ready(conn, kinds)
|
||||||
changes = db.get_changes(conn, kinds, cursor, since_ts)
|
changes = db.get_changes(conn, kinds, cursor, since_ts)
|
||||||
if changes is None:
|
if changes is None:
|
||||||
raise HTTPException(status_code=410, detail="since is outside the change journal; fetch the full /addresses list")
|
raise HTTPException(status_code=410, detail="since is outside the change journal; fetch the full /addresses list")
|
||||||
@@ -216,13 +230,19 @@ def health():
|
|||||||
|
|
||||||
with db.session() as conn:
|
with db.session() as conn:
|
||||||
counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")}
|
counts = {"cidrs": db.count_values(conn, "asn"), "fqdn_ips": db.count_values(conn, "fqdn")}
|
||||||
|
pending = db.recreated_pending(conn, ("asn", "fqdn"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
last_restore = load_json(cc.RESTORE_FILE, None) # след автовосстановления базы из копии
|
last_restore = load_json(cc.RESTORE_FILE, None) # след автовосстановления базы из копии
|
||||||
except StorageError:
|
except StorageError:
|
||||||
last_restore = None
|
last_restore = None
|
||||||
|
|
||||||
healthy = alive and not any(j.get("last_error") for j in jobs.values())
|
try:
|
||||||
|
recreated = load_json(cc.RECREATED_FILE, None) # база пересоздана после порчи без копий
|
||||||
|
except StorageError:
|
||||||
|
recreated = None
|
||||||
|
|
||||||
|
healthy = alive and not pending and not any(j.get("last_error") for j in jobs.values())
|
||||||
return {
|
return {
|
||||||
"status": "ok" if healthy else "degraded",
|
"status": "ok" if healthy else "degraded",
|
||||||
"collector_alive": alive,
|
"collector_alive": alive,
|
||||||
@@ -230,6 +250,8 @@ def health():
|
|||||||
"jobs": jobs,
|
"jobs": jobs,
|
||||||
"counts": counts,
|
"counts": counts,
|
||||||
"last_restore": last_restore,
|
"last_restore": last_restore,
|
||||||
|
# Только время и признак ожидания (пути карантина наружу не отдаём)
|
||||||
|
"db_recreated": {"at": recreated.get("at"), "pending": pending} if recreated else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ COLLECT_REQUEST_FILE = os.path.join(DATA_DIR, "collect_request.json") # API ->
|
|||||||
# Резервные копии базы (задание backup в демоне); по умолчанию на том же томе, каталог можно вынести
|
# Резервные копии базы (задание backup в демоне); по умолчанию на том же томе, каталог можно вынести
|
||||||
BACKUP_DIR = os.environ.get("RIPE_BACKUP_DIR", os.path.join(DATA_DIR, "backups"))
|
BACKUP_DIR = os.environ.get("RIPE_BACKUP_DIR", os.path.join(DATA_DIR, "backups"))
|
||||||
RESTORE_FILE = os.path.join(DATA_DIR, "last_restore.json") # след автовосстановления базы (читает /health)
|
RESTORE_FILE = os.path.join(DATA_DIR, "last_restore.json") # след автовосстановления базы (читает /health)
|
||||||
|
RECREATED_FILE = os.path.join(DATA_DIR, "db_recreated.json") # база пересоздана после порчи без копий (см. db.recreated_pending)
|
||||||
BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json"
|
BASE_URL = "https://stat.ripe.net/data/announced-prefixes/data.json"
|
||||||
|
|
||||||
DEFAULT_TTL_DAYS = 90
|
DEFAULT_TTL_DAYS = 90
|
||||||
@@ -163,6 +164,8 @@ class CIDRCollector:
|
|||||||
logger.info("Expired %d prefixes of unconfigured ASNs", 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))
|
db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS))
|
||||||
logger.info("CIDR data saved to %s", DB_FILE)
|
logger.info("CIDR data saved to %s", DB_FILE)
|
||||||
|
with db.session() as conn:
|
||||||
|
db.settle_recreated(conn)
|
||||||
|
|
||||||
|
|
||||||
class FQDNCollector:
|
class FQDNCollector:
|
||||||
@@ -225,6 +228,8 @@ class FQDNCollector:
|
|||||||
logger.info("Expired %d IPs of unconfigured FQDNs", 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))
|
db.prune_changes(conn, now, self.config.get("changes_retention_days", DEFAULT_CHANGES_RETENTION_DAYS))
|
||||||
logger.info("FQDN data saved to %s", DB_FILE)
|
logger.info("FQDN data saved to %s", DB_FILE)
|
||||||
|
with db.session() as conn:
|
||||||
|
db.settle_recreated(conn)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ def _recover(path, error, cc):
|
|||||||
"""Порча базы при открытии: карантин и восстановление из последней исправной копии.
|
"""Порча базы при открытии: карантин и восстановление из последней исправной копии.
|
||||||
|
|
||||||
API и демон могут обнаружить порчу одновременно, поэтому всё выполняется под блокировкой,
|
API и демон могут обнаружить порчу одновременно, поэтому всё выполняется под блокировкой,
|
||||||
а внутри неё база проверяется повторно (другой процесс мог уже восстановить). Нет копий - StorageError.
|
а внутри неё база проверяется повторно (другой процесс мог уже восстановить или пересоздать).
|
||||||
|
Нет исправных копий - создаётся новая пустая база и ставится метка db_recreated.json: пока данные
|
||||||
|
не собраны заново, API не отдаёт пустой список за настоящий (см. recreated_pending).
|
||||||
"""
|
"""
|
||||||
with file_lock(path + ".restore"):
|
with file_lock(path + ".restore"):
|
||||||
try:
|
try:
|
||||||
@@ -109,16 +111,22 @@ def _recover(path, error, cc):
|
|||||||
save_json_atomic(cc.RESTORE_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"),
|
save_json_atomic(cc.RESTORE_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||||
"backup": candidate, "quarantine": quarantine, "error": str(error)})
|
"backup": candidate, "quarantine": quarantine, "error": str(error)})
|
||||||
return conn
|
return conn
|
||||||
raise StorageError(f"{path} is corrupted and no valid backup was found") from error
|
logger.error("No valid backup for %s: a new empty database is created; the API withholds "
|
||||||
|
"addresses until the collector gathers data again (remove %s to override)", path, cc.RECREATED_FILE)
|
||||||
|
# Метка ставится до создания базы: сбой между шагами не оставит пустую базу без метки
|
||||||
def _restore_backup(candidate, path, cc):
|
save_json_atomic(cc.RECREATED_FILE, {"at": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||||
"""Кладёт копию на место базы и сбрасывает журнал изменений (его номера после точки копии уже выдавались)."""
|
"quarantine": quarantine, "error": str(error)})
|
||||||
tmp = path + ".restore.tmp"
|
|
||||||
shutil.copyfile(candidate, tmp)
|
|
||||||
os.replace(tmp, path)
|
|
||||||
conn = _open(path, cc)
|
conn = _open(path, cc)
|
||||||
try:
|
try:
|
||||||
|
_reset_journal(conn)
|
||||||
|
except BaseException:
|
||||||
|
conn.close()
|
||||||
|
raise
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_journal(conn):
|
||||||
|
"""Очищает журнал и сдвигает счётчик: курсоры, выданные до потери или отката базы, станут недействительными."""
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
try:
|
try:
|
||||||
row = conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'changes'").fetchone()
|
row = conn.execute("SELECT seq FROM sqlite_sequence WHERE name = 'changes'").fetchone()
|
||||||
@@ -135,12 +143,49 @@ def _restore_backup(candidate, path, cc):
|
|||||||
conn.execute("ROLLBACK")
|
conn.execute("ROLLBACK")
|
||||||
raise
|
raise
|
||||||
conn.execute("COMMIT")
|
conn.execute("COMMIT")
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_backup(candidate, path, cc):
|
||||||
|
"""Кладёт копию на место базы и сбрасывает журнал изменений (его номера после точки копии уже выдавались)."""
|
||||||
|
tmp = path + ".restore.tmp"
|
||||||
|
shutil.copyfile(candidate, tmp)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
conn = _open(path, cc)
|
||||||
|
try:
|
||||||
|
_reset_journal(conn)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise
|
raise
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def recreated_pending(conn, kinds):
|
||||||
|
"""True, если базу пересоздали после порчи без копий и по запрошенным типам данные ещё не собраны заново.
|
||||||
|
|
||||||
|
Тип считается неготовым, когда для него в config.json есть источники, а в базе нет ни одного значения;
|
||||||
|
тип без источников готов (пустой список законен). Нечитаемый конфиг - блокировка сохраняется.
|
||||||
|
"""
|
||||||
|
import cidr_collector as cc
|
||||||
|
|
||||||
|
if not os.path.exists(cc.RECREATED_FILE):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
config = cc.load_full_config()
|
||||||
|
except StorageError:
|
||||||
|
return True
|
||||||
|
configured = {"asn": config.get("asns"), "fqdn": config.get("fqdns")}
|
||||||
|
return any(configured[kind] and count_values(conn, kind) == 0 for kind in kinds)
|
||||||
|
|
||||||
|
|
||||||
|
def settle_recreated(conn):
|
||||||
|
"""Снимает метку пересоздания, когда данные по всем типам собраны заново. Вызывается после сбора."""
|
||||||
|
import cidr_collector as cc
|
||||||
|
|
||||||
|
if os.path.exists(cc.RECREATED_FILE) and not recreated_pending(conn, ("asn", "fqdn")):
|
||||||
|
os.remove(cc.RECREATED_FILE)
|
||||||
|
logger.info("Data gathered again after the database loss; %s removed", cc.RECREATED_FILE)
|
||||||
|
|
||||||
|
|
||||||
def backup_database(conn, now, keep, backup_dir=None):
|
def backup_database(conn, now, keep, backup_dir=None):
|
||||||
"""Онлайн-копия базы с проверкой и ротацией. Возвращает путь копии.
|
"""Онлайн-копия базы с проверкой и ротацией. Возвращает путь копии.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# План: защита от пустой выдачи после потери базы (находка 1 ревью)
|
||||||
|
|
||||||
|
Источник: `docs/review-2026-09-21.md`, находка 1 (высокая); риск 3 анализа.
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
Если `ripe.db` испорчена и исправной копии нет, первый запрос получает 503, а затем создаётся новая пустая база, и `/addresses` отвечает `200 []`. Потребитель (роутер, файрвол), забирающий список по расписанию, может принять пустой список за истину и стереть свои правила. `/health` при живом демоне показывает `ok` при `counts = 0`.
|
||||||
|
|
||||||
|
## Дизайн
|
||||||
|
- **Признак потери.** Когда база пересоздана из-за порчи без копий, `db._recover` пишет файл-метку `db_recreated.json` в `DATA_DIR` (время, путь карантина, текст ошибки). Метка ставится только в этом случае: чистая установка (базы ещё не было) и восстановление из копии её не создают.
|
||||||
|
- **Сразу рабочее соединение.** `_recover` больше не бросает `StorageError` в этом случае, а создаёт новую базу и возвращает соединение (демон продолжает сбор, `/health` и записи работают). Журнал изменений новой базы сдвигается так же, как при восстановлении из копии (`RESTORE_JOURNAL_JUMP`): курсоры старой базы дают `410`. Общий код сброса журнала выносится из `_restore_backup`.
|
||||||
|
- **Что блокируется.** Пока данные не собраны заново, `GET /addresses` и `GET /addresses/diff` отвечают `503` с заголовком `Retry-After` и пояснением; остальные эндпоинты работают. Проверка идёт по запрошенным типам:
|
||||||
|
- блокируется тип, для которого в `config.json` есть источники, но в базе по нему нет ни одного значения;
|
||||||
|
- тип без источников (например, нет ни одного FQDN) не блокируется и отдаёт законный пустой список.
|
||||||
|
- **Снятие метки.** В конце каждого запуска сбора (`ASN`, `FQDN`, в том числе через CLI) вызывается `db.settle_recreated`: если ни один сконфигурированный тип не пуст, файл-метка удаляется. Снять блокировку вручную (принять пустую выдачу) можно, удалив `db_recreated.json`.
|
||||||
|
- **`/health`.** Поле `db_recreated`: `null` или `{"at": ..., "pending": true|false}` (без путей: это же закрывает связанную находку 6 для нового поля). Пока блокировка активна, статус `degraded`.
|
||||||
|
- **Устойчивость.** Проверка метки читает только файл и базу; если `config.json` нечитаем, блокировка сохраняется (fail closed).
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
1. `cidr_collector.py`: `RECREATED_FILE`, вызов `db.settle_recreated` в обоих `run_collection`.
|
||||||
|
2. `db.py`: метка и создание новой базы в `_recover`, `_reset_journal` (общий код с `_restore_backup`), `recreated_pending(conn, kinds)`, `settle_recreated(conn)`.
|
||||||
|
3. `api_server.py`: проверка в `/addresses` и `/addresses/diff` (503 + `Retry-After`), поле `db_recreated` и статус в `/health`.
|
||||||
|
4. `README.md`: раздел «Automatic restore» (поведение без копий, ручное снятие метки), `/health`.
|
||||||
|
5. `.gitignore`/`.dockerignore`: `db_recreated.json`.
|
||||||
|
6. Тесты (2 новых, один существующий обновляется; всего 24): БД (порча без копий: соединение рабочее, метка записана, старый курсор `410`; метка снимается при появлении данных и не мешает, если источников нет); API (`/addresses` и `/addresses/diff` дают 503, `/health` `degraded` с `db_recreated`, после появления данных 200 и метка снята; тип без источников не блокируется). Существующая проверка «без копий -> StorageError» в `test_restore_from_backup` заменяется на новое поведение.
|
||||||
|
|
||||||
|
## Не входит
|
||||||
|
- Чистая установка без базы по-прежнему отдаёт пустой список (терять нечего).
|
||||||
|
- **Удаление файла базы вручную не распознаётся** как потеря (порчи нет): пустая база создаётся без метки. Возможное продолжение: при отсутствии базы и наличии копий восстанавливаться из них.
|
||||||
|
- Порча внутри файла, обнаруженная при чтении, по-прежнему даёт 503 и восстанавливается вручную.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
Тесты в контейнере; вручную в отдельных процессах: порча `ripe.db` без копий при работающих API и демоне -> `/addresses` 503, `/health` `degraded`, после сбора 200 с данными и метка удалена; повтор в Docker Compose (общий том).
|
||||||
|
|
||||||
|
## Откат
|
||||||
|
Убрать проверку в API и запись метки в `_recover` (возврат к предыдущему коммиту): схема базы не меняется, файл `db_recreated.json` можно удалить.
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| # | Серьёзность | Находка | Где | Статус |
|
| # | Серьёзность | Находка | Где | Статус |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | **Высокая** | **Пустой список после порчи базы без копий (подтверждено запуском).** Первый запрос даёт 503, затем создаётся новая пустая база, и `/addresses` отвечает `200 []`. Если демон жив, `/health` показывает `ok` при `counts = 0`. Потребители, забирающие список по расписанию, могут стереть свои списки. | `db.py:83-112` | Ждёт решения |
|
| 1 | **Высокая** | **Пустой список после порчи базы без копий (подтверждено запуском).** Первый запрос даёт 503, затем создаётся новая пустая база, и `/addresses` отвечает `200 []`. Если демон жив, `/health` показывает `ok` при `counts = 0`. Потребители, забирающие список по расписанию, могут стереть свои списки. | `db.py:83-112` | Исправлено (`summary-loss-guard.md`) |
|
||||||
| 2 | Средняя | **500 вместо 401 при нелатинском `X-API-Key` (подтверждено).** `secrets.compare_digest` для `str` работает только с ASCII. Обхода авторизации нет. | `api_server.py:105` | Исправлено (`summary-input-hardening.md`) |
|
| 2 | Средняя | **500 вместо 401 при нелатинском `X-API-Key` (подтверждено).** `secrets.compare_digest` для `str` работает только с ASCII. Обхода авторизации нет. | `api_server.py:105` | Исправлено (`summary-input-hardening.md`) |
|
||||||
| 3 | Средняя | **500 при некорректном `since` (подтверждено):** `since=²` (`str.isdigit()` истинно для символов юникода, а `int()` их не принимает), число длиннее 4300 цифр (лимит `int`), крайние даты с поясом (`0001-01-01T00:00:00+05:00`, `9999-12-31T23:59:59-05:00`: `OverflowError`). | `api_server.py:152` | Исправлено (`summary-input-hardening.md`) |
|
| 3 | Средняя | **500 при некорректном `since` (подтверждено):** `since=²` (`str.isdigit()` истинно для символов юникода, а `int()` их не принимает), число длиннее 4300 цифр (лимит `int`), крайние даты с поясом (`0001-01-01T00:00:00+05:00`, `9999-12-31T23:59:59-05:00`: `OverflowError`). | `api_server.py:152` | Исправлено (`summary-input-hardening.md`) |
|
||||||
| 4 | Средняя | **DNS-адреса без фильтрации (подтверждено).** В списки попадают любые ответы: `127.0.0.1`, `10.x`, `0.0.0.0`. Оставлять нужно только глобальные адреса. | `cidr_collector.py:176-179` | Исправлено (`summary-input-hardening.md`) |
|
| 4 | Средняя | **DNS-адреса без фильтрации (подтверждено).** В списки попадают любые ответы: `127.0.0.1`, `10.x`, `0.0.0.0`. Оставлять нужно только глобальные адреса. | `cidr_collector.py:176-179` | Исправлено (`summary-input-hardening.md`) |
|
||||||
@@ -44,5 +44,5 @@
|
|||||||
|
|
||||||
## Рекомендуемый порядок
|
## Рекомендуемый порядок
|
||||||
1. Доработка «Безопасность ввода и данных»: п. 2, 3, 4 и тесты (`plan-input-hardening.md`).
|
1. Доработка «Безопасность ввода и данных»: п. 2, 3, 4 и тесты (`plan-input-hardening.md`).
|
||||||
2. Отдельное решение по п. 1: после порчи без копий отвечать 503 до первого успешного сбора или запретить пустую выдачу при только что созданной базе.
|
2. Находка 1: после порчи без копий отвечать 503 до первого успешного сбора (`plan-loss-guard.md`, выполнено).
|
||||||
3. Пункты 5-10 объединить с доработкой наблюдаемости (п. 2 плана из анализа).
|
3. Пункты 5-10 объединить с доработкой наблюдаемости (п. 2 плана из анализа).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Итоги: защита от пустой выдачи после потери базы (находка 1 ревью)
|
||||||
|
|
||||||
|
План: `docs/plan-loss-guard.md`. Источник: `docs/review-2026-09-21.md`.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- **Метка потери** (`db._recover`): при порче базы без исправных копий пишется `db_recreated.json` (до создания базы) и создаётся новая пустая база; соединение сразу рабочее (раньше первый запрос получал 503, следующие - `200 []`). Журнал изменений новой базы сдвигается общим `_reset_journal` (вынесен из `_restore_backup`): курсоры старой базы дают `410`.
|
||||||
|
- **Блокировка выдачи:** `GET /addresses` и `GET /addresses/diff` отвечают `503` с `Retry-After: 300`, пока по запрошенным типам данные не собраны заново. Тип блокируется, если для него в `config.json` есть источники, а в базе нет значений; тип без источников не блокируется. Нечитаемый конфиг - блокировка сохраняется.
|
||||||
|
- **Снятие метки:** `db.settle_recreated` в конце каждого запуска сбора (ASN, FQDN, CLI); ручное снятие - удалить `db_recreated.json`.
|
||||||
|
- **`/health`:** поле `db_recreated` (`at`, `pending`, без путей); при `pending` статус `degraded`.
|
||||||
|
- **Прочее:** `db_recreated.json` в `.gitignore`/`.dockerignore`, README (раздел о восстановлении, `/health`), находка 1 в отчёте ревью отмечена исправленной.
|
||||||
|
- **Тесты:** 2 новых (метка, курсор, снятие; API: 503 на обоих эндпоинтах, тип без источников, `/health`, возобновление) и 1 обновлён. Всего 24, в контейнере 24 passed. Добавлен `tests/conftest.py`: автоматическая изоляция файлов состояния всех тестов во временный каталог (без неё один из тестов оставлял метку в каталоге проекта, и следующий тест получал 503).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
- Отдельные процессы (демон и API, реальный DNS): после сбора `example.com` выдача есть; порча `ripe.db` без копий -> `/addresses` и `/addresses/diff` 503, `Retry-After: 300`, `/health` `degraded` и `db_recreated: {pending: true}`, запрос `type=cidr` (ASN не настроены) -> `[]` 200; повторный `POST /collect` -> 200 с данными, `/health` `ok`, `db_recreated.json` удалён.
|
||||||
|
- Docker Compose (отдельный проект, порт 18000, стенд убран): тот же сценарий в томе, оба сервиса `healthy`.
|
||||||
|
- В первом ручном прогоне сбор не запустился из-за ошибки в моём скрипте (заголовки не разделились по словам, 401); прогон повторён.
|
||||||
|
|
||||||
|
## Замечания
|
||||||
|
- Пока RIPE или DNS недоступны после потери, выдача остаётся 503: так задумано (лучше 503, чем пустой список); обход - удалить `db_recreated.json`.
|
||||||
|
- **Не распознаётся как потеря:** удаление файла базы вручную (порчи нет, пустая база создаётся без метки) и чистая установка. Возможное продолжение: при отсутствии базы и наличии копий восстанавливаться из них.
|
||||||
|
- Тип с источниками, для которого сбор вообще ничего не находит (например, все адреса FQDN отфильтрованы как неглобальные), останется в ожидании; при необходимости - удалить метку вручную.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolated_state(tmp_path, monkeypatch):
|
||||||
|
"""Файлы состояния всех тестов - во временном каталоге: ни один тест не трогает каталог проекта и не видит чужие метки."""
|
||||||
|
for attr, name in (("CONFIG_FILE", "config.json"), ("DB_FILE", "ripe.db"), ("DATA_FILE", "data.json"),
|
||||||
|
("FQDN_DATA_FILE", "fqdn_data.json"), ("STATUS_FILE", "status.json"),
|
||||||
|
("COLLECT_REQUEST_FILE", "collect_request.json"), ("RESTORE_FILE", "last_restore.json"),
|
||||||
|
("RECREATED_FILE", "db_recreated.json"), ("BACKUP_DIR", "backups")):
|
||||||
|
monkeypatch.setattr(cc, attr, str(tmp_path / name))
|
||||||
+4
-4
@@ -56,12 +56,12 @@ def test_corrupted_storage_is_preserved(files):
|
|||||||
assert not path.exists()
|
assert not path.exists()
|
||||||
assert len(list(files.glob("data.json.corrupt-*"))) == 1
|
assert len(list(files.glob("data.json.corrupt-*"))) == 1
|
||||||
|
|
||||||
# Битая база: убирается в сторону, ошибка отдаётся как StorageError
|
# Битая база без копий: оригинал убран в сторону, создана новая пустая база и поставлена метка пересоздания
|
||||||
(files / "ripe.db").write_bytes(b"this is not a sqlite database" * 100)
|
(files / "ripe.db").write_bytes(b"this is not a sqlite database" * 100)
|
||||||
with pytest.raises(StorageError):
|
with db.session() as conn:
|
||||||
db.connect()
|
assert db.get_values(conn) == []
|
||||||
assert not (files / "ripe.db").exists()
|
|
||||||
assert len(list(files.glob("ripe.db.corrupt-*"))) == 1
|
assert len(list(files.glob("ripe.db.corrupt-*"))) == 1
|
||||||
|
assert (files / "db_recreated.json").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_post_schedule_auth(files, monkeypatch):
|
def test_post_schedule_auth(files, monkeypatch):
|
||||||
|
|||||||
+23
-3
@@ -19,6 +19,8 @@ def files(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(cc, attr, str(tmp_path / name))
|
monkeypatch.setattr(cc, attr, str(tmp_path / name))
|
||||||
monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups"))
|
monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups"))
|
||||||
monkeypatch.setattr(cc, "RESTORE_FILE", str(tmp_path / "last_restore.json"))
|
monkeypatch.setattr(cc, "RESTORE_FILE", str(tmp_path / "last_restore.json"))
|
||||||
|
monkeypatch.setattr(cc, "RECREATED_FILE", str(tmp_path / "db_recreated.json"))
|
||||||
|
monkeypatch.setattr(cc, "CONFIG_FILE", str(tmp_path / "config.json"))
|
||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
@@ -121,9 +123,27 @@ def test_restore_from_backup(files):
|
|||||||
assert len(glob.glob(str(files / "ripe.db.corrupt-*"))) == 1
|
assert len(glob.glob(str(files / "ripe.db.corrupt-*"))) == 1
|
||||||
assert json.loads((files / "last_restore.json").read_text())["backup"].endswith(".db")
|
assert json.loads((files / "last_restore.json").read_text())["backup"].endswith(".db")
|
||||||
|
|
||||||
# Без исправных копий - прежнее поведение: ошибка хранилища, база в карантине
|
# Без исправных копий создаётся новая пустая база (с меткой, см. test_recreated_database_guard)
|
||||||
for backup in db.list_backups(cc.BACKUP_DIR):
|
for backup in db.list_backups(cc.BACKUP_DIR):
|
||||||
os.unlink(backup)
|
os.unlink(backup)
|
||||||
(files / "ripe.db").write_bytes(b"garbage" * 1000)
|
(files / "ripe.db").write_bytes(b"garbage" * 1000)
|
||||||
with pytest.raises(db.StorageError):
|
with db.session() as conn:
|
||||||
db.connect()
|
assert db.get_values(conn) == []
|
||||||
|
assert os.path.exists(cc.RECREATED_FILE) and glob.glob(str(files / "ripe.db.corrupt-*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_recreated_database_guard(files):
|
||||||
|
(files / "config.json").write_text('{"asns": [1], "fqdns": []}')
|
||||||
|
(files / "ripe.db").write_bytes(b"garbage" * 1000) # порча, копий нет
|
||||||
|
|
||||||
|
with db.session() as conn:
|
||||||
|
assert os.path.exists(cc.RECREATED_FILE)
|
||||||
|
# ASN настроен, данных нет - ожидание; FQDN не настроен - пустой список законен
|
||||||
|
assert db.recreated_pending(conn, ("asn",)) and not db.recreated_pending(conn, ("fqdn",))
|
||||||
|
assert db.get_changes(conn, {"asn"}, cursor=5) is None # курсор старой базы недействителен
|
||||||
|
|
||||||
|
# Данные собраны заново - сборщик снимает метку
|
||||||
|
with db.transaction(conn):
|
||||||
|
db.merge_source(conn, "asn", "1", {"a"}, datetime.datetime.now(), 90)
|
||||||
|
db.settle_recreated(conn)
|
||||||
|
assert not os.path.exists(cc.RECREATED_FILE)
|
||||||
@@ -21,6 +21,9 @@ def env(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(cc, "DATA_FILE", str(tmp_path / "data.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, "FQDN_DATA_FILE", str(tmp_path / "fqdn_data.json"))
|
||||||
monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db"))
|
monkeypatch.setattr(cc, "DB_FILE", str(tmp_path / "ripe.db"))
|
||||||
|
monkeypatch.setattr(cc, "RECREATED_FILE", str(tmp_path / "db_recreated.json"))
|
||||||
|
monkeypatch.setattr(cc, "RESTORE_FILE", str(tmp_path / "last_restore.json"))
|
||||||
|
monkeypatch.setattr(cc, "BACKUP_DIR", str(tmp_path / "backups"))
|
||||||
monkeypatch.setenv("RIPE_API_TOKEN", "secret")
|
monkeypatch.setenv("RIPE_API_TOKEN", "secret")
|
||||||
save_json_atomic(cc.CONFIG_FILE, {"asns": [], "fqdns": [], "ttl_days": 90})
|
save_json_atomic(cc.CONFIG_FILE, {"asns": [], "fqdns": [], "ttl_days": 90})
|
||||||
return TestClient(api_server.app)
|
return TestClient(api_server.app)
|
||||||
@@ -106,3 +109,26 @@ def test_input_hardening(env):
|
|||||||
assert env.get("/addresses/diff", params={"since": bad}).status_code == 400, bad
|
assert env.get("/addresses/diff", params={"since": bad}).status_code == 400, bad
|
||||||
# Корректный, но выходящий за журнал курсор - 410, а не 400
|
# Корректный, но выходящий за журнал курсор - 410, а не 400
|
||||||
assert env.get("/addresses/diff", params={"since": "9" * 30}).status_code == 410
|
assert env.get("/addresses/diff", params={"since": "9" * 30}).status_code == 410
|
||||||
|
|
||||||
|
|
||||||
|
def test_recreated_database_is_withheld(env):
|
||||||
|
cc.add_to_config_list("asns", 62041)
|
||||||
|
with open(cc.DB_FILE, "wb") as f: # порча базы, копий нет
|
||||||
|
f.write(b"garbage" * 1000)
|
||||||
|
|
||||||
|
# Пока данные не собраны заново, пустой список не отдаётся (503 + Retry-After)
|
||||||
|
for url in ("/addresses", "/addresses/diff?since=0"):
|
||||||
|
response = env.get(url)
|
||||||
|
assert response.status_code == 503 and response.headers["Retry-After"], url
|
||||||
|
assert env.get("/addresses", params={"type": "fqdn"}).json() == [] # тип без источников не блокируется
|
||||||
|
health = env.get("/health").json()
|
||||||
|
assert health["status"] == "degraded" and health["db_recreated"]["pending"] is True
|
||||||
|
assert "quarantine" not in health["db_recreated"] # пути наружу не отдаются
|
||||||
|
|
||||||
|
# Сборщик собрал данные: выдача возобновляется, метка снята
|
||||||
|
with db.session() as conn:
|
||||||
|
with db.transaction(conn):
|
||||||
|
db.merge_source(conn, "asn", "62041", {"1.0.0.0/24"}, datetime.datetime.now(), 90)
|
||||||
|
db.settle_recreated(conn)
|
||||||
|
assert env.get("/addresses").json() == ["1.0.0.0/24"]
|
||||||
|
assert env.get("/health").json()["db_recreated"] is None
|
||||||
Reference in new issue
Block a user