From c575d7177aa4ebe8e3d62bc866d93f524677ff01 Mon Sep 17 00:00:00 2001 From: nfebe Date: Fri, 17 Jul 2026 15:28:19 +0100 Subject: [PATCH] feat(firewall): Enforce the host firewall policy through nftables The firewall app persisted, validated, and previewed a host-wide inbound and outbound policy but never enforced it. Saving a policy now translates it to nftables and applies it, and the stored policy is re-applied at startup so it survives a restart. Disabling the firewall removes only its rules. Enforcement only ever touches FlatRun's own nftables table, so rules managed by Docker or the operator elsewhere are left alone, and the generated ruleset always keeps loopback, established connections, and the active SSH port open, so switching to a default-deny inbound policy cannot lock the operator out. A new ruleset is snapshotted before it loads and restored if the load fails, so a rejected policy never leaves the host half-configured. Where nftables is not available the policy is still saved but reported as not enforced. --- internal/api/server.go | 5 + pkg/plugins/firewall/firewall.go | 61 +++++++++- pkg/plugins/firewall/nft_exec.go | 41 +++++++ pkg/plugins/firewall/nftables.go | 157 ++++++++++++++++++++++++++ pkg/plugins/firewall/nftables_test.go | 148 ++++++++++++++++++++++++ pkg/plugins/firewall/plugin.go | 33 +++++- 6 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 pkg/plugins/firewall/nft_exec.go create mode 100644 pkg/plugins/firewall/nftables.go create mode 100644 pkg/plugins/firewall/nftables_test.go diff --git a/internal/api/server.go b/internal/api/server.go index 39213f0..ca8681e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -170,6 +170,11 @@ func New(cfg *config.Config, configPath string) *Server { firewallPlugin := firewall.New(firewall.NewStore(cfg.DeploymentsPath)) _ = pluginRegistry.Register(firewallPlugin) + if enforced, err := firewallPlugin.EnforceCurrent(); err != nil { + log.Printf("firewall: failed to enforce saved policy at startup: %v", err) + } else if enforced { + log.Printf("firewall: enforcing saved policy") + } builtinDNS := []plugins.Plugin{ dnsPlugins.NewCloudflarePlugin(), diff --git a/pkg/plugins/firewall/firewall.go b/pkg/plugins/firewall/firewall.go index c040b7d..ce9470c 100644 --- a/pkg/plugins/firewall/firewall.go +++ b/pkg/plugins/firewall/firewall.go @@ -151,10 +151,59 @@ func Plan(cfg *Config) []string { return plan } -// Apply would translate the config to host firewall rules. It is a no-op scaffold that only -// validates the config; the host firewall is never touched yet. -func Apply(cfg *Config) error { - // TODO: render rules to nftables/iptables with a safety net that preserves the active - // SSH session before committing a default-deny inbound policy. - return Validate(cfg) +// nftRunner runs the nft commands enforcement needs. It is an interface so the +// apply/rollback logic can be tested without touching the host firewall. +type nftRunner interface { + // Available reports whether nft can be used on this host. + Available() bool + // ListRuleset returns the current full ruleset, for rollback. + ListRuleset() (string, error) + // ApplyScript loads an `nft -f` script. + ApplyScript(script string) error +} + +// Apply enforces the config on the host firewall, or removes FlatRun's rules when +// the firewall is disabled. It reports whether enforcement actually happened: +// when nft is unavailable (a non-Linux host, or nft not installed) the config is +// still saved but not enforced, which is not an error. +// +// Before a new ruleset is loaded the current one is snapshotted, and if the load +// fails the snapshot is restored, so a rejected ruleset never leaves the host in +// a half-applied state. The generated ruleset always keeps loopback, +// established/related, and the active SSH port open, so a default-deny inbound +// policy cannot drop the operator's session. +func Apply(cfg *Config, runner nftRunner) (enforced bool, err error) { + if err := Validate(cfg); err != nil { + return false, err + } + if runner == nil { + runner = newExecNftRunner() + } + if !runner.Available() { + return false, nil + } + + if cfg == nil || !cfg.Enabled { + // Disabling enforcement removes only FlatRun's table. + script := fmt.Sprintf("add table inet %s\ndelete table inet %s\n", tableName, tableName) + if err := runner.ApplyScript(script); err != nil { + return false, fmt.Errorf("failed to remove firewall rules: %w", err) + } + return false, nil + } + + snapshot, err := runner.ListRuleset() + if err != nil { + return false, fmt.Errorf("failed to read current firewall state: %w", err) + } + + script := renderNftables(cfg, detectSSHSession()) + if err := runner.ApplyScript(script); err != nil { + if snapshot != "" { + // Best-effort restore of the ruleset that was in place before. + _ = runner.ApplyScript("flush ruleset\n" + snapshot) + } + return false, fmt.Errorf("failed to apply firewall rules: %w", err) + } + return true, nil } diff --git a/pkg/plugins/firewall/nft_exec.go b/pkg/plugins/firewall/nft_exec.go new file mode 100644 index 0000000..cda81d5 --- /dev/null +++ b/pkg/plugins/firewall/nft_exec.go @@ -0,0 +1,41 @@ +package firewall + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// execNftRunner runs the real nft binary. It is used in production; tests inject +// a fake runner instead so they never touch the host firewall. +type execNftRunner struct{} + +func newExecNftRunner() *execNftRunner { return &execNftRunner{} } + +func (execNftRunner) Available() bool { + _, err := exec.LookPath("nft") + return err == nil +} + +func (execNftRunner) ListRuleset() (string, error) { + out, err := exec.Command("nft", "list", "ruleset").Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func (execNftRunner) ApplyScript(script string) error { + cmd := exec.Command("nft", "-f", "-") + cmd.Stdin = strings.NewReader(script) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return fmt.Errorf("%s", msg) + } + return err + } + return nil +} diff --git a/pkg/plugins/firewall/nftables.go b/pkg/plugins/firewall/nftables.go new file mode 100644 index 0000000..964dced --- /dev/null +++ b/pkg/plugins/firewall/nftables.go @@ -0,0 +1,157 @@ +package firewall + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// tableName is the single nftables table FlatRun owns. Enforcement only ever +// creates, replaces, or deletes this table, never the whole ruleset, so rules +// managed by Docker or the operator in other tables are left untouched. +const tableName = "flatrun_firewall" + +// sshSession is the SSH connection the agent is reachable through, if any. It is +// used to keep that session (and future SSH) alive under a default-deny inbound +// policy. +type sshSession struct { + clientIP string + serverPort int +} + +// detectSSHSession reads SSH_CONNECTION ("client_ip client_port server_ip +// server_port"), set by sshd for a session. It reports the SSH port so a +// default-deny policy never locks the operator out. +func detectSSHSession() sshSession { + parts := strings.Fields(os.Getenv("SSH_CONNECTION")) + if len(parts) != 4 { + return sshSession{} + } + port, err := strconv.Atoi(parts[3]) + if err != nil || port <= 0 || port > 65535 { + return sshSession{} + } + return sshSession{clientIP: parts[0], serverPort: port} +} + +// saddrClause returns the nftables source/destination match for a CIDR, picking +// the address family, or "" for any address. +func addrClause(field, cidr string) string { + if cidr == "" { + return "" + } + family := "ip" + if strings.Contains(cidr, ":") { + family = "ip6" + } + return fmt.Sprintf("%s %s %s ", family, field, cidr) +} + +// ruleLines renders one config rule to zero or more nftables statements. A rule +// with no protocol (or "any") and a port expands to both tcp and udp. +func ruleLines(r Rule, addrField string) []string { + verdict := "accept" + if r.Action == PolicyDeny { + verdict = "drop" + } + addr := addrClause(addrField, r.CIDR) + + protos := []string{} + switch r.Protocol { + case "tcp", "udp": + protos = []string{r.Protocol} + default: // "", "any" + if r.Port != 0 { + protos = []string{"tcp", "udp"} + } + } + + if r.Port == 0 || len(protos) == 0 { + // Match on address (and protocol, if a specific one was given) alone. + proto := "" + if r.Protocol == "tcp" || r.Protocol == "udp" { + proto = "meta l4proto " + r.Protocol + " " + } + return []string{fmt.Sprintf(" %s%s%s", addr, proto, verdict)} + } + + var lines []string + for _, p := range protos { + lines = append(lines, fmt.Sprintf(" %s%s dport %d %s", addr, p, r.Port, verdict)) + } + return lines +} + +// renderNftables translates a firewall config to an `nft -f` script for the +// FlatRun table. The script is idempotent: it recreates the table from scratch +// on every apply. Loopback and established/related traffic are always accepted, +// and the active SSH port is always accepted, so switching to a default-deny +// inbound policy never drops the operator's connection. +func renderNftables(cfg *Config, ssh sshSession) string { + inbound := cfg.DefaultInbound + if inbound == "" { + inbound = PolicyAllow + } + outbound := cfg.DefaultOutbound + if outbound == "" { + outbound = PolicyAllow + } + + var b strings.Builder + b.WriteString("# Managed by FlatRun. Do not edit.\n") + // add-then-delete makes the following table definition a clean replace even + // on the first apply, when the table does not exist yet. + fmt.Fprintf(&b, "add table inet %s\n", tableName) + fmt.Fprintf(&b, "delete table inet %s\n", tableName) + fmt.Fprintf(&b, "table inet %s {\n", tableName) + + // Input chain. + fmt.Fprintf(&b, " chain input {\n") + fmt.Fprintf(&b, " type filter hook input priority 0; policy %s;\n", policyVerdict(inbound)) + b.WriteString(" iif \"lo\" accept\n") + b.WriteString(" ct state established,related accept\n") + b.WriteString(" ct state invalid drop\n") + if inbound == PolicyDeny { + // Preserve SSH under a default-deny so the operator is not locked out. + port := 22 + if ssh.serverPort != 0 { + port = ssh.serverPort + } + fmt.Fprintf(&b, " tcp dport %d accept\n", port) + } + for _, r := range cfg.Rules { + if r.Direction != DirInbound { + continue + } + for _, line := range ruleLines(r, "saddr") { + b.WriteString(line + "\n") + } + } + b.WriteString(" }\n") + + // Output chain. + fmt.Fprintf(&b, " chain output {\n") + fmt.Fprintf(&b, " type filter hook output priority 0; policy %s;\n", policyVerdict(outbound)) + b.WriteString(" oif \"lo\" accept\n") + b.WriteString(" ct state established,related accept\n") + for _, r := range cfg.Rules { + if r.Direction != DirOutbound { + continue + } + for _, line := range ruleLines(r, "daddr") { + b.WriteString(line + "\n") + } + } + b.WriteString(" }\n") + + b.WriteString("}\n") + return b.String() +} + +func policyVerdict(policy string) string { + if policy == PolicyDeny { + return "drop" + } + return "accept" +} diff --git a/pkg/plugins/firewall/nftables_test.go b/pkg/plugins/firewall/nftables_test.go new file mode 100644 index 0000000..cbacd0b --- /dev/null +++ b/pkg/plugins/firewall/nftables_test.go @@ -0,0 +1,148 @@ +package firewall + +import ( + "errors" + "strings" + "testing" +) + +var errBadRuleset = errors.New("bad ruleset") + +type fakeRunner struct { + available bool + snapshot string + applyErr error // returned when a full table script is applied + scripts []string +} + +func (f *fakeRunner) Available() bool { return f.available } +func (f *fakeRunner) ListRuleset() (string, error) { return f.snapshot, nil } +func (f *fakeRunner) ApplyScript(script string) error { + f.scripts = append(f.scripts, script) + if f.applyErr != nil && strings.Contains(script, "table inet flatrun_firewall {") { + return f.applyErr + } + return nil +} + +func TestRenderNftablesKeepsSSHAndBaseTrafficUnderDeny(t *testing.T) { + cfg := &Config{Enabled: true, DefaultInbound: "deny", DefaultOutbound: "allow"} + script := renderNftables(cfg, sshSession{serverPort: 2222}) + + for _, want := range []string{ + "policy drop;", + `iif "lo" accept`, + "ct state established,related accept", + "tcp dport 2222 accept", + } { + if !strings.Contains(script, want) { + t.Errorf("deny-inbound ruleset must contain %q, got:\n%s", want, script) + } + } +} + +func TestRenderNftablesDefaultsToSSHPort22(t *testing.T) { + script := renderNftables(&Config{Enabled: true, DefaultInbound: "deny"}, sshSession{}) + if !strings.Contains(script, "tcp dport 22 accept") { + t.Errorf("without a detected SSH session, port 22 must be kept open, got:\n%s", script) + } +} + +func TestRenderNftablesAllowDefaultIsPermissive(t *testing.T) { + script := renderNftables(&Config{Enabled: true, DefaultInbound: "allow", DefaultOutbound: "allow"}, sshSession{}) + if strings.Contains(script, "policy drop;") { + t.Errorf("an allow default must not drop, got:\n%s", script) + } + if strings.Count(script, "policy accept;") != 2 { + t.Errorf("both chains should accept by default, got:\n%s", script) + } +} + +func TestRenderNftablesTranslatesRules(t *testing.T) { + cfg := &Config{Enabled: true, DefaultInbound: "deny", Rules: []Rule{ + {Direction: "inbound", Action: "allow", Protocol: "tcp", Port: 80}, + {Direction: "inbound", Action: "allow", Port: 53}, // any proto -> tcp + udp + {Direction: "inbound", Action: "deny", CIDR: "192.168.1.5/32"}, + {Direction: "outbound", Action: "deny", Protocol: "tcp", Port: 25}, + }} + script := renderNftables(cfg, sshSession{}) + + for _, want := range []string{ + "tcp dport 80 accept", + "tcp dport 53 accept", + "udp dport 53 accept", + "ip saddr 192.168.1.5/32 drop", + "tcp dport 25 drop", + } { + if !strings.Contains(script, want) { + t.Errorf("expected rule %q in:\n%s", want, script) + } + } +} + +func TestApplyNoOpWhenUnavailable(t *testing.T) { + r := &fakeRunner{available: false} + enforced, err := Apply(&Config{Enabled: true, DefaultInbound: "deny"}, r) + if err != nil || enforced { + t.Fatalf("without nft, apply must be a no-op: enforced=%v err=%v", enforced, err) + } + if len(r.scripts) != 0 { + t.Errorf("no nft script should run when nft is unavailable, got %v", r.scripts) + } +} + +func TestApplyEnforcesWhenEnabled(t *testing.T) { + r := &fakeRunner{available: true, snapshot: "table inet other {}"} + enforced, err := Apply(&Config{Enabled: true, DefaultInbound: "deny"}, r) + if err != nil || !enforced { + t.Fatalf("apply should enforce: enforced=%v err=%v", enforced, err) + } + last := r.scripts[len(r.scripts)-1] + if !strings.Contains(last, "table inet flatrun_firewall {") { + t.Errorf("expected the firewall table to be applied, got:\n%s", last) + } +} + +func TestApplyRestoresSnapshotOnFailure(t *testing.T) { + r := &fakeRunner{available: true, snapshot: "table inet other { chain c {} }", applyErr: errBadRuleset} + enforced, err := Apply(&Config{Enabled: true, DefaultInbound: "deny"}, r) + if err == nil || enforced { + t.Fatalf("a failed apply must report the error: enforced=%v err=%v", enforced, err) + } + restored := false + for _, s := range r.scripts { + if strings.Contains(s, "flush ruleset") && strings.Contains(s, "table inet other") { + restored = true + } + } + if !restored { + t.Errorf("the previous ruleset should be restored after a failed apply, scripts:\n%v", r.scripts) + } +} + +func TestApplyDisabledRemovesTable(t *testing.T) { + r := &fakeRunner{available: true} + enforced, err := Apply(&Config{Enabled: false}, r) + if err != nil || enforced { + t.Fatalf("a disabled firewall is not enforced: enforced=%v err=%v", enforced, err) + } + if len(r.scripts) != 1 || !strings.Contains(r.scripts[0], "delete table inet flatrun_firewall") { + t.Errorf("disabling should remove only the firewall table, got %v", r.scripts) + } + if strings.Contains(r.scripts[0], "chain input") { + t.Errorf("disabling should not define chains, got:\n%s", r.scripts[0]) + } +} + +func TestDetectSSHSession(t *testing.T) { + t.Setenv("SSH_CONNECTION", "203.0.113.4 51000 10.0.0.2 2222") + s := detectSSHSession() + if s.clientIP != "203.0.113.4" || s.serverPort != 2222 { + t.Errorf("unexpected parse: %+v", s) + } + + t.Setenv("SSH_CONNECTION", "") + if s := detectSSHSession(); s.serverPort != 0 { + t.Errorf("no SSH_CONNECTION should yield an empty session, got %+v", s) + } +} diff --git a/pkg/plugins/firewall/plugin.go b/pkg/plugins/firewall/plugin.go index 9378f1e..f16e601 100644 --- a/pkg/plugins/firewall/plugin.go +++ b/pkg/plugins/firewall/plugin.go @@ -10,17 +10,28 @@ import ( // Plugin is the built-in Firewall app. It implements plugins.Plugin so it registers in the // plugin registry and lists as an installed app. type Plugin struct { - store *Store + store *Store + runner nftRunner // nil in production; a fake is injected in tests } func New(store *Store) *Plugin { return &Plugin{store: store} } +// EnforceCurrent applies the stored config to the host firewall. The server calls +// it at startup so a saved policy takes effect again after a restart. +func (p *Plugin) EnforceCurrent() (bool, error) { + cfg, err := p.store.Load() + if err != nil { + return false, err + } + return Apply(cfg, p.runner) +} + func (p *Plugin) Info() plugins.PluginInfo { return plugins.PluginInfo{ Name: "firewall", Version: "0.1.0", DisplayName: "Firewall", - Description: "Set the server's inbound and outbound traffic rules in one place. Enforcement coming soon.", + Description: "Set and enforce the server's inbound and outbound traffic rules in one place.", Author: "FlatRun", Type: plugins.TypeIntegration, Category: "security", @@ -67,7 +78,12 @@ func (p *Plugin) RegisterRoutes(router *gin.RouterGroup) error { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"message": "firewall config saved", "enforced": false}) + enforced, err := Apply(&cfg, p.runner) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "saved": true, "enforced": false}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "firewall config saved", "enforced": enforced}) }) router.GET("/firewall/plan", func(c *gin.Context) { @@ -76,7 +92,16 @@ func (p *Plugin) RegisterRoutes(router *gin.RouterGroup) error { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"plan": Plan(cfg), "enforced": false}) + c.JSON(http.StatusOK, gin.H{"plan": Plan(cfg), "available": enforcementAvailable(p.runner)}) }) return nil } + +// enforcementAvailable reports whether the host can enforce firewall rules (nft +// present), so the UI can tell an operator when saving will only persist. +func enforcementAvailable(runner nftRunner) bool { + if runner == nil { + runner = newExecNftRunner() + } + return runner.Available() +}