mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-18 16:36:27 +00:00
Update firewall
This commit is contained in:
@@ -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) => {
|
const handleFirewallToggle = async (level: "host" | "cluster", enable: boolean) => {
|
||||||
setFirewallAction(true)
|
setFirewallAction(true)
|
||||||
setError("")
|
setError("")
|
||||||
@@ -2633,7 +2678,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
<div key={ruleKey}>
|
<div key={ruleKey}>
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-[2rem_4.5rem_1fr_2rem] sm:grid-cols-[2rem_4.5rem_2rem_3rem_5rem_1fr_3.5rem_2rem] gap-2 px-3 py-2.5 items-center hover:bg-muted/20 transition-colors cursor-pointer"
|
className="grid grid-cols-[2rem_4.5rem_1fr_2rem] sm:grid-cols-[2rem_4.5rem_2rem_3rem_5rem_1fr_3.5rem_2rem] gap-2 px-3 py-2.5 items-center hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
onClick={() => setExpandedRuleKey(isExpanded ? null : ruleKey)}
|
onClick={() => setExpandedRuleKey(isExpanded ? null : ruleKey)}
|
||||||
>
|
>
|
||||||
{/* Direction icon */}
|
{/* Direction icon */}
|
||||||
@@ -2683,63 +2728,151 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
{/* Expanded details */}
|
{/* Expanded details */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-3 pb-3 pt-0 border-t border-border/50 bg-muted/10">
|
<div className="px-3 pb-3 pt-0 border-t border-border/50 bg-muted/10">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3">
|
{editingRuleKey === ruleKey ? (
|
||||||
<div>
|
/* ── Inline Edit Form ── */
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Direction</p>
|
<div className="py-3 space-y-3">
|
||||||
<p className="text-xs font-medium flex items-center gap-1">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
{direction === "IN" ? <ArrowDownLeft className="h-3 w-3 text-blue-400" /> : <ArrowUpRight className="h-3 w-3 text-amber-400" />}
|
<div>
|
||||||
{direction === "IN" ? "Incoming" : "Outgoing"}
|
<Label className="text-[10px] text-muted-foreground uppercase">Direction</Label>
|
||||||
</p>
|
<select value={editRule.direction} onChange={(e) => setEditRule({ ...editRule, direction: e.target.value })}
|
||||||
</div>
|
className="w-full h-8 text-xs rounded-md border border-border bg-background px-2 mt-0.5">
|
||||||
<div>
|
<option value="IN">IN</option>
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Protocol</p>
|
<option value="OUT">OUT</option>
|
||||||
<p className="text-xs font-medium font-mono">{rule.p || "any"}</p>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Port</p>
|
<Label className="text-[10px] text-muted-foreground uppercase">Action</Label>
|
||||||
<p className="text-xs font-medium font-mono">{rule.dport || "any"}</p>
|
<select value={editRule.action} onChange={(e) => setEditRule({ ...editRule, action: e.target.value })}
|
||||||
</div>
|
className="w-full h-8 text-xs rounded-md border border-border bg-background px-2 mt-0.5">
|
||||||
<div>
|
<option value="ACCEPT">ACCEPT</option>
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Source</p>
|
<option value="DROP">DROP</option>
|
||||||
<p className="text-xs font-medium font-mono">{rule.source || "any"}</p>
|
<option value="REJECT">REJECT</option>
|
||||||
</div>
|
</select>
|
||||||
{rule.i && (
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Interface</p>
|
<Label className="text-[10px] text-muted-foreground uppercase">Protocol</Label>
|
||||||
<p className="text-xs font-medium font-mono">{rule.i}</p>
|
<select value={editRule.protocol} onChange={(e) => setEditRule({ ...editRule, protocol: e.target.value })}
|
||||||
|
className="w-full h-8 text-xs rounded-md border border-border bg-background px-2 mt-0.5">
|
||||||
|
<option value="tcp">TCP</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
<option value="icmp">ICMP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase">Port</Label>
|
||||||
|
<Input value={editRule.dport} onChange={(e) => setEditRule({ ...editRule, dport: e.target.value })}
|
||||||
|
placeholder="e.g. 80,443" className="h-8 text-xs mt-0.5" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Scope</p>
|
<Label className="text-[10px] text-muted-foreground uppercase">Source</Label>
|
||||||
<p className="text-xs font-medium flex items-center gap-1">
|
<Input value={editRule.source} onChange={(e) => setEditRule({ ...editRule, source: e.target.value })}
|
||||||
{rule.source_file === "cluster" ? <Globe className="h-3 w-3 text-blue-400" /> : <Shield className="h-3 w-3 text-purple-400" />}
|
placeholder="IP or CIDR" className="h-8 text-xs mt-0.5" />
|
||||||
{rule.source_file === "cluster" ? "Cluster" : "Host"}
|
</div>
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<Label className="text-[10px] text-muted-foreground uppercase">Interface</Label>
|
||||||
{comment && (
|
<Input value={editRule.iface} onChange={(e) => setEditRule({ ...editRule, iface: e.target.value })}
|
||||||
<div className="col-span-2">
|
placeholder="e.g. vmbr0" className="h-8 text-xs mt-0.5" />
|
||||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Comment</p>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{comment}</p>
|
<div className="col-span-2 sm:col-span-1">
|
||||||
|
<Label className="text-[10px] text-muted-foreground uppercase">Comment</Label>
|
||||||
|
<Input value={editRule.comment} onChange={(e) => setEditRule({ ...editRule, comment: e.target.value })}
|
||||||
|
placeholder="Description" className="h-8 text-xs mt-0.5" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
</div>
|
<Button variant="ghost" size="sm"
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-border/50">
|
onClick={(e) => { e.stopPropagation(); setEditingRuleKey(null) }}
|
||||||
<code className="text-[10px] text-muted-foreground/60 font-mono truncate max-w-[70%]">{rule.raw}</code>
|
className="h-7 text-xs text-muted-foreground">
|
||||||
<Button
|
<X className="h-3 w-3 mr-1" /> Cancel
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
<Button variant="outline" size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); handleDeleteRule(rule.rule_index, rule.source_file) }}
|
onClick={(e) => { e.stopPropagation(); handleSaveEditRule(rule.rule_index, rule.source_file || "host") }}
|
||||||
disabled={deletingRuleIdx === rule.rule_index}
|
disabled={savingRule}
|
||||||
className="h-7 text-xs text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent"
|
className="h-7 text-xs text-green-500 border-green-500/30 hover:bg-green-500/10 bg-transparent">
|
||||||
>
|
{savingRule ? (
|
||||||
{deletingRuleIdx === rule.rule_index ? (
|
<div className="animate-spin h-3 w-3 border-2 border-green-500 border-t-transparent rounded-full mr-1" />
|
||||||
<div className="animate-spin h-3 w-3 border-2 border-red-500 border-t-transparent rounded-full mr-1" />
|
) : (
|
||||||
) : (
|
<Check className="h-3 w-3 mr-1" />
|
||||||
<Trash2 className="h-3 w-3 mr-1" />
|
)}
|
||||||
)}
|
Save Changes
|
||||||
Delete Rule
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Read-only Details ── */
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Direction</p>
|
||||||
|
<p className="text-xs font-medium flex items-center gap-1">
|
||||||
|
{direction === "IN" ? <ArrowDownLeft className="h-3 w-3 text-blue-400" /> : <ArrowUpRight className="h-3 w-3 text-amber-400" />}
|
||||||
|
{direction === "IN" ? "Incoming" : "Outgoing"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Protocol</p>
|
||||||
|
<p className="text-xs font-medium font-mono">{rule.p || "any"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Port</p>
|
||||||
|
<p className="text-xs font-medium font-mono">{rule.dport || "any"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Source</p>
|
||||||
|
<p className="text-xs font-medium font-mono">{rule.source || "any"}</p>
|
||||||
|
</div>
|
||||||
|
{rule.i && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Interface</p>
|
||||||
|
<p className="text-xs font-medium font-mono">{rule.i}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Scope</p>
|
||||||
|
<p className="text-xs font-medium flex items-center gap-1">
|
||||||
|
{rule.source_file === "cluster" ? <Globe className="h-3 w-3 text-blue-400" /> : <Shield className="h-3 w-3 text-purple-400" />}
|
||||||
|
{rule.source_file === "cluster" ? "Cluster" : "Host"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{comment && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-0.5">Comment</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{comment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-border/50">
|
||||||
|
<code className="text-[10px] text-muted-foreground/60 font-mono truncate max-w-[50%]">{rule.raw}</code>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); startEditRule(rule) }}
|
||||||
|
className="h-7 text-xs text-blue-400 border-blue-400/30 hover:bg-blue-400/10 bg-transparent"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteRule(rule.rule_index, rule.source_file) }}
|
||||||
|
disabled={deletingRuleIdx === rule.rule_index}
|
||||||
|
className="h-7 text-xs text-red-500 border-red-500/30 hover:bg-red-500/10 bg-transparent"
|
||||||
|
>
|
||||||
|
{deletingRuleIdx === rule.rule_index ? (
|
||||||
|
<div className="animate-spin h-3 w-3 border-2 border-red-500 border-t-transparent rounded-full mr-1" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,6 +106,39 @@ def firewall_delete_rule():
|
|||||||
return jsonify({"success": False, "message": str(e)}), 500
|
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'])
|
@security_bp.route('/api/security/firewall/monitor-port', methods=['POST'])
|
||||||
def firewall_add_monitor_port():
|
def firewall_add_monitor_port():
|
||||||
"""Add firewall rule to allow port 8008 for ProxMenux Monitor"""
|
"""Add firewall rule to allow port 8008 for ProxMenux Monitor"""
|
||||||
|
|||||||
@@ -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)}"
|
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"):
|
def delete_firewall_rule(rule_index, level="host"):
|
||||||
"""
|
"""
|
||||||
Delete a firewall rule by index from host or cluster config.
|
Delete a firewall rule by index from host or cluster config.
|
||||||
|
|||||||
Reference in New Issue
Block a user