GVSDServiceServer:VenomPackage 增 Dependencies(JSON)/Readme(Text) 与迁移 0002,新增按包名取详情接口 /api/venom/project/<包名>/,定制页配置新增依赖编辑器与 README 编辑。 gvsds:新增 /API/Venom/Project/<包名> 公开只读接口,按 PublicSiteHost 反查产品并回源提供者;SubscriptionGroup 增 PublicSiteHost 字段(迁移 0055)。 Main.py:venom 子域加 SPA fallback,/project/<包名>/ 回退 index.html。
1069 lines
41 KiB
Python
1069 lines
41 KiB
Python
import os
|
||
import sys
|
||
import ssl
|
||
import json
|
||
import time
|
||
import logging
|
||
import logging.handlers
|
||
import datetime
|
||
import traceback
|
||
import signal
|
||
import socket
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import colorama
|
||
import gevent
|
||
from gevent import monkey
|
||
from gevent.pywsgi import WSGIServer
|
||
from gevent.pool import Pool
|
||
from gevent.threadpool import ThreadPool
|
||
|
||
monkey.patch_all()
|
||
|
||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gvsds.settings')
|
||
|
||
import django
|
||
django.setup()
|
||
|
||
CPU_CORES = os.cpu_count() or 4
|
||
MAX_COROUTINES = CPU_CORES * 50
|
||
MAX_THREADS = CPU_CORES * 2
|
||
|
||
# 单实例控制端口(仅回环):全局只允许一个 Main.py 实例,同时作为 --reload 的通知通道
|
||
# 行协议:PING\n → PONG\n ;RELOAD [json-argv]\n → OK\n(收到后由既有实例按该参数自重启)
|
||
CONTROL_HOST = '127.0.0.1'
|
||
# ⚠ 默认端口必须避开 Windows「保留端口段」(Hyper-V / WSL / Docker 会动态保留若干区间):
|
||
# 落在保留段内时,即使没有任何进程监听,bind 也会失败(WinError 10013 权限禁止),
|
||
# 看起来像"该端口被占用"。查看保留段:netsh int ipv4 show excludedportrange protocol=tcp
|
||
# 同时取 < 49152(动态端口范围起点)的静态区,避免被其它程序临时占用。
|
||
# 本机实测:57321 落在保留段 57269-57368 → 必失败;45731 可正常绑定。
|
||
CONTROL_PORT = int(os.environ.get('MAIN_CONTROL_PORT') or 45731)
|
||
# 抢绑端口的宽限时间:覆盖"上一个实例刚 execv、端口尚未释放"的瞬间
|
||
INSTANCE_TAKEOVER_SECONDS = 5.0
|
||
# 抢绑重试间隔 / 探测既有实例的连接与应答超时(都要短,避免启动慢)
|
||
INSTANCE_RETRY_INTERVAL = 0.2
|
||
CONTROL_TIMEOUT = 0.5
|
||
|
||
# WSGI 环境的 SERVER_NAME:必须预置,否则 gevent 的 WSGIServer.init_socket() 会对监听地址
|
||
# 做反向 DNS(socket.getfqdn('0.0.0.0'))—— 实测本机要 1.3s 起,网络抖动时更久,期间服务器
|
||
# 一直没 started(表现为"启动很慢"甚至误判成启动失败)。用本机名预置即可跳过该查询(纯本地调用,不走 DNS)。
|
||
SERVER_NAME = socket.gethostname()
|
||
|
||
from gvsds.secrets import get as _secret_get
|
||
SQL_IP = _secret_get('database', 'host', fallback='127.0.0.1')
|
||
|
||
WHITE_HOSTS = [
|
||
"www.gvsds.com", "gvsds.com", "api.gvsds.com", "127.0.0.1", "localhost", SQL_IP,
|
||
"gvsds.dev", "yc8z.org", "ycbz.org", "gvsds.org", "gvsds.studio", "114.66.59.25",
|
||
"librok.gvsds.com", "rollcall.gvsds.com",
|
||
"viper.gvsds.com", "venom.gvsds.com",
|
||
]
|
||
BAN_IPS = [
|
||
"www.kanglaohui.com", "www.best-loving.cn", "11.4.5.14", "185.223.152.189"
|
||
]
|
||
|
||
PIRACY_DIR = Path("TempLate") / "Errors" / "Piracy"
|
||
PIRACY_LIST = os.listdir(PIRACY_DIR) if PIRACY_DIR.exists() else []
|
||
|
||
# 静态站点:host -> 站点根目录
|
||
# viper.gvsds.com 走 Django SubdomainView(TempLate/Viper,Vue 官网产物)
|
||
STATIC_SITES = {
|
||
"librok.gvsds.com": Path("TempLate") / "rok" / "lib",
|
||
"rollcall.gvsds.com": Path("TempLate") / "rollcall",
|
||
"venom.gvsds.com": Path("TempLate") / "Venom",
|
||
}
|
||
|
||
# 单页应用站点:命中不到文件时回退 index.html,交由前端路由处理
|
||
# (如 venom 官网的 /project/<包名>/ 详情页;带扩展名的资源请求仍按 404 处理)
|
||
SPA_STATIC_SITES = {"venom.gvsds.com"}
|
||
|
||
LOG_CONFIG = {
|
||
'disable_static_success': False,
|
||
'disable_favicon': False,
|
||
'disable_all_static': False,
|
||
'color_whole_line': True,
|
||
'static_extensions': ['.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot'],
|
||
'log_level': 'INFO',
|
||
'show_domain': True,
|
||
'show_full_host': True,
|
||
}
|
||
|
||
STATUS_COLOR_MAP = {
|
||
200: colorama.Fore.GREEN,
|
||
301: colorama.Fore.CYAN,
|
||
302: colorama.Fore.CYAN,
|
||
304: colorama.Fore.CYAN,
|
||
400: colorama.Fore.YELLOW,
|
||
403: colorama.Fore.RED,
|
||
404: colorama.Fore.MAGENTA,
|
||
500: colorama.Fore.RED + colorama.Style.BRIGHT,
|
||
502: colorama.Fore.RED + colorama.Style.BRIGHT,
|
||
503: colorama.Fore.RED + colorama.Style.BRIGHT,
|
||
}
|
||
|
||
LEVEL_COLOR_MAP = {
|
||
'DEBUG': colorama.Fore.CYAN,
|
||
'INFO': colorama.Fore.GREEN,
|
||
'WARNING': colorama.Fore.YELLOW,
|
||
'ERROR': colorama.Fore.RED,
|
||
'CRITICAL': colorama.Fore.RED + colorama.Style.BRIGHT,
|
||
}
|
||
|
||
logger = None
|
||
|
||
|
||
class NoOpLog:
|
||
@staticmethod
|
||
def write(msg):
|
||
pass
|
||
@staticmethod
|
||
def flush():
|
||
pass
|
||
|
||
|
||
class CustomWSGIServer(WSGIServer):
|
||
def handle_error(self, request, client_address):
|
||
exc_type, exc_value, exc_traceback = self.get_exc_info()
|
||
ignored_exceptions = (
|
||
ssl.SSLEOFError, ConnectionResetError, BrokenPipeError, OSError, EOFError
|
||
)
|
||
if isinstance(exc_value, ignored_exceptions):
|
||
logger.debug(f"客户端 {client_address} 连接异常断开: {exc_value}",
|
||
extra={'domain': 'system', 'status_code': 0})
|
||
return
|
||
super().handle_error(request, client_address)
|
||
|
||
|
||
class WholeLineColorFormatter(logging.Formatter):
|
||
def format(self, record):
|
||
timestamp = self.formatTime(record, datefmt='%Y-%m-%d %H:%M:%S')
|
||
levelname = record.levelname
|
||
message = record.getMessage()
|
||
status_code = getattr(record, 'status_code', 0)
|
||
domain = getattr(record, 'domain', '')
|
||
host = getattr(record, 'host', '')
|
||
|
||
if LOG_CONFIG['color_whole_line'] and status_code:
|
||
color = STATUS_COLOR_MAP.get(status_code, LEVEL_COLOR_MAP.get(levelname, colorama.Fore.WHITE))
|
||
else:
|
||
color = LEVEL_COLOR_MAP.get(levelname, colorama.Fore.WHITE)
|
||
|
||
log_parts = [f"[{timestamp}]", f"[{levelname:7s}]"]
|
||
if LOG_CONFIG['show_domain']:
|
||
if domain:
|
||
log_parts.append(f"[域:{domain:20s}]")
|
||
elif host:
|
||
log_parts.append(f"[Host:{host:20s}]")
|
||
|
||
log_parts.append(message)
|
||
log_line = " ".join(log_parts)
|
||
|
||
if LOG_CONFIG['color_whole_line']:
|
||
log_line = f"{color}{log_line}{colorama.Style.RESET_ALL}"
|
||
return log_line
|
||
|
||
|
||
class RequestFilter:
|
||
@staticmethod
|
||
def is_favicon(path):
|
||
return path.lower() == '/favicon.ico'
|
||
|
||
@staticmethod
|
||
def is_static_file(path):
|
||
ext = os.path.splitext(path)[1].lower()
|
||
return ext in LOG_CONFIG['static_extensions']
|
||
|
||
@staticmethod
|
||
def should_skip_log(path, status_code):
|
||
if LOG_CONFIG['disable_favicon'] and RequestFilter.is_favicon(path):
|
||
return True
|
||
if RequestFilter.is_static_file(path):
|
||
if LOG_CONFIG['disable_all_static']:
|
||
return True
|
||
if LOG_CONFIG['disable_static_success'] and status_code < 400:
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def extract_domain(host):
|
||
if not host:
|
||
return ''
|
||
return host.split(':')[0] if ':' in host else host
|
||
|
||
def GetIP(environ: dict = {}):
|
||
x_forwarded_for_raw = environ.get('HTTP_X_FORWARDED_FOR', '')
|
||
x_forwarded_for_list = [x.strip() for x in x_forwarded_for_raw.split(',') if x.strip()]
|
||
x_forwarded_for = x_forwarded_for_list[0] if x_forwarded_for_list else ''
|
||
return x_forwarded_for
|
||
|
||
class FilteredLoggingMiddleware:
|
||
def __init__(self, app):
|
||
self.app = app
|
||
|
||
def __call__(self, environ, start_response):
|
||
path = environ.get('PATH_INFO', '')
|
||
query_string = environ.get('QUERY_STRING', '')
|
||
full_path = path + (f"?{query_string}" if query_string else "")
|
||
method = environ.get('REQUEST_METHOD', '')
|
||
ip = environ.get('REMOTE_ADDR', '')
|
||
host = environ.get('HTTP_HOST', '')
|
||
domain = RequestFilter.extract_domain(host)
|
||
start_time = datetime.datetime.now()
|
||
|
||
real_ip = GetIP(environ)
|
||
real_ip = real_ip if real_ip else ip
|
||
|
||
|
||
if RequestFilter.should_skip_log(full_path, 200):
|
||
def wrapped_start_response(status, headers, exc_info=None):
|
||
return start_response(status, headers, exc_info)
|
||
return self.app(environ, wrapped_start_response)
|
||
|
||
def wrapped_start_response(status, headers, exc_info=None):
|
||
try:
|
||
status_code = int(status.split()[0]) if status else 0
|
||
|
||
if RequestFilter.should_skip_log(full_path, status_code):
|
||
return start_response(status, headers, exc_info)
|
||
|
||
duration = (datetime.datetime.now() - start_time).total_seconds()
|
||
log_message = f"[{status_code:3d}] [{method.ljust(4)[:4]}] [{real_ip:15}] [{duration:.6f}s] {full_path}"
|
||
|
||
extra = {
|
||
'status_code': status_code,
|
||
'domain': domain,
|
||
'host': host if LOG_CONFIG['show_full_host'] else ''
|
||
}
|
||
|
||
if status_code >= 500:
|
||
logger.error(log_message, extra=extra, exc_info=exc_info)
|
||
elif status_code >= 400:
|
||
logger.warning(log_message, extra=extra)
|
||
else:
|
||
logger.info(log_message, extra=extra)
|
||
except Exception as e:
|
||
logger.error(f"日志记录失败: {e}", exc_info=True)
|
||
|
||
return start_response(status, headers, exc_info)
|
||
|
||
return self.app(environ, wrapped_start_response)
|
||
|
||
import threading
|
||
import time
|
||
import re
|
||
|
||
class SecurityMiddleware:
|
||
def __init__(self, app):
|
||
self.app = app
|
||
|
||
# 恶意参数正则
|
||
self.bad_reg = re.compile(r'^(?=.{8,})(\d+[A-Za-z&]{1,}|[A-Za-z]\d+|&\d+)', re.I)
|
||
|
||
# ==================== 文件配置 ====================
|
||
self.ip_file = "./ips.txt" # 黑名单IP
|
||
self.ip_times_file = "./iptimes.txt" # IP:次数
|
||
self.total_times_file = "./times.txt" # 总拦截次数
|
||
|
||
# ==================== 内存存储 ====================
|
||
self.seen_ips = self.load_file_to_set(self.ip_file)
|
||
self.ip_counter = self.load_file_to_dict(self.ip_times_file) # {ip: count}
|
||
self.total_count = self.load_total_count()
|
||
|
||
# ==================== 线程队列 & 异步写入 ====================
|
||
self.queue = []
|
||
self.queue_lock = threading.Lock()
|
||
self.sync_interval = 5 # 每5秒批量落地一次
|
||
self.start_async_saver()
|
||
|
||
# ==================== 加载工具 ====================
|
||
def load_file_to_set(self, path):
|
||
s = set()
|
||
if os.path.exists(path):
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
ip = line.strip()
|
||
if ip:
|
||
s.add(ip)
|
||
return s
|
||
|
||
def load_file_to_dict(self, path):
|
||
d = {}
|
||
if os.path.exists(path):
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line or ":" not in line:
|
||
continue
|
||
ip, cnt = line.split(":", 1)
|
||
try:
|
||
d[ip.strip()] = int(cnt.strip())
|
||
except:
|
||
continue
|
||
return d
|
||
|
||
def load_total_count(self):
|
||
if os.path.exists(self.total_times_file):
|
||
try:
|
||
with open(self.total_times_file, "r", encoding="utf-8") as f:
|
||
return int(f.read().strip())
|
||
except:
|
||
return 0
|
||
return 0
|
||
|
||
# ==================== 异步落地方案(核心) ====================
|
||
def start_async_saver(self):
|
||
def saver():
|
||
while True:
|
||
time.sleep(self.sync_interval)
|
||
self.sync_to_disk()
|
||
|
||
t = threading.Thread(target=saver, daemon=True)
|
||
t.start()
|
||
|
||
def sync_to_disk(self):
|
||
with self.queue_lock:
|
||
if not self.queue:
|
||
return
|
||
queue_copy = self.queue.copy()
|
||
self.queue.clear()
|
||
|
||
# 批量更新
|
||
for ip in queue_copy:
|
||
self.ip_counter[ip] = self.ip_counter.get(ip, 0) + 1
|
||
self.total_count += 1
|
||
self.seen_ips.add(ip)
|
||
|
||
# 写入磁盘
|
||
with open(self.ip_times_file, "w", encoding="utf-8") as f:
|
||
for ip, cnt in self.ip_counter.items():
|
||
f.write(f"{ip}:{cnt}\n")
|
||
|
||
with open(self.ip_file, "w", encoding="utf-8") as f:
|
||
for ip in self.seen_ips:
|
||
f.write(f"{ip}\n")
|
||
|
||
with open(self.total_times_file, "w", encoding="utf-8") as f:
|
||
f.write(str(self.total_count))
|
||
|
||
# ==================== 记录IP(入队列,不阻塞) ====================
|
||
def add_ip_to_queue(self, ip):
|
||
with self.queue_lock:
|
||
self.queue.append(ip)
|
||
|
||
# ==================== 获取客户端IP ====================
|
||
def get_ip(self, environ):
|
||
forward = environ.get('HTTP_X_FORWARDED_FOR', '')
|
||
if forward:
|
||
ip = forward.split(',')[0].strip()
|
||
if ip:
|
||
return ip
|
||
return environ.get('REMOTE_ADDR', '127.0.0.1')
|
||
|
||
# ==================== 请求入口 ====================
|
||
def __call__(self, environ, start_response):
|
||
query = environ.get('QUERY_STRING', '')
|
||
|
||
# ==================== 拦截恶意参数 ====================
|
||
if query and self.bad_reg.search(query):
|
||
ip = self.get_ip(environ)
|
||
self.add_ip_to_queue(ip)
|
||
|
||
# 直接关闭连接,不产生日志
|
||
start_response('444 No Response', [])
|
||
return []
|
||
|
||
# ==================== 原有逻辑保持不变 ====================
|
||
host = environ.get('HTTP_HOST', '').lower()
|
||
path = environ.get('PATH_INFO', '')
|
||
|
||
# 跳转到 www.gvsds.com
|
||
if host == "gvsds.com":
|
||
location = f'https://www.gvsds.com{path}'
|
||
if query:
|
||
location += f'?{query}'
|
||
start_response('301 Moved Permanently', [('Location', location), ('Content-Type', 'text/plain')])
|
||
return [b'Redirecting to www.gvsds.com...']
|
||
|
||
# robots.txt
|
||
if path == "/robots.txt":
|
||
start_response('200 OK', [('Content-Type', 'text/plain')])
|
||
return [b'User-agent: *\nDisallow: /assets/']
|
||
|
||
# 盗版域名拦截
|
||
domain_suffix = ".".join(host.split(".")[-2:]) if "." in host else host
|
||
if domain_suffix in PIRACY_LIST:
|
||
try:
|
||
content = open(PIRACY_DIR / domain_suffix, encoding="utf-8").read()
|
||
except Exception:
|
||
content = "Access Denied"
|
||
start_response('403 Forbidden', [('Content-Type', 'text/html')])
|
||
return [content.encode('utf-8')]
|
||
|
||
# 非法HOST + 黑名单IP
|
||
x_forwarded_for = self.get_ip(environ)
|
||
host_no_port = host.split(':')[0] if ':' in host else host
|
||
if (host not in WHITE_HOSTS and host_no_port not in WHITE_HOSTS
|
||
and domain_suffix not in WHITE_HOSTS) or x_forwarded_for in BAN_IPS:
|
||
try:
|
||
content = open(Path("TempLate") / "Errors" / "_403_HOST.html", encoding="utf-8").read()
|
||
except Exception:
|
||
content = "Forbidden"
|
||
start_response('403 Forbidden', [('Content-Type', 'text/html')])
|
||
return [content.encode('utf-8')]
|
||
|
||
# 拦截脚本后缀
|
||
lower_path = path.lower().split("?")[0]
|
||
if "." in lower_path and lower_path.split(".")[-1] in ["php", "asp", "aspx", "cgi"]:
|
||
start_response('403 Forbidden', [('Content-Type', 'text/plain')])
|
||
return [b'Server does not support this type of page.']
|
||
|
||
# 静态站点路由:将指定 host 的请求直接映射到本地目录;
|
||
# /API/ 前缀(如 rollcall 数据持久化接口)转交 Django 处理
|
||
if host in STATIC_SITES:
|
||
if path.startswith('/API/'):
|
||
return self.app(environ, start_response)
|
||
return self._serve_static_site(environ, start_response, host, path)
|
||
|
||
return self.app(environ, start_response)
|
||
|
||
def _serve_static_site(self, environ, start_response, host, path):
|
||
"""为静态站点(host -> 目录)提供文件服务
|
||
- 默认入口:目录请求 / 返回 index.html
|
||
- 仅允许访问站点根目录内的文件(防止 ../ 越权)
|
||
"""
|
||
import mimetypes
|
||
mimetypes.add_type('application/javascript', '.js')
|
||
mimetypes.add_type('text/css', '.css')
|
||
mimetypes.add_type('image/svg+xml', '.svg')
|
||
|
||
site_root = STATIC_SITES[host].resolve()
|
||
# 清理路径:去掉前导 /,URL 反解码,过滤 ..
|
||
rel_path = path.lstrip('/')
|
||
if not rel_path:
|
||
rel_path = 'index.html'
|
||
try:
|
||
from urllib.parse import unquote
|
||
rel_path = unquote(rel_path)
|
||
except Exception:
|
||
pass
|
||
# 防止目录越权
|
||
if '..' in rel_path.split('/'):
|
||
start_response('403 Forbidden', [('Content-Type', 'text/plain')])
|
||
return [b'Forbidden']
|
||
|
||
target = (site_root / rel_path).resolve()
|
||
try:
|
||
target.relative_to(site_root)
|
||
except ValueError:
|
||
start_response('403 Forbidden', [('Content-Type', 'text/plain')])
|
||
return [b'Forbidden']
|
||
|
||
# 目录则尝试 index.html
|
||
if target.is_dir():
|
||
target = target / 'index.html'
|
||
# SPA 兜底:客户端路由(无扩展名,如 /project/<包名>/)回退站点入口
|
||
if not target.is_file() and host in SPA_STATIC_SITES \
|
||
and '.' not in os.path.basename(rel_path):
|
||
spa_index = site_root / 'index.html'
|
||
if spa_index.is_file():
|
||
target = spa_index
|
||
if not target.is_file():
|
||
start_response('404 Not Found', [('Content-Type', 'text/html')])
|
||
try:
|
||
content = open(Path("TempLate") / "Errors" / "404.html", encoding="utf-8").read().encode('utf-8')
|
||
except Exception:
|
||
content = b'404 Not Found'
|
||
return [content]
|
||
|
||
try:
|
||
data = target.read_bytes()
|
||
except Exception as e:
|
||
start_response('500 Internal Server Error', [('Content-Type', 'text/plain')])
|
||
return [f'Read error: {e}'.encode('utf-8')]
|
||
|
||
ctype, _ = mimetypes.guess_type(str(target))
|
||
if not ctype:
|
||
ctype = 'application/octet-stream'
|
||
start_response('200 OK', [
|
||
('Content-Type', ctype),
|
||
('Content-Length', str(len(data))),
|
||
('Cache-Control', 'no-cache'),
|
||
])
|
||
return [data]
|
||
|
||
|
||
def SetupLogging():
|
||
global logger
|
||
log_dir = "./logs"
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
log_filename = os.path.join(log_dir, f"server_{datetime.datetime.now().strftime('%Y-%m-%d')}.log")
|
||
|
||
log_level = getattr(logging, LOG_CONFIG['log_level'].upper(), logging.INFO)
|
||
formatter = WholeLineColorFormatter()
|
||
|
||
file_handler = logging.handlers.RotatingFileHandler(
|
||
log_filename, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8'
|
||
)
|
||
file_handler.setFormatter(formatter)
|
||
|
||
console_handler = logging.StreamHandler()
|
||
console_handler.setFormatter(formatter)
|
||
|
||
logging.basicConfig(level=log_level, handlers=[file_handler, console_handler], force=True)
|
||
|
||
for lib in ['django', 'django.request', 'django.server', 'django.db.backends']:
|
||
logging.getLogger(lib).setLevel(logging.WARNING)
|
||
|
||
logger = logging.getLogger('server')
|
||
logger.setLevel(log_level)
|
||
logger.propagate = False
|
||
|
||
if not logger.handlers:
|
||
logger.addHandler(file_handler)
|
||
logger.addHandler(console_handler)
|
||
|
||
return logger
|
||
|
||
|
||
class ConnectionPoolManager:
|
||
_instance = None
|
||
_lock = gevent.lock.BoundedSemaphore(1)
|
||
|
||
def __new__(cls):
|
||
if cls._instance is None:
|
||
with cls._lock:
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
cls._instance._init_pools()
|
||
return cls._instance
|
||
|
||
def _init_pools(self):
|
||
self.coroutine_pool = Pool(MAX_COROUTINES)
|
||
self.thread_pool = ThreadPool(MAX_THREADS)
|
||
|
||
|
||
def CreateSslContext(cert_path, key_path):
|
||
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||
context.load_cert_chain(certfile=cert_path, keyfile=key_path)
|
||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
||
context.set_ciphers('ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305')
|
||
context.options |= ssl.OP_CIPHER_SERVER_PREFERENCE
|
||
return context
|
||
|
||
|
||
def RedirectApp(environ, start_response):
|
||
query = environ.get('QUERY_STRING', '')
|
||
path = environ.get('PATH_INFO', '')
|
||
if query:
|
||
path += '?' + query
|
||
host = environ.get('HTTP_HOST', '')
|
||
location = f'https://{host}{path}'
|
||
|
||
domain = RequestFilter.extract_domain(host)
|
||
logger.info(f"[301] [GET] {path} - {environ.get('REMOTE_ADDR', '')} - 0.000000s",
|
||
extra={'status_code': 301, 'domain': domain, 'host': host})
|
||
|
||
start_response('301 Moved Permanently', [
|
||
('Location', location),
|
||
('Content-Type', 'text/plain'),
|
||
('Access-Control-Allow-Origin', f'https://{host}'),
|
||
('Access-Control-Allow-Credentials', 'true')
|
||
])
|
||
return [b'Redirecting to HTTPS...']
|
||
|
||
|
||
def get_django_wsgi_application():
|
||
from django.core.wsgi import get_wsgi_application
|
||
app = get_wsgi_application()
|
||
app = FilteredLoggingMiddleware(app)
|
||
app = SecurityMiddleware(app)
|
||
from gvsdsdk.msync.sio import create_sio_app
|
||
app = create_sio_app(app)
|
||
return app
|
||
|
||
|
||
class HotReloader:
|
||
"""文件变更探测(轮询 .py 的 mtime/size,**不用 watchdog**)
|
||
|
||
**为什么不用 watchdog**:`monkey.patch_all()` 把 threading 一起打了补丁,watchdog 的
|
||
Observer/BaseThread 于是变成 gevent 协程(实测 MRO:BaseThread → gevent.threading.Thread);
|
||
它内部阻塞的 ReadDirectoryChangesW 是 Win32 阻塞调用、不会让出 hub,一个协程就能把整个
|
||
事件循环卡死 —— 端口全在监听却不再 accept,80/443 与控制端口一起静默失联。
|
||
轮询是无副作用的纯检查,代价可忽略(约 400 个文件),且不引入任何线程/dependency。
|
||
"""
|
||
|
||
def __init__(self, watch_dirs=None, extensions=None):
|
||
self.watch_dirs = watch_dirs or ['./gvsds']
|
||
self.extensions = extensions or {'.py'}
|
||
self._changed = gevent.event.Event()
|
||
self._snapshot = {}
|
||
|
||
def _ShouldWatch(self, path):
|
||
ext = os.path.splitext(path)[1].lower()
|
||
if ext not in self.extensions:
|
||
return False
|
||
parts = os.path.normpath(path).split(os.sep)
|
||
return '__pycache__' not in parts and '.git' not in parts
|
||
|
||
def _Scan(self):
|
||
"""扫描监视目标,返回 {路径: (mtime, size)}(目录递归、单文件直取)"""
|
||
snapshot = {}
|
||
for target in self.watch_dirs:
|
||
node = Path(target)
|
||
if node.is_dir():
|
||
candidates = node.rglob('*')
|
||
elif node.is_file():
|
||
candidates = [node]
|
||
else:
|
||
continue
|
||
for item in candidates:
|
||
if not self._ShouldWatch(str(item)):
|
||
continue
|
||
try:
|
||
stat = item.stat()
|
||
except OSError:
|
||
continue
|
||
snapshot[str(item)] = (stat.st_mtime, stat.st_size)
|
||
return snapshot
|
||
|
||
def start(self):
|
||
"""记录初始快照作为基线(之后只对比变化)"""
|
||
self._snapshot = self._Scan()
|
||
logger.info(
|
||
f"[热重启] 文件监听已启动(轮询 mtime): {self.watch_dirs},共 {len(self._snapshot)} 个文件",
|
||
extra={'domain': 'system', 'status_code': 0},
|
||
)
|
||
|
||
def _Poll(self):
|
||
snapshot = self._Scan()
|
||
detail = None
|
||
for path, fingerprint in snapshot.items():
|
||
if self._snapshot.get(path) != fingerprint:
|
||
detail = os.path.relpath(path, '.')
|
||
break
|
||
if detail is None and len(snapshot) != len(self._snapshot):
|
||
detail = '文件新增或删除'
|
||
self._snapshot = snapshot
|
||
if detail is None:
|
||
return
|
||
logger.warning(f"[热重启] 检测到文件变更: {detail}", extra={'domain': 'system', 'status_code': 0})
|
||
self._changed.set()
|
||
|
||
def wait_for_change(self, timeout=None):
|
||
"""等一个轮询周期后检查一次变更;返回是否已置位"""
|
||
self._changed.clear()
|
||
gevent.sleep(timeout if timeout is not None else 1.0)
|
||
self._Poll()
|
||
return self._changed.is_set()
|
||
|
||
@property
|
||
def is_changed(self):
|
||
return self._changed.is_set()
|
||
|
||
|
||
class ControlServer:
|
||
"""单实例控制端口(仅 127.0.0.1)
|
||
|
||
两个职责:
|
||
1. **单实例闸门** —— 绑定该端口即"我是唯一实例",后来者绑不上就有据可查;
|
||
2. **热重载通道** —— 后来者先 PING 探测,再发 RELOAD 让既有实例自重启,
|
||
从而做到"全局只有一个 Main.py 实例,重启是通知而不是新起一个抢 80/443"。
|
||
|
||
行协议(客户端发一行,服务端回一行):
|
||
PING\\n → PONG\\n
|
||
RELOAD\\n → OK\\n(随后由主协程按本进程自己的启动参数重启,不在本协程里做)
|
||
|
||
两个必须遵守的实现约束:
|
||
- **不能设 SO_REUSEADDR** —— Windows 下它允许重复绑定同一端口,单实例闸门会失效;
|
||
- **连接就地处理、不另开协程** —— 消息只有几十字节,直接 accept 后同步收发即可;
|
||
一旦改成 spawn,处理协程若因任何原因没被调度,连接就会堆在 CloseWait 里占满
|
||
backlog,后来者被"连接拒绝",闸门与通知通道一起失效(实测踩过)。
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.host = CONTROL_HOST
|
||
self.port = CONTROL_PORT
|
||
self.on_reload = None
|
||
self.socket = None
|
||
self.greenlet = None
|
||
self.last_error = None
|
||
|
||
def SetReloadHandler(self, handler):
|
||
self.on_reload = handler
|
||
return self
|
||
|
||
def TryBind(self):
|
||
"""尝试绑定控制端口;成功返回 True,被占用返回 False(原因存 last_error)"""
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
try:
|
||
sock.bind((self.host, self.port))
|
||
sock.listen(8)
|
||
except OSError as e:
|
||
self.last_error = e
|
||
try:
|
||
sock.close()
|
||
except Exception:
|
||
pass
|
||
return False
|
||
sock.settimeout(1)
|
||
self.socket = sock
|
||
return True
|
||
|
||
def start(self):
|
||
self.greenlet = gevent.spawn(self._Serve)
|
||
return self
|
||
|
||
def _Serve(self):
|
||
while True:
|
||
try:
|
||
conn, addr = self.socket.accept()
|
||
except socket.timeout:
|
||
continue
|
||
except (OSError, gevent.GreenletExit):
|
||
return
|
||
self._Handle(conn, addr)
|
||
|
||
def _Handle(self, conn, addr):
|
||
"""逐行收发(一个连接可以连发多条命令:PING 探活 + RELOAD 通知)"""
|
||
try:
|
||
conn.settimeout(CONTROL_TIMEOUT)
|
||
buffer = b''
|
||
while True:
|
||
try:
|
||
chunk = conn.recv(256)
|
||
except socket.timeout:
|
||
return
|
||
if not chunk:
|
||
return
|
||
buffer += chunk
|
||
while b'\n' in buffer:
|
||
line, buffer = buffer.split(b'\n', 1)
|
||
if not self._Dispatch(conn, addr, line.decode('utf-8', 'replace').strip()):
|
||
return
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
try:
|
||
conn.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def _Dispatch(self, conn, addr, line):
|
||
"""处理一条命令;返回 False 表示可以收尾(收到重启通知)"""
|
||
command = line.split(' ', 1)[0].upper()
|
||
if command == 'PING':
|
||
conn.sendall(b'PONG\n')
|
||
return True
|
||
if command in ('RELOAD', 'RESTART'):
|
||
logger.warning(f"[单实例] 收到来自 {addr[0]} 的重启通知", extra={'domain': 'system', 'status_code': 0})
|
||
conn.sendall(b'OK\n')
|
||
# 只置标志,真正重启交给主协程(本协程若在此 stop_all 会被连带停掉)
|
||
if self.on_reload:
|
||
self.on_reload()
|
||
return False
|
||
conn.sendall(b'ERR\n')
|
||
return True
|
||
|
||
def stop(self):
|
||
greenlet, self.greenlet = self.greenlet, None
|
||
if greenlet is not None:
|
||
try:
|
||
greenlet.kill()
|
||
except Exception:
|
||
pass
|
||
sock, self.socket = self.socket, None
|
||
if sock is not None:
|
||
try:
|
||
sock.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def ProbeRunningInstance():
|
||
"""PING 探测是否已有 Main.py 实例(**不发重启指令**,只用于给出准确报错信息)"""
|
||
try:
|
||
with socket.create_connection((CONTROL_HOST, CONTROL_PORT), timeout=CONTROL_TIMEOUT) as conn:
|
||
conn.settimeout(CONTROL_TIMEOUT)
|
||
conn.sendall(b'PING\n')
|
||
return conn.recv(64).strip().upper().startswith(b'PONG')
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def NotifyRunningInstance():
|
||
"""探测既有 Main.py 实例并通知它重启;通知成功返回 True
|
||
|
||
连不上 / 不应答 / 回的既不是 PONG,都说明"没有既有实例",一律返回 False,
|
||
由调用方决定怎么办(`--reload` 直接退出,不带参数则继续抢绑端口)。
|
||
"""
|
||
try:
|
||
with socket.create_connection((CONTROL_HOST, CONTROL_PORT), timeout=CONTROL_TIMEOUT) as conn:
|
||
conn.settimeout(CONTROL_TIMEOUT)
|
||
conn.sendall(b'PING\n')
|
||
if not conn.recv(64).strip().upper().startswith(b'PONG'):
|
||
return False
|
||
conn.sendall(b'RELOAD\n')
|
||
conn.recv(64)
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def WaitForPortFree(port, host='0.0.0.0', timeout=INSTANCE_TAKEOVER_SECONDS):
|
||
"""等待端口真正可绑(execv 重启时旧进程会短暂占着 80/443)
|
||
|
||
**探测用的临时 socket 不能设 SO_REUSEADDR**:Windows 下带该选项的 bind 在端口被占用时
|
||
也会"成功",于是新进程会在旧进程尚未退出时抢绑 80/443 —— 表面绑上了,实际没有任何
|
||
连接进来(静默停服)。必须用裸 bind 才能如实反映"是否仍被占用"。
|
||
"""
|
||
deadline = time.time() + timeout
|
||
while True:
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
try:
|
||
sock.bind((host, port))
|
||
return True
|
||
except OSError:
|
||
pass
|
||
finally:
|
||
try:
|
||
sock.close()
|
||
except Exception:
|
||
pass
|
||
if time.time() >= deadline:
|
||
return False
|
||
time.sleep(INSTANCE_RETRY_INTERVAL)
|
||
|
||
|
||
def AcquireInstanceControl():
|
||
"""抢绑单实例控制端口(仅"启动服务器"的路径调用):返回 (control, status)
|
||
|
||
status 取值:
|
||
'primary' —— 本进程拿到控制端口,是唯一实例(control 已绑定,尚未 start)
|
||
'occupied' —— 端口被占(既有实例或别的程序),调用方应报错退出
|
||
|
||
**顺序很关键:先抢绑端口(瞬时),绑不上再 PING 看是不是既有实例(≤0.5s)。**
|
||
反着来(先 PING 再绑)会在"上一个实例刚 execv、端口半开"时白等十几秒 —— 启动慢的根源。
|
||
"""
|
||
control = ControlServer()
|
||
deadline = time.time() + INSTANCE_TAKEOVER_SECONDS
|
||
while True:
|
||
if control.TryBind():
|
||
return control, 'primary'
|
||
if time.time() >= deadline:
|
||
return None, 'occupied'
|
||
time.sleep(INSTANCE_RETRY_INTERVAL)
|
||
|
||
|
||
class ServerManager:
|
||
def __init__(self, control=None):
|
||
global logger
|
||
if logger is None:
|
||
SetupLogging()
|
||
|
||
self.pool_manager = ConnectionPoolManager()
|
||
self.servers = []
|
||
# 控制端口(单实例闸门 + --reload 通知通道):由 __main__ 抢绑后传入
|
||
self.control_server = control
|
||
# 控制端口请求的重启信号:由控制协程置位,主协程执行重启
|
||
self.reload_signal = gevent.event.Event()
|
||
self.ssl_context = CreateSslContext(
|
||
os.environ.get('SSL_CERT_PATH', './SSL/gvsds.com_public.crt'),
|
||
os.environ.get('SSL_KEY_PATH', './SSL/gvsds.com.key')
|
||
)
|
||
|
||
self.django_app = get_django_wsgi_application()
|
||
|
||
logger.info("Django 应用初始化完成", extra={'domain': 'system', 'status_code': 0})
|
||
|
||
def add_http_redirect_server(self, port=80):
|
||
server = CustomWSGIServer(
|
||
('0.0.0.0', port), RedirectApp,
|
||
spawn=self.pool_manager.coroutine_pool,
|
||
log=NoOpLog(), error_log=NoOpLog(),
|
||
environ={'SERVER_NAME': SERVER_NAME, 'SERVER_PORT': str(port)},
|
||
)
|
||
self.servers.append(('HTTP Redirect', port, server, None))
|
||
return self
|
||
|
||
def add_django_server(self, port, is_ssl=True, app_name="Django App"):
|
||
kwargs = {
|
||
'listener': ('0.0.0.0', port),
|
||
'application': self.django_app,
|
||
'spawn': self.pool_manager.coroutine_pool,
|
||
'log': NoOpLog(),
|
||
'error_log': NoOpLog(),
|
||
'suppress_ragged_eofs': True,
|
||
'do_handshake_on_connect': False,
|
||
'environ': {'SERVER_NAME': SERVER_NAME, 'SERVER_PORT': str(port)},
|
||
}
|
||
if is_ssl:
|
||
kwargs['ssl_context'] = self.ssl_context
|
||
|
||
server = CustomWSGIServer(**kwargs)
|
||
self.servers.append((app_name, port, server, None))
|
||
return self
|
||
|
||
def RequestReload(self):
|
||
"""控制端口收到重启通知:置位重启信号(真正的重启由主协程按本进程自身参数执行)"""
|
||
self.reload_signal.set()
|
||
|
||
def start_all(self):
|
||
logger.info("=== 启动 Django 服务器集群 ===", extra={'domain': 'system', 'status_code': 0})
|
||
logger.info(f"协程池: {MAX_COROUTINES} | 线程池: {MAX_THREADS}", extra={'domain': 'system', 'status_code': 0})
|
||
|
||
# 启动单实例控制端口:绑定动作已在 __main__ 的 AcquireInstanceControl() 完成,
|
||
# 这里只挂上重启回调并开接受循环(80/443 之前启动,闸门才算真正生效)
|
||
if self.control_server is None:
|
||
self.control_server = ControlServer()
|
||
if not self.control_server.TryBind():
|
||
logger.critical(
|
||
f"控制端口 {CONTROL_HOST}:{CONTROL_PORT} 绑定失败"
|
||
f"({self.control_server.last_error}):已有 Main.py 实例或该端口被占用,本进程退出",
|
||
extra={'domain': 'system', 'status_code': 0},
|
||
)
|
||
sys.exit(1)
|
||
self.control_server.SetReloadHandler(self.RequestReload).start()
|
||
logger.info(f"单实例控制端口: {CONTROL_HOST}:{CONTROL_PORT}", extra={'domain': 'system', 'status_code': 0})
|
||
|
||
from django.urls import get_resolver
|
||
resolver = get_resolver()
|
||
logger.info("=== Django URL 路由已加载 ===", extra={'domain': 'system', 'status_code': 0})
|
||
|
||
running_servers = []
|
||
for name, port, server, _ in self.servers:
|
||
# 等端口真正空闲再绑:execv 重启时旧进程可能还没退完,抢绑会变成"静默停服"
|
||
if not WaitForPortFree(port):
|
||
logger.critical(
|
||
f"{name} 端口 {port} 仍被占用,无法监听,本进程退出",
|
||
extra={'domain': 'system', 'status_code': 0},
|
||
)
|
||
sys.exit(1)
|
||
logger.info(f"启动 {name} 服务器 on 0.0.0.0:{port}", extra={'domain': 'system', 'status_code': 0})
|
||
gevent.sleep(0.1)
|
||
g = gevent.spawn(server.serve_forever)
|
||
running_servers.append((name, port, server, g))
|
||
|
||
self.servers = running_servers
|
||
# 绑成功与否只有 gevent 内部知道(异常发生在协程里、不进日志),这里复核一次。
|
||
# 用有界轮询而不是固定 sleep:固定等待在机器繁忙时会把"正在启动"误判成"启动失败"(实测踩过)。
|
||
deadline = time.time() + INSTANCE_TAKEOVER_SECONDS
|
||
while True:
|
||
pending = [item for item in self.servers if not getattr(item[2], 'started', False)]
|
||
if not pending:
|
||
break
|
||
if time.time() >= deadline:
|
||
for name, port, _server, _g in pending:
|
||
logger.critical(
|
||
f"{name} 服务器未成功监听 0.0.0.0:{port},本进程退出",
|
||
extra={'domain': 'system', 'status_code': 0},
|
||
)
|
||
sys.exit(1)
|
||
gevent.sleep(0.1)
|
||
|
||
# 文件热重载常开:监视 gvsds(业务代码)+ gvsdsdk(SDK,业务大量逻辑在这里)
|
||
# + Main.py 自身,任一 .py 变更即 os.execv 全量重启。
|
||
reloader = HotReloader(watch_dirs=['./gvsds', './gvsdsdk', './Main.py'])
|
||
reloader.start()
|
||
|
||
try:
|
||
while True:
|
||
# 每轮最多等 1s:既轮询文件变更,也等控制端口(单实例通知)的重启信号
|
||
reloader.wait_for_change(timeout=1)
|
||
|
||
if self.reload_signal.is_set():
|
||
logger.warning("[单实例] 收到 TCP 热重载通知,正在重启服务器...", extra={'domain': 'system', 'status_code': 0})
|
||
elif reloader.is_changed:
|
||
logger.warning("[热重启] 检测到文件变更,正在重启服务器...", extra={'domain': 'system', 'status_code': 0})
|
||
else:
|
||
continue
|
||
|
||
self.stop_all()
|
||
# 重启沿用本进程自己的启动参数(--reload 那条路径不会走到这里,它只负责通知)
|
||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||
except KeyboardInterrupt:
|
||
self.stop_all()
|
||
|
||
def stop_all(self):
|
||
logger.info("=== 停止所有服务器 ===", extra={'domain': 'system', 'status_code': 0})
|
||
# 先释放单实例控制端口:execv 重启时新进程要能立刻绑上它
|
||
if self.control_server is not None:
|
||
try:
|
||
self.control_server.stop()
|
||
except:
|
||
pass
|
||
self.control_server = None
|
||
for item in self.servers:
|
||
if len(item) == 4:
|
||
_, _, server, g = item
|
||
try:
|
||
server.stop()
|
||
except:
|
||
pass
|
||
try:
|
||
self.pool_manager.coroutine_pool.kill()
|
||
self.pool_manager.thread_pool.kill()
|
||
except:
|
||
pass
|
||
logger.info("所有服务器已停止", extra={'domain': 'system', 'status_code': 0})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
colorama.init(autoreset=True)
|
||
os.environ['GEVENT_THREADPOOL_SIZE'] = str(MAX_THREADS)
|
||
|
||
server_manager = None
|
||
try:
|
||
logger = SetupLogging()
|
||
|
||
# `--reload` 是**纯指令**:只通知正在运行的实例重启,自己不新开服务器。
|
||
# 有实例 → 通知并退出;没有实例 → 直接退出(要启动服务器请不带参数运行)。
|
||
if '--reload' in sys.argv:
|
||
if NotifyRunningInstance():
|
||
logger.warning(
|
||
f"[单实例] 已通知正在运行的 Main.py 实例重启,本进程退出"
|
||
f"(控制端口 {CONTROL_HOST}:{CONTROL_PORT})",
|
||
extra={'domain': 'system', 'status_code': 0},
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"[单实例] 没有正在运行的 Main.py 实例,--reload 无事可做,本进程退出"
|
||
"(要启动服务器请直接运行 python Main.py)",
|
||
extra={'domain': 'system', 'status_code': 0},
|
||
)
|
||
sys.exit(0)
|
||
|
||
# 单实例:全局只允许一个 Main.py。先抢绑控制端口(瞬时),抢不到按占用报错退出。
|
||
control, status = AcquireInstanceControl()
|
||
if status != 'primary':
|
||
if ProbeRunningInstance():
|
||
detail = "已有 Main.py 实例在运行(要让它重启请执行 python Main.py --reload)"
|
||
else:
|
||
detail = (
|
||
"该端口被其它程序占用,或落入 Windows 保留端口段"
|
||
"(保留段内即使无进程监听也会 bind 失败并报 WinError 10013;"
|
||
"查看:netsh int ipv4 show excludedportrange protocol=tcp;"
|
||
"换端口:设置环境变量 MAIN_CONTROL_PORT)"
|
||
)
|
||
logger.critical(
|
||
f"控制端口 {CONTROL_HOST}:{CONTROL_PORT} 绑定失败:{detail},本进程退出",
|
||
extra={'domain': 'system', 'status_code': 0},
|
||
)
|
||
sys.exit(1)
|
||
logger.info("[热重启] 文件监听已启用(改动 gvsds / gvsdsdk / Main.py 自动重启)", extra={'domain': 'system', 'status_code': 0})
|
||
server_manager = ServerManager(control)
|
||
server_manager.add_http_redirect_server(80)
|
||
server_manager.add_django_server(443, is_ssl=True, app_name="Main Django App")
|
||
server_manager.start_all()
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
if logger:
|
||
logger.critical(f"服务器启动失败: {e}", exc_info=True, extra={'domain': 'system', 'status_code': 500})
|
||
if server_manager:
|
||
server_manager.stop_all()
|
||
finally:
|
||
if server_manager:
|
||
server_manager.stop_all()
|
||
gevent.killall([], timeout=5)
|