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:
@@ -101,6 +101,23 @@ def _run_migrations() -> None:
|
|||||||
("users", "totp_enabled", "BOOLEAN DEFAULT 0"),
|
("users", "totp_enabled", "BOOLEAN DEFAULT 0"),
|
||||||
("system_config", "ssl_mode", "TEXT DEFAULT 'letsencrypt'"),
|
("system_config", "ssl_mode", "TEXT DEFAULT 'letsencrypt'"),
|
||||||
("system_config", "wildcard_cert_id", "INTEGER"),
|
("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:
|
for table, column, col_type in migrations:
|
||||||
if not _has_column(table, column):
|
if not _has_column(table, column):
|
||||||
|
|||||||
@@ -172,6 +172,28 @@ class SystemConfig(Base):
|
|||||||
String(255), nullable=True,
|
String(255), nullable=True,
|
||||||
comment="If set, only Azure AD users in this group (object ID) are allowed to log in."
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||||
@@ -208,6 +230,21 @@ class SystemConfig(Base):
|
|||||||
"azure_client_id": self.azure_client_id or "",
|
"azure_client_id": self.azure_client_id or "",
|
||||||
"azure_client_secret_set": bool(self.azure_client_secret_encrypted),
|
"azure_client_secret_set": bool(self.azure_client_secret_encrypted),
|
||||||
"azure_allowed_group_id": self.azure_allowed_group_id or "",
|
"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,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token
|
from app.dependencies import create_access_token, create_mfa_token, get_current_user, verify_mfa_token
|
||||||
from app.models import SystemConfig, User
|
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 (
|
from app.utils.security import (
|
||||||
decrypt_value,
|
decrypt_value,
|
||||||
encrypt_value,
|
encrypt_value,
|
||||||
@@ -35,24 +37,94 @@ from app.limiter import limiter
|
|||||||
async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)):
|
async def login(request: Request, payload: LoginRequest, db: Session = Depends(get_db)):
|
||||||
"""Authenticate with username/password. May require MFA as a second step.
|
"""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.
|
Rate-limited to 10 attempts per minute per IP address.
|
||||||
"""
|
"""
|
||||||
user = db.query(User).filter(User.username == payload.username).first()
|
config = get_system_config(db)
|
||||||
if not user or not verify_password(payload.password, user.password_hash):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid username or password.",
|
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:
|
if not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Account is disabled.",
|
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":
|
if user.auth_provider == "local":
|
||||||
config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
sys_config = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
|
||||||
if config and getattr(config, "mfa_enabled", False):
|
if sys_config and getattr(sys_config, "mfa_enabled", False):
|
||||||
mfa_token = create_mfa_token(user.username)
|
mfa_token = create_mfa_token(user.username)
|
||||||
return {
|
return {
|
||||||
"mfa_required": True,
|
"mfa_required": True,
|
||||||
@@ -61,7 +133,7 @@ async def login(request: Request, payload: LoginRequest, db: Session = Depends(g
|
|||||||
}
|
}
|
||||||
|
|
||||||
token = create_access_token(user.username)
|
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 {
|
return {
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.dependencies import get_current_user
|
from app.dependencies import get_current_user
|
||||||
from app.models import SystemConfig, 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.config import get_system_config
|
||||||
from app.utils.security import encrypt_value
|
from app.utils.security import encrypt_value
|
||||||
from app.utils.validators import SystemConfigUpdate
|
from app.utils.validators import SystemConfigUpdate
|
||||||
@@ -86,6 +86,14 @@ async def update_settings(
|
|||||||
raw_secret = update_data.pop("azure_client_secret")
|
raw_secret = update_data.pop("azure_client_secret")
|
||||||
row.azure_client_secret_encrypted = encrypt_value(raw_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():
|
for field, value in update_data.items():
|
||||||
if hasattr(row, field):
|
if hasattr(row, field):
|
||||||
setattr(row, field, value)
|
setattr(row, field, value)
|
||||||
@@ -164,6 +172,64 @@ async def list_npm_certificates(
|
|||||||
return result["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")
|
@router.get("/branding")
|
||||||
async def get_branding(db: Session = Depends(get_db)):
|
async def get_branding(db: Session = Depends(get_db)):
|
||||||
"""Public endpoint — returns branding info for the login page (no auth required)."""
|
"""Public endpoint — returns branding info for the login page (no auth required)."""
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ async def reset_password(
|
|||||||
if user.auth_provider != "local":
|
if user.auth_provider != "local":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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)
|
new_password = secrets.token_urlsafe(16)
|
||||||
@@ -151,7 +151,7 @@ async def reset_mfa(
|
|||||||
if user.auth_provider != "local":
|
if user.auth_provider != "local":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
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
|
user.totp_enabled = False
|
||||||
|
|||||||
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Customer, Deployment, DeploymentLog
|
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.config import get_system_config
|
||||||
from app.utils.security import encrypt_value, generate_datastore_encryption_key, generate_relay_secret
|
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.",
|
"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
|
setup_url = external_url
|
||||||
|
|
||||||
deployment = db.query(Deployment).filter(Deployment.customer_id == customer_id).first()
|
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:
|
except Exception as exc:
|
||||||
_log_action(db, customer_id, "undeploy", "error", f"NPM stream removal error: {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
|
# Remove instance directory
|
||||||
if os.path.isdir(instance_dir):
|
if os.path.isdir(instance_dir):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ class AppConfig:
|
|||||||
dashboard_base_port: int
|
dashboard_base_port: int
|
||||||
ssl_mode: str
|
ssl_mode: str
|
||||||
wildcard_cert_id: int | None
|
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)
|
npm_password = decrypt_value(row.npm_api_password_encrypted)
|
||||||
except Exception:
|
except Exception:
|
||||||
npm_password = ""
|
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(
|
return AppConfig(
|
||||||
base_domain=row.base_domain,
|
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,
|
dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000,
|
||||||
ssl_mode=getattr(row, "ssl_mode", "letsencrypt") or "letsencrypt",
|
ssl_mode=getattr(row, "ssl_mode", "letsencrypt") or "letsencrypt",
|
||||||
wildcard_cert_id=getattr(row, "wildcard_cert_id", None),
|
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 "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -137,6 +137,23 @@ class SystemConfigUpdate(BaseModel):
|
|||||||
None, max_length=255,
|
None, max_length=255,
|
||||||
description="Azure AD group object ID. If set, only members of this group can log in."
|
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")
|
@field_validator("ssl_mode")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ msal==1.28.0
|
|||||||
pyotp==2.9.0
|
pyotp==2.9.0
|
||||||
qrcode[pil]==7.4.2
|
qrcode[pil]==7.4.2
|
||||||
slowapi==0.1.9
|
slowapi==0.1.9
|
||||||
|
pywinrm>=0.4.3
|
||||||
|
ldap3>=2.9.1
|
||||||
pytest==7.4.3
|
pytest==7.4.3
|
||||||
pytest-asyncio==0.23.2
|
pytest-asyncio==0.23.2
|
||||||
pytest-httpx==0.28.0
|
pytest-httpx==0.28.0
|
||||||
|
|||||||
Reference in New Issue
Block a user