diff --git a/CLAUDE.md b/CLAUDE.md index 1ebf77e2..fc9624b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/ansible/inventory/pve_hosts.yml b/ansible/inventory/pve_hosts.yml index 5e4e4ada..21eae6f9 100644 --- a/ansible/inventory/pve_hosts.yml +++ b/ansible/inventory/pve_hosts.yml @@ -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 + diff --git a/ansible/plays/install-proxmox-dns.yml b/ansible/plays/install-proxmox-dns.yml new file mode 100644 index 00000000..6b0ebf6e --- /dev/null +++ b/ansible/plays/install-proxmox-dns.yml @@ -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 }}" diff --git a/ansible/plays/vars/proxmox_dns_vault.yml b/ansible/plays/vars/proxmox_dns_vault.yml new file mode 100644 index 00000000..30a8f952 --- /dev/null +++ b/ansible/plays/vars/proxmox_dns_vault.yml @@ -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: ! = +proxmox_dns_api_token_value: "CHANGEME-your-proxmox-api-token-here" diff --git a/ansible/roles/proxmox-dns/defaults/main.yml b/ansible/roles/proxmox-dns/defaults/main.yml new file mode 100644 index 00000000..60ea3530 --- /dev/null +++ b/ansible/roles/proxmox-dns/defaults/main.yml @@ -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 diff --git a/ansible/roles/proxmox-dns/handlers/main.yml b/ansible/roles/proxmox-dns/handlers/main.yml new file mode 100644 index 00000000..439b3936 --- /dev/null +++ b/ansible/roles/proxmox-dns/handlers/main.yml @@ -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 diff --git a/ansible/roles/proxmox-dns/tasks/main.yml b/ansible/roles/proxmox-dns/tasks/main.yml new file mode 100644 index 00000000..4c407139 --- /dev/null +++ b/ansible/roles/proxmox-dns/tasks/main.yml @@ -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 diff --git a/ansible/roles/proxmox-dns/templates/config.yaml.j2 b/ansible/roles/proxmox-dns/templates/config.yaml.j2 new file mode 100644 index 00000000..7c50e0ac --- /dev/null +++ b/ansible/roles/proxmox-dns/templates/config.yaml.j2 @@ -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 }}" diff --git a/dns-server/README.md b/dns-server/README.md new file mode 100644 index 00000000..860d799e --- /dev/null +++ b/dns-server/README.md @@ -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. diff --git a/dns-server/SETUP.md b/dns-server/SETUP.md new file mode 100644 index 00000000..6420c400 --- /dev/null +++ b/dns-server/SETUP.md @@ -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 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 -- 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 diff --git a/dns-server/config.yaml.example b/dns-server/config.yaml.example new file mode 100644 index 00000000..89a7c9ff --- /dev/null +++ b/dns-server/config.yaml.example @@ -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" diff --git a/dns-server/proxmox-dns.service b/dns-server/proxmox-dns.service new file mode 100644 index 00000000..75929380 --- /dev/null +++ b/dns-server/proxmox-dns.service @@ -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 diff --git a/dns-server/proxmox_dns.py b/dns-server/proxmox_dns.py new file mode 100644 index 00000000..160bc644 --- /dev/null +++ b/dns-server/proxmox_dns.py @@ -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() diff --git a/dns-server/requirements.txt b/dns-server/requirements.txt new file mode 100644 index 00000000..f5f4f0c5 --- /dev/null +++ b/dns-server/requirements.txt @@ -0,0 +1,4 @@ +dnslib>=0.9.23 +proxmoxer>=2.0.1 +requests>=2.31.0 +PyYAML>=6.0 diff --git a/opentofu/lxc_118_proxmox_dns.tf b/opentofu/lxc_118_proxmox_dns.tf new file mode 100644 index 00000000..094909ef --- /dev/null +++ b/opentofu/lxc_118_proxmox_dns.tf @@ -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" +}