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}
+
- )}
-
-
-
{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 && (
+
+ )}
+
+
Scope
+
+ {rule.source_file === "cluster" ? : }
+ {rule.source_file === "cluster" ? "Cluster" : "Host"}
+
+
+ {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.