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
109 changes: 109 additions & 0 deletions internal/service/dns_manager.go
Original file line number Diff line number Diff line change
@@ -1 +1,110 @@
// Package service implements application-level services for DNS
// management. DNSManagerService provides concurrency-safe operations to
// add, remove and list nameservers using a ResolvConf adapter (defined
// in the ports package). The service validates and normalizes IP
// addresses, enforces duplicate checks, and returns domain sentinel
// errors for callers to handle (see internal/core/domain/errors.go).
package service

import (
"context"
"log/slog"
"net"
"sync"

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

// Check at compile time that DNSManagerService implements the interface ports.DNSManager.
var _ ports.DNSManager = (*DNSManagerService)(nil)

// DNSManagerService implements the domain logic of DNS management (ports.DNSManager).
type DNSManagerService struct {
resolvConf ports.ResolvConf
logger *slog.Logger

mu sync.RWMutex
}

// NewDNSManagerService creates a new instance of the service NewDNSManagerService.
func NewDNSManagerService(rc ports.ResolvConf, logger *slog.Logger) *DNSManagerService {
return &DNSManagerService{
resolvConf: rc,
logger: logger,
}
}

// AddDNSServer checks the IP, looks for duplicates, and atomically adds the server.
func (s *DNSManagerService) AddDNSServer(ctx context.Context, address string) error {
normalizedIP, err := s.validateAndNormalizeIP(address)
if err != nil {
return domain.ErrInvalidIP
}

s.mu.Lock()
defer s.mu.Unlock()

servers, err := s.resolvConf.GetServers(ctx)
if err != nil {
return err
}

for _, srv := range servers {
if srv == normalizedIP {
return domain.ErrAlreadyExists
}
}

servers = append(servers, normalizedIP)
return s.resolvConf.RewriteServers(ctx, servers)
}

// RemoveDNSServer deletes the IP address, if it exists.
func (s *DNSManagerService) RemoveDNSServer(ctx context.Context, address string) error {
normalizedIP, err := s.validateAndNormalizeIP(address)
if err != nil {
return domain.ErrInvalidIP
}

s.mu.Lock()
defer s.mu.Unlock()

servers, err := s.resolvConf.GetServers(ctx)
if err != nil {
return err
}

foundIdx := -1
for i, srv := range servers {
if srv == normalizedIP {
foundIdx = i
break
}
}

if foundIdx == -1 {
return domain.ErrNotFound
}

servers = append(servers[:foundIdx], servers[foundIdx+1:]...)

return s.resolvConf.RewriteServers(ctx, servers)
}

// ListDNSServers returns a list of current servers.
func (s *DNSManagerService) ListDNSServers(ctx context.Context) ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()

return s.resolvConf.GetServers(ctx)
}

// validateAndNormalizeIP checks the format and returns a canonical string (for example, removes extra zeros).
func (s *DNSManagerService) validateAndNormalizeIP(address string) (string, error) {
ip := net.ParseIP(address)
if ip == nil {
return "", domain.ErrInvalidIP
}
return ip.String(), nil
}
102 changes: 102 additions & 0 deletions internal/service/dns_manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package service

import (
"context"
"errors"
"log/slog"
"reflect"
"sync"
"testing"

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

// fakeResolvConf is a lightweight in-memory implementation of ports.ResolvConf
// used for unit tests. It records rewrite calls and allows injecting errors.
type fakeResolvConf struct {
mu sync.Mutex
servers []string
rewriteCalls [][]string
getErr error
rewriteErr error
}

func (f *fakeResolvConf) GetServers(_ context.Context) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.getErr != nil {
return nil, f.getErr
}
out := make([]string, len(f.servers))
copy(out, f.servers)
return out, nil
}

func (f *fakeResolvConf) RewriteServers(_ context.Context, servers []string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.rewriteErr != nil {
return f.rewriteErr
}
f.servers = make([]string, len(servers))
copy(f.servers, servers)
f.rewriteCalls = append(f.rewriteCalls, append([]string(nil), servers...))
return nil
}

func TestDNSManagerService_AddRemoveList(t *testing.T) {
ctx := context.Background()
fake := &fakeResolvConf{servers: []string{"1.1.1.1"}}
svc := NewDNSManagerService(fake, slog.Default())

// Add a new server
if err := svc.AddDNSServer(ctx, "8.8.8.8"); err != nil {
t.Fatalf("AddDNSServer failed: %v", err)
}
got, err := svc.ListDNSServers(ctx)
if err != nil {
t.Fatalf("ListDNSServers failed: %v", err)
}
expected := []string{"1.1.1.1", "8.8.8.8"}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("unexpected servers: got %v, want %v", got, expected)
}

// Adding duplicate should return ErrAlreadyExists
if err := svc.AddDNSServer(ctx, "8.8.8.8"); !errors.Is(err, domain.ErrAlreadyExists) {
t.Fatalf("expected ErrAlreadyExists; got %v", err)
}

// Remove existing
if err := svc.RemoveDNSServer(ctx, "8.8.8.8"); err != nil {
t.Fatalf("RemoveDNSServer failed: %v", err)
}
got, err = svc.ListDNSServers(ctx)
if err != nil {
t.Fatalf("ListDNSServers failed: %v", err)
}
if !reflect.DeepEqual(got, []string{"1.1.1.1"}) {
t.Fatalf("after remove, unexpected servers: %v", got)
}

// Removing missing should return ErrNotFound
if err := svc.RemoveDNSServer(ctx, "8.8.8.8"); !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("expected ErrNotFound; got %v", err)
}
}

func TestValidateAndNormalizeIP(t *testing.T) {
svc := NewDNSManagerService(&fakeResolvConf{}, slog.Default())

ip, err := svc.validateAndNormalizeIP("2001:db8::1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ip != "2001:db8::1" {
t.Fatalf("unexpected normalized IP: %s", ip)
}

if _, err := svc.validateAndNormalizeIP("not-an-ip"); !errors.Is(err, domain.ErrInvalidIP) {
t.Fatalf("expected ErrInvalidIP; got %v", err)
}
}
Loading