From a948324b2e6fa6f1f8007bc6a4bf73e1a03e8600 Mon Sep 17 00:00:00 2001
From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com>
Date: Mon, 15 Jun 2026 11:08:31 +0200
Subject: [PATCH] dev fix, ip controls
---
app/db/apps.py | 90 ++++++++++++++++++
app/db/migrations.py | 16 ++++
app/routes/apps.py | 192 +++++++++++++++++++++++++++++++++++++++
app/routes/developers.py | 3 +-
app/static/styles.css | 10 ++
5 files changed, 310 insertions(+), 1 deletion(-)
diff --git a/app/db/apps.py b/app/db/apps.py
index 0b67654..5dbb137 100644
--- a/app/db/apps.py
+++ b/app/db/apps.py
@@ -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()
diff --git a/app/db/migrations.py b/app/db/migrations.py
index c844f0c..bd7518e 100644
--- a/app/db/migrations.py
+++ b/app/db/migrations.py
@@ -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()
diff --git a/app/routes/apps.py b/app/routes/apps.py
index 3a8fa23..a302d53 100644
--- a/app/routes/apps.py
+++ b/app/routes/apps.py
@@ -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 = '
Zatím nejsou evidované žádné proměnné. '
+ 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'{html.escape(opt)} '
+ 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"""
+
+
+ {render_method_options(rule.get("methods", ""))}
+
+ Aktivní
+
+
+ Uložit
+
+
+
+ """
+
+ if not ip_access_rule_rows:
+ ip_access_rule_rows = 'Zatím nejsou evidovaná žádná IP access pravidla. '
+
notice = ""
if message:
notice = f'{html.escape(message)}
'
@@ -724,6 +786,7 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
"""
@@ -821,6 +884,45 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
else ""
)
+ ip_access_card = (
+ f"""
+
+
Security / IP Access
+
+ Pravidla určují, z jakých IP/CIDR adres lze volat dané HTTP metody.
+ WRITE = POST, PUT, PATCH, DELETE; ALL = všechny běžné metody včetně GET.
+ Pravidla se zatím pouze ukládají (Caddy je začne vynucovat později).
+
+
+
+ IP/CIDR
+ Methods
+ Description
+ Enabled
+ Actions
+
+ {ip_access_rule_rows}
+
+
+
+
+ """
+ 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}
+
Souhrn
@@ -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)
diff --git a/app/routes/developers.py b/app/routes/developers.py
index 4fe29db..aef4b48 100644
--- a/app/routes/developers.py
+++ b/app/routes/developers.py
@@ -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:
diff --git a/app/static/styles.css b/app/static/styles.css
index eed7937..0640189 100644
--- a/app/static/styles.css
+++ b/app/static/styles.css
@@ -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;