325 lines
11 KiB
Python
325 lines
11 KiB
Python
|
|
"""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
|