Files
homelab/dns-server/main.go
T
k.renner f453a5a90c
Security Audit / Infrastructure Scan (IaC) (push) Successful in 40s
Deploy Infrastructure / plan (push) Failing after 2m8s
Deploy Infrastructure / apply (push) Has been skipped
Security Audit / Docker Image Scan (docker.io/apache/tika:latest) (push) Failing after 2m50s
Security Audit / Docker Image Scan (docker.io/library/postgres:15) (push) Failing after 55s
Security Audit / Docker Image Scan (docker.io/library/redis:7) (push) Failing after 31s
Security Audit / Docker Image Scan (ghcr.io/icereed/paperless-gpt:latest) (push) Failing after 1m7s
Security Audit / Docker Image Scan (docker.io/gotenberg/gotenberg:8) (push) Failing after 4m47s
Security Audit / Docker Image Scan (shenxn/protonmail-bridge:latest) (push) Successful in 42s
Security Audit / Docker Image Scan (ghcr.io/paperless-ngx/paperless-ngx:latest) (push) Failing after 3m53s
Refactor code structure for improved readability and maintainability
2026-02-15 15:55:18 +01:00

566 lines
13 KiB
Go

package main
import (
"crypto/tls"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"gopkg.in/yaml.v3"
)
type Config struct {
Proxmox struct {
Host string `yaml:"host"`
User string `yaml:"user"`
TokenName string `yaml:"token_name"`
TokenValue string `yaml:"token_value"`
VerifySSL bool `yaml:"verify_ssl"`
} `yaml:"proxmox"`
DNSHost string `yaml:"dns_host"`
DNSPort int `yaml:"dns_port"`
DNSSuffix string `yaml:"dns_suffix"`
DNSTTL int `yaml:"dns_ttl"`
CacheTTL int `yaml:"cache_ttl"`
UpstreamDNS string `yaml:"upstream_dns"`
}
type DNSRecord struct {
IPv4 string
IPv6 string
Timestamp time.Time
}
type ProxmoxDNS struct {
config Config
cache map[string]*DNSRecord
cacheMux sync.RWMutex
client *http.Client
stopChan chan struct{}
nodes []string
nodesMux sync.RWMutex
}
func NewProxmoxDNS(configPath string) (*ProxmoxDNS, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Set defaults
if config.DNSPort == 0 {
config.DNSPort = 53
}
if config.CacheTTL == 0 {
config.CacheTTL = 60
}
if config.DNSTTL == 0 {
config.DNSTTL = 300
}
if config.UpstreamDNS == "" {
config.UpstreamDNS = "10.2.40.1:53"
}
if !strings.Contains(config.UpstreamDNS, ":") {
config.UpstreamDNS += ":53"
}
return &ProxmoxDNS{
config: config,
cache: make(map[string]*DNSRecord),
nodes: []string{},
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
stopChan: make(chan struct{}),
}, nil
}
// isValidIPv6ForDNS checks if an IPv6 address is suitable for DNS records
func isValidIPv6ForDNS(ip net.IP) bool {
if ip == nil || ip.To16() == nil {
return false
}
// Reject loopback
if ip.IsLoopback() {
return false
}
// Reject link-local (fe80::/10) - THIS IS THE KEY FIX
if ip.IsLinkLocalUnicast() {
return false
}
// Reject multicast
if ip.IsMulticast() {
return false
}
// Reject unspecified
if ip.IsUnspecified() {
return false
}
// Accept global unicast and ULA (fc00::/7)
return true
}
func (p *ProxmoxDNS) queryProxmoxAPI(endpoint string) ([]byte, error) {
// Remove port from host if present
host := p.config.Proxmox.Host
if !strings.Contains(host, ":") {
host += ":8006"
}
url := fmt.Sprintf("https://%s/api2/json/%s", host, endpoint)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
auth := fmt.Sprintf("PVEAPIToken=%s!%s=%s",
p.config.Proxmox.User,
p.config.Proxmox.TokenName,
p.config.Proxmox.TokenValue)
req.Header.Set("Authorization", auth)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}
func (p *ProxmoxDNS) discoverNodes() error {
data, err := p.queryProxmoxAPI("nodes")
if err != nil {
return fmt.Errorf("failed to discover nodes: %w", err)
}
var response struct {
Data []struct {
Node string `json:"node"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
return fmt.Errorf("failed to parse nodes: %w", err)
}
p.nodesMux.Lock()
defer p.nodesMux.Unlock()
p.nodes = []string{}
for _, node := range response.Data {
if node.Status == "online" {
p.nodes = append(p.nodes, node.Node)
}
}
log.Printf("Discovered %d online nodes: %v", len(p.nodes), p.nodes)
return nil
}
func (p *ProxmoxDNS) refreshCache() {
log.Println("Refreshing DNS cache...")
// Discover nodes first
if err := p.discoverNodes(); err != nil {
log.Printf("Failed to discover nodes: %v", err)
return
}
newCache := make(map[string]*DNSRecord)
p.nodesMux.RLock()
nodes := append([]string{}, p.nodes...)
p.nodesMux.RUnlock()
for _, node := range nodes {
// Query VMs
p.refreshVMs(node, newCache)
// Query LXC containers
p.refreshLXC(node, newCache)
}
p.cacheMux.Lock()
p.cache = newCache
p.cacheMux.Unlock()
log.Printf("Cache refreshed: %d entries", len(newCache))
}
func (p *ProxmoxDNS) refreshVMs(node string, cache map[string]*DNSRecord) {
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu", node))
if err != nil {
log.Printf("Failed to query VMs on %s: %v", node, err)
return
}
var response struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
log.Printf("Failed to parse VM data: %v", err)
return
}
for _, vm := range response.Data {
if vm.Status != "running" || vm.Name == "" {
continue
}
// Query agent network info
agentData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/qemu/%d/agent/network-get-interfaces", node, vm.VMID))
if err != nil {
log.Printf("VM %s (ID %d): no guest agent data", vm.Name, vm.VMID)
continue
}
var agentResp struct {
Data struct {
Result []struct {
Name string `json:"name"`
IPAddresses []struct {
IPAddress string `json:"ip-address"`
IPAddressType string `json:"ip-address-type"`
} `json:"ip-addresses"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(agentData, &agentResp); err != nil {
continue
}
var ipv4, ipv6 string
for _, iface := range agentResp.Data.Result {
if iface.Name == "lo" {
continue
}
for _, ipAddr := range iface.IPAddresses {
if ipAddr.IPAddressType == "ipv4" && ipv4 == "" {
if !strings.HasPrefix(ipAddr.IPAddress, "127.") {
ipv4 = ipAddr.IPAddress
}
} else if ipAddr.IPAddressType == "ipv6" && ipv6 == "" {
ip := net.ParseIP(ipAddr.IPAddress)
if isValidIPv6ForDNS(ip) {
ipv6 = ipAddr.IPAddress
log.Printf(" VM %s: accepting IPv6 %s", vm.Name, ipv6)
} else if ip != nil {
log.Printf(" VM %s: rejecting IPv6 %s (link-local/loopback/multicast)", vm.Name, ipAddr.IPAddress)
}
}
}
// Stop after finding both on first non-lo interface
if ipv4 != "" && ipv6 != "" {
break
}
}
if ipv4 != "" || ipv6 != "" {
hostname := strings.ToLower(vm.Name)
// Strip DNS suffix from hostname if present
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
cache[hostname] = &DNSRecord{
IPv4: ipv4,
IPv6: ipv6,
Timestamp: time.Now(),
}
log.Printf("VM: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
}
}
}
func (p *ProxmoxDNS) refreshLXC(node string, cache map[string]*DNSRecord) {
data, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc", node))
if err != nil {
log.Printf("Failed to query LXC on %s: %v", node, err)
return
}
var response struct {
Data []struct {
VMID int `json:"vmid"`
Name string `json:"name"`
Status string `json:"status"`
} `json:"data"`
}
if err := json.Unmarshal(data, &response); err != nil {
log.Printf("Failed to parse LXC data: %v", err)
return
}
for _, lxc := range response.Data {
if lxc.Status != "running" || lxc.Name == "" {
continue
}
// Query LXC interfaces
ifaceData, err := p.queryProxmoxAPI(fmt.Sprintf("nodes/%s/lxc/%d/interfaces", node, lxc.VMID))
if err != nil {
log.Printf("LXC %s (ID %d): failed to get interfaces", lxc.Name, lxc.VMID)
continue
}
var ifaceResp struct {
Data []struct {
Name string `json:"name"`
Inet string `json:"inet"`
Inet6 string `json:"inet6"`
} `json:"data"`
}
if err := json.Unmarshal(ifaceData, &ifaceResp); err != nil {
continue
}
var ipv4, ipv6 string
for _, iface := range ifaceResp.Data {
if iface.Name == "lo" {
continue
}
if iface.Inet != "" && ipv4 == "" {
// Format is "IP/CIDR"
parts := strings.Split(iface.Inet, "/")
if len(parts) > 0 && !strings.HasPrefix(parts[0], "127.") {
ipv4 = parts[0]
}
}
if iface.Inet6 != "" && ipv6 == "" {
parts := strings.Split(iface.Inet6, "/")
if len(parts) > 0 {
ip := net.ParseIP(parts[0])
if isValidIPv6ForDNS(ip) {
ipv6 = parts[0]
log.Printf(" LXC %s: accepting IPv6 %s", lxc.Name, ipv6)
} else if ip != nil {
log.Printf(" LXC %s: rejecting IPv6 %s (link-local/loopback/multicast)", lxc.Name, parts[0])
}
}
}
// Stop after finding both on first non-lo interface
if ipv4 != "" && ipv6 != "" {
break
}
}
if ipv4 != "" || ipv6 != "" {
hostname := strings.ToLower(lxc.Name)
// Strip DNS suffix from hostname if present
hostname = strings.TrimSuffix(hostname, p.config.DNSSuffix)
cache[hostname] = &DNSRecord{
IPv4: ipv4,
IPv6: ipv6,
Timestamp: time.Now(),
}
log.Printf("LXC: %s -> IPv4=%s IPv6=%s", hostname, ipv4, ipv6)
}
}
}
func (p *ProxmoxDNS) handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative = true
for _, q := range r.Question {
name := strings.ToLower(strings.TrimSuffix(q.Name, "."))
qtype := dns.TypeToString[q.Qtype]
// Check if query is for our domain suffix
if !strings.HasSuffix(name, strings.TrimPrefix(p.config.DNSSuffix, ".")) {
// Forward to upstream
log.Printf("Query %s (%s) - forwarding to upstream", name, qtype)
p.forwardToUpstream(w, r)
return
}
// Extract hostname (remove suffix)
hostname := strings.TrimSuffix(name, p.config.DNSSuffix)
p.cacheMux.RLock()
record, found := p.cache[hostname]
p.cacheMux.RUnlock()
if !found {
// NXDOMAIN
m.SetRcode(r, dns.RcodeNameError)
log.Printf("Query %s (%s) - NXDOMAIN", name, qtype)
w.WriteMsg(m)
return
}
switch q.Qtype {
case dns.TypeA:
if record.IPv4 != "" {
rr := &dns.A{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: uint32(p.config.DNSTTL),
},
A: net.ParseIP(record.IPv4),
}
m.Answer = append(m.Answer, rr)
log.Printf("Query %s (A) -> %s", name, record.IPv4)
} else {
// NODATA (host exists but no A record)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (A) - NODATA", name)
}
case dns.TypeAAAA:
if record.IPv6 != "" {
// Parse and validate IPv6 again before returning
ip := net.ParseIP(record.IPv6)
if ip != nil && isValidIPv6ForDNS(ip) {
rr := &dns.AAAA{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: uint32(p.config.DNSTTL),
},
AAAA: ip,
}
m.Answer = append(m.Answer, rr)
log.Printf("Query %s (AAAA) -> %s", name, record.IPv6)
} else {
// IPv6 is invalid (link-local etc)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (AAAA) - NODATA (invalid IPv6 in cache)", name)
}
} else {
// NODATA (host exists but no AAAA record)
m.Ns = append(m.Ns, p.getSOARecord())
log.Printf("Query %s (AAAA) - NODATA", name)
}
}
}
w.WriteMsg(m)
}
func (p *ProxmoxDNS) getSOARecord() dns.RR {
domain := strings.TrimPrefix(p.config.DNSSuffix, ".")
soa := &dns.SOA{
Hdr: dns.RR_Header{
Name: domain + ".",
Rrtype: dns.TypeSOA,
Class: dns.ClassINET,
Ttl: 300,
},
Ns: "ns." + domain + ".",
Mbox: "admin." + domain + ".",
Serial: uint32(time.Now().Unix()),
Refresh: 3600,
Retry: 600,
Expire: 86400,
Minttl: 300,
}
return soa
}
func (p *ProxmoxDNS) forwardToUpstream(w dns.ResponseWriter, r *dns.Msg) {
c := new(dns.Client)
c.Timeout = 2 * time.Second
resp, _, err := c.Exchange(r, p.config.UpstreamDNS)
if err != nil {
log.Printf("Failed to forward to upstream: %v", err)
m := new(dns.Msg)
m.SetRcode(r, dns.RcodeServerFailure)
w.WriteMsg(m)
return
}
w.WriteMsg(resp)
}
func (p *ProxmoxDNS) startCacheRefresher() {
ticker := time.NewTicker(time.Duration(p.config.CacheTTL) * time.Second)
defer ticker.Stop()
// Initial refresh
p.refreshCache()
for {
select {
case <-ticker.C:
p.refreshCache()
case <-p.stopChan:
return
}
}
}
func (p *ProxmoxDNS) Start() error {
// Start cache refresher
go p.startCacheRefresher()
// Start DNS server
dns.HandleFunc(".", p.handleDNSRequest)
addr := fmt.Sprintf("%s:%d", p.config.DNSHost, p.config.DNSPort)
log.Printf("Starting Proxmox DNS server on %s", addr)
log.Printf("Base domain: %s", p.config.DNSSuffix)
log.Printf("Upstream DNS: %s", p.config.UpstreamDNS)
server := &dns.Server{Addr: addr, Net: "udp"}
return server.ListenAndServe()
}
func main() {
configPath := flag.String("c", "/etc/proxmox-dns/config.yaml", "Path to configuration file")
flag.Parse()
server, err := NewProxmoxDNS(*configPath)
if err != nil {
log.Fatalf("Failed to create DNS server: %v", err)
}
log.Printf("Proxmox DNS Server starting...")
if err := server.Start(); err != nil {
log.Fatalf("Failed to start DNS server: %v", err)
}
}