From 533268be3f1ebc168e8cebca6e902676038b566a Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Sat, 26 Oct 2024 10:45:37 +0530 Subject: [PATCH] initial commit --- .github/workflows/build.yml | 122 ++++++++++++++++++++++ .gitignore | 1 + Dockerfile | 13 +++ README.md | 186 ++++++++++++++++++++++++++++++++++ cmd/server/main.go | 32 ++++++ cmd/server/main_test.go | 14 +++ docker-compose.yml | 41 ++++++++ go.mod | 12 +++ go.sum | 22 ++++ internal/dns/handler.go | 122 ++++++++++++++++++++++ internal/dns/handler_test.go | 153 ++++++++++++++++++++++++++++ internal/dns/records.go | 151 +++++++++++++++++++++++++++ internal/dns/records_test.go | 186 ++++++++++++++++++++++++++++++++++ internal/dns/resolver.go | 22 ++++ internal/dns/resolver_test.go | 38 +++++++ pkg/config/config.go | 30 ++++++ pkg/config/config_test.go | 107 +++++++++++++++++++ scripts/install.sh | 124 +++++++++++++++++++++++ scripts/uninstall.sh | 50 +++++++++ 19 files changed, 1426 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 cmd/server/main_test.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/dns/handler.go create mode 100644 internal/dns/handler_test.go create mode 100644 internal/dns/records.go create mode 100644 internal/dns/records_test.go create mode 100644 internal/dns/resolver.go create mode 100644 internal/dns/resolver_test.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 scripts/install.sh create mode 100644 scripts/uninstall.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7a5a80b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,122 @@ +name: Build + +on: + workflow_dispatch: + push: + tags: [ 'v*' ] + # branches: [ "master" ] + # pull_request: + # branches: [ "master" ] + +permissions: + contents: write + packages: write + +env: + GO_VERSION: '1.22' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Install dependencies + run: go mod download + + - name: Run tests with coverage + run: go test -cover ./... + + - name: Build + run: go build -v -o nanodns ./cmd/server + + docker: + needs: build + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=long + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Make package public + run: | + PACKAGE_NAME=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') + curl -L \ + -X PUT \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/orgs/${{ github.repository_owner }}/packages/container/${PACKAGE_NAME}/visibility \ + -d '{"visibility":"public"}' + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION}} + cache: true + + - name: Build Release Binaries + run: | + GOOS=linux GOARCH=amd64 go build -o nanodns-linux-amd64 ./cmd/server + GOOS=linux GOARCH=arm64 go build -o nanodns-linux-arm64 ./cmd/server + GOOS=darwin GOARCH=amd64 go build -o nanodns-darwin-amd64 ./cmd/server + GOOS=darwin GOARCH=arm64 go build -o nanodns-darwin-arm64 ./cmd/server + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + nanodns-linux-amd64 + nanodns-linux-arm64 + nanodns-darwin-amd64 + nanodns-darwin-arm64 + scripts/install.sh + scripts/uninstall.sh + generate_release_notes: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..502bce1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +nanodns diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fdd1cac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Build stage +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY . . +RUN go mod download +RUN CGO_ENABLED=0 GOOS=linux go build -o nanodns ./cmd/server + +# Final stage +FROM alpine:latest +WORKDIR /app +COPY --from=builder /app/nanodns . +EXPOSE 53/udp +CMD ["./nanodns"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a31c0a --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# Nano DNS Server + +A lightweight DNS server designed for Docker Compose environments, allowing dynamic resolution of service names and custom DNS records. + +## Features + +- Environment variable-based configuration +- Support for A, CNAME, MX, and TXT records +- Docker service name resolution +- Optional TTL configuration (default: 60 seconds) +- Lightweight and fast +- Configurable port + +## Installation + +### Download + +Download the latest release from the [releases page](https://github.com/mguptahub/nanodns/releases). + +### Platform-specific Instructions + +#### Linux Service Installation + +Release assets include scripts for installing/uninstalling NanoDNS as a system service: + +```bash +# Make scripts executable +chmod +x install.sh uninstall.sh + +# Install service +sudo ./install.sh + +# View status and logs +systemctl status nanodns +journalctl -u nanodns -f + +# Edit configuration +sudo nano /etc/nanodns/nanodns.env + +# Uninstall service +sudo ./uninstall.sh +``` + +#### macOS + +If you see the warning "Apple could not verify this app", run these commands: + +```bash +# Remove quarantine attribute +xattr -d com.apple.quarantine nanodns-darwin-arm64 + +# Make executable +chmod +x nanodns-darwin-arm64 + +# Run the binary +./nanodns-darwin-arm64 +``` + +## Configuration + +### Environment Variables + +| Variable | Description | Default | Example | +|----------|-------------|---------|---------| +| DNS_PORT | UDP port for DNS server | 53 | 5353 | +| A_xxx | A Record Details | - | - | +| CNAME_xxx | CNAME Record Details | - | - | +| MX_xxx | MX Record Details | - | - | +| TXT_xxx | TXT Record Details | - | - | + +### Record Format + +All records use the `|` character as a separator. The general format is: +``` +RECORD_TYPE_NUMBER=domain|value[|ttl] +``` + +### A Records +``` +A_REC1=domain|ip|ttl +A_REC2=domain|service:servicename|ttl +``` +Example: +``` +A_REC1=app.example.com|192.168.1.10|300 +A_REC2=api.example.com|service:webapp +``` + +### CNAME Records +``` +CNAME_REC1=domain|target|ttl +``` +Example: +``` +CNAME_REC1=www.example.com|app.example.com|3600 +``` + +### MX Records +``` +MX_REC1=domain|priority|mailserver|ttl +``` +Example: +``` +MX_REC1=example.com|10|mail1.example.com|3600 +MX_REC2=example.com|20|mail2.example.com +``` + +### TXT Records +``` +TXT_REC1=domain|"text value"|ttl +``` +Example: +``` +TXT_REC1=example.com|v=spf1 include:_spf.example.com ~all|3600 +TXT_REC2=_dmarc.example.com|v=DMARC1; p=reject; rua=mailto:dmarc@example.com +``` + +## Docker Compose Usage + +```yaml +name: nanodns +services: + dns: + build: . + environment: + - DNS_PORT=5353 # Optional, defaults to 53 + # A Records + - A_REC1=app.example.com|service:webapp + - A_REC2=api.example.com|192.168.1.10|300 + # TXT Records + - TXT_REC1=example.com|v=spf1 include:_spf.example.com ~all + ports: + - "${DNS_PORT:-5353}:${DNS_PORT:-5353}/udp" # Uses DNS_PORT if set, otherwise 5353 + networks: + - app_network + +networks: + app_network: + driver: bridge +``` + +## Running Without Docker Compose + +```bash +# Set environment variables +export DNS_PORT=5353 +export A_REC1=app.example.com|192.168.1.10 +export TXT_REC1=example.com|v=spf1 include:_spf.example.com ~all + +# Run the server +./nanodns +``` + +## Testing Records + +```bash +# Test using custom port +dig @localhost -p 5353 app.example.com A + +# Test CNAME record +dig @localhost -p 5353 www.example.com CNAME + +# Test MX record +dig @localhost -p 5353 example.com MX + +# Test TXT record +dig @localhost -p 5353 example.com TXT +``` + +## Common Issues and Solutions + +1. Port 53 already in use (common on macOS and Linux): + - Use a different port by setting `DNS_PORT=5353` or another available port + - Update your client configurations to use the custom port + +2. Permission denied when using port 53: + - Use a port number above 1024 to avoid requiring root privileges + - Set `DNS_PORT=5353` or another high-numbered port + +## Development + +```bash +# Build the server +go build -o nanodns cmd/server/main.go + +``` \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..6e4d01a --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + + "github.com/mguptahub/nanodns/internal/dns" + "github.com/mguptahub/nanodns/pkg/config" + externaldns "github.com/miekg/dns" +) + +func main() { + // Load records from environment variables + records := dns.LoadRecords() + + // Create DNS handler + handler := dns.NewHandler(records) + externaldns.HandleFunc(".", handler.ServeDNS) + + // Configure server + port := config.GetDNSPort() + server := &externaldns.Server{ + Addr: ":" + port, + Net: "udp", + } + + log.Printf("Starting DNS server on port %s", port) + if err := server.ListenAndServe(); err != nil { + log.Fatalf("Failed to start server: %v", err) + } + + defer server.Shutdown() +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..84cc630 --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,14 @@ +package main + +import ( + "testing" +) + +func TestMainPackage(t *testing.T) { + // This is a placeholder test to ensure the main package can be built + // Real integration tests would be more appropriate here + t.Run("main package exists", func(t *testing.T) { + // Simply verify the package can be imported and built + // The actual server functionality should be tested through integration tests + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c0f2d8e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +name: nanodns + +services: + dns: + build: . + environment: + - DNS_PORT=5353 + # A Records (domain|ip[|ttl]) + - A_REC1=app.example.com|service:webapp + - A_REC2=api.example.com|service:api|120 + - A_REC3=static.example.com|192.168.1.10|300 + + # CNAME Records (domain|target[|ttl]) + - CNAME_REC1=www.example.com|app.example.com + + # MX Records (domain|priority|mailserver[|ttl]) + - MX_REC1=example.com|10|mail1.example.com + - MX_REC2=example.com|20|mail2.example.com|3600 + + # TXT Records (domain|"text value"[|ttl]) + - TXT_REC1=example.com|v=spf1 include:_spf.example.com ~all + - TXT_REC2=_dmarc.example.com|v=DMARC1; p=reject; rua=mailto:dmarc@example.com|3600 + - TXT_REC3=_acme-challenge.example.com|validation-token-here|60 + ports: + - "5353:5353/udp" + networks: + - app_network + + webapp: + image: nginx + networks: + - app_network + + api: + image: node + networks: + - app_network + +networks: + app_network: + driver: bridge \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..be7c948 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/mguptahub/nanodns + +go 1.22.4 + +require ( + github.com/miekg/dns v1.1.62 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/tools v0.22.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..72d5473 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= +github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= diff --git a/internal/dns/handler.go b/internal/dns/handler.go new file mode 100644 index 0000000..9150ea2 --- /dev/null +++ b/internal/dns/handler.go @@ -0,0 +1,122 @@ +package dns + +import ( + "log" + "net" + "strings" + + "github.com/miekg/dns" +) + +type Handler struct { + records map[string][]DNSRecord +} + +func NewHandler(records map[string][]DNSRecord) *Handler { + return &Handler{ + records: records, + } +} + +func (h *Handler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + for _, q := range r.Question { + log.Printf("Query for %s (type: %v)", q.Name, dns.TypeToString[q.Qtype]) + + if recs, exists := h.records[q.Name]; exists { + for _, rec := range recs { + if answer := h.createAnswer(q, rec); answer != nil { + m.Answer = append(m.Answer, answer) + } + } + } + } + + w.WriteMsg(m) +} + +func (h *Handler) createAnswer(q dns.Question, rec DNSRecord) dns.RR { + switch { + case q.Qtype == dns.TypeA && rec.RecordType == ARecord: + return h.createARecord(q, rec) + case q.Qtype == dns.TypeCNAME && rec.RecordType == CNAMERecord: + return h.createCNAMERecord(q, rec) + case q.Qtype == dns.TypeMX && rec.RecordType == MXRecord: + return h.createMXRecord(q, rec) + case q.Qtype == dns.TypeTXT && rec.RecordType == TXTRecord: + return h.createTXTRecord(q, rec) + default: + return nil + } +} + +func (h *Handler) createARecord(q dns.Question, rec DNSRecord) dns.RR { + var ip net.IP + if rec.IsService { + resolvedIP, err := ResolveServiceIP(rec.Value) + if err != nil { + log.Printf("Failed to resolve service %s: %v", rec.Value, err) + return nil + } + ip = net.ParseIP(resolvedIP) + } else { + ip = net.ParseIP(rec.Value) + } + + if ip == nil { + log.Printf("Invalid IP address for %s", rec.Value) + return nil + } + + return &dns.A{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: rec.TTL, + }, + A: ip, + } +} + +func (h *Handler) createCNAMERecord(q dns.Question, rec DNSRecord) dns.RR { + return &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: rec.TTL, + }, + Target: rec.Value, + } +} + +func (h *Handler) createMXRecord(q dns.Question, rec DNSRecord) dns.RR { + return &dns.MX{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeMX, + Class: dns.ClassINET, + Ttl: rec.TTL, + }, + Preference: rec.Priority, + Mx: rec.Value, + } +} + +func (h *Handler) createTXTRecord(q dns.Question, rec DNSRecord) dns.RR { + // Split TXT record by spaces if it contains multiple strings + txtParts := strings.Split(rec.Value, " ") + return &dns.TXT{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: rec.TTL, + }, + Txt: txtParts, + } +} diff --git a/internal/dns/handler_test.go b/internal/dns/handler_test.go new file mode 100644 index 0000000..6e4d676 --- /dev/null +++ b/internal/dns/handler_test.go @@ -0,0 +1,153 @@ +package dns + +import ( + "net" + "testing" + + "github.com/miekg/dns" +) + +type mockResponseWriter struct { + msgs []*dns.Msg +} + +func (m *mockResponseWriter) LocalAddr() net.Addr { return nil } +func (m *mockResponseWriter) RemoteAddr() net.Addr { return nil } +func (m *mockResponseWriter) WriteMsg(msg *dns.Msg) error { m.msgs = append(m.msgs, msg); return nil } +func (m *mockResponseWriter) Write([]byte) (int, error) { return 0, nil } +func (m *mockResponseWriter) Close() error { return nil } +func (m *mockResponseWriter) TsigStatus() error { return nil } +func (m *mockResponseWriter) TsigTimersOnly(bool) {} +func (m *mockResponseWriter) Hijack() {} + +func TestHandler_ServeDNS(t *testing.T) { + // Test records + records := map[string][]DNSRecord{ + "example.com.": { + { + Domain: "example.com.", + Value: "192.168.1.1", + TTL: 300, + RecordType: ARecord, + }, + { + Domain: "example.com.", + Value: "mail.example.com", + TTL: 300, + RecordType: MXRecord, + Priority: 10, + }, + }, + "www.example.com.": { + { + Domain: "www.example.com.", + Value: "example.com", + TTL: 300, + RecordType: CNAMERecord, + }, + }, + } + + handler := NewHandler(records) + + tests := []struct { + name string + question dns.Question + expectedRcode int + expectedCount int + expectedType uint16 + expectedAnswer string + }{ + { + name: "A record query", + question: dns.Question{ + Name: "example.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + expectedRcode: dns.RcodeSuccess, + expectedCount: 1, + expectedType: dns.TypeA, + expectedAnswer: "192.168.1.1", + }, + { + name: "CNAME record query", + question: dns.Question{ + Name: "www.example.com.", + Qtype: dns.TypeCNAME, + Qclass: dns.ClassINET, + }, + expectedRcode: dns.RcodeSuccess, + expectedCount: 1, + expectedType: dns.TypeCNAME, + expectedAnswer: "example.com", + }, + { + name: "MX record query", + question: dns.Question{ + Name: "example.com.", + Qtype: dns.TypeMX, + Qclass: dns.ClassINET, + }, + expectedRcode: dns.RcodeSuccess, + expectedCount: 1, + expectedType: dns.TypeMX, + expectedAnswer: "mail.example.com", + }, + { + name: "Non-existent domain", + question: dns.Question{ + Name: "nonexistent.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + expectedRcode: dns.RcodeSuccess, + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &mockResponseWriter{msgs: make([]*dns.Msg, 0)} + r := new(dns.Msg) + r.Question = []dns.Question{tt.question} + + handler.ServeDNS(w, r) + + if len(w.msgs) != 1 { + t.Fatalf("Expected 1 message, got %d", len(w.msgs)) + } + + msg := w.msgs[0] + if msg.Rcode != tt.expectedRcode { + t.Errorf("Expected Rcode %d, got %d", tt.expectedRcode, msg.Rcode) + } + + if len(msg.Answer) != tt.expectedCount { + t.Errorf("Expected %d answers, got %d", tt.expectedCount, len(msg.Answer)) + } + + if tt.expectedCount > 0 { + ans := msg.Answer[0] + if ans.Header().Rrtype != tt.expectedType { + t.Errorf("Expected type %d, got %d", tt.expectedType, ans.Header().Rrtype) + } + + switch rr := ans.(type) { + case *dns.A: + if rr.A.String() != tt.expectedAnswer { + t.Errorf("Expected A record %s, got %s", tt.expectedAnswer, rr.A.String()) + } + case *dns.CNAME: + if rr.Target != tt.expectedAnswer { + t.Errorf("Expected CNAME record %s, got %s", tt.expectedAnswer, rr.Target) + } + case *dns.MX: + if rr.Mx != tt.expectedAnswer { + t.Errorf("Expected MX record %s, got %s", tt.expectedAnswer, rr.Mx) + } + } + } + }) + } +} diff --git a/internal/dns/records.go b/internal/dns/records.go new file mode 100644 index 0000000..b5886af --- /dev/null +++ b/internal/dns/records.go @@ -0,0 +1,151 @@ +package dns + +import ( + "fmt" + "log" + "os" + "strconv" + "strings" + + "github.com/mguptahub/nanodns/pkg/config" +) + +type RecordType string + +const ( + ARecord RecordType = "A" + CNAMERecord RecordType = "CNAME" + MXRecord RecordType = "MX" + TXTRecord RecordType = "TXT" + + // Record separator + RecordSeparator = "|" +) + +type DNSRecord struct { + Domain string + Value string + TTL uint32 + RecordType RecordType + IsService bool + Priority uint16 // For MX records +} + +var records = make(map[string][]DNSRecord) + +// LoadRecords loads DNS records from environment variables +func LoadRecords() map[string][]DNSRecord { + for _, env := range os.Environ() { + pair := strings.SplitN(env, "=", 2) + key := pair[0] + value := pair[1] + + if strings.HasPrefix(key, "A_") || + strings.HasPrefix(key, "CNAME_") || + strings.HasPrefix(key, "MX_") || + strings.HasPrefix(key, "TXT_") { + + record, err := parseRecord(key, value) + if err != nil { + log.Printf("Error parsing record %s: %v", key, err) + continue + } + domain := record.Domain + records[domain] = append(records[domain], record) + } + } + + logLoadedRecords() + return records +} + +func parseRecord(key, value string) (DNSRecord, error) { + parts := strings.Split(value, RecordSeparator) + + if len(parts) < 2 { + return DNSRecord{}, fmt.Errorf("invalid format: expected parts separated by %s", RecordSeparator) + } + + domain := parts[0] + if !strings.HasSuffix(domain, ".") { + domain = domain + "." + } + + ttl := uint32(config.DefaultTTL) + record := DNSRecord{ + Domain: domain, + TTL: ttl, + } + + // Set record type and parse value based on prefix + switch { + case strings.HasPrefix(key, "A_"): + record.RecordType = ARecord + record.Value = parts[1] + if config.IsServiceRecord(record.Value) { + record.IsService = true + record.Value = config.GetServiceName(record.Value) + } + if len(parts) > 2 { + if parsedTTL, err := strconv.ParseUint(parts[2], 10, 32); err == nil { + record.TTL = uint32(parsedTTL) + } + } + + case strings.HasPrefix(key, "CNAME_"): + record.RecordType = CNAMERecord + record.Value = parts[1] + if len(parts) > 2 { + if parsedTTL, err := strconv.ParseUint(parts[2], 10, 32); err == nil { + record.TTL = uint32(parsedTTL) + } + } + + case strings.HasPrefix(key, "MX_"): + record.RecordType = MXRecord + if len(parts) < 3 { + return DNSRecord{}, fmt.Errorf("MX record requires priority: domain|priority|value[|ttl]") + } + priority, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return DNSRecord{}, fmt.Errorf("invalid MX priority: %v", err) + } + record.Priority = uint16(priority) + record.Value = parts[2] + if len(parts) > 3 { + if parsedTTL, err := strconv.ParseUint(parts[3], 10, 32); err == nil { + record.TTL = uint32(parsedTTL) + } + } + + case strings.HasPrefix(key, "TXT_"): + record.RecordType = TXTRecord + record.Value = parts[1] + if len(parts) > 2 { + if parsedTTL, err := strconv.ParseUint(parts[2], 10, 32); err == nil { + record.TTL = uint32(parsedTTL) + } + } + } + + return record, nil +} + +func logLoadedRecords() { + log.Println("Loaded DNS Records:") + for domain, recs := range records { + for _, rec := range recs { + var extraInfo string + switch rec.RecordType { + case MXRecord: + extraInfo = fmt.Sprintf(" Priority: %d", rec.Priority) + case ARecord: + if rec.IsService { + extraInfo = " (Docker Service)" + } + } + log.Printf("%s -> %s (TTL: %d, Type: %s%s)", + domain, rec.Value, rec.TTL, rec.RecordType, extraInfo) + } + } +} diff --git a/internal/dns/records_test.go b/internal/dns/records_test.go new file mode 100644 index 0000000..3597b35 --- /dev/null +++ b/internal/dns/records_test.go @@ -0,0 +1,186 @@ +package dns + +import ( + "os" + "testing" +) + +func TestParseRecord(t *testing.T) { + tests := []struct { + name string + key string + value string + wantRecord DNSRecord + wantErr bool + errContains string + }{ + { + name: "valid A record", + key: "A_REC1", + value: "example.com|192.168.1.1|300", + wantRecord: DNSRecord{ + Domain: "example.com.", + Value: "192.168.1.1", + TTL: 300, + RecordType: ARecord, + IsService: false, + }, + wantErr: false, + }, + { + name: "valid A record with service", + key: "A_REC1", + value: "example.com|service:webapp", + wantRecord: DNSRecord{ + Domain: "example.com.", + Value: "webapp", + TTL: 60, + RecordType: ARecord, + IsService: true, + }, + wantErr: false, + }, + { + name: "valid CNAME record", + key: "CNAME_REC1", + value: "www.example.com|example.com|600", + wantRecord: DNSRecord{ + Domain: "www.example.com.", + Value: "example.com", + TTL: 600, + RecordType: CNAMERecord, + }, + wantErr: false, + }, + { + name: "valid MX record", + key: "MX_REC1", + value: "example.com|10|mail.example.com|300", + wantRecord: DNSRecord{ + Domain: "example.com.", + Value: "mail.example.com", + TTL: 300, + RecordType: MXRecord, + Priority: 10, + }, + wantErr: false, + }, + { + name: "valid TXT record", + key: "TXT_REC1", + value: "example.com|v=spf1 include:_spf.example.com ~all|300", + wantRecord: DNSRecord{ + Domain: "example.com.", + Value: "v=spf1 include:_spf.example.com ~all", + TTL: 300, + RecordType: TXTRecord, + }, + wantErr: false, + }, + { + name: "invalid format", + key: "A_REC1", + value: "example.com", + wantErr: true, + errContains: "invalid format", + }, + { + name: "invalid MX priority", + key: "MX_REC1", + value: "example.com|invalid|mail.example.com", + wantErr: true, + errContains: "invalid MX priority", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseRecord(tt.key, tt.value) + if tt.wantErr { + if err == nil { + t.Errorf("parseRecord() error = nil, want error containing %q", tt.errContains) + } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("parseRecord() error = %v, want error containing %q", err, tt.errContains) + } + return + } + if err != nil { + t.Errorf("parseRecord() error = %v, want nil", err) + return + } + if !recordsEqual(got, tt.wantRecord) { + t.Errorf("parseRecord() = %v, want %v", got, tt.wantRecord) + } + }) + } +} + +func TestLoadRecords(t *testing.T) { + // Save current env and defer restore + oldEnv := os.Environ() + defer func() { + os.Clearenv() + for _, pair := range oldEnv { + parts := splitEnv(pair) + os.Setenv(parts[0], parts[1]) + } + }() + + // Set up test environment + os.Clearenv() + testEnv := map[string]string{ + "A_REC1": "app.example.com|192.168.1.1|300", + "CNAME_REC1": "www.example.com|app.example.com|600", + "MX_REC1": "example.com|10|mail.example.com|300", + "TXT_REC1": "example.com|v=spf1 include:_spf.example.com ~all|300", + } + + for k, v := range testEnv { + os.Setenv(k, v) + } + + // Run test + got := LoadRecords() + + // Verify records + tests := []struct { + domain string + count int + }{ + {"app.example.com.", 1}, + {"www.example.com.", 1}, + {"example.com.", 2}, // MX and TXT records + } + + for _, tt := range tests { + t.Run(tt.domain, func(t *testing.T) { + records := got[tt.domain] + if len(records) != tt.count { + t.Errorf("LoadRecords() got %d records for %s, want %d", len(records), tt.domain, tt.count) + } + }) + } +} + +// Helper functions +func contains(s, substr string) bool { + return s != "" && substr != "" && s != substr && len(s) > len(substr) && s[:len(substr)] == substr +} + +func recordsEqual(a, b DNSRecord) bool { + return a.Domain == b.Domain && + a.Value == b.Value && + a.TTL == b.TTL && + a.RecordType == b.RecordType && + a.IsService == b.IsService && + a.Priority == b.Priority +} + +func splitEnv(env string) [2]string { + for i := 0; i < len(env); i++ { + if env[i] == '=' { + return [2]string{env[:i], env[i+1:]} + } + } + return [2]string{env, ""} +} diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go new file mode 100644 index 0000000..9d69b22 --- /dev/null +++ b/internal/dns/resolver.go @@ -0,0 +1,22 @@ +package dns + +import ( + "fmt" + "net" +) + +// ResolveServiceIP attempts to resolve Docker service name to IP +func ResolveServiceIP(serviceName string) (string, error) { + ips, err := net.LookupIP(serviceName) + if err != nil { + return "", err + } + + for _, ip := range ips { + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4.String(), nil + } + } + + return "", fmt.Errorf("no IPv4 address found for service: %s", serviceName) +} diff --git a/internal/dns/resolver_test.go b/internal/dns/resolver_test.go new file mode 100644 index 0000000..35aa664 --- /dev/null +++ b/internal/dns/resolver_test.go @@ -0,0 +1,38 @@ +package dns + +import ( + "testing" +) + +func TestResolveServiceIP(t *testing.T) { + tests := []struct { + name string + serviceName string + wantErr bool + }{ + { + name: "invalid service name", + serviceName: "nonexistent-service", + wantErr: true, + }, + // Note: Can't easily test successful resolution without a running Docker environment + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ip, err := ResolveServiceIP(tt.serviceName) + if tt.wantErr { + if err == nil { + t.Errorf("ResolveServiceIP() expected error for service %q, got nil", tt.serviceName) + } + return + } + if err != nil { + t.Errorf("ResolveServiceIP() error = %v, want nil", err) + } + if !tt.wantErr && ip == "" { + t.Error("ResolveServiceIP() returned empty IP for valid service") + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..483361e --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,30 @@ +package config + +import ( + "os" + "strings" +) + +const ( + DefaultTTL = 60 + DefaultPort = "53" + ServicePrefix = "service:" +) + +// GetDNSPort returns the configured DNS port or default +func GetDNSPort() string { + if port := os.Getenv("DNS_PORT"); port != "" { + return port + } + return DefaultPort +} + +// IsServiceRecord checks if the value represents a Docker service +func IsServiceRecord(value string) bool { + return strings.HasPrefix(value, ServicePrefix) +} + +// GetServiceName extracts service name from value +func GetServiceName(value string) string { + return strings.TrimPrefix(value, ServicePrefix) +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..7cd4935 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,107 @@ +package config + +import ( + "os" + "testing" +) + +func TestGetDNSPort(t *testing.T) { + // Save current env and defer restore + oldPort := os.Getenv("DNS_PORT") + defer os.Setenv("DNS_PORT", oldPort) + + tests := []struct { + name string + envValue string + want string + }{ + { + name: "default port", + envValue: "", + want: DefaultPort, + }, + { + name: "custom port", + envValue: "5353", + want: "5353", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv("DNS_PORT", tt.envValue) + } else { + os.Unsetenv("DNS_PORT") + } + + if got := GetDNSPort(); got != tt.want { + t.Errorf("GetDNSPort() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsServiceRecord(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + { + name: "service prefix", + value: "service:webapp", + want: true, + }, + { + name: "no service prefix", + value: "192.168.1.1", + want: false, + }, + { + name: "empty string", + value: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsServiceRecord(tt.value); got != tt.want { + t.Errorf("IsServiceRecord() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetServiceName(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + { + name: "with service prefix", + value: "service:webapp", + want: "webapp", + }, + { + name: "without service prefix", + value: "webapp", + want: "webapp", + }, + { + name: "empty string", + value: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetServiceName(tt.value); got != tt.want { + t.Errorf("GetServiceName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..0598504 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# Define colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Define variables +SERVICE_NAME="nanodns" +BINARY_PATH="/usr/local/bin/nanodns" +SERVICE_PATH="/etc/systemd/system/nanodns.service" +CONFIG_PATH="/etc/nanodns" +ENV_FILE="${CONFIG_PATH}/nanodns.env" + +# Function to print colored output +print_status() { + local color=$1 + local message=$2 + echo -e "${color}${message}${NC}" +} + +# Check if script is run as root +if [ "$EUID" -ne 0 ]; then + print_status $RED "Please run as root" + exit 1 +fi + +# Create directories +print_status $YELLOW "Creating directories..." +mkdir -p "$CONFIG_PATH" + +# Copy binary +print_status $YELLOW "Installing NanoDNS binary..." +if [ -f "./nanodns" ]; then + cp ./nanodns "$BINARY_PATH" + chmod +x "$BINARY_PATH" +else + print_status $RED "nanodns binary not found in current directory" + exit 1 +fi + +# Create environment file if it doesn't exist +if [ ! -f "$ENV_FILE" ]; then + print_status $YELLOW "Creating default environment file..." + cat > "$ENV_FILE" << EOF +# NanoDNS Environment Configuration + +# DNS server port (default: 53) +DNS_PORT=53 + +# DNS Records +# Format: domain|value|ttl +# Examples: +# A_REC1=app.local|192.168.1.10|300 +# A_REC2=api.local|service:myservice +# CNAME_REC1=www.local|app.local +# MX_REC1=local|10|mail.local +# TXT_REC1=local|v=spf1 include:_spf.google.com ~all + +# Add your records below: +A_REC1=app.local|127.0.0.1|300 +EOF +fi + +# Create systemd service file +print_status $YELLOW "Creating systemd service..." +cat > "$SERVICE_PATH" << EOF +[Unit] +Description=NanoDNS Server +After=network.target + +[Service] +Type=simple +User=root +EnvironmentFile=${ENV_FILE} +ExecStart=${BINARY_PATH} +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# Security settings +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true + +[Install] +WantedBy=multi-user.target +EOF + +# Set permissions +print_status $YELLOW "Setting permissions..." +chmod 644 "$SERVICE_PATH" +chmod 600 "$ENV_FILE" + +# Reload systemd +print_status $YELLOW "Reloading systemd..." +systemctl daemon-reload + +# Enable and start service +print_status $YELLOW "Enabling and starting NanoDNS service..." +systemctl enable nanodns +systemctl start nanodns + +# Check service status +if systemctl is-active --quiet nanodns; then + print_status $GREEN "NanoDNS service has been installed and started successfully!" + print_status $GREEN "\nUseful commands:" + echo " Check status: systemctl status nanodns" + echo " View logs: journalctl -u nanodns" + echo " Edit configuration: nano ${ENV_FILE}" + echo " Restart service: systemctl restart nanodns" +else + print_status $RED "Failed to start NanoDNS service. Please check the logs:" + echo " journalctl -u nanodns" +fi + +print_status $YELLOW "\nConfiguration file location: ${ENV_FILE}" +print_status $YELLOW "Please edit this file to add your DNS records" \ No newline at end of file diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..134ef1d --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Define colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Define variables +SERVICE_NAME="nanodns" +BINARY_PATH="/usr/local/bin/nanodns" +SERVICE_PATH="/etc/systemd/system/nanodns.service" +CONFIG_PATH="/etc/nanodns" + +# Function to print colored output +print_status() { + local color=$1 + local message=$2 + echo -e "${color}${message}${NC}" +} + +# Check if script is run as root +if [ "$EUID" -ne 0 ]; then + print_status $RED "Please run as root" + exit 1 +fi + +# Stop and disable service +print_status $YELLOW "Stopping and disabling NanoDNS service..." +systemctl stop nanodns +systemctl disable nanodns + +# Remove service file +print_status $YELLOW "Removing systemd service..." +rm -f "$SERVICE_PATH" +systemctl daemon-reload + +# Remove binary +print_status $YELLOW "Removing NanoDNS binary..." +rm -f "$BINARY_PATH" + +# Optionally remove configuration +read -p "Do you want to remove configuration files? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + print_status $YELLOW "Removing configuration files..." + rm -rf "$CONFIG_PATH" +fi + +print_status $GREEN "NanoDNS has been uninstalled successfully!" \ No newline at end of file