feat: add Windows DNS integration and LDAP/AD authentication
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>
This commit is contained in:
153
app/services/dns_service.py
Normal file
153
app/services/dns_service.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""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}"}
|
||||
180
app/services/ldap_service.py
Normal file
180
app/services/ldap_service.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Active Directory / LDAP authentication via ldap3.
|
||||
|
||||
Provides LDAP-based user authentication as an alternative to local password
|
||||
authentication. Supports standard Active Directory via sAMAccountName lookup
|
||||
and optional group membership restriction.
|
||||
|
||||
All ldap3 operations run in a thread executor since ldap3 is synchronous.
|
||||
|
||||
Authentication flow:
|
||||
1. Bind with service account (ldap_bind_dn + ldap_bind_password)
|
||||
2. Search for the user entry using ldap_user_filter
|
||||
3. If ldap_group_dn is set: verify group membership
|
||||
4. Re-bind with the user's own DN + supplied password to verify credentials
|
||||
5. Return user info dict on success
|
||||
|
||||
Raises:
|
||||
ValueError: If the user was found but the password is wrong.
|
||||
RuntimeError: If LDAP is misconfigured or the server is unreachable.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ldap_test(server: str, port: int, use_ssl: bool, bind_dn: str, bind_password: str) -> dict:
|
||||
"""Synchronous LDAP connectivity test — bind with service account.
|
||||
|
||||
Returns dict with ``ok`` and ``message``.
|
||||
"""
|
||||
from ldap3 import ALL, SIMPLE, Connection, Server as LdapServer, SUBTREE # noqa: F401
|
||||
|
||||
srv = LdapServer(server, port=port, use_ssl=use_ssl, get_info=ALL)
|
||||
try:
|
||||
conn = Connection(srv, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True)
|
||||
conn.unbind()
|
||||
return {"ok": True, "message": f"Bind successful to {server}:{port} as '{bind_dn}'."}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "message": f"LDAP bind failed: {exc}"}
|
||||
|
||||
|
||||
def _ldap_authenticate(
|
||||
server: str,
|
||||
port: int,
|
||||
use_ssl: bool,
|
||||
bind_dn: str,
|
||||
bind_password: str,
|
||||
base_dn: str,
|
||||
user_filter: str,
|
||||
group_dn: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> dict | None:
|
||||
"""Synchronous LDAP authentication.
|
||||
|
||||
Returns:
|
||||
User info dict on success: {"username": ..., "email": ..., "display_name": ...}
|
||||
None if user was not found in LDAP (caller may fall back to local auth).
|
||||
|
||||
Raises:
|
||||
ValueError: Correct username but wrong password.
|
||||
RuntimeError: LDAP server error / misconfiguration.
|
||||
"""
|
||||
from ldap3 import ALL, SIMPLE, SUBTREE, Connection, Server as LdapServer
|
||||
|
||||
srv = LdapServer(server, port=port, use_ssl=use_ssl, get_info=ALL)
|
||||
|
||||
# Step 1: Bind with service account to search for the user
|
||||
try:
|
||||
conn = Connection(srv, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"LDAP service account bind failed: {exc}") from exc
|
||||
|
||||
# Step 2: Search for user
|
||||
safe_filter = user_filter.replace("{username}", username.replace("(", "").replace(")", "").replace("*", ""))
|
||||
conn.search(
|
||||
search_base=base_dn,
|
||||
search_filter=safe_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=["distinguishedName", "mail", "displayName", "sAMAccountName", "memberOf"],
|
||||
)
|
||||
|
||||
if not conn.entries:
|
||||
conn.unbind()
|
||||
return None # User not found in LDAP — caller falls back to local auth
|
||||
|
||||
entry = conn.entries[0]
|
||||
user_dn = entry.entry_dn
|
||||
email = str(entry.mail.value) if entry.mail else username
|
||||
display_name = str(entry.displayName.value) if entry.displayName else username
|
||||
|
||||
# Step 3: Optional group membership check
|
||||
if group_dn:
|
||||
member_of = [str(g) for g in entry.memberOf] if entry.memberOf else []
|
||||
if not any(group_dn.lower() == g.lower() for g in member_of):
|
||||
conn.unbind()
|
||||
logger.warning(
|
||||
"LDAP login denied for '%s': not a member of required group '%s'.",
|
||||
username, group_dn,
|
||||
)
|
||||
raise ValueError(f"Access denied: not a member of the required AD group.")
|
||||
|
||||
conn.unbind()
|
||||
|
||||
# Step 4: Verify user's password by binding as the user
|
||||
try:
|
||||
user_conn = Connection(srv, user=user_dn, password=password, authentication=SIMPLE, auto_bind=True)
|
||||
user_conn.unbind()
|
||||
except Exception:
|
||||
raise ValueError("Invalid password.")
|
||||
|
||||
return {
|
||||
"username": username.lower(),
|
||||
"email": email,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
|
||||
async def test_ldap_connection(config: Any) -> dict:
|
||||
"""Test connectivity to the LDAP / Active Directory server.
|
||||
|
||||
Attempts a service account bind to verify credentials and reachability.
|
||||
|
||||
Args:
|
||||
config: AppConfig with LDAP settings.
|
||||
|
||||
Returns:
|
||||
Dict with ``ok`` (bool) and ``message`` (str).
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
_ldap_test,
|
||||
config.ldap_server,
|
||||
config.ldap_port,
|
||||
config.ldap_use_ssl,
|
||||
config.ldap_bind_dn,
|
||||
config.ldap_bind_password,
|
||||
)
|
||||
except ImportError:
|
||||
return {"ok": False, "message": "ldap3 is not installed. Add 'ldap3' to requirements.txt."}
|
||||
except Exception as exc:
|
||||
logger.error("LDAP test_connection error: %s", exc)
|
||||
return {"ok": False, "message": f"LDAP error: {exc}"}
|
||||
|
||||
|
||||
async def authenticate_ldap(username: str, password: str, config: Any) -> dict | None:
|
||||
"""Authenticate a user against LDAP / Active Directory.
|
||||
|
||||
Args:
|
||||
username: The login username (matched via ldap_user_filter).
|
||||
password: The user's password.
|
||||
config: AppConfig with LDAP settings.
|
||||
|
||||
Returns:
|
||||
User info dict on success: {"username": ..., "email": ..., "display_name": ...}
|
||||
None if the user was not found in LDAP.
|
||||
|
||||
Raises:
|
||||
ValueError: User found but password incorrect, or group membership denied.
|
||||
RuntimeError: LDAP server unreachable or misconfigured.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
_ldap_authenticate,
|
||||
config.ldap_server,
|
||||
config.ldap_port,
|
||||
config.ldap_use_ssl,
|
||||
config.ldap_bind_dn,
|
||||
config.ldap_bind_password,
|
||||
config.ldap_base_dn,
|
||||
config.ldap_user_filter,
|
||||
config.ldap_group_dn,
|
||||
username,
|
||||
password,
|
||||
)
|
||||
@@ -30,7 +30,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Customer, Deployment, DeploymentLog
|
||||
from app.services import docker_service, npm_service, port_manager
|
||||
from app.services import dns_service, docker_service, npm_service, port_manager
|
||||
from app.utils.config import get_system_config
|
||||
from app.utils.security import encrypt_value, generate_datastore_encryption_key, generate_relay_secret
|
||||
|
||||
@@ -326,7 +326,20 @@ async def deploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||
"Please create it manually in NPM or ensure DNS resolves and port 80 is reachable, then re-deploy.",
|
||||
)
|
||||
|
||||
# Step 10: Create or update deployment record
|
||||
# Step 10: Create Windows DNS A-record (non-fatal — failure does not abort deployment)
|
||||
if config.dns_enabled and config.dns_server and config.dns_zone and config.dns_record_ip:
|
||||
try:
|
||||
dns_result = await dns_service.create_dns_record(customer.subdomain, config)
|
||||
if dns_result["ok"]:
|
||||
_log_action(db, customer_id, "dns_create", "success", dns_result["message"])
|
||||
else:
|
||||
_log_action(db, customer_id, "dns_create", "error", dns_result["message"])
|
||||
logger.warning("DNS record creation failed (non-fatal): %s", dns_result["message"])
|
||||
except Exception as exc:
|
||||
logger.error("DNS service error (non-fatal): %s", exc)
|
||||
_log_action(db, customer_id, "dns_create", "error", str(exc))
|
||||
|
||||
# Step 11: Create or update deployment record
|
||||
setup_url = external_url
|
||||
|
||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
||||
@@ -441,6 +454,17 @@ async def undeploy_customer(db: Session, customer_id: int) -> dict[str, Any]:
|
||||
except Exception as exc:
|
||||
_log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {exc}")
|
||||
|
||||
# Remove Windows DNS A-record (non-fatal)
|
||||
if config and config.dns_enabled and config.dns_server and config.dns_zone:
|
||||
try:
|
||||
dns_result = await dns_service.delete_dns_record(customer.subdomain, config)
|
||||
if dns_result["ok"]:
|
||||
_log_action(db, customer_id, "undeploy", "info", dns_result["message"])
|
||||
else:
|
||||
_log_action(db, customer_id, "undeploy", "error", f"DNS removal: {dns_result['message']}")
|
||||
except Exception as exc:
|
||||
logger.error("DNS record deletion failed (non-fatal): %s", exc)
|
||||
|
||||
# Remove instance directory
|
||||
if os.path.isdir(instance_dir):
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user