the shed // ruby // linux // networking

A box with two default routes at the same metric, or a gateway that is not on any connected subnet, fails in ways that look like flaky DNS or random timeouts. This pure-stdlib Ruby script reads /proc/net/route and tells you exactly what is wrong.

Step through the build below:

route_table_audit.rb

Routing bugs are quiet. A second NIC brings up its own default route, a static route is typed with a gateway on the wrong subnet, or a VPN client leaves a duplicate behind. Nothing errors; some packets just go the wrong way. Checking by eye with ip route does not scale across a fleet. This script parses the kernel table directly and reports problems with a severity and an exit code you can wire into monitoring.

#!/usr/bin/env ruby
# frozen_string_literal: true
# route_table_audit.rb - audit the Linux IPv4 routing table from /proc/net/route.
# Pure stdlib. Usage: ruby route_table_audit.rb [--file PATH] [--json]
require 'json'
require 'optparse'
Route = Struct.new(:iface, :dest, :gateway, :flags, :metric, :mask, keyword_init: true) do
  def prefix_len = mask.to_s(2).count('1')
  def default? = dest.zero? && mask.zero?
  def up? = flags.anybits?(0x1)
  def via_gateway? = flags.anybits?(0x2)
end
# /proc/net/route stores addresses as little-endian hex; convert to Integer
# in network (big-endian) order so normal bit math works.
def le_hex_to_int(hex) = [hex.to_i(16)].pack('L<').unpack1('N')
def ip(int) = [int].pack('N').unpack('C4').join('.')
def parse(path)
  File.readlines(path, chomp: true).drop(1).filter_map do |line|
    f = line.split
    next if f.size < 8
    Route.new(iface: f[0], dest: le_hex_to_int(f[1]), gateway: le_hex_to_int(f[2]),
              flags: f[3].to_i(16), metric: f[6].to_i, mask: le_hex_to_int(f[7]))
  end
end
def audit(routes)
  findings = []
  add = ->(sev, msg) { findings << { severity: sev, message: msg } }
  defaults = routes.select { |r| r.default? && r.up? }
  add.(:high, 'No default route: host cannot reach anything off-link') if defaults.empty?
  defaults.group_by(&:metric).each do |m, rs|
    add.(:medium, "#{rs.size} default routes share metric #{m} (#{rs.map(&:iface).join(', ')}): path choice is ambiguous") if rs.size > 1
  end
  routes.group_by { |r| [r.dest, r.mask, r.metric] }.each do |(d, m, me), rs|
    add.(:medium, "Duplicate route #{ip(d)}/#{m.to_s(2).count('1')} metric #{me} on #{rs.map(&:iface).join(', ')}") if rs.size > 1
  end
  connected = routes.select { |r| !r.via_gateway? && !r.default? }
  routes.select(&:via_gateway?).each do |r|
    ok = connected.any? { |c| c.iface == r.iface && (r.gateway & c.mask) == c.dest }
    add.(:high, "Gateway #{ip(r.gateway)} for #{ip(r.dest)}/#{r.prefix_len} is not inside any connected subnet on #{r.iface}") unless ok
  end
  routes.reject(&:up?).each { |r| add.(:low, "Route #{ip(r.dest)}/#{r.prefix_len} on #{r.iface} is marked down") }
  findings
end
opts = { file: '/proc/net/route', json: false }
OptionParser.new do |o|
  o.on('--file PATH') { |v| opts[:file] = v }
  o.on('--json') { opts[:json] = true }
end.parse!
routes = parse(opts[:file])
findings = audit(routes)
if opts[:json]
  puts JSON.pretty_generate(routes: routes.size, findings: findings)
else
  puts "Routes parsed: #{routes.size}"
  routes.each { |r| puts format('  %-16s %-8s via %-15s metric %d', "#{ip(r.dest)}/#{r.prefix_len}", r.iface, r.via_gateway? ? ip(r.gateway) : 'on-link', r.metric) }
  puts findings.empty? ? 'OK: no routing problems found' : findings.map { |f| "[#{f[:severity].upcase}] #{f[:message]}" }
end
exit(findings.any? { |f| f[:severity] == :high } ? 2 : (findings.empty? ? 0 : 1))

1. Decode the hex. /proc/net/route stores IPv4 addresses as little-endian hex (010200C0 is 192.0.2.1). [hex.to_i(16)].pack('L<').unpack1('N') reverses the byte order into a normal integer so subnet math works.

2. Model a route. A Struct holds interface, destination, gateway, flags, metric and mask. Helpers compute the prefix length (count of 1 bits), whether it is the default route, and whether the UP (0x1) and GATEWAY (0x2) flags are set.

3. Run the checks. Four rules: no live default route (high); several defaults sharing a metric (medium); duplicate destination/mask/metric rows (medium); and a gateway whose address does not fall inside a connected on-link subnet for the same interface (high). Down routes are reported as low.

4. Report and exit. Text or --json output. Exit 0 is clean, 1 warnings, 2 a high-severity finding, so cron or a monitoring agent can alert without parsing text.

$ captured from a real test run

$ ruby route_table_audit.rb --file bad_route
Routes parsed: 5
  0.0.0.0/0        eth0     via 192.168.1.1     metric 100
  0.0.0.0/0        wlan0    via 192.168.0.1     metric 100
  192.168.0.0/16   eth0     via on-link         metric 100
  192.168.0.0/16   eth0     via on-link         metric 100
  10.0.0.0/8       eth0     via 10.0.0.1        metric 0
[MEDIUM] 2 default routes share metric 100 (eth0, wlan0): path choice is ambiguous
[MEDIUM] Duplicate route 0.0.0.0/0 metric 100 on eth0, wlan0
[MEDIUM] Duplicate route 192.168.0.0/16 metric 100 on eth0, eth0
[HIGH] Gateway 192.168.0.1 for 0.0.0.0/0 is not inside any connected subnet on wlan0
[HIGH] Gateway 10.0.0.1 for 10.0.0.0/8 is not inside any connected subnet on eth0
(exit code 2)
Get the code

Full script + README on GitHub: ruby-devops-toolkit/route-table-audit

prerequisites

What you need

Ruby 2.7+ (tested on 3.3.6), Linux with /proc/net/route, no gems. The --file flag lets you test against a saved table on any OS.

route_table_audit.rb pipeline diagram

route_table_audit.rb pipeline
full script

The complete code

route_table_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# route_table_audit.rb - audit the Linux IPv4 routing table from /proc/net/route.
# Pure stdlib. Usage: ruby route_table_audit.rb [--file PATH] [--json]
require 'json'
require 'optparse'
Route = Struct.new(:iface, :dest, :gateway, :flags, :metric, :mask, keyword_init: true) do
  def prefix_len = mask.to_s(2).count('1')
  def default? = dest.zero? && mask.zero?
  def up? = flags.anybits?(0x1)
  def via_gateway? = flags.anybits?(0x2)
end
# /proc/net/route stores addresses as little-endian hex; convert to Integer
# in network (big-endian) order so normal bit math works.
def le_hex_to_int(hex) = [hex.to_i(16)].pack('L<').unpack1('N')
def ip(int) = [int].pack('N').unpack('C4').join('.')
def parse(path)
  File.readlines(path, chomp: true).drop(1).filter_map do |line|
    f = line.split
    next if f.size < 8
    Route.new(iface: f[0], dest: le_hex_to_int(f[1]), gateway: le_hex_to_int(f[2]),
              flags: f[3].to_i(16), metric: f[6].to_i, mask: le_hex_to_int(f[7]))
  end
end
def audit(routes)
  findings = []
  add = ->(sev, msg) { findings << { severity: sev, message: msg } }
  defaults = routes.select { |r| r.default? && r.up? }
  add.(:high, 'No default route: host cannot reach anything off-link') if defaults.empty?
  defaults.group_by(&:metric).each do |m, rs|
    add.(:medium, "#{rs.size} default routes share metric #{m} (#{rs.map(&:iface).join(', ')}): path choice is ambiguous") if rs.size > 1
  end
  routes.group_by { |r| [r.dest, r.mask, r.metric] }.each do |(d, m, me), rs|
    add.(:medium, "Duplicate route #{ip(d)}/#{m.to_s(2).count('1')} metric #{me} on #{rs.map(&:iface).join(', ')}") if rs.size > 1
  end
  connected = routes.select { |r| !r.via_gateway? && !r.default? }
  routes.select(&:via_gateway?).each do |r|
    ok = connected.any? { |c| c.iface == r.iface && (r.gateway & c.mask) == c.dest }
    add.(:high, "Gateway #{ip(r.gateway)} for #{ip(r.dest)}/#{r.prefix_len} is not inside any connected subnet on #{r.iface}") unless ok
  end
  routes.reject(&:up?).each { |r| add.(:low, "Route #{ip(r.dest)}/#{r.prefix_len} on #{r.iface} is marked down") }
  findings
end
opts = { file: '/proc/net/route', json: false }
OptionParser.new do |o|
  o.on('--file PATH') { |v| opts[:file] = v }
  o.on('--json') { opts[:json] = true }
end.parse!
routes = parse(opts[:file])
findings = audit(routes)
if opts[:json]
  puts JSON.pretty_generate(routes: routes.size, findings: findings)
else
  puts "Routes parsed: #{routes.size}"
  routes.each { |r| puts format('  %-16s %-8s via %-15s metric %d', "#{ip(r.dest)}/#{r.prefix_len}", r.iface, r.via_gateway? ? ip(r.gateway) : 'on-link', r.metric) }
  puts findings.empty? ? 'OK: no routing problems found' : findings.map { |f| "[#{f[:severity].upcase}] #{f[:message]}" }
end
exit(findings.any? { |f| f[:severity] == :high } ? 2 : (findings.empty? ? 0 : 1))
how it works

Step-by-step walkthrough

1. Decode the hex

/proc/net/route stores IPv4 addresses as little-endian hex (010200C0 is 192.0.2.1). [hex.to_i(16)].pack('L<').unpack1('N') reverses the byte order into a normal integer so subnet math works.

2. Model a route

A Struct holds interface, destination, gateway, flags, metric and mask. Helpers compute the prefix length (count of 1 bits), whether it is the default route, and whether the UP (0x1) and GATEWAY (0x2) flags are set.

3. Run the checks

Four rules: no live default route (high); several defaults sharing a metric (medium); duplicate destination/mask/metric rows (medium); and a gateway whose address does not fall inside a connected on-link subnet for the same interface (high). Down routes are reported as low.

4. Report and exit

Text or --json output. Exit 0 is clean, 1 warnings, 2 a high-severity finding, so cron or a monitoring agent can alert without parsing text.

example output

What you will see

terminaltext
$ ruby route_table_audit.rb --file bad_route
Routes parsed: 5
  0.0.0.0/0        eth0     via 192.168.1.1     metric 100
  0.0.0.0/0        wlan0    via 192.168.0.1     metric 100
  192.168.0.0/16   eth0     via on-link         metric 100
  192.168.0.0/16   eth0     via on-link         metric 100
  10.0.0.0/8       eth0     via 10.0.0.1        metric 0
[MEDIUM] 2 default routes share metric 100 (eth0, wlan0): path choice is ambiguous
[MEDIUM] Duplicate route 0.0.0.0/0 metric 100 on eth0, wlan0
[MEDIUM] Duplicate route 192.168.0.0/16 metric 100 on eth0, eth0
[HIGH] Gateway 192.168.0.1 for 0.0.0.0/0 is not inside any connected subnet on wlan0
[HIGH] Gateway 10.0.0.1 for 10.0.0.0/8 is not inside any connected subnet on eth0
(exit code 2)
troubleshooting

When it does not work

  • Missing /proc/net/route: you are not on Linux. Use --file with a saved copy.
  • Gateway flagged unreachable but traffic works: the gateway may be reachable through a more specific route on a different interface; extend the check to search all connected routes.
  • Everything shows metric 0: normal on simple hosts; metrics matter only with multiple defaults.
next steps

Extending the script

  • Parse /proc/net/ipv6_route for IPv6.
  • Diff against a stored known-good table and alert on change.
  • Ship JSON output to Prometheus or your log pipeline.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating