12 Commits
32 changed files with 377 additions and 129 deletions
+18 -18
View File
@@ -1,8 +1,8 @@
# dns-helper Development Guidelines
# ekdns Development Guidelines
## Project Overview
dns-helper — a Go CLI tool that resolves hostnames against a specified DNS server and writes the resulting IP-to-hostname mappings into the local hosts file. It manages a clearly delimited block within the hosts file so it can cleanly add and remove only its own entries. Built as a single static binary with no runtime dependencies.
ekdns — a Go CLI tool that resolves hostnames against a specified DNS server and writes the resulting IP-to-hostname mappings into the local hosts file. It manages a clearly delimited block within the hosts file so it can cleanly add and remove only its own entries. Built as a single static binary with no runtime dependencies.
## Go Toolchain — CRITICAL
@@ -48,32 +48,32 @@ go test ./... # May use incompatible Go version
.\build.ps1 -Release -Package
```
Output: `build\dns-helper.exe` (default), `build\dns-helper-<os>-<arch>[.exe]` (cross-compile).
Release packages: `releases\dns-helper-<version>-<os>-<arch>.zip` / `.tar.gz`
Output: `build\ekdns.exe` (default), `build\ekdns-<os>-<arch>[.exe]` (cross-compile).
Release packages: `releases\ekdns-<version>-<os>-<arch>.zip` / `.tar.gz`
## Running dns-helper
## Running ekdns
dns-helper has two commands: `add` and `delete` (with short aliases `a` and `d`/`del`).
ekdns has two commands: `add` and `delete` (with short aliases `a` and `d`/`del`).
```powershell
# Add one or more hostnames (resolves via the given DNS server, writes to hosts file)
.\build\dns-helper.exe add -host hostname.example.com -server dns.example.com
.\build\dns-helper.exe a -host hostname.example.com -server dns.example.com
.\build\ekdns.exe add -host hostname.example.com -server dns.example.com
.\build\ekdns.exe a -host hostname.example.com -server dns.example.com
# Add multiple hostnames at once (comma-separated)
.\build\dns-helper.exe add -host "host1.example.com,host2.example.com" -server dns.example.com
.\build\ekdns.exe add -host "host1.example.com,host2.example.com" -server dns.example.com
# Delete a specific hostname from the managed block
.\build\dns-helper.exe delete -host hostname.example.com
.\build\dns-helper.exe del -host hostname.example.com
.\build\dns-helper.exe d -host hostname.example.com
.\build\ekdns.exe delete -host hostname.example.com
.\build\ekdns.exe del -host hostname.example.com
.\build\ekdns.exe d -host hostname.example.com
# Delete multiple hostnames (comma-separated)
.\build\dns-helper.exe delete -host "host1.example.com,host2.example.com"
.\build\ekdns.exe delete -host "host1.example.com,host2.example.com"
# Delete ALL entries added by dns-helper
.\build\dns-helper.exe delete all
.\build\dns-helper.exe delete a
# Delete ALL entries added by ekdns
.\build\ekdns.exe delete all
.\build\ekdns.exe delete a
```
### Flag Reference
@@ -83,7 +83,7 @@ dns-helper has two commands: `add` and `delete` (with short aliases `a` and `d`/
| `add` | `-host` | Yes | Comma-separated list of hostnames to resolve and add |
| `add` | `-server` | Yes | DNS resolver to query (e.g. `dns.example.com`) |
| `delete` | `-host` | Yes (unless `all`) | Comma-separated list of hostnames to remove |
| `delete all` | — | — | Removes every entry dns-helper has written |
| `delete all` | — | — | Removes every entry ekdns has written |
### Exit Codes
@@ -94,7 +94,7 @@ dns-helper has two commands: `add` and `delete` (with short aliases `a` and `d`/
### Hosts File Management
dns-helper wraps its entries between two marker lines:
ekdns wraps its entries between two marker lines:
```
# DNSHelper <<-> START CONFIG
+2 -2
View File
@@ -17,7 +17,7 @@
Follow-up TODOs: None
-->
# DNS-Helper Constitution
# ekdns Constitution
## Core Principles
@@ -188,7 +188,7 @@ None. All external functionality is provided by `golang.org/x/net/dns/dnsmessage
## Governance
This constitution is the authoritative source of project standards for
DNS-Helper. It supersedes informal conventions, ad-hoc decisions, and
ekdns. It supersedes informal conventions, ad-hoc decisions, and
prior practices that conflict with its contents.
- **Amendment process**: Any change to this constitution MUST be
+31 -31
View File
@@ -1,12 +1,12 @@
# dns-helper
# ekDNSHelper
A cross-platform CLI tool that resolves hostnames against DNS servers and writes the resulting IP-to-hostname mappings into the local hosts file. It manages a clearly delimited block within the hosts file so it can cleanly add and remove only its own entries.
By default, dns-helper performs **smart resolution**: it discovers the authoritative nameserver for each hostname through a parallel NS fan-out across local and public resolvers, then queries that NS directly for the freshest answer. Internal hostnames (without zone delegation) fall back to a parallel A-record query across all resolvers.
By default, ekdns performs **smart resolution**: it discovers the authoritative nameserver for each hostname through a parallel NS fan-out across local and public resolvers, then queries that NS directly for the freshest answer. Internal hostnames (without zone delegation) fall back to a parallel A-record query across all resolvers.
Built as a single static binary with no runtime dependencies.
**Copyright (c) 2024 Emberkom LLC**
**Copyright (c) 2026 Emberkom LLC**
## Features
@@ -19,7 +19,7 @@ Built as a single static binary with no runtime dependencies.
- Context-aware error messages with private TLD hints (`.local`, `.corp`, `.internal`, etc.)
- Per-query timeout control via `-timeout`
- Verbose per-stage resolution trace via `-verbose`
- Managed block markers ensure only dns-helper's entries are ever modified
- Managed block markers ensure only ekdns's entries are ever modified
- Automatic backup of the hosts file before every write
- File-based lock prevents concurrent modifications
- Cross-platform: Windows, macOS, and Linux
@@ -36,7 +36,7 @@ Built as a single static binary with no runtime dependencies.
```powershell
# Clone the repository
git clone <repo-url>
cd dns-helper
cd ekdns
# Build for the current platform
.\build.ps1
@@ -45,7 +45,7 @@ cd dns-helper
.\build.ps1 -Release -CrossCompile
```
The default build output is `build\dns-helper.exe` (Windows). Cross-compiled binaries are named `build\dns-helper-<os>-<arch>[.exe]`.
The default build output is `build\ekdns.exe` (Windows). Cross-compiled binaries are named `build\ekdns-<os>-<arch>[.exe]`.
### Release packages
@@ -54,46 +54,46 @@ The default build output is `build\dns-helper.exe` (Windows). Cross-compiled bin
.\build.ps1 -Release -CrossCompile -Package
```
Archives are written to `releases\dns-helper-<version>-<os>-<arch>.zip` (Windows) or `.tar.gz` (macOS/Linux).
Archives are written to `releases\ekdns-<version>-<os>-<arch>.zip` (Windows) or `.tar.gz` (macOS/Linux).
## Usage
> **Note:** dns-helper must be run with elevated privileges (Run as Administrator on Windows, `sudo` on macOS/Linux) because it modifies the system hosts file.
> **Note:** ekdns must be run with elevated privileges (Run as Administrator on Windows, `sudo` on macOS/Linux) because it modifies the system hosts file.
### Add entries
Resolve hostnames using smart default resolution and add to hosts file:
```sh
dns-helper add -host www.example.com
ekdns add -host www.example.com
```
Specify a resolution mode:
```sh
# Use only local DNS resolvers (no public fallback)
dns-helper add -host internal.corp -server local
ekdns add -host internal.corp -server local
# Use the default gateway as DNS
dns-helper add -host www.example.com -server gateway
ekdns add -host www.example.com -server gateway
# Use a specific DNS server
dns-helper add -host www.example.com -server 8.8.8.8
ekdns add -host www.example.com -server 8.8.8.8
# Use a specific DNS server on a non-standard port
dns-helper add -host www.example.com -server 10.0.0.53:5353
ekdns add -host www.example.com -server 10.0.0.53:5353
```
Add multiple hostnames at once:
```sh
dns-helper add -host "host1.example.com,host2.example.com"
ekdns add -host "host1.example.com,host2.example.com"
```
Use timeout and verbose options:
```sh
dns-helper add -host www.example.com -timeout 5 -verbose
ekdns add -host www.example.com -timeout 5 -verbose
```
### Delete entries
@@ -101,19 +101,19 @@ dns-helper add -host www.example.com -timeout 5 -verbose
Remove a specific hostname:
```sh
dns-helper delete -host hostname.example.com
ekdns delete -host hostname.example.com
```
Remove multiple hostnames:
```sh
dns-helper delete -host "host1.example.com,host2.example.com"
ekdns delete -host "host1.example.com,host2.example.com"
```
Remove **all** managed entries:
```sh
dns-helper delete all
ekdns delete all
```
Short aliases: `a` for `add`, `d` / `del` for `delete`
@@ -127,7 +127,7 @@ Short aliases: `a` for `add`, `d` / `del` for `delete`
| `add` | `-timeout` | No | 3 | Per-query DNS timeout in seconds |
| `add` | `-verbose` | No | false | Emit per-stage resolution trace to stderr |
| `delete` | `-host` | Yes (unless `all`) | — | Comma-separated hostnames to remove |
| `delete all` | — | — | — | Removes every entry dns-helper has written |
| `delete all` | — | — | — | Removes every entry ekdns has written |
### Exit codes
@@ -140,7 +140,7 @@ Short aliases: `a` for `add`, `d` / `del` for `delete`
### Smart Default (no `-server` flag)
When `-server` is omitted, dns-helper performs multi-stage authoritative resolution:
When `-server` is omitted, ekdns performs multi-stage authoritative resolution:
1. **Discover** local DNS resolvers and default gateway from OS network configuration.
2. **Build resolver pool**: local resolvers + hardcoded bootstrap set (1.1.1.1, 8.8.8.8, etc.), deduplicated.
@@ -153,7 +153,7 @@ When `-server` is omitted, dns-helper performs multi-stage authoritative resolut
```sh
# Example: smart default resolution with verbose trace
dns-helper add -host www.example.com -verbose
ekdns add -host www.example.com -verbose
```
Verbose output:
@@ -172,7 +172,7 @@ Verbose output:
Queries only the locally configured DNS resolvers (from OS network configuration) in priority order. No fallback to public resolvers.
```sh
dns-helper add -host internal.client.local -server local
ekdns add -host internal.client.local -server local
```
Fails with an error if all local resolvers are unreachable or no local resolvers are found.
@@ -182,7 +182,7 @@ Fails with an error if all local resolvers are unreachable or no local resolvers
Discovers the default gateway IP and uses it as the DNS server. No fallback.
```sh
dns-helper add -host www.example.com -server gateway
ekdns add -host www.example.com -server gateway
```
### Explicit Server (`-server <ip>[:<port>]`)
@@ -190,8 +190,8 @@ dns-helper add -host www.example.com -server gateway
Queries the specified DNS server directly. Port defaults to 53 if omitted.
```sh
dns-helper add -host www.example.com -server 8.8.8.8
dns-helper add -host www.example.com -server 10.0.0.53:5353
ekdns add -host www.example.com -server 8.8.8.8
ekdns add -host www.example.com -server 10.0.0.53:5353
```
## Error Messages
@@ -203,7 +203,7 @@ When a hostname with a private TLD (`.local`, `.corp`, `.internal`, `.lan`, `.ho
```
Error: failed to resolve myservice.client.local: ...
Hint: the hostname uses a private TLD (.local). Try specifying an internal DNS server:
dns-helper add -host myservice.client.local -server <internal-dns-ip>
ekdns add -host myservice.client.local -server <internal-dns-ip>
```
### Split-horizon conflict
@@ -226,17 +226,17 @@ Error: CNAME chain depth exceeded for www.example.com (max 10 hops): probable CN
## How it works
dns-helper wraps its entries between two marker lines in the hosts file:
ekdns wraps its entries between two marker lines in the hosts file:
```
# DNSHelper <<-> START CONFIG
# ekDNSHelper <<-> START CONFIG
192.0.2.1 hostname.example.com
# DNSHelper <->> END CONFIG
# ekDNSHelper <->> END CONFIG
```
Only lines within this managed block are ever modified or removed. The rest of the hosts file is preserved exactly as-is.
Before every write, a backup of the hosts file is saved to the directory containing the dns-helper executable.
Before every write, a backup of the hosts file is saved to the directory containing the ekdns executable.
### Hosts file locations
@@ -248,7 +248,7 @@ Before every write, a backup of the hosts file is saved to the directory contain
### Network discovery (per platform)
When using smart default, local, or gateway modes, dns-helper discovers your network configuration via OS utilities:
When using smart default, local, or gateway modes, ekdns discovers your network configuration via OS utilities:
| Platform | Method |
|----------|--------|
+14 -12
View File
@@ -1,4 +1,4 @@
# Build script for dns-helper
# Build script for ekDNSHelper
# Builds the executables using the go1.20 toolchain
#
# USAGE:
@@ -10,13 +10,13 @@
# .\build.ps1 -Release -Package # Full release: optimized build + packages
#
# OUTPUT:
# - Default build: build\dns-helper.exe
# - Cross-compiled: build\dns-helper-<os>-<arch>[.exe]
# - Release packages: releases\dns-helper-<version>-<os>-<arch>.zip / .tar.gz
# - Default build: build\ekdns.exe
# - Cross-compiled: build\ekdns-<os>-<arch>[.exe]
# - Release packages: releases\ekdns-<version>-<os>-<arch>.zip / .tar.gz
param(
[string]$OutputDir = "build",
[string]$OutputName = "dns-helper",
[string]$OutputName = "ekdns",
[switch]$Release,
[switch]$Clean,
[switch]$CrossCompile,
@@ -54,7 +54,7 @@ $ErrorActionPreference = $prevErrorActionPreference
$buildTime = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " dns-helper Build" -ForegroundColor Cyan
Write-Host " ekDNSHelper Build" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host " Version: $gitTag" -ForegroundColor Gray
@@ -78,12 +78,14 @@ if (-not (Test-Path $OutputDir)) {
Write-Host "Created output directory: $OutputDir" -ForegroundColor Green
}
# Build flags
$ldflags = ""
# Build flags — always inject version and copyright year; release builds also strip debug symbols.
$buildYear = (Get-Date).Year
$injectedFlags = "-X main.version=$gitTag -X main.copyrightYear=$buildYear"
if ($Release) {
$ldflags = "-s -w"
$ldflags = "-s -w $injectedFlags"
Write-Host "Building RELEASE version (optimized, no debug symbols)..." -ForegroundColor Green
} else {
$ldflags = $injectedFlags
Write-Host "Building DEBUG version (with debug symbols)..." -ForegroundColor Yellow
}
@@ -232,7 +234,7 @@ if ($Package) {
# VERSION.txt
@"
dns-helper Version Information
ekDNSHelper Version Information
===============================
Version: $gitTag
@@ -246,7 +248,7 @@ Run '$exeName --help' for usage information.
# CHECKSUMS.txt
$exeHash = (Get-FileHash -Path (Join-Path $packageDir $exeName) -Algorithm SHA256).Hash
@"
dns-helper Release Checksums
ekDNSHelper Release Checksums
Version: $gitTag
Build: $gitHash
Platform: $platformName
@@ -296,7 +298,7 @@ To verify (Linux/macOS):
Write-Host "Release packaging complete!" -ForegroundColor Green
Write-Host ""
Write-Host "Package contents:" -ForegroundColor Cyan
Write-Host " - dns-helper executable" -ForegroundColor Gray
Write-Host " - ekdns executable" -ForegroundColor Gray
Write-Host " - README.md (if present)" -ForegroundColor Gray
Write-Host " - VERSION.txt" -ForegroundColor Gray
Write-Host " - CHECKSUMS.txt (integrity verification)" -ForegroundColor Gray
+3 -3
View File
@@ -1,4 +1,4 @@
# Bump Version Script for dns-helper
# Bump Version Script for ekdnsr
# Automatically increments version based on semantic versioning
#
# USAGE:
@@ -36,7 +36,7 @@ param(
$ErrorActionPreference = "Stop"
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " dns-helper" -ForegroundColor Cyan
Write-Host " ekdnsr" -ForegroundColor Cyan
Write-Host " Version Bump" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
@@ -139,7 +139,7 @@ if ($DryRun) {
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host " 1. Build release: .\build.ps1 -Release -Package" -ForegroundColor White
Write-Host " 2. Test the build: .\build\dns-helper.exe --help" -ForegroundColor White
Write-Host " 2. Test the build: .\build\ekdns--help" -ForegroundColor White
Write-Host ""
# Output the new version for CI/CD pipelines
+1 -1
View File
@@ -1,4 +1,4 @@
module dns-helper
module ekdns
go 1.20
+10 -3
View File
@@ -9,8 +9,15 @@ import (
)
const (
StartMarker = "# DNSHelper <<-> START CONFIG"
EndMarker = "# DNSHelper <->> END CONFIG"
StartMarker = "# ekDNSHelper <<-> START CONFIG"
EndMarker = "# ekDNSHelper <->> END CONFIG"
// Legacy markers from the pre-rename "DNSHelper" era.
// ParseManagedBlock recognises these so that hosts files written by
// the old binary are detected, parsed, and silently upgraded to the
// current markers on the next write.
LegacyStartMarker = "# DNSHelper <<-> START CONFIG"
LegacyEndMarker = "# DNSHelper <->> END CONFIG"
)
// DNSEntry represents a single IP-to-hostname mapping.
@@ -116,7 +123,7 @@ func (m *Manager) Write(hf *HostsFile, backupDir string) error {
// This is required so the subsequent rename stays on the same filesystem
// partition (avoids EXDEV on Unix).
success := false
tempFile, err := m.fs.CreateTemp(filepath.Dir(hf.Path), ".dns-helper-tmp-*")
tempFile, err := m.fs.CreateTemp(filepath.Dir(hf.Path), ".ekdns-tmp-*")
if err != nil {
m.fs.Remove(backupPath) //nolint:errcheck
return fmt.Errorf("creating temp file: %w", err)
+1 -2
View File
@@ -1,6 +1,7 @@
package hostfile_test
import (
"ekdns/hostfile"
"errors"
"fmt"
"os"
@@ -8,8 +9,6 @@ import (
"strings"
"testing"
"time"
"dns-helper/hostfile"
)
// ---------------------------------------------------------------------------
+21 -4
View File
@@ -17,13 +17,17 @@ func ParseManagedBlock(lines []string) (prefix, managed, postfix []string, hasMa
endCount := 0
for i, line := range lines {
if strings.Contains(line, "DNSHelper <<-> START CONFIG") {
// Recognise both current and legacy markers so that hosts files
// written by the old "DNSHelper" binary are parsed correctly.
if strings.Contains(line, "ekDNSHelper <<-> START CONFIG") ||
strings.Contains(line, "DNSHelper <<-> START CONFIG") {
startCount++
if startIdx == -1 {
startIdx = i
}
}
if strings.Contains(line, "DNSHelper <->> END CONFIG") {
if strings.Contains(line, "ekDNSHelper <->> END CONFIG") ||
strings.Contains(line, "DNSHelper <->> END CONFIG") {
endCount++
if endIdx == -1 {
endIdx = i
@@ -57,6 +61,19 @@ func ParseManagedBlock(lines []string) (prefix, managed, postfix []string, hasMa
return prefix, managed, postfix, true, nil
}
// lineMatchesHostname parses a hosts-file line ("IP<whitespace>hostname [aliases...]")
// and returns true if any hostname field (including aliases) matches,
// using case-insensitive comparison per RFC 4343.
func lineMatchesHostname(line, hostname string) bool {
fields := strings.Fields(line)
for _, f := range fields[1:] {
if strings.EqualFold(f, hostname) {
return true
}
}
return false
}
// AddEntries removes existing entries for the same hostnames as the new entries,
// then appends the new entries. Returns deduplicated managed content lines.
func AddEntries(existing []string, entries []DNSEntry) []string {
@@ -71,7 +88,7 @@ func AddEntries(existing []string, entries []DNSEntry) []string {
for _, line := range existing {
keep := true
for h := range hostnames {
if strings.Contains(line, h) {
if lineMatchesHostname(line, h) {
keep = false
break
}
@@ -101,7 +118,7 @@ func RemoveByHostname(existing []string, hostnames []string) []string {
for _, line := range existing {
shouldRemove := false
for _, host := range hostnames {
if strings.Contains(line, host) {
if lineMatchesHostname(line, host) {
shouldRemove = true
break
}
+225 -3
View File
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
"dns-helper/hostfile"
"ekdns/hostfile"
)
// ---------------------------------------------------------------------------
@@ -159,6 +159,134 @@ func TestParseManagedBlock_CorruptDuplicateMarkers(t *testing.T) {
}
}
// ---------------------------------------------------------------------------
// ParseManagedBlock — legacy marker tests
// ---------------------------------------------------------------------------
func TestParseManagedBlock_LegacyMarkers(t *testing.T) {
lines := []string{
"127.0.0.1 localhost",
hostfile.LegacyStartMarker,
"1.1.1.1\thost1",
"2.2.2.2\thost2",
hostfile.LegacyEndMarker,
"# trailing comment",
}
prefix, managed, postfix, has, err := hostfile.ParseManagedBlock(lines)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !has {
t.Fatal("expected HasManagedBlock=true for legacy markers")
}
if len(prefix) != 1 || prefix[0] != "127.0.0.1 localhost" {
t.Errorf("unexpected prefix: %v", prefix)
}
if len(managed) != 2 {
t.Errorf("expected 2 managed lines, got %v", managed)
}
if len(postfix) != 1 || postfix[0] != "# trailing comment" {
t.Errorf("unexpected postfix: %v", postfix)
}
}
func TestParseManagedBlock_LegacyStartMarkerAtLine0(t *testing.T) {
lines := []string{
hostfile.LegacyStartMarker,
"1.1.1.1\thost1",
hostfile.LegacyEndMarker,
}
prefix, managed, postfix, has, err := hostfile.ParseManagedBlock(lines)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !has {
t.Fatal("expected HasManagedBlock=true")
}
if len(prefix) != 0 {
t.Errorf("expected empty prefix, got %v", prefix)
}
if len(managed) != 1 || managed[0] != "1.1.1.1\thost1" {
t.Errorf("unexpected managed: %v", managed)
}
if len(postfix) != 0 {
t.Errorf("expected empty postfix, got %v", postfix)
}
}
func TestParseManagedBlock_LegacyCorruptStartWithoutEnd(t *testing.T) {
lines := []string{
"127.0.0.1 localhost",
hostfile.LegacyStartMarker,
"1.1.1.1\thost1",
}
_, _, _, _, err := hostfile.ParseManagedBlock(lines)
if err == nil {
t.Fatal("expected error for legacy start marker without end marker")
}
}
func TestParseManagedBlock_LegacyCorruptEndWithoutStart(t *testing.T) {
lines := []string{
"127.0.0.1 localhost",
"1.1.1.1\thost1",
hostfile.LegacyEndMarker,
}
_, _, _, _, err := hostfile.ParseManagedBlock(lines)
if err == nil {
t.Fatal("expected error for legacy end marker without start marker")
}
}
func TestParseManagedBlock_MixedOldStartNewEnd(t *testing.T) {
// Legacy start with current end — still a valid block (both substrings match).
lines := []string{
"127.0.0.1 localhost",
hostfile.LegacyStartMarker,
"1.1.1.1\thost1",
hostfile.EndMarker,
}
_, managed, _, has, err := hostfile.ParseManagedBlock(lines)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !has {
t.Fatal("expected HasManagedBlock=true for mixed markers")
}
if len(managed) != 1 {
t.Errorf("expected 1 managed line, got %v", managed)
}
}
func TestParseManagedBlock_LegacyUpgradedOnWrite(t *testing.T) {
// Verify that a legacy block is parsed and then reassembled with current markers.
lines := []string{
"127.0.0.1 localhost",
hostfile.LegacyStartMarker,
"1.1.1.1\thost1",
hostfile.LegacyEndMarker,
"# trailing",
}
prefix, managed, postfix, _, err := hostfile.ParseManagedBlock(lines)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result := hostfile.AssembleContent(prefix, managed, postfix)
// The reassembled output should use the current markers, not legacy.
found := false
for _, l := range result {
if l == hostfile.StartMarker {
found = true
}
if l == hostfile.LegacyStartMarker {
t.Fatal("reassembled content should not contain legacy start marker")
}
}
if !found {
t.Fatal("reassembled content should contain current start marker")
}
}
// ---------------------------------------------------------------------------
// AddEntries tests
// ---------------------------------------------------------------------------
@@ -201,6 +329,52 @@ func TestAddEntries_ReplacesExistingHostname(t *testing.T) {
}
}
func TestAddEntries_DoesNotClobberSubdomains(t *testing.T) {
// Regression: substring matching would cause adding "example.com" to
// incorrectly remove "sub.example.com" entries.
existing := []string{
"1.1.1.1\tsub.example.com",
"2.2.2.2\tother.example.com",
}
entries := []hostfile.DNSEntry{{IP: "9.9.9.9", Hostname: "example.com"}}
result := hostfile.AddEntries(existing, entries)
if len(result) != 3 {
t.Fatalf("expected 3 entries (2 existing + 1 new), got %d: %v", len(result), result)
}
for _, line := range result {
if line == "1.1.1.1\tsub.example.com" || line == "2.2.2.2\tother.example.com" || line == "9.9.9.9\texample.com" {
continue
}
t.Errorf("unexpected line: %q", line)
}
}
func TestAddEntries_MatchesAliasColumn(t *testing.T) {
// A hosts-file line can have aliases: "IP primary alias1 alias2"
// Replacing "alias1" should remove the entire line.
existing := []string{"1.1.1.1\tprimary\talias1"}
entries := []hostfile.DNSEntry{{IP: "9.9.9.9", Hostname: "alias1"}}
result := hostfile.AddEntries(existing, entries)
for _, line := range result {
if strings.Contains(line, "primary") {
t.Errorf("line with alias1 should have been removed, but found: %q", line)
}
}
}
func TestAddEntries_CaseInsensitive(t *testing.T) {
// RFC 4343: hostnames are case-insensitive.
existing := []string{"1.1.1.1\tHost1.Example.COM"}
entries := []hostfile.DNSEntry{{IP: "9.9.9.9", Hostname: "host1.example.com"}}
result := hostfile.AddEntries(existing, entries)
if len(result) != 1 {
t.Fatalf("expected 1 entry (old replaced), got %d: %v", len(result), result)
}
if result[0] != "9.9.9.9\thost1.example.com" {
t.Errorf("expected new entry, got %q", result[0])
}
}
func TestAddEntries_Deduplicates(t *testing.T) {
existing := []string{"1.1.1.1\thost1"}
entries := []hostfile.DNSEntry{
@@ -253,7 +427,7 @@ func TestAssembleContent_EmptyManagedOmitsBlock(t *testing.T) {
t.Fatalf("expected 2 lines (no managed block), got %d: %v", len(result), result)
}
for _, line := range result {
if strings.Contains(line, "DNSHelper") {
if strings.Contains(line, "ekDNSHelper") {
t.Errorf("managed block markers should be absent when managed is empty: %v", result)
}
}
@@ -332,6 +506,54 @@ func TestRemoveByHostname_RegressionNoDuplicates(t *testing.T) {
}
}
func TestRemoveByHostname_DoesNotClobberSubdomains(t *testing.T) {
// Regression: substring matching would remove "sub.example.com" when
// only "example.com" was requested for removal.
existing := []string{
"1.1.1.1\texample.com",
"2.2.2.2\tsub.example.com",
"3.3.3.3\tmy-example.com",
}
result := hostfile.RemoveByHostname(existing, []string{"example.com"})
if len(result) != 2 {
t.Fatalf("expected 2 remaining entries, got %d: %v", len(result), result)
}
if result[0] != "2.2.2.2\tsub.example.com" {
t.Errorf("expected sub.example.com to remain, got %q", result[0])
}
if result[1] != "3.3.3.3\tmy-example.com" {
t.Errorf("expected my-example.com to remain, got %q", result[1])
}
}
func TestRemoveByHostname_MatchesAliasColumn(t *testing.T) {
existing := []string{
"1.1.1.1\tprimary\talias1",
"2.2.2.2\thost2",
}
result := hostfile.RemoveByHostname(existing, []string{"alias1"})
if len(result) != 1 {
t.Fatalf("expected 1 remaining entry, got %d: %v", len(result), result)
}
if result[0] != "2.2.2.2\thost2" {
t.Errorf("expected host2 to remain, got %q", result[0])
}
}
func TestRemoveByHostname_CaseInsensitive(t *testing.T) {
existing := []string{
"1.1.1.1\tHost1.Example.COM",
"2.2.2.2\thost2",
}
result := hostfile.RemoveByHostname(existing, []string{"host1.example.com"})
if len(result) != 1 {
t.Fatalf("expected 1 remaining entry, got %d: %v", len(result), result)
}
if result[0] != "2.2.2.2\thost2" {
t.Errorf("expected host2 to remain, got %q", result[0])
}
}
// ---------------------------------------------------------------------------
// RemoveAll tests (T018)
// ---------------------------------------------------------------------------
@@ -353,7 +575,7 @@ func TestRemoveAll_AssembleContentOmitsBlock(t *testing.T) {
t.Fatalf("expected 2 lines with no managed block, got %d: %v", len(result), result)
}
for _, line := range result {
if strings.Contains(line, "DNSHelper") {
if strings.Contains(line, "ekDNSHelper") {
t.Errorf("managed block markers should be absent after RemoveAll: %v", result)
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
const (
lockFileName = ".dns-helper.lock"
lockFileName = ".ekdns.lock"
staleTimeout = 2 * time.Minute
retryInterval = 200 * time.Millisecond
maxWait = 2 * time.Second
+4 -4
View File
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"dns-helper/lockfile"
"ekdns/lockfile"
)
// T006-1: Acquire succeeds and lock file exists on disk.
@@ -18,7 +18,7 @@ func TestAcquireSucceeds(t *testing.T) {
}
defer lock.Release()
lockPath := filepath.Join(dir, ".dns-helper.lock")
lockPath := filepath.Join(dir, ".ekdns.lock")
if _, err := os.Stat(lockPath); os.IsNotExist(err) {
t.Error("lock file does not exist after Acquire")
}
@@ -36,7 +36,7 @@ func TestReleaseDeletesLockFile(t *testing.T) {
t.Fatalf("Release returned unexpected error: %v", err)
}
lockPath := filepath.Join(dir, ".dns-helper.lock")
lockPath := filepath.Join(dir, ".ekdns.lock")
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
t.Error("lock file still exists after Release")
}
@@ -60,7 +60,7 @@ func TestDoubleAcquireFails(t *testing.T) {
// T006-4: Stale lock file (>2 minutes old) is broken and Acquire succeeds.
func TestStaleLockBroken(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, ".dns-helper.lock")
lockPath := filepath.Join(dir, ".ekdns.lock")
// Manually create a lock file with a stale modification time.
if err := os.WriteFile(lockPath, []byte("99999\n"), 0644); err != nil {
+20 -16
View File
@@ -8,16 +8,20 @@ import (
"strings"
"time"
"dns-helper/hostfile"
"dns-helper/lockfile"
"dns-helper/platform"
"dns-helper/resolver"
"ekdns/hostfile"
"ekdns/lockfile"
"ekdns/platform"
"ekdns/resolver"
)
// version and copyrightYear are set at build time via -ldflags.
var version = "dev"
var copyrightYear = "2024"
func main() {
// Banner (always printed to stdout).
fmt.Println("DNSHelper v1.0")
fmt.Println("Copyright (c) 2024 Emberkom LLC")
fmt.Printf("ekDNSHelper %s\n", version)
fmt.Printf("Copyright (c) %s Emberkom LLC\n", copyrightYear)
fmt.Println("")
if len(os.Args) < 2 {
@@ -291,9 +295,9 @@ func printUsage() {
fmt.Println("Updates the local hosts file with DNS entries resolved by this tool.")
fmt.Println("")
fmt.Println("Usage:")
fmt.Println(" dns-helper add -host <hostnames> [-server <mode>] [-timeout <seconds>] [-verbose]")
fmt.Println(" dns-helper delete -host <hostnames>")
fmt.Println(" dns-helper delete all")
fmt.Println(" ekdns add -host <hostnames> [-server <mode>] [-timeout <seconds>] [-verbose]")
fmt.Println(" ekdns delete -host <hostnames>")
fmt.Println(" ekdns delete all")
fmt.Println("")
fmt.Println("Flags (add):")
fmt.Println(" -host Comma-separated list of hostnames to resolve and add (required)")
@@ -307,19 +311,19 @@ func printUsage() {
fmt.Println(" -verbose Emit per-stage resolution trace to stderr")
fmt.Println("")
fmt.Println("Examples:")
fmt.Println(" dns-helper add -host www.example.com")
fmt.Println(" ekdns add -host www.example.com")
fmt.Println(" Resolve using smart default (NS fan-out + authoritative query).")
fmt.Println(" dns-helper add -host internal.client.local -server local")
fmt.Println(" ekdns add -host internal.client.local -server local")
fmt.Println(" Resolve using only locally configured DNS resolvers.")
fmt.Println(" dns-helper add -host www.example.com -server gateway")
fmt.Println(" ekdns add -host www.example.com -server gateway")
fmt.Println(" Resolve via the default gateway IP.")
fmt.Println(" dns-helper add -host www.example.com -server 8.8.8.8")
fmt.Println(" ekdns add -host www.example.com -server 8.8.8.8")
fmt.Println(" Resolve via a specific DNS server.")
fmt.Println(" dns-helper add -host www.example.com -server 8.8.8.8:5353 -timeout 5 -verbose")
fmt.Println(" ekdns add -host www.example.com -server 8.8.8.8:5353 -timeout 5 -verbose")
fmt.Println(" Resolve via 8.8.8.8:5353 with 5s timeout and verbose trace.")
fmt.Println(" dns-helper delete -host www.example.com")
fmt.Println(" ekdns delete -host www.example.com")
fmt.Println(" Remove all managed entries for www.example.com from the hosts file.")
fmt.Println(" dns-helper delete all")
fmt.Println(" ekdns delete all")
fmt.Println(" Remove all managed entries from the hosts file.")
fmt.Println("")
fmt.Println("Notes:")
+1 -2
View File
@@ -1,10 +1,9 @@
package platform_test
import (
"ekdns/platform"
"errors"
"testing"
"dns-helper/platform"
)
func TestFakeNetworkDiscoverer_ReturnsInfo(t *testing.T) {
+1 -2
View File
@@ -1,11 +1,10 @@
package platform_test
import (
"ekdns/platform"
"os"
"strings"
"testing"
"dns-helper/platform"
)
// T007-1: GetHostsFilePath returns a non-empty path with no error on the current OS.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -345,7 +345,6 @@ func QueryAuthoritative(ctx context.Context, w io.Writer, ns *AuthoritativeNS, h
default:
if skipErr := parser.SkipAnswer(); skipErr != nil {
parseOK = false
break
}
}
if !parseOK {
+1 -2
View File
@@ -2,13 +2,12 @@ package resolver_test
import (
"context"
"ekdns/resolver"
"io"
"net"
"testing"
"time"
"dns-helper/resolver"
"golang.org/x/net/dns/dnsmessage"
)
+1 -2
View File
@@ -2,12 +2,11 @@ package resolver_test
import (
"context"
"ekdns/resolver"
"io"
"testing"
"time"
"dns-helper/resolver"
"golang.org/x/net/dns/dnsmessage"
)
+13 -5
View File
@@ -8,7 +8,7 @@ import (
"strings"
"time"
"dns-helper/platform"
"ekdns/platform"
)
// Resolve performs DNS resolution for hostname using the specified mode and config.
@@ -85,11 +85,19 @@ func resolveDefault(hostname string, pool []string, localResolvers []string, con
if authority != nil {
fmt.Fprintf(w, "[dns] Selected authority: %s → %s\n",
authority.Zone, strings.Join(authority.Nameservers, ", "))
return resolveAuthoritative(ctx, authority, hostname, pool, localResolvers, config, depth, w)
ips, err := resolveAuthoritative(ctx, authority, hostname, pool, localResolvers, config, depth, w)
if err == nil {
return ips, nil
}
// Stage 2 failed (e.g. sub-zone delegation referral not followed) —
// fall through to Stage 3 parallel A fallback.
fmt.Fprintf(w, "[dns] Stage 2 failed: %v\n", err)
fmt.Fprintf(w, "[dns] Falling back to Stage 3 parallel A queries\n")
} else {
fmt.Fprintf(w, "[dns] Stage 1: No NS records found at any level\n")
}
// Stage 3: No NS records found — fall back to parallel A queries.
fmt.Fprintf(w, "[dns] Stage 1: No NS records found at any level\n")
// Stage 3: Fall back to parallel recursive A queries.
ips, err := ParallelAFallback(ctx, w, pool, hostname, timeout)
if err != nil {
return nil, addPrivateTLDHint(hostname, err)
@@ -247,7 +255,7 @@ func addPrivateTLDHint(hostname string, origErr error) error {
tld := strings.ToLower(parts[len(parts)-1])
if PrivateTLDs[tld] {
return fmt.Errorf(
"%w\n Hint: the hostname uses a private TLD (.%s). Try specifying an internal DNS server:\n dns-helper add -host %s -server <internal-dns-ip>",
"%w\n Hint: the hostname uses a private TLD (.%s). Try specifying an internal DNS server:\n ekdns add -host %s -server <internal-dns-ip>",
origErr, tld, hostname)
}
}
+2 -3
View File
@@ -1,13 +1,12 @@
package resolver_test
import (
"ekdns/platform"
"ekdns/resolver"
"strings"
"testing"
"time"
"dns-helper/platform"
"dns-helper/resolver"
"golang.org/x/net/dns/dnsmessage"
)
+1 -2
View File
@@ -1,10 +1,9 @@
package resolver_test
import (
"ekdns/resolver"
"strings"
"testing"
"dns-helper/resolver"
)
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,6 +1,6 @@
package resolver
import "dns-helper/platform"
import "ekdns/platform"
// BuildResolverPool constructs the ordered list of DNS resolver addresses for
// the given ServerMode and discovered NetworkInfo.
+2 -3
View File
@@ -1,11 +1,10 @@
package resolver_test
import (
"ekdns/platform"
"ekdns/resolver"
"reflect"
"testing"
"dns-helper/platform"
"dns-helper/resolver"
)
func TestBuildResolverPool(t *testing.T) {
+1 -2
View File
@@ -1,14 +1,13 @@
package resolver_test
import (
"ekdns/resolver"
"encoding/binary"
"net"
"strings"
"testing"
"time"
"dns-helper/resolver"
"golang.org/x/net/dns/dnsmessage"
)
+1 -2
View File
@@ -2,11 +2,10 @@ package resolver_test
import (
"context"
"ekdns/resolver"
"io"
"testing"
"time"
"dns-helper/resolver"
)
// ---------------------------------------------------------------------------
+1 -2
View File
@@ -2,11 +2,10 @@ package resolver_test
import (
"context"
"ekdns/resolver"
"net"
"testing"
"time"
"dns-helper/resolver"
)
// ---------------------------------------------------------------------------