diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 3f74f0ec9b..895a5db8c5 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -1,3 +1,4 @@ + import asyncio import copy import sys @@ -216,7 +217,7 @@ async def reset( enforce_max_turns: int = -1, # llm compressor llm_compress_instruction: str | None = None, - llm_compress_keep_recent_ratio: float = 0.15, + llm_compress_keep_recent: int = 0, llm_compress_provider: Provider | None = None, # truncate by turns compressor truncate_turns: int = 1, @@ -233,7 +234,7 @@ async def reset( self.streaming = streaming self.enforce_max_turns = enforce_max_turns self.llm_compress_instruction = llm_compress_instruction - self.llm_compress_keep_recent_ratio = llm_compress_keep_recent_ratio + self.llm_compress_keep_recent = llm_compress_keep_recent self.llm_compress_provider = llm_compress_provider self.truncate_turns = truncate_turns self.custom_token_counter = custom_token_counter @@ -248,7 +249,7 @@ async def reset( enforce_max_turns=self.enforce_max_turns, truncate_turns=self.truncate_turns, llm_compress_instruction=self.llm_compress_instruction, - llm_compress_keep_recent_ratio=self.llm_compress_keep_recent_ratio, + llm_compress_keep_recent=self.llm_compress_keep_recent, llm_compress_provider=self.llm_compress_provider, custom_token_counter=self.custom_token_counter, custom_compressor=self.custom_compressor, @@ -458,8 +459,11 @@ async def _iter_llm_responses( self, *, include_model: bool = True ) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" + messages_for_provider = getattr( + self, "_provider_messages", self.run_context.messages + ) payload = { - "contexts": self._sanitize_contexts_for_provider(self.run_context.messages), + "contexts": self._sanitize_contexts_for_provider(messages_for_provider), "func_tool": self._func_tool_for_provider(), "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] @@ -580,10 +584,7 @@ def _sanitize_contexts_for_provider( self, contexts: list[Message] | list[dict[str, T.Any]], ) -> list[Message] | list[dict[str, T.Any]]: - modalities = self.provider.provider_config.get("modalities", None) - if ( - not modalities - ): # Unconfigured (None or empty list) defaults to support all modalities + if not self._should_fix_modalities_for_provider(): return contexts sanitized_contexts, stats = sanitize_contexts_by_modalities( contexts, @@ -592,6 +593,12 @@ def _sanitize_contexts_for_provider( log_context_sanitize_stats(stats) return sanitized_contexts + def _should_fix_modalities_for_provider(self) -> bool: + modalities = self.provider.provider_config.get("modalities", None) + return ( + isinstance(modalities, list) and modalities + ) # Empty list is treated as unconfigured + def _func_tool_for_provider(self) -> ToolSet | None: if not self.req.func_tool: return None @@ -604,14 +611,11 @@ def _func_tool_for_provider(self) -> ToolSet | None: return None return self.req.func_tool - def _simple_print_message_role(self, tag: str, messages: list): - roles = [m.role for m in messages] - n = len(roles) - if n > 10: - summary = ",".join(roles[:4]) + ",...," + ",".join(roles[-4:]) - else: - summary = ",".join(roles) - logger.debug(f"{tag} messages -> [{n}] {summary}") + def _simple_print_message_role(self, tag: str = ""): + roles = [] + for message in self.run_context.messages: + roles.append(message.role) + logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}") def follow_up( self, @@ -705,13 +709,16 @@ async def step(self): self._transition_state(AgentState.RUNNING) llm_resp_result = None - # Process request-time context before sending it to the provider. + # Process request-time context on a copy so the runner's canonical + # messages are never mutated. The processed result is only used for this + # provider call. Persistent compaction is owned by the conversation / + # memory layer. token_usage = self.req.conversation.token_usage if self.req.conversation else 0 - self._simple_print_message_role("[BefCompact]", self.run_context.messages) - self.run_context.messages = await self.request_context_manager.process( + self._simple_print_message_role("[BefCompact]") + self._provider_messages = await self.request_context_manager.process( self.run_context.messages, trusted_token_usage=token_usage ) - self._simple_print_message_role("[AftCompact]", self.run_context.messages) + self._simple_print_message_role("[AftCompact]") async for llm_response in self._iter_llm_responses_with_fallback(): if llm_response.is_chunk: @@ -893,6 +900,25 @@ async def step(self): parts.append(TextPart(text=llm_resp.completion_text)) if len(parts) == 0: parts = None + + # 过滤掉无效的 tool calls,确保 assistant 消息不包含无 id/name 的条目 + if llm_resp.tools_call_name and llm_resp.tools_call_ids: + valid_indices = [ + i for i, (name, tid) in enumerate( + zip(llm_resp.tools_call_name, llm_resp.tools_call_ids) + ) + if name and tid + ] + if len(valid_indices) < len(llm_resp.tools_call_name): + llm_resp.tools_call_name = [llm_resp.tools_call_name[i] for i in valid_indices] + llm_resp.tools_call_args = [llm_resp.tools_call_args[i] for i in valid_indices] + llm_resp.tools_call_ids = [llm_resp.tools_call_ids[i] for i in valid_indices] + + # 如果过滤后没有有效的 tool calls,跳过构建 tool_calls_result + if not llm_resp.tools_call_name: + await self._complete_with_assistant_response(llm_resp) + return + tool_calls_result = ToolCallsResult( tool_calls_info=AssistantMessageSegment( tool_calls=llm_resp.to_openai_to_calls_model(), @@ -997,6 +1023,13 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: llm_response.tools_call_args, llm_response.tools_call_ids, ): + # 跳过无效的 tool call(name 或 id 为空) + if not func_tool_name or not func_tool_id: + logger.warning( + f"Skipping invalid tool call with name={func_tool_name!r}, id={func_tool_id!r}" + ) + continue + tool_result_blocks_start = len(tool_call_result_blocks) tool_call_streak = self._track_tool_call_streak(func_tool_name) yield _HandleFunctionToolsResult.from_message_chain( diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index de5caad554..b75b557e07 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -349,7 +349,7 @@ async def _execute_handoff( continue prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {}) - agent_max_step = int(prov_settings.get("max_agent_step", 30)) + agent_max_step = int(prov_settings.get("max_agent_step", 114514)) stream = prov_settings.get("streaming_response", False) llm_resp = await ctx.tool_loop_agent( event=event, diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 1fb7b5cf7a..d435c72cb5 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -12,6 +12,7 @@ from python_ripgrep import search from astrbot.api import logger +from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.computer.file_read_utils import ( detect_text_encoding, read_local_text_range_sync, @@ -22,10 +23,7 @@ from .base import ComputerBooter from .shipyard_search_file_util import _truncate_long_lines -_BLOCKED_COMMAND_PATTERNS = [ - " rm -rf ", - " rm -fr ", - " rm -r ", +DEFAULT_BLOCKED_COMMAND_PATTERNS = [ " mkfs", " dd if=", " shutdown", @@ -39,9 +37,31 @@ ] +def _get_blocked_command_patterns() -> list[str]: + """Return user-configured blocked command patterns. + + The configuration is read on each shell execution so dashboard changes take + effect without recreating the local booter. If the config cannot be loaded + or the value has an unexpected type, fall back to the built-in defaults. + """ + try: + computer_config = AstrBotConfig().get("computer", {}) + patterns = computer_config.get("blocked_command_patterns") + except Exception as e: + logger.warning( + f"Failed to load computer.blocked_command_patterns, using defaults: {e}" + ) + return DEFAULT_BLOCKED_COMMAND_PATTERNS + + if not isinstance(patterns, list): + return DEFAULT_BLOCKED_COMMAND_PATTERNS + + return [str(pattern).lower() for pattern in patterns if str(pattern)] + + def _is_safe_command(command: str) -> bool: cmd = f" {command.strip().lower()} " - return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS) + return not any(pat in cmd for pat in _get_blocked_command_patterns()) def _decode_bytes_with_fallback( diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 22a53bb446..88a279e629 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -153,7 +153,7 @@ "deerflow_agent_runner_provider_id": "", "unsupported_streaming_strategy": "realtime_segmenting", "reachability_check": False, - "max_agent_step": 30, + "max_agent_step": 114514, "tool_call_timeout": 120, "tool_schema_mode": "full", "llm_safety_mode": True, @@ -243,6 +243,20 @@ "t2i_active_template": "base", "http_proxy": "", "no_proxy": ["localhost", "127.0.0.1", "::1", "10.*", "192.168.*"], + "computer": { + "blocked_command_patterns": [ + " mkfs", + " dd if=", + " shutdown", + " reboot", + " poweroff", + " halt", + " sudo ", + ":(){:|:&};:", + " kill -9 ", + " killall ", + ], + }, "dashboard": { "enable": True, "username": "astrbot", @@ -270,6 +284,17 @@ "cert_file": "", "key_file": "", "ca_certs": "", + "auto_acme": { + "enable": False, + "ip": "", + "email": "", + "server": "letsencrypt", + "keylength": "2048", + "certificate_profile": "shortlived", + "days": "6", + "httpport": "80", + "force_issue": False, + }, }, }, "platform": [], @@ -328,6 +353,19 @@ "description": "消息平台适配器", "type": "list", "config_template": { + "Telegram": { + "id": "telegram", + "type": "telegram", + "enable": True, + "telegram_token": "your_bot_token", + "start_message": "Hello, I'm AstrBot!", + "telegram_api_base_url": "https://api.telegram.org/bot", + "telegram_file_base_url": "https://api.telegram.org/file/bot", + "telegram_command_register": True, + "telegram_command_auto_refresh": True, + "telegram_command_register_interval": 300, + "telegram_polling_restart_delay": 5.0, + }, "QQ 官方机器人(WebSocket)": { "id": "default", "type": "qq_official", @@ -439,19 +477,6 @@ "client_secret": "", "card_template_id": "", }, - "Telegram": { - "id": "telegram", - "type": "telegram", - "enable": True, - "telegram_token": "your_bot_token", - "start_message": "Hello, I'm AstrBot!", - "telegram_api_base_url": "https://api.telegram.org/bot", - "telegram_file_base_url": "https://api.telegram.org/file/bot", - "telegram_command_register": True, - "telegram_command_auto_refresh": True, - "telegram_command_register_interval": 300, - "telegram_polling_restart_delay": 5.0, - }, "Discord": { "id": "discord", "type": "discord", @@ -2982,6 +3007,12 @@ "items": {"type": "string"}, "hint": "在此处添加不希望通过代理访问的地址,例如内部服务地址。回车添加,可添加多个,如未设置代理请忽略此配置", }, + "computer.blocked_command_patterns": { + "description": "本地 Shell 命令拦截规则", + "type": "list", + "items": {"type": "string"}, + "hint": "本地计算机工具执行 shell 命令前会将命令转为小写并在首尾补空格,然后检查是否包含列表中的任意片段。命中则拒绝执行。请谨慎修改,删除默认项可能降低安全性。", + }, "timezone": { "type": "string", }, @@ -3014,6 +3045,32 @@ "type": "string", "condition": {"dashboard.ssl.enable": True}, }, + "dashboard.ssl.auto_acme.enable": { + "type": "bool", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.auto_acme.ip": { + "type": "string", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.email": { + "type": "string", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.server": { + "type": "string", + "options": ["letsencrypt", "zerossl"], + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.keylength": { + "type": "string", + "options": ["2048", "ec-256", "ec-384"], + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.force_issue": { + "type": "bool", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, "log_file_enable": {"type": "bool"}, "log_file_path": {"type": "string", "condition": {"log_file_enable": True}}, "log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}}, @@ -4291,6 +4348,44 @@ "hint": "可选。用于指定 CA 证书文件路径。", "condition": {"dashboard.ssl.enable": True}, }, + "dashboard.ssl.auto_acme.enable": { + "description": "自动申请 Let's Encrypt IP 证书", + "type": "bool", + "hint": "启用后,WebUI HTTPS 启动时会使用 acme.sh standalone HTTP-01 模式为当前公网 IP 申请 Let's Encrypt 证书。要求 80 端口可被公网访问且未被占用。", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.auto_acme.ip": { + "description": "公网 IP", + "type": "string", + "hint": "留空时自动检测公网 IP。Let's Encrypt 的 IP 证书支持情况可能受 ACME 服务策略影响。", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.email": { + "description": "ACME 邮箱", + "type": "string", + "hint": "用于注册 acme.sh 账户和接收证书通知,可留空。", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.server": { + "description": "ACME CA", + "type": "string", + "options": ["letsencrypt", "zerossl"], + "hint": "默认使用 letsencrypt。", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.keylength": { + "description": "证书密钥类型", + "type": "string", + "options": ["2048", "ec-256", "ec-384"], + "hint": "默认 RSA 2048。", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, + "dashboard.ssl.auto_acme.force_issue": { + "description": "强制重新签发证书", + "type": "bool", + "hint": "一般保持关闭。开启后每次启动会带 --force 重新申请,可能触发 CA 频率限制。", + "condition": {"dashboard.ssl.auto_acme.enable": True}, + }, "log_file_enable": { "description": "启用文件日志", "type": "bool", @@ -4356,6 +4451,12 @@ "type": "list", "items": {"type": "string"}, }, + "computer.blocked_command_patterns": { + "description": "本地 Shell 命令拦截规则", + "type": "list", + "items": {"type": "string"}, + "hint": "本地计算机工具执行 shell 命令前会将命令转为小写并在首尾补空格,然后检查是否包含列表中的任意片段。命中则拒绝执行。请谨慎修改,删除默认项可能降低安全性。", + }, }, }, }, diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index 49dd7c2597..eb88206365 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -61,7 +61,7 @@ async def initialize(self, ctx: PipelineContext) -> None: self.unsupported_streaming_strategy: str = settings[ "unsupported_streaming_strategy" ] - self.max_step: int = settings.get("max_agent_step", 30) + self.max_step: int = settings.get("max_agent_step", 114514) self.tool_call_timeout: int = settings.get("tool_call_timeout", 60) self.tool_schema_mode: str = settings.get("tool_schema_mode", "full") if self.tool_schema_mode not in ("skills_like", "full"): diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py index 8e12683ffb..120a91101e 100644 --- a/astrbot/core/provider/entities.py +++ b/astrbot/core/provider/entities.py @@ -452,6 +452,13 @@ def to_openai_tool_calls(self) -> list[dict]: """Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead.""" ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): + if not self.tools_call_name[idx]: + logger.warning( + f"Skipping tool call at index {idx} because function.name is empty/None. " + f"tool_call_id={self.tools_call_ids[idx] if idx < len(self.tools_call_ids) else 'N/A'}, " + f"arguments={tool_call_arg}" + ) + continue payload = { "id": self.tools_call_ids[idx], "function": { @@ -471,6 +478,13 @@ def to_openai_to_calls_model(self) -> list[ToolCall]: """The same as to_openai_tool_calls but return pydantic model.""" ret = [] for idx, tool_call_arg in enumerate(self.tools_call_args): + if idx >= len(self.tools_call_name) or not self.tools_call_name[idx]: + logger.warning( + f"Skipping tool call at index {idx} because function.name is empty/None or out of bounds. " + f"tool_call_id={self.tools_call_ids[idx] if idx < len(self.tools_call_ids) else 'N/A'}, " + f"arguments={tool_call_arg}" + ) + continue ret.append( ToolCall( id=self.tools_call_ids[idx], diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 9888da8f5f..9a55af0075 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -44,6 +44,7 @@ from .routes.session_management import SessionManagementRoute from .routes.subagent import SubAgentRoute from .routes.t2i import T2iRoute +from .ssl_auto_acme import ensure_dashboard_ip_certificate _RATE_LIMITED_ENDPOINTS: frozenset = frozenset( { @@ -655,6 +656,15 @@ def run(self): ) resolved_ssl_config: dict[str, str] = {} if ssl_enable: + try: + auto_acme_changed, _ = ensure_dashboard_ip_certificate(ssl_config) + if auto_acme_changed: + self.config.save_config() + except Exception as e: + logger.error( + "Failed to prepare automatic dashboard HTTPS certificate: %s", + e, + ) ssl_enable, resolved_ssl_config = self._resolve_dashboard_ssl_config( ssl_config, ) diff --git a/astrbot/dashboard/ssl_auto_acme.py b/astrbot/dashboard/ssl_auto_acme.py new file mode 100644 index 0000000000..8090ed53a2 --- /dev/null +++ b/astrbot/dashboard/ssl_auto_acme.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import os +import shutil +import subprocess +import urllib.request +from pathlib import Path +from typing import Any + +from astrbot.core import logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +_DEFAULT_IP_SERVICES = ( + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", +) + + +def _read_url(url: str, timeout: int = 10) -> str: + with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 + return resp.read().decode("utf-8", errors="replace").strip() + + +def _resolve_public_ip(auto_acme_config: dict[str, Any]) -> str: + configured_ip = str( + os.environ.get("ASTRBOT_DASHBOARD_ACME_IP") + or auto_acme_config.get("ip") + or "" + ).strip() + if configured_ip: + return configured_ip + + services = auto_acme_config.get("ip_services") or _DEFAULT_IP_SERVICES + if not isinstance(services, list): + services = list(_DEFAULT_IP_SERVICES) + + last_error: Exception | None = None + for service in services: + try: + ip = _read_url(str(service)).strip() + if ip: + return ip + except Exception as e: # pragma: no cover - network dependent + last_error = e + logger.debug(f"Failed to get public IP from {service}: {e}") + + raise RuntimeError(f"Unable to resolve public IP for ACME certificate: {last_error}") + + +def _find_acme_sh() -> str | None: + candidates = [ + os.environ.get("ACME_SH"), + os.path.expanduser("~/.acme.sh/acme.sh"), + shutil.which("acme.sh"), + ] + for candidate in candidates: + if candidate and Path(candidate).is_file(): + return str(Path(candidate).expanduser()) + return None + + +def _install_acme_sh(email: str) -> str: + logger.info("acme.sh not found. Installing acme.sh for dashboard HTTPS ACME.") + installer = _read_url("https://get.acme.sh", timeout=30) + cmd = ["sh", "-s"] + if email: + cmd.append(f"email={email}") + subprocess.run( + cmd, + input=installer, + text=True, + check=True, + timeout=180, + ) + acme_sh = _find_acme_sh() + if not acme_sh: + raise RuntimeError("acme.sh installation finished but acme.sh was not found") + return acme_sh + + +def _run_acme(args: list[str], timeout: int) -> None: + logger.debug("Running ACME command: %s", " ".join(args)) + subprocess.run(args, check=True, timeout=timeout) + + +def ensure_dashboard_ip_certificate( + ssl_config: dict[str, Any], +) -> tuple[bool, dict[str, str]]: + """Ensure a Let's Encrypt certificate for the dashboard public IP. + + This uses acme.sh standalone HTTP-01 mode, similar to 3x-ui's shell helper. + Port 80 must be reachable from the public Internet and not occupied. + Returns (changed, paths). ``changed`` means ssl_config was updated. + """ + auto_acme_config = ssl_config.get("auto_acme", {}) + if not isinstance(auto_acme_config, dict): + auto_acme_config = {} + + enabled = bool( + os.environ.get("ASTRBOT_DASHBOARD_ACME_ENABLE", "").lower() + in ("1", "true", "yes", "on") + or auto_acme_config.get("enable", False) + ) + if not enabled: + return False, {} + + cert_file = str(ssl_config.get("cert_file") or "").strip() + key_file = str(ssl_config.get("key_file") or "").strip() + force_issue = bool(auto_acme_config.get("force_issue", False)) + if ( + cert_file + and key_file + and Path(cert_file).expanduser().is_file() + and Path(key_file).expanduser().is_file() + and not force_issue + ): + return False, {"cert_file": cert_file, "key_file": key_file} + + public_ip = _resolve_public_ip(auto_acme_config) + email = str( + os.environ.get("LE_EMAIL") + or os.environ.get("ASTRBOT_DASHBOARD_ACME_EMAIL") + or auto_acme_config.get("email") + or "" + ).strip() + server = str(auto_acme_config.get("server") or "letsencrypt").strip() + keylength = str(auto_acme_config.get("keylength") or "2048").strip() + certificate_profile = str( + auto_acme_config.get("certificate_profile") or "shortlived" + ).strip() + days = str(auto_acme_config.get("days") or "6").strip() + httpport = str(auto_acme_config.get("httpport") or "80").strip() + timeout = int(auto_acme_config.get("timeout", 600)) + + cert_dir = Path( + auto_acme_config.get("cert_dir") + or Path(get_astrbot_data_path()) / "certs" / "dashboard-acme" / public_ip + ).expanduser() + cert_dir.mkdir(parents=True, exist_ok=True) + fullchain_path = cert_dir / "fullchain.pem" + key_path = cert_dir / "privkey.pem" + + acme_sh = _find_acme_sh() or _install_acme_sh(email) + + _run_acme([acme_sh, "--set-default-ca", "--server", server], timeout=120) + + issue_cmd = [ + acme_sh, + "--issue", + "--server", + server, + "-d", + public_ip, + "--standalone", + "--keylength", + keylength, + "--certificate-profile", + certificate_profile, + "--days", + days, + "--httpport", + httpport, + ] + if force_issue: + issue_cmd.append("--force") + _run_acme(issue_cmd, timeout=timeout) + + install_cmd = [ + acme_sh, + "--install-cert", + "-d", + public_ip, + "--fullchain-file", + str(fullchain_path), + "--key-file", + str(key_path), + ] + _run_acme(install_cmd, timeout=timeout) + + if not fullchain_path.is_file() or not key_path.is_file(): + raise RuntimeError("ACME certificate files were not created") + + ssl_config["cert_file"] = str(fullchain_path) + ssl_config["key_file"] = str(key_path) + logger.info("Dashboard HTTPS IP certificate is ready for %s.", public_ip) + return True, {"cert_file": str(fullchain_path), "key_file": str(key_path)} diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 618b95bac4..1ec776d2d7 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1118,6 +1118,32 @@ "ca_certs": { "description": "SSL CA Certificate File Path", "hint": "Optional. Path to CA certificate file." + }, + "auto_acme": { + "enable": { + "description": "Automatically issue Let's Encrypt IP certificate", + "hint": "When enabled, WebUI HTTPS startup uses acme.sh standalone HTTP-01 mode to issue a Let's Encrypt certificate for the current public IP. Port 80 must be reachable from the Internet and not occupied." + }, + "ip": { + "description": "Public IP", + "hint": "Leave empty to auto-detect the public IP. Let's Encrypt IP certificate support may depend on ACME service policy." + }, + "email": { + "description": "ACME email", + "hint": "Used to register the acme.sh account and receive certificate notifications. Optional." + }, + "server": { + "description": "ACME CA", + "hint": "Defaults to letsencrypt." + }, + "keylength": { + "description": "Certificate key type", + "hint": "Defaults to RSA 2048." + }, + "force_issue": { + "description": "Force certificate re-issue", + "hint": "Usually keep disabled. When enabled, startup passes --force and may hit CA rate limits." + } } }, "totp": { @@ -1177,6 +1203,10 @@ }, "no_proxy": { "description": "Direct Connection Address List" + }, + "computer.blocked_command_patterns": { + "description": "Local shell command block patterns", + "hint": "Before the local computer tool runs a shell command, it lowercases the command, pads it with spaces, and blocks execution if it contains any pattern in this list. Edit carefully; removing defaults may reduce safety." } } }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index c42a3313a5..803d9f2222 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1119,6 +1119,32 @@ "ca_certs": { "description": "Путь к сертификату CA SSL", "hint": "Опционально. Путь к сертификату CA." + }, + "auto_acme": { + "enable": { + "description": "Автоматически выпустить IP-сертификат Let's Encrypt", + "hint": "Если включено, при запуске WebUI HTTPS используется acme.sh в режиме standalone HTTP-01 для выпуска сертификата Let's Encrypt для текущего публичного IP. Порт 80 должен быть доступен из Интернета и не занят." + }, + "ip": { + "description": "Публичный IP", + "hint": "Оставьте пустым для автоматического определения публичного IP. Поддержка IP-сертификатов Let's Encrypt может зависеть от политики ACME-сервиса." + }, + "email": { + "description": "Email ACME", + "hint": "Используется для регистрации аккаунта acme.sh и уведомлений о сертификатах. Необязательно." + }, + "server": { + "description": "ACME CA", + "hint": "По умолчанию letsencrypt." + }, + "keylength": { + "description": "Тип ключа сертификата", + "hint": "По умолчанию RSA 2048." + }, + "force_issue": { + "description": "Принудительно перевыпускать сертификат", + "hint": "Обычно оставляйте выключенным. При включении запуск передает --force и может попасть под ограничения CA." + } } }, "totp": { @@ -1178,6 +1204,10 @@ }, "no_proxy": { "description": "Список исключений прокси" + }, + "computer.blocked_command_patterns": { + "description": "Шаблоны блокировки локальных shell-команд", + "hint": "Перед запуском shell-команды локальный инструмент приводит команду к нижнему регистру, добавляет пробелы по краям и блокирует выполнение, если команда содержит любой шаблон из списка. Изменяйте осторожно: удаление значений по умолчанию может снизить безопасность." } } }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 200b3b9fe1..1d3d8b1bdc 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1120,6 +1120,32 @@ "ca_certs": { "description": "SSL CA 证书文件路径", "hint": "可选。用于指定 CA 证书文件路径。" + }, + "auto_acme": { + "enable": { + "description": "自动申请 Let's Encrypt IP 证书", + "hint": "启用后,WebUI HTTPS 启动时会使用 acme.sh standalone HTTP-01 模式为当前公网 IP 申请 Let's Encrypt 证书。要求 80 端口可被公网访问且未被占用。" + }, + "ip": { + "description": "公网 IP", + "hint": "留空时自动检测公网 IP。Let's Encrypt 的 IP 证书支持情况可能受 ACME 服务策略影响。" + }, + "email": { + "description": "ACME 邮箱", + "hint": "用于注册 acme.sh 账户和接收证书通知,可留空。" + }, + "server": { + "description": "ACME CA", + "hint": "默认使用 letsencrypt。" + }, + "keylength": { + "description": "证书密钥类型", + "hint": "默认 RSA 2048。" + }, + "force_issue": { + "description": "强制重新签发证书", + "hint": "一般保持关闭。开启后每次启动会带 --force 重新申请,可能触发 CA 频率限制。" + } } }, "totp": { @@ -1179,6 +1205,10 @@ }, "no_proxy": { "description": "直连地址列表" + }, + "computer.blocked_command_patterns": { + "description": "本地 Shell 命令拦截规则", + "hint": "本地计算机工具执行 shell 命令前会将命令转为小写并在首尾补空格,然后检查是否包含列表中的任意片段。命中则拒绝执行。请谨慎修改,删除默认项可能降低安全性。" } } },