the shed // linux + windows / config

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.

Get the code

Full script + README on GitHub: ruby-devops-toolkit/hosts-file-manager

Step through the build below:

hosts_file_manager.rb

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
Safe write path: parse, mutate, diff, backup, atomic rename

Every change: parse -> mutate in memory -> diff -> backup -> atomic rename
01 / context

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.

02 / setup

Prerequisites

You need
  • 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 a hosts: list of {ip:, names: []} maps (example in the README).
  • Use --file against a scratch copy first; everything in the output tab was produced that way.
03 / source

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.

hosts_file_manager.rbruby
#!/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__
04 / walkthrough

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.

05 / output

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:

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.
$ … add 10.0.5.20 db01.internal db01 # again – idempotent
No changes needed (already in desired state).
$ … verify ./hosts.yml
DRIFT – missing: ["db02.internal", "vault.internal"] extra: [] changed: [] exit=1
$ … 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)
$ … add 999.1.1.1 bad
error: invalid IP address: 999.1.1.1 exit=2
06 / debug

Troubleshooting

When it misbehaves
  • 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 .txt extension (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.create needs 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/hosts via ACL.
07 / next

Extending the script

Ideas
  • Pull the desired list from Consul, etcd, or an internal API instead of a YAML file and run apply on a timer to keep a fleet’s hosts files converged.
  • Add --comment TEXT so managed lines record the ticket or change ID that introduced them.
  • Wire verify into your monitoring: exit 1 becomes a WARNING that someone hand-edited a managed entry.
  • Keep only the last N backups by globbing hosts.*.bak and deleting the oldest in save!.
  • Emit a JSON diff for change-management systems that want structured evidence of what changed and when.