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" "time"
) )
// Apply writes ruleset to a temp file and loads it with `nft -f`, after // applyTable writes ruleset to a temp file and loads it with `nft -f`,
// first deleting the server's own table (ignoring the error - the table // after first deleting the given table (ignoring the error - the table may
// may not exist yet on first apply). Only ever touches the single table // not exist yet on first apply). Only ever touches that single table,
// named by TableName(serverID), never any other nftables state. // never any other nftables state.
// Returns combined nft output for display, and an error if the load failed. func applyTable(tableName, ruleset string) (string, error) {
func Apply(serverID, ruleset string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
// best-effort: drop any previous version of this server's table so // best-effort: drop the previous version of this table so reapplying
// reapplying is idempotent. Error ignored - table may not exist yet. // is idempotent. Error ignored - table may not exist yet.
_ = exec.CommandContext(ctx, "nft", "delete", "table", "inet", TableName(serverID)).Run() _ = exec.CommandContext(ctx, "nft", "delete", "table", "inet", tableName).Run()
tmpFile, err := os.CreateTemp("", "wg-ui-multi-fw-*.nft") tmpFile, err := os.CreateTemp("", "wg-ui-multi-fw-*.nft")
if err != nil { if err != nil {
@@ -42,3 +41,14 @@ func Apply(serverID, ruleset string) (string, error) {
} }
return string(out), nil 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()
}
+104
View File
@@ -1779,3 +1779,107 @@ func GetSystemUpdateStatus() echo.HandlerFunc {
return c.JSON(http.StatusOK, system.CheckAptUpdates()) 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,
})
}
}
+6
View File
@@ -237,6 +237,12 @@ func main() {
app.GET(util.BasePath+"/test-hash", handler.GetHashesChanges(db), handler.ValidSession) app.GET(util.BasePath+"/test-hash", handler.GetHashesChanges(db), handler.ValidSession)
app.GET(util.BasePath+"/about", handler.AboutPage()) app.GET(util.BasePath+"/about", handler.AboutPage())
app.GET(util.BasePath+"/system/update-status", handler.GetSystemUpdateStatus(), handler.ValidSession, handler.NeedsAdmin) 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+"/_health", handler.Health())
app.GET(util.BasePath+"/favicon", handler.Favicon()) app.GET(util.BasePath+"/favicon", handler.Favicon())
app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson) app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson)
+15
View File
@@ -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"`
}
+6
View File
@@ -116,6 +116,11 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
log.Fatal(err) log.Fatal(err)
} }
tmplFirewallListsString, err := util.StringFromEmbedFile(tmplDir, "firewall_lists.html")
if err != nil {
log.Fatal(err)
}
// create template list // create template list
funcs := template.FuncMap{ funcs := template.FuncMap{
"StringsJoin": strings.Join, "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["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["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["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")) lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
if err != nil { if err != nil {
+30
View File
@@ -736,3 +736,33 @@ func (o *JsonDB) DeleteFirewallRule(serverID, ruleID string) error {
} }
return o.conn.Delete("firewall_rules", ruleID) 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)
}
+3
View File
@@ -38,4 +38,7 @@ type IStore interface {
CreateFirewallRule(rule model.FirewallRule) error CreateFirewallRule(rule model.FirewallRule) error
UpdateFirewallRule(rule model.FirewallRule) error UpdateFirewallRule(rule model.FirewallRule) error
DeleteFirewallRule(serverID, ruleID string) error DeleteFirewallRule(serverID, ruleID string) error
GetIPListEntries() ([]model.IPListEntry, error)
CreateIPListEntry(entry model.IPListEntry) error
DeleteIPListEntry(id string) error
} }
+8
View File
@@ -145,6 +145,14 @@
</p> </p>
</a> </a>
</li> </li>
<li class="nav-item">
<a href="{{.basePath}}/firewall-lists" class="nav-link {{if eq .baseData.Active "firewall-lists" }}active{{end}}">
<i class="nav-icon fas fa-shield-alt"></i>
<p>
Global Firewall Lists
</p>
</a>
</li>
{{if not .loginDisabled}} {{if not .loginDisabled}}
<li class="nav-item"> <li class="nav-item">
<a href="{{.basePath}}/users-settings" class="nav-link {{if eq .baseData.Active "users-settings" }}active{{end}}"> <a href="{{.basePath}}/users-settings" class="nav-link {{if eq .baseData.Active "users-settings" }}active{{end}}">
+184
View File
@@ -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"}}
<section class="content">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="card card-warning">
<div class="card-header">
<h3 class="card-title">Host-wide Allow / Block Lists</h3>
</div>
<div class="card-body">
<p class="text-muted">
These entries are NOT scoped to a single WireGuard server - they apply to
<strong>all traffic on this host</strong>, 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)".
</p>
<table class="table table-sm" id="_iplist_table">
<thead>
<tr><th>Type</th><th>CIDR / IP</th><th>Comment</th><th></th></tr>
</thead>
<tbody id="_iplist_tbody"></tbody>
</table>
<form id="frm_iplist_entry" class="form-inline">
<select class="form-control form-control-sm mr-1 mb-1" id="_iplist_type">
<option value="block">block</option>
<option value="allow">allow</option>
</select>
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_iplist_cidr" placeholder="e.g. 203.0.113.0/24" style="width:14em">
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_iplist_comment" placeholder="comment" style="width:14em">
<button type="submit" class="btn btn-primary btn-sm mb-1">Add entry</button>
</form>
<hr>
<p class="text-muted mb-1">Ruleset preview:</p>
<pre id="_iplist_preview_text" style="max-height: 30vh; overflow:auto;"></pre>
<button type="button" class="btn btn-danger" id="btn_apply_global_firewall">Apply now (live)</button>
</div>
</div>
</div>
</div>
</div>
</section>
{{end}}
{{define "bottom_js"}}
<script>
function refreshGlobalPreview() {
$("#_iplist_preview_text").text("Loading...");
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/firewall-lists/preview',
dataType: 'text',
success: function (data) { $("#_iplist_preview_text").text(data); },
error: function () { $("#_iplist_preview_text").text("Could not load preview."); }
});
}
function renderIPList(entries) {
const tbody = $("#_iplist_tbody");
tbody.empty();
$.each(entries, function (i, entry) {
const safeCidr = $('<div>').text(entry.cidr).html();
const safeComment = $('<div>').text(entry.comment || "").html();
const row = `<tr>
<td>${entry.list_type}</td>
<td>${safeCidr}</td>
<td>${safeComment}</td>
<td><button type="button" class="btn btn-outline-danger btn-sm btn-delete-iplist" data-id="${entry.id}">Delete</button></td>
</tr>`;
tbody.append(row);
});
}
function loadIPList() {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/firewall-lists/entries',
dataType: 'json',
success: function (entries) { renderIPList(entries); },
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
$(document).ready(function () {
loadIPList();
refreshGlobalPreview();
$("#frm_iplist_entry").on('submit', function (e) {
e.preventDefault();
const data = {
list_type: $("#_iplist_type").val(),
cidr: $("#_iplist_cidr").val(),
comment: $("#_iplist_comment").val()
};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/firewall-lists/entries',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function () {
toastr.success("Entry added");
$("#frm_iplist_entry")[0].reset();
loadIPList();
refreshGlobalPreview();
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#_iplist_tbody").on('click', '.btn-delete-iplist', function () {
const id = $(this).data('id');
if (!confirm("Delete this entry?")) return;
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/firewall-lists/entries/' + id + '/delete',
dataType: 'json',
contentType: "application/json",
success: function () {
toastr.success("Entry deleted");
loadIPList();
refreshGlobalPreview();
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#btn_apply_global_firewall").click(function () {
if (!confirm("Apply the global allow/block lists to the live firewall now?\n" +
"This runs 'nft -f' on the server, scoped to the dedicated wireguard_ui_global table only, " +
"but it affects ALL traffic on this host, not just WireGuard.")) {
return;
}
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/firewall-lists/apply',
dataType: 'json',
contentType: "application/json",
success: function (data) {
toastr.success(data.message);
if (data.output) { $("#_iplist_preview_text").text(data.output); }
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message'] || "Failed to apply");
if (responseJson['output']) { $("#_iplist_preview_text").text(responseJson['output']); }
}
});
});
});
</script>
{{end}}