novy prehled prirazovani IP apod.
This commit is contained in:
@@ -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."
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user