feat(firewall): Enforce the host firewall policy through nftables#180
Conversation
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.
Code Review SummaryThe PR successfully implements firewall enforcement using 🚀 Key Improvements
💡 Minor Suggestions
🚨 Critical Issues
|
| @@ -151,10 +151,59 @@ func Plan(cfg *Config) []string { | |||
| return plan | |||
| } | |||
There was a problem hiding this comment.
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.
| } | |
| script := renderNftables(cfg, detectSSHSession()) | |
| if err := runner.ApplyScript(script); err != nil { | |
| return false, fmt.Errorf("failed to apply firewall rules: %w", err) | |
| } |
| // default-deny policy never locks the operator out. | ||
| func detectSSHSession() sshSession { |
There was a problem hiding this comment.
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.
| // 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} | |
| } |
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, the stored policy is re-applied at startup so it survives a restart, and 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. The generated ruleset always keeps loopback, established connections, and the active SSH port open (detected from the current session, else port 22), 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.
Verification: the translation and the apply/rollback logic are unit-tested with a fake runner that never touches the host firewall, and the generated ruleset was separately loaded into real nft inside an isolated container network namespace, where it loaded, read back correctly, re-applied idempotently, and disabled cleanly. It was deliberately not applied to a live host's firewall, so please review the ruleset and the SSH safeguard before rolling out.