feat(vmis): 租戶自動開通完整流程 + Admin Portal SSO + NC 行事曆訂閱
Backend: - schedule_tenant: NC 新容器自動 pgsql 安裝 (_nc_db_check 全新容器處理) - schedule_tenant: NC 初始化加入 Redis + APCu memcache 設定 (修正 OIDC invalid_state) - schedule_tenant: 新租戶 KC realm 自動設定 accessCodeLifespan=600s (修正 authentication_expired) - schedule_account: NC Mail 帳號自動設定 (nc_mail_result/nc_mail_done_at) - schedule_account: NC 台灣國定假日行事曆自動訂閱 (CalDAV MKCALENDAR) - nextcloud_client: 新增 subscribe_calendar() CalDAV 訂閱方法 - settings: 新增系統設定 API (site_title/version/timezone/SSO/Keycloak) - models/result: 新增 nc_mail_result, nc_mail_done_at 欄位 - alembic: 遷移 002(system_settings) 003(keycloak_admin) 004(nc_mail_result) Frontend (Admin Portal): - 新增完整管理後台 (index/tenants/accounts/servers/schedules/logs/settings/system-status) - api.js: Keycloak JS Adapter SSO 整合 (PKCE/S256, fallback KC JS 來源, 自動 token 更新) - index.html: Promise.allSettled 取代 Promise.all,防止單一 API 失敗影響整頁 - 所有頁面加入 try/catch + toast 錯誤處理 - 新增品牌 LOGO 與 favicon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,36 @@
|
||||
"""
|
||||
MailClient — 呼叫 Docker Mailserver Admin API (http://10.1.0.254:8080)
|
||||
管理 mail domain 和 mailbox 的建立/查詢。
|
||||
建立 domain 前必須驗證 MX DNS 設定(對 active 租戶)。
|
||||
MailClient — 透過 SSH + docker exec mailserver 管理 docker-mailserver。
|
||||
domain 在 docker-mailserver 中是隱式的(由 mailbox 決定),
|
||||
所以 domain_exists 檢查是否有任何 @domain 的 mailbox。
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
import httpx
|
||||
import dns.resolver
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT = 10.0
|
||||
MAILSERVER_CONTAINER = "mailserver"
|
||||
|
||||
|
||||
class MailClient:
|
||||
def __init__(self):
|
||||
self._base = settings.MAIL_ADMIN_API_URL.rstrip("/")
|
||||
self._headers = {"X-API-Key": settings.MAIL_ADMIN_API_KEY}
|
||||
|
||||
def _ssh_exec(self, cmd: str) -> tuple[int, str]:
|
||||
"""SSH 到 10.1.0.254 執行指令,回傳 (exit_code, stdout)"""
|
||||
import paramiko
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(
|
||||
settings.DOCKER_SSH_HOST,
|
||||
username=settings.DOCKER_SSH_USER,
|
||||
timeout=15,
|
||||
)
|
||||
_, stdout, _ = client.exec_command(cmd)
|
||||
output = stdout.read().decode().strip()
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
client.close()
|
||||
return exit_code, output
|
||||
|
||||
def check_mx_dns(self, domain: str) -> bool:
|
||||
"""驗證 domain 的 MX record 是否指向正確的 mail server"""
|
||||
@@ -33,49 +45,65 @@ class MailClient:
|
||||
return False
|
||||
|
||||
def domain_exists(self, domain: str) -> bool:
|
||||
"""檢查 mailserver 是否有任何 @domain 的 mailbox(docker-mailserver 的 domain 由 mailbox 決定)"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._base}/api/v1/domains/{domain}",
|
||||
headers=self._headers,
|
||||
timeout=TIMEOUT,
|
||||
code, output = self._ssh_exec(
|
||||
f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '@{domain}' && echo yes || echo no"
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return output.strip() == "yes"
|
||||
except Exception as e:
|
||||
logger.warning(f"domain_exists({domain}) SSH failed: {e}")
|
||||
return False
|
||||
|
||||
def create_domain(self, domain: str) -> bool:
|
||||
"""
|
||||
docker-mailserver 不需要顯式建立 domain(mailbox 新增時自動處理)。
|
||||
新增一個 postmaster@ 系統帳號來確保 domain 被識別。
|
||||
"""
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{self._base}/api/v1/domains",
|
||||
json={"domain": domain},
|
||||
headers=self._headers,
|
||||
timeout=TIMEOUT,
|
||||
import secrets
|
||||
passwd = secrets.token_urlsafe(16)
|
||||
code, output = self._ssh_exec(
|
||||
f"docker exec {MAILSERVER_CONTAINER} setup email add postmaster@{domain} {passwd} 2>&1"
|
||||
)
|
||||
return resp.status_code in (200, 201, 204)
|
||||
if code == 0:
|
||||
logger.info(f"create_domain({domain}): postmaster account created")
|
||||
return True
|
||||
# 若帳號已存在視為成功
|
||||
if "already exists" in output.lower():
|
||||
return True
|
||||
logger.error(f"create_domain({domain}) failed (exit {code}): {output}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"create_domain({domain}) failed: {e}")
|
||||
logger.error(f"create_domain({domain}) SSH failed: {e}")
|
||||
return False
|
||||
|
||||
def mailbox_exists(self, email: str) -> bool:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{self._base}/api/v1/mailboxes/{email}",
|
||||
headers=self._headers,
|
||||
timeout=TIMEOUT,
|
||||
code, output = self._ssh_exec(
|
||||
f"docker exec {MAILSERVER_CONTAINER} setup email list 2>/dev/null | grep -q '{email}' && echo yes || echo no"
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
return output.strip() == "yes"
|
||||
except Exception as e:
|
||||
logger.warning(f"mailbox_exists({email}) SSH failed: {e}")
|
||||
return False
|
||||
|
||||
def create_mailbox(self, email: str, password: Optional[str], quota_gb: int = 20) -> bool:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{self._base}/api/v1/mailboxes",
|
||||
json={"email": email, "password": password or "", "quota": quota_gb},
|
||||
headers=self._headers,
|
||||
timeout=TIMEOUT,
|
||||
import secrets
|
||||
passwd = password or secrets.token_urlsafe(16)
|
||||
quota_mb = quota_gb * 1024
|
||||
code, output = self._ssh_exec(
|
||||
f"docker exec {MAILSERVER_CONTAINER} setup email add {email} {passwd} 2>&1"
|
||||
)
|
||||
return resp.status_code in (200, 201, 204)
|
||||
if code != 0 and "already exists" not in output.lower():
|
||||
logger.error(f"create_mailbox({email}) failed (exit {code}): {output}")
|
||||
return False
|
||||
# 設定配額
|
||||
self._ssh_exec(
|
||||
f"docker exec {MAILSERVER_CONTAINER} setup quota set {email} {quota_mb}M 2>/dev/null"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"create_mailbox({email}) failed: {e}")
|
||||
logger.error(f"create_mailbox({email}) SSH failed: {e}")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user