Add live firewall rule management per server (nftables)
New model.FirewallRule + jsondb CRUD (GetFirewallRules/CreateFirewallRule/ UpdateFirewallRule/DeleteFirewallRule), scoped per server. firewall package now generates a full ruleset (baseline + enabled custom rules) and can apply it live via `nft -f` (firewall.Apply), scoped to a per-server nftables table (wireguard_ui_<serverID>) so applying one server never touches another server's rules or any pre-existing firewall state. New endpoints: GET/POST /servers/:id/firewall/rules, POST .../rules/:ruleId, POST .../rules/:ruleId/delete, POST .../apply (live, admin-only). UI in the All Servers page: rule table with add/delete, ruleset preview, and an "Apply now (live)" button with an explicit confirm() warning before it touches the running firewall. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
28eb08df41
commit
1d080904b0
@@ -0,0 +1,44 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Apply writes ruleset to a temp file and loads it with `nft -f`, after
|
||||
// first deleting the server's own table (ignoring the error - the table
|
||||
// may not exist yet on first apply). Only ever touches the single table
|
||||
// named by TableName(serverID), never any other nftables state.
|
||||
// Returns combined nft output for display, and an error if the load failed.
|
||||
func Apply(serverID, ruleset string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// best-effort: drop any previous version of this server's table so
|
||||
// reapplying is idempotent. Error ignored - table may not exist yet.
|
||||
_ = exec.CommandContext(ctx, "nft", "delete", "table", "inet", TableName(serverID)).Run()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "wg-ui-multi-fw-*.nft")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot create temp ruleset file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if _, err := tmpFile.WriteString(ruleset); err != nil {
|
||||
tmpFile.Close()
|
||||
return "", fmt.Errorf("cannot write temp ruleset file: %w", err)
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
return "", fmt.Errorf("cannot close temp ruleset file: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "nft", "-f", tmpFile.Name())
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("nft -f failed: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
+59
-19
@@ -1,7 +1,7 @@
|
||||
// Package firewall generates an nftables ruleset preview for a WireGuard
|
||||
// server. It never touches the live firewall - the output is text only,
|
||||
// meant to be reviewed and applied manually (nft -f <file>) or copied into
|
||||
// an existing ruleset.
|
||||
// Package firewall builds and (optionally) applies an nftables ruleset for
|
||||
// a WireGuard server. Every server gets its own table
|
||||
// (inet wireguard_ui_<serverID>) so applying/removing one server's rules
|
||||
// never touches any other table on the system.
|
||||
package firewall
|
||||
|
||||
import (
|
||||
@@ -11,10 +11,38 @@ import (
|
||||
"github.com/ngoduykhanh/wireguard-ui/model"
|
||||
)
|
||||
|
||||
// GeneratePreview renders an nftables ruleset snippet for the given server.
|
||||
// If settings.LanInterface is empty, only WireGuard-interface-local traffic
|
||||
// rules are emitted; forwarding to a LAN interface is added when set.
|
||||
func GeneratePreview(server model.Server, settings model.ServerSetting) string {
|
||||
// TableName returns the dedicated nftables table name for a server.
|
||||
func TableName(serverID string) string {
|
||||
return "wireguard_ui_" + serverID
|
||||
}
|
||||
|
||||
// buildRuleLine renders one custom rule as an nftables statement.
|
||||
func buildRuleLine(rule model.FirewallRule) string {
|
||||
var parts []string
|
||||
if rule.Source != "" {
|
||||
parts = append(parts, fmt.Sprintf("ip saddr %s", rule.Source))
|
||||
}
|
||||
switch {
|
||||
case rule.Protocol != "" && rule.Port != "":
|
||||
parts = append(parts, fmt.Sprintf("%s dport %s", rule.Protocol, rule.Port))
|
||||
case rule.Protocol != "":
|
||||
parts = append(parts, fmt.Sprintf("meta l4proto %s", rule.Protocol))
|
||||
case rule.Port != "":
|
||||
parts = append(parts, fmt.Sprintf("th dport %s", rule.Port))
|
||||
}
|
||||
parts = append(parts, rule.Action)
|
||||
comment := rule.Comment
|
||||
if comment == "" {
|
||||
comment = "wg-ui-multi custom rule"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("comment %q", comment))
|
||||
return " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// GenerateRuleset renders the full nftables ruleset for a server: the
|
||||
// baseline (listen-port accept, WireGuard-interface forwarding, optional
|
||||
// LAN forwarding) plus every enabled custom rule, grouped by chain.
|
||||
func GenerateRuleset(server model.Server, settings model.ServerSetting, rules []model.FirewallRule) string {
|
||||
ifaceName := "wgX"
|
||||
listenPort := 0
|
||||
if server.Interface != nil {
|
||||
@@ -24,16 +52,30 @@ func GeneratePreview(server model.Server, settings model.ServerSetting) string {
|
||||
listenPort = server.Interface.ListenPort
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# nftables ruleset preview for server %q (%s)\n", server.Name, server.ID)
|
||||
fmt.Fprintf(&b, "# Generated by wireguard-ui-multi - review before applying, e.g.:\n")
|
||||
fmt.Fprintf(&b, "# nft -f this-file.nft\n")
|
||||
fmt.Fprintf(&b, "# Not applied automatically.\n\n")
|
||||
var inputExtra, forwardExtra []string
|
||||
for _, rule := range rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
line := buildRuleLine(rule)
|
||||
if rule.Direction == "forward" {
|
||||
forwardExtra = append(forwardExtra, line)
|
||||
} else {
|
||||
inputExtra = append(inputExtra, line)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "table inet wireguard_ui_%s {\n", server.ID)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# nftables ruleset for server %q (%s)\n", server.Name, server.ID)
|
||||
fmt.Fprintf(&b, "# Generated by wireguard-ui-multi.\n\n")
|
||||
|
||||
fmt.Fprintf(&b, "table inet %s {\n", TableName(server.ID))
|
||||
fmt.Fprintf(&b, " chain input {\n")
|
||||
fmt.Fprintf(&b, " type filter hook input priority 0; policy accept;\n")
|
||||
fmt.Fprintf(&b, " udp dport %d accept comment \"wg-ui-multi: %s\"\n", listenPort, server.ID)
|
||||
for _, line := range inputExtra {
|
||||
fmt.Fprintf(&b, "%s\n", line)
|
||||
}
|
||||
fmt.Fprintf(&b, " }\n\n")
|
||||
|
||||
fmt.Fprintf(&b, " chain forward {\n")
|
||||
@@ -44,13 +86,11 @@ func GeneratePreview(server model.Server, settings model.ServerSetting) string {
|
||||
fmt.Fprintf(&b, " iifname \"%s\" oifname \"%s\" accept comment \"wg-ui-multi: %s -> lan\"\n", ifaceName, settings.LanInterface, server.ID)
|
||||
fmt.Fprintf(&b, " iifname \"%s\" oifname \"%s\" accept comment \"wg-ui-multi: lan -> %s\"\n", settings.LanInterface, ifaceName, server.ID)
|
||||
}
|
||||
for _, line := range forwardExtra {
|
||||
fmt.Fprintf(&b, "%s\n", line)
|
||||
}
|
||||
fmt.Fprintf(&b, " }\n")
|
||||
fmt.Fprintf(&b, "}\n")
|
||||
|
||||
if settings.LanInterface == "" {
|
||||
fmt.Fprintf(&b, "\n# No LAN interface configured for this server - peers can only reach\n")
|
||||
fmt.Fprintf(&b, "# each other, not your LAN. Set one in Server Settings to add forwarding.\n")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user