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
566 lines
13 KiB
Go
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)
|
|
}
|
|
}
|