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() +}