Files
dnshelper/hostfile/hostfile.go
T
dyoder f0ce5a4042 feat: Implement platform-specific hosts file path retrieval
- Created a new `platform` package to handle OS-specific hosts file paths.
- Added implementations for macOS, Linux, and Windows to retrieve the hosts file path.
- Introduced tests for the `GetHostsFilePath` function to ensure correct behavior across platforms.

feat: Add DNS resolver for IP lookups

- Implemented a `resolver` package for performing DNS lookups.
- Created a `DNSResolver` struct with a `LookupIP` method to handle A-record lookups and CNAME resolution.
- Added comprehensive tests for various DNS scenarios, including CNAME chains and error handling.

chore: Refactor project structure and update dependencies

- Restructured the project to follow Go's standard package layout.
- Updated `go.mod` to Go 1.20 and added `golang.org/x/net` dependency.
- Removed old source files and ensured a clean build with all tests passing.
2026-03-03 18:37:02 -05:00

202 lines
5.8 KiB
Go

package hostfile
import (
"fmt"
"math/rand"
"path/filepath"
"strings"
"time"
)
const (
StartMarker = "# DNSHelper <<-> START CONFIG"
EndMarker = "# DNSHelper <->> END CONFIG"
)
// DNSEntry represents a single IP-to-hostname mapping.
type DNSEntry struct {
IP string
Hostname string
}
// String returns the hosts file line representation: "IP\tHostname".
func (e DNSEntry) String() string {
return e.IP + "\t" + e.Hostname
}
// HostsFile represents the parsed content of a hosts file.
type HostsFile struct {
Path string
OriginalContent []string
PrefixContent []string
ManagedContent []string
PostfixContent []string
HasManagedBlock bool
}
// Manager handles hosts file read and atomic write operations.
type Manager struct {
fs FileSystem
}
// NewManager creates a Manager with the given FileSystem implementation.
func NewManager(fs FileSystem) *Manager {
return &Manager{fs: fs}
}
// Read reads and parses the hosts file at the given path.
// Returns an error if the file cannot be read or the managed block is corrupt.
func (m *Manager) Read(path string) (*HostsFile, error) {
data, err := m.fs.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading hosts file %s: %w", path, err)
}
lines := strings.Split(string(data), "\n")
// Remove trailing empty line from Split if file ends with newline.
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
// Trim \r from each line to handle Windows \r\n line endings.
// strings.Split on "\n" does not strip \r, unlike bufio.Scanner.
for i, line := range lines {
lines[i] = strings.TrimRight(line, "\r")
}
prefix, managed, postfix, hasBlock, err := ParseManagedBlock(lines)
if err != nil {
return nil, err
}
return &HostsFile{
Path: path,
OriginalContent: lines,
PrefixContent: prefix,
ManagedContent: managed,
PostfixContent: postfix,
HasManagedBlock: hasBlock,
}, nil
}
// Write atomically writes the hosts file content back to disk.
// It creates a backup before writing and removes it on success.
// Uses the temp-file-and-rename pattern for atomicity (R-002).
// backupDir is the directory for the backup file (typically the exe directory).
func (m *Manager) Write(hf *HostsFile, backupDir string) error {
// 1. Assemble new content.
newContent := AssembleContent(hf.PrefixContent, hf.ManagedContent, hf.PostfixContent)
// 2. Skip write if content is unchanged.
if slicesEqual(newContent, hf.OriginalContent) {
return nil
}
// 3. Build output bytes (LF line endings).
var buf strings.Builder
for _, line := range newContent {
buf.WriteString(line)
buf.WriteString("\n")
}
output := []byte(buf.String())
// 4. Get original file permissions so we can restore them on the temp file.
info, err := m.fs.Stat(hf.Path)
if err != nil {
return fmt.Errorf("checking hosts file permissions: %w", err)
}
originalMode := info.Mode()
// 5. Create backup (copy of current hosts file in backupDir).
backupPath, err := m.createBackup(hf.Path, backupDir)
if err != nil {
return fmt.Errorf("creating backup: %w", err)
}
// 6. Write to a temp file in the SAME directory as the hosts file.
// 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-*")
if err != nil {
m.fs.Remove(backupPath) //nolint:errcheck
return fmt.Errorf("creating temp file: %w", err)
}
tempPath := tempFile.Name()
defer func() {
if !success {
m.fs.Remove(tempPath) //nolint:errcheck
m.fs.Remove(backupPath) //nolint:errcheck
}
}()
if _, err := tempFile.Write(output); err != nil {
tempFile.Close() //nolint:errcheck
return fmt.Errorf("writing temp file: %w", err)
}
// Sync before close for durability (data survives power loss).
if err := tempFile.Sync(); err != nil {
tempFile.Close() //nolint:errcheck
return fmt.Errorf("syncing temp file: %w", err)
}
// Close before rename — required on Windows.
tempFile.Close() //nolint:errcheck
// 7. Set permissions on temp file to match original.
if err := m.fs.Chmod(tempPath, originalMode); err != nil {
return fmt.Errorf("setting temp file permissions: %w", err)
}
// 8. Atomic rename (with retry on Windows sharing violations).
if err := m.renameWithRetry(tempPath, hf.Path); err != nil {
return fmt.Errorf("renaming temp file to hosts file: %w", err)
}
// 9. Success — delete the backup (original was safely replaced).
success = true
m.fs.Remove(backupPath) //nolint:errcheck
return nil
}
// createBackup copies the hosts file to <backupDir>/hosts.bak.<YYYYMMDD>-<4hex>.
func (m *Manager) createBackup(hostsPath, backupDir string) (string, error) {
data, err := m.fs.ReadFile(hostsPath)
if err != nil {
return "", fmt.Errorf("reading hosts file for backup: %w", err)
}
timestamp := time.Now().Format("20060102")
suffix := fmt.Sprintf("%04x", rand.Uint32()&0xFFFF)
backupName := fmt.Sprintf("hosts.bak.%s-%s", timestamp, suffix)
backupPath := filepath.Join(backupDir, backupName)
if err := m.fs.WriteFile(backupPath, data, 0600); err != nil {
return "", fmt.Errorf("writing backup file %s: %w", backupPath, err)
}
return backupPath, nil
}
// renameWithRetry calls Rename and retries once on failure (for Windows
// sharing violations where the hosts file may be briefly locked).
func (m *Manager) renameWithRetry(src, dst string) error {
err := m.fs.Rename(src, dst)
if err == nil {
return nil
}
// Single retry after a short delay.
time.Sleep(200 * time.Millisecond)
return m.fs.Rename(src, dst)
}
// slicesEqual reports whether a and b contain the same strings in the same order.
func slicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}