From 3e2169f217af6a64619da182c0dec998e18e7452 Mon Sep 17 00:00:00 2001 From: nessi Date: Wed, 18 Mar 2026 13:30:34 +0100 Subject: [PATCH] feat: add VPN DNS service with dynamic service catalog resolution and CoreDNS integration Add ServiceDNSRecord type and gateway API endpoint to expose active service domain-to-IP mappings. Implement ListServiceDNSRecords repository method querying services table with proxy_ip resolution using effectiveAccessProxyIP helper. Add vpn-dns microservice built on CoreDNS with periodic sync from backend API. Generate Corefile with configurable upstream DNS servers and hosts plugin for service overrides. --- backend/internal/gateway/handler.go | 15 +++ backend/internal/gateway/repository.go | 28 +++++ backend/internal/gateway/service.go | 4 + backend/internal/gateway/types_dns.go | 6 + backend/internal/httpserver/router.go | 1 + deploy/.env.example | 4 + deploy/docker-compose.yml | 13 +++ deploy/scripts/gateway-entrypoint.sh | 4 +- deploy/vpn-dns/Dockerfile | 11 ++ deploy/vpn-dns/go.mod | 3 + deploy/vpn-dns/main.go | 150 +++++++++++++++++++++++++ 11 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 backend/internal/gateway/types_dns.go create mode 100644 deploy/vpn-dns/Dockerfile create mode 100644 deploy/vpn-dns/go.mod create mode 100644 deploy/vpn-dns/main.go diff --git a/backend/internal/gateway/handler.go b/backend/internal/gateway/handler.go index 4c98072..e276c86 100644 --- a/backend/internal/gateway/handler.go +++ b/backend/internal/gateway/handler.go @@ -90,6 +90,21 @@ func (h *Handler) AgentSyncBundle(w http.ResponseWriter, r *http.Request) { apiutil.JSON(w, http.StatusOK, bundle) } +func (h *Handler) AgentServiceDNS(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Gateway-Bootstrap-Token") != h.bootstrapToken { + apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid gateway bootstrap token") + return + } + + items, err := h.service.ListServiceDNSRecords(r.Context()) + if err != nil { + apiutil.Error(w, http.StatusInternalServerError, "service_dns_failed", "unable to build service dns records") + return + } + + apiutil.JSON(w, http.StatusOK, map[string]any{"records": items}) +} + func (h *Handler) Telemetry(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Gateway-Bootstrap-Token") != h.bootstrapToken { apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid gateway bootstrap token") diff --git a/backend/internal/gateway/repository.go b/backend/internal/gateway/repository.go index 0e5e56c..56e4840 100644 --- a/backend/internal/gateway/repository.go +++ b/backend/internal/gateway/repository.go @@ -17,6 +17,7 @@ type Repository interface { List(ctx context.Context) ([]Gateway, error) FirstActive(ctx context.Context) (Gateway, error) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, error) + ListServiceDNSRecords(ctx context.Context) ([]ServiceDNSRecord, error) StoreTelemetry(ctx context.Context, gatewayID uuid.UUID, snapshot TelemetrySnapshot) error Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error) UpsertByName(ctx context.Context, input BootstrapRequest) (Gateway, error) @@ -192,6 +193,33 @@ func effectiveAccessProxyIP(proxyIP string) string { return proxyIP } +func (r *PGRepository) ListServiceDNSRecords(ctx context.Context) ([]ServiceDNSRecord, error) { + rows, err := r.db.Query(ctx, ` + select distinct + s.domain, + host(s.proxy_ip) + from services s + where s.deleted_at is null and s.is_active = true + order by s.domain asc + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []ServiceDNSRecord + for rows.Next() { + var item ServiceDNSRecord + var proxyIP string + if err := rows.Scan(&item.Domain, &proxyIP); err != nil { + return nil, err + } + item.TargetIP = effectiveAccessProxyIP(proxyIP) + items = append(items, item) + } + return items, rows.Err() +} + func (r *PGRepository) Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error) { row := r.db.QueryRow(ctx, ` update gateways diff --git a/backend/internal/gateway/service.go b/backend/internal/gateway/service.go index 1fe1fa5..fb0be4d 100644 --- a/backend/internal/gateway/service.go +++ b/backend/internal/gateway/service.go @@ -32,6 +32,10 @@ func (s *Service) BuildSyncBundle(ctx context.Context, gatewayID string) (wiregu return s.repo.BuildSyncBundle(ctx, id) } +func (s *Service) ListServiceDNSRecords(ctx context.Context) ([]ServiceDNSRecord, error) { + return s.repo.ListServiceDNSRecords(ctx) +} + func (s *Service) Update(ctx context.Context, gatewayID string, input UpdateRequest) (Gateway, error) { id, err := uuid.Parse(gatewayID) if err != nil { diff --git a/backend/internal/gateway/types_dns.go b/backend/internal/gateway/types_dns.go new file mode 100644 index 0000000..1800c87 --- /dev/null +++ b/backend/internal/gateway/types_dns.go @@ -0,0 +1,6 @@ +package gateway + +type ServiceDNSRecord struct { + Domain string `json:"domain"` + TargetIP string `json:"target_ip"` +} diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go index 7c77ada..7d52883 100644 --- a/backend/internal/httpserver/router.go +++ b/backend/internal/httpserver/router.go @@ -42,6 +42,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler { r.Post("/auth/refresh", handlers.Auth.Refresh) r.Post("/auth/logout", handlers.Auth.Logout) r.Post("/gateway-agent/bootstrap", handlers.Gateway.Bootstrap) + r.Get("/gateway-agent/dns/services", handlers.Gateway.AgentServiceDNS) r.Get("/gateway-agent/{id}/sync", handlers.Gateway.AgentSyncBundle) r.Post("/gateway-agent/{id}/telemetry", handlers.Gateway.Telemetry) diff --git a/deploy/.env.example b/deploy/.env.example index c3f7a6b..86cff4a 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -27,3 +27,7 @@ NEXAVPN_BACKEND_HOST=127.0.0.1 NEXAVPN_ACCESS_PROXY_IP=172.16.0.120 NEXAVPN_ACCESS_PROXY_HTTP_ADDR=172.16.0.120:80 NEXAVPN_ACCESS_PROXY_HTTPS_ADDR=172.16.0.120:443 +NEXAVPN_DNS_SYNC_URL=http://127.0.0.1:8080/api/v1/gateway-agent/dns/services +NEXAVPN_VPN_DNS_ADDR=:53 +NEXAVPN_VPN_DNS_UPSTREAMS=172.16.0.100,172.16.0.105 +NEXAVPN_CLIENT_DNS_SERVERS=172.16.0.119 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1664e12..b8ad672 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -111,6 +111,19 @@ services: volumes: - gateway-state:/var/lib/nexavpn + vpn-dns: + build: + context: . + dockerfile: vpn-dns/Dockerfile + depends_on: + - backend + network_mode: host + environment: + GATEWAY_BOOTSTRAP_TOKEN: ${GATEWAY_BOOTSTRAP_TOKEN:-nexavpn-gateway-bootstrap} + NEXAVPN_DNS_SYNC_URL: ${NEXAVPN_DNS_SYNC_URL:-http://127.0.0.1:8080/api/v1/gateway-agent/dns/services} + NEXAVPN_VPN_DNS_ADDR: ${NEXAVPN_VPN_DNS_ADDR:-:53} + NEXAVPN_VPN_DNS_UPSTREAMS: ${NEXAVPN_VPN_DNS_UPSTREAMS:-172.16.0.100,172.16.0.105} + volumes: postgres-data: gateway-state: diff --git a/deploy/scripts/gateway-entrypoint.sh b/deploy/scripts/gateway-entrypoint.sh index 02fb7bb..ae644fa 100644 --- a/deploy/scripts/gateway-entrypoint.sh +++ b/deploy/scripts/gateway-entrypoint.sh @@ -13,6 +13,7 @@ BOOTSTRAP_URL="${NEXAVPN_GATEWAY_BOOTSTRAP_URL:-http://backend:8080/api/v1/gatew SYNC_BASE_URL="${NEXAVPN_GATEWAY_SYNC_URL:-http://backend:8080/api/v1/gateway-agent}" GATEWAY_ID_FILE="/var/lib/nexavpn/gateway-id" BACKEND_HOST="${NEXAVPN_BACKEND_HOST:-backend}" +CLIENT_DNS_SERVERS="${NEXAVPN_CLIENT_DNS_SERVERS:-10.20.0.53}" if [ -z "${GATEWAY_BOOTSTRAP_TOKEN:-}" ]; then echo "GATEWAY_BOOTSTRAP_TOKEN is required." @@ -36,11 +37,12 @@ fi bootstrap_gateway() { GATEWAY_PUBLIC_KEY="$(printf '%s' "${NEXAVPN_GATEWAY_PRIVATE_KEY}" | wg pubkey)" + DNS_JSON="$(printf '%s' "${CLIENT_DNS_SERVERS}" | jq -R 'split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length > 0))')" echo "Bootstrapping gateway ${GATEWAY_NAME}" BOOTSTRAP_RESPONSE="$(curl -fsSL \ -H "Content-Type: application/json" \ -H "X-Gateway-Bootstrap-Token: ${GATEWAY_BOOTSTRAP_TOKEN}" \ - -d "{\"name\":\"${GATEWAY_NAME}\",\"endpoint\":\"${DEFAULT_GATEWAY_ENDPOINT:-localhost:${GATEWAY_LISTEN_PORT}}\",\"public_key\":\"${GATEWAY_PUBLIC_KEY}\",\"listen_port\":${GATEWAY_LISTEN_PORT},\"vpn_cidr\":\"${DEFAULT_VPN_CIDR:-100.96.0.0/24}\",\"dns_servers\":[\"10.20.0.53\"]}" \ + -d "{\"name\":\"${GATEWAY_NAME}\",\"endpoint\":\"${DEFAULT_GATEWAY_ENDPOINT:-localhost:${GATEWAY_LISTEN_PORT}}\",\"public_key\":\"${GATEWAY_PUBLIC_KEY}\",\"listen_port\":${GATEWAY_LISTEN_PORT},\"vpn_cidr\":\"${DEFAULT_VPN_CIDR:-100.96.0.0/24}\",\"dns_servers\":${DNS_JSON}}" \ "${BOOTSTRAP_URL}")" NEXAVPN_GATEWAY_ID="$(printf '%s' "${BOOTSTRAP_RESPONSE}" | jq -r '.id')" if [ -z "${NEXAVPN_GATEWAY_ID:-}" ] || [ "${NEXAVPN_GATEWAY_ID}" = "null" ]; then diff --git a/deploy/vpn-dns/Dockerfile b/deploy/vpn-dns/Dockerfile new file mode 100644 index 0000000..1bec493 --- /dev/null +++ b/deploy/vpn-dns/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /src +COPY vpn-dns/go.mod ./ +COPY vpn-dns/main.go ./ +RUN go build -o /out/nexavpn-vpn-dns ./main.go + +FROM coredns/coredns:1.11.3 + +COPY --from=builder /out/nexavpn-vpn-dns /usr/local/bin/nexavpn-vpn-dns +ENTRYPOINT ["/usr/local/bin/nexavpn-vpn-dns"] diff --git a/deploy/vpn-dns/go.mod b/deploy/vpn-dns/go.mod new file mode 100644 index 0000000..545751d --- /dev/null +++ b/deploy/vpn-dns/go.mod @@ -0,0 +1,3 @@ +module nexavpn/vpn-dns + +go 1.23.0 diff --git a/deploy/vpn-dns/main.go b/deploy/vpn-dns/main.go new file mode 100644 index 0000000..c472c84 --- /dev/null +++ b/deploy/vpn-dns/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type dnsResponse struct { + Records []dnsRecord `json:"records"` +} + +type dnsRecord struct { + Domain string `json:"domain"` + TargetIP string `json:"target_ip"` +} + +func main() { + ctx := context.Background() + + if err := os.MkdirAll("/etc/coredns", 0o755); err != nil { + log.Fatalf("unable to create coredns config dir: %v", err) + } + + if err := writeCorefile(); err != nil { + log.Fatalf("unable to write Corefile: %v", err) + } + if err := refreshOverrides(ctx); err != nil { + log.Printf("initial dns override sync failed: %v", err) + } + + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := refreshOverrides(ctx); err != nil { + log.Printf("dns override sync failed: %v", err) + } + } + }() + + cmd := exec.Command("/coredns", "-conf", "/etc/coredns/Corefile") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + log.Printf("starting coredns on %s", envOrDefault("NEXAVPN_VPN_DNS_ADDR", ":53")) + if err := cmd.Run(); err != nil { + log.Fatalf("coredns exited: %v", err) + } +} + +func writeCorefile() error { + upstreams := parseList(envOrDefault("NEXAVPN_VPN_DNS_UPSTREAMS", "172.16.0.100,172.16.0.105")) + if len(upstreams) == 0 { + return errors.New("no upstream dns servers configured") + } + + corefile := fmt.Sprintf(`%s { + errors + hosts /etc/coredns/service-overrides.hosts { + ttl 30 + reload 15s + fallthrough + } + forward . %s + cache 30 +} +`, envOrDefault("NEXAVPN_VPN_DNS_ADDR", ":53"), strings.Join(upstreams, " ")) + + return os.WriteFile("/etc/coredns/Corefile", []byte(corefile), 0o644) +} + +func refreshOverrides(ctx context.Context) error { + syncURL := strings.TrimRight(envOrDefault("NEXAVPN_DNS_SYNC_URL", "http://127.0.0.1:8080/api/v1/gateway-agent/dns/services"), "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, syncURL, nil) + if err != nil { + return err + } + req.Header.Set("X-Gateway-Bootstrap-Token", os.Getenv("GATEWAY_BOOTSTRAP_TOKEN")) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("service dns sync failed with status %s", resp.Status) + } + + var payload dnsResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return err + } + + lines := make([]string, 0, len(payload.Records)) + for _, record := range payload.Records { + domain := normalizeDomain(record.Domain) + targetIP := strings.TrimSpace(record.TargetIP) + if domain == "" || targetIP == "" { + continue + } + lines = append(lines, targetIP+" "+domain) + } + + content := strings.Join(lines, "\n") + if content != "" { + content += "\n" + } + + return os.WriteFile(filepath.Clean("/etc/coredns/service-overrides.hosts"), []byte(content), 0o644) +} + +func parseList(raw string) []string { + seen := make(map[string]struct{}) + values := make([]string, 0) + for _, part := range strings.Split(raw, ",") { + value := strings.TrimSpace(part) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + values = append(values, value) + } + return values +} + +func normalizeDomain(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + value = strings.TrimSuffix(value, ".") + return value +} + +func envOrDefault(key string, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +}