Задачи 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:
1 parent
cd09ef0805
commit
13e17fbb47
57 files changed
+1748
-166
No files matched your search
+72
-4
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user