mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
10 Commits
v1.2.6-dev
...
v1.2.9-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da96d437a4 | ||
|
|
feaf8062e5 | ||
|
|
4bab76f233 | ||
|
|
09416b4615 | ||
|
|
bc1c5f6b0e | ||
|
|
2f2742e6fe | ||
|
|
be3c346a74 | ||
|
|
0c7a6fff12 | ||
|
|
3b4f0e3147 | ||
|
|
51212a2a0c |
@@ -28,6 +28,7 @@ class EndpointService:
|
||||
'host': 'host',
|
||||
'title': 'title',
|
||||
'status': 'status_code',
|
||||
'tech': 'tech',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
@@ -115,7 +116,7 @@ class EndpointService:
|
||||
"""获取目标下的所有端点"""
|
||||
queryset = self.repo.get_by_target(target_id)
|
||||
if filter_query:
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
|
||||
return queryset
|
||||
|
||||
def count_endpoints_by_target(self, target_id: int) -> int:
|
||||
@@ -134,7 +135,7 @@ class EndpointService:
|
||||
"""获取所有端点(全局查询)"""
|
||||
queryset = self.repo.get_all()
|
||||
if filter_query:
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
|
||||
return queryset
|
||||
|
||||
def iter_endpoint_urls_by_target(self, target_id: int, chunk_size: int = 1000) -> Iterator[str]:
|
||||
|
||||
@@ -20,6 +20,7 @@ class WebSiteService:
|
||||
'host': 'host',
|
||||
'title': 'title',
|
||||
'status': 'status_code',
|
||||
'tech': 'tech',
|
||||
}
|
||||
|
||||
def __init__(self, repository=None):
|
||||
@@ -107,14 +108,14 @@ class WebSiteService:
|
||||
"""获取目标下的所有网站"""
|
||||
queryset = self.repo.get_by_target(target_id)
|
||||
if filter_query:
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
|
||||
return queryset
|
||||
|
||||
def get_all(self, filter_query: Optional[str] = None):
|
||||
"""获取所有网站"""
|
||||
queryset = self.repo.get_all()
|
||||
if filter_query:
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING)
|
||||
queryset = apply_filters(queryset, filter_query, self.FILTER_FIELD_MAPPING, json_array_fields=['tech'])
|
||||
return queryset
|
||||
|
||||
def get_by_url(self, url: str, target_id: int) -> int:
|
||||
|
||||
@@ -274,6 +274,7 @@ class WebSiteViewSet(viewsets.ModelViewSet):
|
||||
- host="example" 主机名模糊匹配
|
||||
- title="login" 标题模糊匹配
|
||||
- status="200,301" 状态码多值匹配
|
||||
- tech="nginx" 技术栈匹配(数组字段)
|
||||
- 多条件空格分隔 AND 关系
|
||||
"""
|
||||
|
||||
@@ -534,6 +535,7 @@ class EndpointViewSet(viewsets.ModelViewSet):
|
||||
- host="example" 主机名模糊匹配
|
||||
- title="login" 标题模糊匹配
|
||||
- status="200,301" 状态码多值匹配
|
||||
- tech="nginx" 技术栈匹配(数组字段)
|
||||
- 多条件空格分隔 AND 关系
|
||||
"""
|
||||
|
||||
|
||||
@@ -40,8 +40,14 @@ def fetch_config_and_setup_django():
|
||||
print(f"[CONFIG] 正在从配置中心获取配置: {config_url}")
|
||||
print(f"[CONFIG] IS_LOCAL={is_local}")
|
||||
try:
|
||||
# 构建请求头(包含 Worker API Key)
|
||||
headers = {}
|
||||
worker_api_key = os.environ.get("WORKER_API_KEY", "")
|
||||
if worker_api_key:
|
||||
headers["X-Worker-API-Key"] = worker_api_key
|
||||
|
||||
# verify=False: 远程 Worker 通过 HTTPS 访问时可能使用自签名证书
|
||||
resp = requests.get(config_url, timeout=10, verify=False)
|
||||
resp = requests.get(config_url, headers=headers, timeout=10, verify=False)
|
||||
resp.raise_for_status()
|
||||
config = resp.json()
|
||||
|
||||
@@ -57,9 +63,6 @@ def fetch_config_and_setup_django():
|
||||
os.environ.setdefault("DB_USER", db_user)
|
||||
os.environ.setdefault("DB_PASSWORD", config['db']['password'])
|
||||
|
||||
# Redis 配置
|
||||
os.environ.setdefault("REDIS_URL", config['redisUrl'])
|
||||
|
||||
# 日志配置
|
||||
os.environ.setdefault("LOG_DIR", config['paths']['logs'])
|
||||
os.environ.setdefault("LOG_LEVEL", config['logging']['level'])
|
||||
@@ -71,7 +74,6 @@ def fetch_config_and_setup_django():
|
||||
print(f"[CONFIG] DB_PORT: {db_port}")
|
||||
print(f"[CONFIG] DB_NAME: {db_name}")
|
||||
print(f"[CONFIG] DB_USER: {db_user}")
|
||||
print(f"[CONFIG] REDIS_URL: {config['redisUrl']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取配置失败: {config_url} - {e}", file=sys.stderr)
|
||||
|
||||
49
backend/apps/common/exception_handlers.py
Normal file
49
backend/apps/common/exception_handlers.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
自定义异常处理器
|
||||
|
||||
统一处理 DRF 异常,确保错误响应格式一致
|
||||
"""
|
||||
|
||||
from rest_framework.views import exception_handler
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import AuthenticationFailed, NotAuthenticated
|
||||
|
||||
from apps.common.response_helpers import error_response
|
||||
from apps.common.error_codes import ErrorCodes
|
||||
|
||||
|
||||
def custom_exception_handler(exc, context):
|
||||
"""
|
||||
自定义异常处理器
|
||||
|
||||
处理认证相关异常,返回统一格式的错误响应
|
||||
"""
|
||||
# 先调用 DRF 默认的异常处理器
|
||||
response = exception_handler(exc, context)
|
||||
|
||||
if response is not None:
|
||||
# 处理 401 未认证错误
|
||||
if response.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||
return error_response(
|
||||
code=ErrorCodes.UNAUTHORIZED,
|
||||
message='Authentication required',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# 处理 403 权限不足错误
|
||||
if response.status_code == status.HTTP_403_FORBIDDEN:
|
||||
return error_response(
|
||||
code=ErrorCodes.PERMISSION_DENIED,
|
||||
message='Permission denied',
|
||||
status_code=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# 处理 NotAuthenticated 和 AuthenticationFailed 异常
|
||||
if isinstance(exc, (NotAuthenticated, AuthenticationFailed)):
|
||||
return error_response(
|
||||
code=ErrorCodes.UNAUTHORIZED,
|
||||
message='Authentication required',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
return response
|
||||
80
backend/apps/common/permissions.py
Normal file
80
backend/apps/common/permissions.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
集中式权限管理
|
||||
|
||||
实现三类端点的认证逻辑:
|
||||
1. 公开端点(无需认证):登录、登出、获取当前用户状态
|
||||
2. Worker 端点(API Key 认证):注册、配置、心跳、回调、资源同步
|
||||
3. 业务端点(Session 认证):其他所有 API
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 公开端点白名单(无需任何认证)
|
||||
PUBLIC_ENDPOINTS = [
|
||||
r'^/api/auth/login/$',
|
||||
r'^/api/auth/logout/$',
|
||||
r'^/api/auth/me/$',
|
||||
]
|
||||
|
||||
# Worker API 端点(需要 API Key 认证)
|
||||
# 包括:注册、配置、心跳、回调、资源同步(字典下载)
|
||||
WORKER_ENDPOINTS = [
|
||||
r'^/api/workers/register/$',
|
||||
r'^/api/workers/config/$',
|
||||
r'^/api/workers/\d+/heartbeat/$',
|
||||
r'^/api/callbacks/',
|
||||
# 资源同步端点(Worker 需要下载字典文件)
|
||||
r'^/api/wordlists/download/$',
|
||||
# 注意:指纹导出 API 使用 Session 认证(前端用户导出用)
|
||||
# Worker 通过数据库直接获取指纹数据,不需要 HTTP API
|
||||
]
|
||||
|
||||
|
||||
class IsAuthenticatedOrPublic(BasePermission):
|
||||
"""
|
||||
自定义权限类:
|
||||
- 白名单内的端点公开访问
|
||||
- Worker 端点需要 API Key 认证
|
||||
- 其他端点需要 Session 认证
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
path = request.path
|
||||
|
||||
# 检查是否在公开白名单内
|
||||
for pattern in PUBLIC_ENDPOINTS:
|
||||
if re.match(pattern, path):
|
||||
return True
|
||||
|
||||
# 检查是否是 Worker 端点
|
||||
for pattern in WORKER_ENDPOINTS:
|
||||
if re.match(pattern, path):
|
||||
return self._check_worker_api_key(request)
|
||||
|
||||
# 其他路径需要 Session 认证
|
||||
return request.user and request.user.is_authenticated
|
||||
|
||||
def _check_worker_api_key(self, request):
|
||||
"""验证 Worker API Key"""
|
||||
api_key = request.headers.get('X-Worker-API-Key')
|
||||
expected_key = getattr(settings, 'WORKER_API_KEY', None)
|
||||
|
||||
if not expected_key:
|
||||
# 未配置 API Key 时,拒绝所有 Worker 请求
|
||||
logger.warning("WORKER_API_KEY 未配置,拒绝 Worker 请求")
|
||||
return False
|
||||
|
||||
if not api_key:
|
||||
logger.warning(f"Worker 请求缺少 X-Worker-API-Key Header: {request.path}")
|
||||
return False
|
||||
|
||||
if api_key != expected_key:
|
||||
logger.warning(f"Worker API Key 无效: {request.path}")
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -9,7 +9,7 @@ from django.utils.decorators import method_decorator
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from apps.common.response_helpers import success_response, error_response
|
||||
from apps.common.error_codes import ErrorCodes
|
||||
@@ -134,30 +134,10 @@ class ChangePasswordView(APIView):
|
||||
修改密码
|
||||
POST /api/auth/change-password/
|
||||
"""
|
||||
authentication_classes = [] # 禁用认证(绕过 CSRF)
|
||||
permission_classes = [AllowAny] # 手动检查登录状态
|
||||
|
||||
def post(self, request):
|
||||
# 手动检查登录状态(从 session 获取用户)
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
|
||||
user_id = request.session.get('_auth_user_id')
|
||||
if not user_id:
|
||||
return error_response(
|
||||
code=ErrorCodes.UNAUTHORIZED,
|
||||
message='Please login first',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
except User.DoesNotExist:
|
||||
return error_response(
|
||||
code=ErrorCodes.UNAUTHORIZED,
|
||||
message='User does not exist',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
# 使用全局权限类验证,request.user 已经是认证用户
|
||||
user = request.user
|
||||
|
||||
# CamelCaseParser 将 oldPassword -> old_password
|
||||
old_password = request.data.get('old_password')
|
||||
|
||||
@@ -9,7 +9,6 @@ import logging
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
@@ -42,9 +41,6 @@ class SystemLogFilesView(APIView):
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -80,15 +76,7 @@ class SystemLogsView(APIView):
|
||||
{
|
||||
"content": "日志内容字符串..."
|
||||
}
|
||||
|
||||
Note:
|
||||
- 当前为开发阶段,暂时允许匿名访问
|
||||
- 生产环境应添加管理员权限验证
|
||||
"""
|
||||
|
||||
# TODO: 生产环境应改为 IsAdminUser 权限
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
44
backend/apps/common/websocket_auth.py
Normal file
44
backend/apps/common/websocket_auth.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
WebSocket 认证基类
|
||||
|
||||
提供需要认证的 WebSocket Consumer 基类
|
||||
"""
|
||||
|
||||
import logging
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthenticatedWebsocketConsumer(AsyncWebsocketConsumer):
|
||||
"""
|
||||
需要认证的 WebSocket Consumer 基类
|
||||
|
||||
子类应该重写 on_connect() 方法实现具体的连接逻辑
|
||||
"""
|
||||
|
||||
async def connect(self):
|
||||
"""
|
||||
连接时验证用户认证状态
|
||||
|
||||
未认证时使用 close(code=4001) 拒绝连接
|
||||
"""
|
||||
user = self.scope.get('user')
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
logger.warning(
|
||||
f"WebSocket 连接被拒绝:用户未认证 - Path: {self.scope.get('path')}"
|
||||
)
|
||||
await self.close(code=4001)
|
||||
return
|
||||
|
||||
# 调用子类的连接逻辑
|
||||
await self.on_connect()
|
||||
|
||||
async def on_connect(self):
|
||||
"""
|
||||
子类实现具体的连接逻辑
|
||||
|
||||
默认实现:接受连接
|
||||
"""
|
||||
await self.accept()
|
||||
@@ -6,17 +6,17 @@ import json
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.common.websocket_auth import AuthenticatedWebsocketConsumer
|
||||
from apps.engine.services import WorkerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkerDeployConsumer(AsyncWebsocketConsumer):
|
||||
class WorkerDeployConsumer(AuthenticatedWebsocketConsumer):
|
||||
"""
|
||||
Worker 交互式终端 WebSocket Consumer
|
||||
|
||||
@@ -31,8 +31,8 @@ class WorkerDeployConsumer(AsyncWebsocketConsumer):
|
||||
self.read_task = None
|
||||
self.worker_service = WorkerService()
|
||||
|
||||
async def connect(self):
|
||||
"""连接时加入对应 Worker 的组并自动建立 SSH 连接"""
|
||||
async def on_connect(self):
|
||||
"""连接时加入对应 Worker 的组并自动建立 SSH 连接(已通过认证)"""
|
||||
self.worker_id = self.scope['url_route']['kwargs']['worker_id']
|
||||
self.group_name = f'worker_deploy_{self.worker_id}'
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ class Command(BaseCommand):
|
||||
single_config,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
default_flow_style=None,
|
||||
)
|
||||
except yaml.YAMLError as e:
|
||||
self.stdout.write(self.style.ERROR(f'生成子引擎 {scan_type} 配置失败: {e}'))
|
||||
|
||||
@@ -66,6 +66,7 @@ def get_start_agent_script(
|
||||
# 替换变量
|
||||
script = script.replace("{{HEARTBEAT_API_URL}}", heartbeat_api_url or '')
|
||||
script = script.replace("{{WORKER_ID}}", str(worker_id) if worker_id else '')
|
||||
script = script.replace("{{WORKER_API_KEY}}", getattr(settings, 'WORKER_API_KEY', ''))
|
||||
|
||||
# 注入镜像版本配置(确保远程节点使用相同版本)
|
||||
docker_user = getattr(settings, 'DOCKER_USER', 'yyhuni')
|
||||
|
||||
@@ -340,13 +340,12 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
|
||||
返回:
|
||||
{
|
||||
"db": {"host": "...", "port": "...", ...},
|
||||
"redisUrl": "...",
|
||||
"paths": {"results": "...", "logs": "..."}
|
||||
}
|
||||
|
||||
配置逻辑:
|
||||
- 本地 Worker (is_local=true): db_host=postgres, redis=redis:6379
|
||||
- 远程 Worker (is_local=false): db_host=PUBLIC_HOST, redis=PUBLIC_HOST:6379
|
||||
- 本地 Worker (is_local=true): db_host=postgres
|
||||
- 远程 Worker (is_local=false): db_host=PUBLIC_HOST
|
||||
"""
|
||||
from django.conf import settings
|
||||
import logging
|
||||
@@ -371,20 +370,17 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
|
||||
if is_local_worker:
|
||||
# 本地 Worker:直接用 Docker 内部服务名
|
||||
worker_db_host = 'postgres'
|
||||
worker_redis_url = 'redis://redis:6379/0'
|
||||
else:
|
||||
# 远程 Worker:通过公网 IP 访问
|
||||
public_host = settings.PUBLIC_HOST
|
||||
if public_host in ('server', 'localhost', '127.0.0.1'):
|
||||
logger.warning("远程 Worker 请求配置,但 PUBLIC_HOST=%s 不是有效的公网地址", public_host)
|
||||
worker_db_host = public_host
|
||||
worker_redis_url = f'redis://{public_host}:6379/0'
|
||||
else:
|
||||
# 远程数据库场景:所有 Worker 都用 DB_HOST
|
||||
worker_db_host = db_host
|
||||
worker_redis_url = getattr(settings, 'WORKER_REDIS_URL', 'redis://redis:6379/0')
|
||||
|
||||
logger.info("返回 Worker 配置 - db_host: %s, redis_url: %s", worker_db_host, worker_redis_url)
|
||||
logger.info("返回 Worker 配置 - db_host: %s", worker_db_host)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -395,7 +391,6 @@ class WorkerNodeViewSet(viewsets.ModelViewSet):
|
||||
'user': settings.DATABASES['default']['USER'],
|
||||
'password': settings.DATABASES['default']['PASSWORD'],
|
||||
},
|
||||
'redisUrl': worker_redis_url,
|
||||
'paths': {
|
||||
'results': getattr(settings, 'CONTAINER_RESULTS_MOUNT', '/opt/xingrin/results'),
|
||||
'logs': getattr(settings, 'CONTAINER_LOGS_MOUNT', '/opt/xingrin/logs'),
|
||||
|
||||
@@ -87,7 +87,7 @@ fingerprint_detect:
|
||||
tools:
|
||||
xingfinger:
|
||||
enabled: true
|
||||
fingerprint-libs: [ehole, goby, wappalyzer] # 启用的指纹库:ehole, goby, wappalyzer, fingers, fingerprinthub, arl
|
||||
fingerprint-libs: [ehole, goby, wappalyzer, fingers, fingerprinthub, arl] # 全部指纹库
|
||||
|
||||
# ==================== 目录扫描 ====================
|
||||
directory_scan:
|
||||
|
||||
@@ -37,28 +37,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def calculate_fingerprint_detect_timeout(
|
||||
url_count: int,
|
||||
base_per_url: float = 3.0,
|
||||
min_timeout: int = 60
|
||||
base_per_url: float = 5.0,
|
||||
min_timeout: int = 300
|
||||
) -> int:
|
||||
"""
|
||||
根据 URL 数量计算超时时间
|
||||
|
||||
公式:超时时间 = URL 数量 × 每 URL 基础时间
|
||||
最小值:60秒
|
||||
最小值:300秒
|
||||
无上限
|
||||
|
||||
Args:
|
||||
url_count: URL 数量
|
||||
base_per_url: 每 URL 基础时间(秒),默认 3秒
|
||||
min_timeout: 最小超时时间(秒),默认 60秒
|
||||
base_per_url: 每 URL 基础时间(秒),默认 5秒
|
||||
min_timeout: 最小超时时间(秒),默认 300秒
|
||||
|
||||
Returns:
|
||||
int: 计算出的超时时间(秒)
|
||||
|
||||
示例:
|
||||
100 URL × 3秒 = 300秒
|
||||
1000 URL × 3秒 = 3000秒(50分钟)
|
||||
10000 URL × 3秒 = 30000秒(8.3小时)
|
||||
"""
|
||||
timeout = int(url_count * base_per_url)
|
||||
return max(min_timeout, timeout)
|
||||
|
||||
@@ -5,12 +5,13 @@ WebSocket Consumer - 通知实时推送
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
|
||||
from apps.common.websocket_auth import AuthenticatedWebsocketConsumer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationConsumer(AsyncWebsocketConsumer):
|
||||
class NotificationConsumer(AuthenticatedWebsocketConsumer):
|
||||
"""
|
||||
通知 WebSocket Consumer
|
||||
|
||||
@@ -23,9 +24,9 @@ class NotificationConsumer(AsyncWebsocketConsumer):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.heartbeat_task = None # 心跳任务
|
||||
|
||||
async def connect(self):
|
||||
async def on_connect(self):
|
||||
"""
|
||||
客户端连接时调用
|
||||
客户端连接时调用(已通过认证)
|
||||
加入通知广播组
|
||||
"""
|
||||
# 通知组名(所有客户端共享)
|
||||
|
||||
@@ -305,6 +305,7 @@ def _push_via_api_callback(notification: Notification, server_url: str) -> None:
|
||||
通过 HTTP 请求 Server 容器的 /api/callbacks/notification/ 接口。
|
||||
Worker 无法直接访问 Redis,需要由 Server 代为推送 WebSocket。
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
|
||||
try:
|
||||
@@ -318,8 +319,14 @@ def _push_via_api_callback(notification: Notification, server_url: str) -> None:
|
||||
'created_at': notification.created_at.isoformat()
|
||||
}
|
||||
|
||||
# 构建请求头(包含 Worker API Key)
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
worker_api_key = os.environ.get("WORKER_API_KEY", "")
|
||||
if worker_api_key:
|
||||
headers["X-Worker-API-Key"] = worker_api_key
|
||||
|
||||
# verify=False: 远程 Worker 回调 Server 时可能使用自签名证书
|
||||
resp = requests.post(callback_url, json=data, timeout=5, verify=False)
|
||||
resp = requests.post(callback_url, json=data, headers=headers, timeout=5, verify=False)
|
||||
resp.raise_for_status()
|
||||
|
||||
logger.debug(f"通知回调推送成功 - ID: {notification.id}")
|
||||
|
||||
@@ -113,9 +113,10 @@ def bulk_merge_tech_field(
|
||||
host = parsed.hostname or ''
|
||||
|
||||
# 插入新记录(带冲突处理)
|
||||
# 显式传入所有 NOT NULL 字段的默认值
|
||||
insert_sql = f"""
|
||||
INSERT INTO {table_name} (target_id, url, host, tech, created_at)
|
||||
VALUES (%s, %s, %s, %s::varchar[], NOW())
|
||||
INSERT INTO {table_name} (target_id, url, host, location, title, webserver, body_preview, content_type, tech, created_at)
|
||||
VALUES (%s, %s, %s, '', '', '', '', '', %s::varchar[], NOW())
|
||||
ON CONFLICT (target_id, url) DO UPDATE SET
|
||||
tech = (
|
||||
SELECT ARRAY(SELECT DISTINCT unnest(
|
||||
|
||||
@@ -96,7 +96,13 @@ def ensure_wordlist_local(wordlist_name: str) -> str:
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
with urllib_request.urlopen(download_url, context=ssl_context) as resp:
|
||||
# 创建带 API Key 的请求
|
||||
req = urllib_request.Request(download_url)
|
||||
worker_api_key = os.getenv('WORKER_API_KEY', '')
|
||||
if worker_api_key:
|
||||
req.add_header('X-Worker-API-Key', worker_api_key)
|
||||
|
||||
with urllib_request.urlopen(req, context=ssl_context) as resp:
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"下载字典失败,HTTP {resp.status}")
|
||||
data = resp.read()
|
||||
|
||||
@@ -177,6 +177,10 @@ STATIC_URL = 'static/'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# ==================== Worker API Key 配置 ====================
|
||||
# Worker 节点认证密钥(从环境变量读取)
|
||||
WORKER_API_KEY = os.environ.get('WORKER_API_KEY', '')
|
||||
|
||||
# ==================== REST Framework 配置 ====================
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PAGINATION_CLASS': 'apps.common.pagination.BasePagination', # 使用基础分页器
|
||||
@@ -186,6 +190,14 @@ REST_FRAMEWORK = {
|
||||
'apps.common.authentication.CsrfExemptSessionAuthentication',
|
||||
],
|
||||
|
||||
# 全局权限配置:默认需要认证,公开端点和 Worker 端点在权限类中单独处理
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'apps.common.permissions.IsAuthenticatedOrPublic',
|
||||
],
|
||||
|
||||
# 自定义异常处理器:统一 401/403 错误响应格式
|
||||
'EXCEPTION_HANDLER': 'apps.common.exception_handlers.custom_exception_handler',
|
||||
|
||||
# JSON 命名格式转换:后端 snake_case ↔ 前端 camelCase
|
||||
'DEFAULT_RENDERER_CLASSES': (
|
||||
'djangorestframework_camel_case.render.CamelCaseJSONRenderer', # 响应数据转换为 camelCase
|
||||
@@ -355,25 +367,16 @@ HOST_WORDLISTS_DIR = '/opt/xingrin/wordlists'
|
||||
# ============================================
|
||||
# Worker 配置中心(任务容器从 /api/workers/config/ 获取)
|
||||
# ============================================
|
||||
# Worker 数据库/Redis 地址由 worker_views.py 的 config API 动态返回
|
||||
# Worker 数据库地址由 worker_views.py 的 config API 动态返回
|
||||
# 根据请求来源(本地/远程)返回不同的配置:
|
||||
# - 本地 Worker(Docker 网络内):使用内部服务名(postgres, redis)
|
||||
# - 本地 Worker(Docker 网络内):使用内部服务名 postgres
|
||||
# - 远程 Worker(公网访问):使用 PUBLIC_HOST
|
||||
#
|
||||
# 以下变量仅作为备用/兼容配置,实际配置由 API 动态生成
|
||||
# 注意:Redis 仅在 Server 容器内使用,Worker 不需要直接连接 Redis
|
||||
_db_host = DATABASES['default']['HOST']
|
||||
_is_internal_db = _db_host in ('postgres', 'localhost', '127.0.0.1')
|
||||
WORKER_DB_HOST = os.getenv('WORKER_DB_HOST', _db_host)
|
||||
|
||||
# 远程 Worker 访问 Redis 的地址(自动推导)
|
||||
# - 如果 PUBLIC_HOST 是外部 IP → 使用 PUBLIC_HOST
|
||||
# - 如果 PUBLIC_HOST 是 Docker 内部名 → 使用 redis(本地部署)
|
||||
_is_internal_public = PUBLIC_HOST in ('server', 'localhost', '127.0.0.1')
|
||||
WORKER_REDIS_URL = os.getenv(
|
||||
'WORKER_REDIS_URL',
|
||||
'redis://redis:6379/0' if _is_internal_public else f'redis://{PUBLIC_HOST}:6379/0'
|
||||
)
|
||||
|
||||
# 容器内挂载目标路径(统一使用 /opt/xingrin)
|
||||
CONTAINER_RESULTS_MOUNT = '/opt/xingrin/results'
|
||||
CONTAINER_LOGS_MOUNT = '/opt/xingrin/logs'
|
||||
|
||||
@@ -16,7 +16,6 @@ Including another URLconf
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from rest_framework import permissions
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
|
||||
@@ -30,7 +29,6 @@ schema_view = get_schema_view(
|
||||
description="Web 应用侦察工具 API 文档",
|
||||
),
|
||||
public=True,
|
||||
permission_classes=(permissions.AllowAny,),
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
@@ -95,6 +95,7 @@ EOF
|
||||
|
||||
RESPONSE=$(curl -k -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Worker-API-Key: ${WORKER_API_KEY}" \
|
||||
-d "$REGISTER_DATA" \
|
||||
"${API_URL}/api/workers/register/" 2>/dev/null)
|
||||
|
||||
@@ -116,7 +117,7 @@ if [ -z "$WORKER_ID" ]; then
|
||||
# 等待 Server 就绪
|
||||
log "等待 Server 就绪..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -k -s "${API_URL}/api/" > /dev/null 2>&1; then
|
||||
if curl -k -s -H "X-Worker-API-Key: ${WORKER_API_KEY}" "${API_URL}/api/workers/config/?is_local=${IS_LOCAL}" > /dev/null 2>&1; then
|
||||
log "${GREEN}Server 已就绪${NC}"
|
||||
break
|
||||
fi
|
||||
@@ -189,6 +190,7 @@ EOF
|
||||
RESPONSE_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -k -s -o "$RESPONSE_FILE" -w "%{http_code}" -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Worker-API-Key: ${WORKER_API_KEY}" \
|
||||
-d "$JSON_DATA" \
|
||||
"${API_URL}/api/workers/${WORKER_ID}/heartbeat/" 2>/dev/null || echo "000")
|
||||
RESPONSE_BODY=$(cat "$RESPONSE_FILE" 2>/dev/null)
|
||||
|
||||
@@ -30,6 +30,7 @@ IMAGE="${DOCKER_USER}/xingrin-agent:${IMAGE_TAG}"
|
||||
# 预设变量(远程部署时由 deploy_service.py 替换)
|
||||
PRESET_SERVER_URL="{{HEARTBEAT_API_URL}}"
|
||||
PRESET_WORKER_ID="{{WORKER_ID}}"
|
||||
PRESET_API_KEY="{{WORKER_API_KEY}}"
|
||||
|
||||
# 颜色定义
|
||||
GREEN='\033[0;32m'
|
||||
@@ -68,6 +69,7 @@ start_agent() {
|
||||
-e SERVER_URL="${PRESET_SERVER_URL}" \
|
||||
-e WORKER_ID="${PRESET_WORKER_ID}" \
|
||||
-e IMAGE_TAG="${IMAGE_TAG}" \
|
||||
-e WORKER_API_KEY="${PRESET_API_KEY}" \
|
||||
-v /proc:/host/proc:ro \
|
||||
${IMAGE}
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ DB_USER=postgres
|
||||
DB_PASSWORD=123.com
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
# 在 Docker 网络中,Redis 服务名称为 redis
|
||||
# Redis 仅在 Docker 内部网络使用,不暴露公网端口
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_DB=0
|
||||
|
||||
# ==================== 服务端口配置 ====================
|
||||
@@ -51,6 +50,12 @@ LOG_LEVEL=INFO
|
||||
# 是否记录命令执行日志(大量扫描时会增加磁盘占用)
|
||||
ENABLE_COMMAND_LOGGING=true
|
||||
|
||||
# ==================== Worker API Key 配置 ====================
|
||||
# Worker 节点认证密钥(用于 Worker 与主服务器之间的 API 认证)
|
||||
# 生产环境务必更换为随机强密钥(建议 32 位以上随机字符串)
|
||||
# 生成方法: openssl rand -hex 32
|
||||
WORKER_API_KEY=change-me-to-a-secure-random-key
|
||||
|
||||
# ==================== Docker Hub 配置(生产模式) ====================
|
||||
# 生产模式下从 Docker Hub 拉取镜像时使用
|
||||
DOCKER_USER=yyhuni
|
||||
|
||||
@@ -24,8 +24,6 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "${REDIS_PORT}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
|
||||
@@ -30,8 +30,6 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- "${REDIS_PORT}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
@@ -72,6 +70,7 @@ services:
|
||||
- WORKER_NAME=本地节点
|
||||
- IS_LOCAL=true
|
||||
- IMAGE_TAG=${IMAGE_TAG}
|
||||
- WORKER_API_KEY=${WORKER_API_KEY}
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
- **权限**: sudo 管理员权限
|
||||
- **端口要求**: 需要开放以下端口
|
||||
- `8083` - HTTPS 访问(主要访问端口)
|
||||
- `5432` - PostgreSQL 数据库(如使用本地数据库)
|
||||
- `6379` - Redis 缓存服务
|
||||
- `5432` - PostgreSQL 数据库(如使用本地数据库且有远程 Worker)
|
||||
- 后端 API 仅容器内监听 8888,由 nginx 反代到 8083,对公网无需放行 8888
|
||||
- Redis 仅在 Docker 内部网络使用,无需对外开放
|
||||
|
||||
## 一键安装
|
||||
|
||||
@@ -60,8 +60,7 @@ sudo ./install.sh --no-frontend
|
||||
#### 必须放行的端口
|
||||
```
|
||||
8083 - HTTPS 访问(主要访问端口)
|
||||
5432 - PostgreSQL(如使用本地数据库)
|
||||
6379 - Redis 缓存
|
||||
5432 - PostgreSQL(如使用本地数据库且有远程 Worker)
|
||||
```
|
||||
|
||||
#### 推荐方案
|
||||
|
||||
@@ -14,6 +14,7 @@ const ENDPOINT_FILTER_FIELDS: FilterField[] = [
|
||||
{ key: "host", label: "Host", description: "Hostname" },
|
||||
{ key: "title", label: "Title", description: "Page title" },
|
||||
{ key: "status", label: "Status", description: "HTTP status code" },
|
||||
{ key: "tech", label: "Tech", description: "Technologies" },
|
||||
]
|
||||
|
||||
// Endpoint page filter examples
|
||||
@@ -21,6 +22,7 @@ const ENDPOINT_FILTER_EXAMPLES = [
|
||||
'url="/api/*" && status="200"',
|
||||
'host="api.example.com" || host="admin.example.com"',
|
||||
'title="Dashboard" && status!="404"',
|
||||
'tech="php" || tech="wordpress"',
|
||||
]
|
||||
|
||||
interface EndpointsDataTableProps<TData extends { id: number | string }, TValue> {
|
||||
|
||||
@@ -15,6 +15,7 @@ const WEBSITE_FILTER_FIELDS: FilterField[] = [
|
||||
{ key: "host", label: "Host", description: "Hostname" },
|
||||
{ key: "title", label: "Title", description: "Page title" },
|
||||
{ key: "status", label: "Status", description: "HTTP status code" },
|
||||
{ key: "tech", label: "Tech", description: "Technologies" },
|
||||
]
|
||||
|
||||
// Website page filter examples
|
||||
@@ -22,6 +23,7 @@ const WEBSITE_FILTER_EXAMPLES = [
|
||||
'host="api.example.com" && status="200"',
|
||||
'title="Login" || title="Admin"',
|
||||
'url="/api/*" && status!="404"',
|
||||
'tech="nginx" || tech="apache"',
|
||||
]
|
||||
|
||||
interface WebSitesDataTableProps {
|
||||
|
||||
@@ -244,8 +244,8 @@ export function useBatchDeleteOrganizations() {
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
toastMessages.dismiss('batch-delete')
|
||||
const { deletedOrganizationCount } = response
|
||||
toastMessages.success('toast.organization.delete.bulkSuccess', { count: deletedOrganizationCount })
|
||||
const { deletedCount } = response
|
||||
toastMessages.success('toast.organization.delete.bulkSuccess', { count: deletedCount })
|
||||
},
|
||||
onError: (error: any, deletedIds, context) => {
|
||||
toastMessages.dismiss('batch-delete')
|
||||
|
||||
@@ -136,6 +136,20 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized: redirect to login page
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const url = error.config?.url || '';
|
||||
// Exclude auth-related APIs to avoid redirect loops
|
||||
const isAuthApi = url.includes('/auth/login') ||
|
||||
url.includes('/auth/logout') ||
|
||||
url.includes('/auth/me');
|
||||
|
||||
if (!isAuthApi && typeof window !== 'undefined') {
|
||||
// Clear any cached state and redirect to login
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -127,13 +127,15 @@ export class OrganizationService {
|
||||
*/
|
||||
static async batchDeleteOrganizations(organizationIds: number[]): Promise<{
|
||||
message: string
|
||||
deletedOrganizationCount: number
|
||||
deletedCount: number
|
||||
deletedOrganizations: string[]
|
||||
}> {
|
||||
const response = await api.post<{
|
||||
message: string
|
||||
deletedOrganizationCount: number
|
||||
}>('/organizations/batch_delete/', {
|
||||
organizationIds // [OK] Use camelCase, interceptor will automatically convert to organization_ids
|
||||
deletedCount: number
|
||||
deletedOrganizations: string[]
|
||||
}>('/organizations/bulk-delete/', {
|
||||
ids: organizationIds // Backend expects 'ids' parameter
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ show_summary() {
|
||||
|
||||
echo -e "${YELLOW}[!] 云服务器某些厂商默认开启了安全策略(阿里云/腾讯云/华为云等):${RESET}"
|
||||
echo -e " 端口未放行可能导致无法访问或无法扫描,强烈推荐用国外vps,或者在云控制台放行:"
|
||||
echo -e " ${RESET}8083, 5432, 6379"
|
||||
echo -e " ${RESET}8083, 5432"
|
||||
echo
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user