Files
lolcathost/internal/daemon/hosts.go
T

290 lines
7.3 KiB
Go

// Package daemon implements the privileged daemon that manages /etc/hosts.
package daemon
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
const (
// HostsPath is the path to the system hosts file.
HostsPath = "/etc/hosts"
// BackupDir is the directory for hosts file backups.
BackupDir = "/var/backups/lolcathost"
// MaxBackups is the maximum number of backups to keep.
MaxBackups = 10
// Markers for the managed section.
markerStart = "# ========== LOLCATHOST MANAGED - DO NOT EDIT =========="
markerEnd = "# ========== END LOLCATHOST =========="
)
// entryRegex matches host entries in the managed section.
// Compiled once at package init for efficiency.
var entryRegex = regexp.MustCompile(`^(\S+)\s+(\S+)\s+#\s*lolcathost:(\S+)$`)
// HostEntry represents a single entry in the hosts file.
type HostEntry struct {
IP string
Domain string
Alias string
Enabled bool
}
// HostsManager handles reading and writing the hosts file.
type HostsManager struct {
hostsPath string
backupDir string
}
// NewHostsManager creates a new hosts manager.
func NewHostsManager() *HostsManager {
return &HostsManager{
hostsPath: HostsPath,
backupDir: BackupDir,
}
}
// WriteManagedEntries writes the managed entries to the hosts file.
func (m *HostsManager) WriteManagedEntries(entries []HostEntry) error {
// Create backup first
if err := m.CreateBackup(); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
// Read existing content
content, err := os.ReadFile(m.hostsPath)
if err != nil {
return fmt.Errorf("failed to read hosts file: %w", err)
}
// Remove existing managed section
newContent := m.removeManagedSection(string(content))
// Build new managed section
managedSection := m.buildManagedSection(entries)
// Append managed section
newContent = strings.TrimRight(newContent, "\n") + "\n\n" + managedSection
// Write atomically
if err := m.writeAtomic(newContent); err != nil {
return fmt.Errorf("failed to write hosts file: %w", err)
}
return nil
}
func (m *HostsManager) removeManagedSection(content string) string {
lines := strings.Split(content, "\n")
var result []string
inManagedSection := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == markerStart {
inManagedSection = true
continue
}
if trimmed == markerEnd {
inManagedSection = false
continue
}
if !inManagedSection {
result = append(result, line)
}
}
// Remove trailing empty lines
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
result = result[:len(result)-1]
}
return strings.Join(result, "\n")
}
func (m *HostsManager) buildManagedSection(entries []HostEntry) string {
var sb strings.Builder
sb.WriteString(markerStart)
sb.WriteString("\n")
for _, entry := range entries {
if entry.Enabled {
sb.WriteString(fmt.Sprintf("%s\t%s\t# lolcathost:%s\n", entry.IP, entry.Domain, entry.Alias))
}
}
sb.WriteString(markerEnd)
sb.WriteString("\n")
return sb.String()
}
func (m *HostsManager) writeAtomic(content string) error {
// Write to temp file first
tmpFile := m.hostsPath + ".tmp"
// #nosec G306 - Hosts file permissions are intentionally 0644
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
return err
}
// Rename atomically
if err := os.Rename(tmpFile, m.hostsPath); err != nil {
_ = os.Remove(tmpFile)
return err
}
return nil
}
// CreateBackup creates a backup of the current hosts file.
func (m *HostsManager) CreateBackup() error {
// #nosec G301 - Backup directory permissions are intentionally 0755
if err := os.MkdirAll(m.backupDir, 0755); err != nil {
return fmt.Errorf("failed to create backup directory: %w", err)
}
content, err := os.ReadFile(m.hostsPath) // #nosec G304 - Path is controlled by daemon, not user input
if err != nil {
return fmt.Errorf("failed to read hosts file: %w", err)
}
timestamp := time.Now().Format("20060102-150405")
backupPath := filepath.Join(m.backupDir, fmt.Sprintf("hosts.%s.bak", timestamp))
// #nosec G306 - Backup file permissions are intentionally 0644
if err := os.WriteFile(backupPath, content, 0644); err != nil {
return fmt.Errorf("failed to write backup: %w", err)
}
// Cleanup old backups
if err := m.cleanupBackups(); err != nil {
// Log but don't fail
fmt.Fprintf(os.Stderr, "warning: failed to cleanup backups: %v\n", err)
}
return nil
}
func (m *HostsManager) cleanupBackups() error {
entries, err := os.ReadDir(m.backupDir)
if err != nil {
return err
}
var backups []os.DirEntry
for _, entry := range entries {
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "hosts.") && strings.HasSuffix(entry.Name(), ".bak") {
backups = append(backups, entry)
}
}
if len(backups) <= MaxBackups {
return nil
}
// Sort by name (timestamp) descending
sort.Slice(backups, func(i, j int) bool {
return backups[i].Name() > backups[j].Name()
})
// Remove oldest backups
for i := MaxBackups; i < len(backups); i++ {
path := filepath.Join(m.backupDir, backups[i].Name())
_ = os.Remove(path)
}
return nil
}
// ListBackups returns a list of available backups.
func (m *HostsManager) ListBackups() ([]BackupInfo, error) {
entries, err := os.ReadDir(m.backupDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var backups []BackupInfo
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "hosts.") || !strings.HasSuffix(entry.Name(), ".bak") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
backups = append(backups, BackupInfo{
Name: entry.Name(),
Timestamp: info.ModTime().Unix(),
Size: info.Size(),
})
}
// Sort by timestamp descending
sort.Slice(backups, func(i, j int) bool {
return backups[i].Timestamp > backups[j].Timestamp
})
return backups, nil
}
// BackupInfo holds information about a backup file.
type BackupInfo struct {
Name string
Timestamp int64
Size int64
}
// GetBackupContent returns the content of a backup file.
func (m *HostsManager) GetBackupContent(name string) (string, error) {
// Validate backup name to prevent path traversal
if filepath.Base(name) != name || !strings.HasPrefix(name, "hosts.") || !strings.HasSuffix(name, ".bak") {
return "", fmt.Errorf("invalid backup name")
}
backupPath := filepath.Join(m.backupDir, name)
content, err := os.ReadFile(backupPath) // #nosec G304 - Path is validated above to prevent traversal
if err != nil {
return "", fmt.Errorf("failed to read backup: %w", err)
}
return string(content), nil
}
// RestoreBackup restores a backup by name.
func (m *HostsManager) RestoreBackup(name string) error {
// Validate backup name to prevent path traversal
if filepath.Base(name) != name || !strings.HasPrefix(name, "hosts.") || !strings.HasSuffix(name, ".bak") {
return fmt.Errorf("invalid backup name")
}
backupPath := filepath.Join(m.backupDir, name)
content, err := os.ReadFile(backupPath) // #nosec G304 - Path is validated above to prevent traversal
if err != nil {
return fmt.Errorf("failed to read backup: %w", err)
}
// Create a backup of current state before restoring
if err := m.CreateBackup(); err != nil {
return fmt.Errorf("failed to create backup before restore: %w", err)
}
if err := m.writeAtomic(string(content)); err != nil {
return fmt.Errorf("failed to restore backup: %w", err)
}
return nil
}