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:
co-authored by
Claude Sonnet 5
parent
bdcf1ec60c
commit
eb1913d400
+19
-9
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}">
|
||||||
|
|||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user