hosts

A safe command-line manager for /etc/hosts.

One bad write to /etc/hosts and the machine resolves nothing. This one never leaves it half written.

A single Bash script that adds, removes, lists and lints entries. Every change is atomic and backed up first; reading needs no privilege at all; nothing is required beyond coreutils.

curl -fsSL https://n36l3c7.github.io/hosts-cli/apt/hosts-cli.asc \
  | sudo gpg --dearmor -o /usr/share/keyrings/hosts-cli.gpg

echo 'deb [signed-by=/usr/share/keyrings/hosts-cli.gpg] https://n36l3c7.github.io/hosts-cli/apt stable main' \
  | sudo tee /etc/apt/sources.list.d/hosts-cli.list

sudo apt update && sudo apt install hosts-cli
A terminal session: listing the entries of a file, adding one for
               10.0.0.5, listing again to see it, removing its alias and then
               its name, and listing once more to see the line gone.
Recorded by a script in the repository, against a scratch file so that recording needs no privilege. Every command shown is a command that ran.

Overview

/etc/hosts is small enough that everyone edits it by hand, and critical enough that a corrupted write breaks name resolution on the whole machine. hosts makes the common operations immediate without giving up safety.

Status

This is 1.1.0. The major version is a promise rather than a claim that the work is over: breaking any of the following costs a new one.

Wave Commands Status
1 ls, get, search, check, export released in 0.1.0
2 backup, backup ls, restore, diff released in 0.2.0
3 add, rm, on, off released in 0.3.0
4 edit, import, block released in 0.4.0
5 profile save, profile load, profile ls, profile rm released in 0.5.0
6 flush, shell completions released in 0.6.0
7 Debian package and APT repository released in 1.0.0
8 Verification: interruption, concurrency, hostile input, four distributions released in 1.0.1
9 check --fix released in 1.1.0

Requirements

Two soft dependencies, neither of which stops the program working:

There is no other runtime dependency: no jq, no Python, no external libraries.

Installation

Debian and Ubuntu

curl -fsSL https://n36l3c7.github.io/hosts-cli/apt/hosts-cli.asc \
  | sudo gpg --dearmor -o /usr/share/keyrings/hosts-cli.gpg

echo 'deb [signed-by=/usr/share/keyrings/hosts-cli.gpg] https://n36l3c7.github.io/hosts-cli/apt stable main' \
  | sudo tee /etc/apt/sources.list.d/hosts-cli.list

sudo apt update
sudo apt install hosts-cli

The repository is signed, and signed-by limits that key to this repository alone rather than adding it to the system-wide keyring. Nothing here asks you to write trusted=yes, which would turn the authenticity check off.

The package installs the command, the man page and the completions for bash and zsh. Every release is also attached to GitHub Releases, the .deb included.

From source

git clone https://github.com/n36l3c7/hosts-cli.git
cd hosts-cli
sudo make install

This installs the script to /usr/local/bin/hosts, the man page to /usr/local/share/man/man1/hosts.1, and the completions alongside them. Override the destination with PREFIX or DESTDIR:

make install PREFIX="$HOME/.local"

To remove it:

sudo make uninstall

make deb builds the Debian package from the same artifacts, if you would rather install it that way.

Usage

hosts ls                          # every active entry
hosts ls '*.local'                # filtered by a glob on the names
hosts get staging                 # just the address, ready to capture
hosts search "staging box"        # match on address, names or comments
hosts check                       # lint the file
hosts --file ./hosts.new check --strict
sudo hosts check --fix            # repair what has only one repair

sudo hosts add 10.0.0.5 staging staging.local
sudo hosts off staging            # keep the line, stop it resolving
sudo hosts on staging             # and bring it back
sudo hosts rm staging

sudo hosts backup                 # keep a copy before editing by hand
$EDITOR /etc/hosts
hosts diff                        # see what changed since that copy
sudo hosts restore --yes          # and go back

man hosts

Command line reference

Command What it does
ls [pattern] List entries. With a pattern, only entries carrying a name that matches that shell glob; matching ignores case. -a or --all includes disabled entries, --disabled selects only those.
get <hostname> Print the addresses a hostname points at, one per line and nothing else. Only active entries are considered, because a commented out entry takes no part in name resolution. Exits with 5 when the name is not in the file.
search <text> Find entries whose address, names or comment contain the text. Matching is on substrings and ignores case; disabled entries are included.
check Lint the file. --strict makes a warning fail as an error does, and --fix repairs the findings that have only one possible repair.
export Write the file to stdout, byte for byte, or as a structured document with --json.
add <ip> <name>... Point names at an address. Adding what is already there changes nothing. A missing name joins the line that already carries the others on the same address; names that are nowhere go on a new line. Names spread over several lines are refused, because merging them is your decision.
rm <name|ip> Take a name off every line that carries it, dropping a line left with nothing but its address, or remove every line pointing at an address. Exits with 5 when nothing matches.
on <name> Enable every disabled entry carrying the name.
off <name> Comment out every active entry carrying the name, keeping the line so on can bring it back.
edit Open the file in an editor, check what comes back, and install it atomically after taking a backup. The editor works on a copy, so the file is never left half written.
import [file] Merge entries from a file, or from standard input. The source is checked before anything is written, and one bad line makes the whole import fail.
block <domain>... Point domains at a sinkhole address. With no argument the domains are read from standard input, one per line.
profile save <name> Keep the file as it is now under a name. Saving over a name that exists is refused unless --force is given.
profile load <name> Bring that state back. What it replaces is kept first, so the load can be undone with restore.
profile ls List the profiles: name, time, size in bytes.
profile rm <name> Delete one. Asks first, since nothing else holds a copy.
flush Clear the cache of the system resolver, where there is one that can be cleared without disturbing anything.
backup Take a backup. This only reads the file, so it needs no privilege on it; what it needs is somewhere to write the copy. A backup identical to the most recent one is not taken.
backup ls List the backups already taken, most recent first, as four tab separated fields: index, identifier, time, size in bytes.
restore [id] Put a backup back in place, the most recent one by default. id is either the index shown by backup ls or an identifier in full. The content about to be replaced is backed up first, so a restore can itself be undone.
diff [id] Compare the file with a backup. Removed lines are what the backup holds, added lines what the file holds now. Exits with 0 whether or not there are differences.

Every command accepts --help.

Global options

Accepted before and after the command name.

Option Effect
--file <path> Operate on a file other than /etc/hosts. A symbolic link is followed: the write lands where it points rather than replacing the link with a regular file.
--json Machine-readable output.
--dry-run Show what would happen and write nothing. The exit status is the one the real run would have had.
--no-backup Skip the automatic backup. Not advisable.
--force Go ahead with something that would otherwise be refused.
-y, --yes Answer yes to the confirmations. Without a terminal and without this, a confirmation is refused rather than assumed.
-q, --quiet Silence diagnostics. Never the data.
-v, --verbose Print diagnostics on standard error.
-h, --help Show help, general or of a command.
-V, --version Show the version.

Output format

Diagnostics go to standard error, data to standard output. The text output of ls and search is four tab-separated fields, always in this order and always this many, on a terminal exactly as in a pipe:

address <TAB> names <TAB> on|off <TAB> comment

Nothing is aligned, coloured or paginated, and no option changes the number of fields, so cut -f1 is always the address and awk -F'\t' '$3=="off"' always selects the disabled entries.

check rules

An error is something the resolver will get wrong or silently ignore; a warning is something untidy or merely suspect. check has to be usable in continuous integration against files it did not write, so anything with a legitimate reading stays a warning.

Rule Severity --fix Meaning
invalid-line error no not an address followed by a hostname
invalid-ip error no the address does not parse
invalid-hostname error no the name breaks RFC 1123
control-character error no the line contains a control character
duplicate-entry error yes an earlier line is identical
duplicate-name warning yes the name already points at that address
conflicting-ip warning no the name points elsewhere in the same family
nonstandard-hostname warning no the name uses an underscore
missing-loopback warning yes no active 127.0.0.1 localhost
missing-loopback6 warning yes no active ::1 localhost
missing-trailing-newline warning yes the file does not end with a newline

The same name on an IPv4 and on an IPv6 address is normal dual stack and is not reported: a rule blind to the address family would flag every healthy Linux machine. Rules that compare lines ignore disabled entries, which resolve nothing.

Findings are printed on standard output in the format compilers use, so the error parser of an editor can consume them directly:

/etc/hosts:14: error: duplicate-entry: identical to the entry on line 12
/etc/hosts: warning: missing-loopback6: no active entry maps ::1 to localhost

Fixing

--fix repairs the findings whose repair the finding itself forces, and only those — the five marked in the table above. It removes a duplicate entry, takes a redundant name off a line and drops the line if that leaves it with nothing but an address, adds a missing loopback entry above the first entry the file has, and adds a missing final newline.

The rest are left alone on purpose. An address or a name that does not parse cannot be repaired without inventing one. conflicting-ip is the one that tempts: two lines send a name to two addresses, and which of them is wanted is knowledge the file does not contain. control-character is left because stripping bytes out of a line in silence would destroy the evidence of what happened to it.

sudo hosts check --fix              # repair what has one repair
hosts --file ./hosts.new check --fix --dry-run

After a fix the file is read and scanned again rather than the earlier findings being patched up, so every line number and every message refers to the file as it now is, and anything a fix introduced would be reported too. What is left is printed as usual and still decides the exit code: hosts check --fix in continuous integration fails exactly when something a person has to look at is still there.

Fixing is a write like any other in this program: it takes a backup first, honours --dry-run and --no-backup, and asks before touching more than one line unless --yes is given. A file with nothing to fix is not written to at all.

Addresses and names

Addresses are validated as inet_pton does, which is stricter than the historic inet_aton: an octet with a leading zero is rejected rather than read as octal, because the two readings disagree. An IPv6 zone identifier (fe80::1%eth0) is accepted, because a link-local address without one is ambiguous.

Hostnames follow RFC 1123. A trailing dot is rejected: an entry in a hosts file is not a DNS wire name. Comparisons ignore case; the file keeps whatever case it had.

A commented line is read as a disabled entry when its fields look like one: an address, at most four names, and every name a plausible hostname. Prose that happens to start with an address stays a comment.

Exit codes

Exit codes are part of the public interface: a value keeps its meaning for the whole 1.x line. Values greater than or equal to 64 are reserved and never returned.

Code Meaning
0 Success, including an operation that changed nothing.
1 Generic error, such as a target file that is not there.
2 Usage error: unknown command, unknown option, or a missing argument.
3 Insufficient permissions to read the file or write where it lives.
4 Invalid input, or check found errors.
5 The requested hostname or backup is not there.
6 Refused because --force was not given.
7 Write abandoned, the result could not be trusted, nothing was written.
8 The operation was not confirmed.

A missing target file is 1 and not 5 on purpose: get uses 5 for a hostname that is absent, and a script must be able to tell the two apart.

8 covers both a declined prompt and a confirmation that could not be asked for. Without a terminal and without --yes, the answer is refused rather than assumed: the behaviour does depend on the environment, but only ever in the safe direction.

JSON output

--json is accepted by ls, get, search, check and export. The schema carries a version field and is part of the public interface. Bytes outside ASCII are passed through unchanged, so comments stay readable.

Fields are added within a schema version and never change meaning or disappear without the version being raised, so a reader should ignore what it does not recognise rather than reject it. summary.fixed on check arrived that way in 1.1.0.

{
  "version": 1,
  "file": "/etc/hosts",
  "entries": [
    {
      "line": 12,
      "kind": "entry",
      "enabled": true,
      "ip": "10.0.0.5",
      "family": "inet",
      "canonical": "staging",
      "aliases": ["staging.local"],
      "comment": "staging box",
      "raw": "10.0.0.5\tstaging staging.local # staging box"
    }
  ]
}

check --json replaces entries with summary and findings; export --json reports every line, comments and blank lines included, distinguished by kind.

Editing entries

add points names at an address, rm takes them away, and off and on comment a line out and back in without deleting it. Three rules are worth knowing because each of them rejects the obvious answer.

The same name on IPv4 and IPv6 is never a clash. hosts add fd00::5 staging when 10.0.0.5 staging exists is ordinary dual stack, not a conflict. A rule blind to the address family would refuse the most ordinary operation there is on a machine that has both.

off, on and rm act on every line carrying the name. Turning off half of a dual-stack pair would leave the name resolving on the other family: a command doing half its job. Refusing to choose would be worse still, because it would fail on the correct hosts file of every Linux machine.

A name already pointing elsewhere is refused rather than moved. --force moves it, and when the line carries nothing but the names being added the address is changed in place, which is both what updating means and the smallest possible diff. A disabled entry for the name is refused too, since hosts on is usually what was meant.

rm of something that is not there exits 5. A typo should not pass for success.

Minimal diffs

A change never rebuilds the file from what was parsed out of it. It says which lines it replaces, which it deletes and what it appends, and every other line is written back exactly as it was read. There is no path through the code that could reformat a line nobody asked to touch.

Within a line the edit is surgical too. Removing an alias takes the token and exactly one run of adjacent whitespace, so

10.0.0.5    staging   staging.local	# staging box

becomes

10.0.0.5    staging	# staging box

and not 10.0.0.5<TAB>staging # staging box. off followed by on gives back the original line byte for byte.

Blocking domains

block points domains at a sinkhole address. Both 0.0.0.0 and :: are used unless --ipv4-only says otherwise: on a machine with IPv6, a block that only covers IPv4 blocks nothing at all, which is the mistake most blocklist tools make. With no arguments the domains are read from standard input, one per line, since a real blocklist does not fit on a command line.

Blocked domains live together at the end of the file:

# >>> hosts block >>>
# Managed by hosts(1): change it with "hosts block" and "hosts rm".
0.0.0.0	ads.example.com
::	ads.example.com
# <<< hosts block <<<

Marking the section off lets a blocklist of tens of thousands of machine-written lines be treated as one thing. ls leaves it out unless you pass --blocked, so the handful of entries you actually maintain are not buried under it, and check does not compare its lines with each other, since near-identical generated names would be nothing but noise. The per-line rules still apply, and get, search and rm treat what is inside exactly as they treat anything else. Nothing about the section is magic, and the markers go away with the last entry in it.

A domain that already has an entry outside the section is skipped with a warning rather than making the whole command fail. That is deliberately unlike add, which refuses: a command handed many domains should not give up at the first obstacle.

What a blocklist costs

Measured on a file of 50,003 lines, against a typical /etc/hosts of three:

typical file 50,003 lines
ls 29 ms 11.1 s
get 29 ms 9.7 s
check 30 ms 14.0 s

Blocking 25,000 domains in one pass takes 2.8 s, so the bulk operation itself is not the problem. The problem is that afterwards the file is that big, and every command pays for reading it, ls included even though it does not show the section. Bash costs roughly 200 µs per line whatever the parser does.

This is a known limit, not a surprise, and the fix is a change of strategy rather than tuning, so it is not bolted on here. If you keep a large blocklist in /etc/hosts, a dedicated DNS-level blocker will serve you better today.

Writing

Every change goes to a temporary file in the directory of the target, is validated, and is then moved into place with a rename. The kernel performs a rename within one filesystem atomically, so what is guaranteed is this:

After a change the file holds either the whole of the old content or the whole of the new one, never a mixture. That holds through a crash or a power cut.

What is not guaranteed is that the new content survives a power cut in the moment right after the rename: Bash cannot call fsync, so the directory entry cannot be forced to disk. The failure mode of that is finding the old file, which is a lost change, never a damaged one. Saying so plainly is better than implying a durability the tool cannot deliver.

Owner, group, permissions and SELinux context are carried over before the file is installed. The SELinux part is not decoration: a file created in /etc inherits etc_t by type transition while /etc/hosts is net_conf_t, and a rename keeps whatever context the new file has, so without copying it across a confined service on an enforcing system can stop resolving names.

An extended ACL cannot be carried over by a rename, so a file that has one is refused with exit 6 unless --force is given. Losing an ACL changes who can read and write the file, which is too quiet a way to change a permission.

Concurrent writers are serialised with flock when it is there. The lock is taken before the file is read and held until the new content is in place, which is the part that matters: two writers that both read before either writes each compute their change from the same starting point, and the second rename then throws the first change away. Serialising only the writes would prevent nothing, because the rename had already made those indivisible — the update that gets lost is lost during the read. There is a test that runs two writers at once against a file large enough for the race to be certain rather than merely possible.

hosts edit holds that lock for as long as the editor is open. Another writer waits ten seconds and then fails saying so, which is the right outcome: the alternative is discarding one of the two changes.

Without flock writes are not serialised at all, and a concurrent write can lose an update — but it still cannot damage the file, because the rename is atomic either way. A lock built out of mkdir was considered and rejected: it would have to guess whether a leftover lock belongs to a live process, and guessing wrong leaves the tool stuck for good, which is worse than the problem it solves.

Backups

A backup is taken before every change, unless --no-backup says otherwise. Backups live in one directory per target file, each a byte-for-byte copy with a sidecar of metadata beside it:

target=/etc/hosts
time=2026-07-27T12:34:56Z
mode=644
owner=root
group=root
sha256=6b604eae8902aea3b14f7b6d49f208116854a8a8c8ae33618a1ad8062462aca2

The copy is deliberately plain, so that in an emergency the file can be put back with cp, knowing nothing about this program.

The sidecar is what makes restoring safe. It records the absolute path the backup came from, and restore refuses when that is not the file it is about to write: without that check, a backup taken with --file from a scratch file could later end up over /etc/hosts. The recorded SHA-256 is verified before anything is written, and is also what tells an unchanged file from a changed one, without which a run of writes that change nothing would push every backup that matters out of the rotation window.

restore backs up the content it is about to replace, so a restore can itself be undone. Only the content is restored: ownership and permissions stay as they are now, and the sidecar keeps the original ones for reference.

Profiles

A profile is a snapshot of the whole file kept under a name, so a known state can be brought back later. It is not an overlay: loading one replaces what is there.

sudo hosts profile save work
sudo hosts profile load home
hosts profile ls

A profile and a backup are the same object with different lifetimes. Backups are taken automatically before every change and rotate away; profiles are made on purpose and stay until you delete them, which is why they live apart, under /var/lib/hosts/profiles. Everything that makes a backup safe applies unchanged: the copy is byte for byte, the sidecar records the file it came from, and the checksum is verified before anything is written.

profile load keeps what it is about to replace, so it can be undone with hosts restore like any other change. Saving over a name that exists is refused unless --force is given, and deleting one asks first, because nothing else holds a copy.

A profile name becomes part of a filename, so it may hold only letters, digits, dot, dash and underscore, may not begin with a dot or a dash, and is at most 64 characters. That is safety, not tidiness.

Clearing the resolver cache

hosts flush clears the cache of the system resolver. Often there is nothing to clear, and that is not a failure:

The C library has no DNS cache of its own. Without a caching daemon in the path, a change to /etc/hosts is already in effect.

That sentence is probably the most useful thing the command can say to someone wondering why their change "has not worked", so it says it and exits 0.

Only flushes that cannot disturb anything are performed: systemd-resolved and nscd. dnsmasq, unbound and BIND are reported with the command that would reload them, and left alone — restarting a resolver can drop the network of the machine, which is a worse outcome than a stale cache entry and not what anybody expects from a command called flush. --force does not change that. Caches kept by browsers are theirs, not the system's, and are not touched.

$ hosts flush
systemd-resolved	flushed
dnsmasq	needs-attention	not reloaded here, since that can drop the network: systemctl reload dnsmasq

Shell completion

Completions for bash and zsh are installed by make install.

Profile names and backup identifiers are always offered: producing them means listing a directory. Hostnames are different, because producing them means reading and parsing the file, and on a file holding a large blocklist that takes seconds and would leave the shell looking hung. So they are offered only while the file is under HOSTS_COMPLETION_MAX_LINES, counted with wc, which reads but does not parse.

Configuration

Variable Default Meaning
HOSTS_BACKUP_DIR /var/backups/hosts where backups are kept
HOSTS_KEEP_BACKUPS 20 how many to keep per file
HOSTS_PROFILE_DIR /var/lib/hosts/profiles where profiles are kept
HOSTS_COMPLETION_MAX_LINES 5000 largest file for which completion offers hostnames

The most recent backup is never removed, whatever HOSTS_KEEP_BACKUPS says. If the backup directory cannot be written, the command fails with exit 3 and names the variable to set, rather than quietly putting backups somewhere you would not think to look for them.

Development

The shipped program is a single self-contained script assembled from the modules in src/, so development stays modular while distribution stays a matter of copying one file. Never edit build/hosts directly.

make build      # assemble build/hosts and build/hosts.1
make lint       # shellcheck on the script, mandoc on the man page
make test       # bats suite, run against the built script
make clean

The toolchain is shellcheck, mandoc and bats:

sudo apt-get install shellcheck mandoc bats

The test suite always runs against the built script and against fixture files passed with --file. It never requires root and never touches the real /etc/hosts.

Performance

File ls check
a typical /etc/hosts, 10 lines 21 ms 23 ms
a 50,000-line blocklist 7.5 s 10 s

For the size /etc/hosts actually is, the tool is instant. A blocklist of tens of thousands of entries is not: Bash costs roughly 150 µs per line whatever the parser does, and no tuning inside the loop changes that order of magnitude. Bulk handling of blocklists arrives with block and import, which will use a single awk pass where it wins.