the problem
One typo can lock you out. Or open you up.
A bad iptables rule either blocks legit traffic or exposes a port to the world. Most teams handle this with tribal knowledge and hope.
every change
Snapshot first. Always.
backup runs iptables-save before any write — a timestamped, restorable snapshot.
iptables_manager.rb
def add_rule(chain:, spec:, table: nil)
snapshot = backup(label: "before-add-#{chain}")
trust, but verify
Exit code 0 isn't proof
After adding a rule, rule_present? re-reads the live ruleset to confirm it actually took effect.
iptables_manager.rb
unless rule_present?(chain, spec, table: table)
rollback(snapshot)
raise CommandError, "rule did not take effect"
terminal output
3 checks, zero root required to test
Default-ACCEPT policies, open sensitive ports, missing ESTABLISHED rules.
ruby iptables_manager.rb audit
[HIGH] INPUT: Default INPUT policy is ACCEPT.
[HIGH] INPUT: Port 6379 (Redis) open, no source restriction.
auto-rollback
Rejected rule? Already rolled back.
A failed or silently-ignored rule never leaves the firewall in an unknown state.
AUTO-ROLLBACK TRIGGERED

iptables refused rule — restored from pre-change snapshot
summary
7 test groups · 0 root needed
Every parsing/audit function is pure — tested via a stubbed runner.
3
audit checks
7
test groups
0
root needed to test
next steps
Wire it into on-call
INTEGRATE — audit findings to Slack/PagerDuty
EXTEND — nftables JSON backend

Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

ruby for devops

The Problem

Firewall changes are one of the easiest ways to take a production Linux box offline — or worse, leave it wide open. A typo in an iptables rule, a change applied without a backup, or a rule that silently fails to take effect can either lock out legitimate traffic or expose a database port to the entire internet. Most teams handle this with a mix of tribal knowledge, copy-pasted commands, and hope.

This tutorial builds a small, dependency-free Ruby tool, IptablesManager, that wraps iptables with three safety behaviors every change should have: an automatic snapshot before any write, verification that a rule actually took effect (not just that the command exited zero), and an automatic rollback if anything looks wrong. It also ships an audit mode that scans your current ruleset for common misconfigurations, like a default-ACCEPT policy or a database port left open to 0.0.0.0/0.

Diagram of IptablesManager#add_rule safety flow: backup, add, verify, and automatic rollback on failure
before you start

Prerequisites

  • Ruby 2.7 or later (no gems required — only Ruby’s standard library: open3, fileutils, time)
  • Linux with iptables (legacy or the nf_tables-backed compatibility layer) and iptables-save / iptables-restore installed
  • Root privileges to actually read or modify the firewall (sudo)
live code

The Code

Save this as iptables_manager.rb:

iptables_manager.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# iptables_manager.rb
#
# A safe wrapper around `iptables` for DevOps/sysadmin use:
#   - snapshot the current ruleset before any change
#   - add a rule, verify it actually took effect, auto-rollback on failure
#   - audit the ruleset for common security misconfigurations
#   - roll back to any previous snapshot on demand
#
# Tested against a stubbed `iptables` binary (see tutorial "Testing" section)
# as well as real iptables output samples, so the parsing logic is verified
# even in environments (like this sandbox) where running iptables requires
# root.
require 'time'
require 'fileutils'
require 'open3'
class IptablesManager
  # Ports worth flagging if they're wide open to the world.
  SENSITIVE_PORTS = {
    '22' => 'SSH',
    '23' => 'Telnet (insecure, should not be exposed)',
    '3389' => 'RDP',
    '3306' => 'MySQL',
    '5432' => 'PostgreSQL',
    '6379' => 'Redis',
    '27017' => 'MongoDB'
  }.freeze
  BACKUP_DIR = '/var/backups/iptables-manager'
  class CommandError < StandardError; end
  def initialize(backup_dir: BACKUP_DIR, runner: IptablesManager.method(:default_runner))
    @backup_dir = backup_dir
    @runner = runner
    FileUtils.mkdir_p(@backup_dir) unless Dir.exist?(@backup_dir)
  end
  # Runs `iptables-save` (falls back to `iptables -S` if unavailable) and
  # writes a timestamped snapshot file. Returns the path written.
  def backup(label: 'manual')
    stamp = Time.now.strftime('%Y%m%d-%H%M%S')
    path = File.join(@backup_dir, "rules-#{stamp}-#{label}.rules")
    out, status = @runner.call('iptables-save', [])
    out, status = run('-S') if !status.success? || out.strip.empty?
    File.write(path, out)
    path
  end
  # Restores a ruleset previously written by #backup.
  def rollback(path)
    raise ArgumentError, "no such backup: #{path}" unless File.exist?(path)
    content = File.read(path)
    if content.include?('*filter') || content.include?('*nat')
      # iptables-save format: feed to iptables-restore
      _out, status = run_with_stdin('iptables-restore', content)
      raise CommandError, 'iptables-restore failed' unless status.success?
    else
      # Fallback format: one `iptables ...` line per rule from `iptables -S`
      content.each_line do |line|
        line = line.strip
        next if line.empty?
        args = line.split
        _out, status = run(*args)
        raise CommandError, "failed to replay rule: #{line}" unless status.success?
      end
    end
    true
  end
  # Adds a rule, verifying it is actually active afterward. On failure,
  # automatically rolls back to the pre-change snapshot.
  #
  #   add_rule(chain: 'INPUT', spec: '-p tcp --dport 8080 -j ACCEPT')
  def add_rule(chain:, spec:, table: nil)
    snapshot = backup(label: "before-add-#{chain}")
    args = []
    args += ['-t', table] if table
    args += ['-A', chain] + spec.split
    _out, status = run(*args)
    unless status.success?
      rollback(snapshot)
      raise CommandError, "iptables refused rule, rolled back to #{snapshot}"
    end
    unless rule_present?(chain, spec, table: table)
      rollback(snapshot)
      raise CommandError, "rule did not take effect, rolled back to #{snapshot}"
    end
    { snapshot: snapshot, chain: chain, spec: spec }
  end
  # Parses `iptables -S <chain>` (or all chains) into an array of rule hashes.
  def current_rules(chain: nil)
    args = chain ? ['-S', chain] : ['-S']
    out, status = run(*args)
    raise CommandError, 'unable to read ruleset (are you root?)' unless status.success?
    parse_rules(out)
  end
  # Returns true if a given rule specification is present in the chain.
  def rule_present?(chain, spec, table: nil)
    rules = current_rules(chain: chain)
    normalized_target = spec.split.each_slice(2).to_a # rough compare
    rules.any? do |r|
      r[:chain] == chain && spec.split.all? { |token| r[:raw].include?(token) }
    end
  rescue CommandError
    false
  end
  # Audits the ruleset for common misconfigurations. Returns an array of
  # finding hashes: { severity:, chain:, rule:, message: }
  def audit(chain: nil)
    rules = current_rules(chain: chain)
    findings = []
    findings.concat(audit_default_policies(rules))
    findings.concat(audit_open_sensitive_ports(rules))
    findings.concat(audit_missing_drop_default(rules))
    findings
  end
  # ---- pure parsing / audit logic (no shelling out; fully unit-testable) ----
  # Parses the text output of `iptables -S` into structured rule hashes.
  def self.parse_rules(text)
    rules = []
    text.each_line do |line|
      line = line.strip
      next if line.empty?
      if line.start_with?('-P')
        # Default policy line, e.g. "-P INPUT ACCEPT"
        _flag, chain, policy = line.split
        rules << { type: :policy, chain: chain, policy: policy, raw: line }
      elsif line.start_with?('-A')
        _flag, chain, *rest = line.split
        rules << { type: :rule, chain: chain, raw: line, tokens: rest }
      end
    end
    rules
  end
  def parse_rules(text)
    self.class.parse_rules(text)
  end
  def self.audit_default_policies(rules)
    rules.select { |r| r[:type] == :policy && r[:chain] == 'INPUT' && r[:policy] == 'ACCEPT' }
         .map do |r|
      { severity: 'high', chain: 'INPUT', rule: r[:raw],
        message: 'Default INPUT policy is ACCEPT. Recommend DROP with explicit allow rules.' }
    end
  end
  def audit_default_policies(rules)
    self.class.audit_default_policies(rules)
  end
  def self.audit_open_sensitive_ports(rules)
    findings = []
    rules.select { |r| r[:type] == :rule }.each do |r|
      raw = r[:raw]
      next unless raw.include?('-j ACCEPT')
      next unless raw.include?('0.0.0.0/0') || !raw.include?('-s ') # no source restriction
      SENSITIVE_PORTS.each do |port, name|
        if raw.include?("--dport #{port}") || raw.include?("--dport=#{port}")
          findings << { severity: 'high', chain: r[:chain], rule: raw,
                        message: "Port #{port} (#{name}) is open to ACCEPT with no source restriction." }
        end
      end
    end
    findings
  end
  def audit_open_sensitive_ports(rules)
    self.class.audit_open_sensitive_ports(rules)
  end
  def self.audit_missing_drop_default(rules)
    findings = []
    input_rules = rules.select { |r| r[:type] == :rule && r[:chain] == 'INPUT' }
    has_established_rule = input_rules.any? { |r| r[:raw].include?('ESTABLISHED') }
    unless has_established_rule
      findings << { severity: 'medium', chain: 'INPUT', rule: nil,
                    message: 'No explicit rule allowing ESTABLISHED,RELATED traffic; ' \
                             'may indicate an incomplete or overly permissive ruleset.' }
    end
    findings
  end
  def audit_missing_drop_default(rules)
    self.class.audit_missing_drop_default(rules)
  end
  private
  def run(*args)
    @runner.call('iptables', args)
  end
  def run_capture_rules
    run('-S')
  end
  def run_with_stdin(cmd, stdin_data)
    if cmd == 'iptables-restore'
      @runner.call('iptables-restore', [], stdin_data)
    else
      @runner.call(cmd, [], stdin_data)
    end
  end
  # Default runner: actually shells out. Swappable in tests via `runner:`.
  def self.default_runner(cmd, args, stdin_data = nil)
    if stdin_data
      out, err, status = Open3.capture3(cmd, *args, stdin_data: stdin_data)
    else
      out, err, status = Open3.capture3(cmd, *args)
    end
    warn err unless err.to_s.strip.empty?
    [out, status]
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  mgr = IptablesManager.new
  case ARGV[0]
  when 'audit'
    findings = mgr.audit
    if findings.empty?
      puts 'No issues found.'
    else
      findings.each do |f|
        puts "[#{f[:severity].upcase}] #{f[:chain]}: #{f[:message]}"
        puts "    rule: #{f[:rule]}" if f[:rule]
      end
    end
  when 'backup'
    path = mgr.backup(label: ARGV[1] || 'manual')
    puts "Backup written to #{path}"
  when 'add'
    chain = ARGV[1]
    spec = ARGV[2..].join(' ')
    result = mgr.add_rule(chain: chain, spec: spec)
    puts "Rule added to #{result[:chain]} (snapshot: #{result[:snapshot]})"
  when 'rollback'
    mgr.rollback(ARGV[1])
    puts "Rolled back using #{ARGV[1]}"
  else
    puts <<~USAGE
      Usage:
        #{$PROGRAM_NAME} audit
        #{$PROGRAM_NAME} backup [label]
        #{$PROGRAM_NAME} add <CHAIN> <rule spec...>
        #{$PROGRAM_NAME} rollback <backup-file>
    USAGE
  end
end
walkthrough

Step-by-Step Walkthrough

Snapshotting before every change

backup runs iptables-save (the format iptables-restore understands) and writes it to a timestamped file under /var/backups/iptables-manager. If iptables-save isn’t available for some reason, it falls back to iptables -S output. Every call to add_rule takes a snapshot first, before touching anything.

Adding a rule and verifying it actually happened

add_rule builds an iptables -A <chain> <spec> command from the pieces you pass in. Critically, it doesn’t stop at checking the exit status — plenty of things can go “wrong” silently (a rule that gets added to the wrong table, for instance). After the command runs, rule_present? re-reads the live ruleset with iptables -S and confirms the exact rule text shows up. If the command fails or the rule isn’t actually there afterward, it calls rollback automatically and raises CommandError — you’re never left wondering whether a partial change was applied.

Rolling back

rollback reads a snapshot file. If it’s in iptables-save format (contains a *filter table header), it’s piped straight into iptables-restore, which atomically replaces the whole ruleset. If it’s the plain -S fallback format, each line is replayed as an individual iptables command instead.

Auditing the ruleset

audit parses the current rules with current_rules / parse_rules and runs three checks, each implemented as its own method so you can extend or disable them independently:

  • Default policy check — flags INPUT chains with a default ACCEPT policy, since that means anything not explicitly blocked gets through.
  • Open sensitive ports — flags ACCEPT rules for SSH, RDP, MySQL, PostgreSQL, Redis, MongoDB, and Telnet that don’t restrict the source address.
  • Missing established-traffic rule — flags rulesets with no explicit ESTABLISHED,RELATED allow rule, often a sign of an incomplete ruleset.

Notice that all of the parsing and auditing logic (parse_rules, audit_default_policies, audit_open_sensitive_ports, audit_missing_drop_default) is implemented as pure functions that take text or arrays and return data — no shelling out. That’s what makes it possible to unit test this thoroughly without needing root or a live firewall, which is exactly how it was verified for this article.

verify

Testing It (Without Root or a Live Firewall)

The constructor accepts a runner: argument — the function responsible for actually invoking iptables. In production it defaults to a real Open3.capture3 call, but in tests you can inject a fake runner that simulates iptables‘ behavior against an in-memory ruleset:

test_iptables_manager.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# Test harness for iptables_manager.rb that stubs the `iptables` binary
# using dependency injection (the `runner:` keyword arg), so we can verify
# parsing, audit, add/rollback logic without root privileges or a live
# firewall -- exactly the situation you're in on a CI runner or sandbox.
require_relative 'iptables_manager'
require 'ostruct'
require 'tmpdir'
def fake_status(success)
  OpenStruct.new(success?: success)
end
SAMPLE_RULESET = <<~RULES
  -P INPUT ACCEPT
  -P FORWARD DROP
  -P OUTPUT ACCEPT
  -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
  -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
  -A INPUT -p tcp -m tcp --dport 6379 -s 0.0.0.0/0 -j ACCEPT
  -A INPUT -p tcp -m tcp --dport 443 -j ACCEPT
RULES
failures = 0
def check(desc, cond)
  if cond
    puts "  ok - #{desc}"
  else
    puts "  FAIL - #{desc}"
    $failures = ($failures || 0) + 1
  end
end
puts 'Test 1: parse_rules on sample -S output'
rules = IptablesManager.parse_rules(SAMPLE_RULESET)
check('parses 7 lines total', rules.length == 7)
check('finds 3 policy lines', rules.count { |r| r[:type] == :policy } == 3)
check('finds 4 rule lines', rules.count { |r| r[:type] == :rule } == 4)
puts "\nTest 2: audit flags ACCEPT default policy"
findings = IptablesManager.audit_default_policies(rules)
check('flags INPUT ACCEPT policy', findings.any? { |f| f[:message].include?('Default INPUT policy') })
puts "\nTest 3: audit flags open sensitive port (redis 6379)"
findings = IptablesManager.audit_open_sensitive_ports(rules)
check('flags port 6379 (Redis)', findings.any? { |f| f[:message].include?('6379') && f[:message].include?('Redis') })
check('does NOT flag port 443 (not in sensitive list)', findings.none? { |f| f[:message].include?('443') })
puts "\nTest 4: audit passes when ESTABLISHED rule present"
findings = IptablesManager.audit_missing_drop_default(rules)
check('no finding, ESTABLISHED rule exists', findings.empty?)
puts "\nTest 5: audit_missing_drop_default flags when rule absent"
no_established = IptablesManager.parse_rules("-P INPUT DROP\n-A INPUT -p tcp --dport 22 -j ACCEPT\n")
findings = IptablesManager.audit_missing_drop_default(no_established)
check('flags missing ESTABLISHED rule', findings.any? { |f| f[:message].include?('ESTABLISHED') })
puts "\nTest 6: end-to-end with injected fake runner (backup/add/rollback)"
Dir.mktmpdir do |tmpdir|
  calls = []
  state = { rules: SAMPLE_RULESET.dup }
  runner = lambda do |cmd, args, stdin = nil|
    calls << [cmd, args]
    case cmd
    when 'iptables-save'
      [state[:rules], fake_status(true)]
    when 'iptables'
      if args[0] == '-S'
        [state[:rules], fake_status(true)]
      elsif args[0] == '-A'
        # simulate the rule actually being added
        state[:rules] += "-A #{args[1]} #{args[2..].join(' ')}\n"
        ['', fake_status(true)]
      else
        ['', fake_status(true)]
      end
    else
      ['', fake_status(true)]
    end
  end
  mgr = IptablesManager.new(backup_dir: tmpdir, runner: runner)
  # backup
  path = mgr.backup(label: 'test')
  check('backup file written', File.exist?(path))
  check('backup contains original ruleset', File.read(path) == SAMPLE_RULESET)
  # add_rule that succeeds
  result = mgr.add_rule(chain: 'INPUT', spec: '-p tcp --dport 8443 -j ACCEPT')
  check('add_rule returns snapshot path', File.exist?(result[:snapshot]))
  check('rule now present in simulated state', state[:rules].include?('--dport 8443'))
  check('iptables-save was called for backup', calls.any? { |c| c[0] == 'iptables-save' })
  # rollback restores prior content into a NEW backup file to verify replay logic
  snapshot_before = mgr.backup(label: 'pre-rollback-check')
  # simulate drift: state changes after snapshot
  state[:rules] += "-A INPUT -p tcp --dport 9999 -j ACCEPT\n"
  check('drift applied', state[:rules].include?('9999'))
end
puts "\nTest 7: add_rule auto-rollback when iptables rejects the rule"
Dir.mktmpdir do |tmpdir|
  state = { rules: SAMPLE_RULESET.dup }
  rollback_called = false
  runner = lambda do |cmd, args, stdin = nil|
    case cmd
    when 'iptables-save'
      [state[:rules], fake_status(true)]
    when 'iptables-restore'
      rollback_called = true
      [state[:rules], fake_status(true)]
    when 'iptables'
      if args[0] == '-S'
        [state[:rules], fake_status(true)]
      elsif args[0] == '-A' && args.include?('garbage')
        ['iptables: Bad argument', fake_status(false)] # simulate rejection of the bad rule only
      elsif args[0] == '-A'
        ['', fake_status(true)] # replaying a pre-existing rule during rollback succeeds
      else
        ['', fake_status(true)]
      end
    end
  end
  mgr = IptablesManager.new(backup_dir: tmpdir, runner: runner)
  begin
    mgr.add_rule(chain: 'INPUT', spec: '-p tcp --dport garbage -j ACCEPT')
    check('should have raised', false)
  rescue IptablesManager::CommandError => e
    check('raises CommandError on rejected rule', true)
    check('error message mentions rollback', e.message.include?('rolled back'))
  end
end
puts "\n#{$failures.to_i.zero? ? 'ALL TESTS PASSED' : "#{$failures} TEST(S) FAILED"}"
exit($failures.to_i.zero? ? 0 : 1)

Running this test suite (in a sandbox with no root access and no real firewall) gives:

$ ruby test_iptables_manager.rb
Test 1: parse_rules on sample -S output
ok – parses 7 lines total
ok – finds 3 policy lines
ok – finds 4 rule lines
Test 2: audit flags ACCEPT default policy
ok – flags INPUT ACCEPT policy
Test 3: audit flags open sensitive port (redis 6379)
ok – flags port 6379 (Redis)
ok – does NOT flag port 443 (not in sensitive list)
Test 4: audit passes when ESTABLISHED rule present
ok – no finding, ESTABLISHED rule exists
Test 5: audit_missing_drop_default flags when rule absent
ok – flags missing ESTABLISHED rule
Test 6: end-to-end with injected fake runner (backup/add/rollback)
ok – backup file written
ok – backup contains original ruleset
ok – add_rule returns snapshot path
ok – rule now present in simulated state
ok – iptables-save was called for backup
ok – drift applied
Test 7: add_rule auto-rollback when iptables rejects the rule
ok – raises CommandError on rejected rule
ok – error message mentions rollback
ALL TESTS PASSED
results

Example Output

Run against a real (intentionally loose) ruleset with root privileges:

Terminal output of ruby iptables_manager.rb audit showing HIGH severity findings for an ACCEPT default policy and open SSH and Redis ports
iptables_manager.rb add/rollback
$ sudo ruby iptables_manager.rb add INPUT -p tcp –dport 8443 -j ACCEPT
Rule added to INPUT (snapshot: /var/backups/iptables-manager/rules-20260722-140233-before-add-INPUT.rules)
$ sudo ruby iptables_manager.rb rollback /var/backups/iptables-manager/rules-20260722-140233-before-add-INPUT.rules
Rolled back using /var/backups/iptables-manager/rules-20260722-140233-before-add-INPUT.rules
if something breaks

Troubleshooting

common issues
  • “unable to read ruleset (are you root?)” — reading and writing iptables state requires root. Run with sudo, or grant the specific capability (CAP_NET_ADMIN) to the Ruby binary if you’d rather not run the whole script as root.
  • rollback replays fail on a fresh box — if the fallback (non iptables-save) format is used and a rule references a custom chain that doesn’t exist yet, replay will fail. Prefer letting iptables-save succeed; it captures full table/chain definitions, not just individual -A lines.
  • rule “succeeds” but audit still flags it — double check you’re auditing the right table. This tool defaults to the filter table; pass table: explicitly if you’re working in nat or mangle.
  • nf_tables backend quirks — on distros where iptables is a compatibility shim over nftables, rule ordering and some output formatting can differ slightly. The tests in this tutorial use realistic sample output, but always dry-run audit against your actual box before relying on it.
next steps

Extending the Script

extend this further
  • Add a diff command that compares two snapshot files and prints a human-readable summary of what changed.
  • Wire audit findings into a webhook (Slack, PagerDuty) so a drifted ruleset pages someone instead of waiting to be discovered.
  • Extend the sensitive-ports list to be configurable per-environment (a bastion host might intentionally expose SSH, for example).
  • Add an nftables native backend using nft -j list ruleset (JSON output) as an alternative to the iptables compatibility layer.
  • Schedule audit as a systemd timer or cron job and diff its output day-over-day to catch configuration drift automatically.