From 93f09090f38c367658d28cb8377f43e5301518bb Mon Sep 17 00:00:00 2001 From: Gellert Ilya Date: Thu, 14 May 2026 03:00:52 +0300 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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