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
+104
View File
@@ -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,
})
}
}