11 Commits
19 changed files with 274 additions and 14 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
# ekekDNSHelper # 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. 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.
@@ -6,7 +6,7 @@ By default, ekdns performs **smart resolution**: it discovers the authoritative
Built as a single static binary with no runtime dependencies. Built as a single static binary with no runtime dependencies.
**Copyright (c) 2024 Emberkom LLC** **Copyright (c) 2026 Emberkom LLC**
## Features ## Features
+5 -3
View File
@@ -78,12 +78,14 @@ if (-not (Test-Path $OutputDir)) {
Write-Host "Created output directory: $OutputDir" -ForegroundColor Green Write-Host "Created output directory: $OutputDir" -ForegroundColor Green
} }
# Build flags # Build flags — always inject version and copyright year; release builds also strip debug symbols.
$ldflags = "" $buildYear = (Get-Date).Year
$injectedFlags = "-X main.version=$gitTag -X main.copyrightYear=$buildYear"
if ($Release) { if ($Release) {
$ldflags = "-s -w" $ldflags = "-s -w $injectedFlags"
Write-Host "Building RELEASE version (optimized, no debug symbols)..." -ForegroundColor Green Write-Host "Building RELEASE version (optimized, no debug symbols)..." -ForegroundColor Green
} else { } else {
$ldflags = $injectedFlags
Write-Host "Building DEBUG version (with debug symbols)..." -ForegroundColor Yellow Write-Host "Building DEBUG version (with debug symbols)..." -ForegroundColor Yellow
} }
+7
View File
@@ -11,6 +11,13 @@ import (
const ( const (
StartMarker = "# ekDNSHelper <<-> START CONFIG" StartMarker = "# ekDNSHelper <<-> START CONFIG"
EndMarker = "# ekDNSHelper <->> END 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. // DNSEntry represents a single IP-to-hostname mapping.
+21 -4
View File
@@ -17,13 +17,17 @@ func ParseManagedBlock(lines []string) (prefix, managed, postfix []string, hasMa
endCount := 0 endCount := 0
for i, line := range lines { for i, line := range lines {
if strings.Contains(line, "ekDNSHelper <<-> 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++ startCount++
if startIdx == -1 { if startIdx == -1 {
startIdx = i startIdx = i
} }
} }
if strings.Contains(line, "ekDNSHelper <->> END CONFIG") { if strings.Contains(line, "ekDNSHelper <->> END CONFIG") ||
strings.Contains(line, "DNSHelper <->> END CONFIG") {
endCount++ endCount++
if endIdx == -1 { if endIdx == -1 {
endIdx = i endIdx = i
@@ -57,6 +61,19 @@ func ParseManagedBlock(lines []string) (prefix, managed, postfix []string, hasMa
return prefix, managed, postfix, true, nil 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, // AddEntries removes existing entries for the same hostnames as the new entries,
// then appends the new entries. Returns deduplicated managed content lines. // then appends the new entries. Returns deduplicated managed content lines.
func AddEntries(existing []string, entries []DNSEntry) []string { func AddEntries(existing []string, entries []DNSEntry) []string {
@@ -71,7 +88,7 @@ func AddEntries(existing []string, entries []DNSEntry) []string {
for _, line := range existing { for _, line := range existing {
keep := true keep := true
for h := range hostnames { for h := range hostnames {
if strings.Contains(line, h) { if lineMatchesHostname(line, h) {
keep = false keep = false
break break
} }
@@ -101,7 +118,7 @@ func RemoveByHostname(existing []string, hostnames []string) []string {
for _, line := range existing { for _, line := range existing {
shouldRemove := false shouldRemove := false
for _, host := range hostnames { for _, host := range hostnames {
if strings.Contains(line, host) { if lineMatchesHostname(line, host) {
shouldRemove = true shouldRemove = true
break break
} }
+222
View File
@@ -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 // 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) { func TestAddEntries_Deduplicates(t *testing.T) {
existing := []string{"1.1.1.1\thost1"} existing := []string{"1.1.1.1\thost1"}
entries := []hostfile.DNSEntry{ entries := []hostfile.DNSEntry{
@@ -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) // RemoveAll tests (T018)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+6 -2
View File
@@ -14,10 +14,14 @@ import (
"ekdns/resolver" "ekdns/resolver"
) )
// version and copyrightYear are set at build time via -ldflags.
var version = "dev"
var copyrightYear = "2024"
func main() { func main() {
// Banner (always printed to stdout). // Banner (always printed to stdout).
fmt.Println("ekDNSHelper v1.0") fmt.Printf("ekDNSHelper %s\n", version)
fmt.Println("Copyright (c) 2024 Emberkom LLC") fmt.Printf("Copyright (c) %s Emberkom LLC\n", copyrightYear)
fmt.Println("") fmt.Println("")
if len(os.Args) < 2 { if len(os.Args) < 2 {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -3
View File
@@ -85,11 +85,19 @@ func resolveDefault(hostname string, pool []string, localResolvers []string, con
if authority != nil { if authority != nil {
fmt.Fprintf(w, "[dns] Selected authority: %s → %s\n", fmt.Fprintf(w, "[dns] Selected authority: %s → %s\n",
authority.Zone, strings.Join(authority.Nameservers, ", ")) 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. // Stage 3: Fall back to parallel recursive A queries.
fmt.Fprintf(w, "[dns] Stage 1: No NS records found at any level\n")
ips, err := ParallelAFallback(ctx, w, pool, hostname, timeout) ips, err := ParallelAFallback(ctx, w, pool, hostname, timeout)
if err != nil { if err != nil {
return nil, addPrivateTLDHint(hostname, err) return nil, addPrivateTLDHint(hostname, err)