diff --git a/app/database.py b/app/database.py index f630cc0..db25258 100644 --- a/app/database.py +++ b/app/database.py @@ -101,6 +101,23 @@ def _run_migrations() -> None: ("users", "totp_enabled", "BOOLEAN DEFAULT 0"), ("system_config", "ssl_mode", "TEXT DEFAULT 'letsencrypt'"), ("system_config", "wildcard_cert_id", "INTEGER"), + # Windows DNS + ("system_config", "dns_enabled", "BOOLEAN DEFAULT 0"), + ("system_config", "dns_server", "TEXT"), + ("system_config", "dns_username", "TEXT"), + ("system_config", "dns_password_encrypted", "TEXT"), + ("system_config", "dns_zone", "TEXT"), + ("system_config", "dns_record_ip", "TEXT"), + # LDAP + ("system_config", "ldap_enabled", "BOOLEAN DEFAULT 0"), + ("system_config", "ldap_server", "TEXT"), + ("system_config", "ldap_port", "INTEGER DEFAULT 389"), + ("system_config", "ldap_use_ssl", "BOOLEAN DEFAULT 0"), + ("system_config", "ldap_bind_dn", "TEXT"), + ("system_config", "ldap_bind_password_encrypted", "TEXT"), + ("system_config", "ldap_base_dn", "TEXT"), + ("system_config", "ldap_user_filter", "TEXT DEFAULT '(sAMAccountName={username})'"), + ("system_config", "ldap_group_dn", "TEXT"), ] for table, column, col_type in migrations: if not _has_column(table, column): diff --git a/app/models.py b/app/models.py index 618babc..ba0447b 100644 --- a/app/models.py +++ b/app/models.py @@ -172,6 +172,28 @@ class SystemConfig(Base): String(255), nullable=True, comment="If set, only Azure AD users in this group (object ID) are allowed to log in." ) + + # Windows DNS integration + dns_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + dns_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + dns_username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + dns_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + dns_zone: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + dns_record_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + + # LDAP / Active Directory authentication + ldap_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + ldap_server: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + ldap_port: Mapped[int] = mapped_column(Integer, default=389) + ldap_use_ssl: Mapped[bool] = mapped_column(Boolean, default=False) + ldap_bind_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + ldap_bind_password_encrypted: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + ldap_base_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + ldap_user_filter: Mapped[Optional[str]] = mapped_column( + String(255), default="(sAMAccountName={username})" + ) + ldap_group_dn: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) updated_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.utcnow, onupdate=datetime.utcnow @@ -208,6 +230,21 @@ class SystemConfig(Base): "azure_client_id": self.azure_client_id or "", "azure_client_secret_set": bool(self.azure_client_secret_encrypted), "azure_allowed_group_id": self.azure_allowed_group_id or "", + "dns_enabled": bool(self.dns_enabled), + "dns_server": self.dns_server or "", + "dns_username": self.dns_username or "", + "dns_password_set": bool(self.dns_password_encrypted), + "dns_zone": self.dns_zone or "", + "dns_record_ip": self.dns_record_ip or "", + "ldap_enabled": bool(self.ldap_enabled), + "ldap_server": self.ldap_server or "", + "ldap_port": self.ldap_port or 389, + "ldap_use_ssl": bool(self.ldap_use_ssl), + "ldap_bind_dn": self.ldap_bind_dn or "", + "ldap_bind_password_set": bool(self.ldap_bind_password_encrypted), + "ldap_base_dn": self.ldap_base_dn or "", + "ldap_user_filter": self.ldap_user_filter or "(sAMAccountName={username})", + "ldap_group_dn": self.ldap_group_dn or "", "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/app/routers/auth.py b/app/routers/auth.py index b02a48a..bac548e 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -13,6 +13,8 @@ from sqlalchemy.orm import Session from app.database import get_db from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token from app.models import SystemConfig, User +from app.services import ldap_service +from app.utils.config import get_system_config from app.utils.security import ( decrypt_value, encrypt_value, @@ -35,24 +37,94 @@ from app.limiter import limiter async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)): """Authenticate with username/password. May require MFA as a second step. + Auth flow: + 1. If LDAP is enabled: try LDAP authentication first. + - Success → find or auto-create local User with auth_provider="ldap" + - Wrong password (user found in LDAP) → HTTP 401 + - User not found in LDAP → fall through to local auth + 2. Local auth: verify bcrypt hash for users with auth_provider="local" + 3. On success: check MFA requirement (local users only) then issue JWT + Rate-limited to 10 attempts per minute per IP address. """ - user = db.query(User).filter(User.username == payload.username).first() - if not user or not verify_password(payload.password, user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password.", - ) + config = get_system_config(db) + user: User | None = None + + # ------------------------------------------------------------------ + # Step 1: LDAP authentication (if enabled) + # ------------------------------------------------------------------ + if config and config.ldap_enabled and config.ldap_server: + try: + ldap_info = await ldap_service.authenticate_ldap( + payload.username, payload.password, config + ) + if ldap_info is not None: + # User authenticated via LDAP — find or create local record + user = db.query(User).filter(User.username == ldap_info["username"]).first() + if not user: + user = User( + username=ldap_info["username"], + password_hash=hash_password(secrets.token_urlsafe(32)), + email=ldap_info.get("email", ""), + is_active=True, + role="viewer", + auth_provider="ldap", + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("LDAP user '%s' auto-created with role 'viewer'.", ldap_info["username"]) + elif not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is disabled.", + ) + else: + # Keep auth_provider in sync in case it was changed + if user.auth_provider != "ldap": + user.auth_provider = "ldap" + db.commit() + except ValueError as exc: + # User found in LDAP but wrong password or group denied + logger.warning("LDAP login failed for '%s': %s", payload.username, exc) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) + except RuntimeError as exc: + # LDAP server unreachable — log and fall through to local auth + logger.error("LDAP server error, falling back to local auth: %s", exc) + + # ------------------------------------------------------------------ + # Step 2: Local authentication (if LDAP didn't produce a user) + # ------------------------------------------------------------------ + if user is None: + local_user = db.query(User).filter(User.username == payload.username).first() + if local_user and local_user.auth_provider == "local": + if not verify_password(payload.password, local_user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) + user = local_user + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password.", + ) + if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account is disabled.", ) - # Check if MFA is required (only for local users) + # ------------------------------------------------------------------ + # Step 3: MFA check (local users only) + # ------------------------------------------------------------------ if user.auth_provider == "local": - config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() - if config and getattr(config, "mfa_enabled", False): + sys_config = db.query(SystemConfig).filter(SystemConfig.id == 1).first() + if sys_config and getattr(sys_config, "mfa_enabled", False): mfa_token = create_mfa_token(user.username) return { "mfa_required": True, @@ -61,7 +133,7 @@ async def login(request: Request, payload: LoginRequest, db: Session = Depends(g } token = create_access_token(user.username) - logger.info("User %s logged in.", user.username) + logger.info("User %s logged in (provider: %s).", user.username, user.auth_provider) return { "access_token": token, "token_type": "bearer", diff --git a/app/routers/settings.py b/app/routers/settings.py index 7ae8e0c..09fe2ab 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session from app.database import get_db from app.dependencies import get_current_user from app.models import SystemConfig, User -from app.services import npm_service +from app.services import dns_service, ldap_service, npm_service from app.utils.config import get_system_config from app.utils.security import encrypt_value from app.utils.validators import SystemConfigUpdate @@ -86,6 +86,14 @@ async def update_settings( raw_secret = update_data.pop("azure_client_secret") row.azure_client_secret_encrypted = encrypt_value(raw_secret) + # Handle DNS password encryption + if "dns_password" in update_data: + row.dns_password_encrypted = encrypt_value(update_data.pop("dns_password")) + + # Handle LDAP bind password encryption + if "ldap_bind_password" in update_data: + row.ldap_bind_password_encrypted = encrypt_value(update_data.pop("ldap_bind_password")) + for field, value in update_data.items(): if hasattr(row, field): setattr(row, field, value) @@ -164,6 +172,64 @@ async def list_npm_certificates( return result["certificates"] +@router.get("/test-dns") +async def test_dns( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Test connectivity to the Windows DNS server via WinRM. + + Returns: + Dict with ``ok`` and ``message``. + """ + config = get_system_config(db) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="System configuration not initialized.", + ) + if not config.dns_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Windows DNS integration is not enabled.", + ) + if not config.dns_server or not config.dns_username or not config.dns_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="DNS server, username, or password not configured.", + ) + return await dns_service.test_dns_connection(config) + + +@router.get("/test-ldap") +async def test_ldap( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Test connectivity to the LDAP / Active Directory server. + + Returns: + Dict with ``ok`` and ``message``. + """ + config = get_system_config(db) + if not config: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="System configuration not initialized.", + ) + if not config.ldap_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="LDAP authentication is not enabled.", + ) + if not config.ldap_server or not config.ldap_bind_dn or not config.ldap_bind_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="LDAP server, bind DN, or bind password not configured.", + ) + return await ldap_service.test_ldap_connection(config) + + @router.get("/branding") async def get_branding(db: Session = Depends(get_db)): """Public endpoint — returns branding info for the login page (no auth required).""" diff --git a/app/routers/users.py b/app/routers/users.py index 0e8d9c9..1262d33 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -126,7 +126,7 @@ async def reset_password( if user.auth_provider != "local": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot reset password for Azure AD users.", + detail="Cannot reset password for external auth users (Azure AD / LDAP).", ) new_password = secrets.token_urlsafe(16) @@ -151,7 +151,7 @@ async def reset_mfa( if user.auth_provider != "local": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot reset MFA for Azure AD users.", + detail="Cannot reset MFA for external auth users (Azure AD / LDAP).", ) user.totp_enabled = False diff --git a/app/services/dns_service.py b/app/services/dns_service.py new file mode 100644 index 0000000..4bacf83 --- /dev/null +++ b/app/services/dns_service.py @@ -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}"} diff --git a/app/services/ldap_service.py b/app/services/ldap_service.py new file mode 100644 index 0000000..d4f5662 --- /dev/null +++ b/app/services/ldap_service.py @@ -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, + ) diff --git a/app/services/netbird_service.py b/app/services/netbird_service.py index f3bef98..bb1895d 100644 --- a/app/services/netbird_service.py +++ b/app/services/netbird_service.py @@ -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: diff --git a/app/utils/config.py b/app/utils/config.py index 970aaf6..c0f84d1 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -33,6 +33,23 @@ class AppConfig: dashboard_base_port: int ssl_mode: str wildcard_cert_id: int | None + # Windows DNS + dns_enabled: bool = False + dns_server: str = "" + dns_username: str = "" + dns_password: str = "" # decrypted + dns_zone: str = "" + dns_record_ip: str = "" + # LDAP + ldap_enabled: bool = False + ldap_server: str = "" + ldap_port: int = 389 + ldap_use_ssl: bool = False + ldap_bind_dn: str = "" + ldap_bind_password: str = "" # decrypted + ldap_base_dn: str = "" + ldap_user_filter: str = "(sAMAccountName={username})" + ldap_group_dn: str = "" # --------------------------------------------------------------------------- @@ -92,6 +109,14 @@ def get_system_config(db: Session) -> Optional[AppConfig]: npm_password = decrypt_value(row.npm_api_password_encrypted) except Exception: npm_password = "" + try: + dns_password = decrypt_value(row.dns_password_encrypted) if row.dns_password_encrypted else "" + except Exception: + dns_password = "" + try: + ldap_bind_password = decrypt_value(row.ldap_bind_password_encrypted) if row.ldap_bind_password_encrypted else "" + except Exception: + ldap_bind_password = "" return AppConfig( base_domain=row.base_domain, @@ -109,4 +134,19 @@ def get_system_config(db: Session) -> Optional[AppConfig]: dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000, ssl_mode=getattr(row, "ssl_mode", "letsencrypt") or "letsencrypt", wildcard_cert_id=getattr(row, "wildcard_cert_id", None), + dns_enabled=bool(getattr(row, "dns_enabled", False)), + dns_server=getattr(row, "dns_server", "") or "", + dns_username=getattr(row, "dns_username", "") or "", + dns_password=dns_password, + dns_zone=getattr(row, "dns_zone", "") or "", + dns_record_ip=getattr(row, "dns_record_ip", "") or "", + ldap_enabled=bool(getattr(row, "ldap_enabled", False)), + ldap_server=getattr(row, "ldap_server", "") or "", + ldap_port=getattr(row, "ldap_port", 389) or 389, + ldap_use_ssl=bool(getattr(row, "ldap_use_ssl", False)), + ldap_bind_dn=getattr(row, "ldap_bind_dn", "") or "", + ldap_bind_password=ldap_bind_password, + ldap_base_dn=getattr(row, "ldap_base_dn", "") or "", + ldap_user_filter=getattr(row, "ldap_user_filter", "(sAMAccountName={username})") or "(sAMAccountName={username})", + ldap_group_dn=getattr(row, "ldap_group_dn", "") or "", ) diff --git a/app/utils/validators.py b/app/utils/validators.py index f19ee75..28bdd4b 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -137,6 +137,23 @@ class SystemConfigUpdate(BaseModel): None, max_length=255, description="Azure AD group object ID. If set, only members of this group can log in." ) + # Windows DNS + dns_enabled: Optional[bool] = None + dns_server: Optional[str] = Field(None, max_length=255) + dns_username: Optional[str] = Field(None, max_length=255) + dns_password: Optional[str] = None # plaintext, encrypted before storage + dns_zone: Optional[str] = Field(None, max_length=255) + dns_record_ip: Optional[str] = Field(None, max_length=45) + # LDAP + ldap_enabled: Optional[bool] = None + ldap_server: Optional[str] = Field(None, max_length=255) + ldap_port: Optional[int] = Field(None, ge=1, le=65535) + ldap_use_ssl: Optional[bool] = None + ldap_bind_dn: Optional[str] = Field(None, max_length=500) + ldap_bind_password: Optional[str] = None # plaintext, encrypted before storage + ldap_base_dn: Optional[str] = Field(None, max_length=500) + ldap_user_filter: Optional[str] = Field(None, max_length=255) + ldap_group_dn: Optional[str] = Field(None, max_length=500) @field_validator("ssl_mode") @classmethod diff --git a/requirements.txt b/requirements.txt index f113dbd..32e6515 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,6 +21,8 @@ msal==1.28.0 pyotp==2.9.0 qrcode[pil]==7.4.2 slowapi==0.1.9 +pywinrm>=0.4.3 +ldap3>=2.9.1 pytest==7.4.3 pytest-asyncio==0.23.2 pytest-httpx==0.28.0