Autarch/core/dns_service.py

325 lines
11 KiB
Python
Raw Permalink Normal View History

"""AUTARCH DNS Service Manager — controls the Go-based autarch-dns binary."""
import os
import sys
import json
import time
import signal
import socket
import subprocess
import threading
from pathlib import Path
try:
from core.paths import find_tool, get_data_dir
except ImportError:
def find_tool(name):
import shutil
return shutil.which(name)
def get_data_dir():
return str(Path(__file__).parent.parent / 'data')
try:
import requests
_HAS_REQUESTS = True
except ImportError:
_HAS_REQUESTS = False
class DNSServiceManager:
"""Manage the autarch-dns Go binary (start/stop/API calls)."""
def __init__(self):
self._process = None
self._pid = None
self._config = None
self._config_path = os.path.join(get_data_dir(), 'dns', 'config.json')
self._load_config()
def _load_config(self):
if os.path.exists(self._config_path):
try:
with open(self._config_path, 'r') as f:
self._config = json.load(f)
except Exception:
self._config = None
if not self._config:
self._config = {
'listen_dns': '0.0.0.0:53',
'listen_api': '127.0.0.1:5380',
'api_token': os.urandom(16).hex(),
'upstream': [], # Empty = pure recursive from root hints
'cache_ttl': 300,
'zones_dir': os.path.join(get_data_dir(), 'dns', 'zones'),
'dnssec_keys_dir': os.path.join(get_data_dir(), 'dns', 'keys'),
'log_queries': True,
}
self._save_config()
def _save_config(self):
os.makedirs(os.path.dirname(self._config_path), exist_ok=True)
with open(self._config_path, 'w') as f:
json.dump(self._config, f, indent=2)
@property
def api_base(self) -> str:
addr = self._config.get('listen_api', '127.0.0.1:5380')
return f'http://{addr}'
@property
def api_token(self) -> str:
return self._config.get('api_token', '')
def find_binary(self) -> str:
"""Find the autarch-dns binary."""
binary = find_tool('autarch-dns')
if binary:
return binary
# Check common locations
base = Path(__file__).parent.parent
candidates = [
base / 'services' / 'dns-server' / 'autarch-dns',
base / 'services' / 'dns-server' / 'autarch-dns.exe',
base / 'tools' / 'windows-x86_64' / 'autarch-dns.exe',
base / 'tools' / 'linux-arm64' / 'autarch-dns',
base / 'tools' / 'linux-x86_64' / 'autarch-dns',
]
for c in candidates:
if c.exists():
return str(c)
return None
def is_running(self) -> bool:
"""Check if the DNS service is running."""
# Check process
if self._process and self._process.poll() is None:
return True
# Check by API
try:
resp = self._api_get('/api/status')
return resp.get('ok', False)
except Exception:
return False
def start(self) -> dict:
"""Start the DNS service."""
if self.is_running():
return {'ok': True, 'message': 'DNS service already running'}
binary = self.find_binary()
if not binary:
return {'ok': False, 'error': 'autarch-dns binary not found. Build it with: cd services/dns-server && go build'}
# Ensure zone dirs exist
os.makedirs(self._config.get('zones_dir', ''), exist_ok=True)
os.makedirs(self._config.get('dnssec_keys_dir', ''), exist_ok=True)
# Save config for the Go binary to read
self._save_config()
cmd = [
binary,
'-config', self._config_path,
]
try:
kwargs = {
'stdout': subprocess.DEVNULL,
'stderr': subprocess.DEVNULL,
}
if sys.platform == 'win32':
kwargs['creationflags'] = (
subprocess.CREATE_NEW_PROCESS_GROUP |
subprocess.CREATE_NO_WINDOW
)
else:
kwargs['start_new_session'] = True
self._process = subprocess.Popen(cmd, **kwargs)
self._pid = self._process.pid
# Wait for API to be ready
for _ in range(30):
time.sleep(0.5)
try:
resp = self._api_get('/api/status')
if resp.get('ok'):
return {
'ok': True,
'message': f'DNS service started (PID {self._pid})',
'pid': self._pid,
}
except Exception:
if self._process.poll() is not None:
return {'ok': False, 'error': 'DNS service exited immediately — may need admin/root for port 53'}
continue
return {'ok': False, 'error': 'DNS service started but API not responding'}
except PermissionError:
return {'ok': False, 'error': 'Permission denied — DNS on port 53 requires admin/root'}
except Exception as e:
return {'ok': False, 'error': str(e)}
def stop(self) -> dict:
"""Stop the DNS service."""
if self._process and self._process.poll() is None:
try:
if sys.platform == 'win32':
self._process.terminate()
else:
os.kill(self._process.pid, signal.SIGTERM)
self._process.wait(timeout=5)
except Exception:
self._process.kill()
self._process = None
self._pid = None
return {'ok': True, 'message': 'DNS service stopped'}
return {'ok': True, 'message': 'DNS service was not running'}
def status(self) -> dict:
"""Get service status."""
running = self.is_running()
result = {
'running': running,
'pid': self._pid,
'listen_dns': self._config.get('listen_dns', ''),
'listen_api': self._config.get('listen_api', ''),
}
if running:
try:
resp = self._api_get('/api/status')
result.update(resp)
except Exception:
pass
return result
# ── API wrappers ─────────────────────────────────────────────────────
def _api_get(self, endpoint: str) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'GET')
resp = requests.get(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}'},
timeout=5,
)
return resp.json()
def _api_post(self, endpoint: str, data: dict = None) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'POST', data)
resp = requests.post(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}', 'Content-Type': 'application/json'},
json=data or {},
timeout=5,
)
return resp.json()
def _api_delete(self, endpoint: str) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'DELETE')
resp = requests.delete(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}'},
timeout=5,
)
return resp.json()
def _api_put(self, endpoint: str, data: dict = None) -> dict:
if not _HAS_REQUESTS:
return self._api_urllib(endpoint, 'PUT', data)
resp = requests.put(
f'{self.api_base}{endpoint}',
headers={'Authorization': f'Bearer {self.api_token}', 'Content-Type': 'application/json'},
json=data or {},
timeout=5,
)
return resp.json()
def _api_urllib(self, endpoint: str, method: str, data: dict = None) -> dict:
"""Fallback using urllib (no requests dependency)."""
import urllib.request
url = f'{self.api_base}{endpoint}'
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(
url, data=body, method=method,
headers={
'Authorization': f'Bearer {self.api_token}',
'Content-Type': 'application/json',
},
)
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read())
# ── High-level zone operations ───────────────────────────────────────
def list_zones(self) -> list:
return self._api_get('/api/zones').get('zones', [])
def create_zone(self, domain: str) -> dict:
return self._api_post('/api/zones', {'domain': domain})
def get_zone(self, domain: str) -> dict:
return self._api_get(f'/api/zones/{domain}')
def delete_zone(self, domain: str) -> dict:
return self._api_delete(f'/api/zones/{domain}')
def list_records(self, domain: str) -> list:
return self._api_get(f'/api/zones/{domain}/records').get('records', [])
def add_record(self, domain: str, rtype: str, name: str, value: str,
ttl: int = 300, priority: int = 0) -> dict:
return self._api_post(f'/api/zones/{domain}/records', {
'type': rtype, 'name': name, 'value': value,
'ttl': ttl, 'priority': priority,
})
def delete_record(self, domain: str, record_id: str) -> dict:
return self._api_delete(f'/api/zones/{domain}/records/{record_id}')
def setup_mail_records(self, domain: str, mx_host: str = '',
dkim_key: str = '', spf_allow: str = '') -> dict:
return self._api_post(f'/api/zones/{domain}/mail-setup', {
'mx_host': mx_host, 'dkim_key': dkim_key, 'spf_allow': spf_allow,
})
def enable_dnssec(self, domain: str) -> dict:
return self._api_post(f'/api/zones/{domain}/dnssec/enable')
def disable_dnssec(self, domain: str) -> dict:
return self._api_post(f'/api/zones/{domain}/dnssec/disable')
def get_metrics(self) -> dict:
return self._api_get('/api/metrics').get('metrics', {})
def get_config(self) -> dict:
return self._config.copy()
def update_config(self, updates: dict) -> dict:
for k, v in updates.items():
if k in self._config:
self._config[k] = v
self._save_config()
# Also update running service
try:
return self._api_put('/api/config', updates)
except Exception:
return {'ok': True, 'message': 'Config saved (service not running)'}
# ── Singleton ────────────────────────────────────────────────────────────────
_instance = None
_lock = threading.Lock()
def get_dns_service() -> DNSServiceManager:
global _instance
if _instance is None:
with _lock:
if _instance is None:
_instance = DNSServiceManager()
return _instance