Add central host-wide firewall allow/block lists

New model.IPListEntry + jsondb CRUD, independent of any single WireGuard
server. firewall.GenerateGlobalRuleset builds an nftables table
(wireguard_ui_global) with allow/block sets evaluated at priority -10 -
before every per-server table - so it applies to all traffic on the host,
not just WireGuard. Allow entries always win over block entries.
firewall.ApplyGlobal loads it live via `nft -f`, scoped to that one table.

New "Global Firewall Lists" page (nav entry under Settings): add/delete
entries, ruleset preview, "Apply now (live)" with an explicit confirm()
warning since this affects the whole host's firewall, not just one server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-12 17:45:53 +02:00
co-authored by Claude Sonnet 5
parent bdcf1ec60c
commit eb1913d400
10 changed files with 438 additions and 9 deletions
+19 -9
View File
@@ -8,18 +8,17 @@ import (
"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) {
// applyTable writes ruleset to a temp file and loads it with `nft -f`,
// after first deleting the given table (ignoring the error - the table may
// not exist yet on first apply). Only ever touches that single table,
// never any other nftables state.
func applyTable(tableName, 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()
// best-effort: drop the previous version of this table so reapplying
// is idempotent. Error ignored - table may not exist yet.
_ = exec.CommandContext(ctx, "nft", "delete", "table", "inet", tableName).Run()
tmpFile, err := os.CreateTemp("", "wg-ui-multi-fw-*.nft")
if err != nil {
@@ -42,3 +41,14 @@ func Apply(serverID, ruleset string) (string, error) {
}
return string(out), nil
}
// Apply loads a single server's ruleset live, scoped to TableName(serverID).
func Apply(serverID, ruleset string) (string, error) {
return applyTable(TableName(serverID), ruleset)
}
// ApplyGlobal loads the host-wide allow/block list ruleset live, scoped to
// GlobalTableName.
func ApplyGlobal(ruleset string) (string, error) {
return applyTable(GlobalTableName, ruleset)
}
+63
View File
@@ -0,0 +1,63 @@
package firewall
import (
"fmt"
"strings"
"github.com/ngoduykhanh/wireguard-ui/model"
)
// GlobalTableName is the dedicated nftables table for the host-wide
// allow/block lists, kept separate from every per-server table.
const GlobalTableName = "wireguard_ui_global"
// GenerateGlobalRuleset renders the host-wide allow/block list ruleset.
// It runs at priority -10, before any per-server table (priority 0), so it
// applies to ALL traffic on the host, not just WireGuard - this is meant to
// be the one central place for IP allow/block lists. Allow entries take
// precedence over block entries.
func GenerateGlobalRuleset(entries []model.IPListEntry) string {
var allow, block []string
for _, e := range entries {
if e.ListType == "allow" {
allow = append(allow, e.CIDR)
} else if e.ListType == "block" {
block = append(block, e.CIDR)
}
}
var b strings.Builder
fmt.Fprintf(&b, "# Host-wide allow/block list, generated by wireguard-ui-multi.\n")
fmt.Fprintf(&b, "# Applies to all traffic on this host (priority -10), before any\n")
fmt.Fprintf(&b, "# per-server WireGuard firewall table.\n\n")
fmt.Fprintf(&b, "table inet %s {\n", GlobalTableName)
fmt.Fprintf(&b, " set allowlist {\n type ipv4_addr; flags interval;\n")
if len(allow) > 0 {
fmt.Fprintf(&b, " elements = { %s }\n", strings.Join(allow, ", "))
}
fmt.Fprintf(&b, " }\n\n")
fmt.Fprintf(&b, " set blocklist {\n type ipv4_addr; flags interval;\n")
if len(block) > 0 {
fmt.Fprintf(&b, " elements = { %s }\n", strings.Join(block, ", "))
}
fmt.Fprintf(&b, " }\n\n")
fmt.Fprintf(&b, " chain input {\n")
fmt.Fprintf(&b, " type filter hook input priority -10; policy accept;\n")
fmt.Fprintf(&b, " ip saddr @allowlist accept comment \"wg-ui-multi: global allowlist\"\n")
fmt.Fprintf(&b, " ip saddr @blocklist drop comment \"wg-ui-multi: global blocklist\"\n")
fmt.Fprintf(&b, " }\n\n")
fmt.Fprintf(&b, " chain forward {\n")
fmt.Fprintf(&b, " type filter hook forward priority -10; policy accept;\n")
fmt.Fprintf(&b, " ip saddr @allowlist accept comment \"wg-ui-multi: global allowlist\"\n")
fmt.Fprintf(&b, " ip saddr @blocklist drop comment \"wg-ui-multi: global blocklist\"\n")
fmt.Fprintf(&b, " }\n")
fmt.Fprintf(&b, "}\n")
return b.String()
}