Windows DNS (WinRM): - New dns_service.py: create/delete A-records via PowerShell over WinRM (NTLM) - Idempotent create (removes existing record first), graceful delete - DNS failures are non-fatal — deployment continues, error logged - test-dns endpoint: GET /api/settings/test-dns - Integrated into deploy_customer() and undeploy_customer() LDAP / Active Directory auth: - New ldap_service.py: service-account bind + user search + user bind (ldap3) - Optional AD group restriction via ldap_group_dn - Login flow: LDAP first → local fallback (prevents admin lockout) - LDAP users auto-created with auth_provider="ldap" and role="viewer" - test-ldap endpoint: GET /api/settings/test-ldap - reset-password/reset-mfa guards extended to block LDAP users All credentials (dns_password, ldap_bind_password) encrypted with Fernet. New DB columns added via backwards-compatible migrations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
154 lines
6.2 KiB
Python
154 lines
6.2 KiB
Python
"""Windows DNS Server integration via WinRM + PowerShell.
|
|
|
|
Uses pywinrm to execute PowerShell DNS cmdlets on a remote Windows DNS server.
|
|
All WinRM operations run in a thread executor since pywinrm is synchronous.
|
|
|
|
Typical usage:
|
|
config = get_system_config(db)
|
|
result = await create_dns_record("kunde1", config)
|
|
# result == {"ok": True, "message": "A-record 'kunde1.example.com → 10.0.0.5' created."}
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _winrm_run(server: str, username: str, password: str, ps_script: str) -> tuple[int, str, str]:
|
|
"""Execute a PowerShell script via WinRM and return (status_code, stdout, stderr).
|
|
|
|
Runs synchronously — must be called via run_in_executor.
|
|
"""
|
|
import winrm # imported here so the app starts even without pywinrm installed
|
|
|
|
session = winrm.Session(
|
|
target=server,
|
|
auth=(username, password),
|
|
transport="ntlm",
|
|
)
|
|
result = session.run_ps(ps_script)
|
|
stdout = result.std_out.decode("utf-8", errors="replace").strip()
|
|
stderr = result.std_err.decode("utf-8", errors="replace").strip()
|
|
return result.status_code, stdout, stderr
|
|
|
|
|
|
async def _run_ps(server: str, username: str, password: str, ps_script: str) -> tuple[int, str, str]:
|
|
"""Async wrapper: runs _winrm_run in a thread executor."""
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(None, _winrm_run, server, username, password, ps_script)
|
|
|
|
|
|
async def test_dns_connection(config: Any) -> dict:
|
|
"""Test WinRM connectivity to the Windows DNS server.
|
|
|
|
Runs 'Get-DnsServerZone' to verify the configured zone exists.
|
|
|
|
Args:
|
|
config: AppConfig with dns_server, dns_username, dns_password, dns_zone.
|
|
|
|
Returns:
|
|
Dict with ``ok`` (bool) and ``message`` (str).
|
|
"""
|
|
zone = config.dns_zone.strip()
|
|
ps = f"Get-DnsServerZone -Name '{zone}' | Select-Object ZoneName, ZoneType"
|
|
try:
|
|
code, stdout, stderr = await _run_ps(
|
|
config.dns_server, config.dns_username, config.dns_password, ps
|
|
)
|
|
if code == 0 and zone.lower() in stdout.lower():
|
|
return {"ok": True, "message": f"Connected. Zone '{zone}' found on {config.dns_server}."}
|
|
err = stderr or stdout or "Unknown error"
|
|
return {"ok": False, "message": f"Zone '{zone}' not found or access denied: {err[:300]}"}
|
|
except ImportError:
|
|
return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."}
|
|
except Exception as exc:
|
|
logger.error("DNS connection test failed: %s", exc)
|
|
return {"ok": False, "message": f"Connection failed: {exc}"}
|
|
|
|
|
|
async def create_dns_record(subdomain: str, config: Any) -> dict:
|
|
"""Create an A-record in the Windows DNS server.
|
|
|
|
Record: {subdomain}.{zone} → {dns_record_ip}
|
|
|
|
If a record already exists for the subdomain, it is removed first to avoid
|
|
duplicate-record errors (idempotent behaviour for re-deployments).
|
|
|
|
Args:
|
|
subdomain: The customer subdomain (e.g. ``kunde1``).
|
|
config: AppConfig with DNS settings.
|
|
|
|
Returns:
|
|
Dict with ``ok`` (bool) and ``message`` (str).
|
|
"""
|
|
zone = config.dns_zone.strip()
|
|
ip = config.dns_record_ip.strip()
|
|
name = subdomain.strip()
|
|
|
|
# Remove existing record first (idempotent — ignore errors)
|
|
ps_remove = (
|
|
f"Try {{"
|
|
f" Remove-DnsServerResourceRecord -ZoneName '{zone}' -RRType 'A' -Name '{name}' -Force -ErrorAction SilentlyContinue"
|
|
f"}} Catch {{}}"
|
|
)
|
|
# Create new A-record
|
|
ps_add = f"Add-DnsServerResourceRecordA -ZoneName '{zone}' -Name '{name}' -IPv4Address '{ip}' -TimeToLive 00:05:00"
|
|
|
|
ps_script = f"{ps_remove}\n{ps_add}"
|
|
|
|
try:
|
|
code, stdout, stderr = await _run_ps(
|
|
config.dns_server, config.dns_username, config.dns_password, ps_script
|
|
)
|
|
if code == 0:
|
|
logger.info("DNS A-record created: %s.%s → %s", name, zone, ip)
|
|
return {"ok": True, "message": f"A-record '{name}.{zone} → {ip}' created successfully."}
|
|
err = stderr or stdout or "Unknown error"
|
|
logger.warning("DNS A-record creation failed for %s.%s: %s", name, zone, err)
|
|
return {"ok": False, "message": f"Failed to create DNS record: {err[:300]}"}
|
|
except ImportError:
|
|
return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."}
|
|
except Exception as exc:
|
|
logger.error("DNS create_record error for %s.%s: %s", name, zone, exc)
|
|
return {"ok": False, "message": f"DNS error: {exc}"}
|
|
|
|
|
|
async def delete_dns_record(subdomain: str, config: Any) -> dict:
|
|
"""Delete the A-record for a customer subdomain from the Windows DNS server.
|
|
|
|
Args:
|
|
subdomain: The customer subdomain (e.g. ``kunde1``).
|
|
config: AppConfig with DNS settings.
|
|
|
|
Returns:
|
|
Dict with ``ok`` (bool) and ``message`` (str).
|
|
"""
|
|
zone = config.dns_zone.strip()
|
|
name = subdomain.strip()
|
|
|
|
ps_script = (
|
|
f"Remove-DnsServerResourceRecord -ZoneName '{zone}' -RRType 'A' -Name '{name}' -Force"
|
|
)
|
|
|
|
try:
|
|
code, stdout, stderr = await _run_ps(
|
|
config.dns_server, config.dns_username, config.dns_password, ps_script
|
|
)
|
|
if code == 0:
|
|
logger.info("DNS A-record deleted: %s.%s", name, zone)
|
|
return {"ok": True, "message": f"A-record '{name}.{zone}' deleted successfully."}
|
|
err = stderr or stdout or "Unknown error"
|
|
# Record not found is acceptable during deletion
|
|
if "not found" in err.lower() or "does not exist" in err.lower():
|
|
logger.info("DNS A-record %s.%s not found (already deleted).", name, zone)
|
|
return {"ok": True, "message": f"A-record '{name}.{zone}' not found (already deleted)."}
|
|
logger.warning("DNS A-record deletion failed for %s.%s: %s", name, zone, err)
|
|
return {"ok": False, "message": f"Failed to delete DNS record: {err[:300]}"}
|
|
except ImportError:
|
|
return {"ok": False, "message": "pywinrm is not installed. Add 'pywinrm' to requirements.txt."}
|
|
except Exception as exc:
|
|
logger.error("DNS delete_record error for %s.%s: %s", name, zone, exc)
|
|
return {"ok": False, "message": f"DNS error: {exc}"}
|