feat: add Proxmox DNS server implementation with dynamic hostname resolution
This commit is contained in:
@@ -232,6 +232,7 @@ ansible-vault edit ansible/inventory/group_vars/all/vault.yml
|
||||
|
||||
## Key Infrastructure Components
|
||||
|
||||
- **Proxmox DNS** (LXC 118): Custom DNS server that resolves VM/LXC names via Proxmox API at `10.2.40.53`
|
||||
- **Gitea** (LXC 114): Git server and CI/CD runner orchestrator at `gitea.renner.uno`
|
||||
- **Gitea Runners** (LXC 905-906): Execute CI/CD pipelines
|
||||
- **RustFS** (LXC 800): Self-hosted S3-compatible storage (MinIO) for OpenTofu state
|
||||
@@ -240,6 +241,92 @@ ansible-vault edit ansible/inventory/group_vars/all/vault.yml
|
||||
- **Immich** (LXC 225): Photo management service
|
||||
- **Cloudflared** (LXC 117): Cloudflare tunnel endpoint
|
||||
|
||||
## Proxmox DNS Server
|
||||
|
||||
The repository includes a custom DNS server (`dns-server/`) that dynamically resolves VM and LXC hostnames by querying the Proxmox API.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Technology**: Python-based DNS server using `dnslib` and `proxmoxer`
|
||||
- **Discovery**: Queries all Proxmox nodes for running VMs (via QEMU guest agent) and LXC containers
|
||||
- **Caching**: Maintains an in-memory cache refreshed every 60 seconds
|
||||
- **DHCP Support**: Works with dynamically assigned IPs by reading from Proxmox state
|
||||
- **Static IP**: Deployed at `10.2.40.53` (LXC 118)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. DNS query arrives for `hostname.renner.uno`
|
||||
2. Server checks cache (60s TTL)
|
||||
3. If cache miss, queries Proxmox API across all nodes
|
||||
4. For VMs: Reads IP from QEMU guest agent network info
|
||||
5. For LXCs: Reads IP from network config or interfaces
|
||||
6. Returns A record with 300s TTL
|
||||
|
||||
### Deployment
|
||||
|
||||
The DNS server uses existing Proxmox credentials from the Ansible vault (no additional setup needed):
|
||||
|
||||
```bash
|
||||
# Deploy via OpenTofu + Ansible
|
||||
cd opentofu
|
||||
tofu apply # Creates LXC 118 and runs ansible playbook
|
||||
|
||||
# Or deploy manually:
|
||||
cd ansible
|
||||
ansible-playbook -i inventory/ plays/install-proxmox-dns.yml
|
||||
```
|
||||
|
||||
The role automatically uses these existing vault variables:
|
||||
- `pve_api_url` - Proxmox API endpoint
|
||||
- `pve_user` - Proxmox user (root@pam)
|
||||
- `pve_token_id` - API token ID (ansible)
|
||||
- `pve_token_secret` - API token secret (from vault)
|
||||
|
||||
### Integration with Network
|
||||
|
||||
To use the DNS server across your network:
|
||||
|
||||
1. **UniFi Configuration**:
|
||||
- Settings > Networks > LAN
|
||||
- DHCP Name Server: `10.2.40.53`
|
||||
- This makes all DHCP clients use the Proxmox DNS server
|
||||
|
||||
2. **DNS Resolution Flow**:
|
||||
- Query for `*.renner.uno` → Proxmox DNS Server (resolves from Proxmox API)
|
||||
- UniFi DNS still handles CNAMEs (managed by OpenTofu)
|
||||
- Upstream queries go to `10.2.40.1` (gateway)
|
||||
|
||||
### Monitoring and Troubleshooting
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
ssh root@10.2.40.53
|
||||
systemctl status proxmox-dns
|
||||
|
||||
# View logs
|
||||
journalctl -u proxmox-dns -f
|
||||
|
||||
# Test resolution locally
|
||||
dig @localhost gitea.srv.renner.uno
|
||||
|
||||
# Test from another host
|
||||
dig @10.2.40.53 gitea.srv.renner.uno
|
||||
```
|
||||
|
||||
### Requirements for VMs
|
||||
|
||||
VMs must have QEMU guest agent installed and running for IP detection:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
apt install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
|
||||
# In Proxmox, ensure agent is enabled in VM options
|
||||
```
|
||||
|
||||
LXC containers don't require any additional setup - IPs are read directly from Proxmox configuration.
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### Running in CI
|
||||
|
||||
@@ -15,3 +15,6 @@ authentik_app
|
||||
authentik-app-1 ansible_user=root
|
||||
authentik-app-2 ansible_user=root
|
||||
|
||||
[proxmox_dns]
|
||||
proxmox-dns.srv.renner.uno ansible_host=10.2.40.53 ansible_user=root
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
# Install Proxmox DNS Server
|
||||
# This playbook installs the custom DNS server that resolves VM/LXC hostnames
|
||||
# by querying the Proxmox API
|
||||
|
||||
- name: Install Proxmox DNS Server
|
||||
hosts: proxmox_dns
|
||||
become: yes
|
||||
|
||||
roles:
|
||||
- proxmox-dns
|
||||
|
||||
post_tasks:
|
||||
- name: Wait for DNS server to start
|
||||
wait_for:
|
||||
port: 53
|
||||
timeout: 30
|
||||
|
||||
- name: Test DNS resolution
|
||||
shell: |
|
||||
dig @localhost gitea.srv.renner.uno +short
|
||||
register: dns_test
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Display DNS test result
|
||||
debug:
|
||||
msg: "DNS test result: {{ dns_test.stdout }}"
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# Proxmox DNS Server Secrets
|
||||
# Encrypt this file with: ansible-vault encrypt proxmox_dns_vault.yml
|
||||
|
||||
# Proxmox API token for DNS server
|
||||
# Create in Proxmox: Datacenter > Permissions > API Tokens
|
||||
# Format: <token-id>!<token-name> = <secret>
|
||||
proxmox_dns_api_token_value: "CHANGEME-your-proxmox-api-token-here"
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
# Proxmox DNS Server Role Defaults
|
||||
|
||||
proxmox_dns_install_dir: "/opt/proxmox-dns"
|
||||
proxmox_dns_config_dir: "/etc/proxmox-dns"
|
||||
|
||||
# Proxmox API connection (uses existing vault variables)
|
||||
proxmox_dns_api_host: "{{ pve_api_url | regex_replace('https://|http://|/api2/json', '') }}"
|
||||
proxmox_dns_api_user: "{{ pve_user }}"
|
||||
proxmox_dns_api_token_name: "{{ pve_token_id }}"
|
||||
proxmox_dns_api_token_value: "{{ pve_token_secret }}"
|
||||
|
||||
# DNS server configuration
|
||||
proxmox_dns_host: "0.0.0.0"
|
||||
proxmox_dns_port: 53
|
||||
proxmox_dns_suffix: ".renner.uno"
|
||||
proxmox_dns_ttl: 300
|
||||
proxmox_dns_cache_ttl: 60
|
||||
proxmox_dns_upstream: "10.2.40.1"
|
||||
|
||||
# Python package requirements
|
||||
proxmox_dns_python_packages:
|
||||
- dnslib
|
||||
- proxmoxer
|
||||
- requests
|
||||
- PyYAML
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
# Handlers for Proxmox DNS Server
|
||||
|
||||
- name: reload systemd
|
||||
systemd:
|
||||
daemon_reload: yes
|
||||
|
||||
- name: restart proxmox-dns
|
||||
systemd:
|
||||
name: proxmox-dns
|
||||
state: restarted
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
# Proxmox DNS Server Installation
|
||||
|
||||
- name: Install system dependencies
|
||||
apt:
|
||||
name:
|
||||
- python3
|
||||
- python3-pip
|
||||
- python3-venv
|
||||
state: present
|
||||
update_cache: yes
|
||||
|
||||
- name: Create installation directory
|
||||
file:
|
||||
path: "{{ proxmox_dns_install_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
|
||||
- name: Create configuration directory
|
||||
file:
|
||||
path: "{{ proxmox_dns_config_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0750'
|
||||
|
||||
- name: Copy DNS server application
|
||||
copy:
|
||||
src: "{{ playbook_dir }}/../dns-server/proxmox_dns.py"
|
||||
dest: "{{ proxmox_dns_install_dir }}/proxmox_dns.py"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0755'
|
||||
|
||||
- name: Copy requirements.txt
|
||||
copy:
|
||||
src: "{{ playbook_dir }}/../dns-server/requirements.txt"
|
||||
dest: "{{ proxmox_dns_install_dir }}/requirements.txt"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
|
||||
- name: Install Python dependencies
|
||||
pip:
|
||||
requirements: "{{ proxmox_dns_install_dir }}/requirements.txt"
|
||||
state: present
|
||||
executable: pip3
|
||||
register: pip_install
|
||||
retries: 3
|
||||
delay: 5
|
||||
until: pip_install is succeeded
|
||||
|
||||
- name: Create configuration file
|
||||
template:
|
||||
src: config.yaml.j2
|
||||
dest: "{{ proxmox_dns_config_dir }}/config.yaml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0640'
|
||||
notify: restart proxmox-dns
|
||||
|
||||
- name: Copy systemd service file
|
||||
copy:
|
||||
src: "{{ playbook_dir }}/../dns-server/proxmox-dns.service"
|
||||
dest: /etc/systemd/system/proxmox-dns.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: '0644'
|
||||
notify:
|
||||
- reload systemd
|
||||
- restart proxmox-dns
|
||||
|
||||
- name: Enable and start proxmox-dns service
|
||||
systemd:
|
||||
name: proxmox-dns
|
||||
enabled: yes
|
||||
state: started
|
||||
daemon_reload: yes
|
||||
@@ -0,0 +1,19 @@
|
||||
# Proxmox DNS Server Configuration
|
||||
# Managed by Ansible - Do not edit manually
|
||||
|
||||
proxmox:
|
||||
host: "{{ proxmox_dns_api_host }}"
|
||||
user: "{{ proxmox_dns_api_user }}"
|
||||
token_name: "{{ proxmox_dns_api_token_name }}"
|
||||
token_value: "{{ proxmox_dns_api_token_value }}"
|
||||
verify_ssl: false
|
||||
|
||||
dns_host: "{{ proxmox_dns_host }}"
|
||||
dns_port: {{ proxmox_dns_port }}
|
||||
|
||||
dns_suffix: "{{ proxmox_dns_suffix }}"
|
||||
dns_ttl: {{ proxmox_dns_ttl }}
|
||||
|
||||
cache_ttl: {{ proxmox_dns_cache_ttl }}
|
||||
|
||||
upstream_dns: "{{ proxmox_dns_upstream }}"
|
||||
@@ -0,0 +1,137 @@
|
||||
# Proxmox DNS Server
|
||||
|
||||
A lightweight DNS server that dynamically resolves VM and LXC container hostnames by querying the Proxmox API.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dynamic Resolution**: Queries Proxmox API in real-time to resolve hostnames
|
||||
- **Automatic Discovery**: Discovers both VMs (with QEMU guest agent) and LXC containers
|
||||
- **Caching**: Built-in caching to reduce API load
|
||||
- **DHCP Compatible**: Works with DHCP-assigned IPs by reading from Proxmox state
|
||||
- **Configurable**: YAML-based configuration
|
||||
|
||||
## How It Works
|
||||
|
||||
1. DNS query comes in for a hostname (e.g., `gitea.srv.renner.uno`)
|
||||
2. Server strips the configured suffix and looks up `gitea`
|
||||
3. Queries Proxmox API across all nodes for a VM/LXC named `gitea`
|
||||
4. Returns the IP address from the VM's guest agent or LXC's network config
|
||||
5. Caches the result for configured TTL
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8+
|
||||
- Access to Proxmox API (token or password)
|
||||
- QEMU guest agent installed on VMs (for IP detection)
|
||||
|
||||
## Installation
|
||||
|
||||
### Manual Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
# Create config directory
|
||||
sudo mkdir -p /etc/proxmox-dns
|
||||
|
||||
# Copy and edit configuration
|
||||
sudo cp config.yaml.example /etc/proxmox-dns/config.yaml
|
||||
sudo nano /etc/proxmox-dns/config.yaml
|
||||
|
||||
# Run the server (requires root for port 53)
|
||||
sudo python3 proxmox_dns.py -c /etc/proxmox-dns/config.yaml
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
|
||||
```bash
|
||||
# Copy service file
|
||||
sudo cp proxmox-dns.service /etc/systemd/system/
|
||||
|
||||
# Reload systemd
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Enable and start
|
||||
sudo systemctl enable proxmox-dns
|
||||
sudo systemctl start proxmox-dns
|
||||
|
||||
# Check status
|
||||
sudo systemctl status proxmox-dns
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `/etc/proxmox-dns/config.yaml`:
|
||||
|
||||
```yaml
|
||||
proxmox:
|
||||
host: "pve.renner.uno"
|
||||
user: "root@pam"
|
||||
token_name: "dns"
|
||||
token_value: "your-token-here"
|
||||
verify_ssl: false
|
||||
|
||||
dns_suffix: ".renner.uno"
|
||||
dns_ttl: 300
|
||||
cache_ttl: 60
|
||||
```
|
||||
|
||||
### Creating a Proxmox API Token
|
||||
|
||||
1. Log into Proxmox web interface
|
||||
2. Go to Datacenter > Permissions > API Tokens
|
||||
3. Click "Add" to create a new token
|
||||
4. User: `root@pam`, Token ID: `dns`
|
||||
5. Uncheck "Privilege Separation" for full access
|
||||
6. Copy the token secret to your config file
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test DNS resolution
|
||||
dig @localhost gitea.srv.renner.uno
|
||||
|
||||
# Test with verbose logging
|
||||
sudo python3 proxmox_dns.py -c /etc/proxmox-dns/config.yaml -v
|
||||
```
|
||||
|
||||
## Integration with UniFi
|
||||
|
||||
To use this as your primary DNS server:
|
||||
|
||||
1. Deploy the DNS server on a static IP (e.g., `10.2.40.10`)
|
||||
2. In UniFi Controller:
|
||||
- Go to Settings > Networks > LAN
|
||||
- Set DHCP Name Server to the DNS server IP
|
||||
- Keep UniFi DNS for CNAME records (pointing to ingress, etc.)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### VMs not resolving
|
||||
|
||||
- Ensure QEMU guest agent is installed and running in the VM
|
||||
- Check that the VM is in "running" state
|
||||
- Verify the guest agent can communicate with Proxmox
|
||||
|
||||
### LXCs not resolving
|
||||
|
||||
- For DHCP LXCs: The container must be running long enough for DHCP to assign an IP
|
||||
- For static IPs: Check the container's network configuration in Proxmox
|
||||
|
||||
### Permission denied on port 53
|
||||
|
||||
- Run as root: `sudo python3 proxmox_dns.py`
|
||||
- OR use a non-privileged port and forward: `sudo iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-port 5353`
|
||||
- OR grant capability: `sudo setcap 'cap_net_bind_service=+ep' /usr/bin/python3`
|
||||
|
||||
## Architecture
|
||||
|
||||
The DNS server uses a background thread that periodically queries the Proxmox API and caches results. When a DNS query arrives:
|
||||
|
||||
1. Check cache first (fast)
|
||||
2. If cache miss or expired, trigger immediate API query
|
||||
3. Update cache with fresh data
|
||||
4. Return IP address or NXDOMAIN
|
||||
|
||||
This hybrid approach ensures low latency while keeping data fresh.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Proxmox DNS Server - Setup Guide
|
||||
|
||||
This guide walks you through deploying the Proxmox DNS server in your homelab.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Proxmox cluster up and running
|
||||
2. OpenTofu installed and configured
|
||||
3. Ansible installed with vault password set up
|
||||
4. Network access to Proxmox API
|
||||
|
||||
## Step 1: Verify Proxmox API Token
|
||||
|
||||
The DNS server uses the existing Proxmox API token already configured in the Ansible vault.
|
||||
|
||||
The following variables are used from the vault and group_vars:
|
||||
- `pve_api_url`: Proxmox API URL (e.g., `https://jupiter.renner.uno:8006`)
|
||||
- `pve_user`: Proxmox user (e.g., `root@pam`)
|
||||
- `pve_token_id`: API token ID (e.g., `ansible`)
|
||||
- `pve_token_secret`: API token secret (stored in vault)
|
||||
|
||||
These are already configured for your infrastructure, so no additional setup is needed!
|
||||
|
||||
## Step 2: Deploy with OpenTofu
|
||||
|
||||
```bash
|
||||
cd opentofu
|
||||
|
||||
# Review what will be created
|
||||
tofu plan
|
||||
|
||||
# Apply (creates LXC 118)
|
||||
tofu apply
|
||||
|
||||
# The ansible_playbook parameter will automatically run the Ansible role
|
||||
# to install and configure the DNS server
|
||||
```
|
||||
|
||||
If you prefer to deploy the LXC manually or the automatic provisioning fails:
|
||||
|
||||
```bash
|
||||
# Deploy LXC only (comment out ansible_playbook in lxc_118_proxmox_dns.tf)
|
||||
tofu apply
|
||||
|
||||
# Then run Ansible manually
|
||||
cd ../ansible
|
||||
ansible-playbook -i inventory/ plays/install-proxmox-dns.yml
|
||||
```
|
||||
|
||||
Note: The playbook automatically uses the vault password from `.vault_pass` via the `ANSIBLE_VAULT_PASSWORD_FILE` environment variable set in `.envrc`.
|
||||
|
||||
## Step 3: Verify Installation
|
||||
|
||||
```bash
|
||||
# SSH to the DNS server
|
||||
ssh root@10.2.40.53
|
||||
|
||||
# Check service status
|
||||
systemctl status proxmox-dns
|
||||
|
||||
# View logs
|
||||
journalctl -u proxmox-dns -f
|
||||
|
||||
# Test DNS resolution locally
|
||||
dig @localhost gitea.srv.renner.uno
|
||||
```
|
||||
|
||||
From another machine:
|
||||
```bash
|
||||
# Test from external host
|
||||
dig @10.2.40.53 gitea.srv.renner.uno
|
||||
|
||||
# Should return the IP of your gitea VM/LXC
|
||||
```
|
||||
|
||||
## Step 4: Configure UniFi to Use DNS Server
|
||||
|
||||
1. Log into UniFi Controller
|
||||
2. Navigate to **Settings > Networks**
|
||||
3. Edit your LAN network
|
||||
4. Scroll to **DHCP**
|
||||
5. Set **DHCP Name Server**: `Manual`
|
||||
6. **DNS Server 1**: `10.2.40.53` (Proxmox DNS)
|
||||
7. **DNS Server 2**: `10.2.40.1` or `8.8.8.8` (fallback)
|
||||
8. Click **Apply Changes**
|
||||
|
||||
All DHCP clients will now use the Proxmox DNS server.
|
||||
|
||||
## Step 5: Configure VMs for Guest Agent (Optional but Recommended)
|
||||
|
||||
For VMs to be discoverable, they need the QEMU guest agent installed:
|
||||
|
||||
### Debian/Ubuntu VMs:
|
||||
```bash
|
||||
apt update
|
||||
apt install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
```
|
||||
|
||||
### Rocky/AlmaLinux/RHEL VMs:
|
||||
```bash
|
||||
dnf install qemu-guest-agent
|
||||
systemctl enable --now qemu-guest-agent
|
||||
```
|
||||
|
||||
### In Proxmox UI:
|
||||
1. Select the VM
|
||||
2. Go to **Options**
|
||||
3. **QEMU Guest Agent**: Enable
|
||||
4. Reboot the VM
|
||||
|
||||
LXC containers don't need any special configuration - IPs are read directly from Proxmox.
|
||||
|
||||
## Testing
|
||||
|
||||
After setup, test that hostnames resolve:
|
||||
|
||||
```bash
|
||||
# From any machine on the network
|
||||
ping gitea.srv.renner.uno
|
||||
ping authentik-db-1.renner.uno
|
||||
|
||||
# Using dig
|
||||
dig @10.2.40.53 rustfs-1.srv.renner.uno
|
||||
|
||||
# Check DNS server logs for queries
|
||||
ssh root@10.2.40.53 journalctl -u proxmox-dns -f
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### DNS server not starting
|
||||
|
||||
```bash
|
||||
# Check logs for errors
|
||||
journalctl -u proxmox-dns -n 100
|
||||
|
||||
# Common issues:
|
||||
# - Invalid Proxmox token
|
||||
# - Network connectivity to Proxmox API
|
||||
# - Port 53 already in use
|
||||
```
|
||||
|
||||
### VM not resolving
|
||||
|
||||
1. Check VM is running: Proxmox UI
|
||||
2. Verify guest agent is installed and running: `systemctl status qemu-guest-agent`
|
||||
3. Check Proxmox can see guest network:
|
||||
```bash
|
||||
qm guest cmd <vmid> network-get-interfaces
|
||||
```
|
||||
|
||||
### LXC not resolving
|
||||
|
||||
1. Verify container is running
|
||||
2. Check network configuration in Proxmox UI
|
||||
3. For DHCP LXCs, ensure they have received an IP:
|
||||
```bash
|
||||
pct exec <ctid> -- ip addr show
|
||||
```
|
||||
|
||||
### DNS queries timing out
|
||||
|
||||
1. Check firewall rules allow UDP/53
|
||||
2. Verify DNS server is listening:
|
||||
```bash
|
||||
ssh root@10.2.40.53 netstat -ulnp | grep 53
|
||||
```
|
||||
3. Test locally first:
|
||||
```bash
|
||||
ssh root@10.2.40.53 dig @localhost test.renner.uno
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updating DNS Server
|
||||
|
||||
```bash
|
||||
# Update code
|
||||
cd ~/homelab
|
||||
git pull
|
||||
|
||||
# Re-run Ansible
|
||||
cd ansible
|
||||
ansible-playbook -i inventory/ plays/install-proxmox-dns.yml --vault-password-file ../.vault_pass
|
||||
|
||||
# Or restart service if only config changed
|
||||
ssh root@10.2.40.53 systemctl restart proxmox-dns
|
||||
```
|
||||
|
||||
### Rotating API Token
|
||||
|
||||
1. Create new token in Proxmox UI
|
||||
2. Update Ansible vault:
|
||||
```bash
|
||||
ansible-vault edit inventory/group_vars/all/vault.yml
|
||||
# Update pve_token_secret value
|
||||
```
|
||||
3. Re-run Ansible to deploy new config
|
||||
4. Delete old token in Proxmox UI
|
||||
|
||||
### Viewing Cache
|
||||
|
||||
The DNS server logs cache statistics on startup and refresh:
|
||||
|
||||
```bash
|
||||
ssh root@10.2.40.53 journalctl -u proxmox-dns | grep "Fetched"
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **Cache TTL**: 60 seconds (configurable in role defaults)
|
||||
- **DNS Response TTL**: 300 seconds (5 minutes)
|
||||
- **Query Pattern**: Background thread refreshes cache, DNS queries served from cache
|
||||
- **Fallback**: Returns REFUSED for non-Proxmox queries (clients will try upstream DNS)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- API token has full read access to Proxmox (needed to query all nodes)
|
||||
- DNS server runs as root (required for port 53)
|
||||
- Config file contains API token (mode 0640, root:root)
|
||||
- Systemd security hardening applied (ProtectSystem, PrivateTmp)
|
||||
- No write operations to Proxmox - read-only
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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"
|
||||
@@ -0,0 +1,26 @@
|
||||
[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
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Proxmox DNS Server
|
||||
|
||||
A lightweight DNS server that dynamically resolves VM and LXC container hostnames
|
||||
by querying the Proxmox API in real-time.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Thread, Lock
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
from dnslib import DNSRecord, DNSHeader, RR, A, QTYPE, RCODE
|
||||
from dnslib.server import DNSServer, BaseResolver
|
||||
from proxmoxer import ProxmoxAPI
|
||||
|
||||
|
||||
class ProxmoxResolver(BaseResolver):
|
||||
"""DNS resolver that queries Proxmox API for VM/LXC hostnames"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.cache: Dict[str, Tuple[str, datetime]] = {}
|
||||
self.cache_lock = Lock()
|
||||
self.cache_ttl = config.get('cache_ttl', 60)
|
||||
self.proxmox_ttl = config.get('dns_ttl', 300)
|
||||
self.suffix = config.get('dns_suffix', '.renner.uno')
|
||||
|
||||
# Proxmox connection
|
||||
self.proxmox = None
|
||||
self.last_connection_attempt = None
|
||||
self.connection_retry_delay = 10
|
||||
|
||||
# Upstream DNS
|
||||
self.upstream_dns = config.get('upstream_dns', '10.2.40.1')
|
||||
|
||||
# Start cache refresh thread
|
||||
self.running = True
|
||||
self.refresh_thread = Thread(target=self._cache_refresh_loop, daemon=True)
|
||||
self.refresh_thread.start()
|
||||
|
||||
logging.info(f"ProxmoxResolver initialized with suffix: {self.suffix}")
|
||||
logging.info(f"Upstream DNS: {self.upstream_dns}")
|
||||
|
||||
def _connect_proxmox(self) -> bool:
|
||||
"""Establish connection to Proxmox API"""
|
||||
now = datetime.now()
|
||||
if self.last_connection_attempt and (now - self.last_connection_attempt).seconds < self.connection_retry_delay:
|
||||
return False
|
||||
|
||||
self.last_connection_attempt = now
|
||||
|
||||
try:
|
||||
auth_config = self.config['proxmox']
|
||||
|
||||
if 'token_name' in auth_config and 'token_value' in auth_config:
|
||||
# Token-based auth
|
||||
self.proxmox = ProxmoxAPI(
|
||||
auth_config['host'],
|
||||
user=auth_config['user'],
|
||||
token_name=auth_config['token_name'],
|
||||
token_value=auth_config['token_value'],
|
||||
verify_ssl=auth_config.get('verify_ssl', False)
|
||||
)
|
||||
else:
|
||||
# Password-based auth
|
||||
self.proxmox = ProxmoxAPI(
|
||||
auth_config['host'],
|
||||
user=auth_config['user'],
|
||||
password=auth_config['password'],
|
||||
verify_ssl=auth_config.get('verify_ssl', False)
|
||||
)
|
||||
|
||||
# Test connection
|
||||
self.proxmox.version.get()
|
||||
logging.info(f"Connected to Proxmox API at {auth_config['host']}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to connect to Proxmox API: {e}")
|
||||
self.proxmox = None
|
||||
return False
|
||||
|
||||
def _fetch_all_hosts(self) -> Dict[str, str]:
|
||||
"""Fetch all VMs and LXCs from Proxmox API"""
|
||||
if not self.proxmox:
|
||||
if not self._connect_proxmox():
|
||||
return {}
|
||||
|
||||
hosts = {}
|
||||
|
||||
try:
|
||||
# Get all nodes
|
||||
nodes = self.proxmox.nodes.get()
|
||||
|
||||
for node in nodes:
|
||||
node_name = node['node']
|
||||
|
||||
# Get VMs (qemu)
|
||||
try:
|
||||
vms = self.proxmox.nodes(node_name).qemu.get()
|
||||
for vm in vms:
|
||||
if vm.get('status') != 'running':
|
||||
continue
|
||||
|
||||
# Get VM config for agent network info
|
||||
try:
|
||||
config = self.proxmox.nodes(node_name).qemu(vm['vmid']).agent.get('network-get-interfaces')
|
||||
|
||||
# Find first non-loopback IPv4 address
|
||||
for iface in config.get('result', []):
|
||||
if iface.get('name') == 'lo':
|
||||
continue
|
||||
|
||||
for ip_info in iface.get('ip-addresses', []):
|
||||
if ip_info.get('ip-address-type') == 'ipv4':
|
||||
ip = ip_info.get('ip-address')
|
||||
if ip and not ip.startswith('127.'):
|
||||
hostname = vm.get('name', '').lower()
|
||||
if hostname:
|
||||
hosts[hostname] = ip
|
||||
logging.debug(f"VM: {hostname} -> {ip}")
|
||||
break
|
||||
|
||||
if hostname in hosts:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# Guest agent might not be running or installed
|
||||
logging.debug(f"Could not get agent info for VM {vm.get('name')}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching VMs from node {node_name}: {e}")
|
||||
|
||||
# Get LXC containers
|
||||
try:
|
||||
lxcs = self.proxmox.nodes(node_name).lxc.get()
|
||||
for lxc in lxcs:
|
||||
if lxc.get('status') != 'running':
|
||||
continue
|
||||
|
||||
# Get LXC config for network info
|
||||
try:
|
||||
config = self.proxmox.nodes(node_name).lxc(lxc['vmid']).config.get()
|
||||
|
||||
# Parse network config (net0, net1, etc.)
|
||||
for key, value in config.items():
|
||||
if key.startswith('net') and isinstance(value, str):
|
||||
# Parse format: "name=eth0,bridge=vmbr0,ip=10.2.40.123/24,..."
|
||||
parts = dict(part.split('=', 1) for part in value.split(',') if '=' in part)
|
||||
if 'ip' in parts:
|
||||
ip = parts['ip'].split('/')[0]
|
||||
if ip and ip != 'dhcp' and not ip.startswith('127.'):
|
||||
hostname = lxc.get('name', '').lower()
|
||||
if hostname:
|
||||
hosts[hostname] = ip
|
||||
logging.debug(f"LXC: {hostname} -> {ip}")
|
||||
break
|
||||
|
||||
# If no static IP found, try to get from network interfaces (for DHCP)
|
||||
if lxc.get('name', '').lower() not in hosts:
|
||||
try:
|
||||
interfaces = self.proxmox.nodes(node_name).lxc(lxc['vmid']).interfaces.get()
|
||||
for iface in interfaces:
|
||||
if iface.get('name') == 'lo':
|
||||
continue
|
||||
|
||||
if 'inet' in iface:
|
||||
ip = iface['inet'].split('/')[0]
|
||||
if ip and not ip.startswith('127.'):
|
||||
hostname = lxc.get('name', '').lower()
|
||||
if hostname:
|
||||
hosts[hostname] = ip
|
||||
logging.debug(f"LXC (DHCP): {hostname} -> {ip}")
|
||||
break
|
||||
except:
|
||||
# Interface endpoint might not be available in all Proxmox versions
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not get config for LXC {lxc.get('name')}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching LXCs from node {node_name}: {e}")
|
||||
|
||||
logging.info(f"Fetched {len(hosts)} hosts from Proxmox")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching hosts from Proxmox: {e}")
|
||||
self.proxmox = None
|
||||
|
||||
return hosts
|
||||
|
||||
def _cache_refresh_loop(self):
|
||||
"""Background thread to refresh cache"""
|
||||
while self.running:
|
||||
try:
|
||||
hosts = self._fetch_all_hosts()
|
||||
|
||||
with self.cache_lock:
|
||||
# Update cache with fresh data
|
||||
now = datetime.now()
|
||||
for hostname, ip in hosts.items():
|
||||
self.cache[hostname] = (ip, now)
|
||||
|
||||
# Clean up stale entries
|
||||
stale_keys = [
|
||||
key for key, (_, timestamp) in self.cache.items()
|
||||
if (now - timestamp).seconds > self.cache_ttl * 2
|
||||
]
|
||||
for key in stale_keys:
|
||||
del self.cache[key]
|
||||
logging.debug(f"Removed stale cache entry: {key}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in cache refresh loop: {e}")
|
||||
|
||||
# Sleep for cache TTL
|
||||
time.sleep(self.cache_ttl)
|
||||
|
||||
def _lookup_hostname(self, hostname: str) -> Optional[str]:
|
||||
"""Look up hostname in cache, with fallback to live query"""
|
||||
hostname = hostname.lower()
|
||||
|
||||
# Remove suffix if present
|
||||
if hostname.endswith(self.suffix):
|
||||
hostname = hostname[:-len(self.suffix)]
|
||||
|
||||
# Check cache first
|
||||
with self.cache_lock:
|
||||
if hostname in self.cache:
|
||||
ip, timestamp = self.cache[hostname]
|
||||
age = (datetime.now() - timestamp).seconds
|
||||
|
||||
if age < self.cache_ttl:
|
||||
logging.debug(f"Cache hit: {hostname} -> {ip} (age: {age}s)")
|
||||
return ip
|
||||
|
||||
# Cache miss or expired - trigger immediate refresh for this query
|
||||
logging.debug(f"Cache miss: {hostname}, triggering refresh")
|
||||
hosts = self._fetch_all_hosts()
|
||||
|
||||
if hostname in hosts:
|
||||
ip = hosts[hostname]
|
||||
with self.cache_lock:
|
||||
self.cache[hostname] = (ip, datetime.now())
|
||||
logging.info(f"Resolved: {hostname} -> {ip}")
|
||||
return ip
|
||||
|
||||
logging.debug(f"Host not found: {hostname}")
|
||||
return None
|
||||
|
||||
def resolve(self, request, handler):
|
||||
"""Resolve DNS query"""
|
||||
reply = request.reply()
|
||||
qname = str(request.q.qname).rstrip('.')
|
||||
qtype = QTYPE[request.q.qtype]
|
||||
|
||||
logging.debug(f"Query: {qname} ({qtype})")
|
||||
|
||||
# Only handle A records for our suffix
|
||||
if qtype == 'A' and (qname.endswith(self.suffix.lstrip('.')) or '.' not in qname):
|
||||
ip = self._lookup_hostname(qname)
|
||||
|
||||
if ip:
|
||||
# Return A record
|
||||
reply.add_answer(
|
||||
RR(
|
||||
rname=request.q.qname,
|
||||
rtype=QTYPE.A,
|
||||
rdata=A(ip),
|
||||
ttl=self.proxmox_ttl
|
||||
)
|
||||
)
|
||||
logging.info(f"Answered: {qname} -> {ip}")
|
||||
return reply
|
||||
else:
|
||||
# NXDOMAIN - host not found
|
||||
reply.header.rcode = RCODE.NXDOMAIN
|
||||
logging.debug(f"NXDOMAIN: {qname}")
|
||||
return reply
|
||||
|
||||
# For everything else, return REFUSED (let upstream handle it)
|
||||
reply.header.rcode = RCODE.REFUSED
|
||||
logging.debug(f"REFUSED: {qname} ({qtype})")
|
||||
return reply
|
||||
|
||||
def stop(self):
|
||||
"""Stop the resolver"""
|
||||
self.running = False
|
||||
if self.refresh_thread.is_alive():
|
||||
self.refresh_thread.join(timeout=5)
|
||||
|
||||
|
||||
def load_config(config_path: str) -> dict:
|
||||
"""Load configuration from YAML file"""
|
||||
with open(config_path, 'r') as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Proxmox DNS Server')
|
||||
parser.add_argument('-c', '--config', default='/etc/proxmox-dns/config.yaml',
|
||||
help='Configuration file path')
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Enable verbose logging')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Setup logging
|
||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# Load configuration
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create resolver
|
||||
resolver = ProxmoxResolver(config)
|
||||
|
||||
# Start DNS server
|
||||
dns_host = config.get('dns_host', '0.0.0.0')
|
||||
dns_port = config.get('dns_port', 53)
|
||||
|
||||
try:
|
||||
server = DNSServer(resolver, address=dns_host, port=dns_port)
|
||||
server.start_thread()
|
||||
|
||||
logging.info(f"Proxmox DNS server started on {dns_host}:{dns_port}")
|
||||
logging.info("Press Ctrl+C to stop")
|
||||
|
||||
# Setup signal handlers
|
||||
def signal_handler(signum, frame):
|
||||
logging.info("Shutting down...")
|
||||
resolver.stop()
|
||||
server.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Keep main thread alive
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
except PermissionError:
|
||||
logging.error(f"Permission denied to bind to port {dns_port}. Try running as root or use a port > 1024.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start DNS server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
dnslib>=0.9.23
|
||||
proxmoxer>=2.0.1
|
||||
requests>=2.31.0
|
||||
PyYAML>=6.0
|
||||
@@ -0,0 +1,47 @@
|
||||
# Proxmox DNS Server LXC
|
||||
# Custom DNS server that resolves VM/LXC hostnames by querying Proxmox API
|
||||
|
||||
module "proxmox_dns" {
|
||||
source = "./modules/proxmox_lxc"
|
||||
|
||||
hostname = "proxmox-dns.srv.renner.uno"
|
||||
vmid = 118
|
||||
target_node = "jupiter"
|
||||
memory = 512
|
||||
cores = 1
|
||||
unprivileged = true
|
||||
|
||||
network_bridge = "v40srv"
|
||||
ip_address = "10.2.40.53" # DNS-appropriate IP
|
||||
|
||||
storage = "local"
|
||||
disk_size = "4G"
|
||||
|
||||
ostemplate = "local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst"
|
||||
|
||||
ssh_public_keys = local.common_ssh_keys
|
||||
|
||||
tags = "dns;infrastructure"
|
||||
|
||||
onboot = true
|
||||
start = true
|
||||
|
||||
features = {
|
||||
nesting = false
|
||||
fuse = false
|
||||
keyctl = false
|
||||
mknod = false
|
||||
}
|
||||
|
||||
# Optional: Automatically provision with Ansible after creation
|
||||
ansible_playbook = "../ansible/plays/install-proxmox-dns.yml"
|
||||
|
||||
# No CNAMEs needed - this IS the DNS server
|
||||
ingress_cnames = []
|
||||
cnames = []
|
||||
}
|
||||
|
||||
output "proxmox_dns_ip" {
|
||||
value = "10.2.40.53"
|
||||
description = "IP address of Proxmox DNS server"
|
||||
}
|
||||
Reference in New Issue
Block a user