Add nftables firewall ruleset preview per server (review-only)
New firewall package generates an nftables snippet (INPUT accept for the listen port, FORWARD rules for the WireGuard interface, optional LAN forwarding via a new ServerSetting.LanInterface field). Text only - nothing is applied to the live firewall. Exposed as GET /servers/:id/firewall-preview and a "Firewall Preview" button in the All Servers page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0000643187
commit
28eb08df41
@@ -0,0 +1,56 @@
|
|||||||
|
// Package firewall generates an nftables ruleset preview for a WireGuard
|
||||||
|
// server. It never touches the live firewall - the output is text only,
|
||||||
|
// meant to be reviewed and applied manually (nft -f <file>) or copied into
|
||||||
|
// an existing ruleset.
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ngoduykhanh/wireguard-ui/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeneratePreview renders an nftables ruleset snippet for the given server.
|
||||||
|
// If settings.LanInterface is empty, only WireGuard-interface-local traffic
|
||||||
|
// rules are emitted; forwarding to a LAN interface is added when set.
|
||||||
|
func GeneratePreview(server model.Server, settings model.ServerSetting) string {
|
||||||
|
ifaceName := "wgX"
|
||||||
|
listenPort := 0
|
||||||
|
if server.Interface != nil {
|
||||||
|
if server.Interface.Name != "" {
|
||||||
|
ifaceName = server.Interface.Name
|
||||||
|
}
|
||||||
|
listenPort = server.Interface.ListenPort
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# nftables ruleset preview for server %q (%s)\n", server.Name, server.ID)
|
||||||
|
fmt.Fprintf(&b, "# Generated by wireguard-ui-multi - review before applying, e.g.:\n")
|
||||||
|
fmt.Fprintf(&b, "# nft -f this-file.nft\n")
|
||||||
|
fmt.Fprintf(&b, "# Not applied automatically.\n\n")
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "table inet wireguard_ui_%s {\n", server.ID)
|
||||||
|
fmt.Fprintf(&b, " chain input {\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, " }\n\n")
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, " chain forward {\n")
|
||||||
|
fmt.Fprintf(&b, " type filter hook forward priority 0; policy accept;\n")
|
||||||
|
fmt.Fprintf(&b, " iifname \"%s\" accept comment \"wg-ui-multi: %s inbound\"\n", ifaceName, server.ID)
|
||||||
|
fmt.Fprintf(&b, " oifname \"%s\" accept comment \"wg-ui-multi: %s outbound\"\n", ifaceName, server.ID)
|
||||||
|
if settings.LanInterface != "" {
|
||||||
|
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, " }\n")
|
||||||
|
fmt.Fprintf(&b, "}\n")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"github.com/ngoduykhanh/wireguard-ui/backup"
|
"github.com/ngoduykhanh/wireguard-ui/backup"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/emailer"
|
"github.com/ngoduykhanh/wireguard-ui/emailer"
|
||||||
|
"github.com/ngoduykhanh/wireguard-ui/firewall"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/model"
|
"github.com/ngoduykhanh/wireguard-ui/model"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/store"
|
"github.com/ngoduykhanh/wireguard-ui/store"
|
||||||
"github.com/ngoduykhanh/wireguard-ui/telegram"
|
"github.com/ngoduykhanh/wireguard-ui/telegram"
|
||||||
@@ -722,6 +723,23 @@ func RemoveServer(db store.IStore) echo.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetServerFirewallPreview returns a generated nftables ruleset preview for
|
||||||
|
// a server as plain text. Never applied automatically - review-only.
|
||||||
|
func GetServerFirewallPreview(db store.IStore) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
serverID := c.Param("id")
|
||||||
|
server, err := db.GetServerByID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server not found"})
|
||||||
|
}
|
||||||
|
settings, err := db.GetServerSettings(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server settings not found"})
|
||||||
|
}
|
||||||
|
return c.String(http.StatusOK, firewall.GeneratePreview(server, settings))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient handler
|
// NewClient handler
|
||||||
func NewClient(db store.IStore) echo.HandlerFunc {
|
func NewClient(db store.IStore) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ func main() {
|
|||||||
app.POST(util.BasePath+"/servers/:id/interface", handler.UpdateServerInterfaceHandler(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
|
app.POST(util.BasePath+"/servers/:id/interface", handler.UpdateServerInterfaceHandler(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/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.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)
|
||||||
|
|||||||
@@ -24,5 +24,10 @@ type ServerSetting struct {
|
|||||||
FirewallMark string `json:"firewall_mark"`
|
FirewallMark string `json:"firewall_mark"`
|
||||||
Table string `json:"table"`
|
Table string `json:"table"`
|
||||||
ConfigFilePath string `json:"config_file_path"`
|
ConfigFilePath string `json:"config_file_path"`
|
||||||
|
// LanInterface is optional: the local interface (e.g. "eth0", "br-lan")
|
||||||
|
// this server's WireGuard traffic should be allowed to forward to/from.
|
||||||
|
// Only used to generate the nftables ruleset preview; left empty means
|
||||||
|
// the preview only covers the WireGuard interface itself.
|
||||||
|
LanInterface string `json:"lan_interface,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-1
@@ -102,6 +102,11 @@ All Servers
|
|||||||
<label for="_settings_table" class="control-label">Table</label>
|
<label for="_settings_table" class="control-label">Table</label>
|
||||||
<input type="text" class="form-control" id="_settings_table" placeholder="auto">
|
<input type="text" class="form-control" id="_settings_table" placeholder="auto">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="_settings_lan_interface" class="control-label">LAN Interface</label>
|
||||||
|
<input type="text" class="form-control" id="_settings_lan_interface" placeholder="e.g. eth0, br-lan">
|
||||||
|
<small class="form-text text-muted">Optional. Used only for the Firewall Preview - lets peers forward to this interface.</small>
|
||||||
|
</div>
|
||||||
</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">Cancel</button>
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
@@ -170,6 +175,29 @@ All Servers
|
|||||||
<!-- /.modal-dialog -->
|
<!-- /.modal-dialog -->
|
||||||
</div>
|
</div>
|
||||||
<!-- /.modal -->
|
<!-- /.modal -->
|
||||||
|
|
||||||
|
<div class="modal fade" id="modal_firewall_preview">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title">Firewall Preview (nftables)</h4>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="text-muted">Preview only - nothing is applied to the running firewall. Review, then apply manually if desired.</p>
|
||||||
|
<pre id="_firewall_preview_text" style="max-height: 50vh; overflow:auto;"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-between">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /.modal-content -->
|
||||||
|
</div>
|
||||||
|
<!-- /.modal-dialog -->
|
||||||
|
</div>
|
||||||
|
<!-- /.modal -->
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "bottom_js"}}
|
{{define "bottom_js"}}
|
||||||
@@ -203,6 +231,9 @@ All Servers
|
|||||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-toggle="modal"
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-toggle="modal"
|
||||||
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">
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm btn-firewall-preview" data-serverid="${obj.id}">Firewall Preview</button>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,6 +338,7 @@ All Servers
|
|||||||
modal.find("#_settings_config_file_path").val("");
|
modal.find("#_settings_config_file_path").val("");
|
||||||
modal.find("#_settings_firewall_mark").val("");
|
modal.find("#_settings_firewall_mark").val("");
|
||||||
modal.find("#_settings_table").val("");
|
modal.find("#_settings_table").val("");
|
||||||
|
modal.find("#_settings_lan_interface").val("");
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
cache: false,
|
cache: false,
|
||||||
@@ -319,6 +351,7 @@ All Servers
|
|||||||
modal.find("#_settings_config_file_path").val(settings.config_file_path);
|
modal.find("#_settings_config_file_path").val(settings.config_file_path);
|
||||||
modal.find("#_settings_firewall_mark").val(settings.firewall_mark);
|
modal.find("#_settings_firewall_mark").val(settings.firewall_mark);
|
||||||
modal.find("#_settings_table").val(settings.table);
|
modal.find("#_settings_table").val(settings.table);
|
||||||
|
modal.find("#_settings_lan_interface").val(settings.lan_interface);
|
||||||
},
|
},
|
||||||
error: function (jqXHR, exception) {
|
error: function (jqXHR, exception) {
|
||||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||||
@@ -333,7 +366,8 @@ All Servers
|
|||||||
"endpoint_address": $("#_settings_endpoint_address").val(),
|
"endpoint_address": $("#_settings_endpoint_address").val(),
|
||||||
"config_file_path": $("#_settings_config_file_path").val(),
|
"config_file_path": $("#_settings_config_file_path").val(),
|
||||||
"firewall_mark": $("#_settings_firewall_mark").val(),
|
"firewall_mark": $("#_settings_firewall_mark").val(),
|
||||||
"table": $("#_settings_table").val()
|
"table": $("#_settings_table").val(),
|
||||||
|
"lan_interface": $("#_settings_lan_interface").val()
|
||||||
};
|
};
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@@ -473,6 +507,27 @@ All Servers
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Firewall preview button: rendered dynamically, use event delegation
|
||||||
|
$(document).ready(function () {
|
||||||
|
$('#servers-list').on('click', '.btn-firewall-preview', function () {
|
||||||
|
const serverId = $(this).data('serverid');
|
||||||
|
$("#_firewall_preview_text").text("Loading...");
|
||||||
|
$("#modal_firewall_preview").modal('show');
|
||||||
|
$.ajax({
|
||||||
|
cache: false,
|
||||||
|
method: 'GET',
|
||||||
|
url: '{{.basePath}}/servers/' + serverId + '/firewall-preview',
|
||||||
|
dataType: 'text',
|
||||||
|
success: function (data) {
|
||||||
|
$("#_firewall_preview_text").text(data);
|
||||||
|
},
|
||||||
|
error: function (jqXHR, exception) {
|
||||||
|
$("#_firewall_preview_text").text("Could not load firewall preview.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Delete server button: rendered dynamically, use event delegation
|
// Delete server button: rendered dynamically, use event delegation
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$('#servers-list').on('click', '.btn-delete-server', function () {
|
$('#servers-list').on('click', '.btn-delete-server', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user