From f08c93cafc7f50932b6214fbfb06bfeaa11e309b Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Wed, 13 May 2026 01:42:27 +0300 Subject: [PATCH 01/13] build: setup project infrastructure, CI/CD and linters --- .github/workflows/ci.yml | 27 +++++++++ .gitignore | 27 +++++++++ .golangci.yaml | 86 ++++++++++++++++++++++++++++ Makefile | 27 +++++++++ README.md | 1 + cmd/client/main.go | 1 + cmd/server/main.go | 1 + go.mod | 15 +++++ go.sum | 38 ++++++++++++ internal/adapters/cli/cobra.go | 1 + internal/adapters/grpc/client.go | 1 + internal/adapters/grpc/server.go | 1 + internal/adapters/resolvconf/file.go | 1 + internal/core/domain/dnsserver.go | 1 + internal/core/ports/dnsmanager.go | 1 + internal/core/ports/resolvconf.go | 1 + internal/service/dns_manager.go | 1 + proto/dns/v1/dns.proto | 0 18 files changed, 231 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/client/main.go create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/adapters/cli/cobra.go create mode 100644 internal/adapters/grpc/client.go create mode 100644 internal/adapters/grpc/server.go create mode 100644 internal/adapters/resolvconf/file.go create mode 100644 internal/core/domain/dnsserver.go create mode 100644 internal/core/ports/dnsmanager.go create mode 100644 internal/core/ports/resolvconf.go create mode 100644 internal/service/dns_manager.go create mode 100644 proto/dns/v1/dns.proto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cd76989 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.26' + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + + - name: Run tests + run: go test -v -race ./... \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bac6db --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Binaries +bin/ +build/ + +# Test output and profiles +*.test +*.out +cover.html + +# Environment variables +.env +.env.local + +# IDEs and OS specific +.idea/ +.vscode/ +.DS_Store + +# Go workspaces +go.work +go.work.sum + +# Vendor directory +vendor/ + +# MD documentation +TASK.md \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..6f2861e --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,86 @@ +version: "2" + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +formatters: + enable: + - gofumpt + - goimports + settings: + goimports: + local-prefixes: + - github.com/ilindan-dev/dns-manager + +linters: + default: none + + enable: + - errcheck + - govet + - staticcheck + - unused + - ineffassign + + - bodyclose + - sqlclosecheck + - rowserrcheck + - copyloopvar + - makezero + - prealloc + + - gosec + - gocritic + - errorlint + - nilerr + - nilnil + - revive + - lll + - gocyclo + - gocognit + - funlen + - nestif + + settings: + lll: + line-length: 120 + tab-width: 4 + gocyclo: + min-complexity: 10 + gocognit: + min-complexity: 15 + nestif: + min-complexity: 4 + funlen: + lines: 70 + statements: 40 + + gocritic: + enabled-tags: + - diagnostic + - style + - performance + + revive: + enable-default-rules: true + rules: + - name: exported + disabled: true + - name: package-comments + disabled: true + + exclusions: + generated: lax + rules: + - path: _test\.go + linters: + - errcheck + - gosec + - bodyclose + - prealloc + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d7194a9 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: unit-test +unit-test: + go test -v -race ./... + +.PHONY: lint +lint: + golangci-lint run ./... + +.PHONY: tools +tools: + go install github.com/yoheimuta/protolint/cmd/protolint@latest + go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $$(go env GOPATH)/bin v2.12.0 + @echo "checking protobuf compiler, if it fails follow guide at https://protobuf.dev/installation/" + @which -s protoc && echo OK || exit 1 + +.PHONY: protolint +protolint: + protolint . + +.PHONY: protobuf +protobuf: + protoc --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + proto/dns/v1/dns.proto \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..730a764 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +package dns_manager diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1 @@ +package main diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1 @@ +package main diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ed4a2d --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/ilindan-dev/dns-manager + +go 1.26.2 + +require ( + google.golang.org/grpc v1.81.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5045847 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/adapters/cli/cobra.go b/internal/adapters/cli/cobra.go new file mode 100644 index 0000000..7f1e458 --- /dev/null +++ b/internal/adapters/cli/cobra.go @@ -0,0 +1 @@ +package cli diff --git a/internal/adapters/grpc/client.go b/internal/adapters/grpc/client.go new file mode 100644 index 0000000..21e034e --- /dev/null +++ b/internal/adapters/grpc/client.go @@ -0,0 +1 @@ +package grpc diff --git a/internal/adapters/grpc/server.go b/internal/adapters/grpc/server.go new file mode 100644 index 0000000..21e034e --- /dev/null +++ b/internal/adapters/grpc/server.go @@ -0,0 +1 @@ +package grpc diff --git a/internal/adapters/resolvconf/file.go b/internal/adapters/resolvconf/file.go new file mode 100644 index 0000000..022894d --- /dev/null +++ b/internal/adapters/resolvconf/file.go @@ -0,0 +1 @@ +package resolvconf diff --git a/internal/core/domain/dnsserver.go b/internal/core/domain/dnsserver.go new file mode 100644 index 0000000..4188b5a --- /dev/null +++ b/internal/core/domain/dnsserver.go @@ -0,0 +1 @@ +package domain diff --git a/internal/core/ports/dnsmanager.go b/internal/core/ports/dnsmanager.go new file mode 100644 index 0000000..808de88 --- /dev/null +++ b/internal/core/ports/dnsmanager.go @@ -0,0 +1 @@ +package ports diff --git a/internal/core/ports/resolvconf.go b/internal/core/ports/resolvconf.go new file mode 100644 index 0000000..808de88 --- /dev/null +++ b/internal/core/ports/resolvconf.go @@ -0,0 +1 @@ +package ports diff --git a/internal/service/dns_manager.go b/internal/service/dns_manager.go new file mode 100644 index 0000000..aed2454 --- /dev/null +++ b/internal/service/dns_manager.go @@ -0,0 +1 @@ +package usecase diff --git a/proto/dns/v1/dns.proto b/proto/dns/v1/dns.proto new file mode 100644 index 0000000..e69de29 From 3e70fec54ea02446007dcf4892e90cdb40d80599 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Wed, 13 May 2026 01:42:49 +0300 Subject: [PATCH 02/13] feat(api): define v1 protobuf contract and project layout --- .github/workflows/ci.yml | 2 +- internal/service/dns_manager.go | 2 +- proto/dns/v1/dns.pb.go | 244 ++++++++++++++++++++++++++++++++ proto/dns/v1/dns.proto | 57 ++++++++ proto/dns/v1/dns_grpc.pb.go | 236 ++++++++++++++++++++++++++++++ 5 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 proto/dns/v1/dns.pb.go create mode 100644 proto/dns/v1/dns_grpc.pb.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd76989..7266c3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,4 @@ jobs: version: latest - name: Run tests - run: go test -v -race ./... \ No newline at end of file + run: make unit-test \ No newline at end of file diff --git a/internal/service/dns_manager.go b/internal/service/dns_manager.go index aed2454..6d43c33 100644 --- a/internal/service/dns_manager.go +++ b/internal/service/dns_manager.go @@ -1 +1 @@ -package usecase +package service diff --git a/proto/dns/v1/dns.pb.go b/proto/dns/v1/dns.pb.go new file mode 100644 index 0000000..9e4be9c --- /dev/null +++ b/proto/dns/v1/dns.pb.go @@ -0,0 +1,244 @@ +// DNS Manager API (v1) +// +// This gRPC definition provides a minimal interface for managing the +// system's DNS servers. It is written to match the exercise described +// in TASK.md (see repository root). The server-side implementation is +// expected to apply changes to /etc/resolv.conf and validate input. +// Authentication is intentionally omitted per the task requirements. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: proto/dns/v1/dns.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// AddDNSServerRequest carries a single DNS server address. Use a +// standard textual representation (IPv4/IPv6). The server should +// validate syntax and reject malformed addresses. +type AddDNSServerRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` // textual IP, e.g. "192.0.2.53" or "2001:db8::53" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddDNSServerRequest) Reset() { + *x = AddDNSServerRequest{} + mi := &file_proto_dns_v1_dns_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddDNSServerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddDNSServerRequest) ProtoMessage() {} + +func (x *AddDNSServerRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_dns_v1_dns_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddDNSServerRequest.ProtoReflect.Descriptor instead. +func (*AddDNSServerRequest) Descriptor() ([]byte, []int) { + return file_proto_dns_v1_dns_proto_rawDescGZIP(), []int{0} +} + +func (x *AddDNSServerRequest) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +// RemoveDNSServerRequest identifies a DNS server address to remove. +type RemoveDNSServerRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` // must match an existing entry to be removed + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveDNSServerRequest) Reset() { + *x = RemoveDNSServerRequest{} + mi := &file_proto_dns_v1_dns_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveDNSServerRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveDNSServerRequest) ProtoMessage() {} + +func (x *RemoveDNSServerRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_dns_v1_dns_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveDNSServerRequest.ProtoReflect.Descriptor instead. +func (*RemoveDNSServerRequest) Descriptor() ([]byte, []int) { + return file_proto_dns_v1_dns_proto_rawDescGZIP(), []int{1} +} + +func (x *RemoveDNSServerRequest) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +// ListDNSServersResponse contains the ordered list of resolver +// addresses. The order should reflect the order they appear in +// /etc/resolv.conf. +type ListDNSServersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Addresses []string `protobuf:"bytes,1,rep,name=addresses,proto3" json:"addresses,omitempty"` // ordered list of nameserver addresses + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDNSServersResponse) Reset() { + *x = ListDNSServersResponse{} + mi := &file_proto_dns_v1_dns_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDNSServersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDNSServersResponse) ProtoMessage() {} + +func (x *ListDNSServersResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_dns_v1_dns_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDNSServersResponse.ProtoReflect.Descriptor instead. +func (*ListDNSServersResponse) Descriptor() ([]byte, []int) { + return file_proto_dns_v1_dns_proto_rawDescGZIP(), []int{2} +} + +func (x *ListDNSServersResponse) GetAddresses() []string { + if x != nil { + return x.Addresses + } + return nil +} + +var File_proto_dns_v1_dns_proto protoreflect.FileDescriptor + +const file_proto_dns_v1_dns_proto_rawDesc = "" + + "\n" + + "\x16proto/dns/v1/dns.proto\x12\x06dns.v1\x1a\x1bgoogle/protobuf/empty.proto\"/\n" + + "\x13AddDNSServerRequest\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\"2\n" + + "\x16RemoveDNSServerRequest\x12\x18\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\"6\n" + + "\x16ListDNSServersResponse\x12\x1c\n" + + "\taddresses\x18\x01 \x03(\tR\taddresses2\xe6\x01\n" + + "\n" + + "DNSManager\x12C\n" + + "\fAddDNSServer\x12\x1b.dns.v1.AddDNSServerRequest\x1a\x16.google.protobuf.Empty\x12I\n" + + "\x0fRemoveDNSServer\x12\x1e.dns.v1.RemoveDNSServerRequest\x1a\x16.google.protobuf.Empty\x12H\n" + + "\x0eListDNSServers\x12\x16.google.protobuf.Empty\x1a\x1e.dns.v1.ListDNSServersResponseB1Z/github.com/ilindan-dev/dns-manager/proto/dns/v1b\x06proto3" + +var ( + file_proto_dns_v1_dns_proto_rawDescOnce sync.Once + file_proto_dns_v1_dns_proto_rawDescData []byte +) + +func file_proto_dns_v1_dns_proto_rawDescGZIP() []byte { + file_proto_dns_v1_dns_proto_rawDescOnce.Do(func() { + file_proto_dns_v1_dns_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_dns_v1_dns_proto_rawDesc), len(file_proto_dns_v1_dns_proto_rawDesc))) + }) + return file_proto_dns_v1_dns_proto_rawDescData +} + +var file_proto_dns_v1_dns_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_proto_dns_v1_dns_proto_goTypes = []any{ + (*AddDNSServerRequest)(nil), // 0: dns.v1.AddDNSServerRequest + (*RemoveDNSServerRequest)(nil), // 1: dns.v1.RemoveDNSServerRequest + (*ListDNSServersResponse)(nil), // 2: dns.v1.ListDNSServersResponse + (*emptypb.Empty)(nil), // 3: google.protobuf.Empty +} +var file_proto_dns_v1_dns_proto_depIdxs = []int32{ + 0, // 0: dns.v1.DNSManager.AddDNSServer:input_type -> dns.v1.AddDNSServerRequest + 1, // 1: dns.v1.DNSManager.RemoveDNSServer:input_type -> dns.v1.RemoveDNSServerRequest + 3, // 2: dns.v1.DNSManager.ListDNSServers:input_type -> google.protobuf.Empty + 3, // 3: dns.v1.DNSManager.AddDNSServer:output_type -> google.protobuf.Empty + 3, // 4: dns.v1.DNSManager.RemoveDNSServer:output_type -> google.protobuf.Empty + 2, // 5: dns.v1.DNSManager.ListDNSServers:output_type -> dns.v1.ListDNSServersResponse + 3, // [3:6] is the sub-list for method output_type + 0, // [0:3] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_proto_dns_v1_dns_proto_init() } +func file_proto_dns_v1_dns_proto_init() { + if File_proto_dns_v1_dns_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_dns_v1_dns_proto_rawDesc), len(file_proto_dns_v1_dns_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_dns_v1_dns_proto_goTypes, + DependencyIndexes: file_proto_dns_v1_dns_proto_depIdxs, + MessageInfos: file_proto_dns_v1_dns_proto_msgTypes, + }.Build() + File_proto_dns_v1_dns_proto = out.File + file_proto_dns_v1_dns_proto_goTypes = nil + file_proto_dns_v1_dns_proto_depIdxs = nil +} diff --git a/proto/dns/v1/dns.proto b/proto/dns/v1/dns.proto index e69de29..7584e54 100644 --- a/proto/dns/v1/dns.proto +++ b/proto/dns/v1/dns.proto @@ -0,0 +1,57 @@ +// DNS Manager API (v1) +// +// This gRPC definition provides a minimal interface for managing the +// system's DNS servers. It is written to match the exercise described +// in TASK.md (see repository root). The server-side implementation is +// expected to apply changes to /etc/resolv.conf and validate input. +// Authentication is intentionally omitted per the task requirements. + +syntax = "proto3"; + +package dns.v1; + +import "google/protobuf/empty.proto"; + +option go_package = "github.com/ilindan-dev/dns-manager/proto/dns/v1"; + +// DNSManager exposes simple operations to add, remove and list DNS +// server addresses. The service assumes the server process will +// translate these RPCs into updates to /etc/resolv.conf and handle +// invalid input gracefully. +service DNSManager { + + // AddDNSServer adds a single DNS server address (typically an IP + // like "8.8.8.8") to the system resolver configuration. The server + // must validate the address format and return an error for invalid + // values. No payload is returned on success (google.protobuf.Empty). + rpc AddDNSServer(AddDNSServerRequest) returns (google.protobuf.Empty); + + // RemoveDNSServer removes the given DNS server address from the + // resolver configuration. If the address is not present, the server + // should return a clear, recoverable error. + rpc RemoveDNSServer(RemoveDNSServerRequest) returns (google.protobuf.Empty); + + // ListDNSServers returns the currently configured resolver addresses. + // This maps directly to the lines in /etc/resolv.conf that specify + // "nameserver" entries. + rpc ListDNSServers(google.protobuf.Empty) returns (ListDNSServersResponse); +} + +// AddDNSServerRequest carries a single DNS server address. Use a +// standard textual representation (IPv4/IPv6). The server should +// validate syntax and reject malformed addresses. +message AddDNSServerRequest { + string address = 1; // textual IP, e.g. "192.0.2.53" or "2001:db8::53" +} + +// RemoveDNSServerRequest identifies a DNS server address to remove. +message RemoveDNSServerRequest { + string address = 1; // must match an existing entry to be removed +} + +// ListDNSServersResponse contains the ordered list of resolver +// addresses. The order should reflect the order they appear in +// /etc/resolv.conf. +message ListDNSServersResponse { + repeated string addresses = 1; // ordered list of nameserver addresses +} diff --git a/proto/dns/v1/dns_grpc.pb.go b/proto/dns/v1/dns_grpc.pb.go new file mode 100644 index 0000000..5e3eb49 --- /dev/null +++ b/proto/dns/v1/dns_grpc.pb.go @@ -0,0 +1,236 @@ +// DNS Manager API (v1) +// +// This gRPC definition provides a minimal interface for managing the +// system's DNS servers. It is written to match the exercise described +// in TASK.md (see repository root). The server-side implementation is +// expected to apply changes to /etc/resolv.conf and validate input. +// Authentication is intentionally omitted per the task requirements. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.21.12 +// source: proto/dns/v1/dns.proto + +package v1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + DNSManager_AddDNSServer_FullMethodName = "/dns.v1.DNSManager/AddDNSServer" + DNSManager_RemoveDNSServer_FullMethodName = "/dns.v1.DNSManager/RemoveDNSServer" + DNSManager_ListDNSServers_FullMethodName = "/dns.v1.DNSManager/ListDNSServers" +) + +// DNSManagerClient is the client API for DNSManager service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// DNSManager exposes simple operations to add, remove and list DNS +// server addresses. The service assumes the server process will +// translate these RPCs into updates to /etc/resolv.conf and handle +// invalid input gracefully. +type DNSManagerClient interface { + // AddDNSServer adds a single DNS server address (typically an IP + // like "8.8.8.8") to the system resolver configuration. The server + // must validate the address format and return an error for invalid + // values. No payload is returned on success (google.protobuf.Empty). + AddDNSServer(ctx context.Context, in *AddDNSServerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // RemoveDNSServer removes the given DNS server address from the + // resolver configuration. If the address is not present, the server + // should return a clear, recoverable error. + RemoveDNSServer(ctx context.Context, in *RemoveDNSServerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // ListDNSServers returns the currently configured resolver addresses. + // This maps directly to the lines in /etc/resolv.conf that specify + // "nameserver" entries. + ListDNSServers(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListDNSServersResponse, error) +} + +type dNSManagerClient struct { + cc grpc.ClientConnInterface +} + +func NewDNSManagerClient(cc grpc.ClientConnInterface) DNSManagerClient { + return &dNSManagerClient{cc} +} + +func (c *dNSManagerClient) AddDNSServer(ctx context.Context, in *AddDNSServerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DNSManager_AddDNSServer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dNSManagerClient) RemoveDNSServer(ctx context.Context, in *RemoveDNSServerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, DNSManager_RemoveDNSServer_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dNSManagerClient) ListDNSServers(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*ListDNSServersResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListDNSServersResponse) + err := c.cc.Invoke(ctx, DNSManager_ListDNSServers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DNSManagerServer is the server API for DNSManager service. +// All implementations must embed UnimplementedDNSManagerServer +// for forward compatibility. +// +// DNSManager exposes simple operations to add, remove and list DNS +// server addresses. The service assumes the server process will +// translate these RPCs into updates to /etc/resolv.conf and handle +// invalid input gracefully. +type DNSManagerServer interface { + // AddDNSServer adds a single DNS server address (typically an IP + // like "8.8.8.8") to the system resolver configuration. The server + // must validate the address format and return an error for invalid + // values. No payload is returned on success (google.protobuf.Empty). + AddDNSServer(context.Context, *AddDNSServerRequest) (*emptypb.Empty, error) + // RemoveDNSServer removes the given DNS server address from the + // resolver configuration. If the address is not present, the server + // should return a clear, recoverable error. + RemoveDNSServer(context.Context, *RemoveDNSServerRequest) (*emptypb.Empty, error) + // ListDNSServers returns the currently configured resolver addresses. + // This maps directly to the lines in /etc/resolv.conf that specify + // "nameserver" entries. + ListDNSServers(context.Context, *emptypb.Empty) (*ListDNSServersResponse, error) + mustEmbedUnimplementedDNSManagerServer() +} + +// UnimplementedDNSManagerServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDNSManagerServer struct{} + +func (UnimplementedDNSManagerServer) AddDNSServer(context.Context, *AddDNSServerRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method AddDNSServer not implemented") +} +func (UnimplementedDNSManagerServer) RemoveDNSServer(context.Context, *RemoveDNSServerRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method RemoveDNSServer not implemented") +} +func (UnimplementedDNSManagerServer) ListDNSServers(context.Context, *emptypb.Empty) (*ListDNSServersResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListDNSServers not implemented") +} +func (UnimplementedDNSManagerServer) mustEmbedUnimplementedDNSManagerServer() {} +func (UnimplementedDNSManagerServer) testEmbeddedByValue() {} + +// UnsafeDNSManagerServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DNSManagerServer will +// result in compilation errors. +type UnsafeDNSManagerServer interface { + mustEmbedUnimplementedDNSManagerServer() +} + +func RegisterDNSManagerServer(s grpc.ServiceRegistrar, srv DNSManagerServer) { + // If the following call panics, it indicates UnimplementedDNSManagerServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DNSManager_ServiceDesc, srv) +} + +func _DNSManager_AddDNSServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddDNSServerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DNSManagerServer).AddDNSServer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DNSManager_AddDNSServer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DNSManagerServer).AddDNSServer(ctx, req.(*AddDNSServerRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DNSManager_RemoveDNSServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RemoveDNSServerRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DNSManagerServer).RemoveDNSServer(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DNSManager_RemoveDNSServer_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DNSManagerServer).RemoveDNSServer(ctx, req.(*RemoveDNSServerRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DNSManager_ListDNSServers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(emptypb.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DNSManagerServer).ListDNSServers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DNSManager_ListDNSServers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DNSManagerServer).ListDNSServers(ctx, req.(*emptypb.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +// DNSManager_ServiceDesc is the grpc.ServiceDesc for DNSManager service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DNSManager_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "dns.v1.DNSManager", + HandlerType: (*DNSManagerServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "AddDNSServer", + Handler: _DNSManager_AddDNSServer_Handler, + }, + { + MethodName: "RemoveDNSServer", + Handler: _DNSManager_RemoveDNSServer_Handler, + }, + { + MethodName: "ListDNSServers", + Handler: _DNSManager_ListDNSServers_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/dns/v1/dns.proto", +} From df6871c7c5843a467c11ac4a26d5d875d6c7cbc2 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Wed, 13 May 2026 22:58:09 +0300 Subject: [PATCH 03/13] feat(domain): add sentinel errors used across domain logic --- internal/core/domain/dnsserver.go | 1 - internal/core/domain/errors.go | 34 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) delete mode 100644 internal/core/domain/dnsserver.go create mode 100644 internal/core/domain/errors.go diff --git a/internal/core/domain/dnsserver.go b/internal/core/domain/dnsserver.go deleted file mode 100644 index 4188b5a..0000000 --- a/internal/core/domain/dnsserver.go +++ /dev/null @@ -1 +0,0 @@ -package domain diff --git a/internal/core/domain/errors.go b/internal/core/domain/errors.go new file mode 100644 index 0000000..a9daa5c --- /dev/null +++ b/internal/core/domain/errors.go @@ -0,0 +1,34 @@ +// Package domain defines domain-level sentinel errors used by the core +// DNS manager logic. These exported error variables are intended for +// callers to compare using errors.Is so semantics remain stable across +// packages and versions. +package domain + +import "errors" + +// Sentinel errors used across domain logic. +var ( + // ErrInvalidIP indicates the provided DNS server address has an invalid + // textual IP format (neither a valid IPv4 nor IPv6). Use when validating + // client input for AddDNSServer. + ErrInvalidIP = errors.New("invalid IP address format") + + // ErrAlreadyExists signals an attempt to add a nameserver that is + // already present in the resolver configuration. Returning this allows + // callers to treat duplicate-add attempts as a recoverable, explicit + // condition instead of a generic success/failure. + ErrAlreadyExists = errors.New("DNS server already exists") + + // ErrNotFound indicates the requested DNS server address was not found + // when attempting removal. + ErrNotFound = errors.New("DNS server not found") + + // ErrPermissionDenied indicates the process lacks permissions to modify + // the resolver configuration (for example, writing /etc/resolv.conf). + ErrPermissionDenied = errors.New("permission denied while accessing resolver configuration") + + // ErrIO represents an unexpected I/O error while reading or writing the + // resolver configuration. When returning ErrIO, wrap the underlying + // error with fmt.Errorf("%w") so callers can inspect the cause. + ErrIO = errors.New("I/O error while accessing resolver configuration") +) From dd8004fd550be59ba742540d452e8712e81c10a2 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Wed, 13 May 2026 22:59:06 +0300 Subject: [PATCH 04/13] feat(ports): defines the contracts to interact with external adapters --- internal/core/ports/dnsmanager.go | 18 ++++++++++++++++++ internal/core/ports/resolvconf.go | 14 ++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/internal/core/ports/dnsmanager.go b/internal/core/ports/dnsmanager.go index 808de88..3da5f11 100644 --- a/internal/core/ports/dnsmanager.go +++ b/internal/core/ports/dnsmanager.go @@ -1 +1,19 @@ +// Package ports defines the contracts that the core logic +// requires to interact with external adapters. package ports + +import "context" + +// DNSManager defines a layer of use cases for managing DNS servers. +type DNSManager interface { + // AddDNSServer checks the validity of the IP address and adds it to the configuration, + // if it is not already there. + AddDNSServer(ctx context.Context, address string) error + + // RemoveDNSServer removes the IP address from the configuration. + // Should return an error if the address is not found. + RemoveDNSServer(ctx context.Context, address string) error + + // ListDNSServers returns a list of current DNS servers, preserving the original order. + ListDNSServers(ctx context.Context) ([]string, error) +} diff --git a/internal/core/ports/resolvconf.go b/internal/core/ports/resolvconf.go index 808de88..fc746e1 100644 --- a/internal/core/ports/resolvconf.go +++ b/internal/core/ports/resolvconf.go @@ -1 +1,15 @@ package ports + +import "context" + +// ResolvConf provides atomic operations for managing the system's resolver configuration. +type ResolvConf interface { + // GetServers reads the configuration and returns the ordered list + // of currently configured nameserver IPs. + GetServers(ctx context.Context) ([]string, error) + + // RewriteServers atomically replaces all existing nameservers with the provided list. + // The implementation MUST preserve all non-nameserver directives (e.g., search, options) + // and guarantee "all-or-nothing" execution to prevent configuration corruption. + RewriteServers(ctx context.Context, addresses []string) error +} From f9338eea369aa12686e207eca08c646c190fff35 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 00:11:21 +0300 Subject: [PATCH 05/13] feat(resolvconf): implements utilities to read and update system resolver configuration and test their --- internal/adapters/resolvconf/file.go | 181 ++++++++++++++++++++++ internal/adapters/resolvconf/file_test.go | 57 +++++++ 2 files changed, 238 insertions(+) create mode 100644 internal/adapters/resolvconf/file_test.go diff --git a/internal/adapters/resolvconf/file.go b/internal/adapters/resolvconf/file.go index 022894d..592632f 100644 --- a/internal/adapters/resolvconf/file.go +++ b/internal/adapters/resolvconf/file.go @@ -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) +} diff --git a/internal/adapters/resolvconf/file_test.go b/internal/adapters/resolvconf/file_test.go new file mode 100644 index 0000000..ea7d2bd --- /dev/null +++ b/internal/adapters/resolvconf/file_test.go @@ -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") + } +} From 405ca9a270e99eb64fcf30463943cbdea2745070 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 00:30:29 +0300 Subject: [PATCH 06/13] feat(service): implement DNSManager and test that --- internal/service/dns_manager.go | 109 +++++++++++++++++++++++++++ internal/service/dns_manager_test.go | 102 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 internal/service/dns_manager_test.go 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) + } +} From 71ed0fd4aabba0dc872fcd39408c1ab8b991a6b2 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 01:44:26 +0300 Subject: [PATCH 07/13] feat(grpc): add gRPC providers for client and server, test server provider --- internal/adapters/grpc/client.go | 69 +++++++++++ internal/adapters/grpc/server.go | 96 ++++++++++++++++ internal/adapters/grpc/server_test.go | 157 ++++++++++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 internal/adapters/grpc/server_test.go diff --git a/internal/adapters/grpc/client.go b/internal/adapters/grpc/client.go index 21e034e..7a08e04 100644 --- a/internal/adapters/grpc/client.go +++ b/internal/adapters/grpc/client.go @@ -1 +1,70 @@ package grpc + +import ( + "context" + "fmt" + "io" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/ilindan-dev/dns-manager/internal/core/ports" + + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" +) + +var ( + // Compile check that Client implements ports.DNSManager. + _ ports.DNSManager = (*Client)(nil) + // Compile check that Client implements io.Closer . + _ io.Closer = (*Client)(nil) +) + +// Client is a thin wrapper around the generated pb.DNSManagerClient that manages +// the underlying connection. +type Client struct { + conn *grpc.ClientConn + api pb.DNSManagerClient +} + +// NewClient connects to target and returns a Client. +func NewClient(target string) (*Client, error) { + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("failed to connect to gRPC server at %s: %w", target, err) + } + + return &Client{ + conn: conn, + api: pb.NewDNSManagerClient(conn), + }, nil +} + +// Close closes the gRPC connection. +func (c *Client) Close() error { + return c.conn.Close() +} + +// AddDNSServer sends a request to add a DNS server. +func (c *Client) AddDNSServer(ctx context.Context, address string) error { + req := &pb.AddDNSServerRequest{Address: address} + _, err := c.api.AddDNSServer(ctx, req) + return err +} + +// RemoveDNSServer sends a request to delete the DNS server. +func (c *Client) RemoveDNSServer(ctx context.Context, address string) error { + req := &pb.RemoveDNSServerRequest{Address: address} + _, err := c.api.RemoveDNSServer(ctx, req) + return err +} + +// ListDNSServers requests a list of current DNS servers. +func (c *Client) ListDNSServers(ctx context.Context) ([]string, error) { + resp, err := c.api.ListDNSServers(ctx, &emptypb.Empty{}) + if err != nil { + return nil, err + } + return resp.Addresses, nil +} diff --git a/internal/adapters/grpc/server.go b/internal/adapters/grpc/server.go index 21e034e..f30e377 100644 --- a/internal/adapters/grpc/server.go +++ b/internal/adapters/grpc/server.go @@ -1 +1,97 @@ +// Package grpc provides a gRPC adapter for the DNS manager service. +// It implements the generated protobuf server (pb.DNSManagerServer), +// maps core domain errors to gRPC status codes, and supplies a small +// client wrapper used by tests and callers. package grpc + +import ( + "context" + "errors" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/ilindan-dev/dns-manager/internal/core/domain" + "github.com/ilindan-dev/dns-manager/internal/core/ports" + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" +) + +// Compile check that Server implements pb.DNSManagerServer. +var _ pb.DNSManagerServer = (*Server)(nil) + +// Server implements pb.DNSManagerServer and delegates requests to ports.DNSManager. +// It performs request validation and translates domain errors to gRPC status codes. +type Server struct { + pb.UnimplementedDNSManagerServer + svc ports.DNSManager +} + +// NewServer returns a Server that delegates business logic to the provided DNSManager. +func NewServer(svc ports.DNSManager) *Server { + return &Server{ + svc: svc, + } +} + +// AddDNSServer validates the request and calls svc.AddDNSServer. +// Returns InvalidArgument for empty address; domain errors are mapped to gRPC codes. +func (s *Server) AddDNSServer(ctx context.Context, req *pb.AddDNSServerRequest) (*emptypb.Empty, error) { + if req.Address == "" { + return nil, status.Error(codes.InvalidArgument, "address cannot be empty") + } + + err := s.svc.AddDNSServer(ctx, req.Address) + if err != nil { + return nil, mapError(err) + } + + return &emptypb.Empty{}, nil +} + +// RemoveDNSServer validates the request and calls svc.RemoveDNSServer. +// Returns InvalidArgument for empty address; domain errors are mapped to gRPC codes. +func (s *Server) RemoveDNSServer(ctx context.Context, req *pb.RemoveDNSServerRequest) (*emptypb.Empty, error) { + if req.Address == "" { + return nil, status.Error(codes.InvalidArgument, "address cannot be empty") + } + + err := s.svc.RemoveDNSServer(ctx, req.Address) + if err != nil { + return nil, mapError(err) + } + + return &emptypb.Empty{}, nil +} + +// ListDNSServers validates the request and calls svc.ListDNSServers. +// Returns domain errors are mapped to gRPC codes. +func (s *Server) ListDNSServers(ctx context.Context, _ *emptypb.Empty) (*pb.ListDNSServersResponse, error) { + servers, err := s.svc.ListDNSServers(ctx) + if err != nil { + return nil, mapError(err) + } + + return &pb.ListDNSServersResponse{ + Addresses: servers, + }, nil +} + +// mapError converts known domain errors into appropriate gRPC status codes, +// returning user-friendly messages for client responses. +func mapError(err error) error { + switch { + case errors.Is(err, domain.ErrInvalidIP): + return status.Errorf(codes.InvalidArgument, "validation failed: %v", err) + case errors.Is(err, domain.ErrAlreadyExists): + return status.Errorf(codes.AlreadyExists, "conflict: %v", err) + case errors.Is(err, domain.ErrNotFound): + return status.Errorf(codes.NotFound, "not found: %v", err) + case errors.Is(err, domain.ErrPermissionDenied): + return status.Errorf(codes.PermissionDenied, "system error: %v", err) + case errors.Is(err, domain.ErrIO): + return status.Error(codes.Internal, "internal server error while accessing resolver config") + default: + return status.Errorf(codes.Unknown, "unknown error: %v", err) + } +} diff --git a/internal/adapters/grpc/server_test.go b/internal/adapters/grpc/server_test.go new file mode 100644 index 0000000..f400b88 --- /dev/null +++ b/internal/adapters/grpc/server_test.go @@ -0,0 +1,157 @@ +package grpc + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/ilindan-dev/dns-manager/internal/core/domain" + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" +) + +const bufSize = 1024 * 1024 + +// mockDNSManager implements the ports.DNSManager interface for deterministic tests. +type mockDNSManager struct { + addErr error + removeErr error + listRes []string + listErr error +} + +func (m *mockDNSManager) AddDNSServer(_ context.Context, _ string) error { return m.addErr } +func (m *mockDNSManager) RemoveDNSServer(_ context.Context, _ string) error { + return m.removeErr +} + +func (m *mockDNSManager) ListDNSServers(_ context.Context) ([]string, error) { + return m.listRes, m.listErr +} + +// setupTestServer starts a bufconn-backed gRPC server, registers NewServer(mockSvc), +// returns a pb.DNSManagerClient connected to it and a cleanup func (call via defer). +func setupTestServer(t *testing.T, mockSvc *mockDNSManager) (client pb.DNSManagerClient, closer func()) { + lis := bufconn.Listen(bufSize) + s := grpc.NewServer() + + pb.RegisterDNSManagerServer(s, NewServer(mockSvc)) + + go func() { + if err := s.Serve(lis); err != nil { + t.Logf("Server exited with error: %v", err) + } + }() + + dialer := func(context.Context, string) (net.Conn, error) { + return lis.Dial() + } + + conn, err := grpc.NewClient("passthrough://bufnet", + grpc.WithContextDialer(dialer), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("Failed to dial bufnet: %v", err) + } + + client = pb.NewDNSManagerClient(conn) + + closer = func() { + err := lis.Close() + if err != nil { + t.Logf("Error closing listener: %v", err) + } + s.Stop() + } + + return client, closer +} + +// TestServer_AddDNSServer - covers success, validation (empty address) and conflict. +func TestServer_AddDNSServer(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + mockSvc := &mockDNSManager{addErr: nil} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + _, err := client.AddDNSServer(ctx, &pb.AddDNSServerRequest{Address: "8.8.8.8"}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) + + t.Run("Empty Address", func(t *testing.T) { + client, closer := setupTestServer(t, &mockDNSManager{}) + defer closer() + + _, err := client.AddDNSServer(ctx, &pb.AddDNSServerRequest{Address: ""}) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.InvalidArgument { + t.Fatalf("Expected InvalidArgument, got %v", err) + } + }) + + t.Run("Already Exists", func(t *testing.T) { + mockSvc := &mockDNSManager{addErr: domain.ErrAlreadyExists} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + _, err := client.AddDNSServer(ctx, &pb.AddDNSServerRequest{Address: "8.8.8.8"}) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.AlreadyExists { + t.Fatalf("Expected AlreadyExists, got code %v", st.Code()) + } + }) +} + +// TestServer_RemoveDNSServer - covers not-found mapping. +func TestServer_RemoveDNSServer(t *testing.T) { + ctx := context.Background() + + t.Run("Not Found", func(t *testing.T) { + mockSvc := &mockDNSManager{removeErr: domain.ErrNotFound} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + _, err := client.RemoveDNSServer(ctx, &pb.RemoveDNSServerRequest{Address: "1.1.1.1"}) + st, ok := status.FromError(err) + if !ok || st.Code() != codes.NotFound { + t.Fatalf("Expected NotFound, got code %v", st.Code()) + } + }) +} + +// TestServer_ListDNSServers - verifies addresses are returned intact. +func TestServer_ListDNSServers(t *testing.T) { + ctx := context.Background() + + t.Run("Success", func(t *testing.T) { + expected := []string{"8.8.8.8", "1.1.1.1"} + mockSvc := &mockDNSManager{listRes: expected} + client, closer := setupTestServer(t, mockSvc) + defer closer() + + resp, err := client.ListDNSServers(ctx, &emptypb.Empty{}) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(resp.Addresses) != len(expected) { + t.Fatalf("Expected %d addresses, got %d", len(expected), len(resp.Addresses)) + } + for i, addr := range expected { + if resp.Addresses[i] != addr { + t.Errorf("Expected address %s, got %s", addr, resp.Addresses[i]) + } + } + }) +} From 93f09090f38c367658d28cb8377f43e5301518bb Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 03:00:52 +0300 Subject: [PATCH 08/13] fix(resolvconf): use pointer for slog.Logger in FileAdapter Passing the logger by pointer ensures consistency across the service layer and prevents potential value copying overhead. --- internal/adapters/resolvconf/file.go | 4 ++-- internal/adapters/resolvconf/file_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/adapters/resolvconf/file.go b/internal/adapters/resolvconf/file.go index 592632f..7fe6b05 100644 --- a/internal/adapters/resolvconf/file.go +++ b/internal/adapters/resolvconf/file.go @@ -35,12 +35,12 @@ var _ ports.ResolvConf = (*FileAdapter)(nil) // FileAdapter implements ports.ResolvConf to work with a local file. type FileAdapter struct { filePath string - logger slog.Logger + 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 { +func NewFileAdapter(path string, logger *slog.Logger) *FileAdapter { return &FileAdapter{ filePath: path, logger: logger, diff --git a/internal/adapters/resolvconf/file_test.go b/internal/adapters/resolvconf/file_test.go index ea7d2bd..cdc78b1 100644 --- a/internal/adapters/resolvconf/file_test.go +++ b/internal/adapters/resolvconf/file_test.go @@ -22,7 +22,7 @@ func TestFileAdapter_RewriteAndGet(t *testing.T) { nopLogger := slog.New(slog.NewTextHandler(io.Discard, nil)) - adapter := NewFileAdapter(testFile, *nopLogger) + adapter := NewFileAdapter(testFile, nopLogger) ctx := context.Background() initialContent := []byte("search example.com\noptions ndots:5\nnameserver 1.1.1.1\n") From bfaee10127a57518a11839d353de3eb56788dd39 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 03:02:27 +0300 Subject: [PATCH 09/13] feat(cli): add docs, improve cli interaction and add integration test end-to-end --- internal/adapters/cli/commands.go | 279 +++++++++++++++++++++++++ internal/adapters/cli/commands_test.go | 76 +++++++ tests/integration_test.go | 124 +++++++++++ 3 files changed, 479 insertions(+) create mode 100644 internal/adapters/cli/commands.go create mode 100644 internal/adapters/cli/commands_test.go create mode 100644 tests/integration_test.go diff --git a/internal/adapters/cli/commands.go b/internal/adapters/cli/commands.go new file mode 100644 index 0000000..0b63adb --- /dev/null +++ b/internal/adapters/cli/commands.go @@ -0,0 +1,279 @@ +// Package cli provides the command-line interface for dns-manager. +// It exposes a Cobra-based root command with server and client subcommands. +// The client subcommands talk to a gRPC dns-manager server; the server command +// runs an in-process gRPC server that delegates to the service layer. +package cli + +import ( + "context" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/ilindan-dev/dns-manager/internal/adapters/resolvconf" + "github.com/ilindan-dev/dns-manager/internal/service" + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" + + dnsgrpc "github.com/ilindan-dev/dns-manager/internal/adapters/grpc" +) + +const ( + timeout = 5 * time.Second + shutdownTimeout = 10 * time.Second +) + +// Execute sets up and runs the root cobra command for the dns-manager CLI. +// It registers subcommands and exits the process with non-zero status on error. +func Execute() { + rootCmd := &cobra.Command{ + Use: "dns-manager", + Short: "Manage DNS servers (server and client modes)", + SilenceUsage: true, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + } + + rootCmd.AddCommand(newServerCmd()) + rootCmd.AddCommand(newClientCmd()) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// newServerCmd returns the "server" command which starts the gRPC server. +// The command validates args, builds dependencies (resolvconf adapter, service) +// and runs the gRPC server until an interrupt/termination signal is received. +func newServerCmd() *cobra.Command { + var configPath string + + cmd := &cobra.Command{ + Use: "server [port]", + Short: "Start the gRPC dns-manager server on the specified port", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + portStr := args[0] + + if _, err := strconv.ParseUint(portStr, 10, 16); err != nil { + return fmt.Errorf("invalid port number: %s", portStr) + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + logger.Info("Starting dns-manager server", "port", portStr, "config", configPath) + + adapter := resolvconf.NewFileAdapter(configPath, logger) + svc := service.NewDNSManagerService(adapter, logger) + s := grpc.NewServer() + + pb.RegisterDNSManagerServer(s, dnsgrpc.NewServer(svc)) + + lis, err := net.Listen("tcp", ":"+portStr) + if err != nil { + return fmt.Errorf("failed to listen on port %s: %w", portStr, err) + } + + go func() { + if err := s.Serve(lis); err != nil { + logger.Error("gRPC server exited", "err", err) + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + <-ctx.Done() + logger.Info("Shutting down dns-manager server gracefully...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + stopped := make(chan struct{}) + go func() { + s.GracefulStop() + close(stopped) + }() + + select { + case <-shutdownCtx.Done(): + logger.Warn("Graceful shutdown timed out, forcing stop") + s.Stop() + case <-stopped: + logger.Info("Server stopped gracefully") + } + + return nil + }, + } + + cmd.Flags().StringVarP(&configPath, "config", "c", "/etc/resolv.conf", "Path to resolv.conf file") + return cmd +} + +// newClientCmd returns the "client" command grouping add/remove/list subcommands. +func newClientCmd() *cobra.Command { + clientCmd := &cobra.Command{ + Use: "client", + Short: "Client commands to manage DNS servers on a dns-manager server", + } + + clientCmd.PersistentFlags().StringP("server", "s", "localhost:50051", "Address of the dns-manager server") + + clientCmd.AddCommand(newAddCmd()) + clientCmd.AddCommand(newRemoveCmd()) + clientCmd.AddCommand(newListCmd()) + + return clientCmd +} + +// setupClient is DRY helper for initializing a gRPC client. +func setupClient(cmd *cobra.Command, logger *slog.Logger) (*dnsgrpc.Client, func(), error) { + addr, _ := cmd.Flags().GetString("server") + + client, err := dnsgrpc.NewClient(addr) + if err != nil { + return nil, nil, fmt.Errorf("failed to connect to %s: %w", addr, err) + } + + cleanup := func() { + if err := client.Close(); err != nil { + logger.Warn("failed to close gRPC client", "err", err) + } + } + + return client, cleanup, nil +} + +// newAddCmd returns the "add" subcommand. It connects to the gRPC server and +// issues an AddDNSServer RPC. The command validates the provided IP and uses +// a short timeout to avoid hanging the CLI. +func newAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add [ip]", + Short: "Add a DNS server to the remote configuration", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ip := args[0] + + if net.ParseIP(ip) == nil { + return fmt.Errorf("invalid IP address: %s", ip) + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + client, cleanup, err := setupClient(cmd, logger) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := client.AddDNSServer(ctx, ip); err != nil { + return handleError(err, "failed to add DNS server") + } + + fmt.Println("DNS server successfully added") + return nil + }, + } +} + +// newRemoveCmd returns the "remove" subcommand which issues RemoveDNSServer RPCs. +func newRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove [ip]", + Short: "Remove a DNS server from the remote configuration", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ip := args[0] + + if net.ParseIP(ip) == nil { + return fmt.Errorf("invalid IP address: %s", ip) + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + client, cleanup, err := setupClient(cmd, logger) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := client.RemoveDNSServer(ctx, ip); err != nil { + return handleError(err, "failed to remove DNS server") + } + + fmt.Println("DNS server successfully removed") + return nil + }, + } +} + +// newListCmd returns the "list" subcommand which requests the current list +// of DNS servers from the remote service and prints them to stderr. +func newListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List DNS servers configured on the remote server", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + client, cleanup, err := setupClient(cmd, logger) + if err != nil { + return err + } + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + servers, err := client.ListDNSServers(ctx) + if err != nil { + return handleError(err, "failed to list DNS servers") + } + + if len(servers) == 0 { + fmt.Println("No DNS servers configured.") + return nil + } + + fmt.Println("Configured DNS servers:") + for _, srv := range servers { + fmt.Printf(" - %s\n", srv) + } + return nil + }, + } +} + +// handleError maps gRPC status codes to user-friendly CLI errors. +func handleError(err error, contextMsg string) error { + st, ok := status.FromError(err) + if !ok { + return fmt.Errorf("%s: %w", contextMsg, err) + } + + switch st.Code() { + case codes.Unavailable: + return fmt.Errorf("%s: server unavailable", contextMsg) + case codes.DeadlineExceeded: + return fmt.Errorf("%s: deadline exceeded", contextMsg) + case codes.AlreadyExists, codes.NotFound, codes.InvalidArgument: + return fmt.Errorf("%s: %s", contextMsg, st.Message()) + case codes.PermissionDenied: + return fmt.Errorf("%s: permission denied (does the server have write access to the config file?)", contextMsg) + default: + return fmt.Errorf("%s: internal error (code: %s)", contextMsg, st.Code().String()) + } +} diff --git a/internal/adapters/cli/commands_test.go b/internal/adapters/cli/commands_test.go new file mode 100644 index 0000000..461c12b --- /dev/null +++ b/internal/adapters/cli/commands_test.go @@ -0,0 +1,76 @@ +// Package cli contains unit tests for the command-line interface. +// These tests exercise argument validation and basic Cobra wiring without +// starting network services. They verify that commands return user-friendly +// errors on invalid input and enforce expected argument counts. +package cli + +import ( + "bytes" + "strings" + "testing" +) + +// TestClientAddCmd_Validation verifies that the add subcommand validates the +// provided IP address and returns an error for malformed input. The test runs +// the Cobra command in-memory and checks the returned error message. +func TestClientAddCmd_Validation(t *testing.T) { + cmd := newAddCmd() + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + + cmd.SetArgs([]string{"999.999.999.999"}) + err := cmd.Execute() + + if err == nil { + t.Fatal("expected error for invalid IP, but got nil") + } + + expectedErr := "invalid IP address: 999.999.999.999" + if err.Error() != expectedErr { + t.Errorf("expected error %q, got %q", expectedErr, err.Error()) + } +} + +// TestClientAddCmd_ArgsCount ensures the add subcommand enforces the required +// number of arguments. When called with no arguments the command should +// return a Cobra-style argument count error. +func TestClientAddCmd_ArgsCount(t *testing.T) { + cmd := newAddCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + + cmd.SetArgs([]string{}) + err := cmd.Execute() + + if err == nil { + t.Fatal("expected error for missing arguments, but got nil") + } + if !strings.Contains(err.Error(), "accepts 1 arg(s), received 0") { + t.Errorf("unexpected error message: %v", err) + } +} + +// TestServerCmd_InvalidPort checks that the server command validates the +// port argument and returns a clear error when the value cannot be parsed +// as a numeric port. The test executes the command in-memory. +func TestServerCmd_InvalidPort(t *testing.T) { + cmd := newServerCmd() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + + cmd.SetArgs([]string{"not-a-port"}) + err := cmd.Execute() + + if err == nil { + t.Fatal("expected error for invalid port, but got nil") + } + + expectedErr := "invalid port number: not-a-port" + if err.Error() != expectedErr { + t.Errorf("expected error %q, got %q", expectedErr, err.Error()) + } +} diff --git a/tests/integration_test.go b/tests/integration_test.go new file mode 100644 index 0000000..ce403c7 --- /dev/null +++ b/tests/integration_test.go @@ -0,0 +1,124 @@ +// Package tests contains integration tests that exercise the full gRPC +// stack against a temporary resolv.conf file. Tests start an in-process +// gRPC server and use the client adapter to perform end-to-end operations. +package tests + +import ( + "context" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + dnsgrpc "github.com/ilindan-dev/dns-manager/internal/adapters/grpc" + "github.com/ilindan-dev/dns-manager/internal/adapters/resolvconf" + "github.com/ilindan-dev/dns-manager/internal/service" + pb "github.com/ilindan-dev/dns-manager/proto/dns/v1" +) + +// setupTestEnvironment prepares a temporary resolv.conf, starts a gRPC server +// bound to a random local port and returns a connected client together with +// a cleanup function. The cleanup function stops the server and closes the client. +func setupTestEnvironment(t *testing.T) (client *dnsgrpc.Client, closer func()) { + t.Helper() + + dir := t.TempDir() + configPath := filepath.Join(dir, "resolv.conf") + + initialContent := []byte("# This file is managed by test\nsearch localdomain\n") + if err := os.WriteFile(configPath, initialContent, 0o644); err != nil { + t.Fatalf("failed to create initial resolv.conf: %v", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + adapter := resolvconf.NewFileAdapter(configPath, logger) + svc := service.NewDNSManagerService(adapter, logger) + + grpcServer := grpc.NewServer() + pb.RegisterDNSManagerServer(grpcServer, dnsgrpc.NewServer(svc)) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + + go func() { + if err := grpcServer.Serve(lis); err != nil { + panic(err) + } + }() + + time.Sleep(10 * time.Millisecond) + + client, err = dnsgrpc.NewClient(lis.Addr().String()) + if err != nil { + grpcServer.Stop() + t.Fatalf("failed to create client: %v", err) + } + + closer = func() { + _ = client.Close() + grpcServer.Stop() + } + + return client, closer +} + +// assertList fetches the list of configured DNS servers using the client +// and fails the test if the result does not exactly match the expected slice. +func assertList(ctx context.Context, t *testing.T, client *dnsgrpc.Client, expected []string) { + t.Helper() + servers, err := client.ListDNSServers(ctx) + if err != nil { + t.Fatalf("ListDNSServers failed: %v", err) + } + if servers == nil { + servers = []string{} + } + if !reflect.DeepEqual(servers, expected) { + t.Fatalf("expected %v, got %v", expected, servers) + } +} + +// TestIntegration_EndToEnd performs an end-to-end scenario against the +// in-process gRPC server. It verifies the initial empty state, adds a DNS +// server, asserts duplicate-add conflict, adds another server, removes one +// and checks the final state. +func TestIntegration_EndToEnd(t *testing.T) { + client, cleanup := setupTestEnvironment(t) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + assertList(ctx, t, client, []string{}) + + if err := client.AddDNSServer(ctx, "8.8.8.8"); err != nil { + t.Fatalf("AddDNSServer failed: %v", err) + } + + assertList(ctx, t, client, []string{"8.8.8.8"}) + + err := client.AddDNSServer(ctx, "8.8.8.8") + if st, ok := status.FromError(err); !ok || st.Code() != codes.AlreadyExists { + t.Fatalf("expected AlreadyExists error, got %v", err) + } + + if err := client.AddDNSServer(ctx, "1.1.1.1"); err != nil { + t.Fatalf("AddDNSServer failed: %v", err) + } + + if err := client.RemoveDNSServer(ctx, "8.8.8.8"); err != nil { + t.Fatalf("RemoveDNSServer failed: %v", err) + } + + assertList(ctx, t, client, []string{"1.1.1.1"}) +} From bded82c01f50f2edcab590ce07dccd076de061c9 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 03:07:10 +0300 Subject: [PATCH 10/13] chore(cmd): remove old cmd/client and cmd/server main packages that were replaced by unified cmd/dns-manager entrypoint --- cmd/client/main.go | 1 - cmd/dns-manager/main.go | 12 ++++++++++++ cmd/server/main.go | 1 - go.mod | 3 +++ go.sum | 10 ++++++++++ 5 files changed, 25 insertions(+), 2 deletions(-) delete mode 100644 cmd/client/main.go create mode 100644 cmd/dns-manager/main.go delete mode 100644 cmd/server/main.go diff --git a/cmd/client/main.go b/cmd/client/main.go deleted file mode 100644 index 06ab7d0..0000000 --- a/cmd/client/main.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/cmd/dns-manager/main.go b/cmd/dns-manager/main.go new file mode 100644 index 0000000..6e57dbb --- /dev/null +++ b/cmd/dns-manager/main.go @@ -0,0 +1,12 @@ +// Package main is the entry point for the dns-manager unified binary. +// It delegates all execution logic to the CLI adapter, which handles +// command routing, flag parsing, and the application lifecycle. +package main + +import "github.com/ilindan-dev/dns-manager/internal/adapters/cli" + +func main() { + // Execute the root Cobra command. All setup, execution, and error handling + // (including os.Exit calls on fatal failures) are managed within the cli package. + cli.Execute() +} diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 06ab7d0..0000000 --- a/cmd/server/main.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/go.mod b/go.mod index 8ed4a2d..d23139d 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,14 @@ module github.com/ilindan-dev/dns-manager go 1.26.2 require ( + github.com/spf13/cobra v1.10.2 google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 ) require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index 5045847..c935a85 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -10,6 +11,13 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -22,6 +30,7 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -36,3 +45,4 @@ google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From bbeb0c28be3de6effeb972f4ff0d951442dc80db Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 03:08:07 +0300 Subject: [PATCH 11/13] chore(Makefile): update target unit-test to test --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index d7194a9..d09031c 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -.PHONY: unit-test -unit-test: +.PHONY: test +test: go test -v -race ./... .PHONY: lint From 77c9ec8e6078bd92cba42af28c16ebfdc68c3f0f Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 03:08:57 +0300 Subject: [PATCH 12/13] ci: update GitHub Actions workflow --- .github/workflows/ci.yml | 2 +- internal/adapters/cli/cobra.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 internal/adapters/cli/cobra.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7266c3a..729d7a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,4 +24,4 @@ jobs: version: latest - name: Run tests - run: make unit-test \ No newline at end of file + run: make test \ No newline at end of file diff --git a/internal/adapters/cli/cobra.go b/internal/adapters/cli/cobra.go deleted file mode 100644 index 7f1e458..0000000 --- a/internal/adapters/cli/cobra.go +++ /dev/null @@ -1 +0,0 @@ -package cli From 06f78425940409fbd0fe6a7c61767f505a3019d4 Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 03:17:25 +0300 Subject: [PATCH 13/13] docs: add comprehensive README with setup and architecture details Included project overview, build instructions, CLI usage examples, and Makefile commands documentation. --- README.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 730a764..079ca31 100644 --- a/README.md +++ b/README.md @@ -1 +1,105 @@ -package dns_manager +# DNS Manager + +![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go) +![Build Status](https://img.shields.io/badge/build-passing-success) +![Lint](https://img.shields.io/badge/lint-golangci--lint-blue) +![Architecture](https://img.shields.io/badge/architecture-hexagonal-orange) + +**DNS Manager** - это легковесное клиент-серверное приложение на Go для безопасного управления DNS-серверами +операционной системы посредством редактирования конфигурации `/etc/resolv.conf`. + +Проект реализован в виде **единого бинарного файла** (Unified Binary), который предоставляет CLI-интерфейс как для +запуска gRPC-сервера, так и для клиентского управления им. + +## Ключевые особенности + +* **Атомарная запись:** Обновление файла конфигурации происходит через временный файл и системный вызов `os.Rename`. + Система гарантированно не останется с поврежденным конфигом при сбоях питания или падении процесса. +* **Сохранность данных:** Парсер изменяет исключительно директивы `nameserver`, оставляя пользовательские комментарии и + системные настройки (например, `search`, `options`) в исходном виде. +* **Потокобезопасность:** Вся бизнес-логика защищена мьютексами (`sync.RWMutex`), что исключает состояние гонки при + конкурентных запросах к gRPC API. +* **Graceful Shutdown:** Сервер корректно обрабатывает системные сигналы (`SIGTERM`, `SIGINT`) и дожидается завершения + активных соединений перед остановкой. +* **Чистая Архитектура:** Разделение на порты и адаптеры (Hexagonal Architecture) позволяет легко тестировать + бизнес-логику изолированно от сети и файловой системы. + +## Подготовка и сборка + +Для работы с проектом потребуется **Go 1.26** и утилита **make**. + +### Управление проектом через Makefile + +В проекте настроен `Makefile` для автоматизации рутинных задач. Описание доступных команд: + +* `make tools` - устанавливает все необходимые зависимости для разработки и кодогенерации (плагины `protoc`, + `protolint`, `grpcurl` и `golangci-lint`). *Убедитесь, что у вас установлен системный компилятор `protoc`*. +* `make protobuf` - компилирует файлы контрактов `proto/dns/v1/dns.proto` в исходный код Go (создает gRPC-клиенты и + стабы сервера). +* `make protolint` - проверяет стиль написания `.proto` файлов с помощью `protolint`. +* `make lint` - запускает строгую проверку исходного кода линтером `golangci-lint` в соответствии с конфигурацией + `.golangci.yaml`. +* `make test` - запускает все юнит-тесты и интеграционные E2E тесты с включенным детектором гонки данных (`-race`). + +### Быстрый старт сборки + +```bash +# 1. Установите инструменты и зависимости +make tools + +# 2. Сгенерируйте gRPC код +make protobuf + +# 3. Соберите бинарный файл +go build -o dns-manager ./cmd/dns-manager +``` + +## Использование + +Приложение предоставляет единую точку входа со встроенной справочной системой (Cobra CLI). Для получения справки по +любой команде используйте флаг --help. + +### Запуск сервера + +По умолчанию сервер пытается изменять системный `/etc/resolv.conf`, для чего требуются права суперпользователя (`sudo`). +Для локального тестирования предусмотрен флаг `--config`. + +```Bash +# Запуск в на порту 50051 (требует root) +sudo ./dns-manager server 50051 + +# Запуск с файлом конфига, куда будем писать +touch test_resolv.conf +./dns-manager server 50051 --config ./test_resolv.conf +``` + +### Команды клиента + +Клиент позволяет управлять запущенным сервером по сети. По умолчанию клиент обращается к `localhost:50051`, но адрес +можно переопределить глобальным флагом `-s` или `--server`. + +```Bash +# Добавить новый DNS-сервер +./dns-manager client add 8.8.8.8 + +# Удалить DNS-сервер +./dns-manager client remove 8.8.8.8 + +# Получить список текущих серверов +./dns-manager client list + +# Пример управления удаленным сервером +./dns-manager client list --server 192.168.1.100:50051 +``` + +## Тестирование + +Проект покрыт изолированными юнит-тестами и полноценным интеграционным E2E-тестом. +Интеграционный тест разворачивает gRPC-сервер в памяти на динамическом порту, подключает к нему клиента и проводит +полный цикл проверки бизнес-логики над временным файлом. + +Для запуска тестов используйте: + +```Bash +make test +``` \ No newline at end of file