diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 4b4fd6f99..5b4f7dbc1 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -4,6 +4,7 @@ import hashlib import logging import os import platform +import re import socket from collections.abc import Callable from datetime import datetime @@ -65,6 +66,9 @@ _BUNDLED_DIST = Path(__file__).parent / "dist" APP: Quart +_ENV_PLACEHOLDER_RE = re.compile( + r"\$(?:\{(?P[A-Za-z_][A-Za-z0-9_]*)(?::-(?P[^}]*))?\}|(?P[A-Za-z_][A-Za-z0-9_]*))" +) def _parse_env_bool(value: str | None, default: bool) -> bool: @@ -73,6 +77,39 @@ def _parse_env_bool(value: str | None, default: bool) -> bool: 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): @@ -431,16 +468,22 @@ class AstrBotDashboard: ) dashboard_config = self.config.get("dashboard", {}) - host = ( + host_value = ( os.environ.get("ASTRBOT_HOST") or os.environ.get("DASHBOARD_HOST") or dashboard_config.get("host", "0.0.0.0") ) - port = int( + 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") + + port_value = ( os.environ.get("ASTRBOT_PORT") or os.environ.get("DASHBOARD_PORT") or dashboard_config.get("port", 6185) ) + resolved_port = _resolve_dashboard_value(port_value, field_name="port") + port = int(resolved_port) ssl_config = dashboard_config.get("ssl", {}) ssl_enable = _parse_env_bool( os.environ.get("ASTRBOT_SSL_ENABLE") @@ -484,16 +527,19 @@ class AstrBotDashboard: or os.environ.get("DASHBOARD_SSL_CERT") or ssl_config.get("cert_file", "") ) + cert_file = _resolve_dashboard_value(cert_file, field_name="ssl.cert_file") key_file = ( os.environ.get("ASTRBOT_SSL_KEY") or os.environ.get("DASHBOARD_SSL_KEY") or ssl_config.get("key_file", "") ) + key_file = _resolve_dashboard_value(key_file, field_name="ssl.key_file") ca_certs = ( os.environ.get("ASTRBOT_SSL_CA_CERTS") or os.environ.get("DASHBOARD_SSL_CA_CERTS") or ssl_config.get("ca_certs", "") ) + ca_certs = _resolve_dashboard_value(ca_certs, field_name="ssl.ca_certs") if cert_file and key_file: cert_path = await anyio.Path(cert_file).expanduser() diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 0cb9f7838..1d759d03e 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -22,7 +22,7 @@ from astrbot.core.star.star import star_registry from astrbot.core.star.star_handler import star_handlers_registry from astrbot.core.utils.pip_installer import PipInstallError from astrbot.dashboard.routes.plugin import PluginRoute -from astrbot.dashboard.server import AstrBotDashboard +from astrbot.dashboard.server import AstrBotDashboard, _expand_env_placeholders from tests.fixtures.helpers import ( MockPluginBuilder, create_mock_updater_install, @@ -80,6 +80,20 @@ def test_check_port_in_use_ignores_permission_errors(monkeypatch: pytest.MonkeyP assert server.check_port_in_use("127.0.0.1", 3002) is False +def test_expand_env_placeholders_resolves_env_and_default( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("HOST", "0.0.0.0") + + assert _expand_env_placeholders("${HOST}", "host") == "0.0.0.0" + assert _expand_env_placeholders("${MISSING:-3002}", "port") == "3002" + + +def test_expand_env_placeholders_raises_for_unresolved_variable(): + with pytest.raises(ValueError, match="dashboard host: HOST"): + _expand_env_placeholders("${HOST}", "host") + + @pytest_asyncio.fixture(scope="module") async def core_lifecycle_td(tmp_path_factory): """Creates and initializes a core lifecycle instance with a temporary database."""