Hand-edited hosts files drift, duplicate, and occasionally get truncated at 3 a.m. This script adds, removes, and declaratively syncs entries with a marker comment, a diff preview, a timestamped backup and an atomic write — on /etc/hosts and drivers\etc\hosts alike.
Full script + README on GitHub: ruby-devops-toolkit/hosts-file-manager
Step through the build below:
The hosts file is the last config file most teams still edit by hand. Split-horizon DNS, lab environments, blue/green cutovers, blocking telemetry endpoints, pointing db01 at a replica during a migration: all of it ends up in /etc/hosts or C:\Windows\System32\drivers\etc\hosts, usually via echo >> or Notepad.
Six months later the file has three lines for the same hostname, a Windows box has LF endings, and someone’s sed -i wiped the localhost entry. We want an idempotent tool: run it twice and nothing changes; describe the desired state in YAML and have the tool add, update and prune only the lines it owns.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# hosts_file_manager.rb - idempotent hosts-file management for Linux and Windows
#
# Adds, removes, lists and verifies entries in /etc/hosts (Linux/macOS) or
# C:\Windows\System32\drivers\etc\hosts (Windows) without clobbering anything
# you did not ask it to touch. Every managed line is tagged with a marker
# comment so the script can find its own entries later, and every write goes
# through: backup -> atomic temp-file write -> rename.
#
# Usage:
# ruby hosts_file_manager.rb list
# ruby hosts_file_manager.rb add 10.0.5.20 db01.internal db01
# ruby hosts_file_manager.rb remove db01.internal
# ruby hosts_file_manager.rb apply hosts.yml # declarative bulk sync
# ruby hosts_file_manager.rb verify hosts.yml # exit 1 if drift detected
#
# Global flags:
# --file PATH operate on a different hosts file (great for testing)
# --dry-run show the diff, change nothing
# --tag NAME marker used to identify managed lines (default: "hostsmgr")
#
# Ruby >= 2.7, stdlib only (yaml, fileutils, tmpdir, optparse).
require 'fileutils'
require 'optparse'
require 'tempfile'
require 'yaml'
module HostsManager
VERSION = '1.0.0'
def self.default_path
if Gem.win_platform?
File.join(ENV.fetch('SystemRoot', 'C:/Windows'), 'System32', 'drivers', 'etc', 'hosts')
else
'/etc/hosts'
end
end
# A single "ip name1 name2 ... # managed-by:tag" line.
Entry = Struct.new(:ip, :names, :managed, :raw) do
def key
names.first.downcase
end
def to_line(tag)
"#{ip.ljust(15)} #{names.join(' ')} # managed-by:#{tag}"
end
end
class HostsFile
IPV4 = /\A\d{1,3}(?:\.\d{1,3}){3}\z/.freeze
IPV6 = /\A[0-9a-f:]+\z/i.freeze
HOSTNAME = /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\z/i.freeze
attr_reader :path, :lines, :tag
def initialize(path, tag: 'hostsmgr')
@path = path
@tag = tag
@lines = File.exist?(path) ? File.read(path).split(/\r?\n/, -1) : []
@lines.pop if @lines.last == '' # drop trailing empty from final newline
end
# Parse every non-comment line into an Entry (managed or not).
def entries
@lines.filter_map do |raw|
body, comment = raw.split('#', 2)
parts = body.to_s.split
next if parts.size < 2
Entry.new(parts[0], parts[1..], comment.to_s.include?("managed-by:#{tag}"), raw)
end
end
def managed
entries.select(&:managed)
end
def add(ip, names)
validate!(ip, names)
entry = Entry.new(ip, names, true, nil)
existing = @lines.index { |l| managed_line_for?(l, entry.key) }
if existing
return false if @lines[existing] == entry.to_line(tag) # already identical -> no-op
@lines[existing] = entry.to_line(tag)
else
@lines << entry.to_line(tag)
end
true
end
def remove(name)
before = @lines.size
@lines.reject! { |l| managed_line_for?(l, name.downcase) }
@lines.size != before
end
# Declarative sync: desired = [{ip:, names:[]}], removes managed lines not in desired.
def sync(desired)
changed = false
desired_keys = desired.map { |d| d[:names].first.downcase }
managed.each do |e|
changed |= remove(e.key) unless desired_keys.include?(e.key)
end
desired.each { |d| changed |= add(d[:ip], d[:names]) }
changed
end
def drift(desired)
current = managed.map { |e| [e.key, [e.ip, e.names]] }.to_h
wanted = desired.map { |d| [d[:names].first.downcase, [d[:ip], d[:names]]] }.to_h
{
missing: wanted.keys - current.keys,
extra: current.keys - wanted.keys,
changed: (wanted.keys & current.keys).reject { |k| wanted[k] == current[k] }
}
end
def content
@lines.join(line_ending) + line_ending
end
# backup + atomic write. Returns the backup path.
def save!(backup_dir: nil)
backup = nil
if File.exist?(path)
dir = backup_dir || File.dirname(path)
backup = File.join(dir, "#{File.basename(path)}.#{Time.now.strftime('%Y%m%d-%H%M%S%L')}.bak")
FileUtils.cp(path, backup, preserve: true)
end
tmp = Tempfile.create(['hosts', '.tmp'], File.dirname(path))
begin
tmp.write(content)
tmp.flush
tmp.fsync
ensure
tmp.close
end
File.chmod(0o644, tmp.path) unless Gem.win_platform?
File.rename(tmp.path, path) # atomic on POSIX; Windows replaces in one step
backup
end
private
def line_ending
Gem.win_platform? ? "\r\n" : "\n"
end
def managed_line_for?(line, key)
return false unless line.include?("managed-by:#{tag}")
parts = line.split('#', 2).first.split
parts.size >= 2 && parts[1].downcase == key
end
def validate!(ip, names)
v4_ok = ip.match?(IPV4) && ip.split('.').all? { |o| o.to_i <= 255 }
raise ArgumentError, "invalid IP address: #{ip}" unless v4_ok || ip.match?(IPV6)
raise ArgumentError, 'at least one hostname required' if names.empty?
names.each { |n| raise ArgumentError, "invalid hostname: #{n}" unless n.match?(HOSTNAME) }
end
end
# Minimal unified-style diff so --dry-run shows exactly what will change.
def self.diff(old_text, new_text)
old_l = old_text.split("\n")
new_l = new_text.split("\n")
out = []
(old_l - new_l).each { |l| out << "- #{l}" }
(new_l - old_l).each { |l| out << "+ #{l}" }
out.empty? ? '(no changes)' : out.join("\n")
end
def self.load_desired(yaml_path)
data = YAML.safe_load(File.read(yaml_path)) || {}
Array(data['hosts']).map do |h|
{ ip: h['ip'].to_s, names: Array(h['names']).map(&:to_s) }
end
end
def self.run(argv)
opts = { file: default_path, dry_run: false, tag: 'hostsmgr' }
parser = OptionParser.new do |o|
o.banner = 'Usage: hosts_file_manager.rb [options] <list|add IP NAME...|remove NAME|apply FILE|verify FILE>'
o.on('--file PATH') { |v| opts[:file] = v }
o.on('--dry-run') { opts[:dry_run] = true }
o.on('--tag NAME') { |v| opts[:tag] = v }
o.on('-v', '--version') { puts VERSION; exit }
end
parser.parse!(argv)
cmd = argv.shift or (puts parser; exit 2)
hf = HostsFile.new(opts[:file], tag: opts[:tag])
before = hf.content
changed = false
case cmd
when 'list'
puts format('%-16s %-40s %s', 'IP', 'NAMES', 'MANAGED')
hf.entries.each { |e| puts format('%-16s %-40s %s', e.ip, e.names.join(' '), e.managed ? 'yes' : '-') }
exit 0
when 'add'
ip, *names = argv
changed = hf.add(ip, names)
when 'remove'
changed = hf.remove(argv.fetch(0))
when 'apply'
changed = hf.sync(load_desired(argv.fetch(0)))
when 'verify'
d = hf.drift(load_desired(argv.fetch(0)))
if d.values.all?(&:empty?)
puts "OK - #{hf.managed.size} managed entries match #{argv[0]}"
exit 0
end
puts "DRIFT - missing: #{d[:missing]} extra: #{d[:extra]} changed: #{d[:changed]}"
exit 1
else
puts parser
exit 2
end
unless changed
puts 'No changes needed (already in desired state).'
exit 0
end
puts diff(before, hf.content)
if opts[:dry_run]
puts "\n--dry-run: #{opts[:file]} not modified."
else
backup = hf.save!
puts "\nWrote #{opts[:file]} (backup: #{backup || 'none'})"
end
exit 0
rescue ArgumentError, Errno::EACCES, Errno::ENOENT, IndexError => e
warn "error: #{e.message}"
exit 2
end
end
HostsManager.run(ARGV) if $PROGRAM_NAME == __FILE__
Ownership by marker. Every line the script writes ends in # managed-by:hostsmgr. add, remove and sync only ever touch lines carrying that marker, so your hand-written entries and the distro’s defaults are never rewritten. Change the marker with --tag if two tools share the file.
Idempotency. add keys on the first hostname. If a managed line for that name already exists with the same IP and aliases, it returns false and the file is not written. sync composes remove and add so a whole YAML file converges in one pass.
Safe writes. save! copies the original to hosts.<timestamp>.bak, writes a Tempfile in the same directory, fsyncs it, then File.renames it over the original. On POSIX that is atomic; on Windows it is a single replace, so a crash never leaves a half-written hosts file. Line endings follow the platform.
$ captured from the sandbox test run
$ ruby hosts_file_manager.rb --file ./hosts add 10.0.5.20 db01.internal db01 --dry-run + 10.0.5.20 db01.internal db01 # managed-by:hostsmgr --dry-run: ./hosts not modified. exit=0 $ ruby hosts_file_manager.rb --file ./hosts add 10.0.5.20 db01.internal db01 + 10.0.5.20 db01.internal db01 # managed-by:hostsmgr Wrote ./hosts (backup: ./hosts.20260909-103820207.bak) exit=0 $ ruby hosts_file_manager.rb --file ./hosts add 10.0.5.20 db01.internal db01 # again - idempotent No changes needed (already in desired state). exit=0 $ ruby hosts_file_manager.rb --file ./hosts verify ./hosts.yml DRIFT - missing: ["db02.internal", "vault.internal"] extra: [] changed: [] exit=1 $ ruby hosts_file_manager.rb --file ./hosts apply ./hosts.yml + 10.0.5.21 db02.internal db02 # managed-by:hostsmgr + 10.0.9.4 vault.internal # managed-by:hostsmgr Wrote ./hosts (backup: ./hosts.20260909-103820435.bak) exit=0 $ ruby hosts_file_manager.rb --file ./hosts verify ./hosts.yml OK - 3 managed entries match ./hosts.yml exit=0 $ ruby hosts_file_manager.rb --file ./hosts remove db02.internal - 10.0.5.21 db02.internal db02 # managed-by:hostsmgr Wrote ./hosts (backup: ./hosts.20260909-103820578.bak) exit=0 $ ruby hosts_file_manager.rb --file ./hosts add 999.1.1.1 bad error: invalid IP address: 999.1.1.1 exit=2 $ ruby hosts_file_manager.rb --file ./hosts list IP NAMES MANAGED 127.0.0.1 localhost - ::1 localhost ip6-localhost - 192.168.1.5 nas.local nas - 10.0.5.20 db01.internal db01 yes 10.0.9.4 vault.internal yes
The real-world problem
Configuration management tools can manage the hosts file, but they are heavy for a one-off lab box, and many Windows estates still have no agent at all. What sysadmins actually reach for is a one-file script that behaves like a good citizen: it previews changes, backs up, never clobbers, and can be run from a scheduled task or a deploy pipeline without a human watching.
The tool has five commands. list prints every entry and whether it is managed. add IP NAME [ALIAS...] and remove NAME are imperative. apply hosts.yml is declarative: the YAML is the source of truth and managed lines converge to it. verify hosts.yml is the read-only sibling that exits 1 on drift, which makes it a monitoring check.
Ruby makes the cross-platform part painless: Gem.win_platform? picks the default path and line ending, Tempfile and File.rename behave sensibly on both, and YAML.safe_load is in the stdlib.
Prerequisites
- Linux, macOS or Windows. On Windows use RubyInstaller and run from an elevated prompt (the hosts file is admin-only). On Linux use
sudo. - Ruby 2.7 or newer — tested on Ruby 3.0.2. Stdlib only:
fileutils,optparse,tempfile,yaml. - For
apply/verify, a YAML file with ahosts:list of{ip:, names: []}maps (example in the README). - Use
--fileagainst a scratch copy first; everything in the output tab was produced that way.
The complete script
Everything below is the exact file that ran in the output tab. It is stdlib-only, so there is no Gemfile to install.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# hosts_file_manager.rb - idempotent hosts-file management for Linux and Windows
#
# Adds, removes, lists and verifies entries in /etc/hosts (Linux/macOS) or
# C:\Windows\System32\drivers\etc\hosts (Windows) without clobbering anything
# you did not ask it to touch. Every managed line is tagged with a marker
# comment so the script can find its own entries later, and every write goes
# through: backup -> atomic temp-file write -> rename.
#
# Usage:
# ruby hosts_file_manager.rb list
# ruby hosts_file_manager.rb add 10.0.5.20 db01.internal db01
# ruby hosts_file_manager.rb remove db01.internal
# ruby hosts_file_manager.rb apply hosts.yml # declarative bulk sync
# ruby hosts_file_manager.rb verify hosts.yml # exit 1 if drift detected
#
# Global flags:
# --file PATH operate on a different hosts file (great for testing)
# --dry-run show the diff, change nothing
# --tag NAME marker used to identify managed lines (default: "hostsmgr")
#
# Ruby >= 2.7, stdlib only (yaml, fileutils, tmpdir, optparse).
require 'fileutils'
require 'optparse'
require 'tempfile'
require 'yaml'
module HostsManager
VERSION = '1.0.0'
def self.default_path
if Gem.win_platform?
File.join(ENV.fetch('SystemRoot', 'C:/Windows'), 'System32', 'drivers', 'etc', 'hosts')
else
'/etc/hosts'
end
end
# A single "ip name1 name2 ... # managed-by:tag" line.
Entry = Struct.new(:ip, :names, :managed, :raw) do
def key
names.first.downcase
end
def to_line(tag)
"#{ip.ljust(15)} #{names.join(' ')} # managed-by:#{tag}"
end
end
class HostsFile
IPV4 = /\A\d{1,3}(?:\.\d{1,3}){3}\z/.freeze
IPV6 = /\A[0-9a-f:]+\z/i.freeze
HOSTNAME = /\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\z/i.freeze
attr_reader :path, :lines, :tag
def initialize(path, tag: 'hostsmgr')
@path = path
@tag = tag
@lines = File.exist?(path) ? File.read(path).split(/\r?\n/, -1) : []
@lines.pop if @lines.last == '' # drop trailing empty from final newline
end
# Parse every non-comment line into an Entry (managed or not).
def entries
@lines.filter_map do |raw|
body, comment = raw.split('#', 2)
parts = body.to_s.split
next if parts.size < 2
Entry.new(parts[0], parts[1..], comment.to_s.include?("managed-by:#{tag}"), raw)
end
end
def managed
entries.select(&:managed)
end
def add(ip, names)
validate!(ip, names)
entry = Entry.new(ip, names, true, nil)
existing = @lines.index { |l| managed_line_for?(l, entry.key) }
if existing
return false if @lines[existing] == entry.to_line(tag) # already identical -> no-op
@lines[existing] = entry.to_line(tag)
else
@lines << entry.to_line(tag)
end
true
end
def remove(name)
before = @lines.size
@lines.reject! { |l| managed_line_for?(l, name.downcase) }
@lines.size != before
end
# Declarative sync: desired = [{ip:, names:[]}], removes managed lines not in desired.
def sync(desired)
changed = false
desired_keys = desired.map { |d| d[:names].first.downcase }
managed.each do |e|
changed |= remove(e.key) unless desired_keys.include?(e.key)
end
desired.each { |d| changed |= add(d[:ip], d[:names]) }
changed
end
def drift(desired)
current = managed.map { |e| [e.key, [e.ip, e.names]] }.to_h
wanted = desired.map { |d| [d[:names].first.downcase, [d[:ip], d[:names]]] }.to_h
{
missing: wanted.keys - current.keys,
extra: current.keys - wanted.keys,
changed: (wanted.keys & current.keys).reject { |k| wanted[k] == current[k] }
}
end
def content
@lines.join(line_ending) + line_ending
end
# backup + atomic write. Returns the backup path.
def save!(backup_dir: nil)
backup = nil
if File.exist?(path)
dir = backup_dir || File.dirname(path)
backup = File.join(dir, "#{File.basename(path)}.#{Time.now.strftime('%Y%m%d-%H%M%S%L')}.bak")
FileUtils.cp(path, backup, preserve: true)
end
tmp = Tempfile.create(['hosts', '.tmp'], File.dirname(path))
begin
tmp.write(content)
tmp.flush
tmp.fsync
ensure
tmp.close
end
File.chmod(0o644, tmp.path) unless Gem.win_platform?
File.rename(tmp.path, path) # atomic on POSIX; Windows replaces in one step
backup
end
private
def line_ending
Gem.win_platform? ? "\r\n" : "\n"
end
def managed_line_for?(line, key)
return false unless line.include?("managed-by:#{tag}")
parts = line.split('#', 2).first.split
parts.size >= 2 && parts[1].downcase == key
end
def validate!(ip, names)
v4_ok = ip.match?(IPV4) && ip.split('.').all? { |o| o.to_i <= 255 }
raise ArgumentError, "invalid IP address: #{ip}" unless v4_ok || ip.match?(IPV6)
raise ArgumentError, 'at least one hostname required' if names.empty?
names.each { |n| raise ArgumentError, "invalid hostname: #{n}" unless n.match?(HOSTNAME) }
end
end
# Minimal unified-style diff so --dry-run shows exactly what will change.
def self.diff(old_text, new_text)
old_l = old_text.split("\n")
new_l = new_text.split("\n")
out = []
(old_l - new_l).each { |l| out << "- #{l}" }
(new_l - old_l).each { |l| out << "+ #{l}" }
out.empty? ? '(no changes)' : out.join("\n")
end
def self.load_desired(yaml_path)
data = YAML.safe_load(File.read(yaml_path)) || {}
Array(data['hosts']).map do |h|
{ ip: h['ip'].to_s, names: Array(h['names']).map(&:to_s) }
end
end
def self.run(argv)
opts = { file: default_path, dry_run: false, tag: 'hostsmgr' }
parser = OptionParser.new do |o|
o.banner = 'Usage: hosts_file_manager.rb [options] <list|add IP NAME...|remove NAME|apply FILE|verify FILE>'
o.on('--file PATH') { |v| opts[:file] = v }
o.on('--dry-run') { opts[:dry_run] = true }
o.on('--tag NAME') { |v| opts[:tag] = v }
o.on('-v', '--version') { puts VERSION; exit }
end
parser.parse!(argv)
cmd = argv.shift or (puts parser; exit 2)
hf = HostsFile.new(opts[:file], tag: opts[:tag])
before = hf.content
changed = false
case cmd
when 'list'
puts format('%-16s %-40s %s', 'IP', 'NAMES', 'MANAGED')
hf.entries.each { |e| puts format('%-16s %-40s %s', e.ip, e.names.join(' '), e.managed ? 'yes' : '-') }
exit 0
when 'add'
ip, *names = argv
changed = hf.add(ip, names)
when 'remove'
changed = hf.remove(argv.fetch(0))
when 'apply'
changed = hf.sync(load_desired(argv.fetch(0)))
when 'verify'
d = hf.drift(load_desired(argv.fetch(0)))
if d.values.all?(&:empty?)
puts "OK - #{hf.managed.size} managed entries match #{argv[0]}"
exit 0
end
puts "DRIFT - missing: #{d[:missing]} extra: #{d[:extra]} changed: #{d[:changed]}"
exit 1
else
puts parser
exit 2
end
unless changed
puts 'No changes needed (already in desired state).'
exit 0
end
puts diff(before, hf.content)
if opts[:dry_run]
puts "\n--dry-run: #{opts[:file]} not modified."
else
backup = hf.save!
puts "\nWrote #{opts[:file]} (backup: #{backup || 'none'})"
end
exit 0
rescue ArgumentError, Errno::EACCES, Errno::ENOENT, IndexError => e
warn "error: #{e.message}"
exit 2
end
end
HostsManager.run(ARGV) if $PROGRAM_NAME == __FILE__
How the code works, step by step
1. Load and parse
HostsFile.new reads the file into an array of raw lines, splitting on \r?\n so a CRLF file from Windows parses on Linux. entries lazily turns each non-comment line into an Entry struct with ip, names, and a managed flag derived from the marker comment.
2. Validate before mutating
validate! rejects malformed IPv4 (each octet must be 0-255; the first test run happily accepted 999.1.1.1, which is why that check exists), accepts IPv6 by character class, and checks each hostname against the RFC 1123 label rules. Bad input raises ArgumentError, caught at the top level and turned into exit code 2.
3. Mutate in memory
add finds an existing managed line for the same primary name and replaces it, or appends. remove deletes managed lines only. sync prunes managed names not in the desired list, then adds everything desired. All three return a boolean changed.
4. Diff and decide
HostsManager.diff is a deliberately simple set difference: lines removed, lines added. It is enough for a hosts file and keeps the script dependency-free. With --dry-run the script prints the diff and stops.
5. Backup, write, rename
save! is the only method that touches disk. The backup keeps the original permissions via FileUtils.cp(preserve: true); the temp file is created next to the target so the rename stays on one filesystem; mode 0644 is restored on POSIX because Tempfile creates 0600.
Example output
A full session against a scratch copy of a hosts file: dry-run, add, the idempotent no-op, drift detection, declarative apply, and the validation error:
Troubleshooting
- Errno::EACCES: you are not root / not elevated. On Windows, right-click your terminal and choose Run as administrator; on Linux use
sudo ruby .... - Windows: changes do not take effect: flush the resolver cache with
ipconfig /flushdns. Also check that the file has no.txtextension (Notepad likes to add one). - Windows Defender / EDR flags the write: hosts-file tampering is a common malware behaviour, so some products block it. Add an exclusion for the script path or run it through your management tool.
- Entries you did not add are being removed: they carry the marker comment, probably from an earlier run with the same
--tag. Use a distinct tag per tool or environment. - Mixed line endings: the script normalises to the platform’s ending on save. If another tool insists on LF on Windows, change
line_ending. - Tempfile permission error on
/etc:Tempfile.createneeds write access to the directory itself, not just the file; that is the case for root, but not for a user who was granted write on/etc/hostsvia ACL.
Extending the script
- Pull the desired list from Consul, etcd, or an internal API instead of a YAML file and run
applyon a timer to keep a fleet’s hosts files converged. - Add
--comment TEXTso managed lines record the ticket or change ID that introduced them. - Wire
verifyinto your monitoring: exit 1 becomes a WARNING that someone hand-edited a managed entry. - Keep only the last N backups by globbing
hosts.*.bakand deleting the oldest insave!. - Emit a JSON diff for change-management systems that want structured evidence of what changed and when.