Files
appfactory-portal/app/routes/ip_access.py
T
2026-07-30 07:13:12 +02:00

760 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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."
),
)