Skip to content

Commit 99a9be8

Browse files
authored
Add DNS cache flush support (--flush flag) (#48)
* Add `FlushDNSCache` method with platform-specific error handling Introduces the `FlushDNSCache` function to handle DNS cache flushing with platform context. Includes a custom `FlushError` type for detailed errors and a helper for formatting command arguments. * Add macOS-specific DNS cache flush implementation Introduces `flushDNSCachePlatform` for darwin builds, using `dscacheutil -flushcache` and `killall -HUP mDNSResponder`. Handles potential error scenarios with platform-specific context. * Add `saveHosts` helper for DryRun/Save/Flush pattern with error handling Introduces the `saveHosts` function to standardize the DryRun/Save/Flush process across mutation commands. Handles DNS cache flush warnings and provides improved error messaging for failures. * Add Linux-specific DNS cache flush implementation Introduces `flushDNSCachePlatform` for Linux builds, attempting `resolvectl` first and falling back to `systemd-resolve`. Handles errors with platform-specific context using the `FlushError` type. * Add DNS cache flush stub for unsupported platforms Introduces a no-op `flushDNSCachePlatform` function that returns an error on unsupported platforms. Uses `FlushError` for consistent error handling with platform-specific context. * Add comprehensive tests for `FlushError`, `joinArgs`, and DNS cache flush behaviors Introduce unit tests to validate `FlushError` error handling, including `Error`, `Unwrap`, and `errors.As`. Add tests for `joinArgs` utility. Mock DNS cache flush scenarios to verify behavior with AutoFlush-enabled save operations. * Add Windows-specific DNS cache flush implementation Introduce `flushDNSCachePlatform` for Windows builds using `ipconfig /flushdns`. Handles errors with `FlushError` for consistent platform-specific error reporting. * Refactor `add` command to use `saveHosts` helper for DryRun/Save logic standardization. * Refactor `remove_cidr` command to use `saveHosts` helper for standardized DryRun/Save logic. * Refactor `remove_comment` command to use `saveHosts` helper for standardized DryRun/Save logic. * Refactor `remove_host` command to use `saveHosts` helper for standardized DryRun/Save logic. * Refactor `remove_ip` command to use `saveHosts` helper for standardized DryRun/Save logic. * Add `--flush` flag to `root` command for DNS cache flushing Introduces `--flush` flag and `TXEH_AUTO_FLUSH` environment variable to enable automatic DNS cache flush after modifying the hosts file. * Add integration tests for `--flush` flag and `TXEH_AUTO_FLUSH` behavior Introduce tests to validate the `--flush` flag, the `TXEH_AUTO_FLUSH` environment variable, and DryRun scenarios. Ensure proper logic for DNS cache flushing in various configurations. * Add `AutoFlush` option to trigger DNS cache flush on save operations Introduce `AutoFlush` field to the Hosts struct, enabling automatic DNS cache flush after successful `Save` or `SaveAs` calls. Handle flush failures with `FlushError`. * Add comprehensive integration tests for `--flush`, `DryRun`, and DNS cache flush scenarios Expand test coverage to validate flush behavior under multiple configurations, including success, failures, and quiet mode. Ensure logic correctness for `--flush` flag, `TXEH_AUTO_FLUSH` environment variable, and DryRun settings. * Expand `flush_test.go` with extensive test coverage for DNS cache flush scenarios Add tests to validate `FlushError` formatting, `joinArgs`, and `FlushDNSCache` behavior, including success, failures, and command sequencing. Introduce `mockExec` helper for mocking `execCommandFunc` in tests. * Add functions to get and set `execCommandFunc` for testability Introduce `ExecCommandFunc` and `SetExecCommandFunc` to retrieve and replace the `execCommandFunc` variable. These functions enhance testability by allowing command execution to be mocked. * Document `--flush` flag and DNS cache flushing behavior in CLI guide * Document platform-specific details for `FlushDNSCache` function * Document macOS-specific DNS cache flush commands in `flushDNSCachePlatform` function * Clarify error message and add documentation for Linux-specific `flushDNSCachePlatform` behavior and resolver limitations * Document Windows-specific `flushDNSCachePlatform` command and behavior in function comments * Expand DNS cache flush section in troubleshooting guide with `--flush` flag and `TXEH_AUTO_FLUSH` details * Add platform-specific DNS cache flush tests for macOS and Linux Expand test coverage with new test cases for macOS (`flush_darwin_test.go`) and Linux (`flush_linux_test.go`) to validate DNS cache flush behavior. Include success, failure, and command sequencing scenarios. * Remove macOS-specific DNS cache flush tests and update Go version to 1.24.7
1 parent e181697 commit 99a9be8

20 files changed

+1155
-60
lines changed

docs/cli.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ sudo txeh add 127.0.0.1 myapp.local --dryrun
7070
| `--quiet` | `-q` | Suppress output |
7171
| `--read` | `-r` | Override path to read hosts file |
7272
| `--write` | `-w` | Override path to write hosts file |
73+
| `--flush` | `-f` | Flush DNS cache after modifying the hosts file |
7374
| `--max-hosts-per-line` | `-m` | Max hostnames per line (0=auto, -1=unlimited) |
7475

7576
## Commands
@@ -206,3 +207,34 @@ Print the txeh version.
206207
```bash
207208
txeh version
208209
```
210+
211+
## DNS Cache Flushing
212+
213+
The `--flush` (`-f`) flag triggers a DNS cache flush after writing the hosts file. This makes new entries resolve immediately without manual intervention.
214+
215+
```bash
216+
sudo txeh add 127.0.0.1 myapp.local --flush
217+
```
218+
219+
You can also set the `TXEH_AUTO_FLUSH` environment variable to always flush:
220+
221+
```bash
222+
export TXEH_AUTO_FLUSH=1
223+
sudo txeh add 127.0.0.1 myapp.local # flush happens automatically
224+
```
225+
226+
If the flush fails (e.g., the resolver binary is missing), the hosts file is still saved. txeh prints a warning to stderr and exits normally.
227+
228+
### Platform Commands
229+
230+
txeh runs these OS-provided commands. Nothing extra needs to be installed.
231+
232+
| Platform | Command | Notes |
233+
|----------|---------|-------|
234+
| macOS | `dscacheutil -flushcache` + `killall -HUP mDNSResponder` | Ships with macOS. Works on 10.15 Catalina through current. |
235+
| Linux | `resolvectl flush-caches` or `systemd-resolve --flush-caches` | Requires systemd-resolved. See below. |
236+
| Windows | `ipconfig /flushdns` | Ships with all supported Windows versions. |
237+
238+
### Linux Without systemd-resolved
239+
240+
If your Linux system doesn't run systemd-resolved (common with dnsmasq, unbound, or no local caching), txeh prints a warning and exits normally. In this case, flushing isn't needed because DNS lookups go directly to `/etc/hosts` or to a remote resolver that doesn't cache your local entries.

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Add this line to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to make it p
3232

3333
After adding entries, if the hostname doesn't resolve:
3434

35-
1. **Flush DNS cache:**
35+
1. **Flush DNS cache.** You can do this automatically with `txeh --flush` (or set `TXEH_AUTO_FLUSH=1`). See the [CLI reference](cli.md#dns-cache-flushing) for details. To flush manually:
3636

3737
=== "macOS"
3838

flush.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package txeh
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"strings"
7+
)
8+
9+
// execCommandFunc is a variable that wraps exec.Command for testability.
10+
var execCommandFunc = exec.Command
11+
12+
// ExecCommandFunc returns the current exec command function.
13+
// This is intended for test code that needs to save and restore the original.
14+
func ExecCommandFunc() func(string, ...string) *exec.Cmd {
15+
return execCommandFunc
16+
}
17+
18+
// SetExecCommandFunc replaces the exec command function.
19+
// This is intended for test code that needs to mock command execution.
20+
func SetExecCommandFunc(fn func(string, ...string) *exec.Cmd) {
21+
execCommandFunc = fn
22+
}
23+
24+
// FlushError represents a DNS cache flush failure with platform context.
25+
type FlushError struct {
26+
Platform string
27+
Command string
28+
Err error
29+
}
30+
31+
// Error returns a human-readable error message.
32+
func (e *FlushError) Error() string {
33+
return fmt.Sprintf("flush DNS cache on %s (%s): %s", e.Platform, e.Command, e.Err)
34+
}
35+
36+
// Unwrap returns the underlying error.
37+
func (e *FlushError) Unwrap() error {
38+
return e.Err
39+
}
40+
41+
// FlushDNSCache flushes the operating system's DNS cache.
42+
//
43+
// Platform commands (all are OS-provided utilities, not installable dependencies):
44+
// - macOS: dscacheutil -flushcache + killall -HUP mDNSResponder (ships with macOS)
45+
// - Linux: resolvectl flush-caches (systemd 239+) or systemd-resolve --flush-caches (older systemd).
46+
// Returns ErrNoResolver if neither binary is found, which typically means the system
47+
// does not cache DNS locally and hosts file changes take effect immediately.
48+
// - Windows: ipconfig /flushdns (ships with Windows)
49+
//
50+
// Returns a *FlushError on failure, or nil on unsupported platforms
51+
// where no resolver is detected.
52+
func FlushDNSCache() error {
53+
return flushDNSCachePlatform()
54+
}
55+
56+
// joinArgs joins command arguments for error display.
57+
func joinArgs(name string, args []string) string {
58+
parts := make([]string, 0, len(args)+1)
59+
parts = append(parts, name)
60+
parts = append(parts, args...)
61+
return strings.Join(parts, " ")
62+
}

flush_darwin.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//go:build darwin
2+
3+
package txeh
4+
5+
import "runtime"
6+
7+
// flushDNSCachePlatform flushes the DNS cache on macOS.
8+
//
9+
// Commands:
10+
// - dscacheutil -flushcache: flushes the Directory Service cache.
11+
// Source: man dscacheutil (local man page, -flushcache flag).
12+
// Present since macOS 10.5 Leopard (2007).
13+
// - killall -HUP mDNSResponder: restarts the mDNS responder to clear its cache.
14+
// Standard Apple convention, widely documented.
15+
//
16+
// Both commands together are the standard recommendation from macOS 10.15 Catalina (2019)
17+
// through current releases. On 10.12-10.14, only the killall was strictly needed, but
18+
// running dscacheutil there is harmless (flushes the Directory Service cache, a no-op
19+
// if DNS isn't cached there).
20+
//
21+
// Both binaries ship with macOS. No external dependencies.
22+
//
23+
// The killall step is non-fatal since mDNSResponder may not be running.
24+
func flushDNSCachePlatform() error {
25+
cmd := execCommandFunc("dscacheutil", "-flushcache") // #nosec G204 -- hardcoded command, not user input
26+
if err := cmd.Run(); err != nil {
27+
return &FlushError{
28+
Platform: runtime.GOOS,
29+
Command: joinArgs("dscacheutil", []string{"-flushcache"}),
30+
Err: err,
31+
}
32+
}
33+
34+
// Attempt to restart mDNSResponder. This may fail if the process
35+
// is not running, which is not an error condition.
36+
cmd = execCommandFunc("killall", "-HUP", "mDNSResponder") // #nosec G204 -- hardcoded command, not user input
37+
_ = cmd.Run()
38+
39+
return nil
40+
}

flush_darwin_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//go:build darwin
2+
3+
package txeh
4+
5+
import (
6+
"context"
7+
"errors"
8+
"os/exec"
9+
"strings"
10+
"sync"
11+
"testing"
12+
)
13+
14+
// --- Platform flush command verification (darwin) ---
15+
16+
// Given execCommandFunc is mocked to record calls and succeed
17+
// When FlushDNSCache() is called on darwin
18+
// Then exactly 2 commands are invoked:
19+
//
20+
// 1st: "dscacheutil" with args ["-flushcache"]
21+
// 2nd: "killall" with args ["-HUP", "mDNSResponder"]
22+
//
23+
// And no error is returned.
24+
func TestFlushDNSCache_Darwin_CommandsAndArgs(t *testing.T) {
25+
origFunc := ExecCommandFunc()
26+
defer SetExecCommandFunc(origFunc)
27+
28+
fn, calls, mu := mockExec("true")
29+
SetExecCommandFunc(fn)
30+
31+
err := FlushDNSCache()
32+
if err != nil {
33+
t.Fatalf("FlushDNSCache() returned error: %v", err)
34+
}
35+
36+
mu.Lock()
37+
defer mu.Unlock()
38+
39+
if len(*calls) != 2 {
40+
t.Fatalf("expected 2 exec calls, got %d: %+v", len(*calls), *calls)
41+
}
42+
43+
// First call: dscacheutil -flushcache
44+
first := (*calls)[0]
45+
if first.Name != "dscacheutil" {
46+
t.Errorf("call[0].Name = %q, want %q", first.Name, "dscacheutil")
47+
}
48+
wantArgs := []string{"-flushcache"}
49+
if strings.Join(first.Args, " ") != strings.Join(wantArgs, " ") {
50+
t.Errorf("call[0].Args = %v, want %v", first.Args, wantArgs)
51+
}
52+
53+
// Second call: killall -HUP mDNSResponder
54+
second := (*calls)[1]
55+
if second.Name != "killall" {
56+
t.Errorf("call[1].Name = %q, want %q", second.Name, "killall")
57+
}
58+
wantArgs2 := []string{"-HUP", "mDNSResponder"}
59+
if strings.Join(second.Args, " ") != strings.Join(wantArgs2, " ") {
60+
t.Errorf("call[1].Args = %v, want %v", second.Args, wantArgs2)
61+
}
62+
}
63+
64+
// Given execCommandFunc is mocked to fail (exit 1)
65+
// When FlushDNSCache() is called on darwin
66+
// Then a *FlushError is returned with Platform="darwin"
67+
// And Command="dscacheutil -flushcache"
68+
// And only 1 command was attempted (killall is not reached).
69+
func TestFlushDNSCache_Darwin_FirstCommandFails(t *testing.T) {
70+
origFunc := ExecCommandFunc()
71+
defer SetExecCommandFunc(origFunc)
72+
73+
fn, calls, mu := mockExec("false")
74+
SetExecCommandFunc(fn)
75+
76+
err := FlushDNSCache()
77+
if err == nil {
78+
t.Fatal("expected error when dscacheutil fails")
79+
}
80+
81+
var fe *FlushError
82+
if !errors.As(err, &fe) {
83+
t.Fatalf("expected *FlushError, got %T: %v", err, err)
84+
}
85+
86+
if fe.Platform != "darwin" {
87+
t.Errorf("Platform = %q, want %q", fe.Platform, "darwin")
88+
}
89+
if fe.Command != "dscacheutil -flushcache" {
90+
t.Errorf("Command = %q, want %q", fe.Command, "dscacheutil -flushcache")
91+
}
92+
93+
mu.Lock()
94+
defer mu.Unlock()
95+
96+
// Only dscacheutil should have been attempted. killall should not run.
97+
if len(*calls) != 1 {
98+
t.Errorf("expected 1 exec call (dscacheutil only), got %d: %+v", len(*calls), *calls)
99+
}
100+
}
101+
102+
// Given execCommandFunc where dscacheutil succeeds but killall fails
103+
// When FlushDNSCache() is called
104+
// Then no error is returned (killall failure is non-fatal).
105+
func TestFlushDNSCache_Darwin_KillallFails_NonFatal(t *testing.T) {
106+
origFunc := ExecCommandFunc()
107+
defer SetExecCommandFunc(origFunc)
108+
109+
callCount := 0
110+
var mu sync.Mutex
111+
SetExecCommandFunc(func(name string, args ...string) *exec.Cmd {
112+
mu.Lock()
113+
callCount++
114+
n := callCount
115+
mu.Unlock()
116+
117+
if n == 1 {
118+
// dscacheutil succeeds
119+
return exec.CommandContext(context.Background(), "true") //nolint:gosec // test
120+
}
121+
// killall fails
122+
return exec.CommandContext(context.Background(), "false") //nolint:gosec // test
123+
})
124+
125+
err := FlushDNSCache()
126+
if err != nil {
127+
t.Errorf("killall failure should be non-fatal, got error: %v", err)
128+
}
129+
130+
mu.Lock()
131+
defer mu.Unlock()
132+
if callCount != 2 {
133+
t.Errorf("expected 2 exec calls, got %d", callCount)
134+
}
135+
}

flush_linux.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//go:build linux
2+
3+
package txeh
4+
5+
import (
6+
"errors"
7+
"os/exec"
8+
"runtime"
9+
)
10+
11+
// ErrNoResolver is returned when no supported DNS resolver is found on the system.
12+
// If your system does not cache DNS locally (e.g., no systemd-resolved, dnsmasq, or
13+
// unbound), hosts file changes take effect immediately without flushing.
14+
var ErrNoResolver = errors.New(
15+
"systemd-resolved not detected (tried resolvectl, systemd-resolve). " +
16+
"If your system does not cache DNS locally, hosts file changes take effect immediately without flushing",
17+
)
18+
19+
// flushDNSCachePlatform flushes the DNS cache on Linux.
20+
//
21+
// Commands:
22+
// - resolvectl flush-caches: systemd-resolved 239+ (2018).
23+
// Source: https://man7.org/linux/man-pages/man1/resolvectl.1.html
24+
// - systemd-resolve --flush-caches: pre-239 name for the same operation.
25+
//
26+
// Tries resolvectl first, then falls back to systemd-resolve.
27+
// Returns ErrNoResolver (wrapped in FlushError) if neither is available.
28+
//
29+
// Other resolvers (dnsmasq, unbound, nscd) are intentionally not supported here
30+
// because flushing them reliably depends on per-site configuration (socket paths,
31+
// service names, etc.) that we can't safely assume.
32+
func flushDNSCachePlatform() error {
33+
// Try resolvectl first (systemd 239+).
34+
if _, err := exec.LookPath("resolvectl"); err == nil {
35+
cmd := execCommandFunc("resolvectl", "flush-caches") // #nosec G204 -- hardcoded command, not user input
36+
if err := cmd.Run(); err != nil {
37+
return &FlushError{
38+
Platform: runtime.GOOS,
39+
Command: joinArgs("resolvectl", []string{"flush-caches"}),
40+
Err: err,
41+
}
42+
}
43+
return nil
44+
}
45+
46+
// Fallback to systemd-resolve (older systemd).
47+
if _, err := exec.LookPath("systemd-resolve"); err == nil {
48+
cmd := execCommandFunc("systemd-resolve", "--flush-caches") // #nosec G204 -- hardcoded command, not user input
49+
if err := cmd.Run(); err != nil {
50+
return &FlushError{
51+
Platform: runtime.GOOS,
52+
Command: joinArgs("systemd-resolve", []string{"--flush-caches"}),
53+
Err: err,
54+
}
55+
}
56+
return nil
57+
}
58+
59+
return &FlushError{
60+
Platform: runtime.GOOS,
61+
Command: "",
62+
Err: ErrNoResolver,
63+
}
64+
}

0 commit comments

Comments
 (0)