Files
TermiNexus 4bd5697cff feat(订阅式软件提供): 包详情支持依赖列表与 README,打通产品官网数据链路
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。
2026-10-07 19:35:46 +08:00

1069 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)