diff --git a/firewall/apply.go b/firewall/apply.go
index ec3cb60..70d0e14 100644
--- a/firewall/apply.go
+++ b/firewall/apply.go
@@ -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)
+}
diff --git a/firewall/global.go b/firewall/global.go
new file mode 100644
index 0000000..5b8ac82
--- /dev/null
+++ b/firewall/global.go
@@ -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()
+}
diff --git a/handler/routes.go b/handler/routes.go
index 853b5c1..fbe2991 100644
--- a/handler/routes.go
+++ b/handler/routes.go
@@ -1779,3 +1779,107 @@ func GetSystemUpdateStatus() echo.HandlerFunc {
return c.JSON(http.StatusOK, system.CheckAptUpdates())
}
}
+
+// FirewallListsPage renders the host-wide allow/block list management page.
+func FirewallListsPage() echo.HandlerFunc {
+ return func(c echo.Context) error {
+ return c.Render(http.StatusOK, "firewall_lists.html", map[string]interface{}{
+ "baseData": model.BaseData{Active: "firewall-lists", CurrentUser: currentUser(c), Admin: isAdmin(c)},
+ })
+ }
+}
+
+func validateIPListEntry(entry model.IPListEntry) error {
+ if entry.ListType != "allow" && entry.ListType != "block" {
+ return fmt.Errorf("list_type must be 'allow' or 'block'")
+ }
+ if !util.ValidateServerAddresses([]string{entry.CIDR}) && net.ParseIP(entry.CIDR) == nil {
+ return fmt.Errorf("cidr must be a valid IP or CIDR")
+ }
+ if len(entry.Comment) > 200 {
+ return fmt.Errorf("comment too long")
+ }
+ return nil
+}
+
+// GetIPListEntries lists every host-wide allow/block list entry.
+func GetIPListEntries(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ entries, err := db.GetIPListEntries()
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+ return c.JSON(http.StatusOK, entries)
+ }
+}
+
+// CreateIPListEntryHandler adds a new host-wide allow/block list entry.
+func CreateIPListEntryHandler(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ var entry model.IPListEntry
+ if err := c.Bind(&entry); err != nil {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
+ }
+ if err := validateIPListEntry(entry); err != nil {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
+ }
+ entry.ID = xid.New().String()
+ entry.CreatedAt = time.Now().UTC()
+ if err := db.CreateIPListEntry(entry); err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot create entry: %v", err)})
+ }
+ return c.JSON(http.StatusOK, entry)
+ }
+}
+
+// DeleteIPListEntryHandler removes a host-wide allow/block list entry.
+func DeleteIPListEntryHandler(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ id := c.Param("id")
+ if err := db.DeleteIPListEntry(id); err != nil {
+ return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
+ }
+ return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Entry deleted successfully"})
+ }
+}
+
+// GetGlobalFirewallPreview returns the generated host-wide allow/block list
+// ruleset as plain text. Preview only.
+func GetGlobalFirewallPreview(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ entries, err := db.GetIPListEntries()
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+ return c.String(http.StatusOK, firewall.GenerateGlobalRuleset(entries))
+ }
+}
+
+// ApplyGlobalFirewallHandler loads the host-wide allow/block list ruleset
+// live via `nft -f`, scoped to firewall.GlobalTableName only. Runs at
+// priority -10, before every per-server WireGuard firewall table, so it
+// applies to all traffic on the host.
+func ApplyGlobalFirewallHandler(db store.IStore) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ entries, err := db.GetIPListEntries()
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
+ }
+ ruleset := firewall.GenerateGlobalRuleset(entries)
+ output, err := firewall.ApplyGlobal(ruleset)
+ if err != nil {
+ log.Errorf("Failed to apply global firewall lists: %v\n%s", err, output)
+ return c.JSON(http.StatusInternalServerError, map[string]interface{}{
+ "success": false,
+ "message": err.Error(),
+ "output": output,
+ })
+ }
+ log.Infof("Applied global firewall allow/block lists")
+ return c.JSON(http.StatusOK, map[string]interface{}{
+ "success": true,
+ "message": "Global firewall lists applied successfully",
+ "output": output,
+ })
+ }
+}
diff --git a/main.go b/main.go
index 95d6308..074b35d 100644
--- a/main.go
+++ b/main.go
@@ -237,6 +237,12 @@ func main() {
app.GET(util.BasePath+"/test-hash", handler.GetHashesChanges(db), handler.ValidSession)
app.GET(util.BasePath+"/about", handler.AboutPage())
app.GET(util.BasePath+"/system/update-status", handler.GetSystemUpdateStatus(), handler.ValidSession, handler.NeedsAdmin)
+ app.GET(util.BasePath+"/firewall-lists", handler.FirewallListsPage(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
+ app.GET(util.BasePath+"/firewall-lists/entries", handler.GetIPListEntries(db), handler.ValidSession, handler.NeedsAdmin)
+ app.POST(util.BasePath+"/firewall-lists/entries", handler.CreateIPListEntryHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
+ app.POST(util.BasePath+"/firewall-lists/entries/:id/delete", handler.DeleteIPListEntryHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
+ app.GET(util.BasePath+"/firewall-lists/preview", handler.GetGlobalFirewallPreview(db), handler.ValidSession, handler.NeedsAdmin)
+ app.POST(util.BasePath+"/firewall-lists/apply", handler.ApplyGlobalFirewallHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/_health", handler.Health())
app.GET(util.BasePath+"/favicon", handler.Favicon())
app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson)
diff --git a/model/iplist.go b/model/iplist.go
new file mode 100644
index 0000000..2ccc9ed
--- /dev/null
+++ b/model/iplist.go
@@ -0,0 +1,15 @@
+package model
+
+import "time"
+
+// IPListEntry is a single CIDR/IP entry in the host-wide allow or block
+// list. These are not scoped to a single WireGuard server - they apply to
+// the whole host, evaluated before any per-server firewall rules (see
+// firewall.GlobalTableName / firewall.GenerateGlobalRuleset).
+type IPListEntry struct {
+ ID string `json:"id"`
+ ListType string `json:"list_type"` // "allow" or "block"
+ CIDR string `json:"cidr"`
+ Comment string `json:"comment"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/router/router.go b/router/router.go
index 30cb203..0f113b1 100644
--- a/router/router.go
+++ b/router/router.go
@@ -116,6 +116,11 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
log.Fatal(err)
}
+ tmplFirewallListsString, err := util.StringFromEmbedFile(tmplDir, "firewall_lists.html")
+ if err != nil {
+ log.Fatal(err)
+ }
+
// create template list
funcs := template.FuncMap{
"StringsJoin": strings.Join,
@@ -131,6 +136,7 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString))
templates["servers.html"] = template.Must(template.New("servers").Funcs(funcs).Parse(tmplBaseString + tmplServersString))
templates["server_clients.html"] = template.Must(template.New("server_clients").Funcs(funcs).Parse(tmplBaseString + tmplServerClientsString))
+ templates["firewall_lists.html"] = template.Must(template.New("firewall_lists").Funcs(funcs).Parse(tmplBaseString + tmplFirewallListsString))
lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
if err != nil {
diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go
index d27ee1b..3bebf7f 100644
--- a/store/jsondb/jsondb.go
+++ b/store/jsondb/jsondb.go
@@ -736,3 +736,33 @@ func (o *JsonDB) DeleteFirewallRule(serverID, ruleID string) error {
}
return o.conn.Delete("firewall_rules", ruleID)
}
+
+// GetIPListEntries func to query every host-wide allow/block list entry
+func (o *JsonDB) GetIPListEntries() ([]model.IPListEntry, error) {
+ entries := make([]model.IPListEntry, 0)
+ records, err := o.conn.ReadAll("ip_list_entries")
+ if err != nil {
+ if err == scribble.ErrMissingCollection {
+ return entries, nil
+ }
+ return nil, err
+ }
+ for _, rec := range records {
+ var entry model.IPListEntry
+ if err := json.Unmarshal(rec, &entry); err != nil {
+ return nil, fmt.Errorf("cannot decode ip list entry json structure: %v", err)
+ }
+ entries = append(entries, entry)
+ }
+ return entries, nil
+}
+
+// CreateIPListEntry func to add a new host-wide allow/block list entry
+func (o *JsonDB) CreateIPListEntry(entry model.IPListEntry) error {
+ return o.conn.Write("ip_list_entries", entry.ID, entry)
+}
+
+// DeleteIPListEntry func to remove a host-wide allow/block list entry
+func (o *JsonDB) DeleteIPListEntry(id string) error {
+ return o.conn.Delete("ip_list_entries", id)
+}
diff --git a/store/store.go b/store/store.go
index 60fa4dd..9676d60 100644
--- a/store/store.go
+++ b/store/store.go
@@ -38,4 +38,7 @@ type IStore interface {
CreateFirewallRule(rule model.FirewallRule) error
UpdateFirewallRule(rule model.FirewallRule) error
DeleteFirewallRule(serverID, ruleID string) error
+ GetIPListEntries() ([]model.IPListEntry, error)
+ CreateIPListEntry(entry model.IPListEntry) error
+ DeleteIPListEntry(id string) error
}
diff --git a/templates/base.html b/templates/base.html
index 32aafff..fec9b29 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -145,6 +145,14 @@
+
+
+
+
+ Global Firewall Lists
+
+
+
{{if not .loginDisabled}}
diff --git a/templates/firewall_lists.html b/templates/firewall_lists.html
new file mode 100644
index 0000000..1bd8f58
--- /dev/null
+++ b/templates/firewall_lists.html
@@ -0,0 +1,184 @@
+{{define "title"}}
+Global Firewall Lists
+{{end}}
+
+{{define "top_css"}}
+{{end}}
+
+{{define "username"}}
+{{.username}}
+{{end}}
+
+{{define "page_title"}}
+Global Firewall Lists
+{{end}}
+
+{{define "page_content"}}
+
+
+
+
+
+
+
+
+ These entries are NOT scoped to a single WireGuard server - they apply to
+ all traffic on this host, evaluated before every per-server
+ firewall table (nftables priority -10). Allow entries always win over block entries.
+ Nothing is applied until you press "Apply now (live)".
+
+
+
+
+ | Type | CIDR / IP | Comment | |
+
+
+
+
+
+
+
+
Ruleset preview:
+
+
+
+
+
+
+
+
+
+{{end}}
+
+{{define "bottom_js"}}
+
+{{end}}