Задачи 011-024: доработки по ревью кодовой базы и исправление находок

Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
    с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
    ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
    отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
    сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
    запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.

Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-26 21:33:50 +03:00
1 parent cd09ef0805
commit 13e17fbb47
57 files changed
+1748 -166

No files matched your search

+72 -4
View File
@@ -1,23 +1,91 @@
"""Вход в UI. Перебор паролей ограничен: {MAX_PER_LOGIN} неудач на логин и {MAX_PER_IP} на IP за {WINDOW_MIN} минут → 429 (изменение 012)."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import User
from app.models import LoginAttempt, User
from app.request_context import request_meta
from app.schemas import LoginIn, TokenOut, UserOut
from app.security import create_token, current_user, verify_password
from app.security import create_token, current_user, hash_password, verify_password
from app.services import ANONYMOUS, audit
router = APIRouter(prefix="/auth", tags=["auth"])
WINDOW = timedelta(minutes=10)
MAX_PER_LOGIN = 5
MAX_PER_IP = 20
__doc__ = __doc__.format(MAX_PER_LOGIN=MAX_PER_LOGIN, MAX_PER_IP=MAX_PER_IP, WINDOW_MIN=int(WINDOW.total_seconds() // 60))
_DUMMY_HASH = hash_password("dummy-password-for-timing") # выравнивает время ответа для несуществующего логина
def _condition(login: str, ip: str | None, scope: str):
return (LoginAttempt.username == login) if scope == "login" else (LoginAttempt.client_ip == ip)
def _retry_after(db: Session, login: str, ip: str | None) -> tuple[int, str | None, dict[str, int]]:
"""(секунд до конца блокировки, причина 'login'|'ip', {"login": n, "ip": m} — число неудач в каждой области, до лимита).
Блокировка длится окно после последней неудачи. Счётчики по областям отдельно (изменение 024, находка №4):
решение о записи в журнал принимается по конкретному логину, а не по максимуму среди логина и IP."""
now = datetime.now(timezone.utc)
worst, why, counts = 0, None, {}
for scope, limit in (("login", MAX_PER_LOGIN), ("ip", MAX_PER_IP)):
if scope == "ip" and ip is None:
continue
rows = db.scalars(select(LoginAttempt.ts).where(_condition(login, ip, scope), LoginAttempt.ts > now - WINDOW).order_by(LoginAttempt.ts.desc()).limit(limit)).all()
counts[scope] = len(rows)
if len(rows) >= limit:
left = int((rows[0] + WINDOW - now).total_seconds()) + 1
if left > worst:
worst, why = left, scope
return worst, why, counts
def _distinct_logins(db: Session, ip: str) -> int:
"""Число различных логинов, для которых была неудачная попытка с этого IP в окне (для diff записи session.locked)."""
now = datetime.now(timezone.utc)
return db.scalar(select(func.count(func.distinct(LoginAttempt.username))).where(LoginAttempt.client_ip == ip, LoginAttempt.ts > now - WINDOW)) or 0
def _locked(retry: int) -> HTTPException:
return HTTPException(429, {"message": f"Слишком много неудачных попыток входа. Повторите через {max(1, -(-retry // 60))} мин.", "retry_after_seconds": retry},
headers={"Retry-After": str(retry)})
@router.post("/login", response_model=TokenOut)
def login(body: LoginIn, db: Session = Depends(get_db)):
ctx = request_meta.get()
ip = ctx["client_ip"] if ctx else None
name = body.username.strip().lower()
# сериализация попыток одного логина (изменение 024, находка №1): без этого параллельные запросы
# проходят проверку блокировки одновременно, и лимит «N за окно» превращается в «N + степень параллелизма»
db.execute(select(func.pg_advisory_xact_lock(func.hashtext(name))))
retry, _, _ = _retry_after(db, name, ip)
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
raise _locked(retry)
user = db.scalar(select(User).where(User.username == body.username, User.is_active))
if user is None:
verify_password(body.password, _DUMMY_HASH)
if user is None or not verify_password(body.password, user.password_hash):
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
_, _, before = _retry_after(db, name, ip)
db.add(LoginAttempt(client_ip=ip, username=name))
db.flush()
retry, why, after = _retry_after(db, name, ip)
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
attempts = after[why]
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
if why == "ip":
diff["distinct_logins"] = _distinct_logins(db, ip)
audit(db, ANONYMOUS, "session", None, "locked", body.username[:100], diff,
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({'по логину' if why == 'login' else 'с IP'})")
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
db.commit()
if retry:
raise _locked(retry)
raise HTTPException(401, "Неверный логин или пароль")
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}")
db.commit()
return TokenOut(access_token=create_token(user))
+9 -4
View File
@@ -13,15 +13,14 @@ from app.db import get_db
from app.models import AuditLog, ClearAttempt, User
from app.rotation import get_settings, rotate, save_settings
from app.security import admin_user, current_user, verify_password
from app.services import audit, commit
from app.services import MAX_OFFSET, audit, commit, like_escape
router = APIRouter(dependencies=[Depends(current_user)], tags=["journal"])
MAX_ATTEMPTS = 5
LOCK_MINUTES = 10
def _escape_like(q: str) -> str:
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
_escape_like = like_escape
def _filtered(stmt, event_type: str, entity_type: str, actor: str, date_from: date | None, date_to: date | None, q: str, client_ip: str = ""):
@@ -57,7 +56,7 @@ def _filtered(stmt, event_type: str, entity_type: str, actor: str, date_from: da
@router.get("/audit", response_model=s.Page[s.AuditOut])
def list_audit(
event_type: str = "", entity_type: str = "", actor: str = "", date_from: date | None = None, date_to: date | None = None,
q: str = "", client_ip: str = "", limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db),
q: str = "", client_ip: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
):
stmt = _filtered(select(AuditLog), event_type, entity_type, actor, date_from, date_to, q, client_ip)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
@@ -153,13 +152,19 @@ def _lock_state(db: Session, user_id: int) -> tuple[int, int]:
def clear_journal(body: ClearIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
used, retry = _lock_state(db, user.id)
if retry:
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: попытка во время блокировки")
db.commit()
raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry})
if not verify_password(body.password, user.password_hash):
db.add(ClearAttempt(user_id=user.id))
db.commit()
used, retry = _lock_state(db, user.id)
if retry:
audit(db, user, "journal", None, "clear_locked", "clear", {"retry_after_seconds": retry}, message="Очистка журнала: неверный пароль, доступ заблокирован")
db.commit()
raise HTTPException(429, {"message": "Слишком много неверных попыток", "retry_after_seconds": retry})
audit(db, user, "journal", None, "clear_failed", "clear", {"attempts_left": MAX_ATTEMPTS - used}, message="Очистка журнала: неверный пароль")
db.commit()
raise HTTPException(403, {"message": "Неверный пароль", "attempts_left": MAX_ATTEMPTS - used})
deleted = db.execute(delete(AuditLog)).rowcount
db.execute(delete(ClearAttempt).where(ClearAttempt.user_id == user.id))
+116 -43
View File
@@ -1,7 +1,7 @@
import ipaddress
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import String, and_, cast, func, or_, select, update
from sqlalchemy import String, and_, cast, func, or_, select, text, update
from sqlalchemy.orm import Session
from app import schemas as s
@@ -9,7 +9,7 @@ from app.db import get_db
from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf
from app.security import admin_user, current_user
from app.services import (
MAX_CAPACITY, apply_update, audit, blockers, capacity, commit, count, flush, get_or_404, next_free, next_free_subnet, refuse_delete,
MAX_CAPACITY, MAX_OFFSET, apply_update, audit, blockers, capacity, commit, count, flush, contains, free_page, get_or_404, network_role, next_free_address, next_free_subnet, refuse_delete,
utilization,
)
@@ -72,6 +72,7 @@ def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]:
usage = _usage(db, [r.id for r in rows])
depths: dict[int, int] = {}
caps: dict[int, int] = {}
vrf_names = dict(db.execute(select(Vrf.id, Vrf.name).where(Vrf.id.in_(list({r.vrf_id for r in rows})))).all()) if rows else {}
for org_id in {r.organization_id for r in rows}:
depths.update(_depths(db, org_id))
caps.update(_capacities(db, org_id))
@@ -80,7 +81,7 @@ def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]:
used, stored = usage.get(r.id, (0, 0))
cap = caps.get(r.id, capacity(str(r.prefix)))
out.append(s.PrefixOut(
id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=r.vrf.name,
id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=vrf_names[r.vrf_id],
prefix=str(r.prefix), family=ipaddress.ip_network(str(r.prefix)).version,
description=r.description, status=r.status, parent_id=r.parent_id, depth=depths.get(r.id, 0),
is_pool=r.is_pool, note=r.note, used=used, capacity=cap,
@@ -111,6 +112,40 @@ def attach_to_tree(db: Session, p: Prefix, keep_parent: bool = False, exclude: f
c.parent_id = p.id
def _narrowest_prefix(db: Session, vrf_id: int, ip: str) -> Prefix | None:
"""Самый узкий префикс VRF, содержащий адрес."""
return db.scalar(select(Prefix).where(Prefix.vrf_id == vrf_id, Prefix.prefix.op(">>=")(ip)).order_by(func.masklen(Prefix.prefix).desc()).limit(1))
def _unusable_after_rehome(db: Session, p: Prefix) -> list[tuple[str, str]]:
"""(адрес, CIDR целевого префикса) для адресов VRF префикса p (его диапазон), которые при переносе в самый узкий
целевой префикс (как в rehome_addresses) окажутся его адресом сети или broadcast — такой перенос запрещён
(изменение 024, находка №2). Целевой префикс — не обязательно p: при переносе VRF им может оказаться уже существующий
вложенный префикс целевого VRF, поэтому в паре возвращается именно он, а не p."""
db.flush()
rows = db.execute(text(
"SELECT host(a2.address) AS ip, (SELECT x.prefix FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address "
" ORDER BY masklen(x.prefix) DESC LIMIT 1) AS cidr "
"FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).all()
return [(ip, cidr) for ip, cidr in rows if network_role(ipaddress.ip_network(cidr), ipaddress.ip_address(ip))]
def _unusable_message(bad: list[tuple[str, str]]) -> str:
items = ", ".join(f"{ip} ({cidr})" for ip, cidr in bad)
return f"Адреса {items} станут адресом сети/broadcast: освободите их или выберите другой префикс"
def rehome_addresses(db: Session, p: Prefix) -> int:
"""Приводит адреса диапазона префикса к правилу «адрес — в самом узком префиксе VRF»
(новый вложенный забирает адреса родителя из своего диапазона; перенесённый префикс — адреса целевого VRF). Возвращает число перенесённых."""
db.flush()
return db.execute(text(
"UPDATE addresses a SET prefix_id = t.best FROM ("
" SELECT a2.id AS aid, (SELECT x.id FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address ORDER BY masklen(x.prefix) DESC LIMIT 1) AS best "
" FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr) t "
"WHERE a.id = t.aid AND t.best IS NOT NULL AND t.best <> a.prefix_id"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).rowcount
def _subtree(db: Session, root: Prefix) -> list[Prefix]:
"""Префикс и все вложенные по цепочке parent_id."""
result, frontier = [root], [root.id]
@@ -130,12 +165,25 @@ def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
taken = db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == vrf.id, Prefix.prefix.in_([str(m.prefix) for m in subtree]))).all()
if taken:
raise HTTPException(409, f"В VRF «{vrf.name}» уже есть: {', '.join(str(x) for x in taken)}")
clash = db.scalars(
select(func.host(Address.address)).where(
Address.prefix_id.in_([m.id for m in subtree]),
Address.address.in_(select(Address.address).where(Address.vrf_id == vrf.id)),
).limit(10)
).all()
if clash:
raise HTTPException(409, f"В VRF «{vrf.name}» уже назначены адреса: {', '.join(clash)}")
old_name = p.vrf.name
for m in subtree:
m.vrf = vrf
p.parent_id = None
db.flush()
db.expire_all() # vrf_id адресов обновлён каскадом БД
attach_to_tree(db, p, exclude=frozenset(m.id for m in subtree))
bad = _unusable_after_rehome(db, p)
if bad: # commit ещё не выполнялся — исключение уходит без частичных изменений (rollback при закрытии сессии)
raise HTTPException(422, _unusable_message(bad))
rehome_addresses(db, p)
return {"vrf": f"{old_name} → {vrf.name}", "moved": len(subtree)}, len(subtree)
@@ -143,7 +191,7 @@ def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
def list_prefixes(
organization_id: int | None = None, vrf_id: int | None = None, status: PrefixStatus | None = None,
family: int | None = Query(None, ge=4, le=6), q: str = "",
limit: int = Query(100, le=1000), offset: int = 0, db: Session = Depends(get_db),
limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
):
stmt = select(Prefix)
if organization_id:
@@ -155,7 +203,7 @@ def list_prefixes(
if family:
stmt = stmt.where(func.family(Prefix.prefix) == (4 if family == 4 else 6))
if q:
stmt = stmt.where(or_(cast(Prefix.prefix, String).ilike(f"%{q.strip()}%"), Prefix.description.ilike(f"%{q.strip()}%")))
stmt = stmt.where(or_(contains(cast(Prefix.prefix, String), q), contains(Prefix.description, q)))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(Prefix.vrf_id, Prefix.prefix).limit(limit).offset(offset)).all()
return s.Page(items=_prefix_outs(db, list(rows)), total=total)
@@ -184,22 +232,34 @@ def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User =
db.add(p)
flush(db, "Такой префикс уже есть в этом VRF")
attach_to_tree(db, p, keep_parent=parent_id is not None)
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name})
bad = _unusable_after_rehome(db, p)
if bad:
msg = _unusable_message(bad) # до rollback: после него объект p истекает
db.rollback()
raise HTTPException(422, msg)
moved = rehome_addresses(db, p)
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})})
commit(db, "Такой префикс уже есть в этом VRF")
return _prefix_outs(db, [p])[0]
def _busy_ranges(db: Session, p: Prefix) -> list[tuple[int, int]]:
"""Занятые диапазоны внутри префикса: вложенные префиксы того же VRF (любой глубины) и адреса, записанные в самом префиксе."""
busy = []
for c in db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(str(p.prefix)))):
n = ipaddress.ip_network(str(c))
busy.append((int(n.network_address), int(n.broadcast_address)))
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == p.id))]
return busy
def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None, int, int]:
"""(свободный блок | None, min длина, max длина) для вложенного префикса в parent."""
net = ipaddress.ip_network(str(parent.prefix))
lo, hi = net.prefixlen + 1, net.max_prefixlen
if not lo <= length <= hi:
raise HTTPException(422, f"Размер вложенного префикса: от /{lo} до /{hi}" if lo <= hi else "Префикс нельзя дробить: это одиночный адрес")
busy = [(int(n.network_address), int(n.broadcast_address)) for n in
(ipaddress.ip_network(str(c)) for c in db.scalars(
select(Prefix.prefix).where(Prefix.vrf_id == parent.vrf_id, Prefix.id != parent.id, Prefix.prefix.op("<<")(str(parent.prefix)))))]
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == parent.id))]
return next_free_subnet(str(parent.prefix), length, busy), lo, hi
return next_free_subnet(str(parent.prefix), length, _busy_ranges(db, parent)), lo, hi
@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview)
@@ -223,7 +283,8 @@ def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db)
db.add(p)
flush(db, "Такой префикс уже есть в этом VRF, повторите запрос")
attach_to_tree(db, p, keep_parent=True)
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix)})
moved = rehome_addresses(db, p) # адреса родителя из выделенного блока (учтены и при выборе блока, но вдруг появились параллельно)
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})})
commit(db, "Такой префикс уже есть в этом VRF, повторите запрос")
return _prefix_outs(db, [p])[0]
@@ -248,7 +309,8 @@ def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), u
if used:
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used})
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
audit(db, user, "prefix", p, "deleted", str(p.prefix))
gone = count(db, select(Address.id).where(Address.prefix_id == id)) if force else 0
audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None)
db.delete(p)
commit(db)
@@ -271,10 +333,11 @@ def _check_device(db: Session, prefix: Prefix, device_id: int | None):
@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage)
def list_addresses(
id: int, status: str = "", q: str = "", limit: int = Query(100, le=500), offset: int = 0,
id: int, status: str = "", q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
db: Session = Depends(get_db),
):
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей)."""
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети)."""
p = get_or_404(db, Prefix, id, "Префикс")
cap = capacity(str(p.prefix))
counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all())
@@ -285,39 +348,47 @@ def list_addresses(
)
if status and status not in {"free", *(x.value for x in AddressStatus)}:
raise HTTPException(422, "Неизвестный статус")
stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(Address.prefix_id == id)
if status and status != "free":
stmt = stmt.where(Address.status == AddressStatus(status))
if q:
like = f"%{q.strip()}%"
stmt = stmt.where(or_(func.host(Address.address).ilike(like), Address.dns_name.ilike(like), Address.description.ilike(like)))
rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()] if status != "free" else []
net = ipaddress.ip_network(str(p.prefix))
want_free = status == "free" or (not status and not q and cap <= FREE_LISTING_LIMIT)
if want_free:
used = {ipaddress.ip_address(s.ip_text(a)) for a in db.scalars(select(Address.address).where(Address.prefix_id == id))}
hosts = net.hosts() if net.version == 4 and net.prefixlen <= 30 else iter(net)
need = offset + limit if status == "free" else cap
free = []
for ip in hosts:
if ip not in used:
free.append(s.AddressOut(id=None, prefix_id=id, address=str(ip), status="free"))
if len(free) >= need:
break
rows = sorted(rows + free, key=lambda r: ipaddress.ip_address(r.address))
total = summary.free if status == "free" else (len(rows) if want_free or q or status else stored)
return s.AddressPage(items=rows[offset:offset + limit], total=total, summary=summary)
def occupied() -> list[int]:
return sorted(int(ipaddress.ip_address(s.ip_text(a))) for a in db.scalars(select(Address.address).where(Address.prefix_id == id)))
def free_rows(ips: list[str]) -> list[s.AddressOut]:
return [s.AddressOut(id=None, prefix_id=id, address=ip, status="free") for ip in ips]
if status == "free":
return s.AddressPage(items=free_rows(free_page(net, occupied(), offset, limit)), total=summary.free, summary=summary)
flt = [Address.prefix_id == id]
if status:
flt.append(Address.status == AddressStatus(status))
if q:
flt.append(or_(contains(func.host(Address.address), q), contains(Address.dns_name, q), contains(Address.description, q)))
stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(*flt)
mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети)
if mixed:
rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()]
rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address))
return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary)
total = count(db, select(Address.id).where(*flt))
page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all()
return s.AddressPage(items=[_addr_out(a, dn) for a, dn in page], total=total, summary=summary)
@router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201)
def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
p = get_or_404(db, Prefix, id, "Префикс")
if ipaddress.ip_address(body.address) not in ipaddress.ip_network(str(p.prefix)):
net, ip = ipaddress.ip_network(str(p.prefix)), ipaddress.ip_address(body.address)
if ip not in net:
raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}")
role = network_role(net, ip)
if role:
raise HTTPException(422, f"Адрес {body.address} — {'адрес сети' if role == 'network' else 'broadcast'} префикса {p.prefix}, назначить его нельзя")
narrowest = _narrowest_prefix(db, p.vrf_id, body.address)
if narrowest is not None and narrowest.id != p.id: # адрес хранится в самом узком префиксе VRF
raise HTTPException(422, f"Адрес {body.address} принадлежит вложенному префиксу {narrowest.prefix}, назначьте его там")
_check_device(db, p, body.device_id)
a = Address(prefix_id=id, **body.model_dump())
a = Address(prefix_id=id, vrf_id=p.vrf_id, **body.model_dump())
db.add(a)
flush(db, "Адрес уже есть в этом префиксе")
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address)
@@ -331,15 +402,17 @@ def allocate_next(
id: int, body: s.AddressUpdate | None = None, db: Session = Depends(get_db), user: User = Depends(admin_user)
):
"""Автоназначение первого свободного адреса; только для префиксов с флагом is_pool."""
p = get_or_404(db, Prefix, id, "Префикс")
p = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # параллельные запросы получают разные адреса
if p is None:
raise HTTPException(404, "Префикс не найден")
if not p.is_pool:
raise HTTPException(422, "Префикс не является пулом для автоназначения")
ip = next_free(db, id, str(p.prefix))
ip = next_free_address(ipaddress.ip_network(str(p.prefix)), _busy_ranges(db, p)) # вложенные префиксы и адрес сети/broadcast пропускаются
if ip is None:
raise HTTPException(409, "В префиксе нет свободных адресов")
data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {}
_check_device(db, p, data.get("device_id"))
a = Address(prefix_id=id, address=ip, **data)
a = Address(prefix_id=id, vrf_id=p.vrf_id, address=ip, **data)
db.add(a)
flush(db, "Адрес уже занят, повторите запрос")
audit(db, user, "address", a, "assigned", ip)
+91 -48
View File
@@ -1,7 +1,7 @@
"""Справочники: организации, VRF, операторы, типы устройств, устройства."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import String, cast, delete, func, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from app import schemas as s
from app.db import get_db
@@ -9,34 +9,41 @@ from app.models import (
Address, AddressStatus, Device, DeviceType, Isp, IspNetwork, Organization, Prefix, User, Vrf,
)
from app.security import admin_user, current_user
from app.services import apply_update, audit, blockers, commit, count, flush, get_or_404, refuse_delete
from app.services import MAX_OFFSET, apply_update, audit, blockers, commit, contains, count, flush, get_or_404, refuse_delete
from fastapi import HTTPException
router = APIRouter(dependencies=[Depends(current_user)])
def _like(q: str) -> str:
return f"%{q.strip()}%"
# ---------------------------------------------------------------- organizations
def _org_out(db: Session, o: Organization) -> s.OrgOut:
out = s.OrgOut.model_validate(o)
out.prefixes_count = count(db, select(Prefix.id).where(Prefix.organization_id == o.id))
out.addresses_count = count(
db, select(Address.id).join(Prefix).where(Prefix.organization_id == o.id, Address.status == AddressStatus.assigned)
)
def _org_outs(db: Session, rows: list[Organization]) -> list[s.OrgOut]:
"""Счётчики одним GROUP BY на страницу (без запросов на каждую строку)."""
ids = [o.id for o in rows]
prefixes = dict(db.execute(select(Prefix.organization_id, func.count()).where(Prefix.organization_id.in_(ids)).group_by(Prefix.organization_id)).all()) if ids else {}
addresses = dict(db.execute(
select(Prefix.organization_id, func.count(Address.id)).join(Prefix, Prefix.id == Address.prefix_id)
.where(Prefix.organization_id.in_(ids), Address.status == AddressStatus.assigned).group_by(Prefix.organization_id)
).all()) if ids else {}
out = []
for o in rows:
item = s.OrgOut.model_validate(o)
item.prefixes_count, item.addresses_count = prefixes.get(o.id, 0), addresses.get(o.id, 0)
out.append(item)
return out
def _org_out(db: Session, o: Organization) -> s.OrgOut:
return _org_outs(db, [o])[0]
@router.get("/organizations", response_model=s.Page[s.OrgOut], tags=["organizations"])
def list_orgs(q: str = "", limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db)):
def list_orgs(q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db)):
stmt = select(Organization)
if q:
stmt = stmt.where(or_(*(c.ilike(_like(q)) for c in (Organization.name, Organization.short_name, Organization.inn, Organization.address))))
stmt = stmt.where(or_(*(contains(c, q) for c in (Organization.name, Organization.short_name, Organization.inn, Organization.address))))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(Organization.id).limit(limit).offset(offset)).all()
return s.Page(items=[_org_out(db, o) for o in rows], total=total)
return s.Page(items=_org_outs(db, list(rows)), total=total)
@router.get("/organizations/{id}", response_model=s.OrgOut, tags=["organizations"])
@@ -82,19 +89,28 @@ def delete_org(id: int, db: Session = Depends(get_db), user: User = Depends(admi
# ------------------------------------------------------------------------- VRF
def _vrf_out(db: Session, v: Vrf) -> s.VrfOut:
out = s.VrfOut.model_validate(v)
out.prefixes_count = count(db, select(Prefix.id).where(Prefix.vrf_id == v.id))
def _vrf_outs(db: Session, rows: list[Vrf]) -> list[s.VrfOut]:
ids = [v.id for v in rows]
counts = dict(db.execute(select(Prefix.vrf_id, func.count()).where(Prefix.vrf_id.in_(ids)).group_by(Prefix.vrf_id)).all()) if ids else {}
out = []
for v in rows:
item = s.VrfOut.model_validate(v)
item.prefixes_count = counts.get(v.id, 0)
out.append(item)
return out
def _vrf_out(db: Session, v: Vrf) -> s.VrfOut:
return _vrf_outs(db, [v])[0]
@router.get("/vrfs", response_model=s.Page[s.VrfOut], tags=["vrf"])
def list_vrfs(organization_id: int | None = None, db: Session = Depends(get_db)):
stmt = select(Vrf)
if organization_id:
stmt = stmt.where(Vrf.organization_id == organization_id)
rows = db.scalars(stmt.order_by(Vrf.id)).all()
return s.Page(items=[_vrf_out(db, v) for v in rows], total=len(rows))
return s.Page(items=_vrf_outs(db, list(rows)), total=len(rows))
@router.post("/vrfs", response_model=s.VrfOut, status_code=201, tags=["vrf"])
@@ -129,16 +145,25 @@ def delete_vrf(id: int, db: Session = Depends(get_db), user: User = Depends(admi
# ---------------------------------------------------------------- device types
def _type_out(db: Session, t: DeviceType) -> s.DeviceTypeOut:
out = s.DeviceTypeOut.model_validate(t)
out.devices_count = count(db, select(Device.id).where(Device.device_type_id == t.id))
def _type_outs(db: Session, rows: list[DeviceType]) -> list[s.DeviceTypeOut]:
ids = [t.id for t in rows]
counts = dict(db.execute(select(Device.device_type_id, func.count()).where(Device.device_type_id.in_(ids)).group_by(Device.device_type_id)).all()) if ids else {}
out = []
for t in rows:
item = s.DeviceTypeOut.model_validate(t)
item.devices_count = counts.get(t.id, 0)
out.append(item)
return out
def _type_out(db: Session, t: DeviceType) -> s.DeviceTypeOut:
return _type_outs(db, [t])[0]
@router.get("/device-types", response_model=s.Page[s.DeviceTypeOut], tags=["devices"])
def list_types(db: Session = Depends(get_db)):
rows = db.scalars(select(DeviceType).order_by(DeviceType.id)).all()
return s.Page(items=[_type_out(db, t) for t in rows], total=len(rows))
return s.Page(items=_type_outs(db, list(rows)), total=len(rows))
@router.post("/device-types", response_model=s.DeviceTypeOut, status_code=201, tags=["devices"])
@@ -174,23 +199,36 @@ def delete_type(id: int, db: Session = Depends(get_db), user: User = Depends(adm
# --------------------------------------------------------------------- devices
def _device_outs(db: Session, devices: list[Device]) -> list[s.DeviceOut]:
"""Адреса и названия типов — двумя запросами на страницу."""
ids = [d.id for d in devices]
by_device: dict[int, list] = {}
if ids:
for dev_id, addr, prefix_id, status in db.execute(
select(Address.device_id, Address.address, Address.prefix_id, Address.status).where(Address.device_id.in_(ids)).order_by(Address.address)
):
by_device.setdefault(dev_id, []).append((addr, prefix_id, status))
type_names = dict(db.execute(select(DeviceType.id, DeviceType.name)).all())
out = []
for d in devices:
rows = by_device.get(d.id, [])
out.append(s.DeviceOut(
id=d.id, name=d.name, device_type_id=d.device_type_id, device_type_name=type_names[d.device_type_id],
organization_id=d.organization_id, mac=d.mac, note=d.note,
ip_addresses=[s.ip_text(r[0]) for r in rows], first_prefix_id=rows[0][1] if rows else None,
all_deprecated=bool(rows) and all(r[2] == AddressStatus.deprecated for r in rows),
))
return out
def _device_out(db: Session, d: Device) -> s.DeviceOut:
rows = db.execute(
select(Address.address, Address.prefix_id, Address.status).where(Address.device_id == d.id).order_by(Address.address)
).all()
t = db.get(DeviceType, d.device_type_id)
return s.DeviceOut(
id=d.id, name=d.name, device_type_id=d.device_type_id, device_type_name=t.name,
organization_id=d.organization_id, mac=d.mac, note=d.note,
ip_addresses=[s.ip_text(r[0]) for r in rows], first_prefix_id=rows[0][1] if rows else None,
all_deprecated=bool(rows) and all(r[2] == AddressStatus.deprecated for r in rows),
)
return _device_outs(db, [d])[0]
@router.get("/devices", response_model=s.Page[s.DeviceOut], tags=["devices"])
def list_devices(
organization_id: int | None = None, device_type_id: int | None = None, q: str = "",
limit: int = Query(100, le=500), offset: int = 0, db: Session = Depends(get_db),
limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
):
stmt = select(Device)
if organization_id:
@@ -198,11 +236,11 @@ def list_devices(
if device_type_id:
stmt = stmt.where(Device.device_type_id == device_type_id)
if q:
ip_match = select(Address.device_id).where(func.host(Address.address).ilike(_like(q)))
stmt = stmt.where(or_(Device.name.ilike(_like(q)), Device.note.ilike(_like(q)), Device.id.in_(ip_match)))
ip_match = select(Address.device_id).where(contains(func.host(Address.address), q))
stmt = stmt.where(or_(contains(Device.name, q), contains(Device.note, q), Device.id.in_(ip_match)))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(Device.id).limit(limit).offset(offset)).all()
return s.Page(items=[_device_out(db, d) for d in rows], total=total)
return s.Page(items=_device_outs(db, list(rows)), total=total)
@router.post("/devices", response_model=s.DeviceOut, status_code=201, tags=["devices"])
@@ -243,30 +281,35 @@ def delete_device(id: int, db: Session = Depends(get_db), user: User = Depends(a
# ------------------------------------------------------------------------ ISPs
def _isp_out(db: Session, i: Isp) -> s.IspOut:
org = db.get(Organization, i.organization_id)
return s.IspOut(
id=i.id, name=i.name, organization_id=i.organization_id, organization_name=org.name,
def _isp_outs(db: Session, rows: list[Isp]) -> list[s.IspOut]:
ids = {i.organization_id for i in rows}
names = dict(db.execute(select(Organization.id, Organization.name).where(Organization.id.in_(ids))).all()) if ids else {}
return [s.IspOut(
id=i.id, name=i.name, organization_id=i.organization_id, organization_name=names[i.organization_id],
networks=[str(n.cidr) for n in i.networks], hotline=i.hotline,
contract_number=i.contract_number, note=i.note,
)
) for i in rows]
def _isp_out(db: Session, i: Isp) -> s.IspOut:
return _isp_outs(db, [i])[0]
@router.get("/isps", response_model=s.Page[s.IspOut], tags=["isps"])
def list_isps(
organization_id: int | None = None, q: str = "", limit: int = Query(100, le=500), offset: int = 0,
organization_id: int | None = None, q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
db: Session = Depends(get_db),
):
stmt = select(Isp)
if organization_id:
stmt = stmt.where(Isp.organization_id == organization_id)
if q:
nets = select(IspNetwork.isp_id).where(cast(IspNetwork.cidr, String).ilike(_like(q)))
orgs = select(Organization.id).where(Organization.name.ilike(_like(q)))
stmt = stmt.where(or_(Isp.name.ilike(_like(q)), Isp.id.in_(nets), Isp.organization_id.in_(orgs)))
nets = select(IspNetwork.isp_id).where(contains(cast(IspNetwork.cidr, String), q))
orgs = select(Organization.id).where(contains(Organization.name, q))
stmt = stmt.where(or_(contains(Isp.name, q), Isp.id.in_(nets), Isp.organization_id.in_(orgs)))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(Isp.id).limit(limit).offset(offset)).all()
return s.Page(items=[_isp_out(db, i) for i in rows], total=total)
rows = db.scalars(stmt.options(selectinload(Isp.networks)).order_by(Isp.id).limit(limit).offset(offset)).all()
return s.Page(items=_isp_outs(db, list(rows)), total=total)
@router.post("/isps", response_model=s.IspOut, status_code=201, tags=["isps"])
+22 -6
View File
@@ -4,6 +4,8 @@
свою учётную запись и последнего активного администратора; зарезервированные логины `system`
и `anonymous` запрещены — журнал различает по ним служебные события (`actor_of` в app/services.py).
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -11,12 +13,19 @@ from sqlalchemy.orm import Session
from app import schemas as s
from app.db import get_db
from app.models import Role, User
from app.security import admin_user, current_user, hash_password, verify_password
from app.services import apply_update, audit, commit, count, flush, get_or_404, refuse_delete
from app.security import admin_user, create_token, current_user, hash_password, verify_password
from app.services import MAX_OFFSET, apply_update, audit, commit, contains, count, flush, get_or_404, refuse_delete
router = APIRouter(dependencies=[Depends(current_user)], tags=["users"])
USERS_ADMIN_LOCK = 703002 # advisory lock: изменения прав администраторов идут по одному (иначе двое отключат друг друга одновременно)
def _lock_admins(db: Session) -> None:
db.execute(select(func.pg_advisory_xact_lock(USERS_ADMIN_LOCK)))
def _other_active_admins(db: Session, user_id: int) -> int:
"""Активные администраторы, кроме указанного: 0 — система осталась бы без прав записи."""
return count(db, select(User.id).where(User.role == Role.admin, User.is_active, User.id != user_id))
@@ -24,12 +33,12 @@ def _other_active_admins(db: Session, user_id: int) -> int:
@router.get("/users", response_model=s.Page[s.UserOut])
def list_users(
q: str = "", limit: int = Query(100, le=500), offset: int = 0,
q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
db: Session = Depends(get_db), admin: User = Depends(admin_user),
):
stmt = select(User)
if q.strip():
stmt = stmt.where(User.username.ilike(f"%{q.strip()}%"))
stmt = stmt.where(contains(User.username, q))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(User.username).limit(limit).offset(offset)).all()
return s.Page(items=[s.UserOut.model_validate(u) for u in rows], total=total)
@@ -50,8 +59,12 @@ def create_user(body: s.UserIn, db: Session = Depends(get_db), admin: User = Dep
@router.patch("/users/{id}", response_model=s.UserOut)
def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
u = get_or_404(db, User, id, "Пользователь")
data = body.model_dump(exclude_unset=True, exclude_none=True)
if "role" in data or "is_active" in data:
_lock_admins(db) # до чтения пользователя и подсчёта администраторов
u = get_or_404(db, User, id, "Пользователь")
if u.id == admin.id and "password" in data:
raise HTTPException(422, "Свой пароль меняется через /users/me/password (с подтверждением текущего)")
pwd = data.pop("password", None)
role, is_active = data.get("role", u.role), data.get("is_active", u.is_active)
loses_admin = u.role == Role.admin and u.is_active and (role != Role.admin or not is_active)
@@ -62,6 +75,7 @@ def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admi
changed = apply_update(u, data)
if pwd:
u.password_hash = hash_password(pwd)
u.password_changed_at = datetime.now(timezone.utc)
if changed:
audit(db, admin, "user", u, "updated", u.username, changed)
if pwd:
@@ -72,6 +86,7 @@ def update_user(id: int, body: s.UserUpdate, db: Session = Depends(get_db), admi
@router.delete("/users/{id}", status_code=204)
def delete_user(id: int, db: Session = Depends(get_db), admin: User = Depends(admin_user)):
_lock_admins(db)
u = get_or_404(db, User, id, "Пользователь")
if u.id == admin.id:
refuse_delete(db, admin, "user", u, u.username, "Нельзя удалить свою учётную запись")
@@ -88,6 +103,7 @@ def change_own_password(body: s.PasswordChange, db: Session = Depends(get_db), u
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(403, "Неверный текущий пароль")
user.password_hash = hash_password(body.new_password)
user.password_changed_at = datetime.now(timezone.utc)
audit(db, user, "user", user, "password_reset", user.username, message=f"{user.username}: пароль изменён пользователем")
commit(db)
return {"ok": True}
return {"ok": True, "access_token": create_token(user)} # прежние токены недействительны — текущая сессия получает новый
+11 -1
View File
@@ -5,7 +5,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+psycopg://ipam:ipam@localhost:55432/ipam"
jwt_secret: str = "dev-only-secret"
jwt_secret: str = "" # обязателен: проверяется при старте приложения (validate_secrets), миграциям не нужен
jwt_ttl_minutes: int = 480
admin_username: str = "admin"
admin_password: str = ""
@@ -13,3 +13,13 @@ class Settings(BaseSettings):
settings = Settings()
_WEAK_SECRETS = {"change-me", "changeme", "dev-only-secret", "secret", "password"}
def validate_secrets(s: Settings = settings) -> None:
"""Fail-fast при старте: подпись токенов известным/коротким ключом позволяет подделать токен любого пользователя."""
if len(s.jwt_secret) < 32 or s.jwt_secret.lower() in _WEAK_SECRETS:
raise RuntimeError("JWT_SECRET не задан, короче 32 символов или является заглушкой: сгенерируйте значение (python scripts/gen_env.py)")
if s.admin_password and len(s.admin_password) < 8:
raise RuntimeError("ADMIN_PASSWORD короче 8 символов: пароль слабее, чем допускает UI")
+37 -3
View File
@@ -1,4 +1,5 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
@@ -10,20 +11,26 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.v1 import auth, journal, overview, prefixes, refs, users
from app.config import settings
from app.config import settings, validate_secrets
from app.db import SessionLocal
from app.models import DeviceType, Role, User
from app.request_context import RequestContextMiddleware
from app.rotation import rotation_loop
from app.security import hash_password
validate_secrets() # приложение не стартует с небезопасной конфигурацией (изменение 017)
log = logging.getLogger("ipam")
DEFAULT_TYPES = ["Сервер", "Сетевое оборудование", "Сетевое хранилище", "Рабочая станция", "Другое"]
def seed():
with SessionLocal() as db:
if not db.scalar(select(User.id).limit(1)) and settings.admin_password:
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin))
if not db.scalar(select(User.id).limit(1)):
if settings.admin_password:
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin))
else:
log.warning("В БД нет пользователей и ADMIN_PASSWORD не задан: администратор не создан, войти в UI нельзя")
if not db.scalar(select(DeviceType.id).limit(1)):
db.add_all(DeviceType(name=n, is_default=(n == "Другое")) for n in DEFAULT_TYPES)
db.commit()
@@ -37,8 +44,35 @@ async def lifespan(_: FastAPI):
task.cancel()
CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'"
DOCS_PATHS = ("/docs", "/redoc", "/openapi.json") # Swagger UI грузит ресурсы с CDN — CSP для него не ставим
class SecurityHeadersMiddleware:
"""ASGI-middleware: заголовки безопасности на все ответы (изменение 023)."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
docs = scope["path"].startswith(DOCS_PATHS)
async def send_with_headers(message):
if message["type"] == "http.response.start":
extra = [(b"x-content-type-options", b"nosniff"), (b"referrer-policy", b"no-referrer")]
if not docs:
extra += [(b"content-security-policy", CSP.encode()), (b"x-frame-options", b"DENY")]
message["headers"] = [*message.get("headers", []), *extra]
await send(message)
await self.app(scope, receive, send_with_headers)
app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan)
app.add_middleware(RequestContextMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
api = APIRouter(prefix="/api/v1")
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router, users.router):
+24 -3
View File
@@ -62,6 +62,7 @@ class Prefix(Base):
__tablename__ = "prefixes"
__table_args__ = (
UniqueConstraint("vrf_id", "prefix"),
UniqueConstraint("id", "vrf_id", name="uq_prefixes_id_vrf_id"), # цель составного FK адресов
ForeignKeyConstraint(["vrf_id", "organization_id"], ["vrfs.id", "vrfs.organization_id"], name="fk_prefixes_vrf_org"),
)
id: Mapped[int] = mapped_column(primary_key=True)
@@ -116,9 +117,15 @@ class Device(Base):
class Address(Base):
__tablename__ = "addresses"
__table_args__ = (UniqueConstraint("prefix_id", "address"),)
# vrf_id дублирует VRF префикса (составной FK, обновляется каскадом при переносе): так БД гарантирует уникальность IP в VRF
__table_args__ = (
UniqueConstraint("prefix_id", "address"),
UniqueConstraint("vrf_id", "address", name="uq_addresses_vrf_address"),
ForeignKeyConstraint(["prefix_id", "vrf_id"], ["prefixes.id", "prefixes.vrf_id"], name="fk_addresses_prefix_vrf", ondelete="CASCADE", onupdate="CASCADE"),
)
id: Mapped[int] = mapped_column(primary_key=True)
prefix_id: Mapped[int] = mapped_column(ForeignKey("prefixes.id", ondelete="CASCADE"), index=True)
prefix_id: Mapped[int] = mapped_column(index=True)
vrf_id: Mapped[int] = mapped_column()
address: Mapped[str] = mapped_column(INET)
status: Mapped[AddressStatus] = mapped_column(Enum(AddressStatus, name="address_status"), default=AddressStatus.assigned)
dns_name: Mapped[str] = mapped_column(String(255), default="")
@@ -133,8 +140,12 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[Role] = mapped_column(Enum(Role, name="user_role"), default=Role.admin)
role: Mapped[Role] = mapped_column(Enum(Role, name="user_role"), default=Role.viewer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны
Index("uq_users_lower_username", func.lower(User.username), unique=True) # логин уникален без учёта регистра
class AuditLog(Base):
@@ -161,6 +172,16 @@ class AppSetting(Base):
value: Mapped[dict] = mapped_column(JSONB)
class LoginAttempt(Base):
"""Неудачные попытки входа (для ограничения перебора паролей); записи старше суток удаляет ротация."""
__tablename__ = "login_attempts"
id: Mapped[int] = mapped_column(primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
client_ip: Mapped[str | None] = mapped_column(INET)
username: Mapped[str] = mapped_column(String(100)) # в нижнем регистре
__table_args__ = (Index("ix_login_attempts_ip_ts", "client_ip", "ts"), Index("ix_login_attempts_user_ts", "username", "ts"))
class ClearAttempt(Base):
"""Неудачные попытки подтверждения пароля при очистке журнала (для блокировки)."""
__tablename__ = "clear_attempts"
+7 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import AppSetting, AuditLog
from app.models import AppSetting, AuditLog, LoginAttempt
from app.services import SYSTEM, audit
log = logging.getLogger("ipam.rotation")
@@ -44,7 +44,12 @@ def rotate(db: Session) -> dict | None:
if total > cfg["max_entries"]:
# удаляем с запасом в одну запись — под сводную запись о ротации, чтобы итог не превышал лимит
n = total - cfg["max_entries"] + 1
by_count = db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(n)))).rowcount
# сначала неудачные и заблокированные входы (их может нагенерировать кто угодно), затем самые старые записи остальных типов (изменение 024, находка №4)
noisy = db.execute(delete(AuditLog).where(AuditLog.id.in_(
select(AuditLog.id).where(AuditLog.entity_type == "session", AuditLog.action.in_(("failed", "locked"))).order_by(AuditLog.id).limit(n)))).rowcount
rest = n - noisy
by_count = noisy + (db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(rest)))).rowcount if rest > 0 else 0)
db.execute(delete(LoginAttempt).where(LoginAttempt.ts < datetime.now(timezone.utc) - timedelta(days=1)))
if by_age or by_count:
parts = []
if by_age:
+44 -17
View File
@@ -30,6 +30,22 @@ _FQDN = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9_]([A-Za-z0-9_-]{0,61}[A-Za-z0-9_]
_MAC = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$")
def _device_name(v: str | None) -> str | None:
"""Имя устройства (hostname/FQDN); общая для DeviceIn и DeviceUpdate (изменение 024, находка №6). None — поле не задано в PATCH."""
if v is not None and not _FQDN.match(v):
raise ValueError("Некорректное имя устройства (hostname)")
return v
def _device_mac(v: str | None) -> str | None:
"""MAC: формат и нормализация к AA:BB:CC:DD:EE:FF; общая для DeviceIn и DeviceUpdate. Пустая строка допустима (mac не указан)."""
if v is None:
return v
if v and not _MAC.match(v):
raise ValueError("Некорректный MAC-адрес (AA:BB:CC:DD:EE:FF)")
return v.upper().replace("-", ":")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
@@ -41,8 +57,8 @@ class ORM(BaseModel):
# --- auth
class LoginIn(BaseModel):
username: str
password: str
username: str = Field(max_length=100)
password: str = Field(max_length=128)
class TokenOut(BaseModel):
@@ -76,7 +92,7 @@ class UserOut(ORM):
class UserIn(BaseModel):
username: Login
password: str = Field(min_length=8, max_length=128)
role: Role = Role.admin
role: Role = Role.viewer # наименьшие права по умолчанию (изменение 016)
is_active: bool = True
@@ -129,11 +145,18 @@ class VrfIn(BaseModel):
note: str = ""
def _blank(v):
"""null в PATCH текстового поля = «очистить» (в колонке NOT NULL хранится пустая строка)."""
return "" if v is None else v
class VrfUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100)
route_target: str | None = Field(None, max_length=50, pattern=r"^(\d+:\d+)?$")
note: str | None = None
_blank_text = field_validator("route_target", "note")(_blank)
class VrfOut(ORM):
id: int
@@ -163,19 +186,8 @@ class DeviceIn(BaseModel):
mac: str = ""
note: str = ""
@field_validator("name")
@classmethod
def _name(cls, v):
if not _FQDN.match(v):
raise ValueError("Некорректное имя устройства (hostname)")
return v
@field_validator("mac")
@classmethod
def _mac(cls, v):
if v and not _MAC.match(v):
raise ValueError("Некорректный MAC-адрес (AA:BB:CC:DD:EE:FF)")
return v.upper().replace("-", ":")
_name_valid = field_validator("name")(_device_name)
_mac_valid = field_validator("mac")(_device_mac)
class DeviceUpdate(BaseModel):
@@ -184,6 +196,10 @@ class DeviceUpdate(BaseModel):
mac: str | None = None
note: str | None = None
_blank_text = field_validator("mac", "note")(_blank) # сначала null → "", затем проверка формата
_name_valid = field_validator("name")(_device_name)
_mac_valid = field_validator("mac")(_device_mac)
class DeviceOut(ORM):
id: int
@@ -252,6 +268,8 @@ class PrefixUpdate(BaseModel):
is_pool: bool | None = None
note: str | None = None
_blank_text = field_validator("description", "note")(_blank)
class PrefixOut(ORM):
id: int
@@ -293,9 +311,18 @@ class AddressUpdate(BaseModel):
status: AddressStatus | None = None
dns_name: str | None = None
description: str | None = Field(None, max_length=500)
device_id: int | None = None
device_id: int | None = None # null — отвязать устройство
note: str | None = None
_blank_text = field_validator("dns_name", "description", "note")(_blank)
@field_validator("status")
@classmethod
def _status_required(cls, v):
if v is None:
raise ValueError("Статус нельзя очистить")
return v
class AddressOut(BaseModel):
id: int | None
+19 -4
View File
@@ -27,9 +27,21 @@ def verify_password(password: str, hashed: str) -> bool:
return False
_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
def _password_version(user: User) -> int:
"""Версия пароля в токене: смена пароля меняет её, и выданные ранее токены перестают действовать (без зависимости от точности часов).
Целочисленно (изменение 024, находка №7): без float, хотя результат и был детерминирован."""
changed = user.password_changed_at
return (changed - _EPOCH) // timedelta(microseconds=1) if changed else 0
def create_token(user: User) -> str:
exp = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_ttl_minutes)
return jwt.encode({"sub": user.username, "exp": exp}, settings.jwt_secret, algorithm="HS256")
now = datetime.now(timezone.utc)
exp = now + timedelta(minutes=settings.jwt_ttl_minutes)
# iat — информационное поле (не проверяется при разборе токена); отзыв выданных токенов работает по pv (версия пароля)
return jwt.encode({"sub": user.username, "iat": now, "exp": exp, "pv": _password_version(user)}, settings.jwt_secret, algorithm="HS256")
def current_user(
@@ -38,12 +50,15 @@ def current_user(
if cred is None:
raise HTTPException(401, "Требуется авторизация")
try:
username = jwt.decode(cred.credentials, settings.jwt_secret, algorithms=["HS256"])["sub"]
except jwt.PyJWTError:
payload = jwt.decode(cred.credentials, settings.jwt_secret, algorithms=["HS256"])
username = payload["sub"]
except (jwt.PyJWTError, KeyError):
raise HTTPException(401, "Недействительный токен")
user = db.scalar(select(User).where(User.username == username, User.is_active))
if user is None:
raise HTTPException(401, "Пользователь не найден")
if payload.get("pv", 0) != _password_version(user):
raise HTTPException(401, "Пароль изменён, войдите заново") # токен выдан до смены пароля
return user
+65 -11
View File
@@ -10,6 +10,7 @@ from app.request_context import request_meta
from app.models import Address, AuditLog, Base
MAX_CAPACITY = 2**53 - 1
MAX_OFFSET = 10_000_000 # верхняя граница offset во всех списках
SYSTEM = SimpleNamespace(username="system")
@@ -70,7 +71,10 @@ def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reaso
raise HTTPException(409, reason)
def commit(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"):
CONFLICT_MSG = "Конфликт с существующими данными: проверьте уникальность значений и связанные объекты"
def commit(db: Session, conflict_msg: str = CONFLICT_MSG):
try:
db.commit()
except IntegrityError:
@@ -78,7 +82,7 @@ def commit(db: Session, conflict_msg: str = "Запись с такими зна
raise HTTPException(409, conflict_msg)
def flush(db: Session, conflict_msg: str = "Запись с такими значениями уже существует"):
def flush(db: Session, conflict_msg: str = CONFLICT_MSG):
"""flush с тем же переводом нарушений уникальности в 409, что и commit."""
try:
db.flush()
@@ -103,7 +107,17 @@ def capacity(prefix: str) -> int:
def utilization(used: int, cap: int) -> int:
return round(used * 100 / cap) if cap else 0
return min(round(used * 100 / cap), 100) if cap else 0 # не больше 100 %, даже если в подсети остались адреса сети/broadcast
def network_role(net, ip) -> str | None:
""""network" / "broadcast" для IPv4 с длиной префикса ≤ 30 (как в capacity()), иначе None."""
if net.version == 4 and net.prefixlen <= 30:
if ip == net.network_address:
return "network"
if ip == net.broadcast_address:
return "broadcast"
return None
def apply_update(obj, data: dict) -> dict:
@@ -115,6 +129,15 @@ def apply_update(obj, data: dict) -> dict:
return changed
def like_escape(q: str) -> str:
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def contains(column, q: str):
"""column ILIKE '%q%' с экранированием % и _ (иначе поиск «%» возвращает всё)."""
return column.ilike(f"%{like_escape(q.strip())}%", escape="\\")
def count(db: Session, stmt) -> int:
return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
@@ -137,11 +160,42 @@ def next_free_subnet(parent: str, length: int, occupied: list[tuple[int, int]])
return str(ipaddress.ip_network((type(net.network_address)(cand), length)))
def next_free(db: Session, prefix_id: int, prefix: str) -> str | None:
net = ipaddress.ip_network(prefix)
used = {ipaddress.ip_address(a) for a in db.scalars(select(Address.address).where(Address.prefix_id == prefix_id))}
hosts = net.hosts() if net.version == 4 and net.prefixlen <= 30 else iter(net)
for ip in hosts:
if ip not in used:
return str(ip)
return None
def free_page(net, occupied: list[int], offset: int, limit: int) -> list[str]:
"""Страница свободных адресов сети: `occupied` — отсортированные занятые адреса (int). Пропуск `offset` — арифметикой по промежуткам,
без перебора адресов (безопасно для IPv6). Для IPv4 с длиной ≤ 30 адреса сети и broadcast не считаются."""
first, last = int(net.network_address), int(net.broadcast_address)
if net.version == 4 and net.prefixlen <= 30:
first, last = first + 1, last - 1
cls, out, skip, cur = type(net.network_address), [], offset, first
for ip in [*occupied, last + 1]:
if ip < cur:
continue
gap_end = min(ip - 1, last)
gap = gap_end - cur + 1
if gap > 0:
if skip >= gap:
skip -= gap
else:
start = cur + skip
skip = 0
out.extend(str(cls(a)) for a in range(start, min(gap_end, start + (limit - len(out)) - 1) + 1))
if len(out) >= limit:
break
cur = max(cur, ip + 1)
return out
def next_free_address(net, occupied: list[tuple[int, int]]) -> str | None:
"""Первый свободный адрес сети; `occupied` — занятые диапазоны (включительно), без перебора адресов.
Для IPv4 с длиной ≤ 30 адреса сети и broadcast не выдаются (как в capacity())."""
first, last = int(net.network_address), int(net.broadcast_address)
if net.version == 4 and net.prefixlen <= 30:
first, last = first + 1, last - 1
cand = first
for start, end in sorted(occupied):
if end < cand:
continue
if start > cand:
break
cand = end + 1
return str(type(net.network_address)(cand)) if cand <= last else None