commit 247f81d6164b26ae12154b573e2a182c355689f6 Author: Aiden Smith <29802327+DevVoxel@users.noreply.github.com> Date: Tue Feb 24 13:44:34 2026 -0500 Initial commit: Go DNS microservice boilerplate diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1b191f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PORT=8080 +API_KEY= +CORS_ORIGINS=http://localhost:3000 +LOG_FORMAT=text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd5dedc --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Binary +/server + +# Environment +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..789a835 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o server ./cmd/server + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +COPY --from=build /app/server /usr/local/bin/server +EXPOSE 8080 +CMD ["server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae39cb8 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# VectorDNS Go Server + +A lightweight Go DNS lookup microservice powered by Chi and miekg/dns. + +## Quick Start + +```bash +# Copy env config +cp .env.example .env + +# Install dependencies +go mod tidy + +# Run the server +go run ./cmd/server +``` + +The server starts on `http://localhost:8080`. + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/healthz` | Health check | +| POST | `/api/v1/dns/lookup` | DNS record lookup | + +### DNS Lookup + +```bash +curl -X POST http://localhost:8080/api/v1/dns/lookup \ + -H "Content-Type: application/json" \ + -d '{"domain": "example.com", "types": ["A", "MX"]}' +``` + +If `types` is omitted, all 9 record types are queried (A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, SRV). + +## Configuration + +All config is via environment variables (or `.env` file): + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | Server port | +| `API_KEY` | (empty) | API key for auth (disabled if empty) | +| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins | +| `LOG_FORMAT` | `text` | `text` or `json` | + +## Docker + +```bash +docker build -t vectordns-server . +docker run -p 8080:8080 --env-file .env vectordns-server +``` + +## Tech Stack + +- **Router**: [Chi v5](https://github.com/go-chi/chi) +- **DNS**: [miekg/dns](https://github.com/miekg/dns) +- **Logging**: stdlib `log/slog` +- **Rate Limiting**: [go-chi/httprate](https://github.com/go-chi/httprate) +- **CORS**: [go-chi/cors](https://github.com/go-chi/cors) diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..5155893 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "log/slog" + "net/http" + "os" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/go-chi/httprate" + + "github.com/vectordns/server/internal/config" + "github.com/vectordns/server/internal/handler" + "github.com/vectordns/server/internal/middleware" +) + +func main() { + cfg := config.Load() + + if cfg.LogFormat == "json" { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + } + + r := chi.NewRouter() + + // Middleware stack + r.Use(chimw.Recoverer) + r.Use(chimw.RequestID) + r.Use(chimw.RealIP) + r.Use(slogMiddleware) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: cfg.CORSOrigins, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "Authorization", "X-API-Key"}, + MaxAge: 86400, + })) + r.Use(httprate.LimitByIP(60, time.Minute)) + + // Public routes + r.Get("/healthz", handler.Health) + + // API routes (with API key auth) + r.Route("/api/v1", func(r chi.Router) { + r.Use(middleware.APIKey(cfg.APIKey)) + r.Post("/dns/lookup", handler.DNSLookup) + }) + + addr := fmt.Sprintf(":%s", cfg.Port) + slog.Info("starting server", "addr", addr) + if err := http.ListenAndServe(addr, r); err != nil { + slog.Error("server failed", "error", err) + os.Exit(1) + } +} + +func slogMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := chimw.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.Status(), + "duration_ms", time.Since(start).Milliseconds(), + "request_id", chimw.GetReqID(r.Context()), + ) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d7fee9f --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/vectordns/server + +go 1.23.0 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/cors v1.2.1 + github.com/go-chi/httprate v0.14.1 + github.com/miekg/dns v1.1.62 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // 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..4a4cb9e --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +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-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= +github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= +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/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.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/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2a731bf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + "os" + "strings" +) + +type Config struct { + Port string + APIKey string + CORSOrigins []string + LogFormat string +} + +func Load() *Config { + return &Config{ + Port: getEnv("PORT", "8080"), + APIKey: getEnv("API_KEY", ""), + CORSOrigins: strings.Split(getEnv("CORS_ORIGINS", "http://localhost:3000"), ","), + LogFormat: getEnv("LOG_FORMAT", "text"), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go new file mode 100644 index 0000000..616c9fe --- /dev/null +++ b/internal/dns/resolver.go @@ -0,0 +1,112 @@ +package dns + +import ( + "fmt" + "strings" + "time" + + mdns "github.com/miekg/dns" +) + +var defaultTypes = []string{"A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA", "CAA", "SRV"} + +const defaultResolver = "8.8.8.8:53" + +var typeMap = map[string]uint16{ + "A": mdns.TypeA, + "AAAA": mdns.TypeAAAA, + "MX": mdns.TypeMX, + "TXT": mdns.TypeTXT, + "NS": mdns.TypeNS, + "CNAME": mdns.TypeCNAME, + "SOA": mdns.TypeSOA, + "CAA": mdns.TypeCAA, + "SRV": mdns.TypeSRV, +} + +func Lookup(req LookupRequest) (*LookupResponse, error) { + domain := req.Domain + if !strings.HasSuffix(domain, ".") { + domain += "." + } + + types := req.Types + if len(types) == 0 { + types = defaultTypes + } + + start := time.Now() + records := make(map[string][]Record) + hasDNSSEC := false + + c := new(mdns.Client) + c.Net = "udp" + + for _, t := range types { + qtype, ok := typeMap[strings.ToUpper(t)] + if !ok { + continue + } + + m := new(mdns.Msg) + m.SetQuestion(domain, qtype) + m.SetEdns0(4096, true) + + r, _, err := c.Exchange(m, defaultResolver) + if err != nil { + continue + } + + if r.AuthenticatedData { + hasDNSSEC = true + } + + recs := parseRecords(r.Answer, strings.ToUpper(t)) + if len(recs) > 0 { + records[strings.ToUpper(t)] = recs + } + } + + return &LookupResponse{ + Domain: req.Domain, + Records: records, + Resolver: "8.8.8.8", + QueryTimeMs: time.Since(start).Milliseconds(), + DNSSEC: hasDNSSEC, + Timestamp: time.Now().UTC(), + }, nil +} + +func parseRecords(answers []mdns.RR, qtype string) []Record { + var records []Record + for _, ans := range answers { + rec := Record{TTL: ans.Header().Ttl} + switch rr := ans.(type) { + case *mdns.A: + rec.Value = rr.A.String() + case *mdns.AAAA: + rec.Value = rr.AAAA.String() + case *mdns.MX: + rec.Value = strings.TrimSuffix(rr.Mx, ".") + rec.Priority = rr.Preference + case *mdns.TXT: + rec.Value = strings.Join(rr.Txt, " ") + case *mdns.NS: + rec.Value = strings.TrimSuffix(rr.Ns, ".") + case *mdns.CNAME: + rec.Value = strings.TrimSuffix(rr.Target, ".") + case *mdns.SOA: + rec.Value = fmt.Sprintf("%s %s %d %d %d %d %d", + rr.Ns, rr.Mbox, rr.Serial, rr.Refresh, rr.Retry, rr.Expire, rr.Minttl) + case *mdns.CAA: + rec.Value = fmt.Sprintf("%d %s \"%s\"", rr.Flag, rr.Tag, rr.Value) + case *mdns.SRV: + rec.Value = fmt.Sprintf("%d %d %d %s", rr.Priority, rr.Weight, rr.Port, strings.TrimSuffix(rr.Target, ".")) + rec.Priority = rr.Priority + default: + continue + } + records = append(records, rec) + } + return records +} diff --git a/internal/dns/types.go b/internal/dns/types.go new file mode 100644 index 0000000..5e8c4cd --- /dev/null +++ b/internal/dns/types.go @@ -0,0 +1,23 @@ +package dns + +import "time" + +type LookupRequest struct { + Domain string `json:"domain"` + Types []string `json:"types,omitempty"` +} + +type LookupResponse struct { + Domain string `json:"domain"` + Records map[string][]Record `json:"records"` + Resolver string `json:"resolver"` + QueryTimeMs int64 `json:"query_time_ms"` + DNSSEC bool `json:"dnssec"` + Timestamp time.Time `json:"timestamp"` +} + +type Record struct { + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Priority uint16 `json:"priority,omitempty"` +} diff --git a/internal/handler/dns.go b/internal/handler/dns.go new file mode 100644 index 0000000..c7f0d4b --- /dev/null +++ b/internal/handler/dns.go @@ -0,0 +1,41 @@ +package handler + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/vectordns/server/internal/dns" +) + +func DNSLookup(w http.ResponseWriter, r *http.Request) { + var req dns.LookupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body", "INVALID_BODY") + return + } + + if req.Domain == "" { + writeError(w, http.StatusBadRequest, "domain is required", "MISSING_DOMAIN") + return + } + + resp, err := dns.Lookup(req) + if err != nil { + slog.Error("dns lookup failed", "domain", req.Domain, "error", err) + writeError(w, http.StatusInternalServerError, "dns lookup failed", "LOOKUP_FAILED") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func writeError(w http.ResponseWriter, status int, msg, code string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "error": msg, + "code": code, + }) +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..d056819 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,14 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +func Health(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "version": "0.1.0", + }) +} diff --git a/internal/middleware/apikey.go b/internal/middleware/apikey.go new file mode 100644 index 0000000..4940467 --- /dev/null +++ b/internal/middleware/apikey.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "encoding/json" + "net/http" +) + +func APIKey(key string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if key == "" { + next.ServeHTTP(w, r) + return + } + + provided := r.Header.Get("X-API-Key") + if provided != key { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid or missing API key", + "code": "UNAUTHORIZED", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +}