Files
SMANAGE/Main.py
T
TermiNexus f8d356f3ff chore(baseline): 注册 RollCall/Viper/Venom 子域与站点,补 rollcall 应用
Main.py:WHITE_HOSTS 增 rollcall/viper/venom 主机,STATIC_SITES 增 rollcall/venom 静态根,静态站点路由下 /API/ 前缀请求转交 Django 处理。

gvsds:settings.py 注册 gvsds.apps.rollcall,urls.py 挂载 API/RollCall/;新增 rollcall 应用(模型/视图/路由/迁移 + RollCallManager 数据访问层)与 TempLate/rollcall 前端站点。

.gitignore:排除 Viper/Venom 前端工程与部署产物、TempLate/FTP 二进制分发目录。
2026-09-14 12:30:06 +08:00

751 lines
26 KiB
Python
Raw 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 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
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",
}
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'
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:
def __init__(self, watch_dirs=None, extensions=None, debounce=1.0):
self.watch_dirs = watch_dirs or ['./gvsds']
self.extensions = extensions or {'.py'}
self.debounce = debounce
self._observer = None
self._changed = gevent.event.Event()
self._last_change_time = 0
self._lock = gevent.lock.BoundedSemaphore(1)
def _should_watch(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)
if '__pycache__' in parts or '.git' in parts:
return False
return True
def _on_change(self, event):
if event.is_directory:
return
if not self._should_watch(event.src_path):
return
now = time.time()
with self._lock:
if now - self._last_change_time < self.debounce:
return
self._last_change_time = now
rel_path = os.path.relpath(event.src_path, '.')
logger.warning(f"[热重启] 检测到文件变更: {rel_path}", extra={'domain': 'system', 'status_code': 0})
self._changed.set()
def start(self):
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class Handler(FileSystemEventHandler):
def __init__(self, callback):
super().__init__()
self.callback = callback
def on_modified(self, event):
self.callback(event)
def on_created(self, event):
self.callback(event)
self._observer = Observer()
handler = Handler(self._on_change)
for d in self.watch_dirs:
abs_dir = os.path.abspath(d)
if os.path.isdir(abs_dir):
self._observer.schedule(handler, abs_dir, recursive=True)
self._observer.daemon = True
self._observer.start()
logger.info(f"[热重启] 文件监听已启动: {self.watch_dirs}", extra={'domain': 'system', 'status_code': 0})
def stop(self):
if self._observer:
self._observer.stop()
self._observer.join(timeout=3)
def wait_for_change(self, timeout=None):
self._changed.clear()
return self._changed.wait(timeout)
@property
def is_changed(self):
return self._changed.is_set()
class ServerManager:
def __init__(self):
global logger
if logger is None:
SetupLogging()
self.pool_manager = ConnectionPoolManager()
self.servers = []
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()
)
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,
}
if is_ssl:
kwargs['ssl_context'] = self.ssl_context
server = CustomWSGIServer(**kwargs)
self.servers.append((app_name, port, server, None))
return self
def start_all(self, hot_reload=False):
logger.info("=== 启动 Django 服务器集群 ===", extra={'domain': 'system', 'status_code': 0})
logger.info(f"协程池: {MAX_COROUTINES} | 线程池: {MAX_THREADS}", 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:
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
reloader = None
if hot_reload:
reloader = HotReloader(watch_dirs=['./gvsds', './Main.py'])
reloader.start()
try:
while True:
if hot_reload and reloader:
reloader.wait_for_change(timeout=1)
if reloader.is_changed:
logger.warning("[热重启] 正在重启服务器...", extra={'domain': 'system', 'status_code': 0})
self.stop_all()
reloader.stop()
os.execv(sys.executable, [sys.executable] + sys.argv)
else:
gevent.sleep(3600)
except KeyboardInterrupt:
if reloader:
reloader.stop()
self.stop_all()
def stop_all(self):
logger.info("=== 停止所有服务器 ===", extra={'domain': 'system', 'status_code': 0})
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)
hot_reload = '--reload' in sys.argv
server_manager = None
try:
logger = SetupLogging()
if hot_reload:
logger.info("[热重启] 模式已启用 (--reload)", extra={'domain': 'system', 'status_code': 0})
server_manager = ServerManager()
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(hot_reload=hot_reload)
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)