mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
880 lines
31 KiB
Python
880 lines
31 KiB
Python
import asyncio
|
|
import errno
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import socket
|
|
import ssl
|
|
from datetime import datetime
|
|
from ipaddress import IPv4Address, IPv6Address, ip_address
|
|
from pathlib import Path
|
|
|
|
import jwt
|
|
import psutil
|
|
import werkzeug.exceptions
|
|
from flask.json.provider import DefaultJSONProvider
|
|
from hypercorn.asyncio import serve
|
|
from hypercorn.config import Config as HyperConfig
|
|
from quart import Quart, g, jsonify, request
|
|
from quart.logging import default_handler
|
|
from quart.typing import ResponseReturnValue
|
|
from quart_cors import cors
|
|
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
|
from werkzeug.routing import Map, Rule
|
|
|
|
from astrbot.core import logger
|
|
from astrbot.core.config.default import VERSION
|
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
|
from astrbot.core.db import BaseDatabase
|
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|
from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
|
from astrbot.core.utils.io import (
|
|
get_bundled_dashboard_dist_path,
|
|
get_local_ip_addresses,
|
|
should_use_bundled_dashboard_dist,
|
|
)
|
|
|
|
from .plugin_page_auth import PluginPageAuth
|
|
from .routes import (
|
|
ApiKeyRoute,
|
|
AuthRoute,
|
|
BackupRoute,
|
|
ChatRoute,
|
|
ChatUIProjectRoute,
|
|
CommandRoute,
|
|
ConfigRoute,
|
|
ConversationRoute,
|
|
CronRoute,
|
|
FileRoute,
|
|
KnowledgeBaseRoute,
|
|
LiveChatRoute,
|
|
LogRoute,
|
|
OpenApiRoute,
|
|
PersonaRoute,
|
|
PlatformRoute,
|
|
PluginRoute,
|
|
Response,
|
|
RouteContext,
|
|
SessionManagementRoute,
|
|
SkillsRoute,
|
|
StaticFileRoute,
|
|
StatRoute,
|
|
SubAgentRoute,
|
|
T2iRoute,
|
|
ToolsRoute,
|
|
UpdateRoute,
|
|
)
|
|
from .routes.api_key import ALL_OPEN_API_SCOPES
|
|
from .routes.auth import DASHBOARD_JWT_COOKIE_NAME
|
|
from .routes.route import is_runtime_request_ready, runtime_loading_response
|
|
|
|
_PUBLIC_ALLOWED_ENDPOINT_PREFIXES = (
|
|
"/api/auth/login",
|
|
"/api/file",
|
|
"/api/platform/webhook",
|
|
"/api/stat/start-time",
|
|
"/api/backup/download",
|
|
)
|
|
_RUNTIME_EXTRA_BYPASS_ENDPOINT_PREFIXES = (
|
|
"/api/stat/version",
|
|
"/api/stat/runtime-status",
|
|
"/api/stat/restart-core",
|
|
"/api/stat/changelog",
|
|
"/api/stat/changelog/list",
|
|
"/api/stat/first-notice",
|
|
)
|
|
_RUNTIME_BYPASS_ENDPOINT_PREFIXES = (
|
|
tuple(
|
|
prefix
|
|
for prefix in _PUBLIC_ALLOWED_ENDPOINT_PREFIXES
|
|
if prefix != "/api/platform/webhook"
|
|
)
|
|
+ _RUNTIME_EXTRA_BYPASS_ENDPOINT_PREFIXES
|
|
)
|
|
_RUNTIME_FAILED_RECOVERY_ENDPOINT_PREFIXES = (
|
|
"/api/config/",
|
|
"/api/plugin/reload-failed",
|
|
"/api/plugin/uninstall-failed",
|
|
"/api/plugin/source/get-failed-plugins",
|
|
)
|
|
|
|
|
|
APP: Quart
|
|
_ENV_PLACEHOLDER_RE = re.compile(
|
|
r"\$(?:\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)(?::-(?P<default>[^}]*))?\}|(?P<plain>[A-Za-z_][A-Za-z0-9_]*))",
|
|
)
|
|
|
|
|
|
def _normalize_plugin_api_route(route: str) -> str:
|
|
route = route.strip()
|
|
if not route.startswith("/"):
|
|
route = f"/{route}"
|
|
return route
|
|
|
|
|
|
def _match_registered_web_api(registered_web_apis, subpath: str, method: str):
|
|
request_path = f"/{subpath.lstrip('/')}"
|
|
request_method = method.upper()
|
|
|
|
for route, view_handler, methods, _ in registered_web_apis:
|
|
allowed_methods = [item.upper() for item in methods]
|
|
if request_method not in allowed_methods:
|
|
continue
|
|
|
|
url_map = Map(
|
|
[
|
|
Rule(
|
|
_normalize_plugin_api_route(route),
|
|
endpoint="plugin_api",
|
|
methods=allowed_methods,
|
|
),
|
|
],
|
|
)
|
|
try:
|
|
_, path_values = url_map.bind("").match(
|
|
request_path,
|
|
method=request_method,
|
|
)
|
|
except (MethodNotAllowed, NotFound):
|
|
continue
|
|
return view_handler, path_values
|
|
|
|
return None
|
|
|
|
|
|
def _parse_env_bool(value: str | None, default: bool) -> bool:
|
|
if value is None:
|
|
return default
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def _expand_env_placeholders(value: str, field_name: str) -> str:
|
|
missing_vars: list[str] = []
|
|
|
|
def _replace(match: re.Match[str]) -> str:
|
|
var_name = match.group("braced") or match.group("plain")
|
|
default = match.group("default")
|
|
env_value = os.environ.get(var_name)
|
|
if env_value is not None:
|
|
return env_value
|
|
if default is not None:
|
|
return default
|
|
missing_vars.append(var_name)
|
|
return match.group(0)
|
|
|
|
expanded = _ENV_PLACEHOLDER_RE.sub(_replace, value)
|
|
if missing_vars:
|
|
missing = ", ".join(sorted(set(missing_vars)))
|
|
raise ValueError(
|
|
f"Unresolved environment variable(s) in dashboard {field_name}: {missing}",
|
|
)
|
|
return expanded
|
|
|
|
|
|
def _resolve_dashboard_value(
|
|
value: str | int | None,
|
|
*,
|
|
field_name: str,
|
|
) -> str | int | None:
|
|
if not isinstance(value, str):
|
|
return value
|
|
return _expand_env_placeholders(value, field_name).strip()
|
|
|
|
|
|
class AstrBotJSONProvider(DefaultJSONProvider):
|
|
def default(self, obj):
|
|
if isinstance(obj, datetime):
|
|
return to_utc_isoformat(obj)
|
|
return super().default(obj)
|
|
|
|
|
|
class AstrBotDashboard:
|
|
"""AstrBot Web Dashboard"""
|
|
|
|
ALLOWED_ENDPOINT_PREFIXES = _PUBLIC_ALLOWED_ENDPOINT_PREFIXES
|
|
RUNTIME_BYPASS_ENDPOINT_PREFIXES = _RUNTIME_BYPASS_ENDPOINT_PREFIXES
|
|
RUNTIME_FAILED_RECOVERY_ENDPOINT_PREFIXES = (
|
|
_RUNTIME_FAILED_RECOVERY_ENDPOINT_PREFIXES
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
core_lifecycle: AstrBotCoreLifecycle,
|
|
db: BaseDatabase,
|
|
shutdown_event: asyncio.Event,
|
|
webui_dir: str | None = None,
|
|
) -> None:
|
|
self.core_lifecycle = core_lifecycle
|
|
self.config = core_lifecycle.astrbot_config
|
|
self.db = db
|
|
self.shutdown_event = shutdown_event
|
|
|
|
self.enable_webui = self._check_webui_enabled()
|
|
self._webui_fallback = False # True if frontend was enabled but files missing
|
|
|
|
self._init_paths(webui_dir)
|
|
self._init_app()
|
|
self.context = RouteContext(self.config, self.app)
|
|
|
|
self._init_routes(db)
|
|
self._init_jwt_secret()
|
|
|
|
def _check_webui_enabled(self) -> bool:
|
|
cfg = self.config.get("dashboard", {})
|
|
_env = os.environ.get("ASTRBOT_DASHBOARD_ENABLE")
|
|
if _env is not None:
|
|
return _env.lower() in ("true", "1", "yes")
|
|
return cfg.get("enable", True)
|
|
|
|
def _init_paths(self, webui_dir: str | None):
|
|
# Path priority:
|
|
# 1. Explicit webui_dir argument
|
|
# 2. data/dist/ (user-installed / manually updated dashboard)
|
|
# 3. astrbot/dashboard/dist/ (bundled with the wheel)
|
|
# resolve() is used throughout to follow symlinks to their real paths.
|
|
if webui_dir and os.path.exists(webui_dir):
|
|
self.data_path = os.path.abspath(webui_dir)
|
|
else:
|
|
user_dist = os.path.join(get_astrbot_data_path(), "dist")
|
|
bundled_dist = get_bundled_dashboard_dist_path()
|
|
if os.path.exists(user_dist) and not should_use_bundled_dashboard_dist(
|
|
user_dist,
|
|
VERSION,
|
|
):
|
|
self.data_path = os.path.abspath(user_dist)
|
|
elif bundled_dist.exists():
|
|
self.data_path = str(bundled_dist)
|
|
logger.info("Using bundled dashboard dist: %s", self.data_path)
|
|
else:
|
|
self.data_path = os.path.abspath(user_dist)
|
|
|
|
if self.enable_webui and not (Path(self.data_path) / "index.html").exists():
|
|
logger.warning(
|
|
f"前端未内置或未初始化 (index.html missing in {self.data_path}), "
|
|
"回退到仅启动后端. 请访问在线面板: dash.astrbot.men",
|
|
)
|
|
self.enable_webui = False
|
|
self._webui_fallback = True
|
|
|
|
def _init_app(self):
|
|
"""初始化 Quart 应用"""
|
|
global APP
|
|
|
|
static_folder = self.data_path if self.enable_webui else None
|
|
static_url_path = "/" if self.enable_webui else None
|
|
|
|
self.app = Quart(
|
|
"AstrBotDashboard",
|
|
static_folder=static_folder,
|
|
static_url_path=static_url_path,
|
|
)
|
|
APP = self.app
|
|
self.app.json_provider_class = DefaultJSONProvider
|
|
self.app.config["MAX_CONTENT_LENGTH"] = 128 * 1024 * 1024 # 128MB
|
|
self.app.json = AstrBotJSONProvider(self.app)
|
|
self.app.json.sort_keys = False
|
|
|
|
# 配置 CORS
|
|
# 支持通过环境变量 ASTRBOT_CORS_ALLOW_ORIGIN 配置允许的域名,多个域名用逗号分隔
|
|
# 如果前端使用 withCredentials:true,需要设置具体的域名而非 "*"
|
|
cors_allow_origin = os.environ.get("ASTRBOT_CORS_ALLOW_ORIGIN", "*")
|
|
cors_allow_credentials = False
|
|
if cors_allow_origin != "*":
|
|
cors_allow_origin = [
|
|
origin.strip() for origin in cors_allow_origin.split(",")
|
|
]
|
|
# 只有设置具体域名时才允许凭据
|
|
cors_allow_credentials = True
|
|
self.app = cors(
|
|
self.app,
|
|
allow_origin=cors_allow_origin,
|
|
allow_credentials=cors_allow_credentials,
|
|
allow_headers=[
|
|
"Authorization",
|
|
"Content-Type",
|
|
"X-API-Key",
|
|
"Accept-Language",
|
|
],
|
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
)
|
|
|
|
@self.app.route("/")
|
|
async def index():
|
|
if not self.enable_webui:
|
|
return "前端未启用, 请访问在线面板: dash.astrbot.men"
|
|
try:
|
|
return await self.app.send_static_file("index.html")
|
|
except werkzeug.exceptions.NotFound:
|
|
logger.error(f"Dashboard index.html not found in {self.data_path}")
|
|
return "Dashboard files not found.", 404
|
|
|
|
@self.app.errorhandler(404)
|
|
async def not_found(e):
|
|
if not self.enable_webui:
|
|
return "前端未启用, 请访问在线面板: dash.astrbot.men"
|
|
if request.path.startswith("/api/"):
|
|
return jsonify(Response().error("Not Found").to_json()), 404
|
|
try:
|
|
return await self.app.send_static_file("index.html")
|
|
except werkzeug.exceptions.NotFound:
|
|
return "Dashboard files not found.", 404
|
|
|
|
@self.app.before_serving
|
|
async def startup():
|
|
pass
|
|
|
|
@self.app.after_serving
|
|
async def shutdown():
|
|
pass
|
|
|
|
self.app.before_request(self.auth_middleware)
|
|
logging.getLogger(self.app.name).removeHandler(default_handler)
|
|
|
|
def _init_routes(self, db: BaseDatabase):
|
|
astrbot_updator = self.core_lifecycle.astrbot_updator
|
|
plugin_manager = self.core_lifecycle.plugin_manager
|
|
assert astrbot_updator is not None
|
|
assert plugin_manager is not None
|
|
|
|
UpdateRoute(self.context, astrbot_updator, self.core_lifecycle)
|
|
StatRoute(self.context, db, self.core_lifecycle)
|
|
PluginRoute(self.context, self.core_lifecycle, plugin_manager)
|
|
|
|
self.command_route = CommandRoute(self.context)
|
|
self.cr = ConfigRoute(self.context, self.core_lifecycle)
|
|
self.lr = LogRoute(self.context, self.core_lifecycle.log_broker)
|
|
self.sfr = StaticFileRoute(self.context)
|
|
self.ar = AuthRoute(self.context, db)
|
|
self.api_key_route = ApiKeyRoute(self.context, db)
|
|
self.chat_route = ChatRoute(self.context, db, self.core_lifecycle)
|
|
self.open_api_route = OpenApiRoute(
|
|
self.context,
|
|
db,
|
|
self.core_lifecycle,
|
|
self.chat_route,
|
|
)
|
|
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
|
self.tools_root = ToolsRoute(self.context, self.core_lifecycle)
|
|
self.subagent_route = SubAgentRoute(self.context, self.core_lifecycle)
|
|
self.skills_route = SkillsRoute(self.context, self.core_lifecycle)
|
|
self.conversation_route = ConversationRoute(
|
|
self.context,
|
|
db,
|
|
self.core_lifecycle,
|
|
)
|
|
self.file_route = FileRoute(self.context)
|
|
self.session_management_route = SessionManagementRoute(
|
|
self.context,
|
|
db,
|
|
self.core_lifecycle,
|
|
)
|
|
self.persona_route = PersonaRoute(self.context, db, self.core_lifecycle)
|
|
self.cron_route = CronRoute(self.context, self.core_lifecycle)
|
|
self.t2i_route = T2iRoute(self.context, self.core_lifecycle)
|
|
self.kb_route = KnowledgeBaseRoute(self.context, self.core_lifecycle)
|
|
self.platform_route = PlatformRoute(self.context, self.core_lifecycle)
|
|
self.backup_route = BackupRoute(self.context, db, self.core_lifecycle)
|
|
self.live_chat_route = LiveChatRoute(self.context, db, self.core_lifecycle)
|
|
|
|
self.app.add_url_rule(
|
|
"/api/plug/<path:subpath>",
|
|
view_func=self.guarded_srv_plug_route,
|
|
methods=["GET", "POST"],
|
|
)
|
|
|
|
def _init_jwt_secret(self):
|
|
dashboard_cfg = self.config.setdefault("dashboard", {})
|
|
if not dashboard_cfg.get("jwt_secret"):
|
|
dashboard_cfg["jwt_secret"] = os.urandom(32).hex()
|
|
self.config.save_config()
|
|
logger.info("Initialized random JWT secret for dashboard.")
|
|
self._jwt_secret = dashboard_cfg["jwt_secret"]
|
|
|
|
async def guarded_srv_plug_route(
|
|
self,
|
|
subpath: str,
|
|
*args,
|
|
**kwargs,
|
|
) -> ResponseReturnValue:
|
|
guard_resp = self._maybe_runtime_guard(request.path)
|
|
if guard_resp is not None:
|
|
return guard_resp
|
|
return await self.srv_plug_route(subpath, *args, **kwargs)
|
|
|
|
def _should_bypass_runtime_guard(self, path: str) -> bool:
|
|
return any(
|
|
path.startswith(prefix) for prefix in self.RUNTIME_BYPASS_ENDPOINT_PREFIXES
|
|
)
|
|
|
|
def _should_allow_failed_runtime_recovery(self, path: str) -> bool:
|
|
if not (
|
|
self.core_lifecycle.runtime_failed
|
|
or self.core_lifecycle.runtime_bootstrap_error is not None
|
|
):
|
|
return False
|
|
return any(
|
|
path.startswith(prefix)
|
|
for prefix in self.RUNTIME_FAILED_RECOVERY_ENDPOINT_PREFIXES
|
|
)
|
|
|
|
def _maybe_runtime_guard(
|
|
self,
|
|
path: str,
|
|
*,
|
|
include_failure_details: bool = True,
|
|
) -> ResponseReturnValue | None:
|
|
if self._should_bypass_runtime_guard(path):
|
|
return None
|
|
if self._should_allow_failed_runtime_recovery(path):
|
|
return None
|
|
if not is_runtime_request_ready(self.core_lifecycle):
|
|
return runtime_loading_response(
|
|
self.core_lifecycle,
|
|
include_failure_details=include_failure_details,
|
|
)
|
|
return None
|
|
|
|
async def srv_plug_route(self, subpath, *args, **kwargs):
|
|
"""插件路由"""
|
|
registered_web_apis = self.core_lifecycle.star_context._registered_web_apis
|
|
matched_api = _match_registered_web_api(
|
|
registered_web_apis,
|
|
subpath,
|
|
request.method,
|
|
)
|
|
if matched_api:
|
|
view_handler, path_values = matched_api
|
|
try:
|
|
return await view_handler(*args, **{**kwargs, **path_values})
|
|
except Exception:
|
|
logger.exception("插件 Web API 执行异常")
|
|
return jsonify(Response().error("插件 Web API 执行异常").to_json())
|
|
return jsonify(Response().error("未找到该路由").to_json())
|
|
|
|
async def auth_middleware(self):
|
|
# 放行CORS预检请求
|
|
if request.method == "OPTIONS":
|
|
return None
|
|
if not request.path.startswith("/api"):
|
|
return None
|
|
if request.path.startswith("/api/v1"):
|
|
raw_key = self._extract_raw_api_key()
|
|
if not raw_key:
|
|
r = jsonify(Response().error("Missing API key").to_json())
|
|
r.status_code = 401
|
|
return r
|
|
key_hash = hashlib.pbkdf2_hmac(
|
|
"sha256",
|
|
raw_key.encode("utf-8"),
|
|
b"astrbot_api_key",
|
|
100_000,
|
|
).hex()
|
|
api_key = await self.db.get_active_api_key_by_hash(key_hash)
|
|
if not api_key:
|
|
r = jsonify(Response().error("Invalid API key").to_json())
|
|
r.status_code = 401
|
|
return r
|
|
|
|
if isinstance(api_key.scopes, list):
|
|
scopes = api_key.scopes
|
|
else:
|
|
scopes = list(ALL_OPEN_API_SCOPES)
|
|
required_scope = self._get_required_open_api_scope(request.path)
|
|
if required_scope and "*" not in scopes and required_scope not in scopes:
|
|
r = jsonify(Response().error("Insufficient API key scope").to_json())
|
|
r.status_code = 403
|
|
return r
|
|
|
|
g.api_key_id = api_key.key_id
|
|
g.api_key_scopes = scopes
|
|
g.username = f"api_key:{api_key.key_id}"
|
|
await self.db.touch_api_key(api_key.key_id)
|
|
guard_resp = self._maybe_runtime_guard(
|
|
request.path,
|
|
include_failure_details=False,
|
|
)
|
|
if guard_resp is not None:
|
|
return guard_resp
|
|
return None
|
|
|
|
allowed_exact_endpoints = {
|
|
"/api/auth/login",
|
|
"/api/auth/logout",
|
|
"/api/auth/setup-status",
|
|
"/api/auth/setup",
|
|
}
|
|
allowed_endpoint_prefixes = [
|
|
"/api/file",
|
|
"/api/platform/webhook",
|
|
"/api/stat/start-time",
|
|
"/api/backup/download", # 备份下载使用 URL 参数传递 token
|
|
]
|
|
if request.path in allowed_exact_endpoints or any(
|
|
request.path.startswith(prefix) for prefix in allowed_endpoint_prefixes
|
|
):
|
|
guard_resp = self._maybe_runtime_guard(
|
|
request.path,
|
|
include_failure_details=False,
|
|
)
|
|
if guard_resp is not None:
|
|
return guard_resp
|
|
return None
|
|
is_plugin_page_path = PluginPageAuth.is_protected_path(request.path)
|
|
token = self._extract_dashboard_jwt()
|
|
if not token and is_plugin_page_path:
|
|
token = PluginPageAuth.extract_asset_token()
|
|
if not token:
|
|
r = jsonify(Response().error("未授权").to_json())
|
|
r.status_code = 401
|
|
return r
|
|
try:
|
|
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
|
|
if PluginPageAuth.is_asset_token(
|
|
payload,
|
|
) and not PluginPageAuth.is_scope_valid(
|
|
payload,
|
|
request.path,
|
|
):
|
|
r = jsonify(Response().error("Token 无效").to_json())
|
|
r.status_code = 401
|
|
return r
|
|
|
|
username = payload.get("username")
|
|
if not isinstance(username, str) or not username.strip():
|
|
raise jwt.InvalidTokenError("missing username in token payload")
|
|
g.username = username
|
|
except jwt.ExpiredSignatureError:
|
|
return self._unauthorized("Token 过期")
|
|
except jwt.PyJWTError:
|
|
return self._unauthorized("Token 无效")
|
|
|
|
guard_resp = self._maybe_runtime_guard(request.path)
|
|
if guard_resp is not None:
|
|
return guard_resp
|
|
|
|
@staticmethod
|
|
def _unauthorized(msg: str):
|
|
r = jsonify(Response().error(msg).to_json())
|
|
r.status_code = 401
|
|
return r
|
|
|
|
@staticmethod
|
|
def _extract_dashboard_jwt() -> str | None:
|
|
auth_header = request.headers.get("Authorization", "").strip()
|
|
if auth_header.startswith("Bearer "):
|
|
token = auth_header.removeprefix("Bearer ").strip()
|
|
if token:
|
|
return token
|
|
|
|
cookie_token = request.cookies.get(DASHBOARD_JWT_COOKIE_NAME, "").strip()
|
|
if cookie_token:
|
|
return cookie_token
|
|
return None
|
|
|
|
@staticmethod
|
|
def _extract_raw_api_key() -> str | None:
|
|
if key := request.args.get("api_key"):
|
|
return key.strip()
|
|
if key := request.args.get("key"):
|
|
return key.strip()
|
|
if key := request.headers.get("X-API-Key"):
|
|
return key.strip()
|
|
auth_header = request.headers.get("Authorization", "").strip()
|
|
if auth_header.startswith("Bearer "):
|
|
return auth_header.removeprefix("Bearer ").strip()
|
|
if auth_header.startswith("ApiKey "):
|
|
return auth_header.removeprefix("ApiKey ").strip()
|
|
return None
|
|
|
|
@staticmethod
|
|
def _get_required_open_api_scope(path: str) -> str | None:
|
|
scope_map = {
|
|
"/api/v1/chat": "chat",
|
|
"/api/v1/chat/ws": "chat",
|
|
"/api/v1/chat/sessions": "chat",
|
|
"/api/v1/configs": "config",
|
|
"/api/v1/file": "file",
|
|
"/api/v1/im/message": "im",
|
|
"/api/v1/im/bots": "im",
|
|
}
|
|
return scope_map.get(path)
|
|
|
|
def check_port_in_use(self, host: str, port: int) -> bool:
|
|
"""跨平台检测端口是否被占用"""
|
|
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
|
try:
|
|
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
s.bind((host, port))
|
|
return False
|
|
except OSError as exc:
|
|
if exc.errno == errno.EADDRINUSE:
|
|
return True
|
|
logger.warning(
|
|
"Skip port preflight for %s:%s due to bind probe failure: %s",
|
|
host,
|
|
port,
|
|
exc,
|
|
)
|
|
return False
|
|
|
|
def get_process_using_port(self, port: int) -> str:
|
|
"""获取占用端口的进程信息"""
|
|
try:
|
|
for proc in psutil.process_iter(["pid", "name"]):
|
|
try:
|
|
connections = proc.net_connections()
|
|
for conn in connections:
|
|
if conn.laddr.port == port:
|
|
return f"PID: {proc.info['pid']}, Name: {proc.info['name']}"
|
|
except (
|
|
psutil.NoSuchProcess,
|
|
psutil.AccessDenied,
|
|
psutil.ZombieProcess,
|
|
):
|
|
pass
|
|
except Exception as e:
|
|
return f"获取进程信息失败: {e!s}"
|
|
return "未知进程"
|
|
|
|
def _build_dashboard_credentials_display(self) -> str:
|
|
username = self.config["dashboard"].get("username", "astrbot")
|
|
generated_password = getattr(self.config, "_generated_dashboard_password", None)
|
|
if not generated_password:
|
|
return f" ➜ Username: {username}\n ✨✨✨\n"
|
|
|
|
credentials_display = (
|
|
f" ➜ Initial username: {username}\n"
|
|
f" ➜ Initial password: {generated_password}\n"
|
|
" ➜ Change it after logging in\n ✨✨✨\n"
|
|
)
|
|
object.__setattr__(self.config, "_generated_dashboard_password", None)
|
|
return credentials_display
|
|
|
|
@staticmethod
|
|
def _resolve_dashboard_ssl_config(
|
|
ssl_config: dict,
|
|
) -> tuple[bool, dict[str, str]]:
|
|
cert_file = (
|
|
os.environ.get("DASHBOARD_SSL_CERT")
|
|
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT")
|
|
or os.environ.get("ASTRBOT_SSL_CERT")
|
|
or ssl_config.get("cert_file", "")
|
|
)
|
|
key_file = (
|
|
os.environ.get("DASHBOARD_SSL_KEY")
|
|
or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY")
|
|
or os.environ.get("ASTRBOT_SSL_KEY")
|
|
or ssl_config.get("key_file", "")
|
|
)
|
|
ca_certs = (
|
|
os.environ.get("DASHBOARD_SSL_CA_CERTS")
|
|
or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS")
|
|
or os.environ.get("ASTRBOT_SSL_CA_CERTS")
|
|
or ssl_config.get("ca_certs", "")
|
|
)
|
|
|
|
if not cert_file or not key_file:
|
|
logger.warning(
|
|
"dashboard.ssl.enable is set, but cert_file or key_file is missing. SSL disabled.",
|
|
)
|
|
return False, {}
|
|
|
|
cert_path = Path(cert_file).expanduser()
|
|
key_path = Path(key_file).expanduser()
|
|
if not cert_path.is_file():
|
|
logger.warning(
|
|
f"dashboard.ssl.enable is set, but cert file is missing: {cert_path}. SSL disabled.",
|
|
)
|
|
return False, {}
|
|
if not key_path.is_file():
|
|
logger.warning(
|
|
f"dashboard.ssl.enable is set, but key file is missing: {key_path}. SSL disabled.",
|
|
)
|
|
return False, {}
|
|
|
|
resolved_ssl_config = {
|
|
"certfile": str(cert_path.resolve()),
|
|
"keyfile": str(key_path.resolve()),
|
|
}
|
|
|
|
if ca_certs:
|
|
ca_path = Path(ca_certs).expanduser()
|
|
if not ca_path.is_file():
|
|
logger.warning(
|
|
f"dashboard.ssl.enable is set, but CA cert file is missing: {ca_path}. SSL disabled.",
|
|
)
|
|
return False, {}
|
|
resolved_ssl_config["ca_certs"] = str(ca_path.resolve())
|
|
|
|
return True, resolved_ssl_config
|
|
|
|
async def run(self) -> None:
|
|
if self._webui_fallback:
|
|
logger.warning(
|
|
"前端未内置或未初始化, 回退到仅启动后端. 请访问在线面板: dash.astrbot.men",
|
|
)
|
|
elif not self.enable_webui:
|
|
logger.warning("前端已禁用, 请访问在线面板: dash.astrbot.men")
|
|
|
|
dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
|
|
host_value = (
|
|
os.environ.get("DASHBOARD_HOST")
|
|
or os.environ.get("ASTRBOT_DASHBOARD_HOST")
|
|
or os.environ.get("ASTRBOT_HOST")
|
|
or dashboard_config.get("host", "0.0.0.0")
|
|
)
|
|
host = _resolve_dashboard_value(host_value, field_name="host")
|
|
if not isinstance(host, str) or not host:
|
|
raise ValueError("Dashboard host must be a non-empty string")
|
|
|
|
ssl_config = dashboard_config.get("ssl", {})
|
|
if not isinstance(ssl_config, dict):
|
|
ssl_config = {}
|
|
ssl_enable = _parse_env_bool(
|
|
os.environ.get("DASHBOARD_SSL_ENABLE")
|
|
or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"),
|
|
bool(ssl_config.get("enable", False)),
|
|
)
|
|
resolved_ssl_config: dict[str, str] = {}
|
|
if ssl_enable:
|
|
ssl_enable, resolved_ssl_config = self._resolve_dashboard_ssl_config(
|
|
ssl_config,
|
|
)
|
|
|
|
if not self.enable_webui:
|
|
logger.info("WebUI disabled.")
|
|
return
|
|
|
|
port_value = (
|
|
os.environ.get("DASHBOARD_PORT")
|
|
or os.environ.get("ASTRBOT_DASHBOARD_PORT")
|
|
or dashboard_config.get("port", 6185)
|
|
)
|
|
port = _resolve_dashboard_value(port_value, field_name="port")
|
|
if not isinstance(port, int) or port < 1 or port > 65535:
|
|
raise ValueError("Dashboard port must be an integer between 1 and 65535")
|
|
|
|
scheme = "https" if ssl_enable else "http"
|
|
ip_addr: list[IPv4Address | IPv6Address] = get_local_ip_addresses()
|
|
binds: list[str] = [self._build_bind(host, port)]
|
|
if host == "::" and platform.system() in ("Windows", "Darwin"):
|
|
binds.append(self._build_bind("0.0.0.0", port))
|
|
|
|
logger.info("Starting WebUI at %s://%s:%s", scheme, host, port)
|
|
if host == "0.0.0.0":
|
|
logger.info(
|
|
"WebUI listens on all interfaces. Check security. Set dashboard.host in data/cmd_config.json to change it.",
|
|
)
|
|
|
|
if self.enable_webui:
|
|
logger.info(
|
|
"正在启动 WebUI + API, 监听: %s",
|
|
", ".join(f"{scheme}://{bind}" for bind in binds),
|
|
)
|
|
else:
|
|
logger.info(
|
|
"正在启动 API Server, 监听: %s",
|
|
", ".join(f"{scheme}://{bind}" for bind in binds),
|
|
)
|
|
|
|
check_hosts = {host}
|
|
if host not in ("127.0.0.1", "localhost", "::1"):
|
|
check_hosts.add("127.0.0.1")
|
|
for check_host in check_hosts:
|
|
if self.check_port_in_use(check_host, port):
|
|
info = self.get_process_using_port(port)
|
|
raise RuntimeError(f"端口 {port} 已被占用\n{info}")
|
|
|
|
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI is ready\n\n"]
|
|
parts.append(f" ➜ Local: {scheme}://localhost:{port}\n")
|
|
for ip in ip_addr:
|
|
parts.append(f" ➜ Network: {scheme}://{ip}:{port}\n")
|
|
parts.append(self._build_dashboard_credentials_display())
|
|
display = "".join(parts)
|
|
|
|
if not ip_addr:
|
|
display += (
|
|
"Set dashboard.host in data/cmd_config.json to enable remote access.\n"
|
|
)
|
|
|
|
logger.info(display)
|
|
|
|
# 配置 Hypercorn
|
|
config = HyperConfig()
|
|
config.bind = binds
|
|
|
|
if ssl_enable:
|
|
config.certfile = resolved_ssl_config["certfile"]
|
|
config.keyfile = resolved_ssl_config["keyfile"]
|
|
if ca_certs := resolved_ssl_config.get("ca_certs"):
|
|
config.ca_certs = ca_certs
|
|
|
|
# 根据配置决定是否禁用访问日志
|
|
disable_access_log = dashboard_config.get("disable_access_log", True)
|
|
if disable_access_log:
|
|
config.accesslog = None
|
|
else:
|
|
config.accesslog = "-"
|
|
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
|
|
|
|
try:
|
|
await serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
|
|
except (ssl.SSLError, asyncio.CancelledError):
|
|
# Client disconnected abruptly — SSL shutdown errors are benign.
|
|
pass
|
|
|
|
@staticmethod
|
|
def _build_bind(host: str, port: int) -> str:
|
|
try:
|
|
ip: IPv4Address | IPv6Address = ip_address(host)
|
|
return f"[{ip}]:{port}" if ip.version == 6 else f"{ip}:{port}"
|
|
except ValueError:
|
|
return f"{host}:{port}"
|
|
|
|
def _print_access_urls(
|
|
self,
|
|
host: str,
|
|
port: int,
|
|
scheme: str = "http",
|
|
enable_webui: bool = True,
|
|
) -> None:
|
|
local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses()
|
|
mode_label = "WebUI + API" if enable_webui else "API Server (WebUI 已分离)"
|
|
|
|
parts: list[str] = [f"\n ✨✨✨\n AstrBot v{VERSION} {mode_label} 已启动\n\n"]
|
|
|
|
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
|
|
|
|
if host in ("::", "0.0.0.0"):
|
|
for ip in local_ips:
|
|
if ip.is_loopback:
|
|
continue
|
|
|
|
if ip.version == 6:
|
|
display_url = f"{scheme}://[{ip}]:{port}"
|
|
else:
|
|
display_url = f"{scheme}://{ip}:{port}"
|
|
|
|
parts.append(f" ➜ 网络: {display_url}\n")
|
|
elif ":" in host:
|
|
parts.append(f" ➜ 指定监听: {scheme}://[{host}]:{port}\n")
|
|
else:
|
|
parts.append(f" ➜ 指定监听: {scheme}://{host}:{port}\n")
|
|
|
|
if enable_webui:
|
|
parts.append(" ➜ 默认用户名和密码: astrbot\n")
|
|
parts.append(" ✨✨✨\n")
|
|
|
|
if not local_ips:
|
|
parts.append(
|
|
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n",
|
|
)
|
|
|
|
logger.info("".join(parts))
|
|
|
|
async def shutdown_trigger(self):
|
|
await self.shutdown_event.wait()
|