dev fix, ip controls

This commit is contained in:
JiriUhlir
2026-06-15 11:08:31 +02:00
parent bf4796d153
commit a948324b2e
5 changed files with 310 additions and 1 deletions
+90
View File
@@ -408,6 +408,96 @@ def delete_app_variable(variable_id: int, app_id: str):
con.close()
def get_app_ip_access_rules(app_id: str):
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT id, app_id, ip_cidr, methods, description,
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
FROM app_ip_access_rules
WHERE app_id = ?
ORDER BY id
""",
(app_id,),
).fetchall()
con.close()
return [dict(row) for row in rows]
def get_app_ip_access_rule(rule_id: int, app_id: str):
run_migrations()
con = get_connection()
row = con.execute(
"""
SELECT id, app_id, ip_cidr, methods, description,
COALESCE(is_enabled, 1) AS is_enabled, created_at, updated_at
FROM app_ip_access_rules
WHERE id = ? AND app_id = ?
""",
(rule_id, app_id),
).fetchone()
con.close()
return dict(row) if row else None
def create_app_ip_access_rule(app_id: str, ip_cidr: str, methods: str, description: str, is_enabled: bool):
run_migrations()
con = get_connection()
con.execute(
"""
INSERT INTO app_ip_access_rules (app_id, ip_cidr, methods, description, is_enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
(app_id, ip_cidr, methods, description, 1 if is_enabled else 0),
)
con.commit()
con.close()
def update_app_ip_access_rule(rule_id: int, app_id: str, ip_cidr: str, methods: str, description: str, is_enabled: bool):
run_migrations()
con = get_connection()
con.execute(
"""
UPDATE app_ip_access_rules
SET ip_cidr = ?,
methods = ?,
description = ?,
is_enabled = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND app_id = ?
""",
(ip_cidr, methods, description, 1 if is_enabled else 0, rule_id, app_id),
)
con.commit()
con.close()
def delete_app_ip_access_rule(rule_id: int, app_id: str):
run_migrations()
con = get_connection()
con.execute(
"""
DELETE FROM app_ip_access_rules
WHERE id = ? AND app_id = ?
""",
(rule_id, app_id),
)
con.commit()
con.close()
def update_app_resources(app_id: str, memory: str, cpus: str):
run_migrations()
con = get_connection()
+16
View File
@@ -267,5 +267,21 @@ def run_migrations():
con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_created ON alert_events(created_at)")
con.execute("CREATE INDEX IF NOT EXISTS idx_alert_events_job_id ON alert_events(job_id)")
con.execute(
"""
CREATE TABLE IF NOT EXISTS app_ip_access_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_id TEXT NOT NULL,
ip_cidr TEXT NOT NULL,
methods TEXT NOT NULL DEFAULT 'WRITE',
description TEXT,
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_app_ip_access_rules_app_id ON app_ip_access_rules(app_id)")
con.commit()
con.close()
+192
View File
@@ -16,14 +16,19 @@ from ..config import (
read_env_value,
)
from ..db.apps import (
create_app_ip_access_rule,
create_app_variable,
delete_app_ip_access_rule,
delete_app_variable,
get_app,
get_app_ip_access_rule,
get_app_ip_access_rules,
get_app_template,
get_app_deployments,
get_app_templates,
get_app_variables,
get_apps,
update_app_ip_access_rule,
update_app_metadata,
update_app_resources,
upsert_created_app,
@@ -41,6 +46,20 @@ from ..templates.layout import page, render_result
router = APIRouter()
DEFAULT_PAGE_SIZE = 20
# Selectable method presets for IP access rules. WRITE = POST,PUT,PATCH,DELETE; ALL = every
# common HTTP method including GET. The Caddy generator will later interpret these values.
IP_ACCESS_METHOD_OPTIONS = (
"WRITE",
"ALL",
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"GET,POST",
"POST,PATCH,DELETE",
)
METADATA_FIELDS = (
"name",
"description",
@@ -605,6 +624,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
template_value = render_template_label(templates, app.get("template", "") or "")
environment_usage_example = render_environment_usage_example(language_raw, runtime_raw, template_raw)
variables = get_app_variables(app.get("id", ""))
ip_access_rules = get_app_ip_access_rules(app.get("id", ""))
incidents = get_service_incidents(app.get("id", ""), limit=20)
current_health = get_service_health(app.get("id", ""))
health_history = get_service_health_history(app.get("id", ""), limit=50)
@@ -701,6 +721,48 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
if not variable_rows:
variable_rows = '<tr><td colspan="4">Zat&iacute;m nejsou evidovan&eacute; &zcaron;&aacute;dn&eacute; prom&ecaron;nn&eacute;.</td></tr>'
def render_method_options(selected: str) -> str:
selected_upper = (selected or "").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
# IP access rules — inputs in each column are associated with the per-row update form via the
# HTML5 form="..." attribute, so the whole row is editable inline while staying valid markup.
ip_access_rule_rows = ""
for rule in ip_access_rules:
rid = html.escape(str(rule.get("id", "")))
form_id = f"iprule-{rid}"
rule_ip = html.escape(rule.get("ip_cidr", "") or "", quote=True)
rule_desc = html.escape(rule.get("description", "") or "", quote=True)
rule_enabled = bool(rule.get("is_enabled", 1))
row_class = "" if rule_enabled else " rule-disabled"
enabled_checked = bool_checked(rule_enabled)
ip_label = html.escape(rule.get("ip_cidr", "") or "")
ip_access_rule_rows += f"""
<tr class="ip-rule-row{row_class}">
<td><input name="ip_cidr" value="{rule_ip}" form="{form_id}" required></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><label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" form="{form_id}"{enabled_checked}> Aktivn&iacute;</label></td>
<td>
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/update" id="{form_id}" class="inline-form"></form>
<button type="submit" form="{form_id}" class="btn btn-compact"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Ulo&zcaron;it</button>
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules/{rid}/delete" class="inline-form" onsubmit="return confirm('Smazat pravidlo {ip_label}?');">
<button type="submit" class="btn btn-secondary btn-compact"><i class="fa-solid fa-trash" aria-hidden="true"></i> Smazat</button>
</form>
</td>
</tr>
"""
if not ip_access_rule_rows:
ip_access_rule_rows = '<tr><td colspan="5">Zat&iacute;m nejsou evidovan&aacute; &zcaron;&aacute;dn&aacute; IP access pravidla.</td></tr>'
notice = ""
if message:
notice = f'<p class="alert">{html.escape(message)}</p>'
@@ -724,6 +786,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
<div class="detail-tabs" aria-label="Sekce detailu slu&zcaron;by">
<a class="btn btn-secondary" href="#metadata">Metadata</a>
<a class="btn btn-secondary" href="#promenne">Prom&ecaron;nn&eacute;</a>
<a class="btn btn-secondary" href="#ip-access">Security / IP Access</a>
<a class="btn btn-secondary" href="#historie">Historie</a>
</div>
"""
@@ -821,6 +884,45 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
else ""
)
ip_access_card = (
f"""
<div class="card" id="ip-access">
<h2><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</h2>
<p class="muted">
Pravidla ur&ccaron;uj&iacute;, z jak&yacute;ch IP/CIDR adres lze volat dan&eacute; HTTP metody.
<strong>WRITE</strong> = POST, PUT, PATCH, DELETE; <strong>ALL</strong> = v&scaron;echny b&ecaron;&zcaron;n&eacute; metody v&ccaron;etn&ecaron; GET.
Pravidla se zat&iacute;m pouze ukl&aacute;daj&iacute; (Caddy je za&ccaron;ne vynucovat pozd&ecaron;ji).
</p>
<table>
<tr>
<th>IP/CIDR</th>
<th>Methods</th>
<th>Description</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
{ip_access_rule_rows}
</table>
<form method="post" action="/portal/apps/{app_url_id}/ip-access-rules" class="metadata-form add-variable-form">
<h3>P&rcaron;idat pravidlo</h3>
<label>IP/CIDR</label>
<input name="ip_cidr" placeholder="nap&rcaron;. 185.10.20.30 nebo 10.0.0.0/24" required>
<label>Methods</label>
<select name="methods">{render_method_options("WRITE")}</select>
<label>Description</label>
<input name="description" placeholder="nap&rcaron;. Office IP">
<label class="checkbox-label"><input type="checkbox" name="is_enabled" value="1" checked> Aktivn&iacute;</label>
<div class="form-actions">
<button type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> P&rcaron;idat pravidlo</button>
</div>
</form>
</div>
"""
if can_manage
else ""
)
return page(
"Detail slu\u017eby",
f"""
@@ -842,6 +944,8 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
{variables_card}
{ip_access_card}
<div class="card">
<h2>Souhrn</h2>
<table>
@@ -1111,6 +1215,94 @@ def apply_app_environment_action(app_id: str, user=Depends(require_developer)):
return redirect_app_detail(app_id, anchor="promenne", message=message)
@router.post("/apps/{app_id}/ip-access-rules")
def add_app_ip_access_rule(
app_id: str,
ip_cidr: str = Form(...),
methods: str = Form("WRITE"),
description: str = Form(""),
is_enabled: str | None = Form(None),
user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
rule_ip = clean_optional(ip_cidr)
rule_methods = clean_optional(methods).upper()
if not rule_ip:
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
if not rule_methods:
return redirect_app_detail(app_id, anchor="ip-access", error="Methods nesmí být prázdné.")
create_app_ip_access_rule(app_id, rule_ip, rule_methods, description.strip(), bool(is_enabled))
log_audit_event(
user,
action="app.ip_access_rule.created",
target_type="app",
target_id=app_id,
metadata={"ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)},
)
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo přidáno.")
@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/update")
def save_app_ip_access_rule(
app_id: str,
rule_id: int,
ip_cidr: str = Form(...),
methods: str = Form("WRITE"),
description: str = Form(""),
is_enabled: str | None = Form(None),
user=Depends(require_developer),
):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
existing = get_app_ip_access_rule(rule_id, app_id)
if not existing:
return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.")
rule_ip = clean_optional(ip_cidr)
rule_methods = clean_optional(methods).upper()
if not rule_ip:
return redirect_app_detail(app_id, anchor="ip-access", error="IP/CIDR nesmí být prázdné.")
if not rule_methods:
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))
log_audit_event(
user,
action="app.ip_access_rule.updated",
target_type="app",
target_id=app_id,
metadata={"rule_id": rule_id, "ip_cidr": rule_ip, "methods": rule_methods, "is_enabled": bool(is_enabled)},
)
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo upraveno.")
@router.post("/apps/{app_id}/ip-access-rules/{rule_id}/delete")
def remove_app_ip_access_rule(app_id: str, rule_id: int, user=Depends(require_developer)):
app = get_app(app_id)
if not app:
raise HTTPException(status_code=404, detail="App not found")
existing = get_app_ip_access_rule(rule_id, app_id)
if not existing:
return redirect_app_detail(app_id, anchor="ip-access", error="IP access pravidlo nebylo nalezeno.")
delete_app_ip_access_rule(rule_id, app_id)
log_audit_event(
user,
action="app.ip_access_rule.deleted",
target_type="app",
target_id=app_id,
metadata={"rule_id": rule_id, "ip_cidr": existing.get("ip_cidr"), "methods": existing.get("methods")},
)
return redirect_app_detail(app_id, anchor="ip-access", message="IP access pravidlo smazáno.")
@router.post("/apps/{app_id}/redeploy")
def redeploy_app(app_id: str, user=Depends(require_developer)):
app = get_app(app_id)
+2 -1
View File
@@ -1,6 +1,7 @@
import html
from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse
from ..auth import require_user
from ..templates.layout import page
@@ -53,7 +54,7 @@ ROLES = [
]
@router.get("/developers")
@router.get("/developers", response_class=HTMLResponse)
def developers_page(user=Depends(require_user)):
section_rows = ""
for icon, title, href, desc, roles in PORTAL_SECTIONS:
+10
View File
@@ -435,6 +435,16 @@ button:hover,
background: var(--muted);
}
/* Disabled IP access rules are dimmed so active/inactive rules are visually distinct. */
.ip-rule-row.rule-disabled {
opacity: 0.55;
}
.ip-rule-row.rule-disabled input,
.ip-rule-row.rule-disabled select {
background: #f3f5f7;
}
.info-dot {
position: relative;
display: inline-flex;