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 requiredsocket, yaml, optparse, json, and timeout from 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

Diagram of port_scanner.rb: targets flow through a bounded worker queue, each does a raw TCP connect classified as open, closed, or unreachable, and the actual result is compared against the expected state to produce OK or VIOLATION

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

Terminal output of port_scanner.rb showing open and closed ports matching expectations, and two violations: an app server unexpectedly closed and a Redis port unexpectedly exposed

$ 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' value error on startup — the expect: field only accepts open or closed, 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 unreachable instead of closed — 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 only ECONNREFUSED specifically confirms nothing is listening and nothing is actively blocking either.
  • DNS-related failures — surface as SocketError in 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’s ulimit -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-check command 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.

Leave a Reply

Your email address will not be published. Required fields are marked *