diff --git a/firewall/nftables.go b/firewall/nftables.go new file mode 100644 index 0000000..9ed2bcb --- /dev/null +++ b/firewall/nftables.go @@ -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 ) 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() +} diff --git a/handler/routes.go b/handler/routes.go index f204c9b..12ed0f0 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -25,6 +25,7 @@ import ( "github.com/ngoduykhanh/wireguard-ui/backup" "github.com/ngoduykhanh/wireguard-ui/emailer" + "github.com/ngoduykhanh/wireguard-ui/firewall" "github.com/ngoduykhanh/wireguard-ui/model" "github.com/ngoduykhanh/wireguard-ui/store" "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 func NewClient(db store.IStore) echo.HandlerFunc { return func(c echo.Context) error { diff --git a/main.go b/main.go index 2cbc8ec..204f50e 100644 --- a/main.go +++ b/main.go @@ -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/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.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.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession) app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession) diff --git a/model/setting.go b/model/setting.go index 408f8a3..f1639fc 100644 --- a/model/setting.go +++ b/model/setting.go @@ -24,5 +24,10 @@ type ServerSetting struct { FirewallMark string `json:"firewall_mark"` Table string `json:"table"` ConfigFilePath string `json:"config_file_path"` - UpdatedAt time.Time `json:"updated_at"` + // 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"` } diff --git a/templates/servers.html b/templates/servers.html index f40c164..dbe0dae 100644 --- a/templates/servers.html +++ b/templates/servers.html @@ -102,6 +102,11 @@ All Servers +
+ + + Optional. Used only for the Firewall Preview - lets peers forward to this interface. +
+ + + {{end}} {{define "bottom_js"}} @@ -203,6 +231,9 @@ All Servers +
+ +
@@ -307,6 +338,7 @@ All Servers modal.find("#_settings_config_file_path").val(""); modal.find("#_settings_firewall_mark").val(""); modal.find("#_settings_table").val(""); + modal.find("#_settings_lan_interface").val(""); $.ajax({ cache: false, @@ -319,6 +351,7 @@ All Servers modal.find("#_settings_config_file_path").val(settings.config_file_path); modal.find("#_settings_firewall_mark").val(settings.firewall_mark); modal.find("#_settings_table").val(settings.table); + modal.find("#_settings_lan_interface").val(settings.lan_interface); }, error: function (jqXHR, exception) { const responseJson = jQuery.parseJSON(jqXHR.responseText); @@ -333,7 +366,8 @@ All Servers "endpoint_address": $("#_settings_endpoint_address").val(), "config_file_path": $("#_settings_config_file_path").val(), "firewall_mark": $("#_settings_firewall_mark").val(), - "table": $("#_settings_table").val() + "table": $("#_settings_table").val(), + "lan_interface": $("#_settings_lan_interface").val() }; $.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 $(document).ready(function () { $('#servers-list').on('click', '.btn-delete-server', function () {