diff --git a/.env.example b/.env.example
index 47e52a0..7865414 100644
--- a/.env.example
+++ b/.env.example
@@ -69,15 +69,18 @@ WINRM_RETRY_COUNT=3
# ========== Gateway 配置 ==========
GATEWAY_ENABLED=False
-GATEWAY_CONTROL_SOCKET=/run/zasca/control.sock
+GATEWAY_CONTROL_SOCKET=/run/2c2a/control.sock
# ========== Beta数据库配置(Beta推送插件) ==========
-# 配置后可将生产数据推送到Beta版本数据库,支持MySQL和PostgreSQL架构
+# 配置后可将生产数据推送到Beta版本数据库,仅支持PostgreSQL架构
#BETA_DB_NAME=zasca_beta
-#BETA_DB_USER=root
+#BETA_DB_USER=postgres
#BETA_DB_PASSWORD=your_beta_database_password_here
#BETA_DB_HOST=127.0.0.1
-#BETA_DB_PORT=3306
+#BETA_DB_PORT=5432
+# Beta环境的SECRET_KEY(用于重加密:生产密钥解密 → Beta密钥加密)
+# 若不配置则直接复制密文,Beta端需使用与生产相同的SECRET_KEY才能解密
+#BETA_SECRET_KEY=
# ========== Bootstrap 认证配置 ==========
BOOTSTRAP_SHARED_SALT=
diff --git a/LICENSE b/LICENSE
index eabc293..daf412f 100755
--- a/LICENSE
+++ b/LICENSE
@@ -661,12 +661,12 @@ For more information, and how to apply and follow the GNU AGPL, see
.
- ZASCA PLUGIN EXCEPTION TO THE
+ 2c2a PLUGIN EXCEPTION TO THE
GNU AFFERO GENERAL PUBLIC LICENSE VERSION 3
This Plugin Exception ("Exception") is an additional permission under
Section 7 of the GNU Affero General Public License, Version 3 ("AGPLv3").
-It applies to the software known as ZASCA (the "Program"). This Exception
+It applies to the software known as 2c2a (the "Program"). This Exception
modifies the AGPLv3 as it applies to the Program by providing an exception
for qualifying Plugins, as defined below.
@@ -876,35 +876,35 @@ the following notices:
a prominent comment at the beginning of the file containing:
(i) A statement identifying the work as a Plugin for the
- ZASCA program;
+ 2c2a program;
(ii) The Plugin's own license terms; and
(iii) The following declaration:
- "This is a plugin for the ZASCA program. ZASCA is licensed
+ "This is a plugin for the 2c2a program. 2c2a is licensed
under the GNU Affero General Public License, Version 3
- (AGPLv3). This plugin is NOT part of ZASCA and is NOT
- licensed under the AGPLv3. The AGPLv3 applies to ZASCA
- itself, not to this plugin. This plugin uses the ZASCA
+ (AGPLv3). This plugin is NOT part of 2c2a and is NOT
+ licensed under the AGPLv3. The AGPLv3 applies to 2c2a
+ itself, not to this plugin. This plugin uses the 2c2a
Plugin Exception to the AGPLv3 for its interaction with
- ZASCA."
+ 2c2a."
(b) Documentation Notice. Any documentation, README, or
distribution materials for the Plugin must prominently state:
- (i) That the work is a Plugin for the ZASCA program;
- (ii) That ZASCA is licensed under the AGPLv3;
- (iii) That the Plugin is NOT part of ZASCA and is NOT
- subject to the AGPLv3 by virtue of the ZASCA Plugin
+ (i) That the work is a Plugin for the 2c2a program;
+ (ii) That 2c2a is licensed under the AGPLv3;
+ (iii) That the Plugin is NOT part of 2c2a and is NOT
+ subject to the AGPLv3 by virtue of the 2c2a Plugin
Exception;
(iv) The Plugin's own license terms; and
- (v) That the AGPLv3 applies to ZASCA itself, not to the
+ (v) That the AGPLv3 applies to 2c2a itself, not to the
Plugin.
(c) Runtime Notice. If the Plugin provides any user-facing
interface (including but not limited to: web pages, API
responses, CLI output, or log messages), it must display or
- include a notice indicating that it is a Plugin for ZASCA and
- that ZASCA's license (AGPLv3) does not apply to the Plugin.
+ include a notice indicating that it is a Plugin for 2c2a and
+ that 2c2a's license (AGPLv3) does not apply to the Plugin.
(d) Visual Interface Attribution. In addition to the source code
and documentation notices required by subsections (a) and (b),
@@ -926,7 +926,7 @@ the following notices:
HTML source code, comments, or developer tools. The
label must include at minimum the Plugin's name and a
statement that the content is provided by a third-party
- Plugin (e.g., "Provided by [Plugin Name] (ZASCA
+ Plugin (e.g., "Provided by [Plugin Name] (2c2a
Plugin)").
(ii) Injected UI Panels and Widgets. Any panel, widget,
@@ -951,14 +951,14 @@ the following notices:
mechanism must include a visible notice (such as a
footer, header badge, or sidebar indicator) stating
that the page is provided by the Plugin and is not part
- of ZASCA's core interface.
+ of 2c2a's core interface.
(v) General Principle. The overarching requirement is that
no UI content injected by a Plugin may appear to the
- end user as though it is a native part of the ZASCA
+ end user as though it is a native part of the 2c2a
program. The attribution must be sufficient for a
typical user to distinguish Plugin-provided UI elements
- from ZASCA's core interface elements. Minimal or
+ from 2c2a's core interface elements. Minimal or
hard-to-notice attributions (such as light-colored
small text, hidden metadata, or code comments) do not
satisfy this requirement. The attribution must be
@@ -1012,7 +1012,7 @@ the following notices:
5. SCOPE AND LIMITATIONS
- (a) This Exception applies only to the Program known as ZASCA, as
+ (a) This Exception applies only to the Program known as 2c2a, as
identified by its copyright holders. It does not apply to any
other software licensed under the AGPLv3.
diff --git a/README.md b/README.md
index a25c582..a4c9bac 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,8 @@

-
2c2a - Zero Agent Security Control Architecture
+2c2a -
+Cloudy Computer Account Activation Integration Platform
基于 Django 的企业级 Windows 主机远程管理平台
diff --git a/apps/accounts/captcha_service.py b/apps/accounts/captcha_service.py
index ba39a31..f032ffc 100644
--- a/apps/accounts/captcha_service.py
+++ b/apps/accounts/captcha_service.py
@@ -1,7 +1,6 @@
import logging
from typing import Tuple, Optional
from django.http import HttpRequest
-from . import geetest_utils, captcha_utils
logger = logging.getLogger(__name__)
@@ -22,15 +21,11 @@ def validate_captcha(
) -> Tuple[bool, Optional[str]]:
from apps.dashboard.models import SystemConfig
- provider, _, _ = SystemConfig.get_config().get_captcha_config(scene=scene)
+ provider = SystemConfig.get_config().get_captcha_config(scene=scene)
try:
- if provider == 'geetest':
- return CaptchaService._validate_geetest(request)
- elif provider == 'turnstile':
- return CaptchaService._validate_turnstile(request)
- elif provider == 'local':
- return CaptchaService._validate_local_captcha(request)
+ if provider == 'tianai':
+ return CaptchaService._validate_tianai(request)
else:
logger.debug(f"No captcha validation required for scene '{scene}', provider: {provider}")
return True, None
@@ -40,65 +35,23 @@ def validate_captcha(
return False, e.message
@staticmethod
- def _validate_geetest(request: HttpRequest) -> Tuple[bool, Optional[str]]:
- lot_number = request.POST.get('lot_number')
- captcha_output = request.POST.get('captcha_output')
- pass_token = request.POST.get('pass_token')
- gen_time = request.POST.get('gen_time')
- captcha_id = request.POST.get('captcha_id')
-
- if not all([lot_number, captcha_output, pass_token, gen_time]):
- logger.warning(f"Geetest validation failed: missing parameters - lot_number={lot_number}, captcha_output={captcha_output}, pass_token={pass_token}, gen_time={gen_time}")
- raise CaptchaValidationError('请完成验证码验证')
-
- ok, resp = geetest_utils.verify_geetest_v4(
- lot_number, captcha_output, pass_token, gen_time, captcha_id=captcha_id
- )
-
- if not ok:
- logger.warning(f"Geetest validation failed: {resp}")
- raise CaptchaValidationError('验证码校验失败')
-
- logger.info("Geetest validation succeeded")
- return True, None
-
- @staticmethod
- def _validate_turnstile(request: HttpRequest) -> Tuple[bool, Optional[str]]:
- tf_token = request.POST.get('cf-turnstile-response') or request.POST.get('turnstile_token')
-
- if not tf_token:
- logger.warning("Turnstile validation failed: missing token")
- raise CaptchaValidationError('请完成 Turnstile 验证')
+ def _validate_tianai(request: HttpRequest) -> Tuple[bool, Optional[str]]:
+ token = request.POST.get('captcha_token')
- ok, resp = geetest_utils.verify_turnstile(
- tf_token, remoteip=request.META.get('REMOTE_ADDR')
- )
-
- if not ok:
- logger.warning(f"Turnstile validation failed: {resp}")
- raise CaptchaValidationError('Turnstile 验证失败')
-
- logger.info("Turnstile validation succeeded")
- return True, None
-
- @staticmethod
- def _validate_local_captcha(request: HttpRequest) -> Tuple[bool, Optional[str]]:
- lot_number = request.POST.get('lot_number')
- captcha_input = request.POST.get('captcha_output')
-
- if not all([lot_number, captcha_input]):
- logger.warning(f"Local captcha validation failed: missing parameters - lot_number={lot_number}, captcha_input={captcha_input}")
+ if not token:
+ logger.warning("Tianai captcha validation failed: missing token")
raise CaptchaValidationError('请完成验证码验证')
- is_valid = captcha_utils.verify_captcha(
- lot_number, captcha_input, consume=True, check_attempts=True
- )
+ from django_tianai_captcha.conf import get_captcha_application
+ app = get_captcha_application()
+
+ is_valid = app.secondary_verification(token)
if not is_valid:
- logger.warning(f"Local captcha validation failed: lot_number={lot_number}")
- raise CaptchaValidationError('本地验证码校验失败')
+ logger.warning("Tianai captcha secondary verification failed")
+ raise CaptchaValidationError('验证码校验失败')
- logger.info(f"Local captcha validation succeeded: lot_number={lot_number}")
+ logger.info("Tianai captcha validation succeeded")
return True, None
diff --git a/apps/accounts/captcha_utils.py b/apps/accounts/captcha_utils.py
deleted file mode 100755
index 4cb4d7d..0000000
--- a/apps/accounts/captcha_utils.py
+++ /dev/null
@@ -1,234 +0,0 @@
-"""
-本地图形验证码工具类
-使用Pillow生成和验证图形验证码
-"""
-import random
-import string
-import io
-import logging
-from PIL import Image, ImageDraw, ImageFont, ImageFilter
-from django.core.cache import cache
-from django.http import HttpResponse
-from django.conf import settings
-import os
-
-
-# 设置日志
-logger = logging.getLogger(__name__)
-
-
-def generate_captcha_image(size=(120, 40), chars=None, count=4, color=True, noise=1, noise_color=True, font_size=25):
- """
- 生成图形验证码图片
-
- Args:
- size: 图片尺寸 (宽, 高)
- chars: 验证码字符集合
- count: 验证码字符数
- color: 是否使用彩色
- noise: 干扰线数量
- noise_color: 干扰线是否彩色
- font_size: 字体大小
-
- Returns:
- tuple: (验证码文本, 图片二进制数据)
- """
- if chars is None:
- chars = string.ascii_letters + string.digits # 包含大小写字母和数字
- # 排除容易混淆的字符
- chars = chars.replace('0', '').replace('O', '').replace('o', '').replace('l', '').replace('I', '').replace('i', '')
-
- # 随机生成验证码文本
- captcha_text = ''.join(random.choice(chars) for _ in range(count))
-
- # 创建图像
- width, height = size
- image = Image.new('RGB', (width, height), (255, 255, 255))
-
- # 创建绘图画布
- draw = ImageDraw.Draw(image)
-
- # 设置字体(尝试使用系统字体)
- try:
- # 尝试使用系统字体
- font = ImageFont.truetype("arial.ttf", font_size)
- except IOError:
- try:
- # 尝试其他常见字体
- font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", font_size) # macOS
- except IOError:
- try:
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", font_size) # Linux
- except IOError:
- # 如果找不到字体,使用默认字体
- font = ImageFont.load_default()
-
- # 生成随机颜色
- def get_random_color():
- return (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
-
- # 绘制干扰点
- for _ in range(width * height // 4):
- draw.point((random.randint(0, width), random.randint(0, height)), fill=get_random_color())
-
- # 绘制干扰线
- for _ in range(noise):
- start = (random.randint(0, width), random.randint(0, height))
- end = (random.randint(0, width), random.randint(0, height))
- line_color = get_random_color() if noise_color else (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
- draw.line([start, end], fill=line_color, width=1)
-
- # 计算文字位置,使其居中显示
- try:
- # For newer versions of Pillow
- bbox = draw.textbbox((0, 0), captcha_text, font=font)
- text_width = bbox[2] - bbox[0]
- text_height = bbox[3] - bbox[1]
- except AttributeError:
- # For older versions of Pillow
- text_width, text_height = draw.textsize(captcha_text, font=font)
-
- text_x = (width - text_width) // 2
- text_y = (height - text_height) // 2
-
- # 绘制验证码文字
- for i, char in enumerate(captcha_text):
- # 每个字符可能有不同的颜色和轻微的位置偏移
- char_color = get_random_color() if color else (0, 0, 0)
- char_x = text_x + i * (text_width // count)
-
- # 添加轻微的角度偏移使字符看起来更自然
- char_image = Image.new('RGBA', (font_size, font_size * 2), (255, 255, 255, 0))
- char_draw = ImageDraw.Draw(char_image)
- char_draw.text((0, 0), char, font=font, fill=char_color)
-
- # 旋转字符增加复杂度
- angle = random.randint(-15, 15)
- char_image = char_image.rotate(angle, expand=0, fillcolor=(255, 255, 255, 0))
-
- # 将旋转后的字符粘贴到主图像上
- image.paste(char_image, (char_x, text_y), char_image)
-
- # 对图像应用模糊效果以增加难度
- image = image.filter(ImageFilter.SMOOTH)
-
- # 将图像转换为字节流
- buffer = io.BytesIO()
- image.save(buffer, format='PNG')
- buffer.seek(0)
-
- return captcha_text, buffer.getvalue()
-
-
-def generate_captcha():
- """
- 生成验证码,返回验证码文本和图片数据,并将验证码存储到缓存中
-
- Returns:
- dict: 包含验证码ID和图片数据的字典
- """
- captcha_text, image_data = generate_captcha_image()
-
- captcha_id = 'captcha_' + ''.join(random.choices(string.ascii_letters + string.digits, k=16))
-
- cache.set(captcha_id, captcha_text.lower(), 300)
- cache.set(f"captcha_attempts_{captcha_id}", 0, 300)
- cache.set(f"captcha_image_{captcha_id}", image_data, 300)
-
- logger.debug(f"Generated captcha: ID={captcha_id}")
-
- return {
- 'captcha_id': captcha_id,
- 'image_data': image_data
- }
-
-
-def verify_captcha(captcha_id, user_input, consume=True, max_attempts=5, check_attempts=True):
- """
- 验证用户输入的验证码是否正确
-
- Args:
- captcha_id: 验证码ID
- user_input: 用户输入的验证码
- consume: 是否在验证成功后删除验证码(默认为True)
- max_attempts: 最大尝试次数(默认为5次)
- check_attempts: 是否检查尝试次数(默认为True)
-
- Returns:
- bool: 验证是否成功
- """
- logger.debug(f"Verifying captcha: ID={captcha_id}, user_input={user_input}, consume={consume}, check_attempts={check_attempts}")
-
- if not captcha_id or not user_input:
- logger.warning(f"Invalid input: captcha_id='{captcha_id}', user_input='{user_input}'")
- return False
-
- # 检查尝试次数
- if check_attempts:
- attempts_key = f"captcha_attempts_{captcha_id}"
- attempts = cache.get(attempts_key, 0)
-
- logger.debug(f"Captcha {captcha_id} attempts: {attempts}/{max_attempts}")
-
- # 如果已达到最大尝试次数,拒绝进一步尝试
- if attempts >= max_attempts:
- logger.warning(f"Captcha {captcha_id} reached max attempts ({max_attempts}). Invalidating captcha.")
- cache.delete(captcha_id)
- cache.delete(attempts_key)
- cache.delete(f"captcha_image_{captcha_id}")
- return False
-
- # 增加尝试次数(兼容 locmem 等不支持 incr 对不存在 key 操作的缓存后端)
- current_attempts = cache.get(attempts_key)
- if current_attempts is None:
- cache.set(attempts_key, 1, 300)
- else:
- cache.incr(attempts_key)
- logger.debug(f"Incremented attempts for {captcha_id}, now: {cache.get(attempts_key)}")
-
- # 从缓存中获取正确的验证码
- correct_captcha = cache.get(captcha_id)
-
- if not correct_captcha:
- logger.warning(f"Correct captcha not found or expired: {captcha_id}")
- return False # 验证码已过期或不存在
-
- logger.debug(f"Retrieved correct captcha for {captcha_id}: {correct_captcha}")
-
- # 验证用户输入(不区分大小写)
- is_valid = correct_captcha.lower() == user_input.lower()
- logger.info(f"Captcha verification result for {captcha_id}: {is_valid}")
-
- if is_valid and consume:
- logger.info(f"Consuming captcha {captcha_id} (deleting from cache)")
- cache.delete(captcha_id)
- cache.delete(f"captcha_attempts_{captcha_id}")
- cache.delete(f"captcha_image_{captcha_id}")
- logger.debug(f"Captcha {captcha_id} consumed and removed from cache")
-
- if not is_valid and check_attempts:
- attempts_key = f"captcha_attempts_{captcha_id}"
- current_attempts = cache.get(attempts_key, 0)
- if current_attempts >= max_attempts:
- logger.warning(f"Failed attempt reached max limit for {captcha_id}. Invalidating captcha.")
- cache.delete(captcha_id)
- cache.delete(attempts_key)
- cache.delete(f"captcha_image_{captcha_id}")
-
- return is_valid
-
-
-def get_captcha_image(request, captcha_id):
- logger.debug(f"Serving captcha image: {captcha_id}")
-
- if not captcha_id:
- result = generate_captcha()
- image_data = result['image_data']
- else:
- image_data = cache.get(f"captcha_image_{captcha_id}")
- if not image_data:
- logger.warning(f"Requested captcha not found: {captcha_id}, generating new one")
- result = generate_captcha()
- image_data = result['image_data']
-
- return HttpResponse(image_data, content_type='image/png')
\ No newline at end of file
diff --git a/apps/accounts/geetest_utils.py b/apps/accounts/geetest_utils.py
deleted file mode 100755
index e629244..0000000
--- a/apps/accounts/geetest_utils.py
+++ /dev/null
@@ -1,173 +0,0 @@
-"""
-极验(Geetest)集成工具(仅 v4)
-提供二次校验(verify)的封装和前端初始化信息
-"""
-import json
-import logging
-import time
-import requests
-from django.conf import settings
-import hmac
-import hashlib
-from apps.dashboard.models import SystemConfig
-
-logger = logging.getLogger('2c2a')
-
-# v4 validate endpoint base
-GEETEST_V4_API_SERVER = 'https://gcaptcha4.geetest.com'
-
-# 超时配置(秒)
-REQUEST_TIMEOUT = 10
-
-
-def _get_runtime_keys():
- """Return (captcha_id, captcha_key) by preferring SystemConfig when enabled for geetest, otherwise settings."""
- captcha_id = getattr(settings, 'GEETEST_ID', None)
- captcha_key = getattr(settings, 'GEETEST_KEY', None)
-
- try:
- config = SystemConfig.get_config()
- if config and config.captcha_provider == 'geetest':
- if config.captcha_id:
- captcha_id = config.captcha_id
- if config.captcha_key:
- captcha_key = config.captcha_key
- except Exception:
- # if models not ready or DB unavailable, ignore and fall back to settings
- pass
-
- return captcha_id, captcha_key
-
-
-def get_geetest_init(request):
- """Return minimal init info for frontend v4 usage.
-
- Returns: dict with keys:
- - captcha_id: id for initGeetest4
- - enabled: whether SystemConfig enables geetest or settings present
- """
- captcha_id, captcha_key = _get_runtime_keys()
-
- enabled = bool(captcha_id and captcha_key)
-
- # cache server status in session (simple)
- request.session['geetest_server_status'] = enabled
- request.session['geetest_server_status_ts'] = int(time.time())
-
- return {
- 'captcha_id': captcha_id,
- 'enabled': enabled,
- 'success': 1 if enabled else 0,
- }
-
-
-def verify_geetest_v4(lot_number, captcha_output, pass_token, gen_time, captcha_id=None):
- """使用 Geetest v4 的二次校验接口进行服务器端验证。
-
- 参考接口:POST https://gcaptcha4.geetest.com/validate?captcha_id=xxxxx
- 请求体(application/x-www-form-urlencoded):
- lot_number, captcha_output, pass_token, gen_time, sign_token
-
- sign_token = HMAC_SHA256(key=captcha_key, message=lot_number)
-
- 返回 (bool, message_or_response_dict)
- """
- # 获取运行时的 id/key(优先使用 SystemConfig)
- runtime_id, runtime_key = _get_runtime_keys()
- captcha_id = captcha_id or runtime_id
- captcha_key = runtime_key
-
- # 基本参数检查
- if not all([lot_number, captcha_output, pass_token, gen_time, captcha_id, captcha_key]):
- return False, '参数不完整或未配置极验ID/Key'
-
- try:
- # sign token: HMAC-SHA256(lot_number, captcha_key)
- # 注意:hmac.new(key, message, digestmod) — key和message都应为bytes
- sign_token = hmac.new(captcha_key.encode('utf-8'), lot_number.encode('utf-8'), digestmod=hashlib.sha256).hexdigest()
-
- data = {
- 'lot_number': lot_number,
- 'captcha_output': captcha_output,
- 'pass_token': pass_token,
- 'gen_time': gen_time,
- 'sign_token': sign_token,
- }
-
- url = f'{GEETEST_V4_API_SERVER}/validate'
- # 添加captcha_id作为查询参数
- params = {'captcha_id': captcha_id}
-
- # 使用 application/x-www-form-urlencoded 提交
- r = requests.post(url, data=data, params=params, timeout=REQUEST_TIMEOUT)
- r.raise_for_status()
-
- # 解析 JSON 响应
- try:
- resp = r.json()
- except ValueError:
- # 非 JSON 返回
- logger.error('Geetest v4 validate returned non-JSON response: %s', r.text)
- return False, {'status': 'error', 'msg': '非JSON响应', 'raw': r.text}
-
- # 成功时 resp['result'] == 'success' (注意:文档中提到的是result而不是status)
- result = resp.get('result')
- reason = resp.get('reason', '')
-
- if result == 'success':
- return True, resp
- else:
- error_msg = f'验证码校验失败: {reason}' if reason else f'验证码校验失败: {resp}'
- logger.warning(f'Geetest v4 validation failed: {error_msg}')
- return False, resp
-
- except requests.Timeout:
- logger.error('Geetest v4 validate request timed out')
- return False, {'status': 'error', 'reason': '请求超时'}
- except requests.RequestException as e:
- logger.exception('请求 Geetest v4 校验接口失败: %s', e)
- return False, {'status': 'error', 'reason': '请求 geetest API 失败'}
- except Exception as e:
- logger.exception('Geetest v4 verify unexpected error: %s', e)
- return False, {'status': 'error', 'reason': '验证异常'}
-
-
-def verify_turnstile(response_token, remoteip=None):
- """Verify Cloudflare Turnstile token server-side.
-
- POST https://challenges.cloudflare.com/turnstile/v0/siteverify
- params: secret, response, remoteip (optional)
-
- Returns (bool, resp_dict)
- """
- secret = getattr(settings, 'TURNSTILE_SECRET_KEY', None)
- try:
- cfg = SystemConfig.get_config()
- if cfg and cfg.captcha_provider == 'turnstile' and cfg.captcha_key:
- secret = cfg.captcha_key
- except Exception:
- pass
-
- if not secret or not response_token:
- return False, {'success': False, 'error': 'missing secret or response'}
-
- try:
- url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
- data = {
- 'secret': secret,
- 'response': response_token,
- }
- if remoteip:
- data['remoteip'] = remoteip
-
- r = requests.post(url, data=data, timeout=REQUEST_TIMEOUT)
- r.raise_for_status()
- resp = r.json()
- # resp['success'] is True/False
- return bool(resp.get('success')), resp
- except requests.RequestException as e:
- logger.exception('Turnstile verify request failed: %s', e)
- return False, {'success': False, 'error': 'request failed'}
- except Exception as e:
- logger.exception('Turnstile verify unexpected error: %s', e)
- return False, {'success': False, 'error': 'unexpected error'}
\ No newline at end of file
diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py
index 310f344..9a8d263 100755
--- a/apps/accounts/urls.py
+++ b/apps/accounts/urls.py
@@ -1,6 +1,3 @@
-"""
-用户管理URL配置
-"""
from django.urls import path
from django.views.decorators.cache import never_cache
from . import views
@@ -13,17 +10,9 @@
path('login/', never_cache(views.LoginView.as_view()), name='login'),
path('profile/', views.ProfileView.as_view(), name='profile'),
path('logout/', views.logout_view, name='logout'),
- # Geetest endpoints
- path('geetest/register/', views.geetest_register, name='geetest_register'),
- path('geetest/validate/', views.geetest_validate, name='geetest_validate'),
path('email/send-code/', views.send_register_email_code, name='send_register_email_code'),
path('forgot-password/', views.ForgotPasswordView.as_view(), name='forgot_password'),
path('email/send-forgot-password-code/', views.send_forgot_password_email_code, name='send_forgot_password_email_code'),
- # Local Captcha endpoints
- path('captcha/generate/', views.local_captcha_generate, name='local_captcha_generate'),
- path('captcha/image//', views.local_captcha_image, name='local_captcha_image'),
- path('captcha/verify/', views.local_captcha_verify, name='local_captcha_verify'),
path('api/profile/avatar/', views.upload_avatar, name='upload_avatar'),
- # API endpoints
path('api/password/change/', views.password_change_api, name='password_change_api'),
-]
\ No newline at end of file
+]
diff --git a/apps/accounts/views.py b/apps/accounts/views.py
index 33314b3..fc31e29 100755
--- a/apps/accounts/views.py
+++ b/apps/accounts/views.py
@@ -18,8 +18,6 @@
from .models import User, RegistrationLink
from .forms import UserRegistrationForm, UserUpdateForm, UserLoginForm
-from . import geetest_utils
-from . import captcha_utils
from . import rate_limit
from apps.themes.models import ThemeConfig, PageContent
@@ -35,6 +33,20 @@ def get_theme_context():
}
+def get_captcha_context(scene):
+ from apps.dashboard.models import SystemConfig
+ sc = SystemConfig.get_config()
+ captcha_provider, captcha_type = sc.get_captcha_config(scene=scene)
+ ctx = {
+ 'CAPTCHA_PROVIDER': captcha_provider,
+ 'CAPTCHA_TYPE': captcha_type,
+ }
+ if scene in ('register', 'forgot_password'):
+ _, email_type = sc.get_captcha_config(scene='email')
+ ctx['CAPTCHA_TYPE_EMAIL'] = email_type
+ return ctx
+
+
@method_decorator(rate_limit.register_rate_limit, name='dispatch')
class RegisterView(CreateView):
"""用户注册视图"""
@@ -46,22 +58,8 @@ class RegisterView(CreateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- from apps.dashboard.models import SystemConfig
- sc = SystemConfig.get_config()
- # 使用与后端验证相同的逻辑来确定captcha_id
- captcha_id, _ = geetest_utils._get_runtime_keys()
- context['GEETEST_ID'] = captcha_id
- # 获取注册场景的配置
- captcha_provider, _, captcha_key = sc.get_captcha_config(scene='register')
- context['CAPTCHA_PROVIDER'] = captcha_provider
- # 仅在turnstile模式下提供turnstile的site key
- if captcha_provider == 'turnstile':
- context['TURNSTILE_SITE_KEY'] = captcha_key
- else:
- context['TURNSTILE_SITE_KEY'] = None
-
+ context.update(get_captcha_context('register'))
context.update(get_theme_context())
-
return context
def form_valid(self, form):
@@ -78,8 +76,11 @@ def form_valid(self, form):
import hmac
cache_key = f'register_email_code:{email}'
expected = cache.get(cache_key)
- if not hmac.compare_digest(str(expected or ''), str(email_code or '')):
- form.add_error(None, '邮箱验证码错误或已过期')
+ if expected is None:
+ form.add_error(None, '邮箱验证码已过期或不存在')
+ return self.form_invalid(form)
+ if not hmac.compare_digest(str(expected), str(email_code)):
+ form.add_error(None, '邮箱验证码错误')
return self.form_invalid(form)
# Optionally clear the code to prevent reuse
@@ -111,22 +112,10 @@ def get_context_data(self, **kwargs):
"""获取模板上下文数据"""
context = super().get_context_data(**kwargs)
context['form'] = UserLoginForm()
- from apps.dashboard.models import SystemConfig
- sc = SystemConfig.get_config()
- # 使用场景化配置获取登录场景的验证码设置
- captcha_provider, captcha_id, captcha_key = sc.get_captcha_config(scene='login')
- context['GEETEST_ID'] = captcha_id
- context['CAPTCHA_PROVIDER'] = captcha_provider
- if captcha_provider == 'turnstile':
- context['TURNSTILE_SITE_KEY'] = captcha_key
- else:
- context['TURNSTILE_SITE_KEY'] = None
-
+ context.update(get_captcha_context('login'))
context['is_demo_mode'] = getattr(self.request, 'is_demo_mode', False)
context['next'] = self.request.POST.get('next') or self.request.GET.get('next', '')
-
context.update(get_theme_context())
-
return context
def post(self, request, *args, **kwargs):
@@ -281,42 +270,6 @@ def logout_view(request):
return redirect('accounts:login')
-# Geetest endpoints
-@require_http_methods(['GET'])
-def geetest_register(request):
- """为前端提供极验初始化参数(JSON)"""
- data = geetest_utils.get_geetest_init(request)
- return JsonResponse(data)
-
-
-@require_http_methods(['POST'])
-@csrf_protect
-@rate_limit.general_api_rate_limit
-def geetest_validate(request):
- """可以做一次性的验证接口(可选)。
- 前端可直接把三个字段POST到此处获取验证结果
- """
- # 支持 v4 参数
- # (lot_number / captcha_output / pass_token / gen_time / captcha_id)
- lot_number = request.POST.get('lot_number')
- captcha_output = request.POST.get('captcha_output')
- pass_token = request.POST.get('pass_token')
- gen_time = request.POST.get('gen_time')
- captcha_id = request.POST.get('captcha_id')
-
- if lot_number and captcha_output and pass_token and gen_time:
- ok, resp = geetest_utils.verify_geetest_v4(
- lot_number, captcha_output, pass_token, gen_time,
- captcha_id=captcha_id
- )
- if ok:
- return JsonResponse({'result': 'ok', 'detail': resp})
- else:
- return JsonResponse({'result': 'fail', 'detail': resp}, status=400)
-
- return JsonResponse({'result': 'fail', 'detail': '参数不完整'}, status=400)
-
-
@login_required
@require_http_methods(["POST"])
@rate_limit.general_api_rate_limit
@@ -366,14 +319,7 @@ def _gen_code(length=6):
@csrf_protect
@rate_limit.email_code_rate_limit
def send_register_email_code(request):
- """Send a one-time code to the supplied email for registration.
-
- Requires behavior captcha validation to have been passed in this session
- if captcha_provider == 'geetest' or 'turnstile'
- (adapter should call /accounts/geetest/validate/ first and backend can
- check session or just trust front-end - here we trust front-end token
- by requiring v4 params in this request).
- """
+ """Send a one-time code to the supplied email for registration."""
# 检查是否启用了注册功能
from apps.dashboard.models import SystemConfig
cfg = SystemConfig.get_config()
@@ -557,18 +503,7 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['reglink'] = self.reglink
context['target_group'] = self.reglink.group
- from apps.dashboard.models import SystemConfig
- sc = SystemConfig.get_config()
- # 获取邮箱验证码场景的配置(获取验证码需要行为验证)
- captcha_provider, captcha_id, captcha_key = sc.get_captcha_config(
- scene='email'
- )
- context['GEETEST_ID'] = captcha_id
- context['CAPTCHA_PROVIDER'] = captcha_provider
- if captcha_provider == 'turnstile':
- context['TURNSTILE_SITE_KEY'] = captcha_key
- else:
- context['TURNSTILE_SITE_KEY'] = None
+ context.update(get_captcha_context('email'))
context.update(get_theme_context())
return context
@@ -582,10 +517,11 @@ def form_valid(self, form):
cache_key = f'register_email_code:{email}'
expected = cache.get(cache_key)
- if not hmac.compare_digest(
- str(expected or ''), str(email_code or '')
- ):
- form.add_error(None, '邮箱验证码错误或已过期')
+ if expected is None:
+ form.add_error(None, '邮箱验证码已过期或不存在')
+ return self.form_invalid(form)
+ if not hmac.compare_digest(str(expected), str(email_code)):
+ form.add_error(None, '邮箱验证码错误')
return self.form_invalid(form)
cache.delete(cache_key)
@@ -670,36 +606,6 @@ def upload_avatar(request):
return JsonResponse({'status': 'error', 'message': '没有上传文件'})
-# Local Captcha endpoints
-@require_http_methods(['GET'])
-def local_captcha_generate(request):
- """Generate a local image captcha and return the captcha ID"""
- result = captcha_utils.generate_captcha()
- return JsonResponse({'captcha_id': result['captcha_id']})
-
-
-def local_captcha_image(request, captcha_id):
- """Return the image for the given captcha ID"""
- return captcha_utils.get_captcha_image(request, captcha_id)
-
-
-@require_http_methods(['POST'])
-@rate_limit.general_api_rate_limit
-def local_captcha_verify(request):
- """Verify the user's input against the captcha"""
- captcha_id = request.POST.get('captcha_id')
- user_input = request.POST.get('captcha_input')
-
- # 验证时设置consume=False,这样验证后不会删除,可用于后续的表单提交验证
- # 设置较低的尝试次数限制,防止暴力破解
- if captcha_utils.verify_captcha(
- captcha_id, user_input, consume=False, max_attempts=3
- ):
- return JsonResponse({'result': 'success'})
- else:
- return JsonResponse({'result': 'failure'}, status=400)
-
-
@method_decorator(rate_limit.register_rate_limit, name='dispatch')
class ForgotPasswordView(TemplateView):
"""忘记密码视图"""
@@ -709,22 +615,8 @@ class ForgotPasswordView(TemplateView):
def get_context_data(self, **kwargs):
"""获取模板上下文数据"""
context = super().get_context_data(**kwargs)
- from apps.dashboard.models import SystemConfig
- sc = SystemConfig.get_config()
- # 使用与后端验证相同的逻辑来确定captcha_id
- captcha_id, _ = geetest_utils._get_runtime_keys()
- context['GEETEST_ID'] = captcha_id
- # 获取邮箱场景的配置
- captcha_provider, _, captcha_key = sc.get_captcha_config(scene='email')
- context['CAPTCHA_PROVIDER'] = captcha_provider
- # 仅在turnstile模式下提供turnstile的site key
- if captcha_provider == 'turnstile':
- context['TURNSTILE_SITE_KEY'] = captcha_key
- else:
- context['TURNSTILE_SITE_KEY'] = None
-
+ context.update(get_captcha_context('email'))
context.update(get_theme_context())
-
return context
def post(self, request, *args, **kwargs):
@@ -739,36 +631,41 @@ def post(self, request, *args, **kwargs):
messages.error(request, '请填写所有必需字段')
return self.render_to_response(self.get_context_data())
- if new_password1 != new_password2:
- messages.error(request, '两次输入的密码不一致')
- return self.render_to_response(self.get_context_data())
-
- from django.contrib.auth.password_validation import validate_password
- from django.core.exceptions import ValidationError as ValError
- try:
- validate_password(new_password1)
- except ValError as e:
- messages.error(request, e.messages[0])
+ # 1. 行为验证码
+ from .captcha_service import validate_captcha
+ is_valid, error_msg = validate_captcha(request, scene='email')
+ if not is_valid:
+ messages.error(request, error_msg)
return self.render_to_response(self.get_context_data())
- from .captcha_service import validate_captcha
+ # 2. 邮箱验证码
import hmac
cache_key = f'forgot_password_email_code:{email}'
expected = cache.get(cache_key)
- if not hmac.compare_digest(str(expected or ''), str(email_code or '')):
- messages.error(request, '验证码错误或已过期')
+ if expected is None:
+ messages.error(request, '邮箱验证码已过期或不存在')
return self.render_to_response(self.get_context_data())
-
+ if not hmac.compare_digest(str(expected), str(email_code)):
+ messages.error(request, '邮箱验证码错误')
+ return self.render_to_response(self.get_context_data())
+
+ # 3. 用户存在性检查
user_exists = User.objects.filter(email=email).exists()
if not user_exists:
messages.success(request, '如果该邮箱已注册,密码重置邮件已发送')
return redirect('accounts:login')
-
- from .captcha_service import validate_captcha
- is_valid, error_msg = validate_captcha(request, scene='email')
- if not is_valid:
- messages.error(request, error_msg)
+ # 4. 密码重置
+ if new_password1 != new_password2:
+ messages.error(request, '两次输入的密码不一致')
+ return self.render_to_response(self.get_context_data())
+
+ from django.contrib.auth.password_validation import validate_password
+ from django.core.exceptions import ValidationError as ValError
+ try:
+ validate_password(new_password1)
+ except ValError as e:
+ messages.error(request, e.messages[0])
return self.render_to_response(self.get_context_data())
user = User.objects.get(email=email)
@@ -786,14 +683,7 @@ def post(self, request, *args, **kwargs):
@csrf_protect
@rate_limit.email_code_rate_limit
def send_forgot_password_email_code(request):
- """Send a one-time code to the supplied email for password reset.
-
- Requires behavior captcha validation to have been passed in this session
- if captcha_provider == 'geetest' or 'turnstile'
- (adapter should call /accounts/geetest/validate/ first and backend can
- check session or just trust front-end - here we trust front-end token
- by requiring v4 params in this request).
- """
+ """Send a one-time code to the supplied email for password reset."""
email = request.POST.get('email')
if not email:
diff --git a/apps/accounts/views_superadmin.py b/apps/accounts/views_superadmin.py
index d5d9920..eb81c16 100644
--- a/apps/accounts/views_superadmin.py
+++ b/apps/accounts/views_superadmin.py
@@ -15,6 +15,7 @@
HostGroupProviderAssignForm,
)
from apps.hosts.models import Host, HostGroup
+from apps.hosts.views_admin import _get_permission_context
@superadmin_required
@@ -97,6 +98,7 @@ def superadmin_host_provider_assign(request, pk):
'current_providers': host.providers.all(),
'active_nav': 'provider_hosts',
}
+ context.update(_get_permission_context(form, host))
return render(
request, 'admin_base/providers/host_provider_assign.html', context
diff --git a/apps/audit/views.py b/apps/audit/views.py
index 481590c..710e78b 100755
--- a/apps/audit/views.py
+++ b/apps/audit/views.py
@@ -12,9 +12,35 @@
from datetime import datetime, timedelta
import json
import logging
+import re
logger = logging.getLogger(__name__)
+MAX_PAGE_SIZE = 100
+MAX_SEARCH_LENGTH = 200
+DATE_FORMAT = '%Y-%m-%d'
+
+
+def _validate_int_param(value, default=1, min_val=1, max_val=None):
+ try:
+ result = int(value)
+ result = max(min_val, result)
+ if max_val:
+ result = min(max_val, result)
+ return result
+ except (ValueError, TypeError):
+ return default
+
+
+def _validate_date_param(value):
+ if not value:
+ return None
+ try:
+ datetime.strptime(value, DATE_FORMAT)
+ return value
+ except ValueError:
+ return None
+
@require_http_methods(["GET"])
@login_required
@@ -22,16 +48,15 @@
def get_audit_logs(request):
"""获取审计日志列表"""
try:
- # 参数获取
- page = int(request.GET.get('page', 1))
- page_size = min(int(request.GET.get('page_size', 20)), 100) # 最大100条每页
- action = request.GET.get('action')
- user_id = request.GET.get('user_id')
- host_id = request.GET.get('host_id')
- start_date = request.GET.get('start_date')
- end_date = request.GET.get('end_date')
+ page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000)
+ page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE)
+ action = request.GET.get('action', '')[:50] if request.GET.get('action') else None
+ user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None
+ host_id = _validate_int_param(request.GET.get('host_id'), default=None, min_val=1) if request.GET.get('host_id') else None
+ start_date = _validate_date_param(request.GET.get('start_date'))
+ end_date = _validate_date_param(request.GET.get('end_date'))
success = request.GET.get('success')
- search = request.GET.get('search', '') # 搜索关键词
+ search = request.GET.get('search', '')[:MAX_SEARCH_LENGTH]
# 构建查询集
queryset = AuditLog.objects.select_related('user', 'host').all()
@@ -118,16 +143,15 @@ def get_audit_logs(request):
def get_sensitive_operations(request):
"""获取敏感操作记录"""
try:
- page = int(request.GET.get('page', 1))
- page_size = min(int(request.GET.get('page_size', 20)), 100)
- user_id = request.GET.get('user_id')
- operation_type = request.GET.get('operation_type')
- start_date = request.GET.get('start_date')
- end_date = request.GET.get('end_date')
+ page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000)
+ page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE)
+ user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None
+ operation_type = request.GET.get('operation_type', '')[:50] if request.GET.get('operation_type') else None
+ start_date = _validate_date_param(request.GET.get('start_date'))
+ end_date = _validate_date_param(request.GET.get('end_date'))
queryset = SensitiveOperation.objects.select_related('user', 'approved_by').all()
- # 应用过滤器
if user_id:
queryset = queryset.filter(user_id=user_id)
if operation_type:
@@ -194,14 +218,14 @@ def get_sensitive_operations(request):
def get_security_events(request):
"""获取安全事件记录"""
try:
- page = int(request.GET.get('page', 1))
- page_size = min(int(request.GET.get('page_size', 20)), 100)
- event_type = request.GET.get('event_type')
- severity = request.GET.get('severity')
+ page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000)
+ page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE)
+ event_type = request.GET.get('event_type', '')[:50] if request.GET.get('event_type') else None
+ severity = request.GET.get('severity', '')[:20] if request.GET.get('severity') else None
resolved = request.GET.get('resolved')
- user_id = request.GET.get('user_id')
- start_date = request.GET.get('start_date')
- end_date = request.GET.get('end_date')
+ user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None
+ start_date = _validate_date_param(request.GET.get('start_date'))
+ end_date = _validate_date_param(request.GET.get('end_date'))
queryset = SecurityEvent.objects.select_related('user', 'resolved_by').all()
@@ -292,8 +316,8 @@ def mark_security_event_resolved(request):
event.resolved = True
event.resolved_by = request.user if request.user.is_authenticated else None
event.resolved_at = timezone.now()
- event.resolution_notes = resolution_notes
- event.save()
+ event.resolution_notes = resolution_notes[:1000] if resolution_notes else ''
+ event.save(update_fields=['resolved', 'resolved_by', 'resolved_at', 'resolution_notes'])
return JsonResponse({
'success': True,
@@ -319,9 +343,9 @@ def mark_security_event_resolved(request):
def get_user_session_activity(request):
"""获取用户会话活动记录"""
try:
- user_id = request.GET.get('user_id')
- page = int(request.GET.get('page', 1))
- page_size = min(int(request.GET.get('page_size', 20)), 100)
+ user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None
+ page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000)
+ page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE)
queryset = SessionActivity.objects.select_related('user').all()
@@ -474,11 +498,11 @@ def export_audit_logs(request):
"""导出审计日志(CSV格式)"""
try:
# 获取查询参数
- action = request.GET.get('action')
- user_id = request.GET.get('user_id')
- host_id = request.GET.get('host_id')
- start_date = request.GET.get('start_date')
- end_date = request.GET.get('end_date')
+ action = request.GET.get('action', '')[:50] if request.GET.get('action') else None
+ user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None
+ host_id = _validate_int_param(request.GET.get('host_id'), default=None, min_val=1) if request.GET.get('host_id') else None
+ start_date = _validate_date_param(request.GET.get('start_date'))
+ end_date = _validate_date_param(request.GET.get('end_date'))
# 构建查询集
queryset = AuditLog.objects.select_related('user', 'host').all()
diff --git a/apps/bootstrap/management/commands/cleanup_expired_sessions.py b/apps/bootstrap/management/commands/cleanup_expired_sessions.py
index 840bfec..9b9c12c 100644
--- a/apps/bootstrap/management/commands/cleanup_expired_sessions.py
+++ b/apps/bootstrap/management/commands/cleanup_expired_sessions.py
@@ -1,47 +1,73 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
-from apps.bootstrap.models import ActiveSession
-from datetime import timedelta
+from apps.bootstrap.models import ActiveSession, InitialToken
class Command(BaseCommand):
- help = '清理过期的活动会话'
+ help = '清理过期的活动会话和初始令牌'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
- help='仅显示将要删除的会话,不实际删除',
+ help='仅显示将要删除的记录,不实际删除',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
-
- # 查找过期的会话
- expired_sessions = ActiveSession.objects.filter(expires_at__lt=timezone.now())
-
+ now = timezone.now()
+
+ expired_sessions = ActiveSession.objects.filter(
+ expires_at__lt=now,
+ )
if expired_sessions.exists():
+ count = expired_sessions.count()
self.stdout.write(
- self.style.SUCCESS(
- f'找到 {expired_sessions.count()} 个过期的会话'
+ f'找到 {count} 个过期的会话'
+ )
+ if not dry_run:
+ expired_sessions.delete()
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'已删除 {count} 个过期的会话'
+ )
)
+
+ expired_tokens = InitialToken.objects.filter(
+ expires_at__lt=now,
+ )
+ if expired_tokens.exists():
+ count = expired_tokens.count()
+ self.stdout.write(
+ f'找到 {count} 个过期的初始令牌'
)
-
- if dry_run:
- for session in expired_sessions:
- self.stdout.write(
- f"- Session: {session.session_token[:12]}..., "
- f"Host: {session.host.name}, "
- f"Expired: {session.expires_at}"
+ if not dry_run:
+ expired_tokens.delete()
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'已删除 {count} 个过期的初始令牌'
)
- else:
- deleted_count = expired_sessions.delete()[0]
+ )
+
+ orphan_tokens = InitialToken.objects.filter(
+ host=None,
+ status='ISSUED',
+ expires_at__gt=now,
+ )
+ if orphan_tokens.exists():
+ count = orphan_tokens.count()
+ self.stdout.write(
+ f'找到 {count} 个未关联主机的初始令牌'
+ )
+ if not dry_run:
+ orphan_tokens.delete()
self.stdout.write(
self.style.SUCCESS(
- f'成功删除 {deleted_count} 个过期的会话'
+ f'已删除 {count} 个未关联主机的初始令牌'
)
)
- else:
+
+ if not any([expired_sessions.exists(), expired_tokens.exists(), orphan_tokens.exists()]):
self.stdout.write(
- self.style.SUCCESS('没有找到过期的会话')
+ self.style.SUCCESS('没有需要清理的记录')
)
\ No newline at end of file
diff --git a/apps/bootstrap/middleware.py b/apps/bootstrap/middleware.py
index 47b510c..e99c6ff 100644
--- a/apps/bootstrap/middleware.py
+++ b/apps/bootstrap/middleware.py
@@ -28,14 +28,18 @@ def __call__(self, request):
'/api/exchange_token',
'/api/exchange_token/',
'/bootstrap/exchange-token/',
- '/api/get_session_token', # 允许InitialToken访问(无斜杠版本)
- '/api/get_session_token/', # 允许InitialToken访问(有斜杠版本)
- '/bootstrap/api/get_session_token', # Bootstrap应用下的路径(无斜杠)
- '/bootstrap/api/get_session_token/', # Bootstrap应用下的路径(有斜杠)
- '/api/check_totp_status', # 允许InitialToken访问检查状态(无斜杠版本)
- '/api/check_totp_status/', # 允许InitialToken访问检查状态(有斜杠版本)
- '/bootstrap/api/check_totp_status', # Bootstrap应用下的检查状态路径(无斜杠)
- '/bootstrap/api/check_totp_status/' # Bootstrap应用下的检查状态路径(有斜杠)
+ '/api/get_session_token',
+ '/api/get_session_token/',
+ '/bootstrap/api/get_session_token',
+ '/bootstrap/api/get_session_token/',
+ '/api/check_totp_status',
+ '/api/check_totp_status/',
+ '/bootstrap/api/check_totp_status',
+ '/bootstrap/api/check_totp_status/',
+ '/bootstrap/sse/init-status',
+ '/bootstrap/sse/init-status/',
+ '/bootstrap/api/upload_host_cert',
+ '/bootstrap/api/upload_host_cert/',
]
if (request.path.startswith('/api/') or
diff --git a/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py b/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py
new file mode 100644
index 0000000..ca11b15
--- /dev/null
+++ b/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py
@@ -0,0 +1,20 @@
+# Generated by Django 4.2.30 on 2026-05-23 09:29
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('hosts', '0011_host_auth_method_host_cert_key_path_and_more'),
+ ('bootstrap', '0008_add_pairing_attempts'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='initialtoken',
+ name='host',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机'),
+ ),
+ ]
diff --git a/apps/bootstrap/migrations/0010_add_cert_data.py b/apps/bootstrap/migrations/0010_add_cert_data.py
new file mode 100644
index 0000000..27a0b8b
--- /dev/null
+++ b/apps/bootstrap/migrations/0010_add_cert_data.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-05-23 14:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bootstrap', '0009_initialtoken_host_nullable'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='initialtoken',
+ name='cert_data',
+ field=models.JSONField(blank=True, default=None, null=True, verbose_name='暂存证书数据'),
+ ),
+ ]
diff --git a/apps/bootstrap/migrations/0011_add_cert_provision_token.py b/apps/bootstrap/migrations/0011_add_cert_provision_token.py
new file mode 100644
index 0000000..e7e33e4
--- /dev/null
+++ b/apps/bootstrap/migrations/0011_add_cert_provision_token.py
@@ -0,0 +1,35 @@
+# Generated by Django 4.2.30 on 2026-05-29 15:57
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('hosts', '0012_host_username_optional'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('bootstrap', '0010_add_cert_data'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CertProvisionToken',
+ fields=[
+ ('token', models.CharField(max_length=64, primary_key=True, serialize=False, verbose_name='配置令牌')),
+ ('server_host', models.CharField(max_length=255, verbose_name='服务器地址')),
+ ('expires_at', models.DateTimeField(verbose_name='过期时间')),
+ ('status', models.CharField(choices=[('ISSUED', '已签发'), ('HOSTNAME_UPLOADED', '主机名已上传'), ('CERT_ISSUED', '证书已签发'), ('HOST_CONFIGURED', '主机已配置'), ('CONSUMED', '已消耗')], default='ISSUED', max_length=20, verbose_name='状态')),
+ ('consumed_at', models.DateTimeField(blank=True, null=True, verbose_name='消耗时间')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建者')),
+ ('host', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机')),
+ ],
+ options={
+ 'verbose_name': '证书配置令牌',
+ 'verbose_name_plural': '证书配置令牌',
+ 'db_table': 'cert_provision_token',
+ },
+ ),
+ ]
diff --git a/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py b/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py
new file mode 100644
index 0000000..62d73eb
--- /dev/null
+++ b/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.30 on 2026-05-30 00:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bootstrap', '0011_add_cert_provision_token'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='certprovisiontoken',
+ name='cert_data',
+ field=models.JSONField(blank=True, default=None, null=True, verbose_name='暂存证书数据'),
+ ),
+ migrations.AddField(
+ model_name='certprovisiontoken',
+ name='hostname',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='主机名'),
+ ),
+ ]
diff --git a/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py b/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py
new file mode 100644
index 0000000..de5339d
--- /dev/null
+++ b/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-05-30 02:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bootstrap', '0012_certprovisiontoken_cert_data_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='certprovisiontoken',
+ name='ip_address',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='主机IP地址'),
+ ),
+ ]
diff --git a/apps/bootstrap/models.py b/apps/bootstrap/models.py
index 68d6f1a..aaa9636 100644
--- a/apps/bootstrap/models.py
+++ b/apps/bootstrap/models.py
@@ -18,13 +18,14 @@ class InitialToken(models.Model):
MAX_PAIRING_ATTEMPTS = 5
token = models.CharField(max_length=255, primary_key=True, verbose_name="AccessToken")
- host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机")
+ host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机", null=True, blank=True)
expires_at = models.DateTimeField(verbose_name="AccessToken过期时间")
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ISSUED', verbose_name="状态")
pairing_code = models.CharField(max_length=6, verbose_name="配对码", blank=True, null=True)
pairing_code_expires_at = models.DateTimeField(verbose_name="配对码过期时间", blank=True, null=True)
pairing_attempts = models.IntegerField(default=0, verbose_name="配对码验证尝试次数")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
+ cert_data = models.JSONField(verbose_name="暂存证书数据", blank=True, null=True, default=None)
class Meta:
verbose_name = "初始令牌"
@@ -37,7 +38,7 @@ def generate_pairing_code(self):
self.pairing_code = code
self.pairing_code_expires_at = timezone.now() + timedelta(minutes=5)
self.pairing_attempts = 0
- self.save()
+ self.save(update_fields=['pairing_code', 'pairing_code_expires_at', 'pairing_attempts'])
return code
def verify_pairing_code(self, input_code):
@@ -48,23 +49,26 @@ def verify_pairing_code(self, input_code):
if timezone.now() > self.pairing_code_expires_at:
return False
+ from django.db.models import F
+ InitialToken.objects.filter(pk=self.pk).update(
+ pairing_attempts=F('pairing_attempts') + 1
+ )
+ self.refresh_from_db()
+
if self.pairing_attempts >= self.MAX_PAIRING_ATTEMPTS:
self.pairing_code = None
self.pairing_code_expires_at = None
- self.save()
+ self.save(update_fields=['pairing_code', 'pairing_code_expires_at'])
return False
- self.pairing_attempts += 1
-
if self.pairing_code != input_code:
- self.save(update_fields=['pairing_attempts'])
return False
self.status = 'PAIRED'
self.pairing_code = None
self.pairing_code_expires_at = None
self.pairing_attempts = 0
- self.save()
+ self.save(update_fields=['status', 'pairing_code', 'pairing_code_expires_at', 'pairing_attempts'])
return True
@@ -79,4 +83,37 @@ class ActiveSession(models.Model):
class Meta:
verbose_name = "活动会话"
verbose_name_plural = "活动会话"
- db_table = "active_session"
\ No newline at end of file
+ db_table = "active_session"
+
+
+class CertProvisionToken(models.Model):
+ STATUS_CHOICES = [
+ ('ISSUED', '已签发'),
+ ('HOSTNAME_UPLOADED', '主机名已上传'),
+ ('CERT_ISSUED', '证书已签发'),
+ ('HOST_CONFIGURED', '主机已配置'),
+ ('CONSUMED', '已消耗'),
+ ]
+
+ token = models.CharField(max_length=64, primary_key=True, verbose_name="配置令牌")
+ host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机", null=True, blank=True)
+ server_host = models.CharField(max_length=255, verbose_name="服务器地址")
+ hostname = models.CharField(max_length=255, verbose_name="主机名", blank=True, default='')
+ ip_address = models.CharField(max_length=255, verbose_name="主机IP地址", blank=True, default='')
+ expires_at = models.DateTimeField(verbose_name="过期时间")
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ISSUED', verbose_name="状态")
+ cert_data = models.JSONField(verbose_name="暂存证书数据", blank=True, null=True, default=None)
+ created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="创建者")
+ consumed_at = models.DateTimeField(null=True, blank=True, verbose_name="消耗时间")
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
+
+ class Meta:
+ verbose_name = "证书配置令牌"
+ verbose_name_plural = "证书配置令牌"
+ db_table = "cert_provision_token"
+
+ def is_expired(self):
+ return timezone.now() > self.expires_at
+
+ def is_valid(self):
+ return self.status == 'ISSUED' and not self.is_expired()
\ No newline at end of file
diff --git a/apps/bootstrap/tasks.py b/apps/bootstrap/tasks.py
index 8bf00a9..6ff60ff 100644
--- a/apps/bootstrap/tasks.py
+++ b/apps/bootstrap/tasks.py
@@ -1,20 +1,28 @@
+import base64
+import datetime
+import logging
+from datetime import timedelta
+from typing import cast
+
from celery import shared_task
+from cryptography import x509
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ec
from django.utils import timezone
+
from .models import ActiveSession, InitialToken
-from datetime import timedelta
-import logging
logger = logging.getLogger(__name__)
@shared_task
def cleanup_expired_sessions():
- """清理过期的活动会话"""
try:
- expired_sessions = ActiveSession.objects.filter(expires_at__lt=timezone.now())
+ expired_sessions = ActiveSession.objects.filter(
+ expires_at__lt=timezone.now()
+ )
count = expired_sessions.count()
expired_sessions.delete()
-
logger.info(f"清理了 {count} 个过期的活动会话")
return f"清理了 {count} 个过期的活动会话"
except Exception as e:
@@ -24,16 +32,13 @@ def cleanup_expired_sessions():
@shared_task
def cleanup_expired_initial_tokens():
- """清理过期的初始令牌"""
try:
- # 删除已过期且已消耗的令牌,或者过期超过7天的令牌
cutoff_time = timezone.now() - timedelta(days=7)
expired_tokens = InitialToken.objects.filter(
expires_at__lt=cutoff_time
)
count = expired_tokens.count()
expired_tokens.delete()
-
logger.info(f"清理了 {count} 个过期的初始令牌")
return f"清理了 {count} 个过期的初始令牌"
except Exception as e:
@@ -43,46 +48,217 @@ def cleanup_expired_initial_tokens():
@shared_task
def generate_bootstrap_config(hostname, ip_address, operator_id):
- """生成引导配置 - 模拟函数"""
try:
- # 这里应该是实际的引导配置生成逻辑
- # 模拟返回一些配置信息
config = {
'hostname': hostname,
'ip_address': ip_address,
'generated_at': timezone.now().isoformat(),
- 'status': 'success'
- }
-
- return {
- 'success': True,
- 'config': config
+ 'status': 'success',
}
+ return {'success': True, 'config': config}
except Exception as e:
logger.error(f"生成引导配置时出错: {str(e)}")
- return {
- 'success': False,
- 'error': str(e)
- }
+ return {'success': False, 'error': str(e)}
@shared_task
def initialize_host_bootstrap(host_id, operator_id):
- """初始化主机引导 - 模拟函数"""
try:
- # 这里应该是实际的主机引导初始化逻辑
from apps.hosts.models import Host
host = Host.objects.get(id=host_id)
-
- # 模拟引导过程
- result = {
+ return {
'host_id': host_id,
'hostname': host.hostname,
'status': 'completed',
- 'completed_at': timezone.now().isoformat()
+ 'completed_at': timezone.now().isoformat(),
}
-
- return result
except Exception as e:
logger.error(f"初始化主机引导时出错: {str(e)}")
raise
+
+
+@shared_task(bind=True, max_retries=1)
+def cert_provision_issue_certs(self, token_str):
+ from apps.bootstrap.models import CertProvisionToken
+ from apps.certificates.models import CertificateAuthority
+ from utils.cert_service import (
+ generate_ca, issue_server_cert, issue_client_cert,
+ generate_random_username, generate_random_password,
+ )
+ from utils.cert_storage import generate_cert_paths, save_cert_files
+
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ return
+
+ if provision_token.status != 'HOSTNAME_UPLOADED':
+ return
+
+ host = provision_token.host
+ hostname = host.hostname if host else provision_token.hostname
+ if not hostname:
+ return
+
+ ip_address = provision_token.ip_address or ''
+
+ ca_obj = CertificateAuthority.objects.filter(is_active=True).first()
+ if not ca_obj:
+ ca_key, ca_cert = generate_ca()
+ ca_obj = CertificateAuthority(
+ name='WinRM-CA', is_active=True,
+ )
+ ca_obj.private_key = ca_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode('utf-8')
+ ca_obj.certificate = ca_cert.public_bytes(
+ serialization.Encoding.PEM,
+ ).decode('utf-8')
+ ca_obj.expires_at = (
+ datetime.datetime.now(datetime.timezone.utc)
+ + datetime.timedelta(days=3650)
+ )
+ ca_obj.save()
+
+ if not ca_obj.private_key or not ca_obj.certificate:
+ return
+
+ ca_key = cast(
+ ec.EllipticCurvePrivateKey,
+ serialization.load_pem_private_key(
+ ca_obj.private_key.encode(), password=None,
+ ),
+ )
+ ca_cert = x509.load_pem_x509_certificate(ca_obj.certificate.encode())
+
+ ntlm_user = generate_random_username()
+ ntlm_password = generate_random_password()
+ upn_value = f"{ntlm_user}@localhost"
+
+ server_result = issue_server_cert(
+ ca_key=ca_key,
+ ca_cert=ca_cert,
+ hostname=hostname,
+ ip_address=ip_address or None,
+ )
+
+ client_key, client_cert = issue_client_cert(
+ ca_key=ca_key,
+ ca_cert=ca_cert,
+ upn_value=upn_value,
+ )
+
+ cert_root, cert_sub = generate_cert_paths()
+
+ ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM)
+ client_cert_pem = client_cert.public_bytes(serialization.Encoding.PEM)
+ client_key_pem = client_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ cert_dir = save_cert_files(
+ cert_root=cert_root,
+ cert_sub=cert_sub,
+ ca_cert_pem=ca_cert_pem,
+ client_cert_pem=client_cert_pem,
+ server_pfx_bytes=server_result['pfx_data'],
+ client_key_pem=client_key_pem,
+ )
+
+ if host:
+ host.cert_root = cert_root
+ host.cert_sub = cert_sub
+ host.pfx_password = server_result['pfx_password']
+ host.ntlm_fallback_user = ntlm_user
+ host.ntlm_fallback_password = ntlm_password
+ host.cert_provision_status = 'ready'
+ host.cert_pem_path = str(cert_dir / 'client.crt')
+ host.cert_key_path = str(cert_dir / 'client.key')
+ host.auth_method = 'certificate'
+ host.use_ssl = True
+ if host.port == 5985:
+ host.port = 5986
+ host.save()
+
+ if not host:
+ provision_token.cert_data = {
+ 'cert_root': cert_root,
+ 'cert_sub': cert_sub,
+ 'pfx_password': server_result['pfx_password'],
+ 'ntlm_user': ntlm_user,
+ 'ntlm_password': ntlm_password,
+ 'ca_cert_b64': base64.b64encode(
+ ca_cert_pem
+ ).decode('utf-8'),
+ 'client_cert_b64': base64.b64encode(
+ client_cert_pem
+ ).decode('utf-8'),
+ 'server_pfx_b64': base64.b64encode(
+ server_result['pfx_data']
+ ).decode('utf-8'),
+ }
+
+ provision_token.status = 'CERT_ISSUED'
+ provision_token.save()
+
+ return {'success': True, 'host_id': host.pk if host else None}
+
+
+@shared_task
+def cleanup_expired_provision_tokens():
+ from apps.bootstrap.models import CertProvisionToken
+ now = timezone.now()
+ CertProvisionToken.objects.filter(
+ status='ISSUED', expires_at__lt=now,
+ ).delete()
+ week_ago = now - timedelta(days=7)
+ CertProvisionToken.objects.filter(expires_at__lt=week_ago).delete()
+
+
+@shared_task
+def cleanup_unactivated_certificates():
+ from apps.hosts.models import Host
+ from utils.cert_storage import delete_cert_files
+ now = timezone.now()
+ cutoff = now - timedelta(minutes=60)
+ hosts = Host.objects.filter(
+ cert_provision_status__in=['pending', 'ready'],
+ created_at__lt=cutoff,
+ cert_activated_at__isnull=True,
+ )
+ for host in hosts:
+ if host.cert_root and host.cert_sub:
+ delete_cert_files(host.cert_root, host.cert_sub)
+ host.cert_provision_status = 'failed'
+ host.cert_root = ''
+ host.cert_sub = ''
+ host.save()
+
+
+@shared_task
+def cleanup_orphan_cert_dirs():
+ from apps.hosts.models import Host
+ from utils.cert_storage import get_cert_base_dir
+ import shutil
+ base_dir = get_cert_base_dir()
+ if not base_dir.exists():
+ return
+ active_paths = set()
+ for host in Host.objects.filter(cert_root__gt='', cert_sub__gt=''):
+ active_paths.add((host.cert_root, host.cert_sub))
+ for root_dir in base_dir.iterdir():
+ if root_dir.is_dir() and len(root_dir.name) == 2:
+ for sub_dir in root_dir.iterdir():
+ if sub_dir.is_dir() and len(sub_dir.name) == 2:
+ if (root_dir.name, sub_dir.name) not in active_paths:
+ shutil.rmtree(sub_dir, ignore_errors=True)
+ try:
+ root_dir.rmdir()
+ except OSError:
+ logger.debug(
+ "Skipping removal of non-empty or inaccessible orphan cert root dir: %s",
+ root_dir,
+ )
diff --git a/apps/bootstrap/token_utils.py b/apps/bootstrap/token_utils.py
new file mode 100644
index 0000000..6a967ff
--- /dev/null
+++ b/apps/bootstrap/token_utils.py
@@ -0,0 +1,21 @@
+import base64
+import json
+
+
+def encode_provision_token(raw_token: str, scheme: str, host: str) -> str:
+ payload = json.dumps({"t": raw_token, "s": scheme, "h": host}, separators=(',', ':'))
+ return base64.urlsafe_b64encode(payload.encode('utf-8')).decode('ascii')
+
+
+def decode_provision_token(encoded: str) -> dict | None:
+ try:
+ padding = 4 - len(encoded) % 4
+ if padding != 4:
+ encoded += '=' * padding
+ payload = base64.urlsafe_b64decode(encoded.encode('ascii')).decode('utf-8')
+ data = json.loads(payload)
+ if 't' in data and 's' in data and 'h' in data:
+ return data
+ except Exception:
+ pass
+ return None
diff --git a/apps/bootstrap/urls.py b/apps/bootstrap/urls.py
index 0c1b6a1..b18bd70 100644
--- a/apps/bootstrap/urls.py
+++ b/apps/bootstrap/urls.py
@@ -24,7 +24,8 @@
path('api/verify_pairing_code/', views.verify_pairing_code, name='api_verify_pairing_code'),
path('api/exchange_token/', views.exchange_token, name='api_exchange_token'),
path('api/get_session_token', views.get_session_token, name='api_get_session_token_no_slash'), # 不带斜杠版本
- path('api/get_session_token/', views.get_session_token, name='api_get_session_token'), # 带斜杠版本
+ path('api/get_session_token/', views.get_session_token, name='api_get_session_token'),
+ path('api/upload_host_cert/', views.upload_host_cert, name='api_upload_host_cert'),
path('api/check_pairing_status', views.check_pairing_status, name='api_check_pairing_status'), # 新增:检查配对状态
path('api/session/', views.revoke_session, name='api_revoke_session'),
@@ -33,4 +34,15 @@
path('api/complete-auto-register/', views.complete_auto_register, name='complete_auto_register'),
path('api/pending-hosts/', views.get_pending_hosts, name='get_pending_hosts'),
path('api/revoke-pending-host/', views.revoke_pending_host, name='revoke_pending_host'),
+
+ path('sse/init-status/', views.sse_init_status, name='sse_init_status'),
+
+ path('api/cert-provision/validate/', views.cert_provision_validate, name='cert_provision_validate'),
+ path('api/cert-provision/upload-hostname/', views.cert_provision_upload_hostname, name='cert_provision_upload_hostname'),
+ path('api/cert-provision/download-certs/', views.cert_provision_download_certs, name='cert_provision_download_certs'),
+ path('api/cert-provision/notify-complete/', views.cert_provision_notify_complete, name='cert_provision_notify_complete'),
+ path('api/cert-provision/disable-password-auth/', views.cert_provision_disable_password_auth, name='cert_provision_disable_password_auth'),
+ path('api/cert-provision/test-result/', views.cert_provision_test_result, name='cert_provision_test_result'),
+ path('api/cert-provision/status-stream/', views.cert_provision_status_stream, name='cert_provision_status_stream'),
+ path('api/cert-provision/test-stream/', views.cert_provision_test_stream, name='cert_provision_test_stream'),
]
\ No newline at end of file
diff --git a/apps/bootstrap/views.py b/apps/bootstrap/views.py
index d17a11b..8259db3 100644
--- a/apps/bootstrap/views.py
+++ b/apps/bootstrap/views.py
@@ -1,10 +1,10 @@
-from django.http import JsonResponse
+from django.http import JsonResponse, StreamingHttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required, permission_required
from django.utils.decorators import method_decorator
from django.views import View
-from .models import InitialToken, ActiveSession
+from .models import InitialToken, ActiveSession, CertProvisionToken
from apps.hosts.models import Host
from apps.certificates.models import CertificateAuthority, ServerCertificate
from apps.tasks.models import AsyncTask
@@ -12,6 +12,7 @@
from django.shortcuts import get_object_or_404
import json
import logging
+import base64
from django.utils import timezone
from django.core.cache import cache
import secrets
@@ -49,7 +50,100 @@ def wrapper(request, *args, **kwargs):
return decorator
+def _save_cert_to_host(host, pfx_b64, pfx_password, service_user, service_password):
+ import base64
+ from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption
+
+ try:
+ pfx_data = base64.b64decode(pfx_b64)
+ private_key, certificate, _ = pkcs12.load_key_and_certificates(
+ pfx_data, pfx_password.encode()
+ )
+ except Exception as e:
+ logger.error(f"PFX decode failed for host {host.pk}: {e}")
+ return False
+
+ if not private_key or not certificate:
+ return False
+
+ import os
+ from django.conf import settings
+ cert_dir = os.path.join(settings.MEDIA_ROOT, 'certs', 'hosts', str(host.pk))
+ os.makedirs(cert_dir, exist_ok=True)
+
+ pem_path = os.path.join(cert_dir, 'client.pem')
+ key_path = os.path.join(cert_dir, 'client.key')
+
+ with open(pem_path, 'wb') as f:
+ f.write(certificate.public_bytes(Encoding.PEM))
+ with open(key_path, 'wb') as f:
+ f.write(private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()))
+
+ os.chmod(pem_path, 0o600)
+ os.chmod(key_path, 0o600)
+
+ update_fields = {'cert_pem_path': pem_path, 'cert_key_path': key_path}
+ if service_user and service_password:
+ update_fields['username'] = service_user
+ from utils.crypto import encrypt_value
+ update_fields['_password'] = encrypt_value(service_password)
+
+ from apps.hosts.models import Host
+ Host.objects.filter(pk=host.pk).update(**update_fields)
+
+ try:
+ host.refresh_from_db()
+ host.test_connection()
+ except Exception as e:
+ logger.warning(f"Connection test after cert upload failed: {e}")
+
+ return True
+
+
+@csrf_exempt
@require_http_methods(["POST"])
+def upload_host_cert(request):
+ try:
+ data = json.loads(request.body)
+ token_value = data.get('token', '')
+ pfx_b64 = data.get('pfx_b64', '')
+ pfx_password = data.get('pfx_password', '')
+ service_user = data.get('service_user', '')
+ service_password = data.get('service_password', '')
+
+ if not token_value or not pfx_b64:
+ return JsonResponse({'success': False, 'error': 'Missing required fields'}, status=400)
+
+ try:
+ token_obj = InitialToken.objects.get(token=token_value)
+ except InitialToken.DoesNotExist:
+ return JsonResponse({'success': False, 'error': 'Invalid token'}, status=401)
+
+ if token_obj.host:
+ ok = _save_cert_to_host(
+ token_obj.host, pfx_b64, pfx_password,
+ service_user, service_password,
+ )
+ if not ok:
+ return JsonResponse({'success': False, 'error': 'Invalid PFX data'}, status=400)
+ else:
+ token_obj.cert_data = {
+ 'pfx_b64': pfx_b64,
+ 'pfx_password': pfx_password,
+ 'service_user': service_user,
+ 'service_password': service_password,
+ }
+ token_obj.save(update_fields=['cert_data'])
+ logger.info(f"Cert data stored on token {token_obj.pk[:8]}, waiting for host association")
+
+ logger.info(f"Cert uploaded for token {token_obj.pk[:8]}")
+ return JsonResponse({'success': True})
+
+ except Exception as e:
+ logger.error(f"upload_host_cert error: {e}", exc_info=True)
+ return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500)
+
+
@login_required
@permission_required('hosts.delete_host', raise_exception=True)
def revoke_pending_host(request):
@@ -609,18 +703,16 @@ def get_session_token(request):
# 原子操作:生成新的session_token,创建ActiveSession记录
from django.db import transaction
with transaction.atomic():
- # 生成新的session_token
session_token = str(uuid.uuid4())
-
- # 在ActiveSession表中插入记录
- active_session = ActiveSession.objects.create(
- session_token=session_token,
- host=token_obj.host,
- bound_ip=ip,
- expires_at=timezone.now() + timezone.timedelta(hours=1) # 1小时后过期
- )
-
- # 更新InitialToken状态为CONSUMED
+
+ if token_obj.host:
+ ActiveSession.objects.create(
+ session_token=session_token,
+ host=token_obj.host,
+ bound_ip=ip,
+ expires_at=timezone.now() + timezone.timedelta(hours=1)
+ )
+
token_obj.status = 'CONSUMED'
token_obj.save()
@@ -941,6 +1033,73 @@ def auto_register_host(request):
}, status=500)
+@login_required
+def sse_init_status(request):
+ import json as _json
+
+ token = request.GET.get('token', '')
+ if not token:
+ return JsonResponse(
+ {'error': 'token required'}, status=400,
+ )
+
+ def event_stream():
+ for _ in range(120):
+ try:
+ token_obj = InitialToken.objects.get(token=token)
+ status = token_obj.status
+ data = {
+ 'status': status,
+ 'host_id': (
+ token_obj.host_id
+ if token_obj.host_id else None
+ ),
+ 'cert_uploaded': bool(token_obj.cert_data),
+ }
+ if status == 'CONSUMED' and token_obj.host:
+ host_status = Host.objects.filter(
+ pk=token_obj.host_id
+ ).values_list('status', flat=True).first()
+ data['host_status'] = host_status
+ if host_status == 'online':
+ yield f"data: {_json.dumps(data)}\n\n"
+ return
+ if status == 'CONSUMED' and token_obj.cert_data and not token_obj.host:
+ yield f"data: {_json.dumps(data)}\n\n"
+ return
+ yield f"data: {_json.dumps(data)}\n\n"
+ if status == 'CONSUMED':
+ for _ in range(24):
+ time.sleep(5)
+ token_obj.refresh_from_db()
+ if token_obj.cert_data and not token_obj.host:
+ data['cert_uploaded'] = True
+ yield f"data: {_json.dumps(data)}\n\n"
+ return
+ if token_obj.host:
+ host_status = Host.objects.filter(
+ pk=token_obj.host_id
+ ).values_list('status', flat=True).first()
+ data['host_status'] = host_status
+ if host_status == 'online':
+ yield f"data: {_json.dumps(data)}\n\n"
+ return
+ return
+ except InitialToken.DoesNotExist:
+ yield f"data: {_json.dumps({'status': 'NOT_FOUND'})}\n\n"
+ return
+ time.sleep(5)
+ yield f"data: {_json.dumps({'status': 'TIMEOUT'})}\n\n"
+
+ response = StreamingHttpResponse(
+ event_stream(),
+ content_type='text/event-stream',
+ )
+ response['Cache-Control'] = 'no-cache'
+ response['X-Accel-Buffering'] = 'no'
+ return response
+
+
def get_client_ip(request):
"""获取客户端真实IP地址"""
from django.conf import settings as django_settings
@@ -1048,4 +1207,237 @@ def delete(self, request):
return JsonResponse({
'success': False,
'error': 'Failed to delete initial token'
- }, status=500)
\ No newline at end of file
+ }, status=500)
+
+
+@csrf_exempt
+@require_http_methods(["GET"])
+def cert_provision_validate(request):
+ token_str = request.GET.get('token', '')
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ return JsonResponse({
+ 'valid': provision_token.is_valid(),
+ 'server_host': provision_token.server_host,
+ 'status': provision_token.status,
+ })
+ except CertProvisionToken.DoesNotExist:
+ return JsonResponse({'valid': False, 'server_host': '', 'status': ''})
+
+
+@csrf_exempt
+@require_http_methods(["POST"])
+def cert_provision_upload_hostname(request):
+ try:
+ data = json.loads(request.body)
+ token_str = data.get('token', '')
+ hostname = data.get('hostname', '')
+ except (json.JSONDecodeError, AttributeError):
+ return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400)
+
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ return JsonResponse({'success': False, 'error': 'Token not found'}, status=404)
+
+ if not provision_token.is_valid():
+ return JsonResponse({'success': False, 'error': 'Token expired'}, status=403)
+
+ host = provision_token.host
+ if host:
+ Host.objects.filter(pk=host.pk).update(hostname=hostname)
+ host.refresh_from_db()
+ else:
+ provision_token.hostname = hostname
+
+ provision_token.status = 'HOSTNAME_UPLOADED'
+ provision_token.save()
+
+ from apps.bootstrap.tasks import cert_provision_issue_certs
+ cert_provision_issue_certs.delay(token_str)
+
+ return JsonResponse({'success': True})
+
+
+@csrf_exempt
+@require_http_methods(["GET"])
+def cert_provision_download_certs(request):
+ token_str = request.GET.get('token', '')
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ return JsonResponse({'success': False, 'error': 'Token not found'}, status=404)
+
+ if provision_token.status != 'CERT_ISSUED':
+ return JsonResponse({'success': False, 'error': 'Certificates not ready'}, status=400)
+
+ host = provision_token.host
+ if host and host.cert_root and host.cert_sub:
+ from utils.cert_storage import get_cert_file_paths
+ paths = get_cert_file_paths(host.cert_root, host.cert_sub)
+
+ ca_cert_b64 = base64.b64encode(paths['ca_cert'].read_bytes()).decode('utf-8')
+ client_cert_b64 = base64.b64encode(paths['client_cert'].read_bytes()).decode('utf-8')
+ server_pfx_b64 = base64.b64encode(paths['server_pfx'].read_bytes()).decode('utf-8')
+
+ return JsonResponse({
+ 'success': True,
+ 'ca_cert': ca_cert_b64,
+ 'client_cert': client_cert_b64,
+ 'server_pfx': server_pfx_b64,
+ 'pfx_password': host.pfx_password,
+ 'ntlm_user': host.ntlm_fallback_user,
+ 'ntlm_password': host.ntlm_fallback_password,
+ 'upn_value': f"{host.ntlm_fallback_user}@localhost",
+ })
+ elif provision_token.cert_data:
+ cd = provision_token.cert_data
+ return JsonResponse({
+ 'success': True,
+ 'ca_cert': cd.get('ca_cert_b64', ''),
+ 'client_cert': cd.get('client_cert_b64', ''),
+ 'server_pfx': cd.get('server_pfx_b64', ''),
+ 'pfx_password': cd.get('pfx_password', ''),
+ 'ntlm_user': cd.get('ntlm_user', ''),
+ 'ntlm_password': cd.get('ntlm_password', ''),
+ 'upn_value': f"{cd.get('ntlm_user', '')}@localhost",
+ })
+
+ return JsonResponse({'success': False, 'error': 'Host not configured'}, status=400)
+
+
+@csrf_exempt
+@require_http_methods(["POST"])
+def cert_provision_notify_complete(request):
+ try:
+ data = json.loads(request.body)
+ token_str = data.get('token', '')
+ except (json.JSONDecodeError, AttributeError):
+ return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400)
+
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ return JsonResponse({'success': False, 'error': 'Token not found'}, status=404)
+
+ provision_token.status = 'HOST_CONFIGURED'
+ provision_token.save()
+
+ host = provision_token.host
+ if host:
+ from apps.hosts.tasks import test_winrm_connection
+ use_cert = host.auth_method == 'certificate'
+ test_winrm_connection.delay(host.pk, use_certificate_auth=use_cert)
+ return JsonResponse({'success': True, 'test': 'started'})
+ else:
+ return JsonResponse({'success': True, 'test': 'deferred'})
+
+
+@csrf_exempt
+@require_http_methods(["POST"])
+def cert_provision_disable_password_auth(request):
+ try:
+ data = json.loads(request.body)
+ token_str = data.get('token', '')
+ except (json.JSONDecodeError, AttributeError):
+ return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400)
+
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ return JsonResponse({'success': False, 'error': 'Token not found'}, status=404)
+
+ provision_token.status = 'CONSUMED'
+ provision_token.consumed_at = timezone.now()
+ provision_token.save()
+
+ return JsonResponse({'success': True})
+
+
+@csrf_exempt
+@require_http_methods(["GET"])
+def cert_provision_test_result(request):
+ token_str = request.GET.get('token', '')
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ return JsonResponse({'success': False, 'error': 'Token not found'}, status=404)
+
+ host = provision_token.host
+ if not host:
+ return JsonResponse({'status': 'testing'})
+
+ if host.cert_provision_status == 'configured':
+ return JsonResponse({'status': 'success'})
+ elif host.cert_provision_status == 'failed':
+ return JsonResponse({'status': 'failed', 'error': 'Connection test failed'})
+ else:
+ return JsonResponse({'status': 'testing'})
+
+
+@csrf_exempt
+@require_http_methods(["GET"])
+def cert_provision_status_stream(request):
+ token_str = request.GET.get('token', '')
+
+ def event_stream():
+ for _ in range(120):
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ yield f"data: {json.dumps({'status': 'failed', 'error': 'Token not found'})}\n\n"
+ return
+
+ if provision_token.status == 'CERT_ISSUED':
+ host = provision_token.host
+ yield f"data: {json.dumps({'status': 'ready'})}\n\n"
+ return
+ elif provision_token.status == 'HOST_CONFIGURED':
+ yield f"data: {json.dumps({'status': 'configured'})}\n\n"
+ return
+ elif provision_token.is_expired():
+ yield f"data: {json.dumps({'status': 'failed', 'error': 'Token expired'})}\n\n"
+ return
+
+ yield f"data: {json.dumps({'status': 'pending'})}\n\n"
+ time.sleep(5)
+
+ yield f"data: {json.dumps({'status': 'failed', 'error': 'Timeout'})}\n\n"
+
+ response = StreamingHttpResponse(event_stream(), content_type='text/event-stream')
+ response['Cache-Control'] = 'no-cache'
+ response['X-Accel-Buffering'] = 'no'
+ return response
+
+
+@csrf_exempt
+@require_http_methods(["GET"])
+def cert_provision_test_stream(request):
+ token_str = request.GET.get('token', '')
+
+ def event_stream():
+ for _ in range(60):
+ try:
+ provision_token = CertProvisionToken.objects.get(token=token_str)
+ except CertProvisionToken.DoesNotExist:
+ yield f"data: {json.dumps({'status': 'failed', 'error': 'Token not found'})}\n\n"
+ return
+
+ host = provision_token.host
+ if host:
+ if host.cert_provision_status == 'configured':
+ yield f"data: {json.dumps({'status': 'success'})}\n\n"
+ return
+ elif host.cert_provision_status == 'failed':
+ yield f"data: {json.dumps({'status': 'failed', 'error': 'Connection test failed'})}\n\n"
+ return
+
+ yield f"data: {json.dumps({'status': 'testing'})}\n\n"
+ time.sleep(5)
+
+ yield f"data: {json.dumps({'status': 'failed', 'error': 'Timeout'})}\n\n"
+
+ response = StreamingHttpResponse(event_stream(), content_type='text/event-stream')
+ response['Cache-Control'] = 'no-cache'
+ response['X-Accel-Buffering'] = 'no'
+ return response
\ No newline at end of file
diff --git a/apps/certificates/apps.py b/apps/certificates/apps.py
index 75bc569..4c9ea59 100755
--- a/apps/certificates/apps.py
+++ b/apps/certificates/apps.py
@@ -1,11 +1,51 @@
+import logging
+
from django.apps import AppConfig
+logger = logging.getLogger(__name__)
+
+
class CertificatesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.certificates'
verbose_name = '证书管理系统'
def ready(self):
- # 导入信号处理器
- import apps.certificates.signals
\ No newline at end of file
+ import apps.certificates.signals
+ self._ensure_ca_exists()
+
+ def _ensure_ca_exists(self):
+ import os
+ if os.environ.get('RUN_MAIN') == 'true':
+ return
+ if os.environ.get('DJANGO_AUTORELOAD') == 'true':
+ return
+ try:
+ CertificateAuthority = self.get_model('CertificateAuthority')
+ if not CertificateAuthority.objects.filter(
+ is_active=True
+ ).exists():
+ from utils.cert_service import generate_ca
+ from cryptography.hazmat.primitives import serialization
+ ca_key, ca_cert = generate_ca()
+ ca = CertificateAuthority(name='WinRM-CA', is_active=True)
+ ca.private_key = ca_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption()
+ ).decode('utf-8')
+ ca.certificate = (
+ ca_cert.public_bytes(serialization.Encoding.PEM)
+ .decode('utf-8')
+ )
+ import datetime
+ ca.expires_at = (
+ datetime.datetime.now(datetime.timezone.utc)
+ + datetime.timedelta(days=3650)
+ )
+ ca.save()
+ except Exception:
+ logger.exception(
+ 'Failed to ensure default certificate authority exists during app startup.'
+ )
\ No newline at end of file
diff --git a/apps/certificates/migrations/0004_migrate_to_ecc_p256.py b/apps/certificates/migrations/0004_migrate_to_ecc_p256.py
new file mode 100644
index 0000000..4b05436
--- /dev/null
+++ b/apps/certificates/migrations/0004_migrate_to_ecc_p256.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.2.30 on 2026-05-29 16:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('certificates', '0003_remove_certificateauthority_private_key_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='clientcertificate',
+ name='upn_value',
+ field=models.CharField(blank=True, default='', max_length=255, verbose_name='UPN值'),
+ ),
+ migrations.AddField(
+ model_name='servercertificate',
+ name='_pfx_password',
+ field=models.CharField(blank=True, db_column='pfx_password', default='', max_length=255, verbose_name='PFX密码(加密)'),
+ ),
+ migrations.AddField(
+ model_name='servercertificate',
+ name='ip_address',
+ field=models.GenericIPAddressField(blank=True, null=True, verbose_name='IP地址'),
+ ),
+ ]
diff --git a/apps/certificates/migrations/0005_remove_cert_content_from_db.py b/apps/certificates/migrations/0005_remove_cert_content_from_db.py
new file mode 100644
index 0000000..63cd317
--- /dev/null
+++ b/apps/certificates/migrations/0005_remove_cert_content_from_db.py
@@ -0,0 +1,37 @@
+# Generated by Django 4.2.30 on 2026-05-30 07:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('certificates', '0004_migrate_to_ecc_p256'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='clientcertificate',
+ name='_private_key',
+ ),
+ migrations.RemoveField(
+ model_name='clientcertificate',
+ name='certificate',
+ ),
+ migrations.RemoveField(
+ model_name='servercertificate',
+ name='_pfx_password',
+ ),
+ migrations.RemoveField(
+ model_name='servercertificate',
+ name='_private_key',
+ ),
+ migrations.RemoveField(
+ model_name='servercertificate',
+ name='certificate',
+ ),
+ migrations.RemoveField(
+ model_name='servercertificate',
+ name='pfx_data',
+ ),
+ ]
diff --git a/apps/certificates/models.py b/apps/certificates/models.py
index 73b1781..69ae6ea 100755
--- a/apps/certificates/models.py
+++ b/apps/certificates/models.py
@@ -1,25 +1,26 @@
from django.db import models
from django.conf import settings
-from django.core.signing import Signer
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
-from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import NameOID
from cryptography.fernet import Fernet, InvalidToken
from django.core.exceptions import ValidationError
import datetime
import base64
import hashlib
+import ipaddress
+import secrets
+import string
def _get_fernet():
- """用SECRET_KEY派生Fernet密钥"""
key = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
return Fernet(base64.urlsafe_b64encode(key))
class CertificateAuthority(models.Model):
- """证书颁发机构"""
name = models.CharField(max_length=255, unique=True, verbose_name="CA名称")
_private_key = models.TextField(db_column='private_key', verbose_name="私钥(加密)")
certificate = models.TextField(verbose_name="CA证书(PEM)")
@@ -35,7 +36,6 @@ class Meta:
@property
def private_key(self):
- """解密私钥"""
if not self._private_key:
return None
try:
@@ -45,25 +45,17 @@ def private_key(self):
@private_key.setter
def private_key(self, value):
- """加密存储私钥"""
if value:
self._private_key = _get_fernet().encrypt(value.encode()).decode()
else:
self._private_key = ''
def generate_self_signed_cert(self):
- """生成自签名CA证书"""
- private_key = rsa.generate_private_key(
- public_exponent=65537,
- key_size=4096 # 使用更强的密钥长度
+ private_key = ec.generate_private_key(
+ ec.SECP256R1(), default_backend()
)
subject = issuer = x509.Name([
- x509.NameAttribute(NameOID.COUNTRY_NAME, "CN"),
- x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "Beijing"),
- x509.NameAttribute(NameOID.LOCALITY_NAME, "Beijing"),
- x509.NameAttribute(NameOID.ORGANIZATION_NAME, "2c2a Corp"),
- x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Security Department"),
x509.NameAttribute(NameOID.COMMON_NAME, self.name),
])
@@ -78,7 +70,7 @@ def generate_self_signed_cert(self):
).not_valid_before(
datetime.datetime.utcnow()
).not_valid_after(
- datetime.datetime.utcnow() + datetime.timedelta(days=3650) # 10年有效期
+ datetime.datetime.utcnow() + datetime.timedelta(days=3650)
).add_extension(
x509.BasicConstraints(ca=True, path_length=None),
critical=True,
@@ -95,7 +87,17 @@ def generate_self_signed_cert(self):
decipher_only=False,
),
critical=True,
- ).sign(private_key, hashes.SHA256())
+ ).add_extension(
+ x509.SubjectKeyIdentifier.from_public_key(
+ private_key.public_key()
+ ),
+ critical=False,
+ ).add_extension(
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(
+ private_key.public_key()
+ ),
+ critical=False,
+ ).sign(private_key, hashes.SHA256(), default_backend())
self.private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
@@ -103,20 +105,25 @@ def generate_self_signed_cert(self):
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
- self.certificate = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8')
- self.expires_at = datetime.datetime.utcnow() + datetime.timedelta(days=3650)
+ self.certificate = cert.public_bytes(
+ serialization.Encoding.PEM
+ ).decode('utf-8')
+ self.expires_at = (
+ datetime.datetime.utcnow() + datetime.timedelta(days=3650)
+ )
def __str__(self):
return f"CA: {self.name}"
class ServerCertificate(models.Model):
- """服务器证书"""
- hostname = models.CharField(max_length=255, unique=True, verbose_name="主机名")
+ hostname = models.CharField(
+ max_length=255, unique=True, verbose_name="主机名"
+ )
+ ip_address = models.GenericIPAddressField(
+ null=True, blank=True, verbose_name='IP地址'
+ )
ca = models.ForeignKey(CertificateAuthority, on_delete=models.CASCADE)
- _private_key = models.TextField(db_column='private_key', verbose_name="私钥(加密)")
- certificate = models.TextField(verbose_name="证书(PEM)")
- pfx_data = models.TextField(verbose_name="PFX(Base64)")
thumbprint = models.CharField(max_length=255, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
@@ -129,115 +136,7 @@ class Meta:
verbose_name_plural = "服务器证书"
db_table = "server_certificate"
- @property
- def private_key(self):
- if not self._private_key:
- return None
- try:
- return _get_fernet().decrypt(self._private_key.encode()).decode()
- except InvalidToken:
- raise ValueError("私钥解密失败,数据可能已损坏或密钥已变更")
-
- @private_key.setter
- def private_key(self, value):
- if value:
- self._private_key = _get_fernet().encrypt(value.encode()).decode()
- else:
- self._private_key = ''
-
- def generate_server_cert(self, hostname, san_names=None):
- """为指定主机生成服务器证书"""
- if not san_names:
- san_names = []
-
- # 使用CA私钥签署服务器证书
- ca_cert = x509.load_pem_x509_certificate(self.ca.certificate.encode())
- ca_private_key = serialization.load_pem_private_key(
- self.ca.private_key.encode(), password=None
- )
-
- server_private_key = rsa.generate_private_key(
- public_exponent=65537,
- key_size=2048
- )
-
- subject = x509.Name([
- x509.NameAttribute(NameOID.COMMON_NAME, hostname),
- ])
-
- # 构建证书主体
- builder = x509.CertificateBuilder().subject_name(
- subject
- ).issuer_name(
- ca_cert.issuer
- ).public_key(
- server_private_key.public_key()
- ).serial_number(
- x509.random_serial_number()
- ).not_valid_before(
- datetime.datetime.utcnow()
- ).not_valid_after(
- datetime.datetime.utcnow() + datetime.timedelta(days=365) # 1年有效期
- ).add_extension(
- x509.KeyUsage(
- key_encipherment=True,
- digital_signature=True,
- key_agreement=False,
- key_cert_sign=False,
- crl_sign=False,
- content_commitment=False,
- data_encipherment=False,
- encipher_only=False,
- decipher_only=False,
- ),
- critical=True,
- ).add_extension(
- x509.ExtendedKeyUsage([
- x509.oid.ExtendedKeyUsageOID.SERVER_AUTH,
- ]),
- critical=True,
- )
-
- # 添加SAN扩展
- if san_names:
- san_list = [x509.DNSName(name) for name in san_names]
- san_list.append(x509.DNSName(hostname))
- builder = builder.add_extension(
- x509.SubjectAlternativeName(san_list),
- critical=False,
- )
-
- cert = builder.sign(ca_private_key, hashes.SHA256())
-
- self.private_key = server_private_key.private_bytes(
- encoding=serialization.Encoding.PEM,
- format=serialization.PrivateFormat.PKCS8,
- encryption_algorithm=serialization.NoEncryption()
- ).decode('utf-8')
-
- self.certificate = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8')
-
- # 生成PFX格式证书(包含私钥)
- from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates
- pfx_password = settings.SECRET_KEY[:32].encode()
- pfx = serialize_key_and_certificates(
- name=hostname.encode(),
- key=server_private_key,
- cert=cert,
- cas=[ca_cert],
- encryption_algorithm=serialization.BestAvailableEncryption(pfx_password)
- )
-
- self.pfx_data = base64.b64encode(pfx).decode('utf-8')
-
- # 计算证书指纹(SHA1)
- fingerprint = cert.fingerprint(hashes.SHA1())
- self.thumbprint = ":".join(f"{byte:02X}" for byte in fingerprint)
-
- self.expires_at = datetime.datetime.utcnow() + datetime.timedelta(days=365)
-
def revoke(self, reason=""):
- """吊销证书"""
self.is_revoked = True
self.revocation_reason = reason
self.revocation_date = datetime.datetime.utcnow()
@@ -248,16 +147,18 @@ def __str__(self):
class ClientCertificate(models.Model):
- """客户端证书"""
name = models.CharField(max_length=255)
+ upn_value = models.CharField(
+ max_length=255, blank=True, default='',
+ verbose_name='UPN值'
+ )
ca = models.ForeignKey(CertificateAuthority, on_delete=models.CASCADE)
- _private_key = models.TextField(db_column='private_key', verbose_name="私钥(加密)")
- certificate = models.TextField(verbose_name="证书(PEM)")
thumbprint = models.CharField(max_length=255, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
assigned_to_user = models.ForeignKey(
- 'accounts.User', on_delete=models.SET_NULL, null=True, blank=True
+ 'accounts.User', on_delete=models.SET_NULL,
+ null=True, blank=True
)
is_active = models.BooleanField(default=True)
description = models.TextField(blank=True, null=True)
@@ -267,90 +168,9 @@ class Meta:
verbose_name_plural = "客户端证书"
db_table = "client_certificate"
- @property
- def private_key(self):
- if not self._private_key:
- return None
- try:
- return _get_fernet().decrypt(self._private_key.encode()).decode()
- except InvalidToken:
- raise ValueError("私钥解密失败,数据可能已损坏或密钥已变更")
-
- @private_key.setter
- def private_key(self, value):
- if value:
- self._private_key = _get_fernet().encrypt(value.encode()).decode()
- else:
- self._private_key = ''
-
- def generate_client_cert(self, name, user=None, description=""):
- """生成客户端证书"""
- # 使用CA私钥签署客户端证书
- ca_cert = x509.load_pem_x509_certificate(self.ca.certificate.encode())
- ca_private_key = serialization.load_pem_private_key(
- self.ca.private_key.encode(), password=None
- )
-
- client_private_key = rsa.generate_private_key(
- public_exponent=65537,
- key_size=2048
- )
-
- subject = x509.Name([
- x509.NameAttribute(NameOID.COMMON_NAME, name),
- x509.NameAttribute(NameOID.ORGANIZATION_NAME, "2c2a Corp"),
- x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Control Center"),
- ])
-
- cert = x509.CertificateBuilder().subject_name(
- subject
- ).issuer_name(
- ca_cert.issuer
- ).public_key(
- client_private_key.public_key()
- ).serial_number(
- x509.random_serial_number()
- ).not_valid_before(
- datetime.datetime.utcnow()
- ).not_valid_after(
- datetime.datetime.utcnow() + datetime.timedelta(days=365) # 1年有效期
- ).add_extension(
- x509.KeyUsage(
- digital_signature=True,
- key_encipherment=True,
- key_agreement=False,
- key_cert_sign=False,
- crl_sign=False,
- content_commitment=False,
- data_encipherment=False,
- encipher_only=False,
- decipher_only=False,
- ),
- critical=True,
- ).add_extension(
- x509.ExtendedKeyUsage([
- x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH,
- ]),
- critical=True,
- ).sign(ca_private_key, hashes.SHA256())
-
- self.name = name
- self.private_key = client_private_key.private_bytes(
- encoding=serialization.Encoding.PEM,
- format=serialization.PrivateFormat.PKCS8,
- encryption_algorithm=serialization.NoEncryption()
- ).decode('utf-8')
-
- self.certificate = cert.public_bytes(serialization.Encoding.PEM).decode('utf-8')
-
- # 计算证书指纹(SHA1)
- fingerprint = cert.fingerprint(hashes.SHA1())
- self.thumbprint = ":".join(f"{byte:02X}" for byte in fingerprint)
-
- self.expires_at = datetime.datetime.utcnow() + datetime.timedelta(days=365)
- self.assigned_to_user = user
- self.description = description
-
def __str__(self):
- user_info = f" (User: {self.assigned_to_user.username})" if self.assigned_to_user else ""
- return f"Client Cert: {self.name}{user_info}"
\ No newline at end of file
+ user_info = (
+ f" (User: {self.assigned_to_user.username})"
+ if self.assigned_to_user else ""
+ )
+ return f"Client Cert: {self.name}{user_info}"
diff --git a/apps/certificates/views.py b/apps/certificates/views.py
index a114799..9615bc5 100755
--- a/apps/certificates/views.py
+++ b/apps/certificates/views.py
@@ -10,7 +10,6 @@
import json
import logging
from datetime import datetime
-from django.core.exceptions import ValidationError
logger = logging.getLogger(__name__)
@@ -52,22 +51,56 @@ def issue_server_certificate(request):
)
if created or cert.is_revoked:
- cert.generate_server_cert(hostname, san_names)
+ from utils.cert_service import (
+ issue_server_cert, generate_ca,
+ )
+ from cryptography.hazmat.primitives import serialization
+ from cryptography import x509 as x509_mod
+ from typing import cast
+ from cryptography.hazmat.primitives.asymmetric import ec
+
+ ca_key = cast(
+ ec.EllipticCurvePrivateKey,
+ serialization.load_pem_private_key(
+ ca.private_key.encode(), password=None,
+ ),
+ )
+ ca_cert = x509_mod.load_pem_x509_certificate(
+ ca.certificate.encode()
+ )
+ result = issue_server_cert(
+ ca_key=ca_key,
+ ca_cert=ca_cert,
+ hostname=hostname,
+ ip_address=None,
+ )
+ from cryptography.hazmat.primitives import hashes
+ fingerprint = result['server_cert'].fingerprint(
+ hashes.SHA1()
+ )
+ cert.thumbprint = ":".join(
+ f"{byte:02X}" for byte in fingerprint
+ )
cert.is_revoked = False
+ cert.expires_at = (
+ datetime.utcnow() + __import__('datetime').timedelta(
+ days=3650
+ )
+ )
cert.save()
logger.info(f"Issued new server certificate for {hostname}")
- elif cert.expires_at < datetime.utcnow():
- cert.generate_server_cert(hostname, san_names)
+ elif cert.expires_at and cert.expires_at < datetime.utcnow():
+ cert.is_revoked = True
cert.save()
- logger.info(f"Renewed expired server certificate for {hostname}")
+ logger.info(f"Marked expired server cert for {hostname}")
return JsonResponse({
'success': True,
'data': {
- 'ca_cert': ca.certificate,
- 'server_cert': cert.certificate,
'thumbprint': cert.thumbprint,
- 'expires_at': cert.expires_at.isoformat()
+ 'expires_at': (
+ cert.expires_at.isoformat() if cert.expires_at else None
+ )
}
})
@@ -133,17 +166,48 @@ def issue_client_certificate(request):
}
)
- if created or not cert.certificate or cert.expires_at < datetime.utcnow():
- cert.generate_client_cert(name, user, description)
+ if created:
+ from utils.cert_service import issue_client_cert
+ from cryptography.hazmat.primitives import serialization, hashes
+ from cryptography import x509 as x509_mod
+ from typing import cast
+ from cryptography.hazmat.primitives.asymmetric import ec
+
+ ca_key = cast(
+ ec.EllipticCurvePrivateKey,
+ serialization.load_pem_private_key(
+ ca.private_key.encode(), password=None,
+ ),
+ )
+ ca_cert = x509_mod.load_pem_x509_certificate(
+ ca.certificate.encode()
+ )
+ upn_value = f"{name}@localhost"
+ cert.upn_value = upn_value
+ client_key, client_cert = issue_client_cert(
+ ca_key=ca_key,
+ ca_cert=ca_cert,
+ upn_value=upn_value,
+ )
+ fingerprint = client_cert.fingerprint(hashes.SHA1())
+ cert.thumbprint = ":".join(
+ f"{byte:02X}" for byte in fingerprint
+ )
+ cert.expires_at = (
+ datetime.utcnow() + __import__('datetime').timedelta(
+ days=3650
+ )
+ )
cert.save()
logger.info(f"Issued new client certificate for {name}")
return JsonResponse({
'success': True,
'data': {
- 'certificate': cert.certificate,
'thumbprint': cert.thumbprint,
- 'expires_at': cert.expires_at.isoformat()
+ 'expires_at': (
+ cert.expires_at.isoformat() if cert.expires_at else None
+ )
}
})
@@ -153,7 +217,9 @@ def issue_client_certificate(request):
'error': 'Invalid JSON in request body'
}, status=400)
except Exception as e:
- logger.error(f"Error issuing client certificate: {str(e)}", exc_info=True)
+ logger.error(
+ f"Error issuing client certificate: {str(e)}", exc_info=True
+ )
return JsonResponse({
'success': False,
'error': 'Failed to issue client certificate'
@@ -176,8 +242,6 @@ def validate_certificate_request(request):
host = Host.objects.filter(
hostname=hostname,
- init_token=token,
- init_token_expires_at__gt=datetime.now()
).first()
if not host:
@@ -201,7 +265,9 @@ def validate_certificate_request(request):
'error': 'Invalid JSON in request body'
}, status=400)
except Exception as e:
- logger.error(f"Error validating certificate request: {str(e)}", exc_info=True)
+ logger.error(
+ f"Error validating certificate request: {str(e)}", exc_info=True
+ )
return JsonResponse({
'success': False,
'error': 'Certificate validation failed'
@@ -215,7 +281,9 @@ def get_ca_certificate(request):
ca_name = request.GET.get('ca_name', 'default-ca')
try:
- ca = CertificateAuthority.objects.get(name=ca_name, is_active=True)
+ ca = CertificateAuthority.objects.get(
+ name=ca_name, is_active=True
+ )
return JsonResponse({
'success': True,
'data': {
@@ -230,7 +298,9 @@ def get_ca_certificate(request):
}, status=404)
except Exception as e:
- logger.error(f"Error getting CA certificate: {str(e)}", exc_info=True)
+ logger.error(
+ f"Error getting CA certificate: {str(e)}", exc_info=True
+ )
return JsonResponse({
'success': False,
'error': 'Failed to retrieve CA certificate'
@@ -239,7 +309,9 @@ def get_ca_certificate(request):
class CertificateManagementView(View):
- @method_decorator(permission_required('certificates.view_certificateauthority'))
+ @method_decorator(
+ permission_required('certificates.view_certificateauthority')
+ )
def get(self, request):
try:
cert_type = request.GET.get('type', 'all')
@@ -298,13 +370,17 @@ def get(self, request):
return JsonResponse(result)
except Exception as e:
- logger.error(f"Error getting certificates: {str(e)}", exc_info=True)
+ logger.error(
+ f"Error getting certificates: {str(e)}", exc_info=True
+ )
return JsonResponse({
'success': False,
'error': 'Failed to retrieve certificates'
}, status=500)
- @method_decorator(permission_required('certificates.delete_servercertificate'))
+ @method_decorator(
+ permission_required('certificates.delete_servercertificate')
+ )
def delete(self, request):
try:
cert_id = request.GET.get('id')
@@ -337,7 +413,9 @@ def delete(self, request):
})
except Exception as e:
- logger.error(f"Error revoking certificate: {str(e)}", exc_info=True)
+ logger.error(
+ f"Error revoking certificate: {str(e)}", exc_info=True
+ )
return JsonResponse({
'success': False,
'error': 'Failed to revoke certificate'
@@ -346,7 +424,9 @@ def delete(self, request):
@require_http_methods(["POST"])
@login_required
-@permission_required('certificates.change_servercertificate', raise_exception=True)
+@permission_required(
+ 'certificates.change_servercertificate', raise_exception=True
+)
def renew_certificate(request):
try:
data = json.loads(request.body.decode('utf-8'))
@@ -361,13 +441,11 @@ def renew_certificate(request):
if cert_type == 'server':
cert = get_object_or_404(ServerCertificate, id=cert_id)
- cert.generate_server_cert(cert.hostname)
+ cert.is_revoked = False
cert.save()
elif cert_type == 'client':
cert = get_object_or_404(ClientCertificate, id=cert_id)
- cert.generate_client_cert(
- cert.name, cert.assigned_to_user, cert.description
- )
+ cert.is_active = True
cert.save()
else:
return JsonResponse({
@@ -379,7 +457,9 @@ def renew_certificate(request):
'success': True,
'message': f'{cert_type.title()} certificate renewed successfully',
'data': {
- 'expires_at': cert.expires_at.isoformat()
+ 'expires_at': (
+ cert.expires_at.isoformat() if cert.expires_at else None
+ )
}
})
@@ -389,7 +469,9 @@ def renew_certificate(request):
'error': 'Invalid JSON in request body'
}, status=400)
except Exception as e:
- logger.error(f"Error renewing certificate: {str(e)}", exc_info=True)
+ logger.error(
+ f"Error renewing certificate: {str(e)}", exc_info=True
+ )
return JsonResponse({
'success': False,
'error': 'Failed to renew certificate'
diff --git a/apps/dashboard/forms.py b/apps/dashboard/forms.py
index f1fc621..b0a3333 100755
--- a/apps/dashboard/forms.py
+++ b/apps/dashboard/forms.py
@@ -108,7 +108,6 @@ class SystemConfigForm(forms.ModelForm):
_PRESERVE_IF_EMPTY = [
'smtp_password',
- 'captcha_key',
]
class Meta:
@@ -123,9 +122,11 @@ class Meta:
'smtp_username',
'smtp_password',
'smtp_from_email',
- 'captcha_id',
- 'captcha_key',
'captcha_provider',
+ 'captcha_type',
+ 'login_captcha_type',
+ 'register_captcha_type',
+ 'email_captcha_type',
'email_suffix_whitelist',
'email_suffix_blacklist',
]
@@ -169,21 +170,6 @@ class Meta:
'class': MD_INPUT_CLASS,
'placeholder': '请输入发件人邮箱'
}),
- 'captcha_id': forms.TextInput(attrs={
- 'class': MD_INPUT_CLASS,
- 'placeholder': (
- '请输入验证码 ID '
- '(Geetest的captcha_id 或 Turnstile的site key)'
- )
- }),
- 'captcha_key': forms.TextInput(attrs={
- 'class': MD_INPUT_CLASS,
- 'placeholder': (
- '请输入验证码密钥 '
- '(Geetest的private_key 或 Turnstile的secret key)'
- ),
- 'type': 'password'
- }),
'captcha_provider': forms.Select(attrs={
'class': MD_SELECT_CLASS
}),
@@ -227,19 +213,6 @@ def clean(self):
cleaned = super().clean()
if cleaned is None:
cleaned = {}
- provider = cleaned.get('captcha_provider')
- errors = {}
-
- if provider in ['geetest', 'turnstile']:
- if not (cleaned.get('captcha_id') and cleaned.get('captcha_key')):
- provider_display = self.instance.get_captcha_provider_display()
- msg = f'启用 {provider_display} 时必须填写验证码 ID 和密钥。'
- errors['captcha_id'] = msg
- errors['captcha_key'] = msg
-
- if errors:
- raise forms.ValidationError(errors)
-
return cleaned
def save(self, commit=True):
diff --git a/apps/dashboard/forms_admin.py b/apps/dashboard/forms_admin.py
index dbd1653..bf2ecec 100644
--- a/apps/dashboard/forms_admin.py
+++ b/apps/dashboard/forms_admin.py
@@ -1,13 +1,8 @@
-"""
-仪表盘超级管理员表单
-"""
-
from django import forms
from .models import DashboardWidget, SystemConfig
class DashboardWidgetForm(forms.ModelForm):
- """仪表盘组件表单"""
class Meta:
model = DashboardWidget
@@ -17,7 +12,6 @@ class Meta:
]
def clean_widget_config(self):
- """验证 widget_config 为有效 JSON"""
import json
config = self.cleaned_data.get('widget_config')
if config:
@@ -30,14 +24,9 @@ def clean_widget_config(self):
class SystemConfigForm(forms.ModelForm):
- """系统配置表单(单例)"""
_PRESERVE_IF_EMPTY = [
'smtp_password',
- 'captcha_key',
- 'email_captcha_key',
- 'login_captcha_key',
- 'register_captcha_key',
]
class Meta:
@@ -54,17 +43,10 @@ class Meta:
'smtp_password',
'smtp_from_email',
'captcha_provider',
- 'captcha_id',
- 'captcha_key',
- 'email_captcha_provider',
- 'email_captcha_id',
- 'email_captcha_key',
- 'login_captcha_provider',
- 'login_captcha_id',
- 'login_captcha_key',
- 'register_captcha_provider',
- 'register_captcha_id',
- 'register_captcha_key',
+ 'captcha_type',
+ 'login_captcha_type',
+ 'register_captcha_type',
+ 'email_captcha_type',
'email_suffix_whitelist',
'email_suffix_blacklist',
'local_access_locked',
@@ -85,35 +67,6 @@ def clean_smtp_port(self):
raise forms.ValidationError('端口号必须在 1-65535 之间')
return port
- def clean(self):
- cleaned = super().clean()
- if cleaned is None:
- cleaned = {}
- provider = cleaned.get('captcha_provider')
- errors = {}
-
- if provider in ['geetest', 'turnstile']:
- captcha_id = cleaned.get('captcha_id')
- captcha_key = cleaned.get('captcha_key')
- if self.instance and self.instance.pk:
- if not captcha_id:
- captcha_id = self.instance.captcha_id
- if not captcha_key:
- captcha_key = self.instance.captcha_key
- if not (captcha_id and captcha_key):
- msg = (
- f'启用 '
- f'{self.instance.get_captcha_provider_display()} '
- f'时必须填写验证码 ID 和密钥。'
- )
- errors['captcha_id'] = msg
- errors['captcha_key'] = msg
-
- if errors:
- raise forms.ValidationError(errors)
-
- return cleaned
-
def save(self, commit=True):
instance = super().save(commit=False)
if self.instance and self.instance.pk:
diff --git a/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py b/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py
new file mode 100644
index 0000000..b014c58
--- /dev/null
+++ b/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py
@@ -0,0 +1,62 @@
+# Generated by Django 4.2.30 on 2026-05-29 15:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dashboard', '0010_migrate_qq_bot_to_plugin_config'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='captcha_id',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='captcha_key',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='email_captcha_id',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='email_captcha_key',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='email_captcha_provider',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='login_captcha_id',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='login_captcha_key',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='login_captcha_provider',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='register_captcha_id',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='register_captcha_key',
+ ),
+ migrations.RemoveField(
+ model_name='systemconfig',
+ name='register_captcha_provider',
+ ),
+ migrations.AlterField(
+ model_name='systemconfig',
+ name='captcha_provider',
+ field=models.CharField(choices=[('none', '无'), ('tianai', '天爱验证码')], default='none', help_text='选择要启用的验证码提供器', max_length=32, verbose_name='验证码提供器'),
+ ),
+ ]
diff --git a/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py b/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py
new file mode 100644
index 0000000..3f31402
--- /dev/null
+++ b/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.2.30 on 2026-05-29 15:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dashboard', '0011_remove_systemconfig_captcha_id_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='systemconfig',
+ name='captcha_type',
+ field=models.CharField(choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], default='SLIDER', help_text='全局默认的验证码类型', max_length=32, verbose_name='默认验证码类型'),
+ ),
+ migrations.AddField(
+ model_name='systemconfig',
+ name='email_captcha_type',
+ field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='邮箱发送验证码场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='邮箱验证码类型'),
+ ),
+ migrations.AddField(
+ model_name='systemconfig',
+ name='login_captcha_type',
+ field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='登录场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='登录验证码类型'),
+ ),
+ migrations.AddField(
+ model_name='systemconfig',
+ name='register_captcha_type',
+ field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='注册场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='注册验证码类型'),
+ ),
+ ]
diff --git a/apps/dashboard/models.py b/apps/dashboard/models.py
index 8f52f89..1b55670 100755
--- a/apps/dashboard/models.py
+++ b/apps/dashboard/models.py
@@ -118,108 +118,53 @@ class SystemConfig(models.Model):
help_text='系统发送邮件时使用的发件人地址'
)
- # 统一的验证码配置 - 适用于Geetest和Turnstile
- captcha_id = models.CharField(
- max_length=255,
- blank=True,
- null=True,
- verbose_name='验证码 ID',
- help_text='验证码服务的公共ID(Geetest的captcha_id 或 Turnstile的site key)'
- )
- captcha_key = models.CharField(
- max_length=255,
- blank=True,
- null=True,
- verbose_name='验证码密钥',
- help_text='验证码服务的密钥(Geetest的private_key 或 Turnstile的secret key)'
+ CAPTCHA_TYPES = (
+ ('SLIDER', '滑块验证'),
+ ('ROTATE', '旋转验证'),
+ ('CONCAT', '滑动还原'),
+ ('WORD_IMAGE_CLICK', '文字点选'),
)
- # 选择验证码提供器:geetest / turnstile / local
- CAPTCHA_PROVIDERS = (
- ('none', '无'),
- ('geetest', 'Geetest (极验 v4)'),
- ('turnstile', 'Cloudflare Turnstile'),
- ('local', '本地图片验证码'),
- )
captcha_provider = models.CharField(
max_length=32,
- choices=CAPTCHA_PROVIDERS,
+ choices=(
+ ('none', '无'),
+ ('tianai', '天爱验证码'),
+ ),
default='none',
verbose_name='验证码提供器',
- help_text='选择要启用的验证码提供器(只能选择其一)'
+ help_text='选择要启用的验证码提供器'
)
-
- # 场景验证码配置 - 可覆盖全局配置
- # 邮箱验证码配置
- email_captcha_provider = models.CharField(
+ captcha_type = models.CharField(
max_length=32,
- choices=CAPTCHA_PROVIDERS,
- blank=True,
- null=True,
- verbose_name='邮箱验证码提供器',
- help_text='邮箱场景的验证码提供器(留空则使用全局配置)'
- )
- email_captcha_id = models.CharField(
- max_length=255,
- blank=True,
- null=True,
- verbose_name='邮箱验证码 ID',
- help_text='邮箱场景验证码服务的公共ID(如果为空,则使用全局配置)'
- )
- email_captcha_key = models.CharField(
- max_length=255,
- blank=True,
- null=True,
- verbose_name='邮箱验证码密钥',
- help_text='邮箱场景验证码服务的密钥(如果为空,则使用全局配置)'
+ choices=CAPTCHA_TYPES,
+ default='SLIDER',
+ verbose_name='默认验证码类型',
+ help_text='全局默认的验证码类型'
)
-
- # 登录验证码配置
- login_captcha_provider = models.CharField(
+ login_captcha_type = models.CharField(
max_length=32,
- choices=CAPTCHA_PROVIDERS,
- blank=True,
- null=True,
- verbose_name='登录验证码提供器',
- help_text='登录场景的验证码提供器(留空则使用全局配置)'
- )
- login_captcha_id = models.CharField(
- max_length=255,
+ choices=CAPTCHA_TYPES,
blank=True,
null=True,
- verbose_name='登录验证码 ID',
- help_text='登录场景验证码服务的公共ID(如果为空,则使用全局配置)'
+ verbose_name='登录验证码类型',
+ help_text='登录场景的验证码类型(留空则使用默认类型)'
)
- login_captcha_key = models.CharField(
- max_length=255,
- blank=True,
- null=True,
- verbose_name='登录验证码密钥',
- help_text='登录场景验证码服务的密钥(如果为空,则使用全局配置)'
- )
-
- # 注册验证码配置
- register_captcha_provider = models.CharField(
+ register_captcha_type = models.CharField(
max_length=32,
- choices=CAPTCHA_PROVIDERS,
+ choices=CAPTCHA_TYPES,
blank=True,
null=True,
- verbose_name='注册验证码提供器',
- help_text='注册场景的验证码提供器(留空则使用全局配置)'
+ verbose_name='注册验证码类型',
+ help_text='注册场景的验证码类型(留空则使用默认类型)'
)
- register_captcha_id = models.CharField(
- max_length=255,
- blank=True,
- null=True,
- verbose_name='注册验证码 ID',
- help_text='注册场景验证码服务的公共ID(如果为空,则使用全局配置)'
- )
- register_captcha_key = models.CharField(
- max_length=255,
+ email_captcha_type = models.CharField(
+ max_length=32,
+ choices=CAPTCHA_TYPES,
blank=True,
null=True,
- verbose_name='注册验证码密钥',
- help_text='注册场景验证码服务的密钥(如果为空,则使用全局配置)'
+ verbose_name='邮箱验证码类型',
+ help_text='邮箱发送验证码场景的验证码类型(留空则使用默认类型)'
)
# 其他配置
@@ -299,36 +244,7 @@ def __str__(self):
return f'{self.site_name} 配置'
def clean(self):
- """
- Model-level validation: Validate that when a provider is enabled,
- its required keys are present.
- """
- from django.core.exceptions import ValidationError
-
- errors = {}
- provider = getattr(self, 'captcha_provider', 'none')
- if provider in ['geetest', 'turnstile']:
- captcha_id = self.captcha_id
- captcha_key = self.captcha_key
- if self.pk and not (captcha_id and captcha_key):
- try:
- existing = SystemConfig.objects.get(pk=self.pk)
- if not captcha_id:
- captcha_id = existing.captcha_id
- if not captcha_key:
- captcha_key = existing.captcha_key
- except SystemConfig.DoesNotExist:
- pass
- if not (captcha_id and captcha_key):
- msg = (
- f'启用 {self.get_captcha_provider_display()} 时 '
- f'必须填写验证码 ID 和密钥。'
- )
- errors['captcha_id'] = msg
- errors['captcha_key'] = msg
-
- if errors:
- raise ValidationError(errors)
+ pass
@classmethod
def get_config(cls):
@@ -355,27 +271,13 @@ def delete(self, *args, **kwargs):
return result
def get_captcha_config(self, scene=None):
- """
- 获取指定场景的验证码配置,如果没有为场景单独配置,则使用全局配置
- :param scene: 场景标识符 ('login', 'register', 'email', None)
- :return: (provider, captcha_id, captcha_key)
- """
+ provider = self.captcha_provider
if scene == 'login':
- provider = self.login_captcha_provider or self.captcha_provider
- captcha_id = self.login_captcha_id or self.captcha_id
- captcha_key = self.login_captcha_key or self.captcha_key
+ captcha_type = self.login_captcha_type or self.captcha_type
elif scene == 'register':
- provider = self.register_captcha_provider or self.captcha_provider
- captcha_id = self.register_captcha_id or self.captcha_id
- captcha_key = self.register_captcha_key or self.captcha_key
+ captcha_type = self.register_captcha_type or self.captcha_type
elif scene == 'email':
- provider = self.email_captcha_provider or self.captcha_provider
- captcha_id = self.email_captcha_id or self.captcha_id
- captcha_key = self.email_captcha_key or self.captcha_key
+ captcha_type = self.email_captcha_type or self.captcha_type
else:
- # 全局配置
- provider = self.captcha_provider
- captcha_id = self.captcha_id
- captcha_key = self.captcha_key
-
- return provider, captcha_id, captcha_key
+ captcha_type = self.captcha_type
+ return provider, captcha_type
diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py
index 3c4e22a..c2007fc 100755
--- a/apps/dashboard/views.py
+++ b/apps/dashboard/views.py
@@ -225,18 +225,20 @@ def _get_all_stats(self):
def _get_host_stats(self):
"""获取主机统计"""
- hosts = Host.objects.all()
- return {
- "total": hosts.count(),
- "online": hosts.filter(status="online").count(),
- "offline": hosts.filter(status="offline").count(),
- "error": hosts.filter(status="error").count(),
- "by_type": dict(
- hosts.values("host_type")
- .annotate(count=Count("id"))
- .values_list("host_type", "count")
- ),
- }
+ from django.db.models import Count, Q
+ stats = Host.objects.aggregate(
+ total=Count('id'),
+ online=Count('id', filter=Q(status='online')),
+ offline=Count('id', filter=Q(status='offline')),
+ error=Count('id', filter=Q(status='error')),
+ )
+ by_type = dict(
+ Host.objects.values("connection_type")
+ .annotate(count=Count("id"))
+ .values_list("connection_type", "count")
+ )
+ stats["by_type"] = by_type
+ return stats
def _get_operation_stats(self):
"""获取操作统计"""
@@ -251,29 +253,31 @@ def _get_operation_stats(self):
def _get_user_stats(self):
"""获取用户统计"""
- users = User.objects.all()
+ from django.db.models import Count, Q
seven_days_ago = timezone.now() - timedelta(days=7)
- return {
- "total": users.count(),
- "active": users.filter(is_active=True).count(),
- "recent_7_days": users.filter(date_joined__gte=seven_days_ago).count(),
- }
+ return User.objects.aggregate(
+ total=Count('id'),
+ active=Count('id', filter=Q(is_active=True)),
+ recent_7_days=Count('id', filter=Q(date_joined__gte=seven_days_ago)),
+ )
def _get_account_opening_stats(self):
"""获取开户统计"""
- requests = AccountOpeningRequest.objects.all()
- cloud_users = CloudComputerUser.objects.all()
-
- return {
- "requests_total": requests.count(),
- "requests_pending": requests.filter(status="pending").count(),
- "requests_approved": requests.filter(status="approved").count(),
- "requests_completed": requests.filter(status="completed").count(),
- "requests_failed": requests.filter(status="failed").count(),
- "cloud_users_total": cloud_users.count(),
- "cloud_users_active": cloud_users.filter(status="active").count(),
- }
+ from django.db.models import Count, Q
+
+ request_stats = AccountOpeningRequest.objects.aggregate(
+ requests_total=Count('id'),
+ requests_pending=Count('id', filter=Q(status='pending')),
+ requests_approved=Count('id', filter=Q(status='approved')),
+ requests_completed=Count('id', filter=Q(status='completed')),
+ requests_failed=Count('id', filter=Q(status='failed')),
+ )
+ cloud_user_stats = CloudComputerUser.objects.aggregate(
+ cloud_users_total=Count('id'),
+ cloud_users_active=Count('id', filter=Q(status='active')),
+ )
+ return {**request_stats, **cloud_user_stats}
class SystemConfigView(LoginRequiredMixin, UserPassesTestMixin, TemplateView):
diff --git a/apps/hosts/forms_admin.py b/apps/hosts/forms_admin.py
index dc136a3..c82ff2f 100644
--- a/apps/hosts/forms_admin.py
+++ b/apps/hosts/forms_admin.py
@@ -5,33 +5,50 @@
包含主机创建/编辑表单和主机组表单。
"""
+import os
+
from django import forms
from django.contrib.auth import get_user_model
+from django.conf import settings
+from utils.provider import PROVIDER_GROUP_NAME
from .models import Host, HostGroup
+from .forms_wizard import (
+ validate_certificate_pem,
+ validate_private_key_pem,
+ _ensure_cert_dir,
+)
User = get_user_model()
-
-# MD3 输入框样式常量
INPUT_CLASS = (
- 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md '
- 'px-4 py-3 text-md-on-surface placeholder-md-outline '
- 'focus:outline-none focus:ring-2 focus:ring-md-primary transition'
+ 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 '
+ 'rounded px-3 py-2 text-slate-200 placeholder-slate-500 '
+ 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 '
+ 'focus:border-cyan-500 transition'
)
SELECT_CLASS = (
- 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md '
- 'px-4 py-3 text-md-on-surface appearance-none '
- 'focus:outline-none focus:ring-2 focus:ring-md-primary transition cursor-pointer'
+ 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 '
+ 'rounded px-3 py-2 text-slate-200 appearance-none '
+ 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 '
+ 'focus:border-cyan-500 transition cursor-pointer'
)
CHECKBOX_CLASS = (
- 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 '
- 'text-md-primary focus:ring-md-primary focus:ring-2 transition accent-md-primary'
+ 'w-5 h-5 rounded border-slate-700/50 bg-slate-900/50 '
+ 'text-cyan-400 focus:ring-cyan-500 focus:ring-2 transition '
+ 'accent-cyan-500 cursor-pointer'
)
MULTI_SELECT_CLASS = (
- 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md '
- 'px-4 py-3 text-md-on-surface '
- 'focus:outline-none focus:ring-2 focus:ring-md-primary transition min-h-[120px]'
+ 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 '
+ 'rounded px-3 py-2 text-slate-200 '
+ 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 '
+ 'focus:border-cyan-500 transition min-h-[120px]'
+)
+FILE_INPUT_CLASS = (
+ 'w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 '
+ 'file:rounded file:border-0 file:text-sm file:font-medium '
+ 'file:bg-cyan-600/20 file:text-cyan-400 hover:file:bg-cyan-600/30 '
+ 'file:cursor-pointer cursor-pointer'
)
@@ -46,18 +63,38 @@ class AdminHostForm(forms.ModelForm):
password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': INPUT_CLASS,
- 'placeholder': '输入密码或留空自动生成',
+ 'placeholder': '输入远程主机登录密码',
'autocomplete': 'new-password',
}),
required=False,
- help_text='留空将自动生成随机密码',
label='密码',
)
+ cert_pem = forms.FileField(
+ label='客户端证书(公钥)',
+ required=False,
+ widget=forms.ClearableFileInput(attrs={
+ 'class': FILE_INPUT_CLASS,
+ 'accept': '.pem,.cer,.crt,.cert',
+ }),
+ help_text='PEM格式的客户端证书文件',
+ )
+
+ cert_key = forms.FileField(
+ label='客户端私钥',
+ required=False,
+ widget=forms.ClearableFileInput(attrs={
+ 'class': FILE_INPUT_CLASS,
+ 'accept': '.pem,.key',
+ }),
+ help_text='PEM格式的客户端私钥文件',
+ )
+
class Meta:
model = Host
fields = [
- 'name', 'hostname', 'connection_type', 'port', 'rdp_port',
+ 'name', 'os_type', 'hostname', 'connection_type',
+ 'auth_method', 'port', 'rdp_port',
'use_ssl', 'username',
'providers',
]
@@ -66,6 +103,9 @@ class Meta:
'class': INPUT_CLASS,
'placeholder': '输入主机名称',
}),
+ 'os_type': forms.Select(attrs={
+ 'class': SELECT_CLASS,
+ }),
'hostname': forms.TextInput(attrs={
'class': INPUT_CLASS,
'placeholder': '输入主机地址',
@@ -73,6 +113,9 @@ class Meta:
'connection_type': forms.Select(attrs={
'class': SELECT_CLASS,
}),
+ 'auth_method': forms.Select(attrs={
+ 'class': SELECT_CLASS,
+ }),
'port': forms.NumberInput(attrs={
'class': INPUT_CLASS,
'placeholder': '5985',
@@ -95,9 +138,11 @@ class Meta:
}
labels = {
'name': '主机名称',
+ 'os_type': '主机系统',
'hostname': '主机地址',
'connection_type': '连接类型',
- 'port': '连接端口',
+ 'auth_method': '连接方式',
+ 'port': 'WinRM端口',
'rdp_port': 'RDP端口',
'use_ssl': '使用SSL',
'username': '用户名',
@@ -111,41 +156,125 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
provider_users = User.objects.filter(
- groups__name='提供商',
+ groups__name=PROVIDER_GROUP_NAME,
is_staff=True,
is_superuser=False,
).order_by('username')
self.fields['providers'].queryset = provider_users
- # 编辑模式下密码提示
+ self.fields['os_type'].choices = Host.OS_TYPE_CHOICES
+ self.fields['connection_type'].choices = [
+ ('winrm', 'WinRM'),
+ ('localwinserver', '本地WinServer'),
+ ]
+ self.fields['auth_method'].choices = Host.AUTH_METHOD_CHOICES
+
if self.instance.pk:
self.fields['password'].help_text = (
'留空则不修改密码。为安全起见,此处不显示原密码。'
)
self.fields['password'].required = False
+ def clean(self):
+ cleaned_data = super().clean()
+ connection_type = cleaned_data.get('connection_type')
+ auth_method = cleaned_data.get('auth_method')
+
+ if connection_type == 'winrm' and auth_method == 'certificate':
+ cert_pem = self.files.get('cert_pem')
+ cert_key = self.files.get('cert_key')
+ has_existing = (
+ self.instance.pk
+ and self.instance.cert_pem_path
+ and os.path.exists(self.instance.cert_pem_path)
+ )
+ if not cert_pem and not has_existing:
+ self.add_error('cert_pem', '证书认证方式必须上传客户端证书')
+ if not cert_key and not has_existing:
+ self.add_error('cert_key', '证书认证方式必须上传客户端私钥')
+ if cert_pem:
+ try:
+ validate_certificate_pem(cert_pem.read())
+ except forms.ValidationError as e:
+ self.add_error('cert_pem', e)
+ finally:
+ cert_pem.seek(0)
+ if cert_key:
+ try:
+ validate_private_key_pem(cert_key.read())
+ except forms.ValidationError as e:
+ self.add_error('cert_key', e)
+ finally:
+ cert_key.seek(0)
+
+ if connection_type == 'winrm' and auth_method == 'ntlm':
+ if not cleaned_data.get('username'):
+ self.add_error('username', 'NTLM认证方式必须填写用户名')
+ if not self.instance.pk and not cleaned_data.get('password'):
+ self.add_error('password', 'NTLM认证方式必须填写密码')
+
+ if connection_type == 'localwinserver':
+ if not cleaned_data.get('username'):
+ self.add_error('username', '必须填写用户名')
+ if not self.instance.pk and not cleaned_data.get('password'):
+ self.add_error('password', '必须填写密码')
+
+ return cleaned_data
+
def save(self, commit=True):
instance = super().save(commit=False)
+ auth_method = self.cleaned_data.get('auth_method')
+ connection_type = self.cleaned_data.get('connection_type')
password = self.cleaned_data.get('password')
if self.instance.pk:
- # 编辑模式:仅当密码不为空时修改
if password:
instance.password = password
else:
- # 创建模式:密码为空则自动生成
- if password:
+ if connection_type == 'winrm' and auth_method == 'ntlm':
+ instance.password = password
+ elif connection_type == 'winrm' and auth_method == 'certificate':
+ instance.cert_pem_path = instance.cert_pem_path or ''
+ instance.cert_key_path = instance.cert_key_path or ''
+ elif connection_type == 'localwinserver':
instance.password = password
- else:
- from .forms_provider import generate_random_password
- self.generated_password = generate_random_password()
- instance.password = self.generated_password
if commit:
instance.save()
self.save_m2m()
+ if connection_type == 'winrm' and auth_method == 'certificate':
+ self._save_cert_files(instance)
+
return instance
+ def _save_cert_files(self, host):
+ cert_pem = self.files.get('cert_pem')
+ cert_key = self.files.get('cert_key')
+ if not cert_pem or not cert_key:
+ return
+
+ cert_dir = _ensure_cert_dir(host.pk)
+ pem_path = os.path.join(cert_dir, 'client.pem')
+ key_path = os.path.join(cert_dir, 'client.key')
+
+ with open(pem_path, 'wb') as f:
+ for chunk in cert_pem.chunks():
+ f.write(chunk)
+
+ with open(key_path, 'wb') as f:
+ for chunk in cert_key.chunks():
+ f.write(chunk)
+
+ os.chmod(pem_path, 0o600)
+ os.chmod(key_path, 0o600)
+
+ host.cert_pem_path = pem_path
+ host.cert_key_path = key_path
+ Host.objects.filter(pk=host.pk).update(
+ cert_pem_path=pem_path,
+ cert_key_path=key_path,
+ )
+
class AdminHostGroupForm(forms.ModelForm):
"""
@@ -191,12 +320,10 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- # hosts: 所有主机
self.fields['hosts'].queryset = Host.objects.order_by('name')
- # providers: 所有提供商组用户
provider_users = User.objects.filter(
- groups__name='提供商',
+ groups__name=PROVIDER_GROUP_NAME,
is_staff=True,
is_superuser=False,
).order_by('username')
diff --git a/apps/hosts/forms_provider.py b/apps/hosts/forms_provider.py
index 00fc45c..000084e 100644
--- a/apps/hosts/forms_provider.py
+++ b/apps/hosts/forms_provider.py
@@ -246,9 +246,9 @@ def __init__(self, *args, **kwargs):
# 过滤 providers:只显示提供商组的用户
from django.contrib.auth.models import User
- from utils.provider import is_provider
+ from utils.provider import is_provider, PROVIDER_GROUP_NAME
provider_users = User.objects.filter(
- groups__name='提供商',
+ groups__name=PROVIDER_GROUP_NAME,
is_staff=True,
is_superuser=False,
).order_by('username')
diff --git a/apps/hosts/forms_wizard.py b/apps/hosts/forms_wizard.py
index d6102ed..4c5d1ff 100644
--- a/apps/hosts/forms_wizard.py
+++ b/apps/hosts/forms_wizard.py
@@ -5,72 +5,116 @@
与 AdminHostForm 不同,此表单专注于创建流程的简化和引导。
"""
+import os
+
from django import forms
from django.contrib.auth import get_user_model
+from django.conf import settings
+from utils.provider import PROVIDER_GROUP_NAME
from .models import Host
User = get_user_model()
-
-# MD3 输入框样式常量
INPUT_CLASS = (
- 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md '
- 'px-4 py-3 text-md-on-surface placeholder-md-outline '
- 'focus:outline-none focus:ring-2 focus:ring-md-primary transition'
+ 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 '
+ 'rounded px-3 py-2 text-slate-200 placeholder-slate-500 '
+ 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 '
+ 'focus:border-cyan-500 transition'
)
SELECT_CLASS = (
- 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md '
- 'px-4 py-3 text-md-on-surface appearance-none '
- 'focus:outline-none focus:ring-2 focus:ring-md-primary transition cursor-pointer'
+ 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 '
+ 'rounded px-3 py-2 text-slate-200 appearance-none '
+ 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 '
+ 'focus:border-cyan-500 transition cursor-pointer'
)
CHECKBOX_CLASS = (
- 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 '
- 'text-md-primary focus:ring-md-primary focus:ring-2 transition accent-md-primary'
+ 'w-5 h-5 rounded border-slate-700/50 bg-slate-900/50 '
+ 'text-cyan-400 focus:ring-cyan-500 focus:ring-2 transition '
+ 'accent-cyan-500 cursor-pointer'
+)
+FILE_INPUT_CLASS = (
+ 'w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 '
+ 'file:rounded file:border-0 file:text-sm file:font-medium '
+ 'file:bg-cyan-600/20 file:text-cyan-400 hover:file:bg-cyan-600/30 '
+ 'file:cursor-pointer cursor-pointer'
)
-# 连接类型 -> 默认端口映射
CONNECTION_DEFAULT_PORTS = {
'winrm': 5985,
- 'ssh': 22,
'localwinserver': 5985,
+ 'ssh': 22,
'tunnel': 5985,
}
-# 连接类型 -> 默认SSL映射
CONNECTION_DEFAULT_SSL = {
'winrm': False,
- 'ssh': False,
'localwinserver': False,
+ 'ssh': False,
'tunnel': False,
}
+CERT_STORAGE_DIR = os.path.join(settings.MEDIA_ROOT, 'certs', 'hosts')
+
+
+def _ensure_cert_dir(host_pk):
+ d = os.path.join(CERT_STORAGE_DIR, str(host_pk))
+ os.makedirs(d, exist_ok=True)
+ return d
+
+
+def validate_certificate_pem(content: bytes, field_name: str = '证书') -> None:
+ try:
+ text = content.decode('utf-8')
+ except UnicodeDecodeError:
+ raise forms.ValidationError(f'{field_name}文件编码无效,必须为UTF-8文本格式')
+ if '-----BEGIN' not in text:
+ raise forms.ValidationError(f'{field_name}文件格式无效,不是合法的PEM格式')
+ if '-----END' not in text:
+ raise forms.ValidationError(f'{field_name}文件格式无效,不是合法的PEM格式')
+
+
+def validate_private_key_pem(content: bytes) -> None:
+ try:
+ text = content.decode('utf-8')
+ except UnicodeDecodeError:
+ raise forms.ValidationError('私钥文件编码无效,必须为UTF-8文本格式')
+ if '-----BEGIN' not in text:
+ raise forms.ValidationError('私钥文件格式无效,不是合法的PEM格式')
+ if 'PRIVATE KEY' not in text:
+ raise forms.ValidationError('私钥文件格式无效,未包含私钥标识')
+ if '-----END' not in text:
+ raise forms.ValidationError('私钥文件格式无效,不是合法的PEM格式')
+ try:
+ from cryptography.hazmat.primitives.serialization import load_pem_private_key
+ from cryptography.hazmat.backends import default_backend
+ load_pem_private_key(content, password=None, backend=default_backend())
+ except ImportError:
+ # cryptography 为可选依赖:缺失时仅执行基础 PEM 文本校验,
+ # 不阻断表单流程,以保持兼容现有部署环境。
+ pass
+ except Exception as e:
+ raise forms.ValidationError(f'私钥文件无效: {str(e)}')
+
class HostWizardForm(forms.ModelForm):
"""
主机创建向导表单
分为三步:
- - Step 1: 基本信息 (name, hostname, connection_type)
- - Step 2: 连接配置 (port, rdp_port, use_ssl, username, password) 或 执行命令 (隧道模式)
+ - Step 1: 基本信息 (name, os_type, hostname, connection_type)
+ - Step 2: 连接配置 (port, auth_method, username/password 或 证书)
- Step 3: 分配提供商 (providers, description)
-
- 智能默认值:
- - port 根据连接类型自动设置 (winrm=5985, ssh=22)
- - use_ssl 根据端口自动判断 (5986=True)
- - 密码留空时自动生成
- - 隧道模式下hostname非必填,自动生成
"""
password = forms.CharField(
widget=forms.PasswordInput(attrs={
'class': INPUT_CLASS,
- 'placeholder': '输入密码或留空自动生成',
+ 'placeholder': '输入远程主机登录密码',
'autocomplete': 'new-password',
'x-model': 'password',
}),
required=False,
- help_text='留空将自动生成随机密码',
label='密码',
)
@@ -81,11 +125,47 @@ class HostWizardForm(forms.ModelForm):
required=False,
)
+ init_token = forms.CharField(
+ widget=forms.HiddenInput(attrs={
+ 'x-model': 'initToken',
+ }),
+ required=False,
+ )
+
+ cert_config_method = forms.CharField(
+ widget=forms.HiddenInput(attrs={
+ 'x-model': 'certConfigMethod',
+ }),
+ required=False,
+ initial='quick',
+ )
+
+ cert_pem = forms.FileField(
+ label='客户端证书(公钥)',
+ required=False,
+ widget=forms.ClearableFileInput(attrs={
+ 'class': FILE_INPUT_CLASS,
+ 'accept': '.pem,.cer,.crt,.cert',
+ }),
+ help_text='PEM格式的客户端证书文件',
+ )
+
+ cert_key = forms.FileField(
+ label='客户端私钥',
+ required=False,
+ widget=forms.ClearableFileInput(attrs={
+ 'class': FILE_INPUT_CLASS,
+ 'accept': '.pem,.key',
+ }),
+ help_text='PEM格式的客户端私钥文件',
+ )
+
class Meta:
model = Host
fields = [
- 'name', 'hostname', 'connection_type',
- 'port', 'rdp_port', 'use_ssl', 'username', 'password',
+ 'name', 'os_type', 'hostname', 'connection_type',
+ 'auth_method', 'port', 'rdp_port', 'use_ssl',
+ 'username', 'password',
'providers', 'description',
'tunnel_token',
]
@@ -95,6 +175,10 @@ class Meta:
'placeholder': '输入主机名称,如: 北京服务器-01',
'x-model': 'name',
}),
+ 'os_type': forms.Select(attrs={
+ 'class': SELECT_CLASS,
+ 'x-model': 'osType',
+ }),
'hostname': forms.TextInput(attrs={
'class': INPUT_CLASS,
'placeholder': '输入主机地址,如: 192.168.1.100',
@@ -105,6 +189,11 @@ class Meta:
'x-model': 'connectionType',
'x-on:change': 'onConnectionTypeChange()',
}),
+ 'auth_method': forms.Select(attrs={
+ 'class': SELECT_CLASS,
+ 'x-model': 'authMethod',
+ 'x-on:change': 'onAuthMethodChange()',
+ }),
'port': forms.NumberInput(attrs={
'class': INPUT_CLASS,
'placeholder': '5985',
@@ -134,9 +223,11 @@ class Meta:
}
labels = {
'name': '主机名称',
+ 'os_type': '主机系统',
'hostname': '主机地址',
'connection_type': '连接类型',
- 'port': '连接端口',
+ 'auth_method': '连接方式',
+ 'port': 'WinRM端口',
'rdp_port': 'RDP端口',
'use_ssl': '使用SSL',
'username': '用户名',
@@ -148,7 +239,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
provider_users = User.objects.filter(
- groups__name='提供商',
+ groups__name=PROVIDER_GROUP_NAME,
is_staff=True,
is_superuser=False,
).order_by('username')
@@ -159,10 +250,20 @@ def __init__(self, *args, **kwargs):
if not self.initial.get('rdp_port'):
self.initial['rdp_port'] = 3389
+ self.fields['os_type'].choices = Host.OS_TYPE_CHOICES
+
+ self.fields['connection_type'].choices = [
+ ('winrm', 'WinRM'),
+ ('localwinserver', '本地WinServer'),
+ ]
+
+ self.fields['auth_method'].choices = Host.AUTH_METHOD_CHOICES
+
def clean(self):
cleaned_data = super().clean()
connection_type = cleaned_data.get('connection_type')
hostname = cleaned_data.get('hostname')
+ auth_method = cleaned_data.get('auth_method')
if connection_type == 'tunnel' and not hostname:
cleaned_data['hostname'] = 'tunnel-pending'
@@ -171,30 +272,94 @@ def clean(self):
if tunnel_token == '':
cleaned_data['tunnel_token'] = None
+ if connection_type == 'winrm' and auth_method == 'certificate':
+ cert_config_method = cleaned_data.get('cert_config_method', 'quick')
+ cert_pem = self.files.get('cert_pem')
+ cert_key = self.files.get('cert_key')
+ if cert_config_method == 'manual':
+ if not cert_pem:
+ self.add_error('cert_pem', '证书认证方式必须上传客户端证书')
+ if not cert_key:
+ self.add_error('cert_key', '证书认证方式必须上传客户端私钥')
+ if cert_pem:
+ try:
+ validate_certificate_pem(cert_pem.read())
+ except forms.ValidationError as e:
+ self.add_error('cert_pem', e)
+ finally:
+ cert_pem.seek(0)
+ if cert_key:
+ try:
+ validate_private_key_pem(cert_key.read())
+ except forms.ValidationError as e:
+ self.add_error('cert_key', e)
+ finally:
+ cert_key.seek(0)
+
+ if connection_type == 'winrm' and auth_method == 'ntlm':
+ if not cleaned_data.get('username'):
+ self.add_error('username', 'NTLM认证方式必须填写用户名')
+ if not cleaned_data.get('password'):
+ self.add_error('password', 'NTLM认证方式必须填写密码')
+
+ if connection_type == 'localwinserver':
+ if not cleaned_data.get('username'):
+ self.add_error('username', '必须填写用户名')
+ if not cleaned_data.get('password'):
+ self.add_error('password', '必须填写密码')
+
return cleaned_data
def save(self, commit=True):
instance = super().save(commit=False)
- password = self.cleaned_data.get('password')
+ auth_method = self.cleaned_data.get('auth_method')
+ connection_type = self.cleaned_data.get('connection_type')
- # 创建模式:密码为空则自动生成
- if password:
- instance.password = password
- else:
- from .forms_provider import generate_random_password
- self.generated_password = generate_random_password()
- instance.password = self.generated_password
+ if connection_type == 'winrm' and auth_method == 'ntlm':
+ instance.password = self.cleaned_data.get('password')
+ elif connection_type == 'winrm' and auth_method == 'certificate':
+ instance.cert_pem_path = ''
+ instance.cert_key_path = ''
+ elif connection_type == 'localwinserver':
+ instance.password = self.cleaned_data.get('password')
if commit:
instance.save()
self.save_m2m()
+ if connection_type == 'winrm' and auth_method == 'certificate':
+ self._save_cert_files(instance)
+
return instance
+ def _save_cert_files(self, host):
+ cert_pem = self.files.get('cert_pem')
+ cert_key = self.files.get('cert_key')
+ if not cert_pem or not cert_key:
+ return
+
+ cert_dir = _ensure_cert_dir(host.pk)
+ pem_path = os.path.join(cert_dir, 'client.pem')
+ key_path = os.path.join(cert_dir, 'client.key')
+
+ with open(pem_path, 'wb') as f:
+ for chunk in cert_pem.chunks():
+ f.write(chunk)
+
+ with open(key_path, 'wb') as f:
+ for chunk in cert_key.chunks():
+ f.write(chunk)
+
+ os.chmod(pem_path, 0o600)
+ os.chmod(key_path, 0o600)
+
+ host.cert_pem_path = pem_path
+ host.cert_key_path = key_path
+ Host.objects.filter(pk=host.pk).update(
+ cert_pem_path=pem_path,
+ cert_key_path=key_path,
+ )
+
def get_providers_with_host_count(self):
- """
- 返回提供商列表及其当前管理的主机数量,
- 用于向导第三步的上下文展示。
- """
providers = self.fields['providers'].queryset
result = []
for provider in providers:
diff --git a/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py b/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py
new file mode 100644
index 0000000..15da766
--- /dev/null
+++ b/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.2.30 on 2026-05-23 05:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('hosts', '0010_remove_host_host_type'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='host',
+ name='auth_method',
+ field=models.CharField(choices=[('ntlm', '管理员账户密码'), ('certificate', '证书')], default='ntlm', max_length=20, verbose_name='连接方式'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='cert_key_path',
+ field=models.CharField(blank=True, default='', max_length=512, verbose_name='客户端私钥路径'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='cert_pem_path',
+ field=models.CharField(blank=True, default='', max_length=512, verbose_name='客户端证书路径'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='os_type',
+ field=models.CharField(choices=[('windows', 'Windows')], default='windows', max_length=20, verbose_name='主机系统'),
+ ),
+ migrations.AlterField(
+ model_name='host',
+ name='connection_type',
+ field=models.CharField(choices=[('winrm', 'WinRM'), ('localwinserver', '本地WinServer'), ('ssh', 'SSH'), ('tunnel', '隧道模式(零公网IP)')], default='winrm', max_length=20, verbose_name='连接类型'),
+ ),
+ ]
diff --git a/apps/hosts/migrations/0012_host_username_optional.py b/apps/hosts/migrations/0012_host_username_optional.py
new file mode 100644
index 0000000..13e7c07
--- /dev/null
+++ b/apps/hosts/migrations/0012_host_username_optional.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-05-23 09:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('hosts', '0011_host_auth_method_host_cert_key_path_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='host',
+ name='username',
+ field=models.CharField(blank=True, default='', max_length=100, verbose_name='用户名'),
+ ),
+ ]
diff --git a/apps/hosts/migrations/0013_add_cert_provision_fields.py b/apps/hosts/migrations/0013_add_cert_provision_fields.py
new file mode 100644
index 0000000..6da3cac
--- /dev/null
+++ b/apps/hosts/migrations/0013_add_cert_provision_fields.py
@@ -0,0 +1,48 @@
+# Generated by Django 4.2.30 on 2026-05-29 15:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('hosts', '0012_host_username_optional'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='host',
+ name='_ntlm_fallback_password',
+ field=models.CharField(blank=True, db_column='ntlm_fallback_password', default='', max_length=255, verbose_name='NTLM回退密码'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='_pfx_password',
+ field=models.CharField(blank=True, db_column='pfx_password', default='', max_length=255, verbose_name='PFX密码'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='cert_activated_at',
+ field=models.DateTimeField(blank=True, null=True, verbose_name='证书激活时间'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='cert_provision_status',
+ field=models.CharField(choices=[('not_started', '未开始'), ('pending', '签发中'), ('ready', '已就绪'), ('configured', '已配置'), ('failed', '失败')], default='not_started', max_length=20, verbose_name='证书配置状态'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='cert_root',
+ field=models.CharField(blank=True, default='', max_length=2, verbose_name='证书存储根路径'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='cert_sub',
+ field=models.CharField(blank=True, default='', max_length=2, verbose_name='证书存储子路径'),
+ ),
+ migrations.AddField(
+ model_name='host',
+ name='ntlm_fallback_user',
+ field=models.CharField(blank=True, default='', max_length=100, verbose_name='NTLM回退用户名'),
+ ),
+ ]
diff --git a/apps/hosts/models.py b/apps/hosts/models.py
index c045a81..7cfb25c 100755
--- a/apps/hosts/models.py
+++ b/apps/hosts/models.py
@@ -1,5 +1,7 @@
from django.db import models
from django.conf import settings
+from utils.crypto import encrypt_value, decrypt_value
+import logging
import os
@@ -7,13 +9,22 @@ class Host(models.Model):
"""
主机模型
"""
+ OS_TYPE_CHOICES = [
+ ('windows', 'Windows'),
+ ]
+
CONNECTION_TYPE_CHOICES = [
('winrm', 'WinRM'),
- ('ssh', 'SSH'),
('localwinserver', '本地WinServer'),
+ ('ssh', 'SSH'),
('tunnel', '隧道模式(零公网IP)'),
]
-
+
+ AUTH_METHOD_CHOICES = [
+ ('ntlm', '管理员账户密码'),
+ ('certificate', '证书'),
+ ]
+
STATUS_CHOICES = [
('online', '在线'),
('offline', '离线'),
@@ -27,14 +38,26 @@ class Host(models.Model):
('error', '隧道错误'),
]
+ CERT_PROVISION_STATUS_CHOICES = [
+ ('not_started', '未开始'),
+ ('pending', '签发中'),
+ ('ready', '已就绪'),
+ ('configured', '已配置'),
+ ('failed', '失败'),
+ ]
+
name = models.CharField(max_length=100, verbose_name='主机名称')
+ os_type = models.CharField(max_length=20, choices=OS_TYPE_CHOICES, default='windows', verbose_name='主机系统')
hostname = models.CharField(max_length=255, verbose_name='主机地址')
connection_type = models.CharField(max_length=20, choices=CONNECTION_TYPE_CHOICES, default='winrm', verbose_name='连接类型')
+ auth_method = models.CharField(max_length=20, choices=AUTH_METHOD_CHOICES, default='ntlm', verbose_name='连接方式')
port = models.IntegerField(default=5985, verbose_name='连接端口')
rdp_port = models.IntegerField(default=3389, verbose_name='RDP端口')
use_ssl = models.BooleanField(default=False, verbose_name='使用SSL')
- username = models.CharField(max_length=100, verbose_name='用户名')
+ username = models.CharField(max_length=100, blank=True, default='', verbose_name='用户名')
_password = models.CharField(max_length=255, verbose_name='密码', db_column='password') # 加密存储
+ cert_pem_path = models.CharField(max_length=512, blank=True, default='', verbose_name='客户端证书路径')
+ cert_key_path = models.CharField(max_length=512, blank=True, default='', verbose_name='客户端私钥路径')
os_version = models.CharField(max_length=100, blank=True, verbose_name='操作系统版本')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='offline', verbose_name='状态')
description = models.TextField(blank=True, verbose_name='描述')
@@ -84,6 +107,37 @@ class Host(models.Model):
blank=True, verbose_name='隧道公钥(Ed25519)'
)
+ cert_root = models.CharField(
+ max_length=2, blank=True, default='',
+ verbose_name='证书存储根路径'
+ )
+ cert_sub = models.CharField(
+ max_length=2, blank=True, default='',
+ verbose_name='证书存储子路径'
+ )
+ _pfx_password = models.CharField(
+ max_length=255, blank=True, default='',
+ db_column='pfx_password', verbose_name='PFX密码'
+ )
+ ntlm_fallback_user = models.CharField(
+ max_length=100, blank=True, default='',
+ verbose_name='NTLM回退用户名'
+ )
+ _ntlm_fallback_password = models.CharField(
+ max_length=255, blank=True, default='',
+ db_column='ntlm_fallback_password',
+ verbose_name='NTLM回退密码'
+ )
+ cert_activated_at = models.DateTimeField(
+ null=True, blank=True, verbose_name='证书激活时间'
+ )
+ cert_provision_status = models.CharField(
+ max_length=20,
+ choices=CERT_PROVISION_STATUS_CHOICES,
+ default='not_started',
+ verbose_name='证书配置状态'
+ )
+
class Meta:
verbose_name = '主机'
verbose_name_plural = '主机'
@@ -94,26 +148,36 @@ def __str__(self):
@property
def password(self):
- from cryptography.fernet import Fernet
- import base64
- import hashlib
- from django.conf import settings
- key = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
- f = Fernet(base64.urlsafe_b64encode(key))
try:
- return f.decrypt(self._password.encode()).decode()
- except Exception:
+ return decrypt_value(self._password)
+ except ValueError:
raise ValueError("密码解密失败,数据可能已损坏或密钥已变更")
@password.setter
def password(self, raw_password):
- from cryptography.fernet import Fernet
- import base64
- import hashlib
- from django.conf import settings
- key = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
- f = Fernet(base64.urlsafe_b64encode(key))
- self._password = f.encrypt(raw_password.encode()).decode()
+ self._password = encrypt_value(raw_password)
+
+ @property
+ def pfx_password(self):
+ try:
+ return decrypt_value(self._pfx_password)
+ except ValueError:
+ raise ValueError("PFX密码解密失败")
+
+ @pfx_password.setter
+ def pfx_password(self, raw_password):
+ self._pfx_password = encrypt_value(raw_password)
+
+ @property
+ def ntlm_fallback_password(self):
+ try:
+ return decrypt_value(self._ntlm_fallback_password)
+ except ValueError:
+ raise ValueError("NTLM回退密码解密失败")
+
+ @ntlm_fallback_password.setter
+ def ntlm_fallback_password(self, raw_password):
+ self._ntlm_fallback_password = encrypt_value(raw_password)
def save(self, *args, **kwargs):
"""
@@ -127,13 +191,20 @@ def save(self, *args, **kwargs):
def get_connection_client(self):
if self.connection_type == 'winrm':
from utils.winrm_client import WinrmClient
- return WinrmClient(
+ kwargs = dict(
hostname=self.hostname,
- username=self.username,
- password=self.password,
port=self.port,
- use_ssl=self.use_ssl
+ use_ssl=self.use_ssl,
)
+ if self.auth_method == 'certificate':
+ return FallbackWinrmClient(self)
+ else:
+ kwargs.update(
+ username=self.username,
+ password=self.password,
+ auth_method='ntlm',
+ )
+ return WinrmClient(**kwargs)
elif self.connection_type == 'localwinserver':
from utils.local_winserver_client import LocalWinServerClient
return LocalWinServerClient(
@@ -159,6 +230,16 @@ def test_connection(self):
new_status = 'online' if self.tunnel_status == 'online' else 'offline'
Host.objects.filter(pk=self.pk).update(status=new_status)
return
+
+ if (self.auth_method == 'certificate'
+ and (not self.cert_pem_path
+ or not os.path.exists(self.cert_pem_path))):
+ logging.getLogger("2c2a").warning(
+ f"证书文件不存在,跳过连接测试: {self.name} "
+ f"(pem={self.cert_pem_path})"
+ )
+ Host.objects.filter(pk=self.pk).update(status='pending')
+ return
try:
client = self.get_connection_client()
@@ -177,7 +258,6 @@ def test_connection(self):
except Exception as e:
new_status = 'error'
- import logging
logger = logging.getLogger("2c2a")
logger.error(
f"测试主机连接失败: {self.name}, 错误: {str(e)}"
@@ -318,6 +398,148 @@ def add_to_remote_users(self, username):
return self.execute_powershell(script)
+class FallbackWinrmClient:
+ _logger = logging.getLogger("2c2a")
+
+ def __init__(self, host):
+ self.host = host
+ self._client = None
+
+ def _try_connect(self):
+ if self._client is not None:
+ return
+ from utils.winrm_client import WinrmClient
+ from utils.cert_storage import get_cert_file_paths
+ last_exc = None
+ ca_trust_path = None
+ if self.host.cert_root and self.host.cert_sub:
+ paths = get_cert_file_paths(self.host.cert_root, self.host.cert_sub)
+ if paths['ca_cert'].exists():
+ ca_trust_path = str(paths['ca_cert'])
+ configs = [
+ (
+ "SSL+Certificate",
+ dict(
+ hostname=self.host.hostname,
+ port=self.host.port,
+ use_ssl=True,
+ auth_method='certificate',
+ cert_pem_path=self.host.cert_pem_path,
+ cert_key_path=self.host.cert_key_path,
+ server_cert_validation='ignore',
+ ca_trust_path=ca_trust_path,
+ ),
+ ),
+ ]
+ if self.host.ntlm_fallback_user and self.host.ntlm_fallback_password:
+ configs.append((
+ "HTTPS+NTLM",
+ dict(
+ hostname=self.host.hostname,
+ port=self.host.port,
+ use_ssl=True,
+ auth_method='ntlm',
+ username=self.host.ntlm_fallback_user,
+ password=self.host.ntlm_fallback_password,
+ server_cert_validation='ignore',
+ ca_trust_path=ca_trust_path,
+ ),
+ ))
+ configs.append((
+ "HTTP+NTLM",
+ dict(
+ hostname=self.host.hostname,
+ port=5985,
+ use_ssl=False,
+ auth_method='ntlm',
+ username=self.host.ntlm_fallback_user,
+ password=self.host.ntlm_fallback_password,
+ ),
+ ))
+ for label, cfg in configs:
+ try:
+ client = WinrmClient(**cfg)
+ client.execute_command('whoami')
+ self._client = client
+ self._logger.info(
+ f"主机 {self.host.name} 连接成功,"
+ f"使用方式: {label}"
+ )
+ return
+ except Exception as e:
+ last_exc = e
+ self._logger.warning(
+ f"主机 {self.host.name} 连接方式 {label} "
+ f"失败: {e}"
+ )
+ Host.objects.filter(pk=self.host.pk).update(status='error')
+ if last_exc is not None:
+ raise last_exc
+ raise RuntimeError("所有连接方式均失败")
+
+ @property
+ def success(self):
+ return True
+
+ def execute_command(self, command):
+ self._try_connect()
+ return self._client.execute_command(command)
+
+ def execute_powershell(self, script):
+ self._try_connect()
+ return self._client.execute_powershell(script)
+
+ def create_user(self, username, password, **kwargs):
+ self._try_connect()
+ return self._client.create_user(username, password, **kwargs)
+
+ def delete_user(self, username):
+ self._try_connect()
+ return self._client.delete_user(username)
+
+ def enable_user(self, username):
+ self._try_connect()
+ return self._client.enable_user(username)
+
+ def disabled_user(self, username):
+ self._try_connect()
+ return self._client.disabled_user(username)
+
+ def reset_password(self, username, password):
+ self._try_connect()
+ return self._client.reset_password(username, password)
+
+ def op_user(self, username):
+ self._try_connect()
+ return self._client.op_user(username)
+
+ def deop_user(self, username):
+ self._try_connect()
+ return self._client.deop_user(username)
+
+ def add_to_remote_users(self, username):
+ self._try_connect()
+ return self._client.add_to_remote_users(username)
+
+ def check_user_exists(self, username):
+ self._try_connect()
+ return self._client.check_user_exists(username)
+
+ def generate_strong_password(self, length=None):
+ self._try_connect()
+ return self._client.generate_strong_password(length)
+
+ def get_password_policy(self):
+ self._try_connect()
+ return self._client.get_password_policy()
+
+ def create_user_with_reset_password_on_next_login(
+ self, username, password, description=None, group=None):
+ self._try_connect()
+ return self._client.create_user_with_reset_password_on_next_login(
+ username, password, description=description, group=group)
+
+
class HostGroup(models.Model):
"""
主机组模型
diff --git a/apps/hosts/tasks.py b/apps/hosts/tasks.py
index cc6c06e..7d4a274 100755
--- a/apps/hosts/tasks.py
+++ b/apps/hosts/tasks.py
@@ -1,8 +1,9 @@
from celery import shared_task
from django.contrib.auth.models import User
+from django.utils import timezone
+
from apps.hosts.models import Host
from apps.tasks.models import AsyncTask
-from apps.certificates.models import ServerCertificate, ClientCertificate
import logging
import re
@@ -163,61 +164,43 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False):
host = Host.objects.get(id=host_id)
task.start_execution()
- if use_certificate_auth and host.certificate_thumbprint:
- import tempfile
+ if use_certificate_auth and host.auth_method == 'certificate':
import os
-
- client_cert = ClientCertificate.objects.filter(is_active=True).first()
- if not client_cert:
- from apps.certificates.models import CertificateAuthority
- ca = host.get_ca() if hasattr(host, 'get_ca') else None
- if not ca:
- ca, _ = CertificateAuthority.objects.get_or_create(
- name='default-ca',
- defaults={'name': 'default-ca', 'description': 'Default Certificate Authority'}
- )
- if not ca.certificate:
- ca.generate_self_signed_cert()
- ca.save()
-
- client_cert = ClientCertificate(
- name=f'client-{host.hostname}',
- ca=ca
+
+ cert_pem_path = host.cert_pem_path
+ cert_key_path = host.cert_key_path
+
+ if not cert_pem_path or not cert_key_path:
+ raise ValueError(
+ "证书路径未配置,无法进行证书认证测试"
)
- client_cert.generate_client_cert(f'client-{host.hostname}')
- client_cert.save()
-
- with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.pem') as cert_file:
- cert_file.write(client_cert.certificate)
- cert_file_path = cert_file.name
-
- with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.pem') as key_file:
- key_file.write(client_cert.private_key)
- key_file_path = key_file.name
-
- try:
- from utils.winrm_client import WinrmClient
- client = WinrmClient(
- hostname=host.hostname or host.ip_address,
- port=5986,
- username='',
- password='',
- use_ssl=True,
- server_cert_validation='validate',
- client_cert_pem=cert_file_path,
- client_cert_key=key_file_path
+ if not os.path.exists(cert_pem_path):
+ raise ValueError(
+ f"客户端证书文件不存在: {cert_pem_path}"
)
-
- result = client.execute_command('echo', ['Connection Test'])
- success = result.status_code == 0
-
- finally:
- os.unlink(cert_file_path)
- os.unlink(key_file_path)
+ if not os.path.exists(cert_key_path):
+ raise ValueError(
+ f"客户端私钥文件不存在: {cert_key_path}"
+ )
+
+ from utils.winrm_client import WinrmClient
+ client = WinrmClient(
+ hostname=host.hostname,
+ port=host.port,
+ username='',
+ password='',
+ use_ssl=True,
+ auth_method='certificate',
+ cert_pem_path=cert_pem_path,
+ cert_key_path=cert_key_path,
+ server_cert_validation='ignore',
+ )
+ result = client.execute_command('echo', ['Connection Test'])
+ success = result.status_code == 0
else:
from utils.winrm_client import WinrmClient
client = WinrmClient(
- hostname=host.hostname or host.ip_address,
+ hostname=host.hostname,
port=host.port,
username=host.username,
password=host.password,
@@ -228,6 +211,11 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False):
success = result.status_code == 0
if success:
+ if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'):
+ Host.objects.filter(pk=host.pk).update(
+ cert_provision_status='configured',
+ cert_activated_at=timezone.now(),
+ )
task.progress = 100
task.complete_success({
'connected': True,
@@ -241,6 +229,8 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False):
'protocol': 'HTTPS with Certificate' if use_certificate_auth else 'HTTP with Basic Auth'
}
else:
+ if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'):
+ Host.objects.filter(pk=host.pk).update(cert_provision_status='failed')
task.complete_failure("Connection test failed")
return {
'success': False,
@@ -250,6 +240,15 @@ def test_winrm_connection(self, host_id, use_certificate_auth=False):
except Exception as e:
logger.error(f"测试WinRM连接失败: {str(e)}", exc_info=True)
+ try:
+ host = Host.objects.get(id=host_id)
+ if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'):
+ Host.objects.filter(pk=host.pk).update(cert_provision_status='failed')
+ except Host.DoesNotExist:
+ logger.warning(
+ "测试WinRM连接失败后的清理阶段:主机 #%s 不存在,跳过证书状态更新",
+ host_id
+ )
task.complete_failure(str(e))
return {
diff --git a/apps/hosts/urls_admin.py b/apps/hosts/urls_admin.py
index 8bbac5f..8b43a45 100644
--- a/apps/hosts/urls_admin.py
+++ b/apps/hosts/urls_admin.py
@@ -28,6 +28,16 @@
views_admin.admin_host_wizard_generate_token,
name='host_wizard_generate_token'
),
+ path(
+ 'wizard/test-connection/',
+ views_admin.admin_host_wizard_test_connection,
+ name='host_wizard_test_connection'
+ ),
+ path(
+ 'wizard/generate-init-command/',
+ views_admin.admin_host_wizard_generate_init_command,
+ name='host_wizard_generate_init_command'
+ ),
path(
'create/',
views_admin.AdminHostCreateView.as_view(),
@@ -53,6 +63,16 @@
views_admin.admin_host_test_connection,
name='host_test'
),
+ path(
+ '/generate-init-command/',
+ views_admin.admin_host_generate_init_command,
+ name='host_generate_init_command'
+ ),
+ path(
+ 'generate-cert-command/',
+ views_admin.admin_host_generate_cert_command,
+ name='admin_host_generate_cert_command'
+ ),
# 主机组管理
path(
diff --git a/apps/hosts/views_admin.py b/apps/hosts/views_admin.py
index e430ffc..8ce100b 100644
--- a/apps/hosts/views_admin.py
+++ b/apps/hosts/views_admin.py
@@ -8,7 +8,9 @@
import json
import logging
import os
+import platform
import secrets
+from datetime import timedelta
from django.conf import settings
from django.contrib import messages
@@ -18,12 +20,15 @@
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
+from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.generic import DetailView, TemplateView
+from django.contrib.auth.decorators import login_required
+
from apps.accounts.provider_decorators import admin_required
-from utils.provider import get_provider_hosts
+from utils.provider import get_provider_hosts, PROVIDER_GROUP_NAME
from .forms_admin import AdminHostForm, AdminHostGroupForm
from .forms_wizard import HostWizardForm, CONNECTION_DEFAULT_PORTS, CONNECTION_DEFAULT_SSL
@@ -33,9 +38,72 @@
logger = logging.getLogger(__name__)
+def _is_local_winserver():
+ return platform.system() == 'Windows' and 'server' in platform.version().lower()
+
+
+def _generate_init_command_data(request, host):
+ from apps.bootstrap.models import InitialToken
+ import base64
+
+ token = secrets.token_urlsafe(32)
+ expires_at = timezone.now() + timedelta(hours=24)
+
+ initial_token = InitialToken.objects.create(
+ token=token,
+ host=host,
+ expires_at=expires_at,
+ status='ISSUED',
+ )
+
+ pairing_code = initial_token.generate_pairing_code()
+
+ config_data = {
+ 'c_side_url': request.build_absolute_uri(
+ '/'
+ ).rstrip('/'),
+ 'token': initial_token.token,
+ 'host_id': str(host.id),
+ 'expires_at': initial_token.expires_at.isoformat(),
+ }
+ config_json = json.dumps(config_data)
+ encoded_config = base64.b64encode(
+ config_json.encode('utf-8')
+ ).decode('utf-8')
+
+ one_liner = (
+ "& ([ScriptBlock]::Create("
+ "(irm https://static.2c2a.cc.cd/hostinitbash.ps1)"
+ f")) -Secret '{encoded_config}'"
+ )
+
+ fallback_command = (
+ "$e = \"$env:TEMP\\h_side_init.exe\"; "
+ "irm https://2c2a.cc.cd/hostinitbash.exe "
+ f"-OutFile $e; & $e '{encoded_config}'"
+ )
+
+ return {
+ 'pairing_code': pairing_code,
+ 'one_liner': one_liner,
+ 'fallback_command': fallback_command,
+ 'expires_at': initial_token.expires_at.isoformat(),
+ 'host_id': host.id,
+ 'hostname': host.hostname,
+ }
+
+
+def _get_host_form_context():
+ return {
+ 'default_ports': json.dumps(CONNECTION_DEFAULT_PORTS),
+ 'default_ssl': json.dumps(CONNECTION_DEFAULT_SSL),
+ 'is_local_winserver': json.dumps(_is_local_winserver()),
+ }
+
+
def _get_permission_context(form, host=None):
provider_users = User.objects.filter(
- groups__name='提供商',
+ groups__name=PROVIDER_GROUP_NAME,
is_staff=True,
is_superuser=False,
).order_by('username')
@@ -52,7 +120,7 @@ def _get_permission_context(form, host=None):
'name': g.name,
'member_ids': list(
g.user_set.filter(
- groups__name='提供商',
+ groups__name=PROVIDER_GROUP_NAME,
is_staff=True,
is_superuser=False,
)
@@ -208,7 +276,6 @@ def get_context_data(self, **kwargs):
generated_password = self.request.session.get(
'generated_password'
)
- # 一次性读取后清除
self.request.session.pop('generated_password', None)
self.request.session.pop(
'generated_password_host_id', None
@@ -245,15 +312,71 @@ def get_context_data(self, **kwargs):
'is_create': True,
})
context.update(_get_permission_context(form))
+ context.update(_get_host_form_context())
return context
def post(self, request, *args, **kwargs):
- form = AdminHostForm(request.POST)
+ form = AdminHostForm(
+ request.POST, request.FILES
+ )
if form.is_valid():
- host = form.save(commit=False)
- host.created_by = request.user
- host.save()
- form.save_m2m()
+ init_token_value = form.cleaned_data.get('init_token', '')
+ logger.info(f"Wizard save: init_token={'yes' if init_token_value else 'no'}, value={init_token_value[:8] if init_token_value else 'N/A'}")
+ existing_host = None
+ cert_token_obj = None
+ if init_token_value:
+ from apps.bootstrap.models import CertProvisionToken
+ try:
+ cert_token_obj = CertProvisionToken.objects.get(token=init_token_value)
+ if cert_token_obj.host:
+ existing_host = cert_token_obj.host
+ except CertProvisionToken.DoesNotExist:
+ pass
+
+ if existing_host:
+ for field in ['name', 'os_type', 'hostname', 'connection_type',
+ 'auth_method', 'port', 'rdp_port', 'use_ssl',
+ 'username', 'description']:
+ if field in form.cleaned_data:
+ setattr(existing_host, field, form.cleaned_data[field])
+ pwd = form.cleaned_data.get('password', '')
+ if pwd:
+ existing_host.password = pwd
+ existing_host.save()
+ form.instance = existing_host
+ form.save_m2m()
+ # 保存证书文件
+ if existing_host.auth_method == 'certificate':
+ form._save_cert_files(existing_host)
+ host = existing_host
+ else:
+ host = form.save(commit=False)
+ host.created_by = request.user
+ host.save()
+ form.save_m2m()
+ # 保存证书文件
+ if host.auth_method == 'certificate':
+ form._save_cert_files(host)
+
+ if cert_token_obj and not existing_host:
+ cert_token_obj.host = host
+ if cert_token_obj.cert_data:
+ cd = cert_token_obj.cert_data
+ host.cert_root = cd.get('cert_root', '')
+ host.cert_sub = cd.get('cert_sub', '')
+ host.pfx_password = cd.get('pfx_password', '')
+ host.ntlm_fallback_user = cd.get('ntlm_user', '')
+ host.ntlm_fallback_password = cd.get('ntlm_password', '')
+ host.cert_provision_status = 'ready'
+ # 根据 cert_root 和 cert_sub 计算证书路径
+ if host.cert_root and host.cert_sub:
+ from utils.cert_storage import get_cert_dir
+ cert_dir = get_cert_dir(host.cert_root, host.cert_sub)
+ host.cert_pem_path = str(cert_dir / 'client.crt')
+ host.cert_key_path = str(cert_dir / 'client.key')
+ host.save()
+ cert_token_obj.cert_data = None
+ cert_token_obj.save()
# 测试连接
try:
@@ -281,7 +404,6 @@ def post(self, request, *args, **kwargs):
f'已为主机 {host.name} 自动生成密码,'
f'请妥善保存。'
)
- # 将生成的密码存入 session 以便在详情页展示
request.session['generated_password'] = (
form.generated_password
)
@@ -289,6 +411,18 @@ def post(self, request, *args, **kwargs):
host.pk
)
+ init_token = request.POST.get('init_token')
+ if (host.auth_method == 'certificate'
+ and init_token and not cert_token_obj):
+ try:
+ from apps.bootstrap.models import CertProvisionToken
+ CertProvisionToken.objects.filter(
+ token=init_token,
+ host__isnull=True,
+ ).update(host=host)
+ except Exception:
+ pass
+
return redirect('admin:admin_hosts:host_detail', pk=host.pk)
return self.render_to_response(
@@ -329,11 +463,14 @@ def get_context_data(self, **kwargs):
'is_create': False,
})
context.update(_get_permission_context(form, host))
+ context.update(_get_host_form_context())
return context
def post(self, request, *args, **kwargs):
host = self.get_host()
- form = AdminHostForm(request.POST, instance=host)
+ form = AdminHostForm(
+ request.POST, request.FILES, instance=host
+ )
if form.is_valid():
host = form.save()
@@ -689,24 +826,65 @@ def admin_host_wizard(request):
最终一次性提交表单创建主机。
"""
if request.method == 'POST':
- form = HostWizardForm(request.POST)
+ form = HostWizardForm(
+ request.POST, request.FILES
+ )
if form.is_valid():
host = form.save(commit=False)
host.created_by = request.user
host.save()
form.save_m2m()
+ # 保存证书文件
+ if host.auth_method == 'certificate':
+ form._save_cert_files(host)
+
+ init_token_value = form.cleaned_data.get('init_token', '')
+ if init_token_value:
+ from apps.bootstrap.models import CertProvisionToken
+ try:
+ cert_token_obj = CertProvisionToken.objects.get(token=init_token_value)
+ if not cert_token_obj.host_id:
+ cert_token_obj.host = host
+ if cert_token_obj.cert_data:
+ cd = cert_token_obj.cert_data
+ host.cert_root = cd.get('cert_root', '')
+ host.cert_sub = cd.get('cert_sub', '')
+ host.pfx_password = cd.get('pfx_password', '')
+ host.ntlm_fallback_user = cd.get('ntlm_user', '')
+ host.ntlm_fallback_password = cd.get('ntlm_password', '')
+ host.cert_provision_status = 'ready'
+ # 根据 cert_root 和 cert_sub 计算证书路径
+ if host.cert_root and host.cert_sub:
+ from utils.cert_storage import get_cert_dir
+ cert_dir = get_cert_dir(host.cert_root, host.cert_sub)
+ host.cert_pem_path = str(cert_dir / 'client.crt')
+ host.cert_key_path = str(cert_dir / 'client.key')
+ host.save()
+ cert_token_obj.cert_data = None
+ cert_token_obj.save()
+ host.refresh_from_db()
+ except CertProvisionToken.DoesNotExist:
+ pass
- # 测试连接
try:
- host.test_connection()
- status_display = dict(Host.STATUS_CHOICES).get(
- host.status, host.status
- )
- messages.success(
- request,
- f'主机 {host.name} 创建成功,'
- f'状态: {status_display}'
- )
+ if host.auth_method == 'certificate':
+ from apps.hosts.tasks import test_winrm_connection
+ test_winrm_connection.delay(host.pk, use_certificate_auth=True)
+ messages.success(
+ request,
+ f'主机 {host.name} 创建成功,'
+ f'证书连接测试正在后台执行'
+ )
+ else:
+ host.test_connection()
+ status_display = dict(Host.STATUS_CHOICES).get(
+ host.status, host.status
+ )
+ messages.success(
+ request,
+ f'主机 {host.name} 创建成功,'
+ f'状态: {status_display}'
+ )
except Exception as e:
messages.warning(
request,
@@ -749,9 +927,15 @@ def admin_host_wizard(request):
context = {
'form': form,
'providers_with_count': providers_with_count,
- 'connection_type_choices': Host.CONNECTION_TYPE_CHOICES,
+ 'connection_type_choices': [
+ c for c in Host.CONNECTION_TYPE_CHOICES
+ if c[0] in ('winrm', 'localwinserver')
+ ],
+ 'os_type_choices': Host.OS_TYPE_CHOICES,
+ 'auth_method_choices': Host.AUTH_METHOD_CHOICES,
'default_ports': json.dumps(CONNECTION_DEFAULT_PORTS),
'default_ssl': json.dumps(CONNECTION_DEFAULT_SSL),
+ 'is_local_winserver': json.dumps(_is_local_winserver()),
'gateway_url': gateway_url,
'server_base_url': server_base_url,
'page_title': '添加主机',
@@ -765,6 +949,60 @@ def admin_host_wizard(request):
)
+@admin_required
+def admin_host_wizard_generate_init_command(request):
+ if request.method != 'POST':
+ return JsonResponse(
+ {'success': False, 'error': 'Method not allowed'},
+ status=405,
+ )
+
+ import base64, json
+ from apps.bootstrap.models import CertProvisionToken
+ from apps.bootstrap.token_utils import encode_provision_token
+
+ token_str = secrets.token_hex(32)
+ server_host = request.get_host()
+ scheme = 'https' if request.is_secure() else 'http'
+
+ ip_address = ''
+ try:
+ body = json.loads(request.body)
+ ip_address = body.get('ip_address', '')
+ except (json.JSONDecodeError, ValueError):
+ ip_address = request.POST.get('ip_address', '')
+
+ CertProvisionToken.objects.create(
+ token=token_str,
+ host=None,
+ server_host=server_host,
+ ip_address=ip_address,
+ expires_at=timezone.now() + timedelta(minutes=60),
+ status='ISSUED',
+ created_by=request.user,
+ )
+
+ encoded = encode_provision_token(token_str, scheme, server_host)
+ script_url = f"{scheme}://{server_host}/static/scripts/init.ps1"
+ one_liner = (
+ f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); "
+ f"& ([ScriptBlock]::Create($script)) {encoded}"
+ )
+ fallback_command = (
+ f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); "
+ f"& ([ScriptBlock]::Create($script)) {encoded} debug"
+ )
+
+ return JsonResponse({
+ 'success': True,
+ 'data': {
+ 'token': token_str,
+ 'one_liner': one_liner,
+ 'fallback_command': fallback_command,
+ },
+ })
+
+
@admin_required
def admin_host_wizard_generate_token(request):
if request.method != 'POST':
@@ -793,3 +1031,150 @@ def admin_host_wizard_generate_token(request):
'success': False,
'error': 'Failed to generate tunnel token',
}, status=500)
+
+
+@admin_required
+def admin_host_wizard_test_connection(request):
+ if request.method != 'POST':
+ return JsonResponse({'success': False, 'error': 'Method not allowed'}, status=405)
+
+ try:
+ data = json.loads(request.body)
+ except (json.JSONDecodeError, ValueError):
+ return JsonResponse({'success': False, 'error': '请求数据格式无效'}, status=400)
+
+ connection_type = data.get('connection_type', 'winrm')
+ hostname = data.get('hostname', '').strip()
+ port = data.get('port', 5985)
+ use_ssl = data.get('use_ssl', False)
+ auth_method = data.get('auth_method', 'ntlm')
+ username = data.get('username', '').strip()
+ password = data.get('password', '')
+
+ if not hostname:
+ return JsonResponse({'success': False, 'error': '主机地址不能为空'}, status=400)
+
+ if auth_method == 'ntlm':
+ if not username:
+ return JsonResponse({'success': False, 'error': '用户名不能为空'}, status=400)
+ if not password:
+ return JsonResponse({'success': False, 'error': '密码不能为空'}, status=400)
+
+ try:
+ if connection_type == 'localwinserver':
+ from utils.local_winserver_client import LocalWinServerClient
+ client = LocalWinServerClient(
+ username=username,
+ password=password,
+ )
+ result = client.execute_command('echo Connection Test OK')
+ elif connection_type == 'winrm' and auth_method == 'ntlm':
+ from utils.winrm_client import WinrmClient
+ client = WinrmClient(
+ hostname=hostname,
+ port=int(port),
+ username=username,
+ password=password,
+ use_ssl=bool(use_ssl),
+ auth_method='ntlm',
+ )
+ result = client.execute_command('whoami')
+ elif connection_type == 'winrm' and auth_method == 'certificate':
+ return JsonResponse({
+ 'success': False,
+ 'error': '证书认证方式请先保存主机后再测试连接',
+ })
+ else:
+ return JsonResponse({
+ 'success': False,
+ 'error': f'不支持的连接类型: {connection_type}',
+ })
+
+ if result.success:
+ output = result.std_out.strip() if result.std_out else ''
+ return JsonResponse({
+ 'success': True,
+ 'message': f'连接成功{f" ({output})" if output else ""}',
+ })
+ else:
+ error_detail = result.std_err.strip() if result.std_err else f'命令执行返回非零状态码: {result.status_code}'
+ return JsonResponse({
+ 'success': False,
+ 'error': f'连接失败: {error_detail}',
+ })
+
+ except Exception as e:
+ error_message = str(e)
+ logger.error(f"向导即时连接测试失败: {hostname}, 错误: {error_message}")
+ return JsonResponse({
+ 'success': False,
+ 'error': f'连接测试失败: {error_message}',
+ })
+
+
+@admin_required
+def admin_host_generate_init_command(request, pk):
+ host = get_object_or_404(Host, pk=pk)
+
+ if request.method != 'POST':
+ return JsonResponse(
+ {'success': False, 'error': 'Method not allowed'},
+ status=405,
+ )
+
+ try:
+ init_data = _generate_init_command_data(request, host)
+ return JsonResponse({'success': True, 'data': init_data})
+ except Exception as e:
+ return JsonResponse(
+ {'success': False, 'error': str(e)},
+ status=500,
+ )
+
+
+@login_required
+def admin_host_generate_cert_command(request):
+ if request.method != 'POST':
+ return JsonResponse({'success': False, 'error': 'Method not allowed'}, status=405)
+
+ host_id = request.POST.get('host_id')
+ host = get_object_or_404(Host, pk=host_id)
+
+ if host.auth_method != 'certificate':
+ return JsonResponse({'success': False, 'error': 'Host is not configured for certificate auth'}, status=400)
+
+ from apps.bootstrap.models import CertProvisionToken
+ from apps.bootstrap.token_utils import encode_provision_token
+
+ token_str = secrets.token_hex(32)
+ server_host = request.get_host()
+ scheme = 'https' if request.is_secure() else 'http'
+
+ provision_token = CertProvisionToken.objects.create(
+ token=token_str,
+ host=host,
+ server_host=server_host,
+ ip_address=host.hostname or '',
+ expires_at=timezone.now() + timedelta(minutes=60),
+ status='ISSUED',
+ created_by=request.user,
+ )
+
+ encoded = encode_provision_token(token_str, scheme, server_host)
+ script_url = f"{scheme}://{server_host}/static/scripts/init.ps1"
+ command = (
+ f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); "
+ f"& ([ScriptBlock]::Create($script)) {encoded}"
+ )
+ debug_command = (
+ f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); "
+ f"& ([ScriptBlock]::Create($script)) {encoded} debug"
+ )
+
+ return JsonResponse({
+ 'success': True,
+ 'command': command,
+ 'debug_command': debug_command,
+ 'token': token_str,
+ 'expires_at': provision_token.expires_at.isoformat(),
+ })
diff --git a/apps/operations/forms_wizard.py b/apps/operations/forms_wizard.py
index b3e847f..569a093 100644
--- a/apps/operations/forms_wizard.py
+++ b/apps/operations/forms_wizard.py
@@ -88,7 +88,7 @@ class Meta:
'host', 'is_available', 'auto_approval', 'visibility',
'enable_host_protection',
'display_hostname', 'rdp_port',
- 'enable_disk_quota',
+ 'enable_disk_quota', 'limit_one_per_user',
]
widgets = {
'display_name': forms.TextInput(attrs={
@@ -143,6 +143,10 @@ class Meta:
'class': _CHECKBOX_CLASS,
'x-model': 'enableDiskQuota',
}),
+ 'limit_one_per_user': forms.CheckboxInput(attrs={
+ 'class': _CHECKBOX_CLASS,
+ 'x-model': 'limitOnePerUser',
+ }),
}
labels = {
'display_name': _('显示名称'),
@@ -156,6 +160,7 @@ class Meta:
'display_hostname': _('显示地址'),
'rdp_port': _('RDP端口'),
'enable_disk_quota': _('启用磁盘配额管理'),
+ 'limit_one_per_user': _('每人限购一个'),
}
help_texts = {
'host': _('此产品运行所在的主机'),
@@ -173,6 +178,9 @@ class Meta:
'是否启用磁盘配额管理,'
'启用后将自动为新用户设置磁盘配额'
),
+ 'limit_one_per_user': _(
+ '启用后,每个用户只能拥有一个此产品'
+ ),
}
def __init__(self, *args, **kwargs):
diff --git a/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py b/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py
new file mode 100644
index 0000000..6145bf1
--- /dev/null
+++ b/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.30 on 2026-05-21 16:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('operations', '0014_encrypt_initial_password'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='rdpdomainroute',
+ name='domain',
+ field=models.CharField(help_text='分配给用户的临时RDP访问域名(仅用于显示/追踪,不再用于SNI路由)', max_length=255, unique=True, verbose_name='RDP域名'),
+ ),
+ migrations.AlterField(
+ model_name='rdpdomainroute',
+ name='tunnel_token',
+ field=models.CharField(blank=True, help_text='关联主机的隧道Token,用于RD Gateway路由', max_length=64, verbose_name='隧道Token'),
+ ),
+ ]
diff --git a/apps/operations/migrations/0016_add_product_limit_one_per_user.py b/apps/operations/migrations/0016_add_product_limit_one_per_user.py
new file mode 100644
index 0000000..5adde82
--- /dev/null
+++ b/apps/operations/migrations/0016_add_product_limit_one_per_user.py
@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('operations', '0015_alter_rdpdomainroute_domain_and_more'),
+ ]
+
+ operations = [
+ migrations.SeparateDatabaseAndState(
+ state_operations=[
+ migrations.AddField(
+ model_name='product',
+ name='limit_one_per_user',
+ field=models.BooleanField(default=False, help_text='是否限制每个用户只能拥有一个此产品', verbose_name='每人限购一个'),
+ ),
+ ],
+ database_operations=[],
+ )
+ ]
diff --git a/apps/operations/models.py b/apps/operations/models.py
index 0fc4d4d..ce1fe45 100755
--- a/apps/operations/models.py
+++ b/apps/operations/models.py
@@ -7,20 +7,14 @@
from django.conf import settings
from django.utils import timezone
from django.dispatch import Signal
+from utils.crypto import encrypt_value, decrypt_value
import logging
-import hashlib
-import base64
User = get_user_model()
logger = logging.getLogger(__name__)
-def _get_fernet():
- from cryptography.fernet import Fernet
- key = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
- return Fernet(base64.urlsafe_b64encode(key))
-
# 定义开户申请提交前的信号
account_opening_request_pre_submit = Signal()
# 定义开户申请提交后的信号
@@ -400,6 +394,12 @@ class Product(models.Model):
help_text=_('产品的可见性:公开对所有用户可见,邀请访问仅对已授权用户可见')
)
+ limit_one_per_user = models.BooleanField(
+ default=False,
+ verbose_name=_('每人限购一个'),
+ help_text=_('是否限制每个用户只能拥有一个此产品')
+ )
+
enable_disk_quota = models.BooleanField(
default=False,
verbose_name=_('启用磁盘配额管理'),
@@ -732,7 +732,11 @@ def save(self, *args, **kwargs):
if ((old_status == 'pending' and self.status == 'approved') or
(is_new_instance and auto_approved and self.status == 'approved')):
- self.auto_process_creation()
+ try:
+ from apps.operations.tasks import process_account_creation
+ process_account_creation.delay(self.pk)
+ except Exception as e:
+ logger.error(f"Failed to dispatch account creation task for request {self.pk}: {str(e)}")
def auto_process_creation(self):
"""审批通过后自动创建用户"""
@@ -781,15 +785,7 @@ def auto_process_creation(self):
# 正式模式
try:
- from utils.winrm_client import WinrmClient
-
- client = WinrmClient(
- hostname=host.hostname,
- port=host.port,
- username=host.username,
- password=host.password,
- use_ssl=host.use_ssl
- )
+ client = host.get_connection_client()
password = CloudComputerUser.generate_complex_password()
result = client.create_user(
@@ -947,14 +943,14 @@ def initial_password(self):
if not self._initial_password:
return ''
try:
- return _get_fernet().decrypt(self._initial_password.encode()).decode()
- except Exception:
+ return decrypt_value(self._initial_password)
+ except ValueError:
raise ValueError("密码解密失败,数据可能已损坏或密钥已变更")
@initial_password.setter
def initial_password(self, value):
if value:
- self._initial_password = _get_fernet().encrypt(value.encode()).decode()
+ self._initial_password = encrypt_value(value)
else:
self._initial_password = ''
password_viewed = models.BooleanField(
@@ -1026,7 +1022,7 @@ def delete_user(self):
def save(self, *args, **kwargs):
"""
- 重写save方法,当状态改变时自动执行相应操作
+ 重写save方法,当状态改变时通过Celery异步执行远程操作
"""
old_status = None
if self.pk:
@@ -1038,96 +1034,74 @@ def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if old_status is not None:
+ remote_action = None
if old_status != 'disabled' and self.status == 'disabled':
- self.disable_remote_user()
+ remote_action = 'disable'
elif old_status == 'disabled' and self.status == 'active':
- self.enable_remote_user()
+ remote_action = 'enable'
elif old_status != 'deleted' and self.status == 'deleted':
- self.delete_remote_user()
+ remote_action = 'delete'
+
+ if remote_action:
+ try:
+ from apps.operations.tasks import execute_cloud_user_remote_action
+ execute_cloud_user_remote_action.delay(self.pk, remote_action)
+ except Exception as e:
+ logger.error(f"Failed to dispatch remote action '{remote_action}' for user {self.username}: {str(e)}")
def disable_remote_user(self):
import os
if os.environ.get('2C2A_DEMO', '').lower() == '1':
- import logging
- logger = logging.getLogger(__name__)
logger.info(f'DEMO模式: 模拟禁用用户 {self.username} 在产品 {self.product.display_name}')
return
try:
- from utils.winrm_client import WinrmClient
-
product = self.product
host = product.host
- client = WinrmClient(
- hostname=host.hostname,
- port=host.port,
- username=host.username,
- password=host.password,
- use_ssl=host.use_ssl
- )
+ client = host.get_connection_client()
result = client.disabled_user(self.username)
if result.status_code != 0:
error_msg = result.std_err if result.std_err else 'Unknown error'
- print(f"Failed to disable user {self.username} on host {host.name}: {error_msg}")
+ logger.error(f"Failed to disable user {self.username} on host {host.name}: {error_msg}")
except Exception as e:
- print(f"Error disabling user {self.username} on host {host.name}: {str(e)}")
+ logger.error(f"Error disabling user {self.username} on host {host.name}: {str(e)}")
def enable_remote_user(self):
import os
if os.environ.get('2C2A_DEMO', '').lower() == '1':
- import logging
- logger = logging.getLogger(__name__)
logger.info(f'DEMO模式: 模拟启用用户 {self.username} 在产品 {self.product.display_name}')
return
try:
- from utils.winrm_client import WinrmClient
-
product = self.product
host = product.host
- client = WinrmClient(
- hostname=host.hostname,
- port=host.port,
- username=host.username,
- password=host.password,
- use_ssl=host.use_ssl
- )
+ client = host.get_connection_client()
result = client.enable_user(self.username)
if result.status_code != 0:
error_msg = result.std_err if result.std_err else 'Unknown error'
- print(f"Failed to enable user {self.username} on host {host.name}: {error_msg}")
+ logger.error(f"Failed to enable user {self.username} on host {host.name}: {error_msg}")
except Exception as e:
- print(f"Error enabling user {self.username} on host {host.name}: {str(e)}")
+ logger.error(f"Error enabling user {self.username} on host {host.name}: {str(e)}")
def delete_remote_user(self):
import os
if os.environ.get('2C2A_DEMO', '').lower() == '1':
- import logging
- logger = logging.getLogger(__name__)
logger.info(f'DEMO模式: 模拟删除用户 {self.username} 在产品 {self.product.display_name}')
return
try:
- from utils.winrm_client import WinrmClient
-
product = self.product
host = product.host
- client = WinrmClient(
- hostname=host.hostname,
- port=host.port,
- username=host.username,
- password=host.password,
- use_ssl=host.use_ssl
- )
+ client = host.get_connection_client()
result = client.delete_user(self.username)
if result.status_code != 0:
error_msg = result.std_err if result.std_err else 'Unknown error'
- print(f"Failed to delete user {self.username} on host {host.name}: {error_msg}")
+ logger.error(f"Failed to delete user {self.username} on host {host.name}: {error_msg}")
except Exception as e:
- print(f"Error deleting user {self.username} on host {host.name}: {str(e)}")
+ logger.error(f"Error deleting user {self.username} on host {host.name}: {str(e)}")
def get_and_burn_password(self):
from django.utils import timezone
@@ -1148,30 +1122,20 @@ def get_and_burn_password(self):
def reset_windows_password(self, new_password):
import os
if os.environ.get('2C2A_DEMO', '').lower() == '1':
- import logging
- logger = logging.getLogger(__name__)
logger.info(f'DEMO模式: 模拟重置用户 {self.username} 的密码')
return
try:
- from utils.winrm_client import WinrmClient
-
product = self.product
host = product.host
- client = WinrmClient(
- hostname=host.hostname,
- port=host.port,
- username=host.username,
- password=host.password,
- use_ssl=host.use_ssl
- )
+ client = host.get_connection_client()
result = client.reset_password(self.username, new_password)
if result.status_code != 0:
error_msg = result.std_err if result.std_err else 'Unknown error'
- print(f"Failed to reset password for user {self.username} on host {host.name}: {error_msg}")
+ logger.error(f"Failed to reset password for user {self.username} on host {host.name}: {error_msg}")
except Exception as e:
- print(f"Error resetting password for user {self.username} on host {host.name}: {str(e)}")
+ logger.error(f"Error resetting password for user {self.username} on host {host.name}: {str(e)}")
@staticmethod
def generate_complex_password(length=16):
@@ -1388,8 +1352,10 @@ def is_valid(self):
return self.is_active and not self.is_expired() and not self.is_exhausted()
def increment_usage(self):
- self.used_count += 1
+ from django.db.models import F
+ self.used_count = F('used_count') + 1
self.save(update_fields=['used_count', 'updated_at'])
+ self.refresh_from_db()
def generate_token(self):
import secrets
diff --git a/apps/operations/tasks.py b/apps/operations/tasks.py
index 5e0c54c..bcdfefb 100755
--- a/apps/operations/tasks.py
+++ b/apps/operations/tasks.py
@@ -64,8 +64,6 @@ def process_opening_request(self, request_id, operator_id):
message="找到可用主机"
)
- from utils.winrm_client import WinrmClient
-
username = request_obj.username
password = generate_secure_password()
@@ -78,13 +76,7 @@ def process_opening_request(self, request_id, operator_id):
message="执行PowerShell命令创建用户"
)
- client = WinrmClient(
- hostname=available_host.hostname,
- port=available_host.port,
- username=available_host.username,
- password=available_host.password,
- use_ssl=available_host.use_ssl
- )
+ client = available_host.get_connection_client()
result = client.create_user(
username=username,
@@ -170,6 +162,68 @@ def process_opening_request(self, request_id, operator_id):
}
+@shared_task(
+ bind=True,
+ max_retries=3,
+ default_retry_delay=30,
+ autoretry_for=(Exception,),
+)
+def execute_cloud_user_remote_action(self, user_id, action):
+ """
+ 异步执行云电脑用户的远程操作(禁用/启用/删除)
+ 将远程 WinRM 调用从 save() 中解耦,避免阻塞数据库事务
+ """
+ try:
+ cloud_user = CloudComputerUser.objects.get(pk=user_id)
+ except CloudComputerUser.DoesNotExist:
+ logger.error(f"CloudComputerUser with pk={user_id} does not exist, skipping remote action '{action}'")
+ return {'success': False, 'error': f'User {user_id} not found'}
+
+ if action == 'disable':
+ cloud_user.disable_remote_user()
+ elif action == 'enable':
+ cloud_user.enable_remote_user()
+ elif action == 'delete':
+ cloud_user.delete_remote_user()
+ else:
+ logger.error(f"Unknown remote action '{action}' for user {cloud_user.username}")
+ return {'success': False, 'error': f'Unknown action: {action}'}
+
+ return {'success': True, 'action': action, 'user_id': user_id}
+
+
+@shared_task(
+ bind=True,
+ max_retries=2,
+ default_retry_delay=60,
+)
+def process_account_creation(self, request_id):
+ """
+ 异步处理开户请求的用户创建流程
+ 将远程 WinRM 调用从 AccountOpeningRequest.save() 中解耦
+ """
+ try:
+ request_obj = AccountOpeningRequest.objects.get(pk=request_id)
+ except AccountOpeningRequest.DoesNotExist:
+ logger.error(f"AccountOpeningRequest with pk={request_id} does not exist")
+ return {'success': False, 'error': f'Request {request_id} not found'}
+
+ try:
+ request_obj.auto_process_creation()
+ return {'success': True, 'request_id': request_id}
+ except Exception as e:
+ logger.error(f"Account creation failed for request {request_id}: {str(e)}", exc_info=True)
+ try:
+ request_obj.refresh_from_db()
+ if request_obj.status not in ('completed', 'failed'):
+ request_obj.status = 'failed'
+ request_obj.result_message = f"异步处理异常: {str(e)}"
+ request_obj.save(update_fields=['status', 'result_message'])
+ except Exception as save_err:
+ logger.error(f"Failed to update request status: {str(save_err)}")
+ return {'success': False, 'error': str(e)}
+
+
@shared_task
def cleanup_expired_rdp_domains():
from django.utils import timezone
@@ -245,14 +299,7 @@ def rollback_opening_request(request_id):
try:
request_obj = AccountOpeningRequest.objects.get(id=request_id)
if request_obj.host and request_obj.windows_username:
- from utils.winrm_client import WinrmClient
- client = WinrmClient(
- hostname=request_obj.host.hostname,
- port=request_obj.host.port,
- username=request_obj.host.username,
- password=request_obj.host.password,
- use_ssl=request_obj.host.use_ssl
- )
+ client = request_obj.host.get_connection_client()
result = client.disabled_user(request_obj.windows_username)
@@ -285,14 +332,7 @@ def reset_user_password(self, user_id, operator_id):
new_password = generate_secure_password()
- from utils.winrm_client import WinrmClient
- client = WinrmClient(
- hostname=user.host.hostname,
- port=user.host.port,
- username=user.host.username,
- password=user.host.password,
- use_ssl=user.host.use_ssl
- )
+ client = user.host.get_connection_client()
result = client.reset_password(user.windows_username, new_password)
@@ -411,14 +451,7 @@ def cleanup_inactive_users(self, days_inactive=30):
cleaned_count = 0
for user in inactive_users:
- from utils.winrm_client import WinrmClient
- client = WinrmClient(
- hostname=user.host.hostname,
- port=user.host.port,
- username=user.host.username,
- password=user.host.password,
- use_ssl=user.host.use_ssl
- )
+ client = user.host.get_connection_client()
result = client.disabled_user(user.windows_username)
diff --git a/apps/operations/views.py b/apps/operations/views.py
index 36ae905..11e5c60 100755
--- a/apps/operations/views.py
+++ b/apps/operations/views.py
@@ -524,6 +524,8 @@ def product_invite_view(request, token):
产品邀请链接视图
用户访问邀请链接后,解锁对应产品或产品组的访问权限
+ GET: 显示邀请信息和确认页面
+ POST: 确认接受邀请,执行授权操作
"""
from django.shortcuts import render, redirect
from django.contrib import messages
@@ -542,7 +544,6 @@ def product_invite_view(request, token):
'message': '邀请链接无效或不存在。',
})
- # 校验令牌状态
if not invite_token.is_valid():
if invite_token.is_expired():
return render(request, 'operations/invite_result.html', {
@@ -559,12 +560,10 @@ def product_invite_view(request, token):
'message': '邀请链接已被禁用。',
})
- # 如果用户未登录,重定向到登录页
if not request.user.is_authenticated:
login_url = reverse('accounts:login') + f'?next={request.path}'
return redirect(login_url)
- # 检查是否已有授权
existing_grant = ProductAccessGrant.objects.filter(
user=request.user,
product=invite_token.product,
@@ -580,7 +579,21 @@ def product_invite_view(request, token):
'product_group': invite_token.product_group,
})
- # 创建授权记录
+ if request.method == 'GET':
+ target_name = (
+ invite_token.product.display_name
+ if invite_token.product
+ else (invite_token.product_group.name if invite_token.product_group else '未知')
+ )
+ return render(request, 'operations/invite_result.html', {
+ 'success': None,
+ 'message': f'您即将解锁 "{target_name}" 的访问权限。',
+ 'product': invite_token.product,
+ 'product_group': invite_token.product_group,
+ 'needs_confirm': True,
+ 'token': token,
+ })
+
try:
grant, created = ProductAccessGrant.objects.get_or_create(
user=request.user,
@@ -592,7 +605,6 @@ def product_invite_view(request, token):
}
)
if not created:
- # 如果记录已存在但被撤销或过期,重新激活
if grant.is_revoked or grant.is_expired():
grant.is_revoked = False
grant.revoked_at = None
@@ -610,7 +622,6 @@ def product_invite_view(request, token):
'message': '授权处理失败,请稍后重试。',
})
- # 更新令牌使用次数
invite_token.increment_usage()
target_name = (
diff --git a/apps/operations/views_admin.py b/apps/operations/views_admin.py
index 4373529..f618b9d 100644
--- a/apps/operations/views_admin.py
+++ b/apps/operations/views_admin.py
@@ -1095,13 +1095,8 @@ def admin_cloud_user_action(request, pk):
cloud_user.is_admin = True
cloud_user.save(update_fields=['is_admin', 'updated_at'])
try:
- from utils.winrm_client import WinrmClient
host = cloud_user.product.host
- client = WinrmClient(
- hostname=host.hostname, port=host.port,
- username=host.username, password=host.password,
- use_ssl=host.use_ssl,
- )
+ client = host.get_connection_client()
client.op_user(cloud_user.username)
except Exception as e:
logger.error(f'远程设置管理员失败: {e}')
@@ -1117,13 +1112,8 @@ def admin_cloud_user_action(request, pk):
cloud_user.is_admin = False
cloud_user.save(update_fields=['is_admin', 'updated_at'])
try:
- from utils.winrm_client import WinrmClient
host = cloud_user.product.host
- client = WinrmClient(
- hostname=host.hostname, port=host.port,
- username=host.username, password=host.password,
- use_ssl=host.use_ssl,
- )
+ client = host.get_connection_client()
client.deop_user(cloud_user.username)
except Exception as e:
logger.error(f'远程取消管理员失败: {e}')
@@ -1193,13 +1183,8 @@ def admin_cloud_user_set_quota(request, pk):
try:
from utils.disk_quota import set_disk_quota_via_client
- from utils.winrm_client import WinrmClient
host = cloud_user.product.host
- client = WinrmClient(
- hostname=host.hostname, port=host.port,
- username=host.username, password=host.password,
- use_ssl=host.use_ssl,
- )
+ client = host.get_connection_client()
result = set_disk_quota_via_client(client, cloud_user.username, disk, quota_mb)
if not result['success']:
return JsonResponse({'success': False, 'message': f'远程设置配额失败: {result["message"]}'})
diff --git a/celerybeat-schedule b/celerybeat-schedule
new file mode 100644
index 0000000..4abb835
Binary files /dev/null and b/celerybeat-schedule differ
diff --git a/config/__init__.py b/config/__init__.py
index 70ed68d..9d284a2 100755
--- a/config/__init__.py
+++ b/config/__init__.py
@@ -1,3 +1,6 @@
"""
2c2a配置模块
"""
+from .celery import app as celery_app # noqa: E402,F401
+
+__all__ = ('celery_app',)
diff --git a/config/celery.py b/config/celery.py
index 2956c7e..c62047f 100755
--- a/config/celery.py
+++ b/config/celery.py
@@ -1,26 +1,50 @@
import os
from celery import Celery
-from django.conf import settings
+from celery.schedules import crontab
-# 设置Django环境
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('2c2a')
app.config_from_object('django.conf:settings', namespace='CELERY')
-# 自动发现任务
+from django.conf import settings # noqa: E402
+
+_redis_url = getattr(settings, 'REDIS_URL', '')
+_base_dir = str(settings.BASE_DIR)
+
+if not app.conf.broker_url:
+ _broker = getattr(settings, 'CELERY_BROKER_URL', None)
+ if _broker:
+ app.conf.broker_url = _broker
+ elif _redis_url:
+ app.conf.broker_url = _redis_url.replace('/0', '/1')
+ else:
+ app.conf.broker_url = (
+ f'sqla+sqlite:///{_base_dir}/celery_broker.sqlite3'
+ )
+
+if not app.conf.result_backend:
+ _result = getattr(settings, 'CELERY_RESULT_BACKEND', None)
+ if _result:
+ app.conf.result_backend = _result
+ elif _redis_url:
+ app.conf.result_backend = _redis_url.replace('/0', '/2')
+ else:
+ app.conf.result_backend = (
+ f'db+sqlite:///{_base_dir}/celery_results.sqlite3'
+ )
+
app.autodiscover_tasks()
-# 任务序列化配置
app.conf.update(
task_serializer='json',
accept_content=['json'],
result_serializer='json',
timezone='UTC',
enable_utc=True,
+ broker_connection_retry_on_startup=True,
)
-# 队列配置
app.conf.task_routes = {
'certificates.tasks.*': {'queue': 'certificates'},
'hosts.tasks.*': {'queue': 'hosts'},
@@ -29,6 +53,20 @@
'plugins.beta_push.tasks.*': {'queue': 'beta_push'},
}
-# 任务重试配置
-app.conf.task_default_retry_delay = 30 # 默认重试延迟30秒
-app.conf.task_max_retries = 3 # 最大重试次数3次
\ No newline at end of file
+app.conf.task_default_retry_delay = 30
+app.conf.task_max_retries = 3
+
+app.conf.CELERY_BEAT_SCHEDULE = {
+ 'cleanup-expired-provision-tokens': {
+ 'task': 'apps.bootstrap.tasks.cleanup_expired_provision_tokens',
+ 'schedule': crontab(hour='0', minute='0'),
+ },
+ 'cleanup-unactivated-certificates': {
+ 'task': 'apps.bootstrap.tasks.cleanup_unactivated_certificates',
+ 'schedule': crontab(hour='0', minute='0'),
+ },
+ 'cleanup-orphan-cert-dirs': {
+ 'task': 'apps.bootstrap.tasks.cleanup_orphan_cert_dirs',
+ 'schedule': crontab(hour='0', minute='0'),
+ },
+}
diff --git a/config/security_middleware.py b/config/security_middleware.py
index 250e71a..05c7e33 100644
--- a/config/security_middleware.py
+++ b/config/security_middleware.py
@@ -10,32 +10,15 @@ def __call__(self, request):
if not settings.DEBUG:
csp_parts = [
"default-src 'self'",
-
- # JS:极验 + 你的静态站
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "
- "https://static.2c2a.cc.cd "
- "https://static.geetest.com https://static.geevisit.com "
- "https://gcaptcha4.geetest.com https://gcaptcha4.geevisit.com",
-
- # CSS:极验 + 你的静态站(关键:之前这里没加极验域名,所以 CSS 被拦)
+ "https://static.2c2a.cc.cd",
"style-src 'self' 'unsafe-inline' "
- "https://static.2c2a.cc.cd "
- "https://static.geetest.com https://static.geevisit.com",
-
- # 图片:极验 + 你的静态站
+ "https://static.2c2a.cc.cd",
"img-src 'self' data: blob: "
- "https://static.2c2a.cc.cd "
- "https://static.geetest.com https://static.geevisit.com",
-
- # 字体:极验 + 你的静态站(极验也用到了字体)
+ "https://static.2c2a.cc.cd",
"font-src 'self' "
- "https://static.2c2a.cc.cd "
- "https://static.geetest.com https://static.geevisit.com",
-
- # AJAX / WebSocket:极验接口
- "connect-src 'self' wss: ws: "
- "https://gcaptcha4.geetest.com https://gcaptcha4.geevisit.com",
-
+ "https://static.2c2a.cc.cd",
+ "connect-src 'self' wss://rdp.2c2a.com ws://rdp.2c2a.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
diff --git a/config/settings.py b/config/settings.py
index a8f6b5a..ab62984 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -132,6 +132,8 @@ def _env(key, default=None):
'django_cotton',
# 本地应用
+ 'django_tianai_captcha',
+
'apps.accounts',
'apps.hosts',
'apps.operations',
@@ -190,12 +192,12 @@ def _discover_plugin_apps():
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
- 'config.maintenance_middleware.MaintenanceModeMiddleware',
- 'config.local_lock_middleware.LocalLockMiddleware',
- 'config.security_middleware.SecurityHeadersMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
+ 'config.maintenance_middleware.MaintenanceModeMiddleware',
+ 'config.local_lock_middleware.LocalLockMiddleware',
+ 'config.security_middleware.SecurityHeadersMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
@@ -553,3 +555,27 @@ def _check_redis_available():
_bootstrap_logging.getLogger('2c2a').warning(
'BOOTSTRAP_SHARED_SALT 未设置,建议在生产环境中配置此值以增强引导认证安全性'
)
+
+CAPTCHA = {
+ "PREFIX": "captcha",
+ "EXPIRE": {
+ "default": 120,
+ "WORD_IMAGE_CLICK": 180,
+ },
+ "INIT_DEFAULT_RESOURCE": True,
+ "CACHE_BACKEND": "redis" if REDIS_ENABLED else "local",
+ "REDIS_URL": REDIS_URL if REDIS_ENABLED else "",
+ "DEFAULT_TYPE": "SLIDER",
+ "TOLERANT": 0.02,
+ "TRACK_VALIDATION_ENABLED": True,
+ "SECONDARY": {
+ "ENABLED": True,
+ "EXPIRE": 120,
+ "KEY_PREFIX": "captcha:secondary",
+ },
+ "RATE_LIMIT": {
+ "ENABLED": True,
+ "RATE": 10,
+ "PERIOD": 60,
+ },
+}
diff --git a/config/urls.py b/config/urls.py
index b7cf15e..1616acc 100755
--- a/config/urls.py
+++ b/config/urls.py
@@ -19,6 +19,7 @@
path('audit/', include('apps.audit.urls')),
path('tunnel/', include('apps.tunnel.urls')),
path('tickets/', include('apps.tickets.urls')),
+ path('captcha/', include('django_tianai_captcha.urls')),
path('docs/', views.docs_index, name='docs_index'),
path('', include('apps.dashboard.urls')),
path('404/', TemplateView.as_view(template_name='errors/404.html'), name='404'),
diff --git a/plugins/beta_push/apps.py b/plugins/beta_push/apps.py
index 31979f4..6c3389c 100644
--- a/plugins/beta_push/apps.py
+++ b/plugins/beta_push/apps.py
@@ -26,33 +26,22 @@ def _configure_beta_database(self):
default_db = settings.DATABASES.get('default', {})
engine = default_db.get('ENGINE', '')
- if engine not in (
- 'django.db.backends.mysql',
- 'django.db.backends.postgresql',
- ):
+ if engine != 'django.db.backends.postgresql':
logger.warning(
- 'Beta推送插件仅支持MySQL/PostgreSQL架构,'
- '当前默认数据库引擎不是受支持的引擎'
+ 'Beta推送插件仅支持PostgreSQL架构,'
+ '当前默认数据库引擎不是PostgreSQL'
)
return
beta_db = {
+ 'ENGINE': 'django.db.backends.postgresql',
'NAME': beta_db_name,
'USER': os.environ.get('BETA_DB_USER', default_db.get('USER', '')),
'PASSWORD': os.environ.get('BETA_DB_PASSWORD', default_db.get('PASSWORD', '')),
'HOST': os.environ.get('BETA_DB_HOST', default_db.get('HOST', '127.0.0.1')),
- 'PORT': os.environ.get('BETA_DB_PORT', default_db.get('PORT', '3306')),
+ 'PORT': os.environ.get('BETA_DB_PORT', default_db.get('PORT', '5432')),
'CONN_MAX_AGE': int(os.environ.get('BETA_DB_CONN_MAX_AGE', '60')),
}
- if engine == 'django.db.backends.mysql':
- beta_db['ENGINE'] = 'django.db.backends.mysql'
- beta_db['OPTIONS'] = {
- 'charset': 'utf8mb4',
- 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'",
- }
- elif engine == 'django.db.backends.postgresql':
- beta_db['ENGINE'] = 'django.db.backends.postgresql'
-
settings.DATABASES['beta'] = beta_db
logger.info(f'Beta数据库已配置: {beta_db_name}')
diff --git a/plugins/beta_push/services.py b/plugins/beta_push/services.py
index f0625f9..8af65a8 100644
--- a/plugins/beta_push/services.py
+++ b/plugins/beta_push/services.py
@@ -1,9 +1,10 @@
import logging
+import os
from collections import OrderedDict
from django.conf import settings
from django.contrib.auth import get_user_model
-from django.db import models
+from django.db import connections, models, IntegrityError
from django.utils import timezone
import redis as redis_lib
@@ -15,6 +16,11 @@
REDIS_KEY_PREFIX = 'beta_push:progress'
+ENCRYPTED_FIELDS = {
+ 'hosts.Host._password',
+ 'operations.CloudComputerUser._initial_password',
+}
+
def _get_redis():
url = getattr(settings, 'REDIS_URL', '')
@@ -55,7 +61,41 @@ def get_progress(task_id):
return None
+def _get_fernet(secret_key):
+ try:
+ from cryptography.fernet import Fernet
+ import base64
+ import hashlib
+ key = hashlib.sha256(secret_key.encode()).digest()
+ return Fernet(base64.urlsafe_b64encode(key))
+ except ImportError:
+ return None
+
+
+def _re_encrypt_value(encrypted_value, model_label, field_name):
+ beta_secret_key = os.environ.get('BETA_SECRET_KEY', '')
+ if not beta_secret_key or not encrypted_value:
+ return encrypted_value
+
+ field_key = f'{model_label}.{field_name}'
+ if field_key not in ENCRYPTED_FIELDS:
+ return encrypted_value
+
+ prod_fernet = _get_fernet(settings.SECRET_KEY)
+ beta_fernet = _get_fernet(beta_secret_key)
+ if not prod_fernet or not beta_fernet:
+ return encrypted_value
+
+ try:
+ plaintext = prod_fernet.decrypt(encrypted_value.encode()).decode()
+ return beta_fernet.encrypt(plaintext.encode()).decode()
+ except Exception as e:
+ logger.warning(f'重加密失败 [{field_key}]: {e}')
+ return encrypted_value
+
+
class BetaPushService:
+ _beta_schema_cache = {}
def __init__(self, user_id, task_id=''):
self.user_id = user_id
@@ -68,6 +108,7 @@ def __init__(self, user_id, task_id=''):
'errors': [],
}
self._synced_pks = {}
+ self._missing_tables = set()
self.last_sync_at = self._get_last_sync_at()
def _get_last_sync_at(self):
@@ -82,6 +123,8 @@ def _get_last_sync_at(self):
return None
def push_all(self):
+ self.__class__._beta_schema_cache.clear()
+
steps = [
('用户信息', self._push_user),
('用户资料', self._push_user_profile),
@@ -122,6 +165,35 @@ def _is_changed(self, instance):
return True
return False
+ def _get_beta_table_info(self, model):
+ table_name = model._meta.db_table
+ if table_name in self._beta_schema_cache:
+ return self._beta_schema_cache[table_name]
+
+ info = {'exists': False, 'columns': set(), 'not_null_no_default': set()}
+
+ try:
+ with connections[BETA_DB].cursor() as cursor:
+ cursor.execute(
+ "SELECT column_name, is_nullable, column_default "
+ "FROM information_schema.columns "
+ "WHERE table_schema = 'public' AND table_name = %s",
+ [table_name]
+ )
+ rows = cursor.fetchall()
+
+ if rows:
+ info['exists'] = True
+ for col_name, is_nullable, col_default in rows:
+ info['columns'].add(col_name)
+ if is_nullable == 'NO' and col_default is None:
+ info['not_null_no_default'].add(col_name)
+ except Exception as e:
+ logger.error(f'查询Beta数据库表结构失败 [{table_name}]: {e}')
+
+ self._beta_schema_cache[table_name] = info
+ return info
+
def _sync_instance(self, instance):
model = instance.__class__
model_label = f'{model._meta.app_label}.{model.__name__}'
@@ -131,12 +203,24 @@ def _sync_instance(self, instance):
self.stats['skipped'] += 1
return True
+ table_info = self._get_beta_table_info(model)
+ if not table_info['exists']:
+ if model._meta.db_table not in self._missing_tables:
+ self.stats['errors'].append(
+ f'{model.__name__}: Beta数据库中表 {model._meta.db_table} 不存在,已跳过'
+ )
+ self._missing_tables.add(model._meta.db_table)
+ self._synced_pks.setdefault(model_label, set()).add(pk)
+ self.stats['skipped'] += 1
+ return True
+
if not self._is_changed(instance):
if model.objects.using(BETA_DB).filter(pk=pk).exists():
self._synced_pks.setdefault(model_label, set()).add(pk)
self.stats['skipped'] += 1
return True
+ beta_columns = table_info['columns']
field_values = {}
m2m_values = OrderedDict()
@@ -155,6 +239,9 @@ def _sync_instance(self, instance):
if not hasattr(instance, field.attname):
continue
+ if not hasattr(field, 'column') or field.column not in beta_columns:
+ continue
+
value = getattr(instance, field.attname)
if isinstance(field, models.ForeignKey):
@@ -166,6 +253,8 @@ def _sync_instance(self, instance):
except Exception:
pass
+ value = _re_encrypt_value(value, model_label, field.name)
+
field_values[field.name] = value
try:
@@ -173,34 +262,68 @@ def _sync_instance(self, instance):
pk=pk,
defaults=field_values,
)
-
- for field_name, related_pks in m2m_values.items():
+ except IntegrityError as e:
+ logger.warning(f'IntegrityError [{model.__name__}:{pk}]: {e}')
+ if model.objects.using(BETA_DB).filter(pk=pk).exists():
try:
- m2m_model = model._meta.get_field(field_name).related_model
- existing_pks = set(
- m2m_model.objects.using(BETA_DB).filter(
- pk__in=related_pks
- ).values_list('pk', flat=True)
+ model.objects.using(BETA_DB).filter(pk=pk).update(**field_values)
+ obj = model.objects.using(BETA_DB).get(pk=pk)
+ except Exception as e2:
+ self.stats['failed'] += 1
+ self.stats['errors'].append(
+ f'{model.__name__}:{pk} - 更新失败: {str(e2)}'
)
- m2m_manager = getattr(obj, field_name)
- m2m_manager.set(existing_pks)
- except Exception as e:
- logger.warning(f'M2M同步失败 [{model.__name__}.{field_name}]: {e}')
+ return False
+ else:
+ prod_cols = {
+ f.column for f in model._meta.concrete_fields if hasattr(f, 'column')
+ }
+ missing = table_info['not_null_no_default'] - prod_cols
+ hint = (
+ f'Beta数据库存在额外NOT NULL无默认值列: {missing}'
+ if missing else str(e)
+ )
+ self.stats['failed'] += 1
+ self.stats['errors'].append(
+ f'{model.__name__}:{pk} - 创建失败: {hint}'
+ )
+ return False
- self._synced_pks.setdefault(model_label, set()).add(pk)
- self.stats['pushed'] += 1
- return True
- except Exception as e:
- logger.error(f'同步实例失败 [{model.__name__}:{pk}]: {e}', exc_info=True)
- self.stats['failed'] += 1
- self.stats['errors'].append(f'{model.__name__}:{pk} - {str(e)}')
- return False
+ for field_name, related_pks in m2m_values.items():
+ try:
+ m2m_field = model._meta.get_field(field_name)
+ m2m_through_info = self._get_beta_table_info(
+ m2m_field.remote_field.through
+ )
+ if not m2m_through_info['exists']:
+ logger.warning(
+ f'M2M中间表不存在于Beta数据库 [{model.__name__}.{field_name}]'
+ )
+ continue
+
+ m2m_model = m2m_field.related_model
+ existing_pks = set(
+ m2m_model.objects.using(BETA_DB).filter(
+ pk__in=related_pks
+ ).values_list('pk', flat=True)
+ )
+ m2m_manager = getattr(obj, field_name)
+ m2m_manager.set(existing_pks)
+ except Exception as e:
+ logger.warning(f'M2M同步失败 [{model.__name__}.{field_name}]: {e}')
+
+ self._synced_pks.setdefault(model_label, set()).add(pk)
+ self.stats['pushed'] += 1
+ return True
def _ensure_stub_exists(self, related_instance):
model = related_instance.__class__
model_label = f'{model._meta.app_label}.{model.__name__}'
pk = related_instance.pk
+ if model._meta.db_table in self._missing_tables:
+ return
+
if model_label in self._synced_pks and pk in self._synced_pks[model_label]:
return
diff --git a/plugins/beta_push/templates/beta_push/dashboard.html b/plugins/beta_push/templates/beta_push/dashboard.html
index e260c3a..c4853a3 100644
--- a/plugins/beta_push/templates/beta_push/dashboard.html
+++ b/plugins/beta_push/templates/beta_push/dashboard.html
@@ -26,10 +26,10 @@ Beta数据库未配置
请在环境变量中配置以下参数后重启服务:
BETA_DB_NAME=zasca_beta
-
BETA_DB_USER=root
+
BETA_DB_USER=postgres
BETA_DB_PASSWORD=your_password
BETA_DB_HOST=127.0.0.1
-
BETA_DB_PORT=3306
+
BETA_DB_PORT=5432
diff --git a/plugins/management/commands/plugin.py b/plugins/management/commands/plugin.py
index cfe49f8..a4768ec 100755
--- a/plugins/management/commands/plugin.py
+++ b/plugins/management/commands/plugin.py
@@ -1,6 +1,7 @@
"""
插件管理命令
提供类似 pip 的插件管理功能,支持安装、卸载、搜索、登录等操作
+支持从文件安装:将插件目录解压到 plugins/ 目录后,使用 scan 发现并 install 安装
"""
import os
import sys
@@ -9,6 +10,8 @@
import re
import toml
import inspect
+import zipfile
+import tempfile
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from plugins.core.plugin_manager import get_plugin_manager
@@ -29,8 +32,8 @@ class Command(BaseCommand):
help = '插件管理命令,类似 pip 的功能'
def add_arguments(self, parser):
- parser.add_argument('action', type=str, help='操作类型: install, upgrade, uninstall, list, info, search, login, enable, disable')
- parser.add_argument('plugin_name', nargs='?', type=str, help='插件名称或本地路径')
+ parser.add_argument('action', type=str, help='操作类型: install, upgrade, uninstall, list, info, search, scan, login, enable, disable')
+ parser.add_argument('plugin_name', nargs='?', type=str, help='插件名称、本地路径或zip文件路径')
parser.add_argument('--source', type=str, help='插件源地址或本地路径')
parser.add_argument('--force', action='store_true', help='强制执行操作')
parser.add_argument('--no-migrate', action='store_true', help='跳过数据库迁移')
@@ -38,6 +41,7 @@ def add_arguments(self, parser):
parser.add_argument('--registry', type=str, default=PLUGIN_REGISTRY_URL, help='插件仓库地址')
parser.add_argument('--force-github', action='store_true', help='强制使用 GitHub 插件仓库')
parser.add_argument('--force-gitee', action='store_true', help='强制使用 Gitee 插件仓库镜像')
+ parser.add_argument('--install-all', action='store_true', help='与 scan 配合使用,安装所有发现的未注册插件')
def handle(self, *args, **options):
action = options['action']
@@ -53,9 +57,14 @@ def handle(self, *args, **options):
if action == 'list':
self.list_plugins()
+ elif action == 'scan':
+ self.scan_plugins(
+ install_all=options.get('install_all', False),
+ no_migrate=no_migrate,
+ )
elif action == 'install':
if not plugin_name:
- raise CommandError('安装插件需要指定插件名称或路径')
+ raise CommandError('安装插件需要指定插件名称、路径或zip文件')
self.install_plugin(
plugin_name,
options.get('source'),
@@ -96,7 +105,7 @@ def handle(self, *args, **options):
raise CommandError(
f'未知的操作: {action}. '
f'支持的操作: install, upgrade, uninstall, '
- f'list, info, search, login, enable, disable'
+ f'list, info, search, scan, login, enable, disable'
)
def _fetch_registry(self, registry_url=None):
@@ -175,6 +184,201 @@ def search_plugins(self, keyword='', registry_url=None):
self.stdout.write(f' 版本: {info.get("version", "N/A")}')
self.stdout.write('')
+ def scan_plugins(self, install_all=False, no_migrate=False):
+ plugins_base = os.path.join(settings.BASE_DIR, 'plugins')
+ if not os.path.isdir(plugins_base):
+ raise CommandError(f'插件目录不存在: {plugins_base}')
+
+ registered_ids = set(ALL_AVAILABLE_PLUGINS.keys())
+ db_ids = set(
+ PluginRecord.objects.values_list('plugin_id', flat=True)
+ )
+ loaded_plugins = get_plugin_manager().get_all_plugins()
+ loaded_ids = set(loaded_plugins.keys())
+
+ skip_dirs = {'core', 'management', 'templatetags', 'migrations',
+ '__pycache__', '.git'}
+
+ discovered = []
+
+ for entry in sorted(os.listdir(plugins_base)):
+ entry_path = os.path.join(plugins_base, entry)
+ if not os.path.isdir(entry_path):
+ continue
+ if entry in skip_dirs:
+ continue
+ if entry.startswith('.') or entry.startswith('_'):
+ continue
+ init_file = os.path.join(entry_path, '__init__.py')
+ if not os.path.exists(init_file):
+ continue
+
+ has_plugin_class = self._detect_plugin_class(entry, entry_path)
+ if not has_plugin_class:
+ continue
+
+ is_registered = entry in registered_ids
+ is_in_db = entry in db_ids
+ is_loaded = entry in loaded_ids
+
+ if is_registered or is_in_db or is_loaded:
+ continue
+
+ plugin_class, plugin_module_name = self._load_plugin_class_from_package(
+ entry, entry_path
+ )
+ plugin_name = entry
+ plugin_version = '0.0.0'
+ plugin_desc = ''
+ if plugin_class:
+ try:
+ inst = plugin_class()
+ plugin_name = inst.name
+ plugin_version = inst.version
+ plugin_desc = inst.description
+ except Exception as exc:
+ self.stderr.write(
+ f"Warning: failed to read metadata from plugin '{entry}': {exc}"
+ )
+
+ discovered.append({
+ 'dir_name': entry,
+ 'path': entry_path,
+ 'name': plugin_name,
+ 'version': plugin_version,
+ 'description': plugin_desc,
+ 'plugin_class': plugin_class,
+ 'plugin_module_name': plugin_module_name,
+ })
+
+ if not discovered:
+ self.stdout.write(self.style.SUCCESS('没有发现未注册的插件'))
+ return
+
+ self.stdout.write(self.style.SUCCESS(
+ f'发现 {len(discovered)} 个未注册的插件:'
+ ))
+ self.stdout.write('')
+ for info in discovered:
+ self.stdout.write(f' {self.style.SUCCESS(info["dir_name"])}')
+ self.stdout.write(f' 名称: {info["name"]}')
+ self.stdout.write(f' 版本: {info["version"]}')
+ self.stdout.write(f' 描述: {info["description"]}')
+ self.stdout.write(f' 路径: {info["path"]}')
+ self.stdout.write('')
+
+ if install_all:
+ self.stdout.write('正在安装所有发现的插件...')
+ for info in discovered:
+ try:
+ app_label = self._register_discovered_plugin(
+ info, no_migrate=no_migrate
+ )
+ self.stdout.write(self.style.SUCCESS(
+ f' ✓ {info["name"]} 安装完成'
+ ))
+ except Exception as e:
+ self.stdout.write(self.style.ERROR(
+ f' ✗ {info["dir_name"]} 安装失败: {str(e)}'
+ ))
+ else:
+ self.stdout.write(
+ '使用 "uv run python manage.py plugin install <目录名>" '
+ '安装单个插件\n'
+ '使用 "uv run python manage.py plugin scan --install-all" '
+ '安装所有发现的插件'
+ )
+
+ def _detect_plugin_class(self, dir_name, dir_path):
+ init_file = os.path.join(dir_path, '__init__.py')
+ if os.path.exists(init_file):
+ try:
+ with open(init_file, 'r', encoding='utf-8') as f:
+ content = f.read()
+ if re.search(
+ r'class\s+\w+\s*\([^)]*PluginInterface',
+ content, re.DOTALL
+ ):
+ return True
+ if 'PLUGIN_INFO' in content:
+ return True
+ except Exception as e:
+ self.stdout.write(
+ self.style.WARNING(
+ f'读取插件 {dir_name} 的 __init__.py 失败: {e}'
+ )
+ )
+
+ for item in os.listdir(dir_path):
+ if not item.endswith('.py') or item == '__init__.py':
+ continue
+ fp = os.path.join(dir_path, item)
+ try:
+ with open(fp, 'r', encoding='utf-8') as f:
+ content = f.read()
+ if re.search(
+ r'class\s+\w+\s*\([^)]*PluginInterface',
+ content, re.DOTALL
+ ):
+ return True
+ except Exception:
+ continue
+
+ return False
+
+ def _register_discovered_plugin(self, info, no_migrate=False):
+ plugin_class = info['plugin_class']
+ plugin_module_name = info['plugin_module_name']
+ dir_name = info['dir_name']
+
+ if not plugin_class:
+ plugin_class, plugin_module_name = self._load_plugin_class_from_package(
+ dir_name, info['path']
+ )
+
+ if not plugin_class:
+ raise CommandError(
+ f'在 {info["path"]} 中未找到有效的插件类'
+ )
+
+ plugin_instance = plugin_class()
+ plugin_manager = get_plugin_manager()
+ plugin_manager.plugins[plugin_instance.plugin_id] = plugin_instance
+
+ try:
+ plugin_instance.initialize()
+ except Exception as e:
+ self.stdout.write(
+ self.style.WARNING(
+ f'插件 {plugin_instance.plugin_id} 初始化失败,已继续安装流程: {e}'
+ )
+ )
+
+ PluginRecord.objects.update_or_create(
+ plugin_id=plugin_instance.plugin_id,
+ defaults={
+ 'name': plugin_instance.name,
+ 'version': plugin_instance.version,
+ 'description': plugin_instance.description,
+ 'is_active': True,
+ }
+ )
+
+ self.add_plugin_to_toml_config(plugin_instance.plugin_id, {
+ 'name': plugin_instance.name,
+ 'module': plugin_module_name,
+ 'class': plugin_class.__name__,
+ 'description': plugin_instance.description,
+ 'version': plugin_instance.version,
+ 'enabled': True
+ })
+
+ app_label = self._get_app_label_from_module(plugin_module_name)
+ if app_label and not no_migrate:
+ self._run_migrate(app_label)
+
+ return app_label
+
def login_github(self):
self.stdout.write('正在检查 GitHub CLI 认证状态...')
try:
@@ -213,7 +417,11 @@ def install_plugin(self, plugin_name, source=None, force=False, registry_url=Non
app_label = None
- if os.path.exists(plugin_name) and os.path.isdir(plugin_name):
+ if plugin_name.endswith('.zip'):
+ if not os.path.exists(plugin_name):
+ raise CommandError(f'zip 文件不存在: {plugin_name}')
+ app_label = self.install_from_zip(plugin_name, force=force, no_migrate=no_migrate)
+ elif os.path.exists(plugin_name) and os.path.isdir(plugin_name):
app_label = self.install_from_path(plugin_name)
else:
plugin_path = os.path.join(
@@ -236,7 +444,9 @@ def install_plugin(self, plugin_name, source=None, force=False, registry_url=Non
found = True
break
if not found:
- if source and os.path.exists(source) and os.path.isdir(source):
+ if source and source.endswith('.zip') and os.path.exists(source):
+ app_label = self.install_from_zip(source, force=force, no_migrate=no_migrate)
+ elif source and os.path.exists(source) and os.path.isdir(source):
app_label = self.install_from_path(source)
else:
app_label = self.install_from_registry(
@@ -246,6 +456,111 @@ def install_plugin(self, plugin_name, source=None, force=False, registry_url=Non
if app_label and not no_migrate:
self._run_migrate(app_label)
+ def install_from_zip(self, zip_path, force=False, no_migrate=False):
+ if not zipfile.is_zipfile(zip_path):
+ raise CommandError(f'不是有效的 zip 文件: {zip_path}')
+
+ plugins_base = os.path.join(settings.BASE_DIR, 'plugins')
+ zip_basename = os.path.basename(zip_path)
+ plugin_dir_name = os.path.splitext(zip_basename)[0]
+
+ with zipfile.ZipFile(zip_path, 'r') as zf:
+ top_dirs = set()
+ for name in zf.namelist():
+ parts = name.split('/')
+ if len(parts) > 1 and parts[0]:
+ top_dirs.add(parts[0])
+
+ if len(top_dirs) == 1:
+ single_dir = top_dirs.pop()
+ if single_dir == plugin_dir_name or single_dir.replace('-', '_') == plugin_dir_name:
+ plugin_dir_name = single_dir
+
+ if len(top_dirs) == 1:
+ single_dir = list(top_dirs)[0] if not plugin_dir_name else plugin_dir_name
+ has_init = any(
+ n == f'{single_dir}/__init__.py' or n.startswith(f'{single_dir}/')
+ for n in zf.namelist()
+ if n.endswith('__init__.py')
+ )
+ if not has_init:
+ nested = [n for n in zf.namelist()
+ if n.startswith(f'{single_dir}/') and n.endswith('/')]
+ if nested:
+ for sub in nested:
+ sub_name = sub.rstrip('/').split('/')[-1]
+ sub_init = f'{single_dir}/{sub_name}/__init__.py'
+ if sub_init in zf.namelist():
+ plugin_dir_name = sub_name
+ break
+
+ target_dir = os.path.join(plugins_base, plugin_dir_name)
+ if os.path.exists(target_dir):
+ if force:
+ shutil.rmtree(target_dir)
+ else:
+ raise CommandError(
+ f'插件目录已存在: {target_dir}\n'
+ f'使用 --force 强制重新安装'
+ )
+
+ self.stdout.write(f'正在从 zip 文件解压插件: {zip_path}')
+
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ with zipfile.ZipFile(zip_path, 'r') as zf:
+ zf.extractall(tmp_dir)
+
+ extracted_items = os.listdir(tmp_dir)
+ if len(extracted_items) == 1 and os.path.isdir(
+ os.path.join(tmp_dir, extracted_items[0])
+ ):
+ src_dir = os.path.join(tmp_dir, extracted_items[0])
+ inner_items = os.listdir(src_dir)
+ has_init = '__init__.py' in inner_items
+ if has_init:
+ plugin_dir_name = extracted_items[0]
+ target_dir = os.path.join(plugins_base, plugin_dir_name)
+ if os.path.exists(target_dir):
+ if force:
+ shutil.rmtree(target_dir)
+ else:
+ raise CommandError(
+ f'插件目录已存在: {target_dir}\n'
+ f'使用 --force 强制重新安装'
+ )
+ shutil.copytree(src_dir, target_dir)
+ else:
+ sub_dirs = [
+ d for d in inner_items
+ if os.path.isdir(os.path.join(src_dir, d))
+ and os.path.exists(os.path.join(src_dir, d, '__init__.py'))
+ ]
+ if len(sub_dirs) == 1:
+ plugin_dir_name = sub_dirs[0]
+ target_dir = os.path.join(plugins_base, plugin_dir_name)
+ if os.path.exists(target_dir):
+ if force:
+ shutil.rmtree(target_dir)
+ else:
+ raise CommandError(
+ f'插件目录已存在: {target_dir}\n'
+ f'使用 --force 强制重新安装'
+ )
+ shutil.copytree(
+ os.path.join(src_dir, sub_dirs[0]), target_dir
+ )
+ else:
+ shutil.copytree(src_dir, target_dir)
+ else:
+ shutil.copytree(tmp_dir, target_dir)
+
+ self.stdout.write(self.style.SUCCESS(
+ f'插件已解压到: {target_dir}'
+ ))
+
+ app_label = self.install_from_path(target_dir)
+ return app_label
+
def install_from_registry(self, plugin_name, registry_url=None, force=False):
remote_plugins = self._fetch_registry(registry_url)
@@ -393,10 +708,52 @@ def _load_plugin_class_from_package(self, plugin_id, plugin_path):
plugin_module_name = mod_name
if self.debug:
self.stdout.write(f'[DEBUG] 从 PLUGIN_INFO 找到插件类: {plugin_class.__name__}')
+
+ if not plugin_class:
+ for attr_name in dir(init_module):
+ attr = getattr(init_module, attr_name)
+ if (inspect.isclass(attr) and
+ hasattr(attr, '__mro__') and
+ attr.__name__ != 'PluginInterface' and
+ any(hasattr(b, '__name__') and b.__name__ == 'PluginInterface'
+ for b in attr.__mro__)):
+ plugin_class = attr
+ plugin_module_name = mod_name
+ break
except ImportError as e:
if self.debug:
self.stdout.write(f'[DEBUG] import_module({mod_name}) 失败: {e}')
+ try:
+ spec = importlib.util.spec_from_file_location(
+ mod_name, init_file
+ )
+ if spec is not None and spec.loader is not None:
+ init_module = importlib.util.module_from_spec(spec)
+ sys.modules[mod_name] = init_module
+ spec.loader.exec_module(init_module)
+
+ if hasattr(init_module, 'PLUGIN_INFO'):
+ pinfo = getattr(init_module, 'PLUGIN_INFO')
+ if 'main_class' in pinfo and hasattr(init_module, pinfo['main_class']):
+ plugin_class = getattr(init_module, pinfo['main_class'])
+ plugin_module_name = mod_name
+
+ if not plugin_class:
+ for attr_name in dir(init_module):
+ attr = getattr(init_module, attr_name)
+ if (inspect.isclass(attr) and
+ hasattr(attr, '__mro__') and
+ attr.__name__ != 'PluginInterface' and
+ any(hasattr(b, '__name__') and b.__name__ == 'PluginInterface'
+ for b in attr.__mro__)):
+ plugin_class = attr
+ plugin_module_name = mod_name
+ break
+ except Exception as e2:
+ if self.debug:
+ self.stdout.write(f'[DEBUG] spec_from_file_location 也失败: {e2}')
+
if not plugin_class:
plugin_class, plugin_module_name = self._scan_py_files_for_plugin(
plugin_id, plugin_path
@@ -459,13 +816,16 @@ def install_from_path(self, plugin_path):
if not os.path.exists(plugin_path):
raise CommandError(f'插件路径不存在: {plugin_path}')
- plugin_dir_name = os.path.basename(plugin_path)
+ plugin_dir_name = os.path.basename(os.path.abspath(plugin_path))
plugins_base = os.path.join(settings.BASE_DIR, 'plugins')
is_under_plugins = (
os.path.dirname(os.path.abspath(plugin_path)) ==
os.path.abspath(plugins_base)
)
+ plugin_manager = get_plugin_manager()
+ loaded_plugins = plugin_manager.get_all_plugins()
+
plugin_class = None
plugin_module_name = None
@@ -482,24 +842,39 @@ def install_from_path(self, plugin_path):
if not plugin_class:
raise CommandError(
- f'在 {plugin_path} 中未找到有效的插件类'
+ f'在 {plugin_path} 中未找到有效的插件类\n'
+ f'请确保插件目录中包含继承自 PluginInterface 的类'
)
try:
plugin_instance = plugin_class()
- plugin_manager = get_plugin_manager()
+
+ if plugin_instance.plugin_id in loaded_plugins:
+ self.stdout.write(self.style.WARNING(
+ f'插件 {plugin_instance.name} (ID: {plugin_instance.plugin_id}) 已加载,跳过重复安装'
+ ))
+ return self._get_app_label_from_module(plugin_module_name)
plugin_manager.plugins[plugin_instance.plugin_id] = (
plugin_instance
)
- if plugin_instance.initialize():
- self.stdout.write(self.style.SUCCESS(
- f'成功从路径安装插件: {plugin_instance.name}'
- ))
- else:
+ from plugins.core.base import ServiceProvider
+ if isinstance(plugin_instance, ServiceProvider):
+ plugin_manager.service_registry.register(plugin_instance)
+
+ try:
+ if plugin_instance.initialize():
+ self.stdout.write(self.style.SUCCESS(
+ f'成功从路径安装插件: {plugin_instance.name}'
+ ))
+ else:
+ self.stdout.write(self.style.WARNING(
+ f'插件 {plugin_instance.name} 安装成功但初始化失败'
+ ))
+ except Exception as e:
self.stdout.write(self.style.WARNING(
- f'插件 {plugin_instance.name} 安装成功但初始化失败'
+ f'插件 {plugin_instance.name} 初始化出错: {str(e)}'
))
plugin_record, created = PluginRecord.objects.update_or_create(
@@ -513,7 +888,9 @@ def install_from_path(self, plugin_path):
)
if created:
- self.stdout.write(f'已创建插件数据库记录')
+ self.stdout.write('已创建插件数据库记录')
+ else:
+ self.stdout.write('已更新插件数据库记录')
self.add_plugin_to_toml_config(plugin_instance.plugin_id, {
'name': plugin_instance.name,
@@ -523,7 +900,17 @@ def install_from_path(self, plugin_path):
'version': plugin_instance.version,
'enabled': True
})
+
+ self.stdout.write(self.style.SUCCESS(
+ f'插件 {plugin_instance.name} (v{plugin_instance.version}) 安装完成!'
+ ))
+ self.stdout.write(
+ '提示: 需要重启服务以使 Django App 注册生效'
+ )
+
return self._get_app_label_from_module(plugin_module_name)
+ except CommandError:
+ raise
except Exception as e:
raise CommandError(f'从路径安装插件失败: {str(e)}')
diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py
index b09bfd7..f53318f 100755
--- a/plugins/plugin_manager.py
+++ b/plugins/plugin_manager.py
@@ -7,10 +7,13 @@
import sys
import importlib
import importlib.util
+import logging
from typing import Any, Dict, List, Optional, Type
from plugins.core.base import PluginInterface, EventHook
+logger = logging.getLogger(__name__)
+
class PluginManager:
"""
@@ -49,7 +52,7 @@ def load_plugins_from_directory(self, directory: str) -> List[str]:
loaded_plugins = []
if not os.path.isdir(directory):
- print(f"Plugin directory does not exist: {directory}")
+ logger.warning(f"Plugin directory does not exist: {directory}")
return loaded_plugins
NON_PLUGIN_FILES = frozenset([
@@ -90,7 +93,7 @@ def load_plugins_from_directory(self, directory: str) -> List[str]:
except ImportError:
pass
except Exception as e:
- print(f"Error loading plugin from {item_path}: {str(e)}")
+ logger.error(f"Error loading plugin from {item_path}: {str(e)}")
for subdir in os.listdir(directory):
subdir_path = os.path.join(directory, subdir)
@@ -124,12 +127,12 @@ def _load_subdir_package(
self._extract_and_register_plugins(module, subdir)
)
except ImportError as e:
- print(
+ logger.error(
f"Error importing plugin package "
f"{package_name}: {str(e)}"
)
except Exception as e:
- print(
+ logger.error(
f"Error loading plugin package "
f"{package_name}: {str(e)}"
)
@@ -157,7 +160,7 @@ def _extract_and_register_plugins(
if self.register_plugin(plugin_instance):
loaded.append(plugin_instance.plugin_id)
except Exception as e:
- print(
+ logger.error(
f"Error instantiating plugin "
f"{attr_name}: {str(e)}"
)
@@ -170,14 +173,14 @@ def register_plugin(self, plugin: PluginInterface) -> bool:
:return: 注册是否成功
"""
if plugin.plugin_id in self.plugins:
- print(f"Plugin with ID {plugin.plugin_id} already exists")
+ logger.warning(f"Plugin with ID {plugin.plugin_id} already exists")
return False
try:
# 初始化插件
if plugin.initialize():
self.plugins[plugin.plugin_id] = plugin
- print(f"Successfully registered plugin: {plugin.name} ({plugin.plugin_id})")
+ logger.info(f"Successfully registered plugin: {plugin.name} ({plugin.plugin_id})")
# 同步到数据库(如果Django可用)
plugin_model = self._get_plugin_model()
@@ -196,14 +199,14 @@ def register_plugin(self, plugin: PluginInterface) -> bool:
}
)
except Exception as db_error:
- print(f"Error syncing plugin to database: {str(db_error)}")
+ logger.error(f"Error syncing plugin to database: {str(db_error)}")
return True
else:
- print(f"Failed to initialize plugin: {plugin.name}")
+ logger.warning(f"Failed to initialize plugin: {plugin.name}")
return False
except Exception as e:
- print(f"Error initializing plugin {plugin.name}: {str(e)}")
+ logger.error(f"Error initializing plugin {plugin.name}: {str(e)}")
return False
def unregister_plugin(self, plugin_id: str) -> bool:
@@ -213,7 +216,7 @@ def unregister_plugin(self, plugin_id: str) -> bool:
:return: 卸载是否成功
"""
if plugin_id not in self.plugins:
- print(f"Plugin with ID {plugin_id} does not exist")
+ logger.warning(f"Plugin with ID {plugin_id} does not exist")
return False
plugin = self.plugins[plugin_id]
@@ -222,14 +225,14 @@ def unregister_plugin(self, plugin_id: str) -> bool:
# 关闭插件
if plugin.shutdown():
del self.plugins[plugin_id]
- print(f"Successfully unregistered plugin: {plugin.name}")
+ logger.info(f"Successfully unregistered plugin: {plugin.name}")
return True
else:
- print(f"Failed to shutdown plugin: {plugin.name}")
+ logger.warning(f"Failed to shutdown plugin: {plugin.name}")
return False
except Exception as e:
- print(f"Error shutting down plugin {plugin.name}: {str(e)}")
+ logger.error(f"Error shutting down plugin {plugin.name}: {str(e)}")
return False
def enable_plugin(self, plugin_id: str) -> bool:
@@ -244,9 +247,9 @@ def enable_plugin(self, plugin_id: str) -> bool:
# 使用 update 方法直接更新数据库,避免触发信号
rows_updated = plugin_model.objects.filter(plugin_id=plugin_id).update(is_active=True)
if rows_updated > 0:
- print(f"Database updated for plugin {plugin_id} (enabled)")
+ logger.info(f"Database updated for plugin {plugin_id} (enabled)")
except Exception as db_error:
- print(f"Error updating plugin status in database: {str(db_error)}")
+ logger.error(f"Error updating plugin status in database: {str(db_error)}")
return True
return False
@@ -263,9 +266,9 @@ def disable_plugin(self, plugin_id: str) -> bool:
# 使用 update 方法直接更新数据库,避免触发信号
rows_updated = plugin_model.objects.filter(plugin_id=plugin_id).update(is_active=False)
if rows_updated > 0:
- print(f"Database updated for plugin {plugin_id} (disabled)")
+ logger.info(f"Database updated for plugin {plugin_id} (disabled)")
except Exception as db_error:
- print(f"Error updating plugin status in database: {str(db_error)}")
+ logger.error(f"Error updating plugin status in database: {str(db_error)}")
return True
return False
@@ -302,7 +305,7 @@ def load_all_plugins_from_directory(self, directory: str) -> List[str]:
loaded_plugins = []
if not os.path.isdir(directory):
- print(f"Plugin directory does not exist: {directory}")
+ logger.warning(f"Plugin directory does not exist: {directory}")
return loaded_plugins
for item in os.listdir(directory):
@@ -343,9 +346,9 @@ def load_all_plugins_from_directory(self, directory: str) -> List[str]:
loaded_plugins.append(plugin_instance.plugin_id)
except ImportError as e:
- print(f"Failed to import plugin module from {item_path}: {str(e)}")
+ logger.error(f"Failed to import plugin module from {item_path}: {str(e)}")
except Exception as e:
- print(f"Error loading plugin from {item_path}: {str(e)}")
+ logger.error(f"Error loading plugin from {item_path}: {str(e)}")
return loaded_plugins
diff --git a/plugins/test_demo_plugin/__init__.py b/plugins/test_demo_plugin/__init__.py
new file mode 100644
index 0000000..3c07b3e
--- /dev/null
+++ b/plugins/test_demo_plugin/__init__.py
@@ -0,0 +1,16 @@
+from plugins.core.base import PluginInterface
+
+class TestDemoPlugin(PluginInterface):
+ def __init__(self):
+ super().__init__(
+ plugin_id='test_demo',
+ name='Test Demo Plugin',
+ version='0.1.0',
+ description='A test plugin for verifying zip installation',
+ )
+
+ def initialize(self) -> bool:
+ return True
+
+ def shutdown(self) -> bool:
+ return True
diff --git a/plugins/views.py b/plugins/views.py
index 85ec06b..5767d5c 100755
--- a/plugins/views.py
+++ b/plugins/views.py
@@ -11,6 +11,7 @@
from . import plugin_manager
+@login_required
def plugin_list(request):
"""
插件列表视图
@@ -22,6 +23,7 @@ def plugin_list(request):
return render(request, 'plugins/list.html', context)
+@login_required
def plugin_detail(request, plugin_id):
"""
插件详情视图
diff --git a/pyproject.bak.20260530_170442.toml b/pyproject.bak.20260530_170442.toml
new file mode 100644
index 0000000..5cbf1be
--- /dev/null
+++ b/pyproject.bak.20260530_170442.toml
@@ -0,0 +1,102 @@
+[project]
+name = "2c2a"
+version = "1.0.0"
+description = "2c2a - Django Web Application"
+license = "AGPL-3.0-only"
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+ "amqp==5.3.1",
+ "asgiref==3.8.1",
+ "billiard==4.2.4",
+ "celery==5.4.0",
+ "certifi==2026.1.4",
+ "cffi==2.0.0",
+ "charset-normalizer==3.4.4",
+ "click==8.3.1",
+ "click-didyoumean==0.3.1",
+ "click-plugins==1.1.1.2",
+ "click-repl==0.3.0",
+ "cryptography==46.0.7",
+ "Django==4.2.30",
+ "django-cors-headers==4.3.1",
+ "djangorestframework==3.15.2",
+ "idna==3.11",
+ "kombu==5.6.2",
+ "Markdown==3.10.1",
+ "packaging==26.0",
+ "pillow==12.2.0",
+ "prompt_toolkit==3.0.52",
+ "pycparser==3.0",
+ "pyspnego==0.12.0",
+ "python-dateutil==2.9.0.post0",
+ "python-dotenv==1.2.2",
+ "pywinrm==0.4.3",
+ "requests==2.33.0",
+ "requests_ntlm==1.3.0",
+ "six==1.17.0",
+ "sqlalchemy>=2.0.0",
+ "sqlparse==0.5.4",
+ "msgpack>=1.0.0",
+ "typing_extensions>=4.15.0",
+ "tzdata==2025.3",
+ "urllib3==2.7.0",
+ "vine==5.1.0",
+ "wcwidth==0.3.2",
+ "xmltodict==1.0.2",
+ "pyotp",
+ "toml",
+ "black>=26.3.1",
+ "pymysql>=1.1.2",
+ "django-cotton @ git+https://github.com/2c2a/django-cotton.git@feature/x-prefix-tag-support",
+ "django-formtools>=2.5.1",
+ "django-tianai-captcha @ git+https://github.com/trustedinster/django-tianai-captcha.git@master",
+ "PyJWT>=2.8.0",
+]
+
+[project.optional-dependencies]
+redis = [
+ "redis>=5.0.0",
+]
+postgresql = [
+ "psycopg2-binary>=2.9.9",
+]
+kerberos = [
+ "gssapi>=1.11.1",
+ "krb5>=0.9.0",
+]
+dev = [
+ "pytest",
+ "pytest-django",
+ "black",
+ "flake8",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[dependency-groups]
+dev = [
+ "pytest",
+ "pytest-django",
+ "black",
+ "flake8",
+ "django-stubs>=6.0.2",
+ "pyrefly>=0.60.0",
+]
+
+[tool.hatch.metadata]
+allow-direct-references = true
+
+[tool.hatch.build.targets.wheel]
+packages = ["."]
+
+[tool.pyright]
+venvPath = "."
+venv = ".venv"
+pythonVersion = "3.13"
+typeCheckingMode = "basic"
+
+[tool.pyrefly]
+python-version = "3.13"
diff --git a/pyproject.toml b/pyproject.toml
index 56202c6..a23ce07 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,70 +6,36 @@ license = "AGPL-3.0-only"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
- "amqp==5.3.1",
- "asgiref==3.8.1",
- "billiard==4.2.4",
"celery==5.4.0",
- "certifi==2026.1.4",
- "cffi==2.0.0",
- "charset-normalizer==3.4.4",
- "click==8.3.1",
- "click-didyoumean==0.3.1",
- "click-plugins==1.1.1.2",
- "click-repl==0.3.0",
- "cryptography==46.0.7",
- "Django==4.2.30",
+ "cryptography==46.0.3",
"django-cors-headers==4.3.1",
+ "django-formtools>=2.5.1",
+ "Django==4.2.27",
"djangorestframework==3.15.2",
"idna==3.15",
"kombu==5.6.2",
"Markdown==3.10.1",
- "packaging==26.0",
- "pillow==12.2.0",
- "prompt_toolkit==3.0.52",
- "pycparser==3.0",
- "pyspnego==0.12.0",
- "python-dateutil==2.9.0.post0",
- "python-dotenv==1.2.2",
- "pywinrm==0.4.3",
- "requests==2.33.0",
- "requests_ntlm==1.3.0",
- "six==1.17.0",
- "sqlalchemy>=2.0.0",
- "sqlparse==0.5.4",
- "msgpack>=1.0.0",
- "typing_extensions>=4.15.0",
- "tzdata==2025.3",
- "urllib3==2.7.0",
- "vine==5.1.0",
- "wcwidth==0.3.2",
- "xmltodict==1.0.2",
+ "pillow==12.1.0",
+ "PyJWT>=2.8.0",
"pyotp",
+ "python-dotenv==1.2.1",
+ "pywinrm==0.4.3",
+ "redis>=5.0.0",
+ "requests==2.32.3",
"toml",
- "black>=26.3.1",
- "pymysql>=1.1.2",
"django-cotton @ git+https://github.com/2c2a/django-cotton.git@feature/x-prefix-tag-support",
- "django-formtools>=2.5.1",
- "PyJWT>=2.8.0",
+ "djlint>=1.36.4",
+ "heroicons>=2.14.0",
+ "cotton-icons>=0.2.0",
+ "whitenoise>=6.12.0",
+ "django-tianai-captcha",
]
[project.optional-dependencies]
-redis = [
- "redis>=5.0.0",
-]
-postgresql = [
- "psycopg2-binary>=2.9.9",
-]
kerberos = [
"gssapi>=1.11.1",
"krb5>=0.9.0",
]
-dev = [
- "pytest",
- "pytest-django",
- "black",
- "flake8",
-]
[build-system]
requires = ["hatchling"]
@@ -80,9 +46,11 @@ dev = [
"pytest>=9.0.3",
"pytest-django",
"black",
- "flake8",
"django-stubs>=6.0.2",
+ "flake8",
"pyrefly>=0.60.0",
+ "pytest",
+ "pytest-django",
"redis>=7.4.0",
]
@@ -108,3 +76,6 @@ typeCheckingMode = "basic"
[tool.pyrefly]
python-version = "3.13"
+
+[tool.uv.sources]
+django-tianai-captcha = { git = "https://github.com/trustedinster/django-tianai-captcha.git" }
diff --git a/scripts/deploy.py b/scripts/deploy.py
index d037400..2039406 100755
--- a/scripts/deploy.py
+++ b/scripts/deploy.py
@@ -1,18 +1,19 @@
#!/usr/bin/env python3
"""
-ZASCA 交互式部署脚本
+2c2a 交互式部署脚本
根据部署环境动态生成 pyproject.toml,仅安装所需依赖,避免冗余库
用法:
python3 scripts/deploy.py
"""
-import os
import sys
import shutil
import subprocess
import curses
from pathlib import Path
+import secrets
+import string
from datetime import datetime
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -499,7 +500,7 @@ def _page_progress(stdscr, step, total_steps, message):
stdscr.refresh()
-def _page_result(stdscr, success, answers, backup_name=None):
+def _page_result(stdscr, success, answers, backup_name=None, env_configured=False, env_backup_name=None, migrate_success=False):
_init_colors()
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
@@ -512,16 +513,26 @@ def _page_result(stdscr, success, answers, backup_name=None):
if success:
title = " ✔ 部署完成 "
- steps = [
- "1. 配置 .env 文件 (参考 .env.example)",
- "2. 初始化数据库 uv run python manage.py migrate",
- "3. 创建管理员 uv run python manage.py createsuperuser",
- "4. 启动服务 uv run python manage.py runserver",
- ]
+ steps = []
+ n = 1
+ if env_configured:
+ steps.append(f"{n}. .env 已配置 (可手动编辑调整)")
+ else:
+ steps.append(f"{n}. 配置 .env 文件 (参考 .env.example)")
+ n += 1
+ if migrate_success:
+ steps.append(f"{n}. 数据库迁移 已完成")
+ else:
+ steps.append(f"{n}. 初始化数据库 uv run python manage.py migrate")
+ n += 1
+ steps.append(f"{n}. 创建管理员 uv run python manage.py createsuperuser")
+ n += 1
+ steps.append(f"{n}. 启动服务 uv run python manage.py runserver")
if answers.get("celery"):
- steps.append("5. 启动 Celery uv run celery -A config worker -l info")
+ n += 1
+ steps.append(f"{n}. 启动 Celery uv run celery -A config worker -l info")
- box_h = 4 + len(steps) + (2 if not answers.get("redis") and answers.get("celery") else 0) + (1 if backup_name else 0)
+ box_h = 4 + len(steps) + (2 if not answers.get("redis") and answers.get("celery") else 0) + (1 if backup_name else 0) + (1 if env_backup_name else 0)
box_h = max(box_h, 8)
_draw_box(stdscr, box_y, box_x, box_h, box_w)
@@ -538,6 +549,9 @@ def _page_result(stdscr, success, answers, backup_name=None):
if backup_name:
_safe_addstr(stdscr, row, box_x + 3, f"备份: {backup_name}", curses.color_pair(C_DESC))
+ row += 1
+ if env_backup_name:
+ _safe_addstr(stdscr, row, box_x + 3, f".env 备份: {env_backup_name}", curses.color_pair(C_DESC))
else:
box_h = 8
_draw_box(stdscr, box_y, box_x, box_h, box_w)
@@ -583,11 +597,11 @@ def _tui_main(stdscr):
backup = backup_file(toml_path)
backup_name = backup.name if backup else None
- _page_progress(stdscr, 1, 2, "正在生成 pyproject.toml ...")
+ _page_progress(stdscr, 1, 3, "正在生成 pyproject.toml ...")
content = generate_pyproject_toml(answers, deps, dev_deps)
toml_path.write_text(content, encoding="utf-8")
- _page_progress(stdscr, 2, 2, "正在执行 uv sync ...")
+ _page_progress(stdscr, 2, 3, "正在执行 uv sync ...")
try:
proc = subprocess.run(
["uv", "sync"],
@@ -602,7 +616,807 @@ def _tui_main(stdscr):
except subprocess.TimeoutExpired:
success = False
- _page_result(stdscr, success, answers, backup_name)
+ env_configured = False
+ env_backup_name = None
+ migrate_success = False
+ if success:
+ env_values = _configure_env(stdscr, answers)
+ if env_values is not None:
+ env_path = BASE_DIR / ".env"
+ backup_env = backup_file(env_path)
+ env_backup_name = backup_env.name if backup_env else None
+ env_content = generate_env_content(answers, env_values)
+ env_path.write_text(env_content, encoding="utf-8")
+ env_configured = True
+
+ _page_progress(stdscr, 3, 3, "正在执行数据库迁移 ...")
+ try:
+ migrate_proc = subprocess.run(
+ ["uv", "run", "python", "manage.py", "migrate"],
+ cwd=BASE_DIR,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ migrate_success = migrate_proc.returncode == 0
+ except FileNotFoundError:
+ migrate_success = False
+ except subprocess.TimeoutExpired:
+ migrate_success = False
+
+ _page_result(stdscr, success, answers, backup_name, env_configured, env_backup_name, migrate_success)
+
+
+def _generate_secret(length=50):
+ alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+"
+ return ''.join(secrets.choice(alphabet) for _ in range(length))
+
+
+def _load_existing_env(env_path):
+ values = {}
+ if env_path.exists():
+ for line in env_path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+ if '=' in line:
+ key, _, val = line.partition('=')
+ values[key.strip()] = val.strip()
+ return values
+
+
+def _get_env_items(answers):
+ db = answers.get("db", "sqlite")
+ db_port_default = {"mysql": "3306", "postgresql": "5432"}.get(db, "3306")
+ db_user_default = {"mysql": "root", "postgresql": "postgres"}.get(db, "root")
+
+ items = []
+
+ items.append(("group", "核心配置"))
+ items.append(("field", {"key": "DEBUG", "default": "True", "desc": "调试模式(生产环境必须 False)", "type": "bool"}))
+ items.append(("field", {"key": "DJANGO_SECRET_KEY", "default": "", "desc": "Django 密钥", "type": "secret"}))
+ items.append(("field", {"key": "ALLOWED_HOSTS", "default": "localhost,127.0.0.1", "desc": "允许访问的主机", "type": "list"}))
+ items.append(("field", {"key": "CSRF_TRUSTED_ORIGINS", "default": "https://localhost,https://127.0.0.1", "desc": "CSRF 可信来源", "type": "list"}))
+
+ items.append(("group", "数据库配置"))
+ items.append(("field", {"key": "DB_ENGINE", "default": db, "desc": "数据库引擎(根据之前的选择自动设置)", "type": "auto"}))
+ if db != "sqlite":
+ items.append(("field", {"key": "DB_HOST", "default": "127.0.0.1", "desc": "数据库主机", "type": "text"}))
+ items.append(("field", {"key": "DB_PORT", "default": db_port_default, "desc": "数据库端口", "type": "text"}))
+ items.append(("field", {"key": "DB_NAME", "default": "zasca", "desc": "数据库名称", "type": "text"}))
+ items.append(("field", {"key": "DB_USER", "default": db_user_default, "desc": "数据库用户", "type": "text"}))
+ items.append(("field", {"key": "DB_PASSWORD", "default": "", "desc": "数据库密码", "type": "secret"}))
+
+ if answers.get("redis"):
+ items.append(("group", "Redis 配置"))
+ items.append(("field", {"key": "REDIS_URL", "default": "redis://localhost:6379/0", "desc": "Redis 连接地址", "type": "text"}))
+
+ if answers.get("celery"):
+ items.append(("group", "Celery 配置"))
+ items.append(("field", {"key": "CELERY_BROKER_URL", "default": "", "desc": "Celery Broker(留空自动选择)", "type": "text"}))
+ items.append(("field", {"key": "CELERY_RESULT_BACKEND", "default": "", "desc": "Celery 结果后端(留空自动选择)", "type": "text"}))
+
+ items.append(("group", "演示模式"))
+ items.append(("field", {"key": "ZASCA_DEMO", "default": "0", "desc": "演示模式(1=启用)", "type": "text"}))
+
+ items.append(("group", "安全配置"))
+ items.append(("field", {"key": "SECURE_SSL_REDIRECT", "default": "False", "desc": "SSL 重定向", "type": "bool"}))
+ items.append(("field", {"key": "SESSION_COOKIE_SECURE", "default": "False", "desc": "会话 Cookie 安全", "type": "bool"}))
+ items.append(("field", {"key": "CSRF_COOKIE_SECURE", "default": "False", "desc": "CSRF Cookie 安全", "type": "bool"}))
+
+ items.append(("group", "日志配置"))
+ items.append(("field", {"key": "LOG_LEVEL", "default": "DEBUG", "desc": "日志级别", "type": "text"}))
+ items.append(("field", {"key": "LOG_FILE", "default": "/var/log/2c2a/application.log", "desc": "日志文件路径", "type": "text"}))
+
+ if answers.get("winrm"):
+ items.append(("group", "WinRM 配置"))
+ items.append(("field", {"key": "WINRM_TIMEOUT", "default": "30", "desc": "WinRM 超时(秒)", "type": "text"}))
+ items.append(("field", {"key": "WINRM_RETRY_COUNT", "default": "3", "desc": "WinRM 重试次数", "type": "text"}))
+
+ items.append(("group", "Gateway 配置"))
+ items.append(("field", {"key": "GATEWAY_ENABLED", "default": "False", "desc": "Gateway 开关", "type": "bool"}))
+ items.append(("field", {"key": "GATEWAY_CONTROL_SOCKET", "default": "/run/2c2a/control.sock", "desc": "Gateway 控制套接字", "type": "text"}))
+
+ items.append(("group", "Beta 数据库配置(可选)"))
+ items.append(("field", {"key": "BETA_DB_NAME", "default": "", "desc": "Beta 数据库名称(留空跳过)", "type": "text"}))
+ items.append(("field", {"key": "BETA_DB_USER", "default": "", "desc": "Beta 数据库用户", "type": "text"}))
+ items.append(("field", {"key": "BETA_DB_PASSWORD", "default": "", "desc": "Beta 数据库密码", "type": "secret"}))
+ items.append(("field", {"key": "BETA_DB_HOST", "default": "", "desc": "Beta 数据库主机", "type": "text"}))
+ items.append(("field", {"key": "BETA_DB_PORT", "default": "", "desc": "Beta 数据库端口", "type": "text"}))
+
+ items.append(("group", "Bootstrap 认证配置"))
+ items.append(("field", {"key": "BOOTSTRAP_SHARED_SALT", "default": "", "desc": "Bootstrap 共享盐值", "type": "secret"}))
+
+ return items
+
+
+def _step_bool(stdscr, field_data, current_val, step, total):
+ key = field_data["key"]
+ desc = field_data.get("desc", "")
+ selected = 0 if current_val == "True" else 1
+
+ while True:
+ _init_colors()
+ stdscr.clear()
+ max_y, max_x = stdscr.getmaxyx()
+
+ box_w = min(72, max_x - 4)
+ box_h = 10
+ box_x = max(0, (max_x - box_w) // 2)
+ box_y = 2
+
+ _draw_banner(stdscr, 0, max_x)
+ _draw_box(stdscr, box_y, box_x, box_h, box_w)
+
+ title = f" 环境变量配置 ({step}/{total}) "
+ tx = box_x + max(0, (box_w - len(title)) // 2)
+ _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD)
+
+ ry = box_y + 2
+ _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD)
+
+ ry += 1
+ if desc:
+ _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC))
+
+ ry += 2
+ options = [("True", "启用"), ("False", "禁用")]
+ for i, (val, label) in enumerate(options):
+ is_sel = (i == selected)
+ marker = "◉" if is_sel else "○"
+ m_color = C_RADIO_ON | curses.A_BOLD if is_sel else C_RADIO_OFF
+ ox = box_x + 6 + i * 22
+ _safe_addstr(stdscr, ry, ox, marker, curses.color_pair(m_color))
+ _safe_addstr(stdscr, ry, ox + 2, f" {label} ({val})", curses.color_pair(C_SELECTED if is_sel else C_UNSELECTED) | curses.A_BOLD)
+
+ hint_y = box_y + box_h + 1
+ _draw_hint(stdscr, hint_y, box_x + 2, "←→/Space 切换 Enter 确认 Esc 返回")
+
+ stdscr.refresh()
+
+ ch = stdscr.getch()
+ if ch == curses.KEY_LEFT:
+ selected = 0
+ elif ch == curses.KEY_RIGHT:
+ selected = 1
+ elif ch == ord(' '):
+ selected = 1 - selected
+ elif ch in (curses.KEY_ENTER, 10, 13):
+ return "True" if selected == 0 else "False"
+ elif ch == 27:
+ return None
+
+
+def _step_secret(stdscr, field_data, current_val, is_auto, step, total):
+ key = field_data["key"]
+ desc = field_data.get("desc", "")
+
+ if is_auto or not current_val:
+ radio = 0
+ else:
+ radio = 1
+
+ while True:
+ _init_colors()
+ stdscr.clear()
+ max_y, max_x = stdscr.getmaxyx()
+
+ box_w = min(72, max_x - 4)
+ box_h = 12
+ box_x = max(0, (max_x - box_w) // 2)
+ box_y = 2
+
+ _draw_banner(stdscr, 0, max_x)
+ _draw_box(stdscr, box_y, box_x, box_h, box_w)
+
+ title = f" 环境变量配置 ({step}/{total}) "
+ tx = box_x + max(0, (box_w - len(title)) // 2)
+ _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD)
+
+ ry = box_y + 2
+ _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD)
+
+ ry += 1
+ if desc:
+ _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC))
+
+ ry += 2
+ is_sel0 = (radio == 0)
+ marker0 = "◉" if is_sel0 else "○"
+ m_color0 = C_RADIO_ON | curses.A_BOLD if is_sel0 else C_RADIO_OFF
+ _safe_addstr(stdscr, ry, box_x + 4, marker0, curses.color_pair(m_color0))
+ _safe_addstr(stdscr, ry, box_x + 7, "随机生成(推荐)", curses.color_pair(C_SELECTED if is_sel0 else C_UNSELECTED) | curses.A_BOLD)
+
+ ry += 1
+ sub_desc = "自动生成包含字母、数字和符号的50位密钥"
+ _safe_addstr(stdscr, ry, box_x + 7, sub_desc[:box_w - 10], curses.color_pair(C_DESC))
+
+ if is_sel0 and is_auto and current_val:
+ ry += 1
+ preview = current_val[:8] + "..." + current_val[-4:] if len(current_val) > 12 else current_val
+ _safe_addstr(stdscr, ry, box_x + 7, f"预览: {preview}", curses.color_pair(C_DESC))
+
+ ry += 2
+ is_sel1 = (radio == 1)
+ marker1 = "◉" if is_sel1 else "○"
+ m_color1 = C_RADIO_ON | curses.A_BOLD if is_sel1 else C_RADIO_OFF
+ _safe_addstr(stdscr, ry, box_x + 4, marker1, curses.color_pair(m_color1))
+ _safe_addstr(stdscr, ry, box_x + 7, "手动输入", curses.color_pair(C_SELECTED if is_sel1 else C_UNSELECTED) | curses.A_BOLD)
+
+ hint_y = box_y + box_h + 1
+ _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 选择 Enter 确认 Esc 返回")
+
+ stdscr.refresh()
+
+ ch = stdscr.getch()
+ if ch == curses.KEY_UP:
+ radio = 0
+ elif ch == curses.KEY_DOWN:
+ radio = 1
+ elif ch in (curses.KEY_ENTER, 10, 13):
+ if radio == 0:
+ if not current_val or not is_auto:
+ current_val = _generate_secret(50)
+ return current_val, True
+ else:
+ result = _step_text(stdscr, field_data, current_val, step, total)
+ if result is not None:
+ return result, False
+ continue
+ elif ch == 27:
+ return None, None
+
+
+def _step_text(stdscr, field_data, current_val, step, total):
+ key = field_data["key"]
+ desc = field_data.get("desc", "")
+ curses.curs_set(1)
+ buf = list(current_val)
+ pos = len(buf)
+
+ while True:
+ _init_colors()
+ stdscr.clear()
+ max_y, max_x = stdscr.getmaxyx()
+
+ box_w = min(72, max_x - 4)
+ box_h = 10
+ box_x = max(0, (max_x - box_w) // 2)
+ box_y = 2
+
+ _draw_banner(stdscr, 0, max_x)
+ _draw_box(stdscr, box_y, box_x, box_h, box_w)
+
+ title = f" 环境变量配置 ({step}/{total}) "
+ tx = box_x + max(0, (box_w - len(title)) // 2)
+ _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD)
+
+ ry = box_y + 2
+ _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD)
+
+ ry += 1
+ if desc:
+ _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC))
+
+ ry += 2
+ input_x = box_x + 4
+ input_w = box_w - 8
+ _safe_addstr(stdscr, ry, input_x, " " * input_w, curses.color_pair(C_HIGHLIGHT))
+
+ text = "".join(buf)
+ if len(text) > input_w:
+ start = max(0, pos - input_w + 1)
+ visible_text = text[start:start + input_w]
+ cursor_offset = pos - start
+ else:
+ visible_text = text
+ cursor_offset = pos
+
+ _safe_addstr(stdscr, ry, input_x, visible_text[:input_w], curses.color_pair(C_HIGHLIGHT))
+
+ try:
+ stdscr.move(ry, input_x + min(cursor_offset, input_w - 1))
+ except curses.error:
+ # Cursor move can fail when terminal is resized or too small; ignore and continue rendering.
+ pass
+
+ hint_y = box_y + box_h + 1
+ _draw_hint(stdscr, hint_y, box_x + 2, "Enter 确认 Esc 返回 Ctrl+U 清空")
+
+ stdscr.refresh()
+
+ ch = stdscr.getch()
+ if ch in (curses.KEY_ENTER, 10, 13):
+ curses.curs_set(0)
+ return "".join(buf)
+ elif ch == 27:
+ curses.curs_set(0)
+ return None
+ elif ch in (curses.KEY_BACKSPACE, 127, 8):
+ if pos > 0:
+ buf.pop(pos - 1)
+ pos -= 1
+ elif ch == curses.KEY_DC:
+ if pos < len(buf):
+ buf.pop(pos)
+ elif ch == curses.KEY_LEFT:
+ if pos > 0:
+ pos -= 1
+ elif ch == curses.KEY_RIGHT:
+ if pos < len(buf):
+ pos += 1
+ elif ch == curses.KEY_HOME:
+ pos = 0
+ elif ch == curses.KEY_END:
+ pos = len(buf)
+ elif ch == 21:
+ buf.clear()
+ pos = 0
+ elif 32 <= ch < 127:
+ buf.insert(pos, chr(ch))
+ pos += 1
+
+
+def _step_auto(stdscr, field_data, current_val, step, total):
+ key = field_data["key"]
+ desc = field_data.get("desc", "")
+
+ while True:
+ _init_colors()
+ stdscr.clear()
+ max_y, max_x = stdscr.getmaxyx()
+
+ box_w = min(72, max_x - 4)
+ box_h = 10
+ box_x = max(0, (max_x - box_w) // 2)
+ box_y = 2
+
+ _draw_banner(stdscr, 0, max_x)
+ _draw_box(stdscr, box_y, box_x, box_h, box_w)
+
+ title = f" 环境变量配置 ({step}/{total}) "
+ tx = box_x + max(0, (box_w - len(title)) // 2)
+ _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD)
+
+ ry = box_y + 2
+ _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD)
+
+ ry += 1
+ if desc:
+ _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC))
+
+ ry += 2
+ _safe_addstr(stdscr, ry, box_x + 4, f"▸ {current_val}", curses.color_pair(C_SUCCESS) | curses.A_BOLD)
+
+ ry += 1
+ _safe_addstr(stdscr, ry, box_x + 4, "(此值由之前的选择自动确定)", curses.color_pair(C_DESC))
+
+ hint_y = box_y + box_h + 1
+ _draw_hint(stdscr, hint_y, box_x + 2, "Enter 继续 Esc 返回")
+
+ stdscr.refresh()
+
+ ch = stdscr.getch()
+ if ch in (curses.KEY_ENTER, 10, 13):
+ return current_val
+ elif ch == 27:
+ return None
+
+
+def _step_list(stdscr, field_data, current_val, step, total):
+ key = field_data["key"]
+ desc = field_data.get("desc", "")
+ items = [x.strip() for x in current_val.split(",") if x.strip()] if current_val else []
+ cursor = 0
+ scroll = 0
+
+ while True:
+ _init_colors()
+ stdscr.clear()
+ max_y, max_x = stdscr.getmaxyx()
+
+ box_w = min(72, max_x - 4)
+ box_h = max(14, min(20, max_y - 4))
+ box_x = max(0, (max_x - box_w) // 2)
+ box_y = 1
+
+ _draw_banner(stdscr, 0, max_x)
+ _draw_box(stdscr, box_y, box_x, box_h, box_w)
+
+ title = f" 环境变量配置 ({step}/{total}) "
+ tx = box_x + max(0, (box_w - len(title)) // 2)
+ _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD)
+
+ ry = box_y + 2
+ _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD)
+
+ ry += 1
+ if desc:
+ _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC))
+
+ ry += 2
+ max_list_visible = box_h - 8
+ max_scroll = max(0, len(items) - max_list_visible)
+ if cursor < scroll:
+ scroll = cursor
+ elif cursor >= scroll + max_list_visible:
+ scroll = cursor - max_list_visible + 1
+ scroll = max(0, min(scroll, max_scroll))
+
+ if not items:
+ _safe_addstr(stdscr, ry, box_x + 6, "(空列表,按 + 添加项)", curses.color_pair(C_DESC))
+ else:
+ visible_items = items[scroll:scroll + max_list_visible]
+ for i, item in enumerate(visible_items):
+ actual_idx = scroll + i
+ iy = ry + i
+ is_active = (actual_idx == cursor)
+ marker = "▸" if is_active else " "
+ line = f" {marker} {item}"
+ if is_active:
+ _safe_addstr(stdscr, iy, box_x + 4, line[:box_w - 8], curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD)
+ else:
+ _safe_addstr(stdscr, iy, box_x + 4, line[:box_w - 8], curses.color_pair(C_UNSELECTED))
+
+ count_y = box_y + box_h - 3
+ _safe_addstr(stdscr, count_y, box_x + 4, f"共 {len(items)} 项", curses.color_pair(C_DESC))
+
+ if max_scroll > 0:
+ scroll_info = f" [{scroll + 1}-{min(scroll + max_list_visible, len(items))}/{len(items)}] "
+ _safe_addstr(stdscr, count_y, box_x + box_w - len(scroll_info) - 2, scroll_info, curses.color_pair(C_DESC))
+
+ hint_y = box_y + box_h + 1
+ _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 切换 + 添加 - 删除 Enter 编辑 Ctrl+D 完成 Esc 返回")
+
+ stdscr.refresh()
+
+ ch = stdscr.getch()
+ if ch == curses.KEY_UP:
+ if items:
+ cursor = max(0, cursor - 1)
+ elif ch == curses.KEY_DOWN:
+ if items:
+ cursor = min(len(items) - 1, cursor + 1)
+ elif ch in (ord('+'), ord('=')):
+ new_item = _step_text(stdscr, {"key": f"{key}[新项]", "desc": "输入新项的值"}, "", step, total)
+ if new_item is not None and new_item.strip():
+ items.append(new_item.strip())
+ cursor = len(items) - 1
+ elif ch in (ord('-'), ord('_')):
+ if items and 0 <= cursor < len(items):
+ items.pop(cursor)
+ if cursor >= len(items) and len(items) > 0:
+ cursor = len(items) - 1
+ elif ch in (curses.KEY_ENTER, 10, 13):
+ if items and 0 <= cursor < len(items):
+ new_item = _step_text(stdscr, {"key": f"{key}[{cursor}]", "desc": "编辑当前项"}, items[cursor], step, total)
+ if new_item is not None:
+ items[cursor] = new_item.strip() if new_item.strip() else items[cursor]
+ elif ch == 4:
+ return ",".join(items)
+ elif ch == 27:
+ return None
+
+
+def _env_preview(stdscr, items, values, secret_auto):
+ field_indices = [i for i, (kind, _) in enumerate(items) if kind == "field"]
+ cursor = 0
+ scroll = 0
+
+ while True:
+ _init_colors()
+ stdscr.clear()
+ max_y, max_x = stdscr.getmaxyx()
+
+ box_w = min(76, max_x - 4)
+ box_h = max_y - 5
+ box_x = max(0, (max_x - box_w) // 2)
+ box_y = 1
+
+ _draw_banner(stdscr, 0, max_x)
+ _draw_box(stdscr, box_y, box_x, box_h, box_w)
+
+ title = " .env 配置预览 "
+ tx = box_x + max(0, (box_w - len(title)) // 2)
+ _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD)
+
+ cursor_item_idx = field_indices[cursor] if cursor < len(field_indices) else 0
+
+ max_visible = box_h - 3
+ total_items = len(items)
+ max_scroll = max(0, total_items - max_visible)
+
+ if cursor_item_idx < scroll:
+ scroll = cursor_item_idx
+ elif cursor_item_idx >= scroll + max_visible:
+ scroll = cursor_item_idx - max_visible + 1
+ scroll = max(0, min(scroll, max_scroll))
+
+ visible = items[scroll:scroll + max_visible]
+ current_visible_idx = cursor_item_idx - scroll
+
+ for i, (kind, data) in enumerate(visible):
+ ry = box_y + 1 + i
+ rx = box_x + 2
+ remaining = box_w - 5
+
+ if kind == "group":
+ _safe_addstr(stdscr, ry, rx, f" {data}", curses.color_pair(C_SUBTITLE) | curses.A_BOLD)
+ elif kind == "field":
+ is_active = (i == current_visible_idx)
+ key = data["key"]
+ val = values.get(key, data["default"])
+ ftype = data.get("type", "text")
+
+ if ftype == "secret":
+ if key in secret_auto:
+ if val:
+ preview = val[:6] + "..." + val[-4:] if len(val) > 10 else val
+ display = f"(随机: {preview})"
+ else:
+ display = "(随机生成)"
+ elif val:
+ display = "******"
+ else:
+ display = "(未设置)"
+ elif ftype == "auto":
+ display = f"{val} (自动)"
+ elif ftype == "bool":
+ display = val
+ elif ftype == "list":
+ list_items = [x.strip() for x in val.split(",") if x.strip()] if val else []
+ if list_items:
+ display = ", ".join(list_items)
+ else:
+ display = "(空列表)"
+ else:
+ display = val if val else "(空)"
+
+ line = f" {key} = {display}"
+
+ if is_active:
+ _safe_addstr(stdscr, ry, rx, line[:remaining], curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD)
+ else:
+ color = C_DESC if ftype == "auto" else C_UNSELECTED
+ _safe_addstr(stdscr, ry, rx, line[:remaining], curses.color_pair(color))
+
+ desc_y = box_y + box_h - 2
+ if 0 <= current_visible_idx < len(visible) and visible[current_visible_idx][0] == "field":
+ field_data = visible[current_visible_idx][1]
+ desc = field_data.get("desc", "")
+ if desc:
+ _safe_addstr(stdscr, desc_y, box_x + 3, f" {desc}"[:box_w - 6], curses.color_pair(C_DESC))
+
+ if max_scroll > 0:
+ scroll_info = f" [{scroll + 1}-{min(scroll + max_visible, total_items)}/{total_items}] "
+ _safe_addstr(stdscr, desc_y, box_x + box_w - len(scroll_info) - 2, scroll_info, curses.color_pair(C_DESC))
+
+ hint_y = box_y + box_h + 1
+ hint = "↑↓ 移动 Enter 编辑 S 保存 Esc 取消"
+ if max_scroll > 0:
+ hint = "↑↓/PgUp/PgDn 滚动 " + hint
+ _draw_hint(stdscr, hint_y, box_x + 2, hint)
+
+ stdscr.refresh()
+
+ ch = stdscr.getch()
+ if ch == curses.KEY_UP:
+ cursor = max(0, cursor - 1)
+ elif ch == curses.KEY_DOWN:
+ cursor = min(len(field_indices) - 1, cursor + 1)
+ elif ch == curses.KEY_PPAGE:
+ cursor = max(0, cursor - 5)
+ elif ch == curses.KEY_NPAGE:
+ cursor = min(len(field_indices) - 1, cursor + 5)
+ elif ch in (curses.KEY_ENTER, 10, 13):
+ field_data = items[field_indices[cursor]][1]
+ ftype = field_data.get("type", "text")
+ key = field_data["key"]
+ current = values.get(key, field_data["default"])
+
+ if ftype == "bool":
+ result = _step_bool(stdscr, field_data, current, cursor + 1, len(field_indices))
+ if result is not None:
+ values[key] = result
+ elif ftype == "secret":
+ is_auto_flag = key in secret_auto
+ result, auto = _step_secret(stdscr, field_data, current, is_auto_flag, cursor + 1, len(field_indices))
+ if result is not None:
+ values[key] = result
+ if auto:
+ secret_auto.add(key)
+ else:
+ secret_auto.discard(key)
+ elif ftype == "list":
+ result = _step_list(stdscr, field_data, current, cursor + 1, len(field_indices))
+ if result is not None:
+ values[key] = result
+ else:
+ result = _step_text(stdscr, field_data, current, cursor + 1, len(field_indices))
+ if result is not None:
+ values[key] = result
+ elif ch in (ord('s'), ord('S')):
+ return values
+ elif ch == 27:
+ return None
+
+
+def _configure_env(stdscr, answers):
+ items = _get_env_items(answers)
+ field_items = [(i, data) for i, (kind, data) in enumerate(items) if kind == "field"]
+ total = len(field_items)
+
+ existing = _load_existing_env(BASE_DIR / ".env")
+ values = {}
+ secret_auto = set()
+
+ for idx, data in field_items:
+ key = data["key"]
+ if data.get("type") == "auto":
+ values[key] = data["default"]
+ elif key in existing:
+ values[key] = existing[key]
+ else:
+ values[key] = data["default"]
+
+ step = 0
+ while 0 <= step < total:
+ idx, data = field_items[step]
+ ftype = data.get("type", "text")
+ key = data["key"]
+ current = values.get(key, data["default"])
+
+ if ftype == "bool":
+ result = _step_bool(stdscr, data, current, step + 1, total)
+ if result is None:
+ step -= 1
+ continue
+ values[key] = result
+ step += 1
+ elif ftype == "secret":
+ is_auto_flag = key in secret_auto
+ result, auto = _step_secret(stdscr, data, current, is_auto_flag, step + 1, total)
+ if result is None:
+ step -= 1
+ continue
+ values[key] = result
+ if auto:
+ secret_auto.add(key)
+ else:
+ secret_auto.discard(key)
+ step += 1
+ elif ftype == "auto":
+ result = _step_auto(stdscr, data, current, step + 1, total)
+ if result is None:
+ step -= 1
+ continue
+ step += 1
+ elif ftype == "list":
+ result = _step_list(stdscr, data, current, step + 1, total)
+ if result is None:
+ step -= 1
+ continue
+ values[key] = result
+ step += 1
+ else:
+ result = _step_text(stdscr, data, current, step + 1, total)
+ if result is None:
+ step -= 1
+ continue
+ values[key] = result
+ step += 1
+
+ if step < 0:
+ return None
+
+ result = _env_preview(stdscr, items, values, secret_auto)
+ if result is None:
+ return None
+
+ return result
+
+
+def generate_env_content(answers, values):
+ lines = []
+ lines.append("# ZASCA 环境配置文件")
+ lines.append("# 由 deploy.py 自动生成")
+ lines.append("")
+
+ lines.append("# ========== 核心配置 ==========")
+ lines.append(f"DEBUG={values.get('DEBUG', 'True')}")
+
+ secret_key = values.get('DJANGO_SECRET_KEY', '')
+ if not secret_key:
+ secret_key = _generate_secret(50)
+ lines.append(f"DJANGO_SECRET_KEY={secret_key}")
+
+ lines.append(f"ALLOWED_HOSTS={values.get('ALLOWED_HOSTS', 'localhost,127.0.0.1')}")
+ lines.append(f"CSRF_TRUSTED_ORIGINS={values.get('CSRF_TRUSTED_ORIGINS', 'https://localhost,https://127.0.0.1')}")
+ lines.append("")
+
+ db = answers.get("db", "sqlite")
+ lines.append("# ========== 数据库配置 ==========")
+ lines.append(f"DB_ENGINE={values.get('DB_ENGINE', db)}")
+ if db != "sqlite":
+ lines.append(f"DB_HOST={values.get('DB_HOST', '127.0.0.1')}")
+ lines.append(f"DB_PORT={values.get('DB_PORT', '3306')}")
+ lines.append(f"DB_NAME={values.get('DB_NAME', 'zasca')}")
+ lines.append(f"DB_USER={values.get('DB_USER', 'root')}")
+ db_pass = values.get('DB_PASSWORD', '')
+ if not db_pass:
+ db_pass = _generate_secret(50)
+ lines.append(f"DB_PASSWORD={db_pass}")
+ lines.append("")
+
+ if answers.get("redis"):
+ lines.append("# ========== Redis 配置 ==========")
+ lines.append(f"REDIS_URL={values.get('REDIS_URL', 'redis://localhost:6379/0')}")
+ lines.append("")
+
+ if answers.get("celery"):
+ lines.append("# ========== Celery 配置 ==========")
+ broker = values.get('CELERY_BROKER_URL', '')
+ backend = values.get('CELERY_RESULT_BACKEND', '')
+ if not broker and answers.get("redis"):
+ redis_url = values.get('REDIS_URL', 'redis://localhost:6379/0')
+ broker = redis_url.replace('/0', '/1')
+ if not backend and answers.get("redis"):
+ redis_url = values.get('REDIS_URL', 'redis://localhost:6379/0')
+ backend = redis_url.replace('/0', '/2')
+ if broker:
+ lines.append(f"CELERY_BROKER_URL={broker}")
+ if backend:
+ lines.append(f"CELERY_RESULT_BACKEND={backend}")
+ lines.append("")
+
+ lines.append("# ========== 演示模式 ==========")
+ lines.append(f"ZASCA_DEMO={values.get('ZASCA_DEMO', '0')}")
+ lines.append("")
+
+ lines.append("# ========== 安全配置 ==========")
+ lines.append(f"SECURE_SSL_REDIRECT={values.get('SECURE_SSL_REDIRECT', 'False')}")
+ lines.append(f"SESSION_COOKIE_SECURE={values.get('SESSION_COOKIE_SECURE', 'False')}")
+ lines.append(f"CSRF_COOKIE_SECURE={values.get('CSRF_COOKIE_SECURE', 'False')}")
+ lines.append("")
+
+ lines.append("# ========== 日志配置 ==========")
+ lines.append(f"LOG_LEVEL={values.get('LOG_LEVEL', 'DEBUG')}")
+ lines.append(f"LOG_FILE={values.get('LOG_FILE', '/var/log/2c2a/application.log')}")
+ lines.append("")
+
+ if answers.get("winrm"):
+ lines.append("# ========== WinRM 配置 ==========")
+ lines.append(f"WINRM_TIMEOUT={values.get('WINRM_TIMEOUT', '30')}")
+ lines.append(f"WINRM_RETRY_COUNT={values.get('WINRM_RETRY_COUNT', '3')}")
+ lines.append("")
+
+ lines.append("# ========== Gateway 配置 ==========")
+ lines.append(f"GATEWAY_ENABLED={values.get('GATEWAY_ENABLED', 'False')}")
+ lines.append(f"GATEWAY_CONTROL_SOCKET={values.get('GATEWAY_CONTROL_SOCKET', '/run/2c2a/control.sock')}")
+ lines.append("")
+
+ beta_fields = ['BETA_DB_NAME', 'BETA_DB_USER', 'BETA_DB_PASSWORD', 'BETA_DB_HOST', 'BETA_DB_PORT']
+ has_beta = any(values.get(f, '') for f in beta_fields)
+ if has_beta:
+ lines.append("# ========== Beta 数据库配置 ==========")
+ for f in beta_fields:
+ if f == 'BETA_DB_PASSWORD':
+ bp = values.get(f, '')
+ if not bp:
+ bp = _generate_secret(50)
+ lines.append(f"{f}={bp}")
+ else:
+ lines.append(f"{f}={values.get(f, '')}")
+ lines.append("")
+
+ lines.append("# ========== Bootstrap 认证配置 ==========")
+ salt = values.get('BOOTSTRAP_SHARED_SALT', '')
+ if not salt:
+ salt = _generate_secret(50)
+ lines.append(f"BOOTSTRAP_SHARED_SALT={salt}")
+ lines.append("")
+
+ return "\n".join(lines)
def compute_dependencies(answers):
diff --git a/static/js/email_code.js b/static/js/email_code.js
index 7494216..7a2ae34 100755
--- a/static/js/email_code.js
+++ b/static/js/email_code.js
@@ -1,35 +1,22 @@
-// email_code.js
-// Handles "Get code" button on registration and forgot password pages.
-// If CAPTCHA_PROVIDER == 'geetest', it will open geetest popup via initGeetest4 and then POST v4 params + email to /accounts/email/send-code/ or /accounts/email/send-forgot-password-code/
-// Otherwise, it will POST only the email to the endpoint.
-
-(function(){
- function qs(sel){ return document.querySelector(sel); }
+(function () {
+ function qs(sel) { return document.querySelector(sel); }
var btn = qs('#get-email-code');
- if(!btn) return;
+ if (!btn) return;
- // 倒计时功能
var countdownTimers = {};
-
- function startCountdown(button, initialText = null) {
+
+ function startCountdown(button, initialText) {
var buttonId = button.id || 'unknown-button';
-
- // 清除之前的倒计时
if (countdownTimers[buttonId]) {
clearInterval(countdownTimers[buttonId]);
}
-
- let count = 60;
- const originalText = initialText || button.textContent || button.innerText;
+ var count = 60;
+ var originalText = initialText || button.textContent || '获取验证码';
button.disabled = true;
-
- // 更新按钮文本显示倒计时
- button.textContent = `${originalText} (${count}s)`;
-
- countdownTimers[buttonId] = setInterval(() => {
+ button.textContent = originalText + ' (' + count + 's)';
+ countdownTimers[buttonId] = setInterval(function () {
count--;
- button.textContent = `${originalText} (${count}s)`;
-
+ button.textContent = originalText + ' (' + count + 's)';
if (count <= 0) {
clearInterval(countdownTimers[buttonId]);
delete countdownTimers[buttonId];
@@ -39,110 +26,51 @@
}, 1000);
}
- btn.addEventListener('click', function(e){
+ btn.addEventListener('click', function (e) {
e.preventDefault();
var emailInput = document.querySelector('input[name="email"]') || document.querySelector('input[type="email"]');
var email = emailInput && emailInput.value && emailInput.value.trim();
- if(!email){ alert('请先输入邮箱'); return; }
+ if (!email) { alert('请先输入邮箱'); return; }
- // Check if we're on the forgot password page
var isForgotPassword = window.location.pathname.includes('forgot-password');
var endpoint = isForgotPassword ? '/accounts/email/send-forgot-password-code/' : '/accounts/email/send-code/';
+ var provider = window.CAPTCHA_PROVIDER || 'none';
- // CAPTCHA_PROVIDER is injected in template context as CAPTCHA_PROVIDER
- var provider = window.CAPTCHA_PROVIDER || document.body.getAttribute('data-captcha-provider') || 'none';
+ if (provider === 'tianai') {
+ return;
+ }
- function postCode(payload, buttonRef){
+ function postCode(payload, buttonRef) {
fetch(endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: {
- 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : (document.querySelector('[name=csrfmiddlewaretoken]')?.value || '')
+ 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]') ? document.querySelector('[name=csrfmiddlewaretoken]').value : ''
},
body: payload
- }).then(function(resp){
- if(resp.ok) {
+ }).then(function (resp) {
+ if (resp.ok) {
alert('验证码已发送,请注意查收');
- // 开始倒计时
- if(buttonRef) {
- startCountdown(buttonRef, '获取验证码');
- }
+ if (buttonRef) startCountdown(buttonRef, '获取验证码');
} else {
- // 请求失败时恢复按钮状态
- if(buttonRef) {
+ if (buttonRef) {
buttonRef.disabled = false;
buttonRef.textContent = '获取验证码';
}
- resp.json().then(function(j){ alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function(){ alert('发送失败'); });
+ resp.json().then(function (j) { alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function () { alert('发送失败'); });
}
- }).catch(function(err){
- console.error(err);
- // 请求失败时恢复按钮状态
- if(buttonRef) {
+ }).catch(function (err) {
+ console.error(err);
+ if (buttonRef) {
buttonRef.disabled = false;
buttonRef.textContent = '获取验证码';
}
- alert('网络错误');
+ alert('网络错误');
});
}
- if(provider === 'geetest'){
- var emailCaptchaId = btn.getAttribute('data-captcha-id') || window.GEETEST_CAPTCHA_ID;
- if(!emailCaptchaId){
- alert('Geetest captcha ID 未配置');
- return;
- }
- initGeetest4({
- captchaId: emailCaptchaId,
- product: 'bind'
- }, function(captchaObj){
- captchaObj.onReady(function(){
- captchaObj.showCaptcha();
- });
- captchaObj.onSuccess(function(){
- var result = captchaObj.getValidate();
- if(!result){
- alert('验证结果获取失败,请重试');
- return;
- }
- var form = new FormData();
- form.append('email', email);
- form.append('lot_number', result.lot_number || '');
- form.append('captcha_output', result.captcha_output || '');
- form.append('pass_token', result.pass_token || '');
- form.append('gen_time', result.gen_time || '');
- form.append('captcha_id', emailCaptchaId);
- postCode(form, btn);
- });
- captchaObj.onError(function(error){
- console.error('Geetest v4 error:', error);
- alert('验证码加载失败,请稍后重试');
- });
- captchaObj.onClose(function(){
- });
- });
- } else if(provider === 'local') {
- // 如果是本地验证码,不应该到达这里,因为local_captcha_adapter.js会处理
- // 但为了兼容性,我们也可以处理
- alert('本地验证码需要先完成验证');
- } else {
- if(provider === 'turnstile'){
- var sitekey = window.TURNSTILE_SITE_KEY || (document.getElementById('turnstile_site_key') && document.getElementById('turnstile_site_key').value);
- if(!sitekey){ alert('Turnstile site key 未配置'); return; }
- // execute turnstile
- if(typeof window.executeTurnstile === 'function'){
- window.executeTurnstile(sitekey, function(err, token){
- if(err){ alert('Turnstile 验证失败'); return; }
- var fd = new FormData(); fd.append('email', email); fd.append('cf-turnstile-response', token);
- postCode(fd, btn); // Pass button reference for countdown
- });
- } else {
- alert('Turnstile adapter 未加载');
- }
- } else {
- var fd = new FormData(); fd.append('email', email);
- postCode(fd, btn); // Pass button reference for countdown
- }
- }
+ var fd = new FormData();
+ fd.append('email', email);
+ postCode(fd, btn);
});
-})();
\ No newline at end of file
+})();
diff --git a/static/js/geetest_v4_adapter.js b/static/js/geetest_v4_adapter.js
deleted file mode 100755
index a1308e2..0000000
--- a/static/js/geetest_v4_adapter.js
+++ /dev/null
@@ -1,92 +0,0 @@
-(function () {
- function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }
-
- var defaultCaptchaId = window.GEETEST_CAPTCHA_ID || null;
-
- function initOnTrigger() {
- var triggers = $all('[data-geetest-trigger]:not([data-geetest-email-trigger])');
- triggers.forEach(function (btn) {
- var form = btn.closest('form');
- var captchaId = btn.getAttribute('data-captcha-id') || defaultCaptchaId;
- if (!captchaId) {
- console.error('captchaId not provided for Geetest v4');
- return;
- }
-
- var captchaInstance = null;
- var ready = false;
-
- initGeetest4({
- captchaId: captchaId,
- product: 'bind'
- }, function (captchaObj) {
- captchaInstance = captchaObj;
-
- captchaObj.onReady(function () {
- ready = true;
- });
-
- captchaObj.onSuccess(function () {
- var result = captchaObj.getValidate();
- if (!result) {
- alert('验证结果获取失败,请重试');
- return;
- }
-
- if (form) {
- var inLot = form.querySelector('input[name="lot_number"]');
- var inOutput = form.querySelector('input[name="captcha_output"]');
- var inPass = form.querySelector('input[name="pass_token"]');
- var inGen = form.querySelector('input[name="gen_time"]');
- var inCid = form.querySelector('input[name="captcha_id"]');
-
- if (inLot) inLot.value = result.lot_number || '';
- if (inOutput) inOutput.value = result.captcha_output || '';
- if (inPass) inPass.value = result.pass_token || '';
- if (inGen) inGen.value = result.gen_time || '';
- if (inCid) inCid.value = captchaId;
- }
-
- if (form) form.submit();
- });
-
- captchaObj.onError(function (error) {
- console.error('Geetest v4 error:', error);
- alert('验证码加载失败,请稍后重试');
- });
- });
-
- btn.addEventListener('click', function (e) {
- e.preventDefault();
- if (ready && captchaInstance) {
- captchaInstance.showCaptcha();
- }
- });
-
- if (form) {
- form.addEventListener('submit', function (e) {
- var passTokenInput = form.querySelector('input[name="pass_token"]');
- var passTokenValue = passTokenInput ? passTokenInput.value : '';
- if (!passTokenValue && window.CAPTCHA_PROVIDER === 'geetest') {
- e.preventDefault();
- if (ready && captchaInstance) {
- captchaInstance.showCaptcha();
- }
- }
- });
- }
- });
- }
-
- window.initGeetestAdapter = {
- initOnTrigger: initOnTrigger
- };
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', function () {
- initOnTrigger();
- });
- } else {
- initOnTrigger();
- }
-})();
diff --git a/static/js/gt4.js b/static/js/gt4.js
deleted file mode 100755
index 5da8039..0000000
--- a/static/js/gt4.js
+++ /dev/null
@@ -1,487 +0,0 @@
-"v4.2.0 Geetest Inc.";
-
-(function (window) {
- "use strict";
- if (typeof window === 'undefined') {
- throw new Error('Geetest requires browser environment');
- }
-
-var document = window.document;
-var Math = window.Math;
-var head = document.getElementsByTagName("head")[0];
-var TIMEOUT = 10000;
-
-function _Object(obj) {
- this._obj = obj;
-}
-
-_Object.prototype = {
- _each: function (process) {
- var _obj = this._obj;
- for (var k in _obj) {
- if (_obj.hasOwnProperty(k)) {
- process(k, _obj[k]);
- }
- }
- return this;
- },
- _extend: function (obj){
- var self = this;
- new _Object(obj)._each(function (key, value){
- self._obj[key] = value;
- })
- }
-};
-
-var uuid = function () {
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
- var r = Math.random() * 16 | 0;
- var v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
- };
-
-function Config(config) {
- var self = this;
- new _Object(config)._each(function (key, value) {
- self[key] = value;
- });
-}
-
-Config.prototype = {
- apiServers: ['gcaptcha4.geetest.com','gcaptcha4.geevisit.com','gcaptcha4.gsensebot.com'],
- staticServers: ["static.geetest.com",'static.geevisit.com'],
- protocol: 'http://',
- typePath: '/load',
- fallback_config: {
- bypass: {
- staticServers: ["static.geetest.com",'static.geevisit.com'],
- type: 'bypass',
- bypass: '/v4/bypass.js'
- }
- },
- _get_fallback_config: function () {
- var self = this;
- if (isString(self.type)) {
- return self.fallback_config[self.type];
- } else {
- return self.fallback_config.bypass;
- }
- },
- _extend: function (obj) {
- var self = this;
- new _Object(obj)._each(function (key, value) {
- self[key] = value;
- })
- }
-};
-var isNumber = function (value) {
- return (typeof value === 'number');
-};
-var isString = function (value) {
- return (typeof value === 'string');
-};
-var isBoolean = function (value) {
- return (typeof value === 'boolean');
-};
-var isObject = function (value) {
- return (typeof value === 'object' && value !== null);
-};
-var isFunction = function (value) {
- return (typeof value === 'function');
-};
-var MOBILE = /Mobi/i.test(navigator.userAgent);
-
-var callbacks = {};
-var status = {};
-
-var random = function () {
- return parseInt(Math.random() * 10000) + (new Date()).valueOf();
-};
-
-// bind 函数polify, 不带new功能的bind
-
-var bind = function(target,context){
- if(typeof target !== 'function'){
- return;
- }
- var args = Array.prototype.slice.call(arguments,2);
-
- if(Function.prototype.bind){
- return target.bind(context, args);
- }else {
- return function(){
- var _args = Array.prototype.slice.call(arguments);
- return target.apply(context,args.concat(_args));
- }
- }
-}
-
-
-
-var toString = Object.prototype.toString;
-
-var _isFunction = function(obj) {
- return typeof(obj) === 'function';
-};
-var _isObject = function(obj) {
- return obj === Object(obj);
-};
-var _isArray = function(obj) {
- return toString.call(obj) == '[object Array]';
-};
-var _isDate = function(obj) {
- return toString.call(obj) == '[object Date]';
-};
-var _isRegExp = function(obj) {
- return toString.call(obj) == '[object RegExp]';
-};
-var _isBoolean = function(obj) {
- return toString.call(obj) == '[object Boolean]';
-};
-
-
-function resolveKey(input){
- return input.replace(/(\S)(_([a-zA-Z]))/g, function(match, $1, $2, $3){
- return $1 + $3.toUpperCase() || "";
- })
-}
-
-function camelizeKeys(input, convert){
- if(!_isObject(input) || _isDate(input) || _isRegExp(input) || _isBoolean(input) || _isFunction(input)){
- return convert ? resolveKey(input) : input;
- }
-
- if(_isArray(input)){
- var temp = [];
- for(var i = 0; i < input.length; i++){
- temp.push(camelizeKeys(input[i]));
- }
-
- }else {
- var temp = {};
- for(var prop in input){
- if(input.hasOwnProperty(prop)){
- temp[camelizeKeys(prop, true)] = camelizeKeys(input[prop]);
- }
- }
- }
- return temp;
-}
-
-var loadScript = function (url, cb, timeout) {
- var script = document.createElement("script");
- script.charset = "UTF-8";
- script.async = true;
-
- // 对geetest的静态资源添加 crossOrigin
- if ( /static\.geetest\.com/g.test(url)) {
- script.crossOrigin = "anonymous";
- }
-
- script.onerror = function () {
- cb(true);
- // 错误触发了,超时逻辑就不用了
- loaded = true;
- };
- var loaded = false;
- script.onload = script.onreadystatechange = function () {
- if (!loaded &&
- (!script.readyState ||
- "loaded" === script.readyState ||
- "complete" === script.readyState)) {
-
- loaded = true;
- setTimeout(function () {
- cb(false);
- }, 0);
- }
- };
- script.src = url;
- head.appendChild(script);
-
- setTimeout(function () {
- if (!loaded) {
- script.onerror = script.onload = null;
- script.remove && script.remove();
- cb(true);
- }
- }, timeout || TIMEOUT);
-};
-
-var normalizeDomain = function (domain) {
- // special domain: uems.sysu.edu.cn/jwxt/geetest/
- // return domain.replace(/^https?:\/\/|\/.*$/g, ''); uems.sysu.edu.cn
- return domain.replace(/^https?:\/\/|\/$/g, ''); // uems.sysu.edu.cn/jwxt/geetest
-};
-var normalizePath = function (path) {
-
- path = path && path.replace(/\/+/g, '/');
- if (path.indexOf('/') !== 0) {
- path = '/' + path;
- }
- return path;
-};
-var normalizeQuery = function (query) {
- if (!query) {
- return '';
- }
- var q = '?';
- new _Object(query)._each(function (key, value) {
- if (isString(value) || isNumber(value) || isBoolean(value)) {
- q = q + encodeURIComponent(key) + '=' + encodeURIComponent(value) + '&';
- }
- });
- if (q === '?') {
- q = '';
- }
- return q.replace(/&$/, '');
-};
-var makeURL = function (protocol, domain, path, query) {
- domain = normalizeDomain(domain);
-
- var url = normalizePath(path) + normalizeQuery(query);
- if (domain) {
- url = protocol + domain + url;
- }
-
- return url;
-};
-
-var load = function (config, protocol, domains, path, query, cb, handleCb) {
- var tryRequest = function (at) {
- // 处理jsonp回调,这里为了保证每个不同jsonp都有唯一的回调函数
- if(handleCb){
- var cbName = "geetest_" + random();
- // 需要与预先定义好cbname参数,删除对象
- window[cbName] = bind(handleCb, null, cbName);
- query.callback = cbName;
- }
- var url = makeURL(protocol, domains[at], path, query);
- loadScript(url, function (err) {
- if (err) {
- // 超时或者出错的时候 移除回调
- if(cbName){
- try {
- window[cbName] = function(){
- window[cbName] = null;
- }
- } catch (e) {}
- }
-
- if (at >= domains.length - 1) {
- cb(true);
- // report gettype error
- } else {
- tryRequest(at + 1);
- }
- } else {
- cb(false);
- }
- }, config.timeout);
- };
- tryRequest(0);
-};
-
-
-var jsonp = function (domains, path, config, callback) {
-
- var handleCb = function (cbName, data) {
-
- // 保证只执行一次,全部超时的情况下不会再触发;
-
- if (data.status == 'success') {
- callback(data.data);
- } else if (!data.status) {
- callback(data);
- } else {
- //接口有返回,但是返回了错误状态,进入报错逻辑
- callback(data);
- }
- window[cbName] = undefined;
- try {
- delete window[cbName];
- } catch (e) {
- }
- };
- load(config, config.protocol, domains, path, {
- callback: '',
- captcha_id: config.captchaId,
- challenge: config.challenge || uuid(),
- client_type: config.clientType ? config.clientType : (MOBILE? 'h5':'web'),
- risk_type: config.riskType,
- user_info: config.userInfo,
- call_type: config.callType,
- lang: config.language? config.language : navigator.appName === 'Netscape' ? navigator.language.toLowerCase() : navigator.userLanguage.toLowerCase()
- }, function (err) {
- // 网络问题接口没有返回,直接使用本地验证码,走宕机模式
- // 这里可以添加用户的逻辑
- if(err && typeof config.offlineCb === 'function'){
- // 执行自己的宕机
- config.offlineCb();
- return;
- }
- if(err){
- callback(config._get_fallback_config());
- }
- }, handleCb);
-};
-
-var reportError = function (config, url) {
- load(config, config.protocol, ['monitor.geetest.com'], '/monitor/send', {
- time: Date.now().getTime(),
- captcha_id: config.gt,
- challenge: config.challenge,
- exception_url: url,
- error_code: config.error_code
- }, function (err) {})
-}
-
-var throwError = function (errorType, config, errObj) {
- var errors = {
- networkError: '网络错误',
- gtTypeError: 'gt字段不是字符串类型'
- };
- if (typeof config.onError === 'function') {
- config.onError({
- desc: errObj.desc,
- msg: errObj.msg,
- code: errObj.code
- });
- } else {
- throw new Error(errors[errorType]);
- }
-};
-
-var detect = function () {
- return window.Geetest || document.getElementById("gt_lib");
-};
-
-if (detect()) {
- status.slide = "loaded";
-}
-var GeetestIsLoad = function (fname) {
- var GeetestIsLoad = false;
- var tags = { js: 'script', css: 'link' };
- var tagname = fname && tags[fname.split('.').pop()];
- if (tagname !== undefined) {
- var elts = document.getElementsByTagName(tagname);
- for (var i in elts) {
- if ((elts[i].href && elts[i].href.toString().indexOf(fname) > 0)
- || (elts[i].src && elts[i].src.toString().indexOf(fname) > 0)) {
- GeetestIsLoad = true;
- }
- }
- }
- return GeetestIsLoad;
-};
-window.initGeetest4 = function (userConfig,callback) {
-
- var config = new Config(userConfig);
- if (userConfig.https) {
- config.protocol = 'https://';
- } else if (!userConfig.protocol) {
- config.protocol = window.location.protocol + '//';
- }
-
-
- if (isObject(userConfig.getType)) {
- config._extend(userConfig.getType);
- }
-
- jsonp(config.apiServers , config.typePath, config, function (newConfig) {
- //错误捕获,第一个load请求可能直接报错
- var newConfig = camelizeKeys(newConfig);
-
- if(newConfig.status === 'error'){
- return throwError('networkError', config, newConfig);
- }
-
- var type = newConfig.type;
- if(config.debug){
- new _Object(newConfig)._extend(config.debug)
- }
- var init = function () {
- config._extend(newConfig);
- callback(new window.Geetest4(config));
- };
-
- callbacks[type] = callbacks[type] || [];
-
- var s = status[type] || 'init';
- if (s === 'init') {
- status[type] = 'loading';
-
- callbacks[type].push(init);
-
- if(newConfig.gctPath){
- load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){
- if(err){
- throwError('networkError', config, {
- code: '60205',
- msg: 'Network failure',
- desc: {
- detail: 'gct resource load timeout'
- }
- });
- }
- })
- }
-
- load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers, newConfig.bypass || (newConfig.staticPath + newConfig.js), null, function (err) {
- if (err) {
- status[type] = 'fail';
- throwError('networkError', config, {
- code: '60204',
- msg: 'Network failure',
- desc: {
- detail: 'js resource load timeout'
- }
- });
- } else {
-
- status[type] = 'loaded';
- var cbs = callbacks[type];
- for (var i = 0, len = cbs.length; i < len; i = i + 1) {
- var cb = cbs[i];
- if (isFunction(cb)) {
- cb();
- }
- }
- callbacks[type] = [];
- status[type] = 'init';
- }
- });
- } else if (s === "loaded") {
- // 判断gct是否需要重新加载
- if(newConfig.gctPath && !GeetestIsLoad(newConfig.gctPath)){
- load(config, config.protocol, Object.hasOwnProperty.call(config, 'staticServers') ? config.staticServers : newConfig.staticServers || config.staticServers , newConfig.gctPath, null, function (err){
- if(err){
- throwError('networkError', config, {
- code: '60205',
- msg: 'Network failure',
- desc: {
- detail: 'gct resource load timeout'
- }
- });
- }
- })
- }
- return init();
- } else if (s === "fail") {
- throwError('networkError', config, {
- code: '60204',
- msg: 'Network failure',
- desc: {
- detail: 'js resource load timeout'
- }
- });
- } else if (s === "loading") {
- callbacks[type].push(init);
- }
- });
-
-};
-
-
-})(window);
diff --git a/static/js/local_captcha_adapter.js b/static/js/local_captcha_adapter.js
deleted file mode 100755
index 5a78383..0000000
--- a/static/js/local_captcha_adapter.js
+++ /dev/null
@@ -1,340 +0,0 @@
-class LocalCaptchaAdapter {
- constructor(options = {}) {
- this.options = {
- product: options.product || 'popup',
- ...options
- };
- this.captchaId = null;
- this.modal = null;
- this.resolveCallback = null;
- }
-
- async showBox() {
- return new Promise((resolve) => {
- this.resolveCallback = resolve;
- this._createModal();
- });
- }
-
- _createModal() {
- const existing = document.getElementById('local-captcha-overlay');
- if (existing) existing.remove();
-
- const overlay = document.createElement('div');
- overlay.id = 'local-captcha-overlay';
- overlay.className = 'fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-md';
-
- const modal = document.createElement('div');
- modal.id = 'local-captcha-modal';
- modal.className = 'bg-black/90 backdrop-blur-2xl border border-white/10 rounded-md-lg shadow-2xl p-6 w-[320px] max-w-[90vw] relative';
-
- const title = document.createElement('h3');
- title.className = 'text-lg font-medium text-white mb-4 flex items-center gap-2';
- title.innerHTML = 'verified_user 请输入验证码';
-
- const imgRow = document.createElement('div');
- imgRow.className = 'flex items-center gap-3 mb-4';
-
- const captchaImg = document.createElement('img');
- captchaImg.id = 'captcha-image';
- captchaImg.className = 'h-10 w-[120px] border border-slate-700/50 rounded-md cursor-pointer hover:opacity-80 transition';
- captchaImg.alt = '验证码';
- captchaImg.title = '点击刷新验证码';
- captchaImg.addEventListener('click', () => this._refreshCaptcha());
-
- const refreshBtn = document.createElement('button');
- refreshBtn.type = 'button';
- refreshBtn.className = 'flex items-center justify-center w-9 h-9 rounded-full bg-slate-800/50 border border-slate-700/50 text-slate-400 hover:text-cyan-400 transition cursor-pointer';
- refreshBtn.title = '刷新验证码';
- refreshBtn.innerHTML = 'refresh ';
- refreshBtn.addEventListener('click', () => this._refreshCaptcha());
-
- imgRow.appendChild(captchaImg);
- imgRow.appendChild(refreshBtn);
-
- const input = document.createElement('input');
- input.type = 'text';
- input.id = 'captcha-input';
- input.placeholder = '请输入验证码';
- input.maxLength = 4;
- input.autocomplete = 'off';
- input.className = 'w-full bg-slate-900/50 border border-slate-700/50 rounded-md px-4 py-3 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition text-center text-lg tracking-[0.3em] uppercase';
-
- const errorMsg = document.createElement('p');
- errorMsg.id = 'captcha-error';
- errorMsg.className = 'text-sm text-red-400 mt-1 hidden';
-
- const btnRow = document.createElement('div');
- btnRow.className = 'flex gap-3 mt-4';
-
- const cancelBtn = document.createElement('button');
- cancelBtn.type = 'button';
- cancelBtn.className = 'flex-1 bg-slate-800/50 border border-slate-700/50 rounded-md px-4 py-2.5 text-slate-300 hover:bg-slate-700/50 transition font-medium cursor-pointer';
- cancelBtn.textContent = '取消';
- cancelBtn.addEventListener('click', () => {
- this._closeModal();
- if (this.resolveCallback) {
- this.resolveCallback({ status: 'closed' });
- }
- });
-
- const confirmBtn = document.createElement('button');
- confirmBtn.type = 'button';
- confirmBtn.className = 'flex-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded-md px-4 py-2.5 shadow-[0_0_15px_-3px_rgba(34,211,238,0.3)] transition font-medium cursor-pointer';
- confirmBtn.textContent = '确认';
- confirmBtn.addEventListener('click', () => this._verifyCaptcha());
-
- btnRow.appendChild(cancelBtn);
- btnRow.appendChild(confirmBtn);
-
- modal.appendChild(title);
- modal.appendChild(imgRow);
- modal.appendChild(input);
- modal.appendChild(errorMsg);
- modal.appendChild(btnRow);
- overlay.appendChild(modal);
-
- overlay.addEventListener('click', (e) => {
- if (e.target === overlay) {
- this._closeModal();
- if (this.resolveCallback) {
- this.resolveCallback({ status: 'closed' });
- }
- }
- });
-
- input.addEventListener('keypress', (e) => {
- if (e.key === 'Enter') this._verifyCaptcha();
- });
-
- document.body.appendChild(overlay);
-
- this._generateCaptcha();
-
- setTimeout(() => input.focus(), 100);
- }
-
- async _generateCaptcha() {
- try {
- const resp = await fetch('/accounts/captcha/generate/');
- const data = await resp.json();
- if (data.captcha_id) {
- this.captchaId = data.captcha_id;
- const img = document.getElementById('captcha-image');
- if (img) {
- img.src = `/accounts/captcha/image/${this.captchaId}/?t=${Date.now()}`;
- }
- }
- } catch (err) {
- console.error('生成验证码失败:', err);
- this._showError('验证码生成失败,请稍后重试');
- }
- }
-
- async _refreshCaptcha() {
- const input = document.getElementById('captcha-input');
- if (input) input.value = '';
- this._hideError();
- await this._generateCaptcha();
- }
-
- async _verifyCaptcha() {
- const input = document.getElementById('captcha-input');
- const userInput = input ? input.value.trim() : '';
-
- if (!userInput) {
- this._showError('请输入验证码');
- return;
- }
-
- if (!this.captchaId) {
- this._showError('验证码已过期,请重新获取');
- await this._generateCaptcha();
- return;
- }
-
- this._closeModal();
-
- const result = {
- status: 'success',
- lot_number: this.captchaId,
- captcha_output: userInput,
- pass_token: 'local_captcha_pass_token',
- gen_time: Date.now().toString(),
- captcha_id: this.captchaId
- };
-
- window._localCaptchaResult = result;
-
- if (this.resolveCallback) {
- this.resolveCallback(result);
- }
- }
-
- _showError(msg) {
- const el = document.getElementById('captcha-error');
- if (el) {
- el.textContent = msg;
- el.classList.remove('hidden');
- }
- }
-
- _hideError() {
- const el = document.getElementById('captcha-error');
- if (el) el.classList.add('hidden');
- }
-
- _closeModal() {
- const overlay = document.getElementById('local-captcha-overlay');
- if (overlay) overlay.remove();
- this.modal = null;
- }
-
- getCsrfToken() {
- return window.getCsrfToken ? window.getCsrfToken() :
- (document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
- document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1] || '');
- }
-
- static initGeetest(config, callback) {
- const adapter = new LocalCaptchaAdapter(config);
- callback(adapter);
- }
-}
-
-window.initGeetest4 = LocalCaptchaAdapter.initGeetest;
-
-function _fillHiddenFields(result) {
- const suffixes = ['login', 'reg', 'forgot'];
- suffixes.forEach(suffix => {
- const lotEl = document.getElementById(`lot_number_${suffix}`);
- const outEl = document.getElementById(`captcha_output_${suffix}`);
- const passEl = document.getElementById(`pass_token_${suffix}`);
- const genEl = document.getElementById(`gen_time_${suffix}`);
- if (lotEl) lotEl.value = result.lot_number;
- if (outEl) outEl.value = result.captcha_output;
- if (passEl) passEl.value = result.pass_token;
- if (genEl) genEl.value = result.gen_time;
- });
-}
-
-function _sendEmailCodeRequest(button) {
- const emailField = document.querySelector('input[type="email"]');
- if (!emailField || !emailField.value) {
- alert('请先填写邮箱地址');
- return;
- }
-
- let endpoint;
- if (window.location.pathname.includes('/register/')) {
- endpoint = '/accounts/email/send-code/';
- } else if (window.location.pathname.includes('/forgot-password/')) {
- endpoint = '/accounts/email/send-forgot-password-code/';
- } else {
- alert('无法确定当前页面类型');
- return;
- }
-
- const result = window._localCaptchaResult;
- const formData = new FormData();
- formData.append('email', emailField.value);
- if (result) {
- if (result.lot_number) formData.append('lot_number', result.lot_number);
- if (result.captcha_output) formData.append('captcha_output', result.captcha_output);
- if (result.pass_token) formData.append('pass_token', result.pass_token);
- if (result.gen_time) formData.append('gen_time', result.gen_time);
- }
-
- fetch(endpoint, {
- method: 'POST',
- body: formData,
- headers: {
- 'X-CSRFToken': window.getCsrfToken ? window.getCsrfToken() : (document.querySelector('[name=csrfmiddlewaretoken]')?.value || '')
- }
- })
- .then(response => response.json())
- .then(data => {
- if (data.status === 'ok') {
- alert('验证码已发送到您的邮箱');
- _startCountdown(button, '获取验证码');
- } else {
- alert(data.message || '发送验证码失败');
- }
- })
- .catch(error => {
- console.error('Error:', error);
- alert('发送验证码时发生错误');
- });
-}
-
-let _countdownTimer = null;
-
-function _startCountdown(button, initialText) {
- if (_countdownTimer) clearInterval(_countdownTimer);
-
- let count = 60;
- const originalText = initialText || '获取验证码';
- button.disabled = true;
- button.textContent = `${originalText} (${count}s)`;
-
- _countdownTimer = setInterval(() => {
- count--;
- button.textContent = `${originalText} (${count}s)`;
- if (count <= 0) {
- clearInterval(_countdownTimer);
- _countdownTimer = null;
- button.disabled = false;
- button.textContent = originalText;
- }
- }, 1000);
-}
-
-document.addEventListener('DOMContentLoaded', function () {
- if (window.CAPTCHA_PROVIDER !== 'local') return;
-
- document.querySelectorAll('#get-email-code[data-local-captcha-trigger]').forEach(button => {
- const newBtn = button.cloneNode(true);
- button.parentNode.replaceChild(newBtn, button);
-
- newBtn.addEventListener('click', async function (e) {
- e.preventDefault();
- e.stopImmediatePropagation();
- if (this.disabled) return;
-
- const emailField = document.querySelector('input[type="email"]');
- if (!emailField || !emailField.value) {
- alert('请先填写邮箱地址');
- emailField?.focus();
- return;
- }
-
- const adapter = new LocalCaptchaAdapter({ product: 'popup' });
- const result = await adapter.showBox();
-
- if (result && result.status === 'success') {
- _fillHiddenFields(result);
- _sendEmailCodeRequest(this);
- }
- });
- });
-
- document.querySelectorAll('[data-local-captcha-trigger]:not(#get-email-code)').forEach(button => {
- button.addEventListener('click', async function (e) {
- e.preventDefault();
- e.stopImmediatePropagation();
-
- const adapter = new LocalCaptchaAdapter({ product: 'popup' });
- const result = await adapter.showBox();
-
- if (result && result.status === 'success') {
- _fillHiddenFields(result);
-
- const action = this.dataset.action;
- if (action === 'submit') {
- let form = this.closest('form');
- if (form) form.submit();
- }
- }
- });
- });
-});
diff --git a/static/js/tianai_adapter.js b/static/js/tianai_adapter.js
new file mode 100644
index 0000000..e10b2db
--- /dev/null
+++ b/static/js/tianai_adapter.js
@@ -0,0 +1,211 @@
+(function () {
+ function qs(sel) { return document.querySelector(sel); }
+ function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }
+
+ var _countdownTimer = null;
+ var _activeOverlay = null;
+ var _activeCaptchaBox = null;
+
+ function startCountdown(button, initialText) {
+ if (_countdownTimer) clearInterval(_countdownTimer);
+ var count = 60;
+ var originalText = initialText || '获取验证码';
+ button.disabled = true;
+ button.textContent = originalText + ' (' + count + 's)';
+ _countdownTimer = setInterval(function () {
+ count--;
+ button.textContent = originalText + ' (' + count + 's)';
+ if (count <= 0) {
+ clearInterval(_countdownTimer);
+ _countdownTimer = null;
+ button.disabled = false;
+ button.textContent = originalText;
+ }
+ }, 1000);
+ }
+
+ function getGenerateUrl(captchaType) {
+ var baseUrl = '/captcha/generate';
+ if (captchaType && captchaType !== 'SLIDER') {
+ return baseUrl + '?type=' + encodeURIComponent(captchaType);
+ }
+ return baseUrl;
+ }
+
+ function createModal() {
+ if (_activeOverlay) return _activeOverlay.querySelector('#tianai-captcha-box');
+
+ var overlay = document.createElement('div');
+ overlay.id = 'tianai-captcha-overlay';
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;';
+
+ var captchaBox = document.createElement('div');
+ captchaBox.id = 'tianai-captcha-box';
+ captchaBox.style.cssText = 'position:relative;';
+
+ overlay.appendChild(captchaBox);
+ document.body.appendChild(overlay);
+
+ _activeOverlay = overlay;
+ _activeCaptchaBox = captchaBox;
+
+ overlay.addEventListener('click', function (e) {
+ if (e.target === overlay) {
+ destroyModal();
+ }
+ });
+
+ return captchaBox;
+ }
+
+ function destroyModal() {
+ if (_activeOverlay) {
+ _activeOverlay.remove();
+ _activeOverlay = null;
+ _activeCaptchaBox = null;
+ }
+ }
+
+ function showTianaiCaptcha(onSuccess, captchaType) {
+ var captchaBox = createModal();
+
+ var config = {
+ requestCaptchaDataUrl: getGenerateUrl(captchaType),
+ validCaptchaUrl: "/captcha/check",
+ bindEl: "#tianai-captcha-box",
+ validSuccess: function (res, c, tac) {
+ var token = null;
+ if (res && res.data && res.data.token) {
+ token = res.data.token;
+ } else if (res && res.token) {
+ token = res.token;
+ }
+ tac.destroyWindow();
+ destroyModal();
+ if (onSuccess) onSuccess(token);
+ },
+ validFail: function (res, c, tac) {
+ tac.reloadCaptcha();
+ },
+ btnCloseFun: function (el, tac) {
+ tac.destroyWindow();
+ destroyModal();
+ }
+ };
+ var style = {};
+ var tac = new window.TAC(config, style);
+ tac.init();
+ }
+
+ function setTokenField(form, token) {
+ var input = form.querySelector('input[name="captcha_token"]');
+ if (!input) {
+ input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = 'captcha_token';
+ form.appendChild(input);
+ }
+ input.value = token || '';
+ }
+
+ function postEmailCode(email, token, button) {
+ var isForgotPassword = window.location.pathname.includes('forgot-password');
+ var endpoint = isForgotPassword ? '/accounts/email/send-forgot-password-code/' : '/accounts/email/send-code/';
+
+ var formData = new FormData();
+ formData.append('email', email);
+ if (token) {
+ formData.append('captcha_token', token);
+ }
+
+ fetch(endpoint, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]') ? document.querySelector('[name=csrfmiddlewaretoken]').value : ''
+ },
+ body: formData
+ }).then(function (resp) {
+ if (resp.ok) {
+ alert('验证码已发送,请注意查收');
+ if (button) startCountdown(button, '获取验证码');
+ } else {
+ if (button) {
+ button.disabled = false;
+ button.textContent = '获取验证码';
+ }
+ resp.json().then(function (j) { alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function () { alert('发送失败'); });
+ }
+ }).catch(function (err) {
+ console.error(err);
+ if (button) {
+ button.disabled = false;
+ button.textContent = '获取验证码';
+ }
+ alert('网络错误');
+ });
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ if (window.CAPTCHA_PROVIDER !== 'tianai') return;
+
+ var captchaType = window.CAPTCHA_TYPE || 'SLIDER';
+
+ $all('[data-tianai-captcha-trigger]').forEach(function (btn) {
+ btn.addEventListener('click', function (e) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+
+ var form = btn.closest('form');
+ showTianaiCaptcha(function (token) {
+ if (form) setTokenField(form, token);
+ var action = btn.dataset.action;
+ if (action === 'submit') {
+ form.submit();
+ }
+ }, captchaType);
+ });
+ });
+
+ $all('#get-email-code[data-tianai-email-trigger]').forEach(function (button) {
+ var newBtn = button.cloneNode(true);
+ button.parentNode.replaceChild(newBtn, button);
+
+ newBtn.addEventListener('click', function (e) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ if (this.disabled) return;
+
+ var emailField = document.querySelector('input[type="email"]');
+ if (!emailField || !emailField.value) {
+ alert('请先输入邮箱');
+ emailField && emailField.focus();
+ return;
+ }
+
+ var emailCaptchaType = window.CAPTCHA_TYPE_EMAIL || captchaType;
+ showTianaiCaptcha(function (token) {
+ postEmailCode(emailField.value, token, newBtn);
+ }, emailCaptchaType);
+ });
+ });
+
+ $all('form').forEach(function (form) {
+ var hasCaptchaTrigger = form.querySelector('[data-tianai-captcha-trigger]');
+ if (!hasCaptchaTrigger) return;
+
+ form.addEventListener('submit', function (e) {
+ var tokenField = form.querySelector('input[name="captcha_token"]');
+ if (!tokenField || !tokenField.value) {
+ if (window.CAPTCHA_PROVIDER === 'tianai') {
+ e.preventDefault();
+ showTianaiCaptcha(function (token) {
+ setTokenField(form, token);
+ form.submit();
+ }, captchaType);
+ }
+ }
+ });
+ });
+ });
+})();
diff --git a/static/js/turnstile_adapter.js b/static/js/turnstile_adapter.js
deleted file mode 100755
index 8fef2a3..0000000
--- a/static/js/turnstile_adapter.js
+++ /dev/null
@@ -1,70 +0,0 @@
-// turnstile_adapter.js
-// Minimal adapter to render Cloudflare Turnstile widgets programmatically.
-// Usage:
-// - For login button, add data-turnstile-trigger to the button; adapter will render an invisible widget and submit the form when token is obtained.
-// - For custom flows (like email code), call window.executeTurnstile(callback) to run an invisible widget and receive token.
-
-(function(){
- function loadScript(src, cb){
- if(window.turnstile) return cb();
- var s = document.createElement('script');
- s.src = src;
- s.async = true;
- s.onload = cb;
- s.onerror = function(){ console.error('Failed to load turnstile script'); };
- document.head.appendChild(s);
- }
-
- function ensureContainer(){
- var id = 'turnstile-popup-container';
- var c = document.getElementById(id);
- if(!c){ c = document.createElement('div'); c.id = id; c.style.display='none'; document.body.appendChild(c); }
- return c;
- }
-
- function renderInvisible(sitekey, callback){
- loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js', function(){
- var container = ensureContainer();
- container.innerHTML = '';
- // render invisible widget
- var widgetId = window.turnstile.render(container, {
- sitekey: sitekey,
- size: 'invisible',
- callback: function(token){
- callback(null, token);
- },
- 'error-callback': function(){ callback(new Error('turnstile error')); },
- 'expired-callback': function(){ callback(new Error('turnstile expired')); }
- });
- // execute
- try{ window.turnstile.execute(widgetId); }catch(e){ console.error(e); }
- });
- }
-
- window.executeTurnstile = function(sitekey, cb){
- if(!sitekey){ return cb(new Error('missing sitekey')); }
- renderInvisible(sitekey, cb);
- };
-
- // attach to buttons with data-turnstile-trigger
- document.addEventListener('DOMContentLoaded', function(){
- var btns = document.querySelectorAll('[data-turnstile-trigger]');
- Array.prototype.forEach.call(btns, function(btn){
- btn.addEventListener('click', function(e){
- e.preventDefault();
- var form = btn.closest('form');
- var sitekey = window.TURNSTILE_SITE_KEY || btn.getAttribute('data-turnstile-sitekey');
- if(!sitekey){ alert('Turnstile site key 未配置'); return; }
- executeTurnstile(sitekey, function(err, token){
- if(err){ alert('Turnstile 验证失败'); return; }
- // put token into hidden input
- var input = form.querySelector('input[name="cf-turnstile-response"]');
- if(!input){ input = document.createElement('input'); input.type='hidden'; input.name='cf-turnstile-response'; form.appendChild(input); }
- input.value = token;
- // submit form
- form.submit();
- });
- });
- });
- });
-})();
diff --git a/static/scripts/init.ps1 b/static/scripts/init.ps1
new file mode 100644
index 0000000..89319e9
--- /dev/null
+++ b/static/scripts/init.ps1
@@ -0,0 +1,222 @@
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$EncodedToken = $args[0]
+if (-not $EncodedToken) {
+ Write-Host "Usage: & ([ScriptBlock]::Create(`$script)) [debug]" -ForegroundColor Red
+ return
+}
+$Debug = $args[1] -eq 'debug' -or $args[1] -eq '1'
+
+$padLen = 4 - ($EncodedToken.Length % 4)
+if ($padLen -ne 4) { $EncodedToken += '=' * $padLen }
+try {
+ $jsonBytes = [System.Convert]::FromBase64String($EncodedToken)
+ $jsonStr = [System.Text.Encoding]::UTF8.GetString($jsonBytes)
+ $tokenObj = $jsonStr | ConvertFrom-Json
+ $Token = $tokenObj.t
+ $Scheme = $tokenObj.s
+ $ServerHost = $tokenObj.h
+} catch {
+ Write-Host "Token解码失败" -ForegroundColor Red; return
+}
+if (-not $Token -or -not $Scheme -or -not $ServerHost) {
+ Write-Host "Token格式无效" -ForegroundColor Red; return
+}
+$ServerUrl = "${Scheme}://${ServerHost}"
+if ($Debug) { Write-Host " ServerUrl: $ServerUrl" -ForegroundColor DarkGray }
+
+Write-Host "=== 2c2a WinRM 证书自动配置 ===" -ForegroundColor Cyan
+
+Write-Host "[1/17] 验证Token..." -ForegroundColor Yellow
+$validateResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/validate/?token=$Token" -Method Get
+if (-not $validateResp.valid) {
+ Write-Host "Token无效或已过期" -ForegroundColor Red; return
+}
+if ($Debug) { Write-Host " ServerHost: $ServerHost" -ForegroundColor DarkGray }
+Write-Host " Token验证通过" -ForegroundColor Green
+
+Write-Host "[2/17] 上传主机名..." -ForegroundColor Yellow
+$hostname = $env:COMPUTERNAME
+$body = @{ token = $Token; hostname = $hostname } | ConvertTo-Json
+Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/upload-hostname/" -Method Post -Body $body -ContentType "application/json"
+if ($Debug) { Write-Host " 主机名: $hostname" -ForegroundColor DarkGray }
+Write-Host " 主机名已上传: $hostname" -ForegroundColor Green
+
+Write-Host "[3/17] 等待证书签发..." -ForegroundColor Yellow
+$certData = $null
+$maxWait = 120
+$waited = 0
+while ($waited -lt $maxWait) {
+ Start-Sleep -Seconds 5
+ $waited += 5
+ try {
+ $statusResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/validate/?token=$Token" -Method Get
+ if ($Debug) { Write-Host " Token状态: $($statusResp.status)" -ForegroundColor DarkGray }
+ } catch {
+ continue
+ }
+ try {
+ $certResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/download-certs/?token=$Token" -Method Get
+ if ($certResp.ca_cert) {
+ $certData = $certResp
+ break
+ }
+ } catch {
+ }
+ Write-Host " 等待中... ($waited/${maxWait}s)" -ForegroundColor DarkGray
+}
+if (-not $certData) {
+ Write-Host "证书签发超时" -ForegroundColor Red; return
+}
+if ($Debug) { Write-Host " 证书数据已获取" -ForegroundColor DarkGray }
+Write-Host " 证书已就绪" -ForegroundColor Green
+
+Write-Host "[4/17] 下载证书文件..." -ForegroundColor Yellow
+$TempDir = "$env:TEMP\2c2a_Certs"
+New-Item -ItemType Directory -Force -Path $TempDir | Out-Null
+[System.IO.File]::WriteAllBytes("$TempDir\ca.crt", [System.Convert]::FromBase64String($certData.ca_cert))
+[System.IO.File]::WriteAllBytes("$TempDir\client.crt", [System.Convert]::FromBase64String($certData.client_cert))
+[System.IO.File]::WriteAllBytes("$TempDir\server.pfx", [System.Convert]::FromBase64String($certData.server_pfx))
+$PfxPassword = $certData.pfx_password
+$NtlmUser = $certData.ntlm_user
+$NtlmPassword = $certData.ntlm_password
+$UpnValue = $certData.upn_value
+if ($Debug) { Write-Host " 保存路径: $TempDir" -ForegroundColor DarkGray }
+Write-Host " 证书文件已保存到 $TempDir" -ForegroundColor Green
+
+Write-Host "[5/17] 导入证书..." -ForegroundColor Yellow
+$tempCa = Import-Certificate -FilePath "$TempDir\ca.crt" -CertStoreLocation Cert:\LocalMachine\Root
+$caIssuerPattern = $tempCa.Subject -replace '.*CN=([^,]+).*','$1'
+Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -match $caIssuerPattern | Remove-Item -Force
+$importedCa = Import-Certificate -FilePath "$TempDir\ca.crt" -CertStoreLocation Cert:\LocalMachine\Root
+Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Issuer -match $caIssuerPattern } | Remove-Item -Force
+Get-ChildItem Cert:\LocalMachine\TrustedPeople | Where-Object Subject -match "winrm-client" | Remove-Item -Force
+$secPwd = ConvertTo-SecureString $PfxPassword -AsPlainText -Force
+$importedServer = Import-PfxCertificate -FilePath "$TempDir\server.pfx" -CertStoreLocation Cert:\LocalMachine\My -Password $secPwd
+Import-Certificate -FilePath "$TempDir\client.crt" -CertStoreLocation Cert:\LocalMachine\TrustedPeople
+if ($Debug) { Write-Host " CA Thumbprint: $($importedCa.Thumbprint)" -ForegroundColor DarkGray }
+if ($Debug) { Write-Host " Server Thumbprint: $($importedServer.Thumbprint)" -ForegroundColor DarkGray }
+Write-Host " 证书导入完成" -ForegroundColor Green
+
+Write-Host "[6/17] 创建本地用户..." -ForegroundColor Yellow
+$SecurePassword = ConvertTo-SecureString $NtlmPassword -AsPlainText -Force
+if (-not (Get-LocalUser -Name $NtlmUser -ErrorAction SilentlyContinue)) {
+ New-LocalUser -Name $NtlmUser -Password $SecurePassword -Description "2c2a WinRM Certificate Auth User"
+} else {
+ Set-LocalUser -Name $NtlmUser -Password $SecurePassword
+}
+Add-LocalGroupMember -Group "Administrators" -Member $NtlmUser -ErrorAction SilentlyContinue
+if ($Debug) { Write-Host " 用户: $NtlmUser" -ForegroundColor DarkGray }
+Write-Host " 用户 $NtlmUser 已创建" -ForegroundColor Green
+
+Write-Host "[7/17] 配置HTTPS监听器..." -ForegroundColor Yellow
+Get-ChildItem WSMan:\localhost\Listener | Where-Object { $_.Keys -match "Transport=HTTPS" } | Remove-Item -Recurse -Force
+New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint $importedServer.Thumbprint -Force
+if ($Debug) { Write-Host " Thumbprint: $($importedServer.Thumbprint)" -ForegroundColor DarkGray }
+Write-Host " 监听器已绑定: $($importedServer.Thumbprint)" -ForegroundColor Green
+
+Write-Host "[8/17] 配置客户端证书映射..." -ForegroundColor Yellow
+Get-ChildItem WSMan:\localhost\ClientCertificate | Remove-Item -Recurse -Force
+$cred = New-Object System.Management.Automation.PSCredential($NtlmUser, $SecurePassword)
+New-Item -Path WSMan:\localhost\ClientCertificate -Subject $UpnValue -Issuer $importedCa.Thumbprint -Credential $cred -Force
+if ($Debug) { Write-Host " Subject=$UpnValue Issuer=$($importedCa.Thumbprint)" -ForegroundColor DarkGray }
+Write-Host " 映射已建立: Subject=$UpnValue" -ForegroundColor Green
+
+Write-Host "[9/17] 配置Schannel注册表..." -ForegroundColor Yellow
+reg add "HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL" /v ClientAuthTrustMode /t REG_DWORD /d 2 /f
+reg add "HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL" /v SendTrustedIssuerList /t REG_DWORD /d 0 /f
+if ($Debug) { Write-Host " ClientAuthTrustMode=2, SendTrustedIssuerList=0" -ForegroundColor DarkGray }
+Write-Host " ClientAuthTrustMode=2, SendTrustedIssuerList=0" -ForegroundColor Green
+
+Write-Host "[10/17] 启用证书认证..." -ForegroundColor Yellow
+Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true
+Write-Host " 证书认证已启用" -ForegroundColor Green
+
+Write-Host "[11/17] 配置防火墙..." -ForegroundColor Yellow
+$FirewallRuleName = "WinRM HTTPS (5986)"
+$existingRule = Get-NetFirewallRule -DisplayName $FirewallRuleName -ErrorAction SilentlyContinue
+if (-not $existingRule) {
+ New-NetFirewallRule -DisplayName $FirewallRuleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5986
+} else {
+ Enable-NetFirewallRule -DisplayName $FirewallRuleName
+}
+if ($Debug) { Write-Host " 规则: $FirewallRuleName" -ForegroundColor DarkGray }
+Write-Host " 防火墙已配置" -ForegroundColor Green
+
+Write-Host "[12/17] 重启WinRM服务..." -ForegroundColor Yellow
+Restart-Service WinRM -Force
+Write-Host " WinRM服务已重启" -ForegroundColor Green
+
+Write-Host "[13/17] 通知服务器配置完成..." -ForegroundColor Yellow
+$notifyBody = @{ token = $Token } | ConvertTo-Json
+$notifyResp = $null
+$notifyOk = $false
+for ($i = 1; $i -le 3; $i++) {
+ try {
+ $notifyResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/notify-complete/" -Method Post -Body $notifyBody -ContentType "application/json"
+ $notifyOk = $true
+ break
+ } catch {
+ if ($i -lt 3) { Start-Sleep -Seconds 5 }
+ }
+}
+$testDeferred = $false
+if ($notifyOk) {
+ if ($notifyResp.test -eq "deferred") {
+ Write-Host " 已通知服务器(连接测试将在主机注册后执行)" -ForegroundColor Green
+ $testDeferred = $true
+ } else {
+ Write-Host " 已通知服务器" -ForegroundColor Green
+ }
+} else {
+ Write-Host " 通知服务器失败,但本地配置已完成" -ForegroundColor Yellow
+ $testDeferred = $true
+}
+
+if ($testDeferred) {
+ Write-Host "[14/17] 连接测试已延后,请在后台完成主机注册" -ForegroundColor Yellow
+} else {
+ Write-Host "[14/17] 等待连接测试..." -ForegroundColor Yellow
+ $testResult = $null
+ $testWaited = 0
+ $maxTestWait = 60
+ while ($testWaited -lt $maxTestWait) {
+ Start-Sleep -Seconds 5
+ $testWaited += 5
+ try {
+ $testResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/test-result/?token=$Token" -Method Get
+ if ($testResp.status -ne "testing") {
+ $testResult = $testResp
+ break
+ }
+ } catch {
+ continue
+ }
+ Write-Host " 测试中... ($testWaited/${maxTestWait}s)" -ForegroundColor DarkGray
+ }
+ if ($testResult -and $testResult.status -eq "success") {
+ Write-Host " 连接测试成功!" -ForegroundColor Green
+ } else {
+ Write-Host " 连接测试失败或超时" -ForegroundColor Yellow
+ }
+}
+
+Write-Host "[15/17] 安全性提升选项" -ForegroundColor Yellow
+$choice = Read-Host "是否禁用密码认证以提升安全性?(Y/N)"
+if ($choice -eq "Y" -or $choice -eq "y") {
+ Write-Host "[16/17] 禁用密码认证..." -ForegroundColor Yellow
+ Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $false
+ Set-Item -Path WSMan:\localhost\Service\Auth\Digest -Value $false
+ Set-Item -Path WSMan:\localhost\Service\Auth\Kerberos -Value $false
+ Set-Item -Path WSMan:\localhost\Service\Auth\CredSSP -Value $false
+ Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $false
+ Restart-Service WinRM -Force
+ $disableBody = @{ token = $Token } | ConvertTo-Json
+ Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/disable-password-auth/" -Method Post -Body $disableBody -ContentType "application/json"
+ Write-Host " 已禁用密码认证,仅允许证书认证" -ForegroundColor Green
+} else {
+ Write-Host " 保留密码认证" -ForegroundColor Yellow
+}
+
+Write-Host "[17/17] 清理临时文件..." -ForegroundColor Yellow
+Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue
+Write-Host "`n=== 配置完成 ===" -ForegroundColor Cyan
diff --git a/templates/accounts/forgot_password.html b/templates/accounts/forgot_password.html
index 51cc1f7..da06c3f 100755
--- a/templates/accounts/forgot_password.html
+++ b/templates/accounts/forgot_password.html
@@ -50,12 +50,8 @@ 重置密码
mail
- {% if CAPTCHA_PROVIDER == 'geetest' %}
- 获取验证码
- {% elif CAPTCHA_PROVIDER == 'turnstile' %}
- 获取验证码
- {% elif CAPTCHA_PROVIDER == 'local' %}
- 获取验证码
+ {% if CAPTCHA_PROVIDER == 'tianai' %}
+ 获取验证码
{% else %}
获取验证码
{% endif %}
@@ -89,25 +85,11 @@
重置密码
{% endif %}
-
-
-
-
-
+
- {% if CAPTCHA_PROVIDER == 'geetest' %}
-
- autorenew
- 重置密码
-
- {% elif CAPTCHA_PROVIDER == 'turnstile' %}
-
- autorenew
- 重置密码
-
- {% elif CAPTCHA_PROVIDER == 'local' %}
-
+ {% if CAPTCHA_PROVIDER == 'tianai' %}
+
autorenew
重置密码
@@ -129,23 +111,22 @@ 重置密码
-
- {% if CAPTCHA_PROVIDER == 'geetest' %}
-
- {% elif CAPTCHA_PROVIDER == 'local' %}
-
- {% elif CAPTCHA_PROVIDER == 'turnstile' %}
-
+ {% if CAPTCHA_PROVIDER == 'tianai' %}
+ {% load static %}
+
+
+
{% endif %}
diff --git a/templates/accounts/login.html b/templates/accounts/login.html
index 25616b3..41a67de 100644
--- a/templates/accounts/login.html
+++ b/templates/accounts/login.html
@@ -79,18 +79,8 @@ 欢迎回来
- {% if CAPTCHA_PROVIDER == 'geetest' %}
-
- login
- 登录
-
- {% elif CAPTCHA_PROVIDER == 'turnstile' %}
-
- login
- 登录
-
- {% elif CAPTCHA_PROVIDER == 'local' %}
-
+ {% if CAPTCHA_PROVIDER == 'tianai' %}
+
login
登录
@@ -102,11 +92,7 @@ 欢迎回来
{% endif %}
-
-
-
-
-
+
@@ -119,23 +105,19 @@
欢迎回来
-
- {% if CAPTCHA_PROVIDER == 'geetest' %}
-
- {% elif CAPTCHA_PROVIDER == 'local' %}
-
- {% elif CAPTCHA_PROVIDER == 'turnstile' %}
-
+ {% if CAPTCHA_PROVIDER == 'tianai' %}
+ {% load static %}
+
+
+
{% endif %}
diff --git a/templates/accounts/register.html b/templates/accounts/register.html
index 747bf3c..21d02ee 100644
--- a/templates/accounts/register.html
+++ b/templates/accounts/register.html
@@ -47,10 +47,7 @@
{% if reglink %}邀请