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.
This commit is contained in:
2026-03-18 13:30:34 +01:00
parent 6cf49ff3e0
commit 3e2169f217
11 changed files with 238 additions and 1 deletions

View File

@@ -90,6 +90,21 @@ func (h *Handler) AgentSyncBundle(w http.ResponseWriter, r *http.Request) {
apiutil.JSON(w, http.StatusOK, bundle) 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) { func (h *Handler) Telemetry(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Gateway-Bootstrap-Token") != h.bootstrapToken { if r.Header.Get("X-Gateway-Bootstrap-Token") != h.bootstrapToken {
apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid gateway bootstrap token") apiutil.Error(w, http.StatusUnauthorized, "unauthorized", "invalid gateway bootstrap token")

View File

@@ -17,6 +17,7 @@ type Repository interface {
List(ctx context.Context) ([]Gateway, error) List(ctx context.Context) ([]Gateway, error)
FirstActive(ctx context.Context) (Gateway, error) FirstActive(ctx context.Context) (Gateway, error)
BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID) (wireguard.GatewayBundle, 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 StoreTelemetry(ctx context.Context, gatewayID uuid.UUID, snapshot TelemetrySnapshot) error
Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error) Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error)
UpsertByName(ctx context.Context, input BootstrapRequest) (Gateway, error) UpsertByName(ctx context.Context, input BootstrapRequest) (Gateway, error)
@@ -192,6 +193,33 @@ func effectiveAccessProxyIP(proxyIP string) string {
return proxyIP 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) { func (r *PGRepository) Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error) {
row := r.db.QueryRow(ctx, ` row := r.db.QueryRow(ctx, `
update gateways update gateways

View File

@@ -32,6 +32,10 @@ func (s *Service) BuildSyncBundle(ctx context.Context, gatewayID string) (wiregu
return s.repo.BuildSyncBundle(ctx, id) 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) { func (s *Service) Update(ctx context.Context, gatewayID string, input UpdateRequest) (Gateway, error) {
id, err := uuid.Parse(gatewayID) id, err := uuid.Parse(gatewayID)
if err != nil { if err != nil {

View File

@@ -0,0 +1,6 @@
package gateway
type ServiceDNSRecord struct {
Domain string `json:"domain"`
TargetIP string `json:"target_ip"`
}

View File

@@ -42,6 +42,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
r.Post("/auth/refresh", handlers.Auth.Refresh) r.Post("/auth/refresh", handlers.Auth.Refresh)
r.Post("/auth/logout", handlers.Auth.Logout) r.Post("/auth/logout", handlers.Auth.Logout)
r.Post("/gateway-agent/bootstrap", handlers.Gateway.Bootstrap) 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.Get("/gateway-agent/{id}/sync", handlers.Gateway.AgentSyncBundle)
r.Post("/gateway-agent/{id}/telemetry", handlers.Gateway.Telemetry) r.Post("/gateway-agent/{id}/telemetry", handlers.Gateway.Telemetry)

View File

@@ -27,3 +27,7 @@ NEXAVPN_BACKEND_HOST=127.0.0.1
NEXAVPN_ACCESS_PROXY_IP=172.16.0.120 NEXAVPN_ACCESS_PROXY_IP=172.16.0.120
NEXAVPN_ACCESS_PROXY_HTTP_ADDR=172.16.0.120:80 NEXAVPN_ACCESS_PROXY_HTTP_ADDR=172.16.0.120:80
NEXAVPN_ACCESS_PROXY_HTTPS_ADDR=172.16.0.120:443 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

View File

@@ -111,6 +111,19 @@ services:
volumes: volumes:
- gateway-state:/var/lib/nexavpn - 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: volumes:
postgres-data: postgres-data:
gateway-state: gateway-state:

View File

@@ -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}" SYNC_BASE_URL="${NEXAVPN_GATEWAY_SYNC_URL:-http://backend:8080/api/v1/gateway-agent}"
GATEWAY_ID_FILE="/var/lib/nexavpn/gateway-id" GATEWAY_ID_FILE="/var/lib/nexavpn/gateway-id"
BACKEND_HOST="${NEXAVPN_BACKEND_HOST:-backend}" BACKEND_HOST="${NEXAVPN_BACKEND_HOST:-backend}"
CLIENT_DNS_SERVERS="${NEXAVPN_CLIENT_DNS_SERVERS:-10.20.0.53}"
if [ -z "${GATEWAY_BOOTSTRAP_TOKEN:-}" ]; then if [ -z "${GATEWAY_BOOTSTRAP_TOKEN:-}" ]; then
echo "GATEWAY_BOOTSTRAP_TOKEN is required." echo "GATEWAY_BOOTSTRAP_TOKEN is required."
@@ -36,11 +37,12 @@ fi
bootstrap_gateway() { bootstrap_gateway() {
GATEWAY_PUBLIC_KEY="$(printf '%s' "${NEXAVPN_GATEWAY_PRIVATE_KEY}" | wg pubkey)" 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}" echo "Bootstrapping gateway ${GATEWAY_NAME}"
BOOTSTRAP_RESPONSE="$(curl -fsSL \ BOOTSTRAP_RESPONSE="$(curl -fsSL \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "X-Gateway-Bootstrap-Token: ${GATEWAY_BOOTSTRAP_TOKEN}" \ -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}")" "${BOOTSTRAP_URL}")"
NEXAVPN_GATEWAY_ID="$(printf '%s' "${BOOTSTRAP_RESPONSE}" | jq -r '.id')" NEXAVPN_GATEWAY_ID="$(printf '%s' "${BOOTSTRAP_RESPONSE}" | jq -r '.id')"
if [ -z "${NEXAVPN_GATEWAY_ID:-}" ] || [ "${NEXAVPN_GATEWAY_ID}" = "null" ]; then if [ -z "${NEXAVPN_GATEWAY_ID:-}" ] || [ "${NEXAVPN_GATEWAY_ID}" = "null" ]; then

11
deploy/vpn-dns/Dockerfile Normal file
View File

@@ -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"]

3
deploy/vpn-dns/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module nexavpn/vpn-dns
go 1.23.0

150
deploy/vpn-dns/main.go Normal file
View File

@@ -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
}