Add live firewall rule management per server (nftables)

New model.FirewallRule + jsondb CRUD (GetFirewallRules/CreateFirewallRule/
UpdateFirewallRule/DeleteFirewallRule), scoped per server. firewall package
now generates a full ruleset (baseline + enabled custom rules) and can
apply it live via `nft -f` (firewall.Apply), scoped to a per-server
nftables table (wireguard_ui_<serverID>) so applying one server never
touches another server's rules or any pre-existing firewall state.

New endpoints: GET/POST /servers/:id/firewall/rules, POST .../rules/:ruleId,
POST .../rules/:ruleId/delete, POST .../apply (live, admin-only). UI in the
All Servers page: rule table with add/delete, ruleset preview, and an
"Apply now (live)" button with an explicit confirm() warning before it
touches the running firewall.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-12 17:31:08 +02:00
co-authored by Claude Sonnet 5
parent 28eb08df41
commit 1d080904b0
8 changed files with 507 additions and 41 deletions
+44
View File
@@ -0,0 +1,44 @@
package firewall
import (
"context"
"fmt"
"os"
"os/exec"
"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) {
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()
tmpFile, err := os.CreateTemp("", "wg-ui-multi-fw-*.nft")
if err != nil {
return "", fmt.Errorf("cannot create temp ruleset file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(ruleset); err != nil {
tmpFile.Close()
return "", fmt.Errorf("cannot write temp ruleset file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return "", fmt.Errorf("cannot close temp ruleset file: %w", err)
}
cmd := exec.CommandContext(ctx, "nft", "-f", tmpFile.Name())
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("nft -f failed: %w", err)
}
return string(out), nil
}
+60 -20
View File
@@ -1,7 +1,7 @@
// Package firewall generates an nftables ruleset preview for a WireGuard // Package firewall builds and (optionally) applies an nftables ruleset for
// server. It never touches the live firewall - the output is text only, // a WireGuard server. Every server gets its own table
// meant to be reviewed and applied manually (nft -f <file>) or copied into // (inet wireguard_ui_<serverID>) so applying/removing one server's rules
// an existing ruleset. // never touches any other table on the system.
package firewall package firewall
import ( import (
@@ -11,10 +11,38 @@ import (
"github.com/ngoduykhanh/wireguard-ui/model" "github.com/ngoduykhanh/wireguard-ui/model"
) )
// GeneratePreview renders an nftables ruleset snippet for the given server. // TableName returns the dedicated nftables table name for a server.
// If settings.LanInterface is empty, only WireGuard-interface-local traffic func TableName(serverID string) string {
// rules are emitted; forwarding to a LAN interface is added when set. return "wireguard_ui_" + serverID
func GeneratePreview(server model.Server, settings model.ServerSetting) string { }
// buildRuleLine renders one custom rule as an nftables statement.
func buildRuleLine(rule model.FirewallRule) string {
var parts []string
if rule.Source != "" {
parts = append(parts, fmt.Sprintf("ip saddr %s", rule.Source))
}
switch {
case rule.Protocol != "" && rule.Port != "":
parts = append(parts, fmt.Sprintf("%s dport %s", rule.Protocol, rule.Port))
case rule.Protocol != "":
parts = append(parts, fmt.Sprintf("meta l4proto %s", rule.Protocol))
case rule.Port != "":
parts = append(parts, fmt.Sprintf("th dport %s", rule.Port))
}
parts = append(parts, rule.Action)
comment := rule.Comment
if comment == "" {
comment = "wg-ui-multi custom rule"
}
parts = append(parts, fmt.Sprintf("comment %q", comment))
return " " + strings.Join(parts, " ")
}
// GenerateRuleset renders the full nftables ruleset for a server: the
// baseline (listen-port accept, WireGuard-interface forwarding, optional
// LAN forwarding) plus every enabled custom rule, grouped by chain.
func GenerateRuleset(server model.Server, settings model.ServerSetting, rules []model.FirewallRule) string {
ifaceName := "wgX" ifaceName := "wgX"
listenPort := 0 listenPort := 0
if server.Interface != nil { if server.Interface != nil {
@@ -24,16 +52,30 @@ func GeneratePreview(server model.Server, settings model.ServerSetting) string {
listenPort = server.Interface.ListenPort listenPort = server.Interface.ListenPort
} }
var b strings.Builder var inputExtra, forwardExtra []string
fmt.Fprintf(&b, "# nftables ruleset preview for server %q (%s)\n", server.Name, server.ID) for _, rule := range rules {
fmt.Fprintf(&b, "# Generated by wireguard-ui-multi - review before applying, e.g.:\n") if !rule.Enabled {
fmt.Fprintf(&b, "# nft -f this-file.nft\n") continue
fmt.Fprintf(&b, "# Not applied automatically.\n\n") }
line := buildRuleLine(rule)
if rule.Direction == "forward" {
forwardExtra = append(forwardExtra, line)
} else {
inputExtra = append(inputExtra, line)
}
}
fmt.Fprintf(&b, "table inet wireguard_ui_%s {\n", server.ID) var b strings.Builder
fmt.Fprintf(&b, "# nftables ruleset for server %q (%s)\n", server.Name, server.ID)
fmt.Fprintf(&b, "# Generated by wireguard-ui-multi.\n\n")
fmt.Fprintf(&b, "table inet %s {\n", TableName(server.ID))
fmt.Fprintf(&b, " chain input {\n") fmt.Fprintf(&b, " chain input {\n")
fmt.Fprintf(&b, " type filter hook input priority 0; policy accept;\n") fmt.Fprintf(&b, " type filter hook input priority 0; policy accept;\n")
fmt.Fprintf(&b, " udp dport %d accept comment \"wg-ui-multi: %s\"\n", listenPort, server.ID) fmt.Fprintf(&b, " udp dport %d accept comment \"wg-ui-multi: %s\"\n", listenPort, server.ID)
for _, line := range inputExtra {
fmt.Fprintf(&b, "%s\n", line)
}
fmt.Fprintf(&b, " }\n\n") fmt.Fprintf(&b, " }\n\n")
fmt.Fprintf(&b, " chain forward {\n") fmt.Fprintf(&b, " chain forward {\n")
@@ -44,13 +86,11 @@ func GeneratePreview(server model.Server, settings model.ServerSetting) string {
fmt.Fprintf(&b, " iifname \"%s\" oifname \"%s\" accept comment \"wg-ui-multi: %s -> lan\"\n", ifaceName, settings.LanInterface, server.ID) fmt.Fprintf(&b, " iifname \"%s\" oifname \"%s\" accept comment \"wg-ui-multi: %s -> lan\"\n", ifaceName, settings.LanInterface, server.ID)
fmt.Fprintf(&b, " iifname \"%s\" oifname \"%s\" accept comment \"wg-ui-multi: lan -> %s\"\n", settings.LanInterface, ifaceName, server.ID) fmt.Fprintf(&b, " iifname \"%s\" oifname \"%s\" accept comment \"wg-ui-multi: lan -> %s\"\n", settings.LanInterface, ifaceName, server.ID)
} }
fmt.Fprintf(&b, " }\n") for _, line := range forwardExtra {
fmt.Fprintf(&b, "}\n") fmt.Fprintf(&b, "%s\n", line)
if settings.LanInterface == "" {
fmt.Fprintf(&b, "\n# No LAN interface configured for this server - peers can only reach\n")
fmt.Fprintf(&b, "# each other, not your LAN. Set one in Server Settings to add forwarding.\n")
} }
fmt.Fprintf(&b, " }\n")
fmt.Fprintf(&b, "}\n")
return b.String() return b.String()
} }
+151 -8
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
@@ -723,20 +724,162 @@ func RemoveServer(db store.IStore) echo.HandlerFunc {
} }
} }
// GetServerFirewallPreview returns a generated nftables ruleset preview for // loadFirewallRuleset fetches everything needed to render a server's full
// a server as plain text. Never applied automatically - review-only. // nftables ruleset (baseline + custom rules).
func GetServerFirewallPreview(db store.IStore) echo.HandlerFunc { func loadFirewallRuleset(db store.IStore, serverID string) (string, error) {
return func(c echo.Context) error {
serverID := c.Param("id")
server, err := db.GetServerByID(serverID) server, err := db.GetServerByID(serverID)
if err != nil { if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"}) return "", fmt.Errorf("server not found")
} }
settings, err := db.GetServerSettings(serverID) settings, err := db.GetServerSettings(serverID)
if err != nil { if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server settings not found"}) return "", fmt.Errorf("server settings not found")
} }
return c.String(http.StatusOK, firewall.GeneratePreview(server, settings)) rules, err := db.GetFirewallRules(serverID)
if err != nil {
return "", fmt.Errorf("cannot load firewall rules: %v", err)
}
return firewall.GenerateRuleset(server, settings, rules), nil
}
// GetServerFirewallPreview returns the generated nftables ruleset (baseline
// + custom rules) for a server as plain text. Preview only.
func GetServerFirewallPreview(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
ruleset, err := loadFirewallRuleset(db, serverID)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
}
return c.String(http.StatusOK, ruleset)
}
}
var validFirewallDirections = map[string]bool{"input": true, "forward": true}
var validFirewallProtocols = map[string]bool{"": true, "tcp": true, "udp": true}
var validFirewallActions = map[string]bool{"accept": true, "drop": true, "reject": true}
var firewallPortRegexp = regexp.MustCompile(`^[0-9]{1,5}(-[0-9]{1,5})?$`)
func validateFirewallRule(rule model.FirewallRule) error {
if !validFirewallDirections[rule.Direction] {
return fmt.Errorf("direction must be 'input' or 'forward'")
}
if !validFirewallProtocols[rule.Protocol] {
return fmt.Errorf("protocol must be 'tcp', 'udp', or empty")
}
if !validFirewallActions[rule.Action] {
return fmt.Errorf("action must be 'accept', 'drop', or 'reject'")
}
if rule.Port != "" && !firewallPortRegexp.MatchString(rule.Port) {
return fmt.Errorf("port must be a number or range like 8000-9000")
}
if rule.Source != "" {
if !util.ValidateServerAddresses([]string{rule.Source}) && net.ParseIP(rule.Source) == nil {
return fmt.Errorf("source must be a valid IP or CIDR")
}
}
if len(rule.Comment) > 200 {
return fmt.Errorf("comment too long")
}
return nil
}
// GetFirewallRules lists a server's custom firewall rules.
func GetFirewallRules(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
rules, err := db.GetFirewallRules(serverID)
if err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
return c.JSON(http.StatusOK, rules)
}
}
// CreateFirewallRuleHandler creates a new custom firewall rule for a server.
func CreateFirewallRuleHandler(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
if _, err := db.GetServerByID(serverID); err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
}
var rule model.FirewallRule
if err := c.Bind(&rule); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
rule.ServerID = serverID
if err := validateFirewallRule(rule); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
rule.ID = xid.New().String()
rule.CreatedAt = time.Now().UTC()
rule.UpdatedAt = rule.CreatedAt
if err := db.CreateFirewallRule(rule); err != nil {
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, fmt.Sprintf("Cannot create rule: %v", err)})
}
return c.JSON(http.StatusOK, rule)
}
}
// UpdateFirewallRuleHandler updates an existing custom firewall rule.
func UpdateFirewallRuleHandler(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
ruleID := c.Param("ruleId")
var rule model.FirewallRule
if err := c.Bind(&rule); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
rule.ID = ruleID
rule.ServerID = serverID
if err := validateFirewallRule(rule); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
rule.UpdatedAt = time.Now().UTC()
if err := db.UpdateFirewallRule(rule); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
return c.JSON(http.StatusOK, rule)
}
}
// DeleteFirewallRuleHandler removes a custom firewall rule.
func DeleteFirewallRuleHandler(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
ruleID := c.Param("ruleId")
if err := db.DeleteFirewallRule(serverID, ruleID); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, err.Error()})
}
return c.JSON(http.StatusOK, jsonHTTPResponse{true, "Rule deleted successfully"})
}
}
// ApplyFirewallHandler generates the full ruleset for a server and loads it
// live via `nft -f`. This DOES modify the running firewall, scoped to this
// server's own nftables table only (see firewall.TableName).
func ApplyFirewallHandler(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
serverID := c.Param("id")
ruleset, err := loadFirewallRuleset(db, serverID)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, err.Error()})
}
output, err := firewall.Apply(serverID, ruleset)
if err != nil {
log.Errorf("Failed to apply firewall rules for server %s: %v\n%s", serverID, err, output)
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"success": false,
"message": err.Error(),
"output": output,
})
}
log.Infof("Applied firewall rules for server %s", serverID)
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
"message": "Firewall rules applied successfully",
"output": output,
})
} }
} }
+5
View File
@@ -266,6 +266,11 @@ func main() {
app.POST(util.BasePath+"/servers/:id/keypair", handler.UpdateServerKeyPairHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.POST(util.BasePath+"/servers/:id/keypair", handler.UpdateServerKeyPairHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/servers/:id/delete", handler.RemoveServer(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.POST(util.BasePath+"/servers/:id/delete", handler.RemoveServer(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/servers/:id/firewall-preview", handler.GetServerFirewallPreview(db), handler.ValidSession, handler.RequireServerAccess(db)) app.GET(util.BasePath+"/servers/:id/firewall-preview", handler.GetServerFirewallPreview(db), handler.ValidSession, handler.RequireServerAccess(db))
app.GET(util.BasePath+"/servers/:id/firewall/rules", handler.GetFirewallRules(db), handler.ValidSession, handler.RequireServerAccess(db))
app.POST(util.BasePath+"/servers/:id/firewall/rules", handler.CreateFirewallRuleHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/servers/:id/firewall/rules/:ruleId", handler.UpdateFirewallRuleHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/servers/:id/firewall/rules/:ruleId/delete", handler.DeleteFirewallRuleHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/servers/:id/firewall/apply", handler.ApplyFirewallHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/backup/download", handler.DownloadBackup(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin) app.POST(util.BasePath+"/backup/download", handler.DownloadBackup(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession) app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession) app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
+21
View File
@@ -0,0 +1,21 @@
package model
import "time"
// FirewallRule is a single user-defined nftables rule scoped to one server.
// Rules are combined with the server's baseline (listen-port accept +
// WireGuard-interface forwarding) to build the full ruleset that gets
// applied via `nft -f`.
type FirewallRule struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
Direction string `json:"direction"` // "input" or "forward"
Protocol string `json:"protocol"` // "tcp", "udp", or "" (any)
Port string `json:"port"` // e.g. "8080" or "8000-9000", "" = any
Source string `json:"source"` // optional CIDR, "" = any
Action string `json:"action"` // "accept", "drop", or "reject"
Comment string `json:"comment"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+59
View File
@@ -677,3 +677,62 @@ func (o *JsonDB) SaveServerHashes(serverID string, hashes model.ClientServerHash
} }
return output return output
} }
// GetFirewallRules func to query all firewall rules belonging to a server
func (o *JsonDB) GetFirewallRules(serverID string) ([]model.FirewallRule, error) {
if err := validateServerID(serverID); err != nil {
return nil, err
}
rules := make([]model.FirewallRule, 0)
records, err := o.conn.ReadAll("firewall_rules")
if err != nil {
if err == scribble.ErrMissingCollection {
return rules, nil
}
return nil, err
}
for _, rec := range records {
var rule model.FirewallRule
if err := json.Unmarshal(rec, &rule); err != nil {
return nil, fmt.Errorf("cannot decode firewall rule json structure: %v", err)
}
if rule.ServerID == serverID {
rules = append(rules, rule)
}
}
return rules, nil
}
// CreateFirewallRule func to add a new firewall rule for a server
func (o *JsonDB) CreateFirewallRule(rule model.FirewallRule) error {
if err := validateServerID(rule.ServerID); err != nil {
return err
}
return o.conn.Write("firewall_rules", rule.ID, rule)
}
// UpdateFirewallRule func to update an existing firewall rule, refusing to
// move it to a different server than it currently belongs to
func (o *JsonDB) UpdateFirewallRule(rule model.FirewallRule) error {
var existing model.FirewallRule
if err := o.conn.Read("firewall_rules", rule.ID, &existing); err != nil {
return fmt.Errorf("firewall rule not found: %v", err)
}
if existing.ServerID != rule.ServerID {
return fmt.Errorf("cannot change server_id of an existing firewall rule")
}
return o.conn.Write("firewall_rules", rule.ID, rule)
}
// DeleteFirewallRule func to remove a firewall rule, verifying it belongs to
// the given server first
func (o *JsonDB) DeleteFirewallRule(serverID, ruleID string) error {
var existing model.FirewallRule
if err := o.conn.Read("firewall_rules", ruleID, &existing); err != nil {
return fmt.Errorf("firewall rule not found: %v", err)
}
if existing.ServerID != serverID {
return fmt.Errorf("firewall rule does not belong to server %s", serverID)
}
return o.conn.Delete("firewall_rules", ruleID)
}
+4
View File
@@ -34,4 +34,8 @@ type IStore interface {
SaveServerHashes(serverID string, hashes model.ClientServerHashes) error SaveServerHashes(serverID string, hashes model.ClientServerHashes) error
UpdateServerInterface(serverID string, serverInterface model.ServerInterface) error UpdateServerInterface(serverID string, serverInterface model.ServerInterface) error
UpdateServerKeyPair(serverID string, serverKeyPair model.ServerKeypair) error UpdateServerKeyPair(serverID string, serverKeyPair model.ServerKeypair) error
GetFirewallRules(serverID string) ([]model.FirewallRule, error)
CreateFirewallRule(rule model.FirewallRule) error
UpdateFirewallRule(rule model.FirewallRule) error
DeleteFirewallRule(serverID, ruleID string) error
} }
+164 -14
View File
@@ -176,21 +176,55 @@ All Servers
</div> </div>
<!-- /.modal --> <!-- /.modal -->
<div class="modal fade" id="modal_firewall_preview"> <div class="modal fade" id="modal_firewall">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">Firewall Preview (nftables)</h4> <h4 class="modal-title">Firewall Rules (nftables) - <span id="_fw_server_label"></span></h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close"> <button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="text-muted">Preview only - nothing is applied to the running firewall. Review, then apply manually if desired.</p> <input type="hidden" id="_fw_server_id">
<pre id="_firewall_preview_text" style="max-height: 50vh; overflow:auto;"></pre>
<table class="table table-sm" id="_fw_rules_table">
<thead>
<tr>
<th>On</th><th>Chain</th><th>Proto</th><th>Port</th><th>Source</th><th>Action</th><th>Comment</th><th></th>
</tr>
</thead>
<tbody id="_fw_rules_tbody"></tbody>
</table>
<form id="frm_firewall_rule" class="form-inline">
<select class="form-control form-control-sm mr-1 mb-1" id="_fw_direction">
<option value="input">input</option>
<option value="forward">forward</option>
</select>
<select class="form-control form-control-sm mr-1 mb-1" id="_fw_protocol">
<option value="">any</option>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_fw_port" placeholder="port(s) e.g. 8080" style="width:9em">
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_fw_source" placeholder="source CIDR (optional)" style="width:11em">
<select class="form-control form-control-sm mr-1 mb-1" id="_fw_action">
<option value="accept">accept</option>
<option value="drop">drop</option>
<option value="reject">reject</option>
</select>
<input type="text" class="form-control form-control-sm mr-1 mb-1" id="_fw_comment" placeholder="comment" style="width:11em">
<button type="submit" class="btn btn-primary btn-sm mb-1">Add rule</button>
</form>
<hr>
<p class="text-muted mb-1">Ruleset preview:</p>
<pre id="_firewall_preview_text" style="max-height: 30vh; overflow:auto;"></pre>
</div> </div>
<div class="modal-footer justify-content-between"> <div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-danger" id="btn_apply_firewall">Apply now (live)</button>
</div> </div>
</div> </div>
<!-- /.modal-content --> <!-- /.modal-content -->
@@ -232,7 +266,7 @@ All Servers
data-target="#modal_server_interface" data-serverid="${obj.id}">Interface</button> data-target="#modal_server_interface" data-serverid="${obj.id}">Interface</button>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-outline-info btn-sm btn-firewall-preview" data-serverid="${obj.id}">Firewall Preview</button> <button type="button" class="btn btn-outline-info btn-sm btn-firewall-preview" data-serverid="${obj.id}" data-servername="${safeName}">Firewall</button>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-outline-danger btn-sm btn-delete-server" data-serverid="${obj.id}" data-servername="${safeName}">Delete</button> <button type="button" class="btn btn-outline-danger btn-sm btn-delete-server" data-serverid="${obj.id}" data-servername="${safeName}">Delete</button>
@@ -507,22 +541,138 @@ All Servers
}); });
}); });
// Firewall preview button: rendered dynamically, use event delegation // Firewall management modal
$(document).ready(function () { function refreshFirewallPreview(serverId) {
$('#servers-list').on('click', '.btn-firewall-preview', function () {
const serverId = $(this).data('serverid');
$("#_firewall_preview_text").text("Loading..."); $("#_firewall_preview_text").text("Loading...");
$("#modal_firewall_preview").modal('show');
$.ajax({ $.ajax({
cache: false, cache: false,
method: 'GET', method: 'GET',
url: '{{.basePath}}/servers/' + serverId + '/firewall-preview', url: '{{.basePath}}/servers/' + serverId + '/firewall-preview',
dataType: 'text', dataType: 'text',
success: function (data) { success: function (data) { $("#_firewall_preview_text").text(data); },
$("#_firewall_preview_text").text(data); error: function () { $("#_firewall_preview_text").text("Could not load firewall preview."); }
});
}
function renderFirewallRules(serverId, rules) {
const tbody = $("#_fw_rules_tbody");
tbody.empty();
$.each(rules, function (i, rule) {
const safeComment = $('<div>').text(rule.comment || "").html();
const row = `<tr data-ruleid="${rule.id}">
<td>${rule.enabled ? "yes" : "no"}</td>
<td>${rule.direction}</td>
<td>${rule.protocol || "any"}</td>
<td>${rule.port || "any"}</td>
<td>${rule.source || "any"}</td>
<td>${rule.action}</td>
<td>${safeComment}</td>
<td><button type="button" class="btn btn-outline-danger btn-sm btn-delete-fw-rule" data-serverid="${serverId}" data-ruleid="${rule.id}">Delete</button></td>
</tr>`;
tbody.append(row);
});
}
function loadFirewallRules(serverId) {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/servers/' + serverId + '/firewall/rules',
dataType: 'json',
success: function (rules) { renderFirewallRules(serverId, rules); },
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// Firewall button: rendered dynamically, use event delegation
$(document).ready(function () {
$('#servers-list').on('click', '.btn-firewall-preview', function () {
const serverId = $(this).data('serverid');
const serverName = $(this).data('servername');
$("#_fw_server_id").val(serverId);
$("#_fw_server_label").text(serverName + " (" + serverId + ")");
$("#modal_firewall").modal('show');
loadFirewallRules(serverId);
refreshFirewallPreview(serverId);
});
$("#frm_firewall_rule").on('submit', function (e) {
e.preventDefault();
const serverId = $("#_fw_server_id").val();
const data = {
direction: $("#_fw_direction").val(),
protocol: $("#_fw_protocol").val(),
port: $("#_fw_port").val(),
source: $("#_fw_source").val(),
action: $("#_fw_action").val(),
comment: $("#_fw_comment").val(),
enabled: true
};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/' + serverId + '/firewall/rules',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function () {
toastr.success("Rule added");
$("#frm_firewall_rule")[0].reset();
loadFirewallRules(serverId);
refreshFirewallPreview(serverId);
}, },
error: function (jqXHR, exception) { error: function (jqXHR) {
$("#_firewall_preview_text").text("Could not load firewall preview."); const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#_fw_rules_tbody").on('click', '.btn-delete-fw-rule', function () {
const serverId = $(this).data('serverid');
const ruleId = $(this).data('ruleid');
if (!confirm("Delete this firewall rule?")) return;
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/' + serverId + '/firewall/rules/' + ruleId + '/delete',
dataType: 'json',
contentType: "application/json",
success: function () {
toastr.success("Rule deleted");
loadFirewallRules(serverId);
refreshFirewallPreview(serverId);
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#btn_apply_firewall").click(function () {
const serverId = $("#_fw_server_id").val();
if (!confirm("Apply this ruleset to the live firewall now?\n" +
"This runs 'nft -f' on the server, scoped to this server's own nftables table only.")) {
return;
}
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/servers/' + serverId + '/firewall/apply',
dataType: 'json',
contentType: "application/json",
success: function (data) {
toastr.success(data.message);
if (data.output) { $("#_firewall_preview_text").text(data.output); }
},
error: function (jqXHR) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message'] || "Failed to apply firewall rules");
if (responseJson['output']) { $("#_firewall_preview_text").text(responseJson['output']); }
} }
}); });
}); });