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
+759
View File
@@ -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}&amp;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'&amp;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'&amp;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."
),
)