diff --git a/AppImage/components/security.tsx b/AppImage/components/security.tsx index 7e79bed0..bf99345e 100644 --- a/AppImage/components/security.tsx +++ b/AppImage/components/security.tsx @@ -479,6 +479,51 @@ export function Security() { } } + const startEditRule = (rule: any) => { + const ruleKey = `${rule.source_file}-${rule.rule_index}` + const comment = rule.raw?.includes("#") ? rule.raw.split("#").slice(1).join("#").trim() : "" + setEditingRuleKey(ruleKey) + setEditRule({ + direction: rule.direction || "IN", + action: rule.action || "ACCEPT", + protocol: rule.p || "tcp", + dport: rule.dport || "", + sport: "", + source: rule.source || "", + iface: rule.i || "", + comment, + level: rule.source_file || "host", + }) + } + + const handleSaveEditRule = async (oldRuleIndex: number, oldLevel: string) => { + setSavingRule(true) + setError("") + setSuccess("") + try { + const data = await fetchApi("/api/security/firewall/rules/edit", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rule_index: oldRuleIndex, + level: oldLevel, + new_rule: editRule, + }), + }) + if (data.success) { + setSuccess(data.message || "Rule updated successfully") + setEditingRuleKey(null) + loadFirewallStatus() + } else { + setError(data.message || "Failed to update rule") + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update rule") + } finally { + setSavingRule(false) + } + } + const handleFirewallToggle = async (level: "host" | "cluster", enable: boolean) => { setFirewallAction(true) setError("") @@ -2633,7 +2678,7 @@ ${(report.sections && report.sections.length > 0) ? `
{/* Main row */}
setExpandedRuleKey(isExpanded ? null : ruleKey)} > {/* Direction icon */} @@ -2683,63 +2728,151 @@ ${(report.sections && report.sections.length > 0) ? ` {/* Expanded details */} {isExpanded && (
-
-
-

Direction

-

- {direction === "IN" ? : } - {direction === "IN" ? "Incoming" : "Outgoing"} -

-
-
-

Protocol

-

{rule.p || "any"}

-
-
-

Port

-

{rule.dport || "any"}

-
-
-

Source

-

{rule.source || "any"}

-
- {rule.i && ( -
-

Interface

-

{rule.i}

+ {editingRuleKey === ruleKey ? ( + /* ── Inline Edit Form ── */ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + setEditRule({ ...editRule, dport: e.target.value })} + placeholder="e.g. 80,443" className="h-8 text-xs mt-0.5" /> +
- )} -
-

Scope

-

- {rule.source_file === "cluster" ? : } - {rule.source_file === "cluster" ? "Cluster" : "Host"} -

-
- {comment && ( -
-

Comment

-

{comment}

+
+
+ + setEditRule({ ...editRule, source: e.target.value })} + placeholder="IP or CIDR" className="h-8 text-xs mt-0.5" /> +
+
+ + setEditRule({ ...editRule, iface: e.target.value })} + placeholder="e.g. vmbr0" className="h-8 text-xs mt-0.5" /> +
+
+ + setEditRule({ ...editRule, comment: e.target.value })} + placeholder="Description" className="h-8 text-xs mt-0.5" /> +
- )} -
-
- {rule.raw} - -
+
+ + +
+
+ ) : ( + /* ── Read-only Details ── */ + <> +
+
+

Direction

+

+ {direction === "IN" ? : } + {direction === "IN" ? "Incoming" : "Outgoing"} +

+
+
+

Protocol

+

{rule.p || "any"}

+
+
+

Port

+

{rule.dport || "any"}

+
+
+

Source

+

{rule.source || "any"}

+
+ {rule.i && ( +
+

Interface

+

{rule.i}

+
+ )} +
+

Scope

+

+ {rule.source_file === "cluster" ? : } + {rule.source_file === "cluster" ? "Cluster" : "Host"} +

+
+ {comment && ( +
+

Comment

+

{comment}

+
+ )} +
+
+ {rule.raw} +
+ + +
+
+ + )}
)}
diff --git a/AppImage/scripts/flask_security_routes.py b/AppImage/scripts/flask_security_routes.py index 426145da..9870c72b 100644 --- a/AppImage/scripts/flask_security_routes.py +++ b/AppImage/scripts/flask_security_routes.py @@ -106,6 +106,39 @@ def firewall_delete_rule(): return jsonify({"success": False, "message": str(e)}), 500 +@security_bp.route('/api/security/firewall/rules/edit', methods=['PUT']) +def firewall_edit_rule(): + """Edit an existing firewall rule (delete old + insert new at same position)""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + data = request.json or {} + rule_index = data.get("rule_index") + level = data.get("level", "host") + new_rule = data.get("new_rule", {}) + if rule_index is None: + return jsonify({"success": False, "message": "rule_index is required"}), 400 + + success, message = security_manager.edit_firewall_rule( + rule_index=int(rule_index), + level=level, + direction=new_rule.get("direction", "IN"), + action=new_rule.get("action", "ACCEPT"), + protocol=new_rule.get("protocol", "tcp"), + dport=new_rule.get("dport", ""), + sport=new_rule.get("sport", ""), + source=new_rule.get("source", ""), + iface=new_rule.get("iface", ""), + comment=new_rule.get("comment", ""), + ) + if success: + return jsonify({"success": True, "message": message}) + else: + return jsonify({"success": False, "message": message}), 400 + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + @security_bp.route('/api/security/firewall/monitor-port', methods=['POST']) def firewall_add_monitor_port(): """Add firewall rule to allow port 8008 for ProxMenux Monitor""" diff --git a/AppImage/scripts/security_manager.py b/AppImage/scripts/security_manager.py index 1a9472d6..8958a284 100644 --- a/AppImage/scripts/security_manager.py +++ b/AppImage/scripts/security_manager.py @@ -274,6 +274,96 @@ def add_firewall_rule(direction="IN", action="ACCEPT", protocol="tcp", dport="", return False, f"Failed to add firewall rule: {str(e)}" +def edit_firewall_rule(rule_index, level="host", direction="IN", action="ACCEPT", + protocol="tcp", dport="", sport="", source="", iface="", comment=""): + """ + Edit an existing firewall rule by replacing it in-place. + Deletes the old rule at rule_index and inserts the new one at the same position. + Returns (success, message) + """ + # Validate inputs + action = action.upper() + if action not in ("ACCEPT", "DROP", "REJECT"): + return False, f"Invalid action: {action}. Must be ACCEPT, DROP, or REJECT" + direction = direction.upper() + if direction not in ("IN", "OUT"): + return False, f"Invalid direction: {direction}. Must be IN or OUT" + + # Build new rule line + parts = [direction, action] + if protocol: + parts.extend(["-p", protocol.lower()]) + if dport: + if not re.match(r'^[\d:,]+$', dport): + return False, f"Invalid destination port: {dport}" + parts.extend(["-dport", dport]) + if sport: + if not re.match(r'^[\d:,]+$', sport): + return False, f"Invalid source port: {sport}" + parts.extend(["-sport", sport]) + if source: + parts.extend(["-source", source]) + if iface: + parts.extend(["-i", iface]) + parts.extend(["-log", "nolog"]) + if comment: + safe_comment = re.sub(r'[^\w\s\-._/():]', '', comment) + parts.append(f"# {safe_comment}") + new_rule_line = " ".join(parts) + + # Determine target file + if level == "cluster": + fw_file = CLUSTER_FW + else: + fw_file = os.path.join(HOST_FW_DIR, "host.fw") + + if not os.path.isfile(fw_file): + return False, "Firewall config file not found" + + try: + with open(fw_file, 'r') as f: + content = f.read() + + lines = content.splitlines() + new_lines = [] + in_rules = False + current_rule_idx = 0 + replaced = False + + for line in lines: + stripped = line.strip() + if stripped.startswith('['): + section_match = re.match(r'\[(\w+)\]', stripped) + if section_match: + section = section_match.group(1).upper() + in_rules = section in ("RULES", "IN", "OUT") + + if in_rules and stripped and not stripped.startswith('#') and not stripped.startswith('['): + if current_rule_idx == rule_index: + # Replace the old rule with the new one + new_lines.append(new_rule_line) + replaced = True + current_rule_idx += 1 + continue + current_rule_idx += 1 + + new_lines.append(line) + + if not replaced: + return False, f"Rule index {rule_index} not found" + + with open(fw_file, 'w') as f: + f.write("\n".join(new_lines) + "\n") + + _run_cmd(["pve-firewall", "reload"]) + + return True, f"Firewall rule updated: {direction} {action} {protocol}{':' + dport if dport else ''}" + except PermissionError: + return False, "Permission denied. Cannot modify firewall config." + except Exception as e: + return False, f"Failed to edit rule: {str(e)}" + + def delete_firewall_rule(rule_index, level="host"): """ Delete a firewall rule by index from host or cluster config.