Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions internal/adapters/resolvconf/file.go
Original file line number Diff line number Diff line change
@@ -1 +1,182 @@
// Package resolvconf provides utilities to read and update system resolver configuration
// files (for example /etc/resolv.conf). It exposes adapters that parse resolver entries,
// preserve non-nameserver lines and ordering, and perform atomic writes (temp-file + rename).
// The package translates filesystem and validation failures into domain sentinel errors
// (e.g. ErrInvalidIP, ErrPermissionDenied, ErrIO) and is safe for concurrent use by higher-level services.
package resolvconf

import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"

"github.com/ilindan-dev/dns-manager/internal/core/domain"
"github.com/ilindan-dev/dns-manager/internal/core/ports"
)

const (
// nameserverPrefix contains the prefix for parsing and recording the DNS server.
nameserverPrefix = "nameserver "

// defaultPerms contains permissions for ease of coding.
defaultPerms = 0o644
)

// Compile check that FileAdapter implements ports.ResolvConf.
var _ ports.ResolvConf = (*FileAdapter)(nil)

// FileAdapter implements ports.ResolvConf to work with a local file.
type FileAdapter struct {
filePath string
logger slog.Logger
}

// NewFileAdapter creates a new adapter instance.
// Passing a path as a parameter allows for easy testing of the adapter on temporary files.
func NewFileAdapter(path string, logger slog.Logger) *FileAdapter {
return &FileAdapter{
filePath: path,
logger: logger,
}
}

// GetServers reads a file and returns a list of current DNS server IP addresses.
func (a *FileAdapter) GetServers(ctx context.Context) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}

file, err := os.Open(a.filePath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return []string{}, nil
}
return nil, a.mapError(err, "failed to open resolv.conf")
}
defer func() {
err := file.Close()
if err != nil {
a.logger.Error("failed to close resolv.conf file", "error", err)
}
}()

var servers []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, nameserverPrefix) {
ip := strings.TrimSpace(strings.TrimPrefix(line, nameserverPrefix))
if ip != "" {
servers = append(servers, ip)
}
}
}

if err := scanner.Err(); err != nil {
return nil, a.mapError(err, "failed to read resolv.conf")
}

return servers, nil
}

// RewriteServers atomically overwrites the file, saving the other settings.
func (a *FileAdapter) RewriteServers(ctx context.Context, addresses []string) error {
if err := ctx.Err(); err != nil {
return err
}

newContent, err := a.buildNewContent(addresses)
if err != nil {
return err
}

return a.atomicSave(newContent)
}

// buildNewContent reads the old file (if any) and generates an updated config.
func (a *FileAdapter) buildNewContent(addresses []string) ([]byte, error) {
var buffer bytes.Buffer

file, err := os.Open(a.filePath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, a.mapError(err, "failed to open resolv.conf for reading")
}

if err == nil {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, nameserverPrefix) {
buffer.WriteString(scanner.Text() + "\n")
}
}
if err := file.Close(); err != nil {
a.logger.Error("failed to close resolv.conf file", "error", err)
}
}

for _, ip := range addresses {
if _, err := fmt.Fprintf(&buffer, "%s%s\n", nameserverPrefix, ip); err != nil {
return nil, a.mapError(err, "failed to write to buffer")
}
}

return buffer.Bytes(), nil
}

// atomicSave implements a pattern of atomic file rewriting.
func (a *FileAdapter) atomicSave(data []byte) error {
dir := filepath.Dir(a.filePath)
tempFile, err := os.CreateTemp(dir, "resolv.conf.tmp.*")
if err != nil {
return a.mapError(err, "failed to create temp file")
}
tempPath := tempFile.Name()

defer func() {
err := os.Remove(tempPath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
a.logger.Error("failed to remove temp file", "error", err)
}
}()

if err := tempFile.Chmod(defaultPerms); err != nil {
_ = tempFile.Close()
return a.mapError(err, "failed to set permissions on temp file")
}

if _, err := tempFile.Write(data); err != nil {
_ = tempFile.Close()
return a.mapError(err, "failed to write to temp file")
}

if err := tempFile.Sync(); err != nil {
_ = tempFile.Close()
return a.mapError(err, "failed to sync temp file to disk")
}

if err := tempFile.Close(); err != nil {
return a.mapError(err, "failed to close temp file")
}

if err := os.Rename(tempPath, a.filePath); err != nil {
return a.mapError(err, "failed to atomically replace resolv.conf")
}

return nil
}

// mapError maps system fs/os errors to our domains.
func (a *FileAdapter) mapError(err error, msg string) error {
if os.IsPermission(err) {
return fmt.Errorf("%s: %w", msg, domain.ErrPermissionDenied)
}
return fmt.Errorf("%s: %w (%w)", msg, domain.ErrIO, err)
}
57 changes: 57 additions & 0 deletions internal/adapters/resolvconf/file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package resolvconf

import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)

// TestFileAdapter_RewriteAndGet verifies that FileAdapter.
// Rewrite atomically replaces the resolver file contents with the supplied nameserver entries and that FileAdapter.Get
// returns the same ordered list.
// The test uses a temporary file, asserts the final file contents and returned addresses,
// and ensures no partial/garbled writes occur (i.e., Write is atomic and preserves ordering).
func TestFileAdapter_RewriteAndGet(t *testing.T) {
dir := t.TempDir()

testFile := filepath.Join(dir, "resolv.conf")

nopLogger := slog.New(slog.NewTextHandler(io.Discard, nil))

adapter := NewFileAdapter(testFile, *nopLogger)
ctx := context.Background()

initialContent := []byte("search example.com\noptions ndots:5\nnameserver 1.1.1.1\n")
err := os.WriteFile(testFile, initialContent, 0o644)
if err != nil {
t.Fatalf("failed to prepare test file: %v", err)
}

newIPs := []string{"8.8.8.8", "8.8.4.4"}
err = adapter.RewriteServers(ctx, newIPs)
if err != nil {
t.Fatalf("RewriteServers failed: %v", err)
}

servers, err := adapter.GetServers(ctx)
if err != nil {
t.Fatalf("GetServers failed: %v", err)
}

if len(servers) != 2 || servers[0] != "8.8.8.8" || servers[1] != "8.8.4.4" {
t.Errorf("expected [8.8.8.8, 8.8.4.4], got %v", servers)
}

content, _ := os.ReadFile(testFile)
contentStr := string(content)
if !strings.Contains(contentStr, "search example.com") {
t.Errorf("lost 'search' directive, file content:\n%s", contentStr)
}
if strings.Contains(contentStr, "1.1.1.1") {
t.Errorf("old nameserver 1.1.1.1 was not removed")
}
}
Loading