Merge pull request 'chore: remove the decommissioned Proxmox DNS server and its migration script' (#622) from drop-decommissioned-dns into master
ci/woodpecker/push/deploy-infrastructure Pipeline failed
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline failed

Reviewed-on: k.renner/homelab#622
This commit was merged in pull request #622.
This commit is contained in:
2026-08-26 05:20:29 +00:00
8 changed files with 0 additions and 722 deletions
@@ -253,22 +253,6 @@ endpoints:
{% endif %}
# Infrastructure
- name: Proxmox DNS (VIP)
group: infrastructure
url: 10.2.40.53
interval: 30s
dns:
query-name: k8s-1.srv.renner.uno
query-type: A
conditions:
- "[DNS_RCODE] == NOERROR"
- "[RESPONSE_TIME] < 500"
alerts:
- type: email
{% if gatus_webhook_url %}
- type: custom
{% endif %}
- name: PostgreSQL HA (VIP)
group: infrastructure
url: tcp://10.2.40.135:5432
-31
View File
@@ -1,31 +0,0 @@
# Proxmox DNS Server Configuration
# Proxmox API connection
proxmox:
host: "pve.renner.uno"
user: "root@pam"
# Token-based authentication (recommended)
token_name: "dns"
token_value: "your-token-secret-here"
# OR password-based authentication
# password: "your-password-here"
verify_ssl: false
# DNS server settings
dns_host: "0.0.0.0" # Listen on all interfaces
dns_port: 53 # Standard DNS port (requires root or CAP_NET_BIND_SERVICE)
# DNS suffix for VM/LXC hostnames
dns_suffix: ".renner.uno"
# TTL for DNS responses (in seconds)
dns_ttl: 300
# Cache settings
cache_ttl: 60 # How often to refresh the cache (in seconds)
# Upstream DNS server for non-Proxmox queries
upstream_dns: "10.2.40.1"
-15
View File
@@ -1,15 +0,0 @@
module proxmox-dns
go 1.21
require (
github.com/miekg/dns v1.1.58
gopkg.in/yaml.v3 v3.0.1
)
require (
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/tools v0.17.0 // indirect
)
-16
View File
@@ -1,16 +0,0 @@
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-565
View File
@@ -1,565 +0,0 @@
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)
}
}
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
[Unit]
Description=Proxmox DNS Server
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/opt/proxmox-dns
ExecStart=/usr/bin/python3 /opt/proxmox-dns/proxmox_dns.py -c /etc/proxmox-dns/config.yaml
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
-52
View File
@@ -1,52 +0,0 @@
#!/bin/bash
# Script to help with UniFi client migration
#
# NOTE: This script is NO LONGER RECOMMENDED
# Instead, follow the migration guide in MIGRATION_QUICKSTART.md
#
# This script removes unifi_client resources from state, but the recommended
# approach is to let 'tofu apply' handle the removal automatically.
echo "⚠️ WARNING: This script is deprecated"
echo ""
echo "Please use the recommended migration path instead:"
echo " 1. Deploy DNS server: tofu apply -target=module.proxmox_dns"
echo " 2. Configure UniFi DNS to use 10.2.40.53"
echo " 3. Run full apply: tofu apply"
echo ""
echo "See MIGRATION_QUICKSTART.md for details."
echo ""
read -p "Do you still want to remove resources from state? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Cancelled. See MIGRATION_QUICKSTART.md for recommended approach."
exit 0
fi
set -e
echo "Removing unifi_client resources from OpenTofu state..."
echo "Note: VMs will keep their current network configuration"
echo ""
# List of all unifi_client resources to remove
RESOURCES=(
"module.cdns_srv_renner_uno.unifi_client.this[0]"
"module.fortress_renner_uno.unifi_client.this[0]"
"module.grafana_srv_renner_uno.unifi_client.this[0]"
"module.influxdb_srv_renner_uno.unifi_client.this[0]"
"module.kasm_srv_renner_uno.unifi_client.this[0]"
"module.mosquitto_srv_renner_uno.unifi_client.this[0]"
"module.netbox_srv_renner_uno.unifi_client.this[0]"
"module.ntp_srv_renner_uno.unifi_client.this[0]"
"module.paperless_srv_renner_uno.unifi_client.this[0]"
"module.uptime_kuma_srv_renner_uno.unifi_client.this[0]"
)
for resource in "${RESOURCES[@]}"; do
echo "Removing: $resource"
tofu state rm "$resource" || echo " (already removed or not found)"
done
echo ""
echo "Done! Run 'tofu plan' to verify."