diff --git a/AppImage/components/security.tsx b/AppImage/components/security.tsx index cf944e22..8c896082 100644 --- a/AppImage/components/security.tsx +++ b/AppImage/components/security.tsx @@ -69,10 +69,24 @@ export function Security() { cluster_fw_enabled: boolean host_fw_enabled: boolean rules_count: number - rules: Array<{ raw: string; direction?: string; action?: string; dport?: string; p?: string; source_file?: string; section?: string }> + rules: Array<{ raw: string; direction?: string; action?: string; dport?: string; p?: string; source?: string; source_file?: string; section?: string; rule_index: number }> monitor_port_open: boolean } | null>(null) const [firewallAction, setFirewallAction] = useState(false) + const [showAddRule, setShowAddRule] = useState(false) + const [newRule, setNewRule] = useState({ + direction: "IN", + action: "ACCEPT", + protocol: "tcp", + dport: "", + sport: "", + source: "", + iface: "", + comment: "", + level: "host", + }) + const [addingRule, setAddingRule] = useState(false) + const [deletingRuleIdx, setDeletingRuleIdx] = useState(null) // Security Tools state const [toolsLoading, setToolsLoading] = useState(true) @@ -235,6 +249,58 @@ export function Security() { return `${Math.floor(s / 86400)}d` } + const handleAddRule = async () => { + if (!newRule.dport && !newRule.source) { + setError("Please specify at least a destination port or source address") + return + } + setAddingRule(true) + setError("") + setSuccess("") + try { + const data = await fetchApi("/api/security/firewall/rules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newRule), + }) + if (data.success) { + setSuccess(data.message || "Rule added successfully") + setShowAddRule(false) + setNewRule({ direction: "IN", action: "ACCEPT", protocol: "tcp", dport: "", sport: "", source: "", iface: "", comment: "", level: "host" }) + loadFirewallStatus() + } else { + setError(data.message || "Failed to add rule") + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to add rule") + } finally { + setAddingRule(false) + } + } + + const handleDeleteRule = async (ruleIndex: number, level: string) => { + setDeletingRuleIdx(ruleIndex) + setError("") + setSuccess("") + try { + const data = await fetchApi("/api/security/firewall/rules", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rule_index: ruleIndex, level }), + }) + if (data.success) { + setSuccess(data.message || "Rule deleted") + loadFirewallStatus() + } else { + setError(data.message || "Failed to delete rule") + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete rule") + } finally { + setDeletingRuleIdx(null) + } + } + const handleFirewallToggle = async (level: "host" | "cluster", enable: boolean) => { setFirewallAction(true) setError("") @@ -1485,12 +1551,25 @@ export function Security() { {/* Proxmox Firewall */} -
- - Proxmox Firewall +
+
+ + Proxmox Firewall +
+ {firewallData?.pve_firewall_installed && ( + + )}
- Manage the Proxmox VE built-in firewall at cluster and host level + Manage the Proxmox VE built-in firewall: enable/disable, configure rules, and protect your services @@ -1521,7 +1600,7 @@ export function Security() {

Cluster Firewall

- {firewallData.cluster_fw_enabled ? "Active" : "Disabled"} + {firewallData.cluster_fw_enabled ? "Active - Required for host rules to work" : "Disabled - Must be enabled first"}

@@ -1552,7 +1631,7 @@ export function Security() {

Host Firewall

- {firewallData.host_fw_enabled ? "Active" : "Disabled"} + {firewallData.host_fw_enabled ? "Active - Rules are being enforced" : "Disabled"}

@@ -1575,90 +1654,268 @@ export function Security() { - {/* ProxMenux Monitor Port 8008 */} -
-
-
- -
-
-

ProxMenux Monitor Port (8008/TCP)

-

- {firewallData.monitor_port_open - ? "Port 8008 is allowed in the firewall" - : "Port 8008 is not configured in the firewall"} -

-
-
- -
- - {!firewallData.monitor_port_open && (firewallData.cluster_fw_enabled || firewallData.host_fw_enabled) && ( -
- -

- The firewall is active but port 8008 is not allowed. ProxMenux Monitor may be inaccessible from other devices. Add the rule above to fix this. + {!firewallData.cluster_fw_enabled && ( +

+ +

+ The Cluster Firewall must be enabled for any host-level firewall rules to take effect. Enable it first, then configure your host rules.

)} - {/* Active Rules */} - {firewallData.rules.length > 0 && ( -
-
-

- Active Rules ({firewallData.rules_count}) -

+ {/* Quick Presets */} +
+

Quick Access Rules

+
+ {/* Monitor Port 8008 */} +
+
+
+
+

ProxMenux Monitor

+

Port 8008/TCP

+
+
-
- {firewallData.rules.map((rule, idx) => ( -
- - {rule.action || "?"} - - {rule.direction || "IN"} - {rule.p && {rule.p}} - {rule.dport && :{rule.dport}} - {rule.source_file} + + {/* Proxmox Web UI hint */} +
+
+
+
+

Proxmox Web UI

+

Port 8006/TCP (always allowed)

- ))} +
+ Built-in
- )} -
- -

- For advanced firewall configuration (IP sets, security groups, per-VM rules), use the Proxmox web interface at port 8006. -

+ {!firewallData.monitor_port_open && (firewallData.cluster_fw_enabled || firewallData.host_fw_enabled) && ( +
+ +

+ The firewall is active but port 8008 is not allowed. ProxMenux Monitor may be inaccessible from other devices. +

+
+ )} +
+ + {/* Firewall Rules */} +
+
+

+ Firewall Rules ({firewallData.rules_count}) +

+ +
+ + {/* Add Rule Form */} + {showAddRule && ( +
+
+ +

New Firewall Rule

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + setNewRule({...newRule, dport: e.target.value})} + className="h-9 text-sm" + /> +

Single port, comma-separated, or range (8000:9000)

+
+
+ + setNewRule({...newRule, source: e.target.value})} + className="h-9 text-sm" + /> +

IP, CIDR, or leave empty for any source

+
+
+ +
+
+ + setNewRule({...newRule, iface: e.target.value})} + className="h-9 text-sm" + /> +
+
+ + +
+
+ +
+ + setNewRule({...newRule, comment: e.target.value})} + className="h-9 text-sm" + /> +
+ +
+ + +
+
+ )} + + {/* Rules List */} + {firewallData.rules.length > 0 ? ( +
+ {/* Table header */} +
+ Action + Direction + Proto + Port + Source + Level + +
+ +
+ {firewallData.rules.map((rule, idx) => ( +
+ + {rule.action || "?"} + + {rule.direction || "IN"} + {rule.p || "-"} + {rule.dport || "-"} + {rule.source || "any"} + + {rule.source_file} + + +
+ ))} +
+
+ ) : ( +
+ +

No firewall rules configured yet

+

Click "Add Rule" above to create your first rule

+
+ )}
)} diff --git a/AppImage/scripts/flask_security_routes.py b/AppImage/scripts/flask_security_routes.py index 1462cbf7..71c55190 100644 --- a/AppImage/scripts/flask_security_routes.py +++ b/AppImage/scripts/flask_security_routes.py @@ -59,6 +59,53 @@ def firewall_disable(): return jsonify({"success": False, "message": str(e)}), 500 +@security_bp.route('/api/security/firewall/rules', methods=['POST']) +def firewall_add_rule(): + """Add a custom firewall rule""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + data = request.json or {} + success, message = security_manager.add_firewall_rule( + direction=data.get("direction", "IN"), + action=data.get("action", "ACCEPT"), + protocol=data.get("protocol", "tcp"), + dport=data.get("dport", ""), + sport=data.get("sport", ""), + source=data.get("source", ""), + dest=data.get("dest", ""), + iface=data.get("iface", ""), + comment=data.get("comment", ""), + level=data.get("level", "host"), + ) + 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/rules', methods=['DELETE']) +def firewall_delete_rule(): + """Delete a firewall rule by index""" + 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") + if rule_index is None: + return jsonify({"success": False, "message": "rule_index is required"}), 400 + success, message = security_manager.delete_firewall_rule(int(rule_index), level) + 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 2634517a..86290c32 100644 --- a/AppImage/scripts/security_manager.py +++ b/AppImage/scripts/security_manager.py @@ -106,10 +106,12 @@ def get_firewall_status(): def _parse_firewall_rules(): """Parse all firewall rules from cluster and host configs""" rules = [] + rule_idx_by_file = {} # Track rule index per file for deletion for fw_file, source in [(CLUSTER_FW, "cluster"), (os.path.join(HOST_FW_DIR, "host.fw"), "host")]: if not os.path.isfile(fw_file): continue + rule_idx_by_file[source] = 0 try: with open(fw_file, 'r') as f: content = f.read() @@ -132,7 +134,9 @@ def _parse_firewall_rules(): if in_rules or section in ("RULES", "IN", "OUT"): rule = _parse_rule_line(line, source, section) if rule: + rule["rule_index"] = rule_idx_by_file[source] rules.append(rule) + rule_idx_by_file[source] += 1 except Exception: pass @@ -181,6 +185,152 @@ def _parse_rule_line(line, source, section): return rule +def add_firewall_rule(direction="IN", action="ACCEPT", protocol="tcp", dport="", sport="", + source="", dest="", iface="", comment="", level="host"): + """ + Add a custom firewall rule to host or cluster firewall config. + 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 rule line + parts = [direction, action] + + if protocol: + parts.extend(["-p", protocol.lower()]) + if dport: + # Validate port + 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 dest: + parts.extend(["-dest", dest]) + if iface: + parts.extend(["-i", iface]) + + parts.extend(["-log", "nolog"]) + + if comment: + # Sanitize comment + safe_comment = re.sub(r'[^\w\s\-._/():]', '', comment) + parts.append(f"# {safe_comment}") + + 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") + + try: + content = "" + has_rules_section = False + + if os.path.isfile(fw_file): + with open(fw_file, 'r') as f: + content = f.read() + has_rules_section = "[RULES]" in content + + if has_rules_section: + lines = content.splitlines() + new_lines = [] + inserted = False + for line in lines: + new_lines.append(line) + if not inserted and line.strip() == "[RULES]": + new_lines.append(rule_line) + inserted = True + content = "\n".join(new_lines) + "\n" + else: + if content and not content.endswith("\n"): + content += "\n" + content += "\n[RULES]\n" + content += rule_line + "\n" + + os.makedirs(os.path.dirname(fw_file), exist_ok=True) + with open(fw_file, 'w') as f: + f.write(content) + + _run_cmd(["pve-firewall", "reload"]) + + return True, f"Firewall rule added: {direction} {action} {protocol}{':' + dport if dport else ''}" + except PermissionError: + return False, "Permission denied. Cannot write to firewall config." + except Exception as e: + return False, f"Failed to add firewall rule: {str(e)}" + + +def delete_firewall_rule(rule_index, level="host"): + """ + Delete a firewall rule by index from host or cluster config. + The index corresponds to the order of rules in [RULES] section. + Returns (success, message) + """ + 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 + removed_rule = None + + 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('['): + # This is a rule line + if current_rule_idx == rule_index: + removed_rule = stripped + current_rule_idx += 1 + continue # Skip this line (delete it) + current_rule_idx += 1 + + new_lines.append(line) + + if removed_rule is None: + 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 deleted: {removed_rule}" + except PermissionError: + return False, "Permission denied. Cannot modify firewall config." + except Exception as e: + return False, f"Failed to delete rule: {str(e)}" + + def add_monitor_port_rule(): """ Add a firewall rule to allow port 8008 (ProxMenux Monitor) on the host.