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:
co-authored by
Claude Sonnet 5
parent
28eb08df41
commit
1d080904b0
+150
-7
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user