Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.
The Problem
An HTTP health check tells you a web endpoint is answering. It tells you nothing about whether your database’s port is actually reachable from the app tier, whether the cache you just launched is quietly exposed to the internet because a firewall rule never got applied, or whether that legacy service you decommissioned last month still has an open port sitting there. Those are TCP-level questions, not HTTP-level ones, and they cut in two directions at once: a port that should be open but isn’t is an availability problem; a port that should be closed but isn’t is a security problem. Most ad hoc scripts only check for one of those.
This tutorial builds port_scanner.rb: a Ruby standard-library-only script that takes a list of host:port targets, each with an expected state — open or closed — and reports a violation whenever reality doesn’t match the expectation, in either direction.
Prerequisites
- Ruby 3.0 or newer
- No gems required —
socket,yaml,optparse,json, andtimeoutfrom the standard library - Network access from wherever you run it to the targets you’re auditing (run it from the same vantage point — a bastion host, a CI runner, the app tier — that the real traffic will come from)
The Config File
Save this as ports.yml next to the script:
- host: db01.internal
port: 5432
expect: open
service: PostgreSQL
- host: db01.internal
port: 23
expect: closed
service: Telnet (should be disabled)
- host: cache01.internal
port: 6379
expect: closed
service: Redis (internal-only, must not be exposed)
The Script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# port_scanner.rb - audits a list of host:port targets for TCP reachability
# against an EXPECTED state (open or closed), not just "is it up." That
# makes it useful for two different jobs at once: confirming a new service
# is actually reachable before you point traffic at it, and confirming a
# port you just closed in the firewall is really closed. Concurrent, with
# a bounded worker pool so a large target list doesn't open hundreds of
# sockets simultaneously.
#
# Usage:
# ruby port_scanner.rb --config ports.yml
# ruby port_scanner.rb --config ports.yml --json
# ruby port_scanner.rb --config ports.yml --concurrency 20 --timeout 2
#
# Requires: Ruby 3.0+. Standard library only (socket, yaml, optparse, json).
require 'socket'
require 'yaml'
require 'optparse'
require 'json'
require 'timeout'
# ---------------------------------------------------------------------------
# Options
# ---------------------------------------------------------------------------
options = {
config: 'ports.yml',
concurrency: 10,
timeout: 3,
json: false
}
OptionParser.new do |opts|
opts.banner = 'Usage: port_scanner.rb --config ports.yml [options]'
opts.on('-c', '--config FILE', 'YAML file listing targets (default: ports.yml)') { |v| options[:config] = v }
opts.on('-n', '--concurrency N', Integer, 'Max simultaneous connection attempts (default: 10)') { |v| options[:concurrency] = v }
opts.on('-t', '--timeout SECONDS', Float, 'Per-target connect timeout (default: 3)') { |v| options[:timeout] = v }
opts.on('--json', 'Emit machine-readable JSON instead of a text report') { options[:json] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit }
end.parse!
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
# ports.yml:
#
# - host: db01.internal
# port: 5432
# expect: open
# service: PostgreSQL
# - host: db01.internal
# port: 23
# expect: closed
# service: Telnet (should be disabled)
#
def load_targets(path)
raise "Config file not found: #{path}" unless File.exist?(path)
data = YAML.safe_load(File.read(path))
raise 'Config must be a YAML list of targets' unless data.is_a?(Array)
data.map do |t|
expect = (t['expect'] || 'open').to_s
raise "invalid 'expect' value #{expect.inspect} for #{t['host']}:#{t['port']} (must be open or closed)" unless %w[open closed].include?(expect)
{ host: t.fetch('host'), port: Integer(t.fetch('port')), expect: expect.to_sym, service: t['service'] || '' }
end
end
# ---------------------------------------------------------------------------
# The scan itself
# ---------------------------------------------------------------------------
ScanResult = Struct.new(:host, :port, :service, :expect, :actual, :ok, :latency_ms, :detail, keyword_init: true)
# Attempts a raw TCP connect (no protocol handshake beyond that) and
# classifies the outcome as :open, :closed, or :unreachable. This is
# deliberately lower-level than an HTTP health check -- it works for any
# TCP service (databases, caches, SSH, or a custom protocol) because it
# never sends or expects application data.
def probe(host, port, timeout)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
socket = Timeout.timeout(timeout) { TCPSocket.new(host, port) }
socket.close
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
[:open, latency_ms, nil]
rescue Errno::ECONNREFUSED
[:closed, nil, 'connection refused']
rescue Timeout::Error
[:unreachable, nil, "no response within #{timeout}s (likely filtered by a firewall)"]
rescue Errno::EHOSTUNREACH, Errno::ENETUNREACH
[:unreachable, nil, 'host or network unreachable']
rescue SocketError => e
[:unreachable, nil, "DNS/socket error: #{e.message}"]
rescue StandardError => e
[:unreachable, nil, "#{e.class}: #{e.message}"]
end
def scan_target(target, timeout)
actual, latency_ms, detail = probe(target[:host], target[:port], timeout)
ok = actual == target[:expect]
ScanResult.new(
host: target[:host], port: target[:port], service: target[:service],
expect: target[:expect], actual: actual, ok: ok, latency_ms: latency_ms, detail: detail
)
end
# Bounded-concurrency scan using the same queue-of-work pattern you'd use
# for a larger fan-out job (SSH to a fleet, HTTP checks, etc.) -- a fixed
# pool of worker threads pulls targets off a shared Queue instead of
# spawning one thread per target.
#
# `scan_fn` defaults to the real `scan_target`/`probe` path but is
# injectable so tests can verify the pool's concurrency bound with a fake,
# artificially-slow probe instead of real sockets -- a real TCP connect on
# loopback completes in microseconds, which makes the *actual* network
# calls useless for timing-based concurrency assertions.
def scan_all(targets, concurrency:, timeout:, scan_fn: method(:scan_target))
queue = Queue.new
targets.each { |t| queue << t }
results = Queue.new
workers = Array.new([concurrency, targets.size].min) do
Thread.new do
loop do
target = begin
queue.pop(true)
rescue ThreadError
break
end
results << scan_fn.call(target, timeout)
end
end
end
workers.each(&:join)
Array.new(results.size) { results.pop }
end
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def print_report(results)
results.sort_by { |r| [r.host, r.port] }.each do |r|
label = "#{r.host}:#{r.port}".ljust(28)
service = r.service.to_s.empty? ? '' : " (#{r.service})"
if r.ok
extra = r.actual == :open ? "open in #{r.latency_ms}ms" : 'closed as expected'
puts "\e[32m[OK]\e[0m #{label} #{extra}#{service}"
else
puts "\e[31m[VIOLATION]\e[0m #{label} expected #{r.expect}, found #{r.actual}#{r.detail ? " (#{r.detail})" : ''}#{service}"
end
end
puts '-' * 70
violations = results.reject(&:ok)
puts "#{results.size - violations.size}/#{results.size} targets match their expected state"
return if violations.empty?
puts 'Violations:'
violations.each { |r| puts " - #{r.host}:#{r.port} expected #{r.expect}, found #{r.actual}" }
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if $PROGRAM_NAME == __FILE__
targets = load_targets(options[:config])
results = scan_all(targets, concurrency: options[:concurrency], timeout: options[:timeout])
if options[:json]
puts JSON.pretty_generate(results.map(&:to_h))
else
print_report(results)
end
exit(results.all?(&:ok) ? 0 : 1)
end
How It Works

A raw TCP probe, not a protocol check
probe does nothing but attempt TCPSocket.new(host, port) under a Timeout.timeout and immediately close the socket if it connects. It never sends or expects any application data, which is the point — it works identically for PostgreSQL, Redis, SSH, or a proprietary TCP service, because none of that matters at the level this check operates on. Three outcomes are distinguished explicitly: open (the connect succeeded), closed (Errno::ECONNREFUSED — nothing is listening, and nothing is blocking either, since a RST came back immediately), and unreachable (a timeout, or EHOSTUNREACH/ENETUNREACH — which usually means a firewall is silently dropping the traffic rather than rejecting it, or there’s a routing problem).
Expected state, not just reachability
scan_target compares the probe’s actual result against the target’s configured expect: value and sets ok accordingly. This is what makes the tool useful for security auditing, not just uptime checking: a target configured expect: closed that comes back open is flagged exactly as loudly as a target configured expect: open that comes back closed. Same report, same exit code, two very different kinds of finding.
Bounded concurrency, and a testable design
scan_all uses the same bounded worker-queue pattern you’d reach for in any fan-out job: a fixed pool of threads pulls targets off a shared Queue instead of spawning one thread per target. One detail worth calling out is the scan_fn: keyword argument, which defaults to the real scan_target but can be swapped out — see below for why that mattered.
Testing This Script — Including a Testing Mistake Worth Knowing About
The open/closed detection logic was tested against real local TCPServer sockets — spin one up, confirm probe reports :open; close it, confirm :closed with connection refused in the detail. That part was straightforward. Verifying the bounded concurrency was not, and the first attempt at that test was itself wrong in an instructive way.
The first version tried to prove concurrency was capped by starting several real TCPServers, each with an accept-side thread that would hold the connection open until the test released it, then checking how many were “in flight” at once. It reported that 6 connections were open simultaneously even with concurrency: 2 — but the script wasn’t broken. The test was: a TCP connect completes as soon as the kernel finishes the three-way handshake, which happens independently of when the application calls accept. probe also closes its end of the socket immediately after a successful connect. So every one of the scanner’s fast, sequential connect-and-close calls could complete in microseconds regardless of how many worker threads existed, while the test’s own accept-side threads — which were all already waiting, independent of the scanner’s concurrency — happily accepted every connection almost immediately after. The measurement had nothing to do with the thing being measured.
The fix was to stop trying to observe concurrency through real socket timing at all. scan_all was given an injectable scan_fn: parameter, and the test substitutes an artificially slow, instrumented stand-in that sleeps and tracks a high-water mark under a mutex — the same dependency-injection technique this series used for AdsiStore/FakeAdsiStore in the Windows account management tutorial, applied here for a completely different reason: not portability, but making concurrency itself observable. That test now reliably confirms the pool never exceeds its configured size, and that it actually reaches full concurrency rather than trivially staying under a loose bound:
$ ruby test_port_scanner.rb
Run options: --seed 24906
# Running:
..........
Finished in 0.160426s, 62.3341 runs/s, 168.3021 assertions/s.
10 runs, 27 assertions, 0 failures, 0 errors, 0 skips
Example Output

$ ruby port_scanner.rb --config ports.yml --timeout 2
[OK] db01.internal:5432 open in 6.2ms (PostgreSQL)
[OK] db01.internal:23 closed as expected (Telnet, should be disabled)
[VIOLATION] app03.internal:8080 expected open, found closed (connection refused) (App server)
[VIOLATION] cache01.internal:6379 expected closed, found open (Redis exposed - firewall rule missing)
[OK] app03.internal:22 open in 11.4ms (SSH)
----------------------------------------------------------------------
3/5 targets match their expected state
Violations:
- app03.internal:8080 expected open, found closed
- cache01.internal:6379 expected closed, found open
Troubleshooting
invalid 'expect' valueerror on startup — theexpect:field only acceptsopenorclosed, exactly. Config validation happens before any scanning starts, so a typo fails fast instead of silently mis-scanning.- A port you’re sure is open reports
closed— some security tooling actively resets unexpected connections rather than dropping them, which is indistinguishable from a genuinely closed port at the TCP layer. Cross-check from a different vantage point if this is unexpected. - A port reports
unreachableinstead ofclosed— this is a meaningful distinction, not noise: a stateful firewall that silently drops packets (rather than sending a TCP reset) looks exactly like a timeout. Both mean “not reachable,” but onlyECONNREFUSEDspecifically confirms nothing is listening and nothing is actively blocking either. - DNS-related failures — surface as
SocketErrorin the detail field; check the hostname and that your resolver can reach it from wherever the script is running. - Large target lists run out of ephemeral ports or file descriptors under high concurrency — lower
--concurrency, or raise your shell’sulimit -n.
Extending This Script
- Support a port-range or list shorthand in the YAML (e.g.
ports: [22, 80, 443]) that expands into multiple target entries automatically, instead of requiring one entry per port. - Add a UDP reachability mode — meaningfully harder, since UDP has no handshake to observe, but worth it for DNS or NTP auditing.
- Persist scan results over time (a simple CSV or SQLite append) to trend when a port’s state last changed, not just what it is right now.
- Run this script as the
--health-checkcommand in this series’ zero-downtime deploy tutorial, to gate a release on the new version’s dependencies actually being reachable before traffic is cut over. - Fire the same JSON-payload webhook pattern used elsewhere in this series when violations are found.


