novy prehled prirazovani IP apod.
This commit is contained in:
+13
-9
@@ -414,11 +414,13 @@ def get_app_ip_access_rules(app_id: str):
|
|||||||
|
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, app_id, ip_cidr, methods, description,
|
SELECT r.id, r.app_id, r.ip_cidr, r.methods, r.description,
|
||||||
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
|
COALESCE(r.is_enabled, 1) AS is_enabled, r.created_at, r.updated_at,
|
||||||
FROM app_ip_access_rules
|
r.address_id, a.label AS address_label
|
||||||
WHERE app_id = ?
|
FROM app_ip_access_rules r
|
||||||
ORDER BY id
|
LEFT JOIN ip_addresses a ON a.id = r.address_id
|
||||||
|
WHERE r.app_id = ?
|
||||||
|
ORDER BY r.id
|
||||||
""",
|
""",
|
||||||
(app_id,),
|
(app_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -433,10 +435,12 @@ def get_app_ip_access_rule(rule_id: int, app_id: str):
|
|||||||
|
|
||||||
row = con.execute(
|
row = con.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, app_id, ip_cidr, methods, description,
|
SELECT r.id, r.app_id, r.ip_cidr, r.methods, r.description,
|
||||||
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
|
COALESCE(r.is_enabled, 1) AS is_enabled, r.created_at, r.updated_at,
|
||||||
FROM app_ip_access_rules
|
r.address_id, a.label AS address_label
|
||||||
WHERE id = ? AND app_id = ?
|
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),
|
(rule_id, app_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)")
|
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.commit()
|
||||||
con.close()
|
con.close()
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
|
|
||||||
from .config import read_env_bool, read_env_value
|
from .config import read_env_bool, read_env_value
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, jobs, logs, operations, runtime, scheduled_scripts, users, workers
|
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, ip_access, jobs, logs, operations, runtime, scheduled_scripts, users, workers
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -42,6 +42,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(runtime.router)
|
app.include_router(runtime.router)
|
||||||
app.include_router(environment.router)
|
app.include_router(environment.router)
|
||||||
|
app.include_router(ip_access.router)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
+37
-6
@@ -759,11 +759,27 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
row_class = "" if rule_enabled else " rule-disabled"
|
row_class = "" if rule_enabled else " rule-disabled"
|
||||||
enabled_checked = bool_checked(rule_enabled)
|
enabled_checked = bool_checked(rule_enabled)
|
||||||
ip_label = html.escape(rule.get("ip_cidr", "") or "")
|
ip_label = html.escape(rule.get("ip_cidr", "") or "")
|
||||||
|
# Pravidla navázaná na katalog IP adres drží IP/CIDR i popis podle katalogu, aby se obě
|
||||||
|
# místa nerozešla – tady jsou proto jen ke čtení a mění se v centrální správě IP Access.
|
||||||
|
address_id = rule.get("address_id")
|
||||||
|
if address_id:
|
||||||
|
address_label = html.escape(rule.get("address_label") or rule.get("description") or "", quote=True)
|
||||||
|
source_cell = (
|
||||||
|
f'<a href="/portal/admin/ip-access?mode=address&address_id={html.escape(str(address_id), quote=True)}" '
|
||||||
|
f'title="Spravovat v centrální správě IP Access">{address_label}</a>'
|
||||||
|
)
|
||||||
|
ip_cell = f'<input name="ip_cidr" value="{rule_ip}" form="{form_id}" readonly>'
|
||||||
|
description_cell = f'<input name="description" value="{rule_desc}" form="{form_id}" readonly>'
|
||||||
|
else:
|
||||||
|
source_cell = '<span class="muted">ručně</span>'
|
||||||
|
ip_cell = f'<input name="ip_cidr" value="{rule_ip}" form="{form_id}" required>'
|
||||||
|
description_cell = f'<input name="description" value="{rule_desc}" form="{form_id}">'
|
||||||
ip_access_rule_rows += f"""
|
ip_access_rule_rows += f"""
|
||||||
<tr class="ip-rule-row{row_class}">
|
<tr class="ip-rule-row{row_class}">
|
||||||
<td><input name="ip_cidr" value="{rule_ip}" form="{form_id}" required></td>
|
<td>{ip_cell}</td>
|
||||||
<td><select name="methods" form="{form_id}">{render_method_options(rule.get("methods", ""))}</select></td>
|
<td><select name="methods" form="{form_id}">{render_method_options(rule.get("methods", ""))}</select></td>
|
||||||
<td><input name="description" value="{rule_desc}" form="{form_id}"></td>
|
<td>{description_cell}</td>
|
||||||
|
<td>{source_cell}</td>
|
||||||
<td><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{enabled_checked}> Aktivní</label></td>
|
<td><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{enabled_checked}> Aktivní</label></td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/update" id="{form_id}" class="inline-form"></form>
|
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/update" id="{form_id}" class="inline-form"></form>
|
||||||
@@ -776,7 +792,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if not ip_access_rule_rows:
|
if not ip_access_rule_rows:
|
||||||
ip_access_rule_rows = '<tr><td colspan="5">Zatím nejsou evidovaná žádná IP access pravidla.</td></tr>'
|
ip_access_rule_rows = '<tr><td colspan="6">Zatím nejsou evidovaná žádná IP access pravidla.</td></tr>'
|
||||||
|
|
||||||
notice = ""
|
notice = ""
|
||||||
if message:
|
if message:
|
||||||
@@ -908,11 +924,19 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
<strong>WRITE</strong> = POST, PUT, PATCH, DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
|
<strong>WRITE</strong> = POST, PUT, PATCH, DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
|
||||||
Pravidla se zatím pouze ukládají (Caddy je začne vynucovat později).
|
Pravidla se zatím pouze ukládají (Caddy je začne vynucovat později).
|
||||||
</p>
|
</p>
|
||||||
|
<p class="inline-form">
|
||||||
|
<a class="btn btn-secondary" href="/portal/admin/ip-access?mode=service&app_id={app_url_id}">
|
||||||
|
<i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Hromadné přiřazení IP adres
|
||||||
|
</a>
|
||||||
|
<span class="muted">Pojmenované adresy z katalogu (sloupec Zdroj) se spravují centrálně —
|
||||||
|
tam jednou adresou obsloužíte i deset služeb.</span>
|
||||||
|
</p>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>IP/CIDR</th>
|
<th>IP/CIDR</th>
|
||||||
<th>Methods</th>
|
<th>Methods</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
|
<th>Zdroj</th>
|
||||||
<th>Enabled</th>
|
<th>Enabled</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1281,12 +1305,19 @@ def save_app_ip_access_rule(
|
|||||||
|
|
||||||
rule_ip = clean_optional(ip_cidr)
|
rule_ip = clean_optional(ip_cidr)
|
||||||
rule_methods = clean_optional(methods).upper()
|
rule_methods = clean_optional(methods).upper()
|
||||||
if not rule_ip:
|
rule_description = description.strip()
|
||||||
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
|
|
||||||
if not rule_methods:
|
if not rule_methods:
|
||||||
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
|
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
|
||||||
|
|
||||||
update_app_ip_access_rule(rule_id, app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled))
|
# Pravidla z katalogu IP adres si IP/CIDR ani popis nedrží sama – ty patří katalogu, jinak by
|
||||||
|
# se obě místa rozešla. Editovatelné odtud zůstávají jen metody a zapnutí/vypnutí.
|
||||||
|
if existing.get("address_id"):
|
||||||
|
rule_ip = existing.get("ip_cidr") or ""
|
||||||
|
rule_description = existing.get("description") or ""
|
||||||
|
elif not rule_ip:
|
||||||
|
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
|
||||||
|
|
||||||
|
update_app_ip_access_rule(rule_id, app_id, rule_ip, rule_methods, rule_description, bool(is_enabled))
|
||||||
log_audit_event(
|
log_audit_event(
|
||||||
user,
|
user,
|
||||||
action="app.ip_access_rule.updated",
|
action="app.ip_access_rule.updated",
|
||||||
|
|||||||
@@ -0,0 +1,759 @@
|
|||||||
|
"""Centrální správa IP access — katalog pojmenovaných IP adres + hromadné přiřazování ke službám.
|
||||||
|
|
||||||
|
Doplněk k per-service kartě v detailu služby: adresu ("Klient X – kancelář") založím jednou
|
||||||
|
a pak ji jedním formulářem přiřadím do libovolného počtu služeb. Přiřazení se ukládá do
|
||||||
|
existující tabulky ``app_ip_access_rules``, ze které generuje pravidla Caddyfile — ta zůstává
|
||||||
|
beze změny zdrojem pravdy, přibyla jen vazba ``address_id`` na katalog.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import html
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from ..auth import is_admin, require_developer
|
||||||
|
from ..db.apps import get_apps
|
||||||
|
from ..db.audit import log_audit_event
|
||||||
|
from ..db.ip_access import (
|
||||||
|
create_ip_address,
|
||||||
|
delete_ip_address,
|
||||||
|
get_assignments,
|
||||||
|
get_ip_address,
|
||||||
|
get_ip_addresses,
|
||||||
|
get_manual_rules,
|
||||||
|
import_manual_rules,
|
||||||
|
set_address_assignments,
|
||||||
|
set_app_assignments,
|
||||||
|
update_ip_address,
|
||||||
|
)
|
||||||
|
from ..routes.apps import IP_ACCESS_METHOD_OPTIONS
|
||||||
|
from ..routes.runtime import render_action_buttons
|
||||||
|
from ..templates.layout import page
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
PAGE_URL = "/portal/admin/ip-access"
|
||||||
|
MODE_ADDRESS = "address"
|
||||||
|
MODE_SERVICE = "service"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: str | None) -> str:
|
||||||
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _checked(value) -> str:
|
||||||
|
return " checked" if value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _method_options(selected: str) -> str:
|
||||||
|
selected_upper = _clean(selected).upper()
|
||||||
|
options = list(IP_ACCESS_METHOD_OPTIONS)
|
||||||
|
if selected_upper and selected_upper not in options:
|
||||||
|
options.append(selected_upper)
|
||||||
|
out = ""
|
||||||
|
for opt in options:
|
||||||
|
is_sel = " selected" if opt == selected_upper else ""
|
||||||
|
out += f'<option value="{html.escape(opt, quote=True)}"{is_sel}>{html.escape(opt)}</option>'
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect(mode: str, address_id=None, app_id=None, message: str = "", error: str = ""):
|
||||||
|
params: dict[str, str] = {"mode": mode}
|
||||||
|
if address_id:
|
||||||
|
params["address_id"] = str(address_id)
|
||||||
|
if app_id:
|
||||||
|
params["app_id"] = str(app_id)
|
||||||
|
if message:
|
||||||
|
params["message"] = message
|
||||||
|
if error:
|
||||||
|
params["error"] = error
|
||||||
|
return RedirectResponse(url=f"{PAGE_URL}?{urlencode(params)}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
async def form_lists(request: Request) -> dict[str, list[str]]:
|
||||||
|
"""Opakovaná pole formuláře (matice přiřazení) načtená přímo z requestu.
|
||||||
|
|
||||||
|
``list[str] = Form(...)`` se napříč verzemi FastAPI chová u opakovaných polí nejednotně,
|
||||||
|
``form.getlist()`` je spolehlivé. Async dependency + sync endpoint znamená, že samotný
|
||||||
|
handler (a jeho blokující sqlite dotazy) dál běží v threadpoolu jako zbytek portálu.
|
||||||
|
"""
|
||||||
|
form = await request.form()
|
||||||
|
return {name: form.getlist(name) for name in ("row_key", "row_methods", "assigned_keys", "rule_ids")}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rows(rows: dict[str, list[str]]) -> dict[str, str]:
|
||||||
|
"""Z paralelních polí formuláře vytáhne {klíč řádku: metody} jen pro zaškrtnuté řádky.
|
||||||
|
|
||||||
|
Skryté ``row_key`` + ``row_methods`` se posílají za každý řádek (proto jsou zarovnané),
|
||||||
|
zaškrtnutá políčka chodí zvlášť v ``assigned_keys`` — nezaškrtnutý checkbox prohlížeč
|
||||||
|
neodešle, takže by pole rozházel.
|
||||||
|
"""
|
||||||
|
checked = set(rows.get("assigned_keys") or [])
|
||||||
|
methods_by_key: dict[str, str] = {}
|
||||||
|
for key, methods in zip(rows.get("row_key") or [], rows.get("row_methods") or []):
|
||||||
|
methods_by_key[key] = _clean(methods).upper() or "WRITE"
|
||||||
|
return {key: methods_by_key.get(key, "WRITE") for key in checked if key in methods_by_key}
|
||||||
|
|
||||||
|
|
||||||
|
# --- render -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _render_catalog_card(addresses: list[dict]) -> str:
|
||||||
|
rows = ""
|
||||||
|
for address in addresses:
|
||||||
|
aid = html.escape(str(address.get("id", "")))
|
||||||
|
form_id = f"ipaddr-{aid}"
|
||||||
|
label = html.escape(address.get("label", "") or "", quote=True)
|
||||||
|
ip_cidr = html.escape(address.get("ip_cidr", "") or "", quote=True)
|
||||||
|
note = html.escape(address.get("note", "") or "", quote=True)
|
||||||
|
enabled = bool(address.get("is_enabled", 1))
|
||||||
|
assigned = int(address.get("assigned_count") or 0)
|
||||||
|
row_class = "" if enabled else " rule-disabled"
|
||||||
|
rows += f"""
|
||||||
|
<tr class="ip-rule-row{row_class}">
|
||||||
|
<td><input name="label" value="{label}" form="{form_id}" required></td>
|
||||||
|
<td><input name="ip_cidr" value="{ip_cidr}" form="{form_id}" required></td>
|
||||||
|
<td><select name="default_methods" form="{form_id}">{_method_options(address.get("default_methods", ""))}</select></td>
|
||||||
|
<td><input name="note" value="{note}" form="{form_id}"></td>
|
||||||
|
<td><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{_checked(enabled)}> Aktivní</label></td>
|
||||||
|
<td><a class="pill" href="{PAGE_URL}?mode={MODE_ADDRESS}&address_id={aid}">{assigned} služeb</a></td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<form method="post" action="{PAGE_URL}/addresses/{aid}/update" id="{form_id}" class="inline-form"></form>
|
||||||
|
<button type="submit" form="{form_id}" class="icon-action" title="Uložit" aria-label="Uložit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i></button>
|
||||||
|
<form method="post" action="{PAGE_URL}/addresses/{aid}/delete" class="inline-form"
|
||||||
|
onsubmit="return confirm('Smazat adresu {label} včetně {assigned} přiřazení?');">
|
||||||
|
<button type="submit" class="icon-action icon-action-danger" title="Smazat" aria-label="Smazat"><i class="fa-solid fa-trash" aria-hidden="true"></i></button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = '<tr><td colspan="7">Katalog je zatím prázdný — přidejte první IP adresu níže.</td></tr>'
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="card" id="adresy">
|
||||||
|
<h2><i class="fa-solid fa-address-book" aria-hidden="true"></i> IP adresy</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Pojmenovaný katalog adres. Změna IP/CIDR nebo názvu se automaticky propíše do všech
|
||||||
|
služeb, kde je adresa přiřazená. Vypnutím adresy odeberete přístup všude naráz —
|
||||||
|
přiřazení zůstanou zachovaná pro pozdější zapnutí.
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Název</th>
|
||||||
|
<th>IP/CIDR</th>
|
||||||
|
<th>Výchozí metody</th>
|
||||||
|
<th>Poznámka</th>
|
||||||
|
<th>Stav</th>
|
||||||
|
<th>Přiřazeno</th>
|
||||||
|
<th>Akce</th>
|
||||||
|
</tr>
|
||||||
|
{rows}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<form method="post" action="{PAGE_URL}/addresses" class="metadata-form add-variable-form">
|
||||||
|
<h3>Přidat IP adresu</h3>
|
||||||
|
<label>Název</label>
|
||||||
|
<input name="label" placeholder="např. Klient Novák – kancelář" required>
|
||||||
|
<label>IP/CIDR</label>
|
||||||
|
<input name="ip_cidr" placeholder="např. 185.10.20.30 nebo 10.0.0.0/24" required>
|
||||||
|
<label>Výchozí metody</label>
|
||||||
|
<select name="default_methods">{_method_options("WRITE")}</select>
|
||||||
|
<label>Poznámka</label>
|
||||||
|
<input name="note" placeholder="např. statická IP, kontakt: Jan Novák">
|
||||||
|
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" checked> Aktivní</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Přidat adresu</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_assignment_card(
|
||||||
|
mode: str,
|
||||||
|
addresses: list[dict],
|
||||||
|
apps: list[dict],
|
||||||
|
assignment_map: dict[tuple[int, str], dict],
|
||||||
|
selected_address: dict | None,
|
||||||
|
selected_app: dict | None,
|
||||||
|
) -> str:
|
||||||
|
address_tab = "btn" if mode == MODE_ADDRESS else "btn btn-secondary"
|
||||||
|
service_tab = "btn" if mode == MODE_SERVICE else "btn btn-secondary"
|
||||||
|
selected_address_id = int(selected_address["id"]) if selected_address else 0
|
||||||
|
selected_app_id = selected_app.get("id", "") if selected_app else ""
|
||||||
|
|
||||||
|
switcher = f"""
|
||||||
|
<div class="detail-tabs" aria-label="Režim přiřazování">
|
||||||
|
<a class="{address_tab}" href="{PAGE_URL}?mode={MODE_ADDRESS}{f'&address_id={selected_address_id}' if selected_address_id else ''}">
|
||||||
|
<i class="fa-solid fa-location-crosshairs" aria-hidden="true"></i> Podle IP adresy
|
||||||
|
</a>
|
||||||
|
<a class="{service_tab}" href="{PAGE_URL}?mode={MODE_SERVICE}{f'&app_id={html.escape(selected_app_id, quote=True)}' if selected_app_id else ''}">
|
||||||
|
<i class="fa-solid fa-server" aria-hidden="true"></i> Podle služby
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if mode == MODE_ADDRESS:
|
||||||
|
if not addresses:
|
||||||
|
return f"""
|
||||||
|
<div class="card" id="prirazeni">
|
||||||
|
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Přiřazení</h2>
|
||||||
|
{switcher}
|
||||||
|
<p class="muted">Nejdřív přidejte alespoň jednu IP adresu do katalogu.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
options = "".join(
|
||||||
|
'<option value="{value}"{sel}>{text}</option>'.format(
|
||||||
|
value=int(item["id"]),
|
||||||
|
sel=" selected" if int(item["id"]) == selected_address_id else "",
|
||||||
|
text=html.escape(
|
||||||
|
"{label} ({ip}){off}".format(
|
||||||
|
label=item.get("label", "") or "",
|
||||||
|
ip=item.get("ip_cidr", "") or "",
|
||||||
|
off="" if item.get("is_enabled", 1) else " – vypnuto",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for item in addresses
|
||||||
|
)
|
||||||
|
selector = f"""
|
||||||
|
<form method="get" action="{PAGE_URL}" class="inline-form">
|
||||||
|
<input type="hidden" name="mode" value="{MODE_ADDRESS}">
|
||||||
|
<label for="address-switch"><strong>IP adresa</strong></label>
|
||||||
|
<select id="address-switch" name="address_id" onchange="this.form.submit()">{options}</select>
|
||||||
|
<button type="submit" class="btn-secondary"><i class="fa-solid fa-arrow-right-arrow-left" aria-hidden="true"></i> Přepnout</button>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
|
||||||
|
disabled_note = ""
|
||||||
|
if not selected_address.get("is_enabled", 1):
|
||||||
|
disabled_note = (
|
||||||
|
'<p class="alert">Adresa je vypnutá — přiřazení se ukládají, ale všechna pravidla '
|
||||||
|
"zůstanou neaktivní, dokud adresu nezapnete v katalogu výše.</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
row_html = ""
|
||||||
|
for item in apps:
|
||||||
|
app_id = item.get("id", "") or ""
|
||||||
|
key = html.escape(app_id, quote=True)
|
||||||
|
rule = assignment_map.get((selected_address_id, app_id))
|
||||||
|
methods = rule.get("methods") if rule else selected_address.get("default_methods", "WRITE")
|
||||||
|
row_html += f"""
|
||||||
|
<tr>
|
||||||
|
<td><input type="hidden" name="row_key" value="{key}">
|
||||||
|
<label class="checkbox-label"><input type="checkbox" name="assigned_keys" value="{key}"{_checked(rule)}> <span class="sr-only">Přiřadit</span></label></td>
|
||||||
|
<td><a href="/portal/apps/{key}#ip-access">{html.escape(item.get("name", "") or app_id)}</a>
|
||||||
|
<div class="muted">{html.escape(app_id)}</div></td>
|
||||||
|
<td><select name="row_methods">{_method_options(methods)}</select></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not row_html:
|
||||||
|
row_html = '<tr><td colspan="3">Zatím nejsou evidované žádné služby.</td></tr>'
|
||||||
|
|
||||||
|
action = f"{PAGE_URL}/addresses/{selected_address_id}/assignments"
|
||||||
|
heading = "Do kterých služeb pustit adresu <strong>{label}</strong> ({ip})".format(
|
||||||
|
label=html.escape(selected_address.get("label", "") or ""),
|
||||||
|
ip=html.escape(selected_address.get("ip_cidr", "") or ""),
|
||||||
|
)
|
||||||
|
first_column = "Přiřadit"
|
||||||
|
second_column = "Služba"
|
||||||
|
else:
|
||||||
|
if not apps:
|
||||||
|
return f"""
|
||||||
|
<div class="card" id="prirazeni">
|
||||||
|
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Přiřazení</h2>
|
||||||
|
{switcher}
|
||||||
|
<p class="muted">Zatím nejsou evidované žádné služby.</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
options = "".join(
|
||||||
|
'<option value="{value}"{sel}>{text}</option>'.format(
|
||||||
|
value=html.escape(item.get("id", "") or "", quote=True),
|
||||||
|
sel=" selected" if (item.get("id", "") or "") == selected_app_id else "",
|
||||||
|
text=html.escape("{name} ({id})".format(name=item.get("name", "") or item.get("id", ""), id=item.get("id", ""))),
|
||||||
|
)
|
||||||
|
for item in apps
|
||||||
|
)
|
||||||
|
selector = f"""
|
||||||
|
<form method="get" action="{PAGE_URL}" class="inline-form">
|
||||||
|
<input type="hidden" name="mode" value="{MODE_SERVICE}">
|
||||||
|
<label for="app-switch"><strong>Služba</strong></label>
|
||||||
|
<select id="app-switch" name="app_id" onchange="this.form.submit()">{options}</select>
|
||||||
|
<button type="submit" class="btn-secondary"><i class="fa-solid fa-arrow-right-arrow-left" aria-hidden="true"></i> Přepnout</button>
|
||||||
|
</form>
|
||||||
|
"""
|
||||||
|
disabled_note = ""
|
||||||
|
|
||||||
|
row_html = ""
|
||||||
|
for item in addresses:
|
||||||
|
address_id = int(item["id"])
|
||||||
|
key = str(address_id)
|
||||||
|
rule = assignment_map.get((address_id, selected_app_id))
|
||||||
|
methods = rule.get("methods") if rule else item.get("default_methods", "WRITE")
|
||||||
|
off_pill = "" if item.get("is_enabled", 1) else ' <span class="pill pill-muted">vypnuto</span>'
|
||||||
|
row_html += f"""
|
||||||
|
<tr>
|
||||||
|
<td><input type="hidden" name="row_key" value="{key}">
|
||||||
|
<label class="checkbox-label"><input type="checkbox" name="assigned_keys" value="{key}"{_checked(rule)}> <span class="sr-only">Přiřadit</span></label></td>
|
||||||
|
<td>{html.escape(item.get("label", "") or "")}{off_pill}
|
||||||
|
<div class="muted">{html.escape(item.get("ip_cidr", "") or "")}</div></td>
|
||||||
|
<td><select name="row_methods">{_method_options(methods)}</select></td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not row_html:
|
||||||
|
row_html = '<tr><td colspan="3">Katalog je zatím prázdný — přidejte první IP adresu výše.</td></tr>'
|
||||||
|
|
||||||
|
action = f"{PAGE_URL}/apps/{html.escape(selected_app_id, quote=True)}/assignments"
|
||||||
|
heading = "Které IP adresy pustit do služby <strong>{name}</strong>".format(
|
||||||
|
name=html.escape(selected_app.get("name", "") or selected_app_id),
|
||||||
|
)
|
||||||
|
first_column = "Přiřadit"
|
||||||
|
second_column = "IP adresa"
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="card" id="prirazeni">
|
||||||
|
<h2><i class="fa-solid fa-diagram-project" aria-hidden="true"></i> Přiřazení</h2>
|
||||||
|
{switcher}
|
||||||
|
{selector}
|
||||||
|
<p>{heading}</p>
|
||||||
|
{disabled_note}
|
||||||
|
<form method="post" action="{action}" id="assignment-form">
|
||||||
|
<div class="inline-form assignment-tools">
|
||||||
|
<button type="button" class="btn-secondary" onclick="ipAccessToggleAll(true)"><i class="fa-solid fa-check-double" aria-hidden="true"></i> Označit vše</button>
|
||||||
|
<button type="button" class="btn-secondary" onclick="ipAccessToggleAll(false)"><i class="fa-solid fa-xmark" aria-hidden="true"></i> Odznačit vše</button>
|
||||||
|
<label for="bulk-methods">Nastavit metody všem</label>
|
||||||
|
<select id="bulk-methods" onchange="ipAccessApplyMethods(this.value)">
|
||||||
|
<option value="">— vyberte —</option>
|
||||||
|
{_method_options("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 1%">{first_column}</th>
|
||||||
|
<th>{second_column}</th>
|
||||||
|
<th style="width: 30%">Metody</th>
|
||||||
|
</tr>
|
||||||
|
{row_html}
|
||||||
|
</table>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Uložit přiřazení</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="muted">
|
||||||
|
Odškrtnuté řádky se při uložení smažou. Metody: <strong>WRITE</strong> = POST, PUT, PATCH,
|
||||||
|
DELETE; <strong>ALL</strong> = všechny běžné metody včetně GET.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_overview_card(addresses: list[dict], assignments: list[dict]) -> str:
|
||||||
|
by_address: dict[int, list[dict]] = {}
|
||||||
|
for item in assignments:
|
||||||
|
by_address.setdefault(int(item["address_id"]), []).append(item)
|
||||||
|
|
||||||
|
rows = ""
|
||||||
|
for address in addresses:
|
||||||
|
address_id = int(address["id"])
|
||||||
|
items = by_address.get(address_id, [])
|
||||||
|
if items:
|
||||||
|
pills = "".join(
|
||||||
|
'<a class="pill{muted}" href="/portal/apps/{app}#ip-access" title="{methods}">{name}</a> '.format(
|
||||||
|
muted="" if item.get("is_enabled", 1) else " pill-muted",
|
||||||
|
app=html.escape(item.get("app_id", "") or "", quote=True),
|
||||||
|
methods=html.escape(item.get("methods", "") or "", quote=True),
|
||||||
|
name=html.escape(item.get("app_name", "") or item.get("app_id", "") or ""),
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pills = '<span class="muted">bez přiřazení</span>'
|
||||||
|
state = "" if address.get("is_enabled", 1) else ' <span class="pill pill-muted">vypnuto</span>'
|
||||||
|
rows += f"""
|
||||||
|
<tr>
|
||||||
|
<td>{html.escape(address.get("label", "") or "")}{state}<div class="muted">{html.escape(address.get("ip_cidr", "") or "")}</div></td>
|
||||||
|
<td>{pills}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = '<tr><td colspan="2">Katalog je zatím prázdný.</td></tr>'
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="card" id="prehled">
|
||||||
|
<h2><i class="fa-solid fa-table-list" aria-hidden="true"></i> Přehled</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%">IP adresa</th>
|
||||||
|
<th>Přiřazené služby</th>
|
||||||
|
</tr>
|
||||||
|
{rows}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_manual_card(manual_rules: list[dict]) -> str:
|
||||||
|
if not manual_rules:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
rows = ""
|
||||||
|
for rule in manual_rules:
|
||||||
|
rid = html.escape(str(rule.get("id", "")))
|
||||||
|
app_id = html.escape(rule.get("app_id", "") or "", quote=True)
|
||||||
|
rows += f"""
|
||||||
|
<tr>
|
||||||
|
<td><label class="checkbox-label"><input type="checkbox" name="rule_ids" value="{rid}"> <span class="sr-only">Importovat</span></label></td>
|
||||||
|
<td><a href="/portal/apps/{app_id}#ip-access">{html.escape(rule.get("app_name", "") or rule.get("app_id", "") or "")}</a></td>
|
||||||
|
<td>{html.escape(rule.get("ip_cidr", "") or "")}</td>
|
||||||
|
<td>{html.escape(rule.get("methods", "") or "")}</td>
|
||||||
|
<td>{html.escape(rule.get("description", "") or "")}</td>
|
||||||
|
<td>{"Ano" if rule.get("is_enabled", 1) else "Ne"}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="card" id="rucni">
|
||||||
|
<h2><i class="fa-solid fa-file-import" aria-hidden="true"></i> Pravidla mimo katalog</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Pravidla zadaná přímo v detailu služby. Fungují dál beze změny; importem se jen
|
||||||
|
navážou na katalog (adresa se najde podle IP/CIDR, jinak se založí), aby šly spravovat
|
||||||
|
hromadně odtud.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{PAGE_URL}/manual-rules/import">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 1%">Import</th>
|
||||||
|
<th>Služba</th>
|
||||||
|
<th>IP/CIDR</th>
|
||||||
|
<th>Metody</th>
|
||||||
|
<th>Popis</th>
|
||||||
|
<th>Aktivní</th>
|
||||||
|
</tr>
|
||||||
|
{rows}
|
||||||
|
</table>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-secondary"><i class="fa-solid fa-file-import" aria-hidden="true"></i> Importovat vybraná do katalogu</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_apply_card(user: dict) -> str:
|
||||||
|
if not is_admin(user):
|
||||||
|
return """
|
||||||
|
<div class="card" id="aplikovat">
|
||||||
|
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Aplikovat na gateway</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Změny jsou uložené v databázi. Pregenerování Caddy může spustit jen administrátor
|
||||||
|
(Admin → Environment → Regenerate Caddy).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="card" id="aplikovat">
|
||||||
|
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Aplikovat na gateway</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Uložením se změní jen databáze. Aby se pravidla projevila na gateway, spusťte
|
||||||
|
pregenerování Caddyfile — akce nevolá docker přímo, vytvoří úlohu do fronty
|
||||||
|
(zpracuje worker). Historii najdete v <a href="/portal/jobs">Úlohách</a>.
|
||||||
|
</p>
|
||||||
|
<div class="inline-form">
|
||||||
|
{render_action_buttons(["regenerate-caddy"], PAGE_URL)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_page(user: dict, mode: str, address_id: str, app_id: str, message: str = "", error: str = "") -> str:
|
||||||
|
addresses = get_ip_addresses()
|
||||||
|
apps = get_apps()
|
||||||
|
assignments = get_assignments()
|
||||||
|
manual_rules = get_manual_rules()
|
||||||
|
|
||||||
|
assignment_map = {(int(item["address_id"]), item["app_id"]): item for item in assignments}
|
||||||
|
|
||||||
|
mode = MODE_SERVICE if mode == MODE_SERVICE else MODE_ADDRESS
|
||||||
|
|
||||||
|
selected_address = None
|
||||||
|
if addresses:
|
||||||
|
selected_address = next((item for item in addresses if str(item["id"]) == str(address_id)), addresses[0])
|
||||||
|
|
||||||
|
selected_app = None
|
||||||
|
if apps:
|
||||||
|
selected_app = next((item for item in apps if str(item.get("id")) == str(app_id)), apps[0])
|
||||||
|
|
||||||
|
notice = ""
|
||||||
|
if message:
|
||||||
|
notice = f'<p class="alert">{html.escape(message)}</p>'
|
||||||
|
if error:
|
||||||
|
notice = f'<p class="alert alert-danger">{html.escape(error)}</p>'
|
||||||
|
|
||||||
|
total_assignments = len(assignments)
|
||||||
|
active_addresses = sum(1 for item in addresses if item.get("is_enabled", 1))
|
||||||
|
|
||||||
|
body = f"""
|
||||||
|
<div class="card">
|
||||||
|
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> IP Access</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Centrální správa přístupů podle IP. Adresu založíte jednou v katalogu a pak ji
|
||||||
|
přepínačem níže přiřadíte do libovolného počtu služeb — typicky dvě IP klienta do
|
||||||
|
deseti služeb. Přiřazení se ukládají do stejných pravidel, která používá detail
|
||||||
|
služby i generátor Caddyfile.
|
||||||
|
</p>
|
||||||
|
{notice}
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><span>IP adres v katalogu</span><strong>{len(addresses)}</strong></div>
|
||||||
|
<div class="stat-card"><span>Aktivních adres</span><strong>{active_addresses}</strong></div>
|
||||||
|
<div class="stat-card"><span>Přiřazení celkem</span><strong>{total_assignments}</strong></div>
|
||||||
|
<div class="stat-card"><span>Pravidel mimo katalog</span><strong>{len(manual_rules)}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{_render_assignment_card(mode, addresses, apps, assignment_map, selected_address, selected_app)}
|
||||||
|
|
||||||
|
{_render_catalog_card(addresses)}
|
||||||
|
|
||||||
|
{_render_overview_card(addresses, assignments)}
|
||||||
|
|
||||||
|
{_render_manual_card(manual_rules)}
|
||||||
|
|
||||||
|
{_render_apply_card(user)}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function ipAccessToggleAll(checked) {{
|
||||||
|
const form = document.getElementById("assignment-form");
|
||||||
|
if (!form) return;
|
||||||
|
form.querySelectorAll('input[name="assigned_keys"]').forEach(function (box) {{
|
||||||
|
box.checked = checked;
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function ipAccessApplyMethods(value) {{
|
||||||
|
if (!value) return;
|
||||||
|
const form = document.getElementById("assignment-form");
|
||||||
|
if (!form) return;
|
||||||
|
form.querySelectorAll('select[name="row_methods"]').forEach(function (select) {{
|
||||||
|
select.value = value;
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return page("IP Access", body, user=user)
|
||||||
|
|
||||||
|
|
||||||
|
# --- endpoints --------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/admin/ip-access", response_class=HTMLResponse)
|
||||||
|
def ip_access_page(
|
||||||
|
request: Request,
|
||||||
|
mode: str = MODE_ADDRESS,
|
||||||
|
address_id: str = "",
|
||||||
|
app_id: str = "",
|
||||||
|
message: str = "",
|
||||||
|
error: str = "",
|
||||||
|
user=Depends(require_developer),
|
||||||
|
):
|
||||||
|
return _render_page(user, mode, address_id, app_id, message=message, error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/ip-access/addresses")
|
||||||
|
def add_ip_address(
|
||||||
|
label: str = Form(...),
|
||||||
|
ip_cidr: str = Form(...),
|
||||||
|
default_methods: str = Form("WRITE"),
|
||||||
|
note: str = Form(""),
|
||||||
|
is_enabled: str | None = Form(None),
|
||||||
|
user=Depends(require_developer),
|
||||||
|
):
|
||||||
|
clean_label = _clean(label)
|
||||||
|
clean_ip = _clean(ip_cidr)
|
||||||
|
clean_methods = _clean(default_methods).upper() or "WRITE"
|
||||||
|
|
||||||
|
if not clean_label:
|
||||||
|
return _redirect(MODE_ADDRESS, error="Název adresy nesmí být prázdný.")
|
||||||
|
if not clean_ip:
|
||||||
|
return _redirect(MODE_ADDRESS, error="IP/CIDR nesmí být prázdné.")
|
||||||
|
|
||||||
|
address_id = create_ip_address(clean_label, clean_ip, _clean(note), clean_methods, bool(is_enabled))
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="ip_access.address.created",
|
||||||
|
target_type="ip_address",
|
||||||
|
target_id=str(address_id),
|
||||||
|
metadata={"label": clean_label, "ip_cidr": clean_ip, "default_methods": clean_methods, "is_enabled": bool(is_enabled)},
|
||||||
|
)
|
||||||
|
return _redirect(MODE_ADDRESS, address_id=address_id, message=f"IP adresa {clean_label} přidána do katalogu.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/ip-access/addresses/{address_id}/update")
|
||||||
|
def save_ip_address(
|
||||||
|
address_id: int,
|
||||||
|
label: str = Form(...),
|
||||||
|
ip_cidr: str = Form(...),
|
||||||
|
default_methods: str = Form("WRITE"),
|
||||||
|
note: str = Form(""),
|
||||||
|
is_enabled: str | None = Form(None),
|
||||||
|
user=Depends(require_developer),
|
||||||
|
):
|
||||||
|
existing = get_ip_address(address_id)
|
||||||
|
if not existing:
|
||||||
|
return _redirect(MODE_ADDRESS, error="IP adresa nebyla nalezena.")
|
||||||
|
|
||||||
|
clean_label = _clean(label)
|
||||||
|
clean_ip = _clean(ip_cidr)
|
||||||
|
clean_methods = _clean(default_methods).upper() or "WRITE"
|
||||||
|
|
||||||
|
if not clean_label:
|
||||||
|
return _redirect(MODE_ADDRESS, address_id=address_id, error="Název adresy nesmí být prázdný.")
|
||||||
|
if not clean_ip:
|
||||||
|
return _redirect(MODE_ADDRESS, address_id=address_id, error="IP/CIDR nesmí být prázdné.")
|
||||||
|
|
||||||
|
affected = update_ip_address(address_id, clean_label, clean_ip, _clean(note), clean_methods, bool(is_enabled))
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="ip_access.address.updated",
|
||||||
|
target_type="ip_address",
|
||||||
|
target_id=str(address_id),
|
||||||
|
metadata={
|
||||||
|
"label": clean_label,
|
||||||
|
"ip_cidr": clean_ip,
|
||||||
|
"default_methods": clean_methods,
|
||||||
|
"is_enabled": bool(is_enabled),
|
||||||
|
"propagated_rules": affected,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
note_text = f" Propsáno do {affected} přiřazených pravidel." if affected else ""
|
||||||
|
return _redirect(MODE_ADDRESS, address_id=address_id, message=f"IP adresa {clean_label} uložena.{note_text}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/ip-access/addresses/{address_id}/delete")
|
||||||
|
def remove_ip_address(address_id: int, user=Depends(require_developer)):
|
||||||
|
existing = get_ip_address(address_id)
|
||||||
|
if not existing:
|
||||||
|
return _redirect(MODE_ADDRESS, error="IP adresa nebyla nalezena.")
|
||||||
|
|
||||||
|
affected = delete_ip_address(address_id)
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="ip_access.address.deleted",
|
||||||
|
target_type="ip_address",
|
||||||
|
target_id=str(address_id),
|
||||||
|
metadata={"label": existing.get("label"), "ip_cidr": existing.get("ip_cidr"), "removed_rules": affected},
|
||||||
|
)
|
||||||
|
return _redirect(
|
||||||
|
MODE_ADDRESS,
|
||||||
|
message=f"IP adresa {existing.get('label')} smazána včetně {affected} přiřazení.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/ip-access/addresses/{address_id}/assignments")
|
||||||
|
def save_address_assignments(
|
||||||
|
address_id: int,
|
||||||
|
rows: dict[str, list[str]] = Depends(form_lists),
|
||||||
|
user=Depends(require_developer),
|
||||||
|
):
|
||||||
|
address = get_ip_address(address_id)
|
||||||
|
if not address:
|
||||||
|
return _redirect(MODE_ADDRESS, error="IP adresa nebyla nalezena.")
|
||||||
|
|
||||||
|
selected = _parse_rows(rows)
|
||||||
|
known_apps = {item.get("id", "") for item in get_apps()}
|
||||||
|
unknown = sorted(set(selected) - known_apps)
|
||||||
|
if unknown:
|
||||||
|
return _redirect(
|
||||||
|
MODE_ADDRESS,
|
||||||
|
address_id=address_id,
|
||||||
|
error="Neznámé služby v požadavku: " + ", ".join(unknown),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = set_address_assignments(address_id, {app: (methods, True) for app, methods in selected.items()})
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="ip_access.assignments.updated",
|
||||||
|
target_type="ip_address",
|
||||||
|
target_id=str(address_id),
|
||||||
|
metadata={"label": address.get("label"), "app_ids": sorted(selected), **counts},
|
||||||
|
)
|
||||||
|
return _redirect(
|
||||||
|
MODE_ADDRESS,
|
||||||
|
address_id=address_id,
|
||||||
|
message=(
|
||||||
|
f"Přiřazení adresy {address.get('label')} uloženo — {len(selected)} služeb "
|
||||||
|
f"(+{counts['created']} / ~{counts['updated']} / -{counts['deleted']}). "
|
||||||
|
"Nezapomeňte pregenerovat Caddy."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/ip-access/apps/{app_id}/assignments")
|
||||||
|
def save_app_ip_assignments(
|
||||||
|
app_id: str,
|
||||||
|
rows: dict[str, list[str]] = Depends(form_lists),
|
||||||
|
user=Depends(require_developer),
|
||||||
|
):
|
||||||
|
apps = get_apps()
|
||||||
|
app = next((item for item in apps if item.get("id") == app_id), None)
|
||||||
|
if not app:
|
||||||
|
return _redirect(MODE_SERVICE, error="Služba nebyla nalezena.")
|
||||||
|
|
||||||
|
selected = _parse_rows(rows)
|
||||||
|
known_addresses = {str(item["id"]) for item in get_ip_addresses()}
|
||||||
|
unknown = sorted(set(selected) - known_addresses)
|
||||||
|
if unknown:
|
||||||
|
return _redirect(
|
||||||
|
MODE_SERVICE,
|
||||||
|
app_id=app_id,
|
||||||
|
error="Neznámé IP adresy v požadavku: " + ", ".join(unknown),
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = set_app_assignments(app_id, {int(key): (methods, True) for key, methods in selected.items()})
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="ip_access.assignments.updated",
|
||||||
|
target_type="app",
|
||||||
|
target_id=app_id,
|
||||||
|
metadata={"address_ids": sorted(int(key) for key in selected), **counts},
|
||||||
|
)
|
||||||
|
return _redirect(
|
||||||
|
MODE_SERVICE,
|
||||||
|
app_id=app_id,
|
||||||
|
message=(
|
||||||
|
f"Přiřazení služby {app.get('name') or app_id} uloženo — {len(selected)} IP adres "
|
||||||
|
f"(+{counts['created']} / ~{counts['updated']} / -{counts['deleted']}). "
|
||||||
|
"Nezapomeňte pregenerovat Caddy."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/ip-access/manual-rules/import")
|
||||||
|
def import_manual_ip_rules(rows: dict[str, list[str]] = Depends(form_lists), user=Depends(require_developer)):
|
||||||
|
rule_ids = [int(value) for value in rows.get("rule_ids") or [] if value.isdigit()]
|
||||||
|
if not rule_ids:
|
||||||
|
return _redirect(MODE_ADDRESS, error="Nebyla vybraná žádná pravidla k importu.")
|
||||||
|
|
||||||
|
counts = import_manual_rules(rule_ids)
|
||||||
|
log_audit_event(
|
||||||
|
user,
|
||||||
|
action="ip_access.manual_rules.imported",
|
||||||
|
target_type="ip_address",
|
||||||
|
target_id=None,
|
||||||
|
metadata={"rule_ids": rule_ids, **counts},
|
||||||
|
)
|
||||||
|
return _redirect(
|
||||||
|
MODE_ADDRESS,
|
||||||
|
message=(
|
||||||
|
f"Importováno {counts['rules_linked']} pravidel, "
|
||||||
|
f"nově založeno {counts['addresses_created']} adres v katalogu."
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -483,6 +483,29 @@ button:hover,
|
|||||||
background: #f3f5f7;
|
background: #f3f5f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pravidla z katalogu IP adres: IP/CIDR a popis se spravují centrálně, tady jen ke čtení. */
|
||||||
|
.ip-rule-row input[readonly] {
|
||||||
|
background: #f3f5f7;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hromadné akce nad maticí přiřazení (označit vše / nastavit metody všem). */
|
||||||
|
.assignment-tools {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #eef6f9;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-tools label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--secondary);
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.info-dot {
|
.info-dot {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ ADMIN_MENU_ITEMS = [
|
|||||||
("Audit", "/portal/audit", "fa-clipboard-list", False),
|
("Audit", "/portal/audit", "fa-clipboard-list", False),
|
||||||
("Environment", "/portal/admin/environment", "fa-sliders", True),
|
("Environment", "/portal/admin/environment", "fa-sliders", True),
|
||||||
("Incidenty", "/portal/incidents", "fa-triangle-exclamation", False),
|
("Incidenty", "/portal/incidents", "fa-triangle-exclamation", False),
|
||||||
|
("IP Access", "/portal/admin/ip-access", "fa-shield-halved", False),
|
||||||
("Nasazení", "/portal/deployments", "fa-rocket", False),
|
("Nasazení", "/portal/deployments", "fa-rocket", False),
|
||||||
("Plánované skripty", "/portal/scheduled-scripts", "fa-calendar-days", False),
|
("Plánované skripty", "/portal/scheduled-scripts", "fa-calendar-days", False),
|
||||||
("Služby", "/portal/apps", "fa-server", False),
|
("Služby", "/portal/apps", "fa-server", False),
|
||||||
@@ -58,6 +59,10 @@ def page(title: str, body: str, user=None) -> str:
|
|||||||
'<a class="nav-link" href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>'
|
'<a class="nav-link" href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>'
|
||||||
'<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>'
|
'<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>'
|
||||||
)
|
)
|
||||||
|
# IP Access spravují i developeři, ale admin menu se jim nezobrazuje – proto jen jim
|
||||||
|
# přidáváme odkaz do hlavní navigace, aby stránku nemuseli hledat přes detail služby.
|
||||||
|
if can_operate and role != "admin":
|
||||||
|
nav_links += '<a class="nav-link" href="/portal/admin/ip-access"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> IP Access</a>'
|
||||||
|
|
||||||
admin_menu = ""
|
admin_menu = ""
|
||||||
if role == "admin":
|
if role == "admin":
|
||||||
|
|||||||
Reference in New Issue
Block a user