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
+150 -7
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"regexp"
@@ -723,20 +724,162 @@ 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.
// loadFirewallRuleset fetches everything needed to render a server's full
// nftables ruleset (baseline + custom rules).
func loadFirewallRuleset(db store.IStore, serverID string) (string, error) {
server, err := db.GetServerByID(serverID)
if err != nil {
return "", fmt.Errorf("server not found")
}
settings, err := db.GetServerSettings(serverID)
if err != nil {
return "", fmt.Errorf("server settings not found")
}
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")
server, err := db.GetServerByID(serverID)
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"})
}
settings, err := db.GetServerSettings(serverID)
if err != nil {
return c.JSON(http.StatusNotFound, jsonHTTPResponse{false, "Server settings not found"})
var rule model.FirewallRule
if err := c.Bind(&rule); err != nil {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
}
return c.String(http.StatusOK, firewall.GeneratePreview(server, settings))
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,
})
}
}