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:
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)
Full script + README on GitHub: ruby-devops-toolkit/route-table-audit
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.
The complete code
#!/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))
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.
What you will see
$ 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)
When it does not work
- Missing
/proc/net/route: you are not on Linux. Use--filewith 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.
Extending the script
- Parse
/proc/net/ipv6_routefor IPv6. - Diff against a stored known-good table and alert on change.
- Ship JSON output to Prometheus or your log pipeline.