├── .gitignore ├── scheme.excalidraw.png ├── .env.example ├── proxmox-service-discovery.service.example ├── go.mod ├── .vscode └── launch.json ├── go.sum ├── main.go ├── README.md ├── .github └── workflows │ └── release.yml └── proxmox.go /.gitignore: -------------------------------------------------------------------------------- 1 | ./DNS 2 | .env 3 | __debug* -------------------------------------------------------------------------------- /scheme.excalidraw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itcaat/proxmox-service-discovery/HEAD/scheme.excalidraw.png -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PROXMOX_URL=https://your-proxmox-api-url 2 | PVE_API_TOKEN=your_proxmox_api_token 3 | DNS_SUFFIX=.example.com 4 | DNS_LISTEN_PORT=2053 5 | DNS_REFRESH_SECONDS=60 6 | DISCOVERY_VM_TAGS=false 7 | DISCOVERY_NODE_CIDR=10.0.3.0/24 8 | -------------------------------------------------------------------------------- /proxmox-service-discovery.service.example: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Proxmox DNS Service Discovery 3 | After=network.target 4 | 5 | [Service] 6 | User=youruser 7 | WorkingDirectory=/path/to/your/dns-service-discovery 8 | ExecStart=/path/to/your/dns-service-discovery/dns-service-discovery-linux 9 | Restart=always 10 | EnvironmentFile=/etc/default/proxmox-service-discovery 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Alphonnse/DNS 2 | 3 | go 1.23 4 | 5 | require ( 6 | golang.org/x/mod v0.18.0 // indirect 7 | golang.org/x/net v0.27.0 // indirect 8 | golang.org/x/sync v0.7.0 // indirect 9 | golang.org/x/sys v0.22.0 // indirect 10 | golang.org/x/tools v0.22.0 // indirect 11 | ) 12 | 13 | require ( 14 | github.com/go-resty/resty/v2 v2.15.3 15 | github.com/joho/godotenv v1.5.1 16 | github.com/miekg/dns v1.1.62 17 | ) 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}", 13 | "dlvFlags": ["--check-go-version=false"] 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8= 2 | github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= 3 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 4 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 5 | github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= 6 | github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= 7 | golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= 8 | golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 9 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= 10 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 11 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 12 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 13 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 14 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 15 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 16 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 17 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 18 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 19 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "os" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/joho/godotenv" 11 | "github.com/miekg/dns" 12 | ) 13 | 14 | var records = map[string]string{} 15 | 16 | // Function to handle DNS requests 17 | func handleDNSRequest(w dns.ResponseWriter, r *dns.Msg) { 18 | msg := new(dns.Msg) 19 | msg.SetReply(r) 20 | msg.Authoritative = true 21 | 22 | found := false 23 | 24 | for _, q := range r.Question { 25 | switch q.Qtype { 26 | case dns.TypeA: 27 | if ip, ok := records[q.Name]; ok { 28 | rr := new(dns.A) 29 | rr.Hdr = dns.RR_Header{ 30 | Name: q.Name, 31 | Rrtype: dns.TypeA, 32 | Class: dns.ClassINET, 33 | Ttl: 60, 34 | } 35 | rr.A = net.ParseIP(ip) 36 | msg.Answer = append(msg.Answer, rr) 37 | found = true 38 | } 39 | } 40 | } 41 | 42 | if !found { 43 | c := new(dns.Client) 44 | externDNS := "8.8.8.8:53" 45 | in, _, err := c.Exchange(r, externDNS) 46 | if err != nil { 47 | log.Printf("Error during recursive query: %v", err) 48 | return 49 | } 50 | 51 | if in != nil { 52 | w.WriteMsg(in) 53 | return 54 | } 55 | } 56 | 57 | if err := w.WriteMsg(msg); err != nil { 58 | log.Printf("Error sending response: %v", err) 59 | } 60 | } 61 | 62 | func main() { 63 | // Load environment variables from .env file 64 | err := godotenv.Load() 65 | if err != nil { 66 | log.Fatal("Error loading .env file") 67 | } 68 | 69 | // Read environment variables 70 | proxmoxURL := os.Getenv("PROXMOX_URL") 71 | apiToken := os.Getenv("PVE_API_TOKEN") 72 | dnsSuffix := os.Getenv("DNS_SUFFIX") 73 | useProxmoxTags := os.Getenv("DISCOVERY_VM_TAGS") 74 | discoveryCIDR := os.Getenv("DISCOVERY_NODE_CIDR") 75 | port := os.Getenv("DNS_LISTEN_PORT") 76 | if port == "" { 77 | port = "2053" // Default port 78 | } 79 | refreshSecondsStr := os.Getenv("DNS_REFRESH_SECONDS") 80 | refreshSeconds, err := strconv.Atoi(refreshSecondsStr) 81 | if err != nil || refreshSeconds <= 0 { 82 | refreshSeconds = 60 // Default to 60 seconds 83 | } 84 | 85 | // Periodically update records based on refresh interval 86 | go func() { 87 | for { 88 | updateRecordsFromProxmox(records, proxmoxURL, apiToken, dnsSuffix, useProxmoxTags, discoveryCIDR) 89 | time.Sleep(time.Duration(refreshSeconds) * time.Second) 90 | } 91 | }() 92 | 93 | dns.HandleFunc(".", handleDNSRequest) 94 | 95 | server := &dns.Server{Addr: ":" + port, Net: "udp"} 96 | 97 | log.Printf("Starting DNS server on port %s...", port) 98 | err = server.ListenAndServe() 99 | if err != nil { 100 | log.Fatalf("Failed to start DNS server: %v", err) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proxmox Service Discovery 2 | 3 | This project implements a DNS-based service discovery mechanism using Go for Proxmox Cluster. The service retrieves virtual machine (VM) information from a Proxmox API and automatically generates DNS `A` records based on VM names and tags. These records are updated periodically and can be used to discover services in a dynamic environment. 4 | 5 | 6 | 7 | ## Features 8 | 9 | - Retrieves VM information (name, tags, IP) from the Proxmox API. 10 | - Dynamically generates DNS `A` records based on VM names and tags. 11 | - Updates DNS records every minute, ensuring the latest VM state. 12 | - Uses a custom DNS suffix for generated records. 13 | - Supports service discovery by allowing other services to resolve VMs by name or tag using DNS. 14 | 15 | ## How It Works 16 | 17 | 1. The service queries the Proxmox API to fetch a list of VMs for each node. 18 | 2. For each VM, the service retrieves the VM's configuration, including: 19 | - Node name and IP Address (`DISCOVERY_NODE_CIDR`) 20 | - VM name (`name`) and IP address (`ipconfig0`) 21 | - VM tags (`tags`), which are separated by semicolons. (`DISCOVERY_VM_TAGS`) 22 | 3. The service creates DNS `A` records for each Node in Cluster and VM based on its name and tags, appending a configurable DNS suffix to each record. 23 | 4. These records are updated in memory every 60 seconds to reflect changes in the Proxmox environment. 24 | 5. The service runs a DNS server on port `2053` that resolves DNS queries based on the stored records. 25 | 26 | ## Requirements 27 | 28 | - Go 1.18+ 29 | - A running Proxmox instance with API access 30 | 31 | ## Installation 32 | 33 | You can use the way you want to start service. For example, using systemd 34 | 35 | 1. Download last release version from github 36 | 2. Copy `proxmox-service-discovery.service.example` to `/etc/systemd/system/proxmox-service-discovery.service` and change values 37 | 3. Copy `.env.example` file `/path/to/your/dns-service-discovery/.env` and fill values. 38 | 4. `sudo systemctl daemon-reload` 39 | 5. `sudo systemctl enable proxmox-service-discovery` 40 | 6. `sudo systemctl start proxmox-service-discovery` 41 | 7. `sudo systemctl status proxmox-service-discovery` 42 | 43 | ## Development 44 | 45 | 1. Clone the repository: 46 | 47 | ```sh 48 | git clone https://github.com/nrukavkov/proxmox-service-discovery.git 49 | cd proxmox-service-discovery 50 | ``` 51 | 2. Install dependencies: 52 | 53 | ```sh 54 | go mod tidy 55 | ``` 56 | 3. Create a .env file to configure the service: 57 | 58 | ```sh 59 | touch .env 60 | ``` 61 | 62 | 4. In the .env file, add the following environment variables: 63 | 64 | ``` 65 | PROXMOX_URL=https://your-proxmox-api-url 66 | PVE_API_TOKEN=your-proxmox-api-token 67 | DNS_SUFFIX=.proxmox.example.com 68 | DNS_LISTEN_PORT=53 # use 2053 for local testing 69 | DNS_REFRESH_SECONDS=60 # how ofter go to proxmox api 70 | DISCOVERY_VM_TAGS=true # if true proxmox-service-discovery records will be filled also with tags 71 | DISCOVERY_NODE_CIDR=192.168.0.0/24 72 | ``` 73 | 74 | 5. Build and run the Go application: 75 | 76 | ```sh 77 | go build -o proxmox-service-discovery 78 | ./proxmox-service-discovery 79 | ``` 80 | 81 | The DNS server will start on port 2053 and will update DNS records every minute. 82 | 83 | To test the DNS queries, you can use dig or any other DNS client: 84 | 85 | ```sh 86 | dig @localhost -p 2053 vm01.proxmox.example.com 87 | ``` 88 | 89 | ## License 90 | 91 | This project is licensed under the MIT License. See the LICENSE file for details. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build, Tag, and Release proxmox-service-discovery 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | version: 10 | outputs: 11 | app_version: ${{ steps.version.outputs.new_tag }} 12 | changelog: ${{ steps.version.outputs.changelog }} 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Bump version and push tag 20 | id: version 21 | uses: mathieudutour/github-tag-action@v6.2 22 | with: 23 | github_token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | build: 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | matrix: 30 | goarch: [amd64, arm64] 31 | 32 | steps: 33 | - name: Checkout code 34 | uses: actions/checkout@v4 35 | 36 | - name: Set up Go 37 | uses: actions/setup-go@v5 38 | with: 39 | go-version: '1.23' 40 | 41 | - name: Set architecture 42 | run: echo "GOARCH=${{ matrix.goarch }}" >> $GITHUB_ENV 43 | 44 | - name: Install dependencies 45 | run: go mod download 46 | 47 | - name: Build proxmox-service-discovery 48 | run: go build -o proxmox-service-discovery-linux-${{ matrix.goarch }} 49 | 50 | - name: Package binary into a ZIP file 51 | run: zip proxmox-service-discovery-linux-${{ matrix.goarch }}.zip proxmox-service-discovery-linux-${{ matrix.goarch }} 52 | 53 | - name: Upload ZIP artifact 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: proxmox-service-discovery-linux-${{ matrix.goarch }}.zip 57 | path: ./proxmox-service-discovery-linux-${{ matrix.goarch }}.zip 58 | 59 | release: 60 | needs: 61 | - version 62 | - build 63 | runs-on: ubuntu-latest 64 | steps: 65 | - name: Checkout code 66 | uses: actions/checkout@v4 67 | 68 | - name: Download ZIP artifacts for amd64 69 | uses: actions/download-artifact@v4 70 | with: 71 | name: proxmox-service-discovery-linux-amd64.zip 72 | 73 | - name: Download ZIP artifacts for arm64 74 | uses: actions/download-artifact@v4 75 | with: 76 | name: proxmox-service-discovery-linux-arm64.zip 77 | 78 | - name: Create GitHub Release 79 | id: create_release 80 | uses: ncipollo/release-action@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | with: 84 | tag: ${{ needs.version.outputs.app_version }} 85 | name: Release ${{ needs.version.outputs.app_version }} 86 | body: ${{ needs.version.outputs.changelog }} 87 | generateReleaseNotes: true 88 | 89 | - name: Upload Linux AMD64 ZIP artifact 90 | uses: actions/upload-release-asset@v1 91 | with: 92 | upload_url: ${{ steps.create_release.outputs.upload_url }} 93 | asset_path: ./proxmox-service-discovery-linux-amd64.zip 94 | asset_name: proxmox-service-discovery-linux-amd64.zip 95 | asset_content_type: application/zip 96 | env: 97 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 98 | 99 | - name: Upload Linux ARM64 ZIP artifact 100 | uses: actions/upload-release-asset@v1 101 | with: 102 | upload_url: ${{ steps.create_release.outputs.upload_url }} 103 | asset_path: ./proxmox-service-discovery-linux-arm64.zip 104 | asset_name: proxmox-service-discovery-linux-arm64.zip 105 | asset_content_type: application/zip 106 | env: 107 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 108 | -------------------------------------------------------------------------------- /proxmox.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/go-resty/resty/v2" 11 | ) 12 | 13 | // Structure for the Proxmox API response for nodes 14 | type Node struct { 15 | Node string `json:"node"` 16 | } 17 | 18 | // Structure for the Proxmox API response for VMs 19 | type VM struct { 20 | VMID int `json:"vmid"` // VMID is an integer in the API response 21 | Tags string `json:"tags"` // Tags separated by semicolons 22 | } 23 | 24 | // Structure for the Proxmox API response for VM configuration (contains nested "data" field) 25 | type VMConfigResponse struct { 26 | Data VMConfig `json:"data"` // Nested object containing ipconfig0 and name 27 | } 28 | 29 | type VMConfig struct { 30 | IPConfig0 string `json:"ipconfig0"` // IP configuration 31 | Name string `json:"name"` // Name of the VM 32 | } 33 | 34 | // Structure for network interface data of the node 35 | type NetworkInterface struct { 36 | Iface string `json:"iface"` 37 | Address string `json:"address"` 38 | CIDR string `json:"cidr"` 39 | Type string `json:"type"` 40 | Families []string `json:"families"` 41 | } 42 | 43 | type NodeNetworkResponse struct { 44 | Data []NetworkInterface `json:"data"` 45 | } 46 | 47 | // Example structure for the Proxmox API response 48 | type ProxmoxNodesResponse struct { 49 | Data []Node `json:"data"` 50 | } 51 | 52 | type ProxmoxVMsResponse struct { 53 | Data []VM `json:"data"` 54 | } 55 | 56 | // Resty client for HTTP requests 57 | var client = resty.New() 58 | 59 | // Generic function for Proxmox API requests 60 | func fetchFromProxmox(url, apiToken string, result interface{}) error { 61 | resp, err := client.R(). 62 | SetHeader("Authorization", "PVEAPIToken="+apiToken). 63 | Get(url) 64 | 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if resp.StatusCode() < 200 || resp.StatusCode() >= 300 { 70 | return fmt.Errorf("unexpected status code: %d", resp.StatusCode()) 71 | } 72 | 73 | err = json.Unmarshal(resp.Body(), &result) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | return nil 79 | } 80 | 81 | // Function to get and update DNS records from Proxmox, including VMs and node network data 82 | func updateRecordsFromProxmox(records map[string]string, proxmoxURL, apiToken, dnsSuffix, useProxmoxTags, discoveryCIDR string) { 83 | // Temporary variable to hold the new records 84 | newRecords := map[string]string{} 85 | 86 | // Fetching all nodes 87 | var nodesResp ProxmoxNodesResponse 88 | err := fetchFromProxmox(fmt.Sprintf("%s/api2/json/nodes", proxmoxURL), apiToken, &nodesResp) 89 | if err != nil { 90 | log.Printf("Error fetching node list: %v. Skipping update.", err) 91 | return // Do not update records if an error occurs 92 | } 93 | 94 | // For each node, fetch VMs, their configuration, and network information 95 | for _, node := range nodesResp.Data { 96 | var vmsResp ProxmoxVMsResponse 97 | err := fetchFromProxmox(fmt.Sprintf("%s/api2/json/nodes/%s/qemu", proxmoxURL, node.Node), apiToken, &vmsResp) 98 | if err != nil { 99 | log.Printf("Error fetching VMs for node %s: %v. Skipping this node.", node.Node, err) 100 | continue // Skip this node and move to the next one 101 | } 102 | 103 | // For each VM, fetch configuration and extract IP address and name 104 | for _, vm := range vmsResp.Data { 105 | var configResp VMConfigResponse 106 | err := fetchFromProxmox(fmt.Sprintf("%s/api2/json/nodes/%s/qemu/%d/config", proxmoxURL, node.Node, vm.VMID), apiToken, &configResp) 107 | if err != nil { 108 | log.Printf("Error fetching configuration for VM %d on node %s: %v. Skipping this VM.", vm.VMID, node.Node, err) 109 | continue // Skip this VM and move to the next one 110 | } 111 | 112 | ip := extractIPFromConfig(configResp.Data.IPConfig0) 113 | if ip != "" { 114 | // Create DNS records based on the VM name 115 | if configResp.Data.Name != "" { 116 | newRecords[configResp.Data.Name+dnsSuffix] = ip 117 | } 118 | 119 | // Check if tags should be used to create DNS records 120 | if useProxmoxTags == "true" && vm.Tags != "" { 121 | tags := strings.Split(vm.Tags, ";") 122 | for _, tag := range tags { 123 | tag = strings.TrimSpace(tag) // Trim any extra spaces 124 | tag = sanitizeTag(tag) // Additional tag sanitization 125 | if tag != "" { 126 | // Create a record based on the tag and IP address 127 | newRecords[tag+dnsSuffix] = ip 128 | } 129 | } 130 | } 131 | } 132 | } 133 | 134 | // Fetch network information for the node 135 | var nodeNetworkResp NodeNetworkResponse 136 | err = fetchFromProxmox(fmt.Sprintf("%s/api2/json/nodes/%s/network", proxmoxURL, node.Node), apiToken, &nodeNetworkResp) 137 | if err != nil { 138 | log.Printf("Error fetching network information for node %s: %v. Skipping network records for this node.", node.Node, err) 139 | continue 140 | } 141 | 142 | // Add network information for the interface that matches the DISCOVERY_NODE_CIDR 143 | if discoveryCIDR != "" { 144 | for _, iface := range nodeNetworkResp.Data { 145 | if iface.CIDR == discoveryCIDR && iface.Address != "" { 146 | // Add node's interface with matching CIDR to the DNS records 147 | newRecords[node.Node+dnsSuffix] = iface.Address 148 | break 149 | } 150 | } 151 | } 152 | } 153 | 154 | // Update global records only if there were no errors 155 | for k, v := range newRecords { 156 | records[k] = v 157 | } 158 | 159 | log.Printf("Successfully updated records: %v", newRecords) 160 | } 161 | 162 | // Function to extract IP address from the ipconfig0 string 163 | func extractIPFromConfig(ipconfig string) string { 164 | // Example string: "ip=10.0.5.62/24,gw=10.0.5.1" 165 | re := regexp.MustCompile(`ip=([\d\.]+)`) 166 | matches := re.FindStringSubmatch(ipconfig) 167 | if len(matches) > 1 { 168 | return matches[1] 169 | } 170 | return "" 171 | } 172 | 173 | // Additional function to sanitize tags 174 | func sanitizeTag(tag string) string { 175 | // Add any logic to remove forbidden characters 176 | return strings.ToLower(tag) // For example, convert all tags to lowercase 177 | } 178 | --------------------------------------------------------------------------------