Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
61 changes: 55 additions & 6 deletions pkg/plugins/firewall/firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,59 @@ func Plan(cfg *Config) []string {
return plan
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nft -f provides atomic execution; if the script contains an error, the kernel transaction is aborted and the previous ruleset remains completely untouched. The manual rollback implemented here is not only redundant but dangerous: flush ruleset clears all tables on the host, including those managed by Docker or other system services. If the subsequent reload of the snapshot fails (e.g., due to syntax issues in nft list ruleset output), the host could be left with no firewall or lost connectivity. It is safer to rely on nftables' native atomicity.

Suggested change
}
script := renderNftables(cfg, detectSSHSession())
if err := runner.ApplyScript(script); err != nil {
return false, fmt.Errorf("failed to apply firewall rules: %w", err)
}


// 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
}
41 changes: 41 additions & 0 deletions pkg/plugins/firewall/nft_exec.go
Original file line number Diff line number Diff line change
@@ -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
}
157 changes: 157 additions & 0 deletions pkg/plugins/firewall/nftables.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment on lines +25 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detecting the SSH port via SSH_CONNECTION is a fragile heuristic. While it works when the process is started manually from an SSH session, background services (like those started via systemd) will typically have an empty environment. This results in the firewall defaulting to port 22. If the user has configured SSH on a different port and expects the automatic safeguard to work, they may be locked out. Consider making the safeguard port configurable or adding an explicit check/warning.

Suggested change
// default-deny policy never locks the operator out.
func detectSSHSession() sshSession {
// detectSSHSession reads SSH_CONNECTION from the environment.
// Warning: This environment variable is usually missing for daemonized processes.
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}
}

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"
}
Loading
Loading