From eb1913d4006316b5c0e86bbd947dc05cbb4c27b4 Mon Sep 17 00:00:00 2001 From: sysops Date: Sun, 12 Jul 2026 17:45:53 +0200 Subject: [PATCH] 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 --- firewall/apply.go | 28 ++++-- firewall/global.go | 63 ++++++++++++ handler/routes.go | 104 +++++++++++++++++++ main.go | 6 ++ model/iplist.go | 15 +++ router/router.go | 6 ++ store/jsondb/jsondb.go | 30 ++++++ store/store.go | 3 + templates/base.html | 8 ++ templates/firewall_lists.html | 184 ++++++++++++++++++++++++++++++++++ 10 files changed, 438 insertions(+), 9 deletions(-) create mode 100644 firewall/global.go create mode 100644 model/iplist.go create mode 100644 templates/firewall_lists.html 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 @@

+ {{if not .loginDisabled}}