diff --git a/internal/service/dns_manager.go b/internal/service/dns_manager.go index 6d43c33..45cb9b0 100644 --- a/internal/service/dns_manager.go +++ b/internal/service/dns_manager.go @@ -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 +} diff --git a/internal/service/dns_manager_test.go b/internal/service/dns_manager_test.go new file mode 100644 index 0000000..a04fe17 --- /dev/null +++ b/internal/service/dns_manager_test.go @@ -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) + } +}