169 lines
6.0 KiB
Markdown
169 lines
6.0 KiB
Markdown
# Package Contracts: dns-helper
|
|||
|
|
|
||
|
|
**Feature Branch**: `001-safety-reliability-refactor`
|
||
|
|
**Date**: 2026-03-03
|
||
|
|
|
||
|
|
## Package: `resolver`
|
||
|
|
|
||
|
|
Handles DNS name resolution against a user-specified DNS server.
|
||
|
|
|
||
|
|
### Interface
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Resolver performs DNS lookups against a specific server.
|
||
|
|
type Resolver interface {
|
||
|
|
// LookupIP resolves a hostname to a list of IPv4 addresses using the
|
||
|
|
// specified DNS server. Follows CNAME chains up to a maximum depth.
|
||
|
|
// Returns deduplicated IPs. Returns an error if the hostname cannot
|
||
|
|
// be resolved (NXDOMAIN, timeout, server unreachable, etc.).
|
||
|
|
LookupIP(hostname, server string) ([]string, error)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Behavior Contract
|
||
|
|
|
||
|
|
- Queries are sent over UDP to `<server>:53`.
|
||
|
|
- The `hostname` is automatically converted to FQDN (trailing dot appended if missing).
|
||
|
|
- An A query is sent first. If A records are returned, they are collected.
|
||
|
|
- If only CNAME records are returned (no A records in the answer), the CNAME target is resolved recursively.
|
||
|
|
- CNAME chain depth is limited to 10 levels. Exceeding this returns an error.
|
||
|
|
- Results are deduplicated before return.
|
||
|
|
- Timeout: 5 seconds per query.
|
||
|
|
- On NXDOMAIN: returns `error` with descriptive message.
|
||
|
|
- On server unreachable/timeout: returns `error` with descriptive message.
|
||
|
|
- Response ID must match query ID; mismatches are treated as errors.
|
||
|
|
- Truncated responses (TC flag set) are treated as errors (no TCP fallback for simplicity).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Package: `hostfile`
|
||
|
|
|
||
|
|
Manages reading, parsing, modifying, and atomically writing the hosts file.
|
||
|
|
|
||
|
|
### Interface
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Manager handles hosts file operations.
|
||
|
|
type Manager interface {
|
||
|
|
// 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.
|
||
|
|
Read(path string) (*HostsFile, error)
|
||
|
|
|
||
|
|
// Write atomically writes the hosts file content to the given path.
|
||
|
|
// Creates a backup before writing. Uses temp-file-and-rename pattern.
|
||
|
|
// backupDir is the directory for the backup file (typically the exe directory).
|
||
|
|
Write(hf *HostsFile, backupDir string) error
|
||
|
|
}
|
||
|
|
|
||
|
|
// HostsFile represents the parsed content of a hosts file.
|
||
|
|
type HostsFile struct {
|
||
|
|
Path string
|
||
|
|
OriginalContent []string
|
||
|
|
PrefixContent []string
|
||
|
|
ManagedContent []string
|
||
|
|
PostfixContent []string
|
||
|
|
HasManagedBlock bool
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Sub-package: Managed Block Operations
|
||
|
|
|
||
|
|
```go
|
||
|
|
// AddEntries adds DNS entries to the managed content, removing any
|
||
|
|
// existing entries for the same hostnames first. Returns updated content.
|
||
|
|
func AddEntries(existing []string, entries []DNSEntry) []string
|
||
|
|
|
||
|
|
// RemoveByHostname removes all managed entries matching any of the
|
||
|
|
// specified hostnames. Returns updated content.
|
||
|
|
func RemoveByHostname(existing []string, hostnames []string) []string
|
||
|
|
|
||
|
|
// RemoveAll returns an empty slice (clears all managed entries).
|
||
|
|
func RemoveAll() []string
|
||
|
|
```
|
||
|
|
|
||
|
|
### Behavior Contract — Read
|
||
|
|
|
||
|
|
- Opens the file at `path` and reads all lines.
|
||
|
|
- Scans for start marker (`# DNSHelper <<-> START CONFIG`) and end marker (`# DNSHelper <->> END CONFIG`).
|
||
|
|
- If neither marker found: `HasManagedBlock = false`, all content goes to `PrefixContent`.
|
||
|
|
- If both markers found in correct order: splits content into Prefix, Managed, Postfix.
|
||
|
|
- **Corruption detection** (returns error):
|
||
|
|
- Start marker without end marker.
|
||
|
|
- End marker without start marker.
|
||
|
|
- End marker before start marker.
|
||
|
|
- Duplicate start or end markers.
|
||
|
|
- Uses sentinel values (not zero-index) for marker detection.
|
||
|
|
|
||
|
|
### Behavior Contract — Write
|
||
|
|
|
||
|
|
1. Creates backup: copies current hosts file to `<backupDir>/hosts.bak.<YYYYMMDD>-<4hex>`.
|
||
|
|
2. Assembles full file content: prefix + (managed block if non-empty) + postfix.
|
||
|
|
3. Compares assembled content with original — skips write if identical.
|
||
|
|
4. Creates temp file in same directory as hosts file: `.dns-helper-tmp-*`.
|
||
|
|
5. Writes content to temp file.
|
||
|
|
6. Sets temp file permissions to match original hosts file permissions.
|
||
|
|
7. Calls `Sync()` on temp file.
|
||
|
|
8. Closes temp file.
|
||
|
|
9. Calls `os.Rename(temp, hostsPath)`.
|
||
|
|
10. On rename success: deletes backup, returns nil.
|
||
|
|
11. On rename failure (including Windows retry 2-3x with 200ms delay): cleans up temp file, returns error. Backup is cleaned up after confirming original is intact.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Package: `platform`
|
||
|
|
|
||
|
|
Provides platform-specific hosts file location.
|
||
|
|
|
||
|
|
### Interface
|
||
|
|
|
||
|
|
```go
|
||
|
|
// GetHostsFilePath returns the absolute path to the system hosts file.
|
||
|
|
// Returns an error if the file does not exist or cannot be accessed.
|
||
|
|
func GetHostsFilePath() (string, error)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Platform Implementations
|
||
|
|
|
||
|
|
| Platform | Path | Detection |
|
||
|
|
|----------|------|-----------|
|
||
|
|
| Windows | `%SystemRoot%\System32\drivers\etc\hosts` (fallback: `C:\Windows\...`) | `os.LookupEnv("SystemRoot")` |
|
||
|
|
| Linux | `/etc/hosts` | Hardcoded |
|
||
|
|
| macOS | `/etc/hosts` | Hardcoded |
|
||
|
|
|
||
|
|
### Behavior Contract
|
||
|
|
|
||
|
|
- Returns error to caller on file-not-found or access error.
|
||
|
|
- **Must NOT** call `os.Exit()`, `log.Fatal()`, or any process-terminating function.
|
||
|
|
- Error messages include the path that was checked.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Package: `lockfile`
|
||
|
|
|
||
|
|
Serializes concurrent access to the hosts file.
|
||
|
|
|
||
|
|
### Interface
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Lock represents an acquired lock.
|
||
|
|
type Lock interface {
|
||
|
|
// Release deletes the lock file. Safe to call multiple times.
|
||
|
|
Release() error
|
||
|
|
}
|
||
|
|
|
||
|
|
// Acquire attempts to acquire a lock file in the specified directory.
|
||
|
|
// Retries for up to maxWait on contention. Breaks stale locks older
|
||
|
|
// than staleTimeout. Returns an error if the lock cannot be acquired.
|
||
|
|
func Acquire(dir string) (Lock, error)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Behavior Contract
|
||
|
|
|
||
|
|
- Lock file path: `<dir>/.dns-helper.lock`.
|
||
|
|
- Created atomically via `os.OpenFile` with `O_CREATE|O_EXCL`.
|
||
|
|
- PID written to lock file for debugging (not used for staleness).
|
||
|
|
- Staleness: lock file with `ModTime` older than 2 minutes is considered stale and removed.
|
||
|
|
- Retry: up to 2 seconds total, 200ms between attempts.
|
||
|
|
- Release: `os.Remove(lockPath)`. Idempotent (no error if already deleted).
|
||
|
|
- Lock acquired **after** DNS resolution, released **after** file write (or on error).
|