novy prehled prirazovani IP apod.

This commit is contained in:
JiriUhlir
2026-07-30 07:13:12 +02:00
parent 6beca6f79a
commit f67a3fc993
8 changed files with 1230 additions and 16 deletions
+13 -9
View File
@@ -414,11 +414,13 @@ def get_app_ip_access_rules(app_id: str):
rows = con.execute(
"""
SELECT id, app_id, ip_cidr, methods, description,
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
FROM app_ip_access_rules
WHERE app_id = ?
ORDER BY id
SELECT r.id, r.app_id, r.ip_cidr, r.methods, r.description,
COALESCE(r.is_enabled, 1) AS is_enabled, r.created_at, r.updated_at,
r.address_id, a.label AS address_label
FROM app_ip_access_rules r
LEFT JOIN ip_addresses a ON a.id = r.address_id
WHERE r.app_id = ?
ORDER BY r.id
""",
(app_id,),
).fetchall()
@@ -433,10 +435,12 @@ def get_app_ip_access_rule(rule_id: int, app_id: str):
row = con.execute(
"""
SELECT id, app_id, ip_cidr, methods, description,
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
FROM app_ip_access_rules
WHERE id = ? AND app_id = ?
SELECT r.id, r.app_id, r.ip_cidr, r.methods, r.description,
COALESCE(r.is_enabled, 1) AS is_enabled, r.created_at, r.updated_at,
r.address_id, a.label AS address_label
FROM app_ip_access_rules r
LEFT JOIN ip_addresses a ON a.id = r.address_id
WHERE r.id = ? AND r.app_id = ?
""",
(rule_id, app_id),
).fetchone()
+368
View File
@@ -0,0 +1,368 @@
"""Katalog pojmenovaných IP adres a jejich přiřazování ke službám.
Zdrojem pravdy pro vynucování zůstává tabulka ``app_ip_access_rules`` (jeden řádek = jedna
IP/CIDR + metody pro jednu službu) — tu čte generátor Caddyfile. Tabulka ``ip_addresses`` je
nad ní jen pojmenovaný katalog: adresu založím jednou a pak ji přiřazuji do N služeb.
Přiřazený řádek má ``address_id`` na katalog a ``ip_cidr`` zkopírovanou z katalogu, ručně
zadaná pravidla mají ``address_id = NULL`` a fungují dál bez změny.
"""
from app.db.database import get_connection
from app.db.migrations import run_migrations
def get_ip_addresses() -> list[dict]:
"""Katalog adres včetně počtu služeb, do kterých je adresa přiřazená."""
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT
a.id,
a.label,
a.ip_cidr,
a.note,
COALESCE(a.default_methods, 'WRITE') AS default_methods,
COALESCE(a.is_enabled, 1) AS is_enabled,
a.created_at,
a.updated_at,
(
SELECT COUNT(*)
FROM app_ip_access_rules r
WHERE r.address_id = a.id
) AS assigned_count,
(
SELECT COUNT(*)
FROM app_ip_access_rules r
WHERE r.address_id = a.id AND COALESCE(r.is_enabled, 1) = 1
) AS assigned_enabled_count
FROM ip_addresses a
ORDER BY a.label COLLATE NOCASE, a.id
"""
).fetchall()
con.close()
return [dict(row) for row in rows]
def get_ip_address(address_id: int) -> dict | None:
run_migrations()
con = get_connection()
row = con.execute(
"""
SELECT
id,
label,
ip_cidr,
note,
COALESCE(default_methods, 'WRITE') AS default_methods,
COALESCE(is_enabled, 1) AS is_enabled,
created_at,
updated_at
FROM ip_addresses
WHERE id = ?
""",
(address_id,),
).fetchone()
con.close()
return dict(row) if row else None
def find_ip_address_by_cidr(ip_cidr: str) -> dict | None:
run_migrations()
con = get_connection()
row = con.execute(
"""
SELECT id, label, ip_cidr
FROM ip_addresses
WHERE ip_cidr = ?
ORDER BY id
LIMIT 1
""",
(ip_cidr,),
).fetchone()
con.close()
return dict(row) if row else None
def create_ip_address(label: str, ip_cidr: str, note: str, default_methods: str, is_enabled: bool) -> int:
run_migrations()
con = get_connection()
con.execute(
"""
INSERT INTO ip_addresses (label, ip_cidr, note, default_methods, is_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(label, ip_cidr, note, default_methods, 1 if is_enabled else 0),
)
address_id = int(con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
con.commit()
con.close()
return address_id
def update_ip_address(address_id: int, label: str, ip_cidr: str, note: str, default_methods: str, is_enabled: bool) -> int:
"""Uloží adresu a propíše novou IP/CIDR i popis do všech jejích přiřazení.
Vrací počet přiřazených pravidel, kterých se propsání dotklo — kvůli hlášce pro uživatele
(změna adresy se projeví ve všech službách, kde je přiřazená).
"""
run_migrations()
con = get_connection()
con.execute(
"""
UPDATE ip_addresses
SET label = ?,
ip_cidr = ?,
note = ?,
default_methods = ?,
is_enabled = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(label, ip_cidr, note, default_methods, 1 if is_enabled else 0, address_id),
)
# Přiřazení drží kopii IP/CIDR (kvůli generátoru Caddyfile), takže se musí přepsat spolu s
# katalogem. Vypnutá adresa vypne i všechna svá pravidla, zapnutí je nechává na uživateli.
cursor = con.execute(
"""
UPDATE app_ip_access_rules
SET ip_cidr = ?,
description = ?,
is_enabled = CASE WHEN ? = 0 THEN 0 ELSE COALESCE(is_enabled, 1) END,
updated_at = CURRENT_TIMESTAMP
WHERE address_id = ?
""",
(ip_cidr, label, 1 if is_enabled else 0, address_id),
)
affected = cursor.rowcount or 0
con.commit()
con.close()
return affected
def delete_ip_address(address_id: int) -> int:
"""Smaže adresu z katalogu i všechna její přiřazení. Vrací počet smazaných přiřazení."""
run_migrations()
con = get_connection()
cursor = con.execute("DELETE FROM app_ip_access_rules WHERE address_id = ?", (address_id,))
affected = cursor.rowcount or 0
con.execute("DELETE FROM ip_addresses WHERE id = ?", (address_id,))
con.commit()
con.close()
return affected
def get_assignments() -> list[dict]:
"""Všechna přiřazení z katalogu (bez ručních pravidel) napříč službami."""
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT
r.id,
r.app_id,
r.address_id,
r.ip_cidr,
r.methods,
COALESCE(r.is_enabled, 1) AS is_enabled,
a.label,
COALESCE(app.name, r.app_id) AS app_name
FROM app_ip_access_rules r
JOIN ip_addresses a ON a.id = r.address_id
LEFT JOIN apps app ON app.id = r.app_id
ORDER BY a.label COLLATE NOCASE, r.app_id
"""
).fetchall()
con.close()
return [dict(row) for row in rows]
def get_manual_rules() -> list[dict]:
"""Pravidla zadaná ručně v detailu služby (bez vazby na katalog) — kandidáti na import."""
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT
r.id,
r.app_id,
r.ip_cidr,
r.methods,
r.description,
COALESCE(r.is_enabled, 1) AS is_enabled,
COALESCE(app.name, r.app_id) AS app_name
FROM app_ip_access_rules r
LEFT JOIN apps app ON app.id = r.app_id
WHERE r.address_id IS NULL
ORDER BY r.ip_cidr, r.app_id
"""
).fetchall()
con.close()
return [dict(row) for row in rows]
def _sync_assignments(con, pairs: dict[tuple[int, str], tuple[str, bool]], scope_column: str, scope_value) -> dict[str, int]:
"""Srovná přiřazení v jednom rozsahu (jedna adresa, nebo jedna služba) s požadovaným stavem.
``pairs`` mapuje (address_id, app_id) -> (methods, is_enabled) pro přiřazení, která mají po
uložení existovat. Cokoli navázaného na katalog v daném rozsahu a chybějícího v ``pairs`` se
smaže. Vrací počty created/updated/deleted pro hlášku a audit.
"""
existing_rows = con.execute(
f"""
SELECT id, address_id, app_id, methods, COALESCE(is_enabled, 1) AS is_enabled
FROM app_ip_access_rules
WHERE address_id IS NOT NULL AND {scope_column} = ?
""",
(scope_value,),
).fetchall()
existing = {(int(row["address_id"]), row["app_id"]): dict(row) for row in existing_rows}
counts = {"created": 0, "updated": 0, "deleted": 0}
for key, row in existing.items():
if key not in pairs:
con.execute("DELETE FROM app_ip_access_rules WHERE id = ?", (row["id"],))
counts["deleted"] += 1
for (address_id, app_id), (methods, is_enabled) in pairs.items():
address = con.execute(
"SELECT label, ip_cidr, COALESCE(is_enabled, 1) AS is_enabled FROM ip_addresses WHERE id = ?",
(address_id,),
).fetchone()
if not address:
# Adresa mezitím zmizela (paralelní smazání) přiřazení nemá kam ukazovat, přeskoč.
continue
# Vypnutá adresa nesmí vzniknout jako aktivní pravidlo, i kdyby to formulář poslal.
effective_enabled = bool(is_enabled) and bool(address["is_enabled"])
row = existing.get((address_id, app_id))
if row is None:
con.execute(
"""
INSERT INTO app_ip_access_rules (
app_id, address_id, ip_cidr, methods, description, is_enabled, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(app_id, address_id, address["ip_cidr"], methods, address["label"], 1 if effective_enabled else 0),
)
counts["created"] += 1
elif row["methods"] != methods or bool(row["is_enabled"]) != effective_enabled:
con.execute(
"""
UPDATE app_ip_access_rules
SET ip_cidr = ?,
methods = ?,
description = ?,
is_enabled = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(address["ip_cidr"], methods, address["label"], 1 if effective_enabled else 0, row["id"]),
)
counts["updated"] += 1
return counts
def set_address_assignments(address_id: int, assignments: dict[str, tuple[str, bool]]) -> dict[str, int]:
"""Nastaví, do kterých služeb je adresa přiřazená. ``assignments``: app_id -> (methods, enabled)."""
run_migrations()
con = get_connection()
pairs = {(address_id, app_id): value for app_id, value in assignments.items()}
counts = _sync_assignments(con, pairs, "address_id", address_id)
con.commit()
con.close()
return counts
def set_app_assignments(app_id: str, assignments: dict[int, tuple[str, bool]]) -> dict[str, int]:
"""Nastaví, které adresy má služba přiřazené. ``assignments``: address_id -> (methods, enabled)."""
run_migrations()
con = get_connection()
pairs = {(address_id, app_id): value for address_id, value in assignments.items()}
counts = _sync_assignments(con, pairs, "app_id", app_id)
con.commit()
con.close()
return counts
def import_manual_rules(rule_ids: list[int]) -> dict[str, int]:
"""Naváže vybraná ruční pravidla na katalog (adresu podle IP/CIDR najde, nebo založí).
Vrací počty vytvořených adres a navázaných pravidel.
"""
run_migrations()
con = get_connection()
counts = {"addresses_created": 0, "rules_linked": 0}
for rule_id in rule_ids:
rule = con.execute(
"""
SELECT id, ip_cidr, methods, description
FROM app_ip_access_rules
WHERE id = ? AND address_id IS NULL
""",
(rule_id,),
).fetchone()
if not rule:
continue
address = con.execute(
"SELECT id, label FROM ip_addresses WHERE ip_cidr = ? ORDER BY id LIMIT 1",
(rule["ip_cidr"],),
).fetchone()
if address:
address_id = int(address["id"])
label = address["label"]
else:
label = (rule["description"] or "").strip() or rule["ip_cidr"]
con.execute(
"""
INSERT INTO ip_addresses (label, ip_cidr, note, default_methods, is_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(label, rule["ip_cidr"], "Importováno z pravidla v detailu služby.", rule["methods"] or "WRITE"),
)
address_id = int(con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
counts["addresses_created"] += 1
con.execute(
"""
UPDATE app_ip_access_rules
SET address_id = ?,
description = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(address_id, label, rule["id"]),
)
counts["rules_linked"] += 1
con.commit()
con.close()
return counts
+23
View File
@@ -285,5 +285,28 @@ def run_migrations():
)
con.execute("CREATE INDEX IF NOT EXISTS idx_app_ip_access_rules_app_id ON app_ip_access_rules(app_id)")
# Pojmenovaný katalog IP adres. Jedna adresa (např. "Klient X kancelář") se přiřazuje do N
# služeb; přiřazení zůstává řádkem v app_ip_access_rules (zdroj pravdy pro generátor Caddyfile),
# jen s vazbou address_id na katalog. Ručně zadaná pravidla mají address_id = NULL.
con.execute(
"""
CREATE TABLE IF NOT EXISTS ip_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
ip_cidr TEXT NOT NULL,
note TEXT,
default_methods TEXT NOT NULL DEFAULT 'WRITE',
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"""
)
con.execute("CREATE INDEX IF NOT EXISTS idx_ip_addresses_label ON ip_addresses(label)")
if "address_id" not in _table_columns(con, "app_ip_access_rules"):
con.execute("ALTER TABLE app_ip_access_rules ADD COLUMN address_id INTEGER")
con.execute("CREATE INDEX IF NOT EXISTS idx_app_ip_access_rules_address_id ON app_ip_access_rules(address_id)")
con.commit()
con.close()