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.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user