# coding: utf-8 """Panel operation toolset - 44 tools registered to ToolRegistry""" import json import os import re import sys import subprocess import time from typing import List from . import register_tool from .base import _xml_response _panel_path = '/www/server/panel' os.chdir(_panel_path) sys.path.insert(0, _panel_path) sys.path.insert(0, _panel_path + "/class/") sys.path.insert(0, _panel_path + "/class_v2/") import public try: from public.hook_import import hook_import hook_import() except: pass from public import lang def _to_obj(params: dict): """Convert dict to panel obj object""" return public.to_dict_obj(params) class _ServiceStatusEnricher: """Backfill s_status for services not covered by get_soft_status. Layered on top of the get_soft_status result, reusing panelPlugin detectors for the services it misses (php/memcached/tomcat/phpmyadmin + a generic fallback). Output stays structurally identical — same outer keys and the same inner 9 keys; on any error the wrapper is returned unchanged so the tool never breaks the base output. """ _COVERED = frozenset({ 'nginx', 'mysql', 'apache', 'pure-ftpd', 'redis', 'pgsql', 'openlitespeed', 'mongodb', }) _PHP_RE = re.compile(r'^php(?:-fpm)?(?:-?(\d{1,2})(?:\.\d+)?)?$') _TITLES = {'tomcat': 'Tomcat', 'phpmyadmin': 'phpMyAdmin'} def __init__(self, plugin): self.plugin = plugin @classmethod def enrich(cls, wrapper, name): if not isinstance(wrapper, dict): return wrapper inner = wrapper.get('message') if not isinstance(inner, dict): return wrapper raw = (name or '').strip() family, phpver = cls._classify(raw) if not family or family in cls._COVERED: return wrapper try: from class_v2.panel_plugin_v2 import panelPlugin enricher = cls(panelPlugin()) except Exception: return wrapper if family == 'php': # get_soft_status judges install via /www/server/, which misses # php-fpm (real dir is /www/server/php/); re-judge via dir scan. running, version, installed = enricher._php(phpver) if installed: inner['status'] = True inner['setup'] = True inner['s_status'] = bool(running) if version: inner['version'] = version if inner.get('title', '') in ('', raw): inner['title'] = 'PHP' + (phpver or '') return wrapper # Non-php: leave uninstalled services untouched (s_status stays False). if not inner.get('status'): return wrapper try: running, version = enricher._detect(family, raw, phpver) except Exception: return wrapper if family in ('memcached', 'tomcat', 'phpmyadmin'): inner['s_status'] = bool(running) if version and not inner.get('version'): inner['version'] = version if inner.get('title', '') in ('', raw): inner['title'] = cls._TITLES.get(family, inner.get('title', '')) elif running: # Generic fallback: only flip to True when running is detected. inner['s_status'] = True return wrapper @classmethod def query(cls, names, fetch=None): """High-level entry: return a newline-joined status report, one line per service (e.g. 'nginx: installed, running, v1.24'). Encapsulates get_soft_status + enrich + describe.""" if fetch is None: from class_v2.panelModelV2.publicModel import main as PublicMain fetch = PublicMain().get_soft_status lines = [] for svc in names: data = cls.enrich(fetch(_to_obj({"name": svc})), svc) inner = data.get('message', {}) if isinstance(data, dict) else {} lines.append(cls._describe(svc, inner)) return '\n'.join(lines) @classmethod def _describe(cls, svc, inner): """Render one service's status as a model-friendly line.""" if not isinstance(inner, dict) or not inner.get('status'): return '{}: not installed'.format(svc) parts = ['installed', 'running' if inner.get('s_status') else 'stopped'] version = inner.get('version', '') if version: parts.append('v' + version) return '{}: {}'.format(svc, ', '.join(parts)) @classmethod def _classify(cls, raw): """Return (family, php_version). 'php' for any php/php-fpm/php- variant, else the raw name; php_version is dot-stripped (e.g. '74').""" n = (raw or '').strip().lower() if not n: return ('', None) m = cls._PHP_RE.match(n) if m: return ('php', m.group(1)) return (n, None) def _detect(self, family, raw, phpver): """Return (running, version) for memcached/tomcat/phpmyadmin/generic.""" if family == 'memcached': return (self._exists('memcached'), '') if family == 'tomcat': return (self._exists('jsvc') or self._exists('java'), '') if family == 'phpmyadmin': try: return (bool(self.plugin.get_phpmyadmin_stat()), '') except Exception: return (False, '') # Generic fallback: the raw name, its separator-stripped form, or a # common daemon alias. candidates = [raw, re.sub(r'[-_.]', '', raw), {'apache': 'httpd', 'mysql': 'mysqld'}.get(raw, '')] return (any(self._exists(c) for c in candidates if c), '') def _exists(self, pname): try: return bool(self.plugin.process_exists(pname)) except Exception: return False def _php(self, ver): """Detect php-fpm via installed /www/server/php/ dirs. When ver is given only that version is checked; version joins detected versions like '7.4/8.0'.""" base = '/www/server/php' installed = [] if os.path.isdir(base): installed = [d for d in os.listdir(base) if d.isdigit() and os.path.isdir(os.path.join(base, d))] if ver: installed = [v for v in installed if v == ver] if not installed: return (False, '', False) running, versions = False, [] for v in installed: try: if self.plugin.get_php_status(v): running = True except Exception: continue try: with open(os.path.join(base, v, 'version.pl'), 'r') as f: content = f.read().strip() if content: versions.append(content) except Exception: pass return (running, '/'.join(versions), True) def _truncate_result(data, limit): """Best-effort truncate a tool result to `limit` items without losing info. Handles plain lists and common wrappers ({data/rows/list/records: [...]}). Returns data unchanged when no list is found (info-preserving fallback).""" def _slice(lst): total = len(lst) if total > limit: lst = lst[:limit] lst.append({"_truncated": True, "total": total, "limit": limit}) return lst if isinstance(data, list): return _slice(data) if isinstance(data, dict): for key in ("data", "rows", "list", "records"): v = data.get(key) if isinstance(v, list): data[key] = _slice(v) return data return data # ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════ # System Diagnostics # ═══════════════════════════════════════════════════════════════ @register_tool(category="System", name_cn="Get System Resources", risk_level="low") def GetSystemResources() -> str: """ Get real-time system resource metrics: CPU load, memory, disk usage, OS version. Returns: Load Avg (1m/5m/15m), Memory (used/total + %), Disk / (used/total + %), OS version. Workflow: GetSystemResources -> GetTopProcesses (for process-level diagnosis). """ try: # Load Average try: load1, load5, load15 = os.getloadavg() load_info = f"Load Avg: {load1:.2f}, {load5:.2f}, {load15:.2f}" except OSError: load_info = "Load Avg: N/A (Windows?)" # Memory mem_info = "Mem: Unknown" if os.path.exists('/proc/meminfo'): with open('/proc/meminfo', 'r') as f: lines = f.readlines() total = 0 available = 0 for line in lines: if 'MemTotal' in line: total = int(line.split()[1]) // 1024 # MB if 'MemAvailable' in line: available = int(line.split()[1]) // 1024 # MB used = total - available percent = (used / total * 100) if total > 0 else 0 mem_info = f"Mem: {used}MB/{total}MB ({percent:.1f}%)" # Disk import shutil disk = shutil.disk_usage("/") total_gb = disk.total // (1024 ** 3) used_gb = disk.used // (1024 ** 3) disk_percent = (disk.used / disk.total * 100) disk_info = f"Disk (/): {used_gb}GB/{total_gb}GB ({disk_percent:.1f}%)" os_info = public.get_os_version() result = f"{load_info}\n{mem_info}\n{disk_info}\nOS: {os_info}" return _xml_response("GetSystemResources", "done", result) except Exception as e: return _xml_response("GetSystemResources", "error", f"Error getting resources: {str(e)}") @register_tool(category="System", name_cn="Get Public IP", risk_level="low") def GetPublicIP() -> str: """ Get the server's public IP (via external probe) and local network IP. Returns both addresses for comparison: - Public IP: the address the internet sees, used for DNS A-record, SSL application, firewall rules. - Local IP: the server's primary network interface address (may differ from public IP behind NAT). If public IP detection fails, only local IP is returned. """ result_parts = [] # 公网 IP public_ip = None for api_url in ['https://api.ipify.org', 'https://ifconfig.me', 'https://ip.sb']: try: p = subprocess.run( ['curl', '-s', '--connect-timeout', '3', api_url], capture_output=True, text=True, timeout=5 ) candidate = p.stdout.strip() if candidate and re.match(r'^\d{1,3}(\.\d{1,3}){3}$', candidate): public_ip = candidate break except Exception: continue if public_ip: result_parts.append(f"Public IP: {public_ip}") else: result_parts.append("Public IP: N/A (detection failed)") # 本机网卡IP try: local_ip = public.GetLocalIp() result_parts.append(f"Local IP: {local_ip}") except Exception: result_parts.append("Local IP: N/A") return _xml_response("GetPublicIP", "done", "\n".join(result_parts)) @register_tool(category="System", name_cn="Get Top 10 Processes", risk_level="low") def GetTopProcesses() -> str: """ Get top 10 processes by CPU and top 10 by memory. Returns: Two sections - CPU TOP 10 and Memory TOP 10 (PID, user, %cpu, %mem, command). Workflow: GetSystemResources -> GetTopProcesses. """ def _run_shell_cmd(command: list, timeout: int = 300) -> tuple: """ Common function to execute shell commands. Returns (success: bool, output: str) """ try: # Use shell=False for security when passing a list result = subprocess.run( command, capture_output=True, text=True, timeout=timeout ) output = result.stdout.strip() if not output: output = result.stderr.strip() return result.returncode == 0, output except subprocess.TimeoutExpired: return False, f"Error: Command timed out after {timeout} seconds." except FileNotFoundError: return False, f"Error: Command not found: {command[0]}" except Exception as e: return False, f"Error executing command: {str(e)}" output_parts = [] # 1. CPU Top 10 success_cpu, output_cpu = _run_shell_cmd(["ps", "-eo", "pid,user,%cpu,%mem,command", "--sort=-%cpu"]) if success_cpu: lines = output_cpu.strip().splitlines() header = lines[0] if lines else "" top10 = lines[1:11] output_parts.append("--- CPU Usage TOP 10 ---") output_parts.append(header) output_parts.extend(top10) else: output_parts.append(f"{lang('Failed to get CPU TOP 10')}: {output_cpu}") output_parts.append("") # 空行分隔 # 2. Memory Top 10 success_mem, output_mem = _run_shell_cmd(["ps", "-eo", "pid,user,%cpu,%mem,command", "--sort=-%mem"]) if success_mem: lines = output_mem.strip().splitlines() header = lines[0] if lines else "" top10 = lines[1:11] output_parts.append("--- Memory Usage TOP 10 ---") output_parts.append(header) output_parts.extend(top10) else: output_parts.append(f"{lang('Failed to get Memory TOP 10')}: {output_mem}") return _xml_response("GetTopProcesses", "done", "\n".join(output_parts)) # Website Management # ═══════════════════════════════════════════════════════════════ @register_tool(category="Website", name_cn="List Websites", risk_level="low") def SiteList(search: str = "", limit: int = 100, include_health: bool = False, site_id_list: List[int] = None) -> str: """ List all websites. Always call this first before any website operation. Returns: JSON array with id, name (domain), project_type, path, status, ps. Set include_health=True to also return ssl/php_version/backup_count/domain_count/domains per site. Workflow: SiteList -> SiteDelete / WebSiteBackup / AutoSSL / GetSitesConf / GetSitesLogs. ALL downstream website tools take `id` from this list as the primary key. Args: search: Optional domain filter keyword. e.g. "example.com". limit: Max items to return (default 100). Items beyond are omitted. include_health: Also return ssl/php_version/backup_count/domain_count/domains per site. Default False. site_id_list: Optional list of specific site IDs. """ try: _fields = 'id,name,project_type,path,status,ps' if site_id_list: ids_in = [int(i) for i in site_id_list] ph = ','.join(['?'] * len(ids_in)) sites = public.M('sites').field(_fields).where('id in ({})'.format(ph), tuple(ids_in)).select() elif search: sites = public.M('sites').field(_fields).where('name like ?', '%{}%'.format(search)).select() else: sites = public.M('sites').field(_fields).select() # 轻量索引模式: 直出 if not include_health: sites = _truncate_result(sites, limit) return _xml_response("SiteList", "done", json.dumps(sites, ensure_ascii=False)) # 诊断模式: 批量查 + 逐站富化 (ssl/php_version/backup_count/domain_count/domains) if not sites: return _xml_response("SiteList", "done", json.dumps([], ensure_ascii=False)) from class_v2.data_v2 import data as _Data ids = [s['id'] for s in sites] bak_map = {} try: bak_rows = public.S('backup').where_in('pid', ids).where("type", "0").group('pid').field( 'pid', 'count(*) as cnt', 'max(addtime) as last_backup_time').select() bak_map = {b['pid']: b for b in bak_rows} except Exception: pass domain_map = {} try: dom_rows = public.S('domain').where_in('pid', ids).field('pid', 'name').select() for _d in dom_rows: domain_map.setdefault(_d['pid'], []).append(_d['name']) except Exception: pass d = _Data() def _enrich(site): try: _ssl = d.get_site_ssl_info(site['name']) # 只保留诊断过期所需字段, 砍掉 issuer/notBefore/dns/subject site['ssl'] = {'endtime': _ssl['endtime'], 'notAfter': _ssl['notAfter']} \ if isinstance(_ssl, dict) else _ssl except Exception: site['ssl'] = -1 try: site['php_version'] = d.get_php_version(site['name']) except Exception: site['php_version'] = 'Static' bak = bak_map.get(site['id'], {}) site['backup_count'] = bak.get('cnt', 0) site['last_backup_time'] = bak.get('last_backup_time') or '' _doms = domain_map.get(site['id'], []) site['domains'] = _doms site['domain_count'] = len(_doms) return site sites = [_enrich(s) for s in sites] sites = _truncate_result(sites, limit) return _xml_response("SiteList", "done", json.dumps(sites, ensure_ascii=False, default=str)) except Exception as e: return _xml_response("SiteList", "error", str(e)) @register_tool(category="Website", name_cn="Get PHP Versions", risk_level="low") def SiteGetPHPVersions() -> str: """ Get PHP versions installed on the server. Returns: JSON array of version codes. e.g. ["00","73","74","80","81","82","83","84"]. "00" = pure static, "other" = custom. Workflow: SiteGetPHPVersions -> SiteCreate (pass version code). """ try: import panel_site_v2 data = panel_site_v2.panelSite().GetPHPVersion(public.to_dict_obj({})) return _xml_response("SiteGetPHPVersions", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("SiteGetPHPVersions", "error", str(e)) @register_tool(category="Website", name_cn="Create Website", risk_level="medium") def SiteCreate(domain: str, port: int = 80, version: str = "", ps: str = "", path: str = "", ftp: bool = False, ftp_username: str = "", ftp_password: str = "", sql: str = "", datauser: str = "", datapassword: str = "", codeing: str = "utf8", type_id: int = 0, force_ssl: int = 0, is_create_default_file: bool = True) -> str: """ Create a new website with domain binding, optional FTP and database. Backend auto-applies SSL asynchronously (ssl_auto=1); not the AutoSSL tool. Args: domain: Primary domain. Required. e.g. "example.com" or "blog.example.com". port: Listen port. Default 80. version: PHP version code. Default "" (pure static). Call SiteGetPHPVersions first. ps: Remark. Optional. path: Website root directory. Optional, auto-generated. ftp: Create FTP account. Default False. ftp_username: FTP username. Optional when ftp=True, auto-generated if omitted. ftp_password: FTP password. Optional when ftp=True, auto-generated if omitted. sql: Database type. "MySQL" to create DB, "" to skip. Default "". datauser: Database username. Optional when sql="MySQL", auto-generated if omitted. datapassword: Database password. Optional when sql="MySQL", auto-generated if omitted. codeing: Database encoding. Default "utf8". type_id: Site type ID. Default 0. force_ssl: Force HTTPS redirect. Default 0. is_create_default_file: Create default index file. Default True. Workflow: SiteGetPHPVersions -> SiteCreate. """ try: import panel_site_v2 # 构造 webname JSON (后端要求 JSON 字符串) webname_obj = { "domain": domain, "domainlist": [], "count": 0, } params = { "webname": json.dumps(webname_obj, ensure_ascii=False), "port": str(port), "type": "PHP", "version": version or "00", "ps": ps or domain.replace(".", "_"), "path": path or f"/www/wwwroot/{domain}", "ftp": ftp, "sql": sql or "", "codeing": codeing, "type_id": type_id, "force_ssl": force_ssl, "ssl_auto": "1", "is_create_default_file": is_create_default_file, } if ftp: params["ftp_username"] = ftp_username or f"ftp_{domain.replace('.', '_')}" params["ftp_password"] = ftp_password or __import__('secrets').token_hex(8) if sql: params["datauser"] = datauser or f"sql_{domain.replace('.', '_')}" params["datapassword"] = datapassword or __import__('secrets').token_hex(8) data = panel_site_v2.panelSite().AddSite(_to_obj(params)) return _xml_response("SiteCreate", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("SiteCreate", "error", str(e)) @register_tool(category="Website", name_cn="Delete Website", risk_level="high") def SiteDelete(id: int, webname: str = "") -> str: """ Delete a website. Irreversible. Args: id: Website ID from SiteList. Required. webname: Website domain name. Optional. Workflow: SiteList -> SiteDelete. """ try: import panel_site_v2 params = {"id": id} if webname: params["webname"] = webname data = panel_site_v2.panelSite().DeleteSite(_to_obj(params)) return _xml_response("SiteDelete", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("SiteDelete", "error", str(e)) @register_tool(category="Website", name_cn="Set Website PHP Version", risk_level="high") def SiteSetPHPVersion(site_id: int, version: str) -> str: """ Switch a website's PHP version. May briefly interrupt the site while the web server reloads. Args: site_id: Site ID from SiteList. Required. version: PHP version code from SiteGetPHPVersions. Required. e.g. "74","83"; "00"=pure static, "other"=custom. Workflow: SiteGetPHPVersions -> SiteSetPHPVersion. Prefer this over manually editing the vhost PHP include. """ try: site = public.M('sites').where('id=?', site_id).field('name').find() if not site: return _xml_response("SiteSetPHPVersion", "error", "Site {} not found".format(site_id)) import panel_site_v2 data = panel_site_v2.panelSite().SetPHPVersion( _to_obj({"siteName": site["name"], "version": version})) return _xml_response("SiteSetPHPVersion", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("SiteSetPHPVersion", "error", str(e)) @register_tool(category="Website", name_cn="Get Website Config", risk_level="low") def GetSitesConf(site_id_list: List[int] = None) -> str: """ Get nginx/Apache vhost configuration for one or more websites. Use for: diagnosing 403/502/500 errors, checking rewrite rules, SSL, reverse proxy config. Args: site_id_list: List of site IDs from SiteList. Pass SPECIFIC sites; Empty = ALL sites (returns every vhost config and can be very large - prefer targeted queries). Workflow: SiteList -> GetSitesConf (takes id; resolves domain internally). """ import json if not site_id_list: all_sites = public.M('sites').field('id,name,project_type').select() if not all_sites: return _xml_response("GetSitesConf", "done", json.dumps({"error": "No sites found in panel."}, ensure_ascii=False, indent=2)) site_id_list = [s['id'] for s in all_sites] results = {} last_site_name = None for sid in site_id_list: site_data = public.M('sites').field('name,project_type').where("id=?", int(sid)).find() if not site_data: results[str(sid)] = {"error": "Site not found in panel."} continue site_name = site_data['name'] last_site_name = site_name project_type = site_data['project_type'].lower() prefix = '' if project_type in ('php', 'proxy', 'phpmod', 'wp2') else project_type + '_' conf_path = f"/www/server/panel/vhost/nginx/{prefix}{site_name}.conf" if not os.path.exists(conf_path): conf_path = f"/www/server/panel/vhost/apache/{prefix}{site_name}.conf" if not os.path.exists(conf_path): results[site_name] = {"error": "Configuration file not found."} continue with open(conf_path, 'r') as f: results[site_name] = f.read() if len(site_id_list) == 1 and last_site_name in results: val = results[last_site_name] if not (isinstance(val, dict) and "error" in val): return _xml_response("GetSitesConf", "done", val) return _xml_response("GetSitesConf", "done", json.dumps(results, ensure_ascii=False, indent=2)) @register_tool(category="Website", name_cn="Get Website Access Logs", risk_level="low") def GetSitesLogs(site_id_list: List[int] = None) -> str: """ Get recent access logs for websites. Use for: analyzing traffic, HTTP status codes, diagnosing 404/500, detecting suspicious activity. Args: site_id_list: List of site IDs from SiteList. Pass SPECIFIC sites; Empty = ALL sites (aggregates every site's logs and can be very large - prefer targeted queries). Workflow: SiteList -> GetSitesLogs (takes id; resolves domain internally). """ import json from logsModelV2.siteModel import main logs_model = main() if not site_id_list: all_sites = public.M('sites').field('id,name').select() if not all_sites: return _xml_response("GetSitesLogs", "done", json.dumps({"error": "No sites found in panel."}, ensure_ascii=False, indent=2)) site_id_list = [s['id'] for s in all_sites] results = {} last_site_name = None for sid in site_id_list: site_data = public.M('sites').field('name').where("id=?", int(sid)).find() if not site_data: results[str(sid)] = {"error": "Site not found in panel."} continue site_name = site_data['name'] last_site_name = site_name logs = logs_model.GetSiteLogs(public.to_dict_obj({"siteName": site_name})).get("message") results[site_name] = logs if len(site_id_list) == 1 and last_site_name in results: return _xml_response("GetSitesLogs", "done", json.dumps(results[last_site_name], ensure_ascii=False, indent=2)) return _xml_response("GetSitesLogs", "done", json.dumps(results, ensure_ascii=False, indent=2)) @register_tool(category="Website", name_cn="Get Website Traffic Data", risk_level="low") def GetSiteOverview(site_id_list: List[int] = None, sort_by: str = "") -> str: """ Get 7-day traffic statistics (UV, PV, bandwidth) for websites. Args: site_id_list: List of site IDs from SiteList. Pass SPECIFIC sites; Empty = ALL sites (can be large with many sites - prefer targeted queries, or use sort_by="traffic" for a ranked overview). sort_by: Sort field. Optional. "traffic" = sort by traffic desc. Empty = no sort. Returns: Per-site traffic data. When sort_by="traffic", returns ranked list for all sites. Workflow: SiteList -> GetSiteOverview (takes id; resolves domain internally). """ import json from projectModelV2.monitorModel import main as monitor # 排序模式:直接返回聚合排名数据 (不依赖站点列表) if sort_by == "traffic": monitordata = monitor().get_overview(public.to_dict_obj({"metric": "traffic", "order": "desc"})).get("message") return _xml_response("GetSiteOverview", "done", json.dumps(monitordata, ensure_ascii=False, indent=2)) if not site_id_list: all_sites = public.M('sites').field('id,name').select() if not all_sites: return _xml_response("GetSiteOverview", "done", json.dumps({"error": "No sites found in panel."}, ensure_ascii=False, indent=2)) site_id_list = [s['id'] for s in all_sites] results = {} last_site_name = None for sid in site_id_list: site_data = public.M('sites').field('name').where("id=?", int(sid)).find() if not site_data: results[str(sid)] = {"error": "Site not found in panel."} continue site_name = site_data['name'] last_site_name = site_name monitordata = monitor().get_overview(public.to_dict_obj({"site_name": site_name})).get("message") results[site_name] = monitordata if len(site_id_list) == 1 and last_site_name in results: return _xml_response("GetSiteOverview", "done", json.dumps(results[last_site_name], ensure_ascii=False, indent=2)) return _xml_response("GetSiteOverview", "done", json.dumps(results, ensure_ascii=False, indent=2)) # @register_tool(category="Website", name_cn="Backup Website", risk_level="high") # def WebSiteBackup(site_ids: List[int]) -> str: # """ # Backup websites by site ID. Auto-detects type: PHP (files only), WP/WP2 (files+DB), Node.js (files). # # Prefer this over manual `RunCommand tar` — it detects site type and records backups in the panel for one-click restore. # # Args: # site_ids: List of site IDs from SiteList. Required. e.g. [1, 2, 3]. # # Returns: Per-site backup trigger result. Backup runs asynchronously. # # Workflow: SiteList -> WebSiteBackup. # """ # import json # # if not site_ids: # return _xml_response("WebSiteBackup", "error", "No site IDs provided. Use SiteList to list available sites.") # # sids = [int(sid) for sid in site_ids] # site_results = [] # 每个站点的预检信息 # # # 1. 逐个查询站点信息,按类型分组 # php_ids, wp_ids, node_ids = [], [], [] # for sid in sids: # site_info = public.M('sites').where('id=?', (sid,)).field('id,name,project_type').find() # if not site_info: # site_results.append({"id": sid, "status": "error", "msg": "Site not found"}) # continue # # ptype = (site_info.get('project_type') or '').upper() # entry = {"id": sid, "name": site_info.get('name', ''), "type": ptype} # # if ptype == 'PHP': # entry["backup_mode"] = "file-only" # php_ids.append(sid) # elif ptype in ('WP', 'WP2'): # entry["backup_mode"] = "full" # wp_ids.append(sid) # elif ptype == 'NODE': # entry["backup_mode"] = "file-only" # node_ids.append(sid) # else: # entry["status"] = "error" # entry["msg"] = "Unsupported type '{}'. Supported: PHP, WP/WP2, Node.".format(ptype) # # site_results.append(entry) # # # 2. 按类型各调一次备份接口 # triggered = {"php": False, "wp": False, "node": False} # # if php_ids: # try: # from panel_site_v2 import panelSite # args = public.to_dict_obj({'id': php_ids, 'bak_type': 0, 'site_type': 'php'}) # res = panelSite().ai_php_wp_backup(args) # triggered["php"] = isinstance(res, dict) and res.get('status') == 0 # except Exception: # pass # # if wp_ids: # try: # from panel_site_v2 import panelSite # args = public.to_dict_obj({'id': wp_ids, 'bak_type': 3, 'site_type': 'wp'}) # res = panelSite().ai_php_wp_backup(args) # triggered["wp"] = isinstance(res, dict) and res.get('status') == 0 # except Exception: # pass # # if node_ids: # try: # from mod.project.nodejs.comMod import main as nodejs_main # args = public.to_dict_obj({'id': node_ids, 'backup_type': 0}) # res = nodejs_main().nodejs_backup(args) # triggered["node"] = isinstance(res, dict) and res.get('status') == 0 # except Exception: # pass # # # 3. 标记各站点触发结果 # for entry in site_results: # if entry.get('status') == 'error': # continue # 已标记错误的跳过 # ptype = entry.get('type', '') # if ptype == 'PHP': # entry["status"] = "ok" if triggered["php"] else "error" # if not triggered["php"]: # entry["msg"] = "PHP backup trigger failed" # elif ptype in ('WP', 'WP2'): # entry["status"] = "ok" if triggered["wp"] else "error" # if not triggered["wp"]: # entry["msg"] = "WordPress backup trigger failed" # elif ptype == 'NODE': # entry["status"] = "ok" if triggered["node"] else "error" # if not triggered["node"]: # entry["msg"] = "Node.js backup trigger failed" # # success = sum(1 for r in site_results if r.get('status') == 'ok') # failed = sum(1 for r in site_results if r.get('status') == 'error') # # output = { # "total": len(sids), # "success": success, # "failed": failed, # "results": site_results, # "progress_hint": "Backup is running asynchronously. Check progress in the backup log.", # } # # status = "done" if failed == 0 else ("partial" if success > 0 else "error") # return _xml_response("WebSiteBackup", status, json.dumps(output, ensure_ascii=False, indent=2)) # ═══════════════════════════════════════════════════════════════ # Database Management # ═══════════════════════════════════════════════════════════════ @register_tool(category="Database", name_cn="List Databases", risk_level="low") def DBList(limit: int = 200) -> str: """ List all databases with id, pid (parent site id, 0 = standalone), name, username, type, allowed IP (accept), sid (server id, 0 = local MySQL), conn_config (connection params: host/port/db_host etc), and backup_count. Returns: JSON array. Use id for DBDelete. Workflow: DBList -> DBCreate / DBDelete / DBChangePassword. Args: limit: Max rows to return (default 200). Rows beyond are omitted. """ try: rows = public.M('databases').field('id,pid,name,username,accept,type,sid,conn_config').select() # 批量备份计数 (databases -> type='1', 一次 where_in) bak_map = {} try: ids = [r['id'] for r in rows] if ids: bak_rows = public.S('backup').where_in('pid', ids).where("type", "1").group('pid').field( 'pid', 'count(*) as cnt').select() bak_map = {b['pid']: b['cnt'] for b in bak_rows} except Exception: pass for r in rows: try: r['conn_config'] = json.loads(r['conn_config']) if r.get('conn_config') else {} except Exception: r['conn_config'] = {} r['backup_count'] = bak_map.get(r['id'], 0) rows = _truncate_result(rows, limit) return _xml_response("DBList", "done", json.dumps(rows, ensure_ascii=False, default=str)) except Exception as e: return _xml_response("DBList", "error", str(e)) @register_tool(category="Database", name_cn="Create Database", risk_level="medium") def DBCreate(name: str, password: str, db_user: str = "", ps: str = "") -> str: """ Create a MySQL database (utf8mb4). Args: name: Database name. Required. password: Database password. Required. db_user: Database username. Optional, defaults to same as name. ps: Remark. Optional. Workflow: DBList -> DBCreate. Note: Remote access is enabled by default (address="%"). """ try: from class_v2.database_v2 import database params = { "name": name, "password": password, "db_user": db_user or name, "codeing": "utf8mb4", "sid": 0, "active": True, "address": "%", "ps": ps, "dtype": "MySQL", } data = database().AddDatabase(_to_obj(params)) return _xml_response("DBCreate", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DBCreate", "error", str(e)) @register_tool(category="Database", name_cn="Delete Database", risk_level="high") def DBDelete(id: int, name: str) -> str: """ Delete a database. Irreversible. Args: id: Database ID from DBList. Required. name: Database name. Required. Workflow: DBList -> DBDelete. """ try: from class_v2.database_v2 import database data = database().DeleteDatabase(_to_obj({"id": id, "name": name})) return _xml_response("DBDelete", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DBDelete", "error", str(e)) @register_tool(category="Database", name_cn="Change Database Password", risk_level="high") def DBChangePassword(id: int, db_user: str, password: str) -> str: """ Change the password of a panel-managed database user. Syncs BOTH MySQL and panel records. Args: id: Database id from DBList. Required. db_user: Database username (DBList 'username' field). Required. password: New password (no Chinese; limited special chars). Required. Workflow: DBList -> DBChangePassword. Note: This is the ONLY safe way to change a DB password - it keeps MySQL and panel records in sync. NEVER use MysqlExecute to alter user passwords. """ try: from class_v2.database_v2 import database data = database().ResDatabasePassword( _to_obj({"id": id, "name": db_user, "password": password})) return _xml_response("DBChangePassword", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DBChangePassword", "error", str(e)) @register_tool(category="Database", name_cn="Backup Database", risk_level="high") def DBBackup(db_id: int) -> str: """ Back up a MySQL database (full dump to a timestamped .sql.zip). Blocks until the dump finishes; slow on large DBs. Args: db_id: Database ID from DBList (MySQL only). Required. Workflow: DBList -> DBBackup -> DBBackupList. Prefer this over raw mysqldump — it records the backup in the panel registry. """ try: from class_v2.database_v2 import database data = database().ToBackup(_to_obj({"id": db_id})) return _xml_response("DBBackup", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DBBackup", "error", str(e)) @register_tool(category="Database", name_cn="List Database Backups", risk_level="low") def DBBackupList(search: str = "", limit: int = 100) -> str: """ (read-only) List MySQL database backup files across all databases, newest first. Args: search: Optional filename keyword to filter. limit: Max items (default 100). Workflow: DBBackup -> DBBackupList. """ try: from class_v2.database_v2 import database params = {"p": 1, "limit": limit} if search: params["search"] = search data = database().GetBackup(_to_obj(params)) return _xml_response("DBBackupList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DBBackupList", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # SSL Certificate Management # ═══════════════════════════════════════════════════════════════ @register_tool(category="SSL", name_cn="List SSL Certificates", risk_level="low") def SSLList(search: str = "", limit: int = 100) -> str: """ List SSL certificates with domain, expiry, renewal status, and hash. Returns: JSON array. hash identifies each certificate. Args: search: Optional domain keyword to filter results. limit: Max items to return (default 100). Items beyond are omitted. """ try: from class_v2.ssl_domainModelV2.api import DomainObject params = {"search": search} if search else {} data = DomainObject().list_ssl_info(_to_obj(params)) data = _truncate_result(data, limit) return _xml_response("SSLList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("SSLList", "error", str(e)) @register_tool(category="Website", name_cn="Auto Apply SSL", risk_level="high") def AutoSSL(site_id: int) -> str: """ Auto-apply free SSL for a site (Let's Encrypt). Reuses existing valid certs if matched. Note: Let's Encrypt SSL can be applied using the server's public IP. Prefer this over `RunCommand certbot` — it reuses panel DNS accounts and records certs under panel management. Verification method is auto-decided: all domains have a DNS Provider -> DNS-01; otherwise HTTP-01 (wildcard domains require DNS-01). Blocks until the certificate is applied or fails, then reports the final result. Args: site_id: Site ID from SiteList. Required. Returns: Pre-check (site info, domain list, DNS resolution) plus the final apply result in ssl_result ({"status": bool, "msg": str}). Workflow: SiteList -> AutoSSL. """ import json import socket sid = int(site_id) precheck = {} # 1. 查询站点信息 try: precheck['site_info'] = public.M('sites').where('id=?', sid).field('id,name,project_type').find() except Exception as e: precheck['site_info'] = {'error': str(e)} # 2. 查询域名列表 domain_names = [] try: domain_records = public.M('domain').where('pid=?', sid).field('name').select() if domain_records and isinstance(domain_records, list): domain_names = [d.get('name', '') for d in domain_records if d.get('name')] precheck['domains'] = domain_names except Exception as e: precheck['domains'] = {'error': str(e)} # 3. A记录解析查询 (公共DNS + 本地DNS, 单域名超时3秒) dns_results = {} for domain in domain_names: record = {} # 公共DNS查询 try: results = socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM) record['public_dns'] = sorted(set(r[4][0] for r in results)) except Exception: record['public_dns'] = lang('unresolved') # 本地DNS查询 try: p = subprocess.run( ['dig', '@127.0.0.1', domain, '+short', '+time=3'], capture_output=True, text=True, timeout=5 ) ips = sorted(set( line.strip() for line in p.stdout.strip().splitlines() if line.strip() and not line.startswith(';'))) record['local_dns'] = ips if ips else lang('unresolved') except Exception: record['local_dns'] = lang('unresolved') dns_results[domain] = record precheck['dns_resolution'] = dns_results # 同步申请SSL, 阻塞至完成 try: from ssl_domainModelV2.service import apply_site_ssl_sync precheck['ssl_result'] = apply_site_ssl_sync(sid) except Exception as e: precheck['ssl_result'] = {"status": False, "msg": f"SSL apply error: {e}"} return _xml_response("AutoSSL", "done", json.dumps(precheck, ensure_ascii=False, indent=2)) # ═══════════════════════════════════════════════════════════════ # DNS Provider & Records # ═══════════════════════════════════════════════════════════════ @register_tool(category="DNS", name_cn="List DNS Providers", risk_level="low") def DNSProviderList() -> str: """ List DNS provider accounts with id, name, and alias. Returns: JSON array. id is used as pid/search_pid in all DNS record operations. Each entry also includes a 'domains' field. aaPanelDns is the panel's self-hosted DNS (powered by PowerDNS, zones in BIND9 format, no API credentials); other providers are third-party APIs. """ try: from class_v2.ssl_domainModelV2.api import DomainObject data = DomainObject().list_dns_api(_to_obj({})) try: for d in data.get('message', {}).get('data', []): if 'api_key' in d: d.pop('api_key') # Remove sensitive API keys from output except: pass return _xml_response("DNSProviderList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DNSProviderList", "error", str(e)) @register_tool(category="DNS", name_cn="List DNS Records", risk_level="low") def DNSRecordList(search_pid: int, domain: str = "", search: str = "") -> str: """ List DNS records under a provider. Args: search_pid: Provider ID from DNSProviderList. domain: Filter by domain. Optional. search: Filter by keyword. Optional. Workflow: DNSProviderList -> DNSRecordList -> DNSRecordDelete. """ try: from class_v2.ssl_domainModelV2.api import DomainObject params = {"search_pid": search_pid} if domain: params["domain"] = domain if search: params["search"] = search data = DomainObject().list_dns_record(_to_obj(params)) return _xml_response("DNSRecordList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DNSRecordList", "error", str(e)) @register_tool(category="DNS", name_cn="Create DNS Record", risk_level="high") def DNSRecordCreate(pid: int, domain: str, record: str, record_type: str, record_value: str, ttl: int = 600, priority: int = 0, proxy: int = 0, ps: str = "") -> str: """ Create a DNS record under a configured DNS provider. Args: pid: Provider ID from DNSProviderList. domain: Domain name. e.g. "example.com". record: Record name. e.g. "www"/"@"(root)/"*"(wildcard). record_type: Record type. e.g. "A"/"AAAA"/"CNAME"/"MX"/"TXT"/"SRV". record_value: Record value. e.g. "1.2.3.4" for A record. ttl: TTL in seconds. Default 600. priority: MX/SRV priority. Default 0. proxy: Cloudflare CDN proxy only (ignored by other providers). 0=off, 1=on. Default 0. ps: Remark. Optional. Workflow: DNSProviderList -> DNSRecordList -> DNSRecordCreate. """ try: from class_v2.ssl_domainModelV2.api import DomainObject params = { "pid": pid, "domain": domain, "record": record, "record_type": record_type, "record_value": record_value, "ttl": ttl, "priority": priority, "proxy": proxy, } if ps: params["ps"] = ps data = DomainObject().create_dns_record(_to_obj(params)) return _xml_response("DNSRecordCreate", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DNSRecordCreate", "error", str(e)) @register_tool(category="DNS", name_cn="Delete DNS Record", risk_level="high") def DNSRecordDelete(id: int) -> str: """ Delete a DNS record by ID. Irreversible. Args: id: Record ID from DNSRecordList. Workflow: DNSRecordList -> DNSRecordDelete. """ try: from class_v2.ssl_domainModelV2.api import DomainObject data = DomainObject().delete_dns_record(_to_obj({"id": id})) return _xml_response("DNSRecordDelete", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("DNSRecordDelete", "error", str(e)) # @register_tool(category="DNS", name_cn="Edit DNS Record", risk_level="high") # def DNSRecordEdit(id: int, pid: int, domain: str, record: str, record_type: str, # record_value: str, ttl: int = 600, proxy: int = 0, # priority: int = 0, ps: str = "") -> str: # """ # Edit an existing DNS record. # # Args: # id: Record ID from DNSRecordList. Required. # pid: Provider ID from DNSProviderList. Required. # domain: Domain name. Required. # record: Record name. Required. # record_type: Type. Required. e.g. "A"/"CNAME"/"MX"/"TXT". # record_value: New value. Required. # ttl: TTL in seconds. Default 600. # proxy: CloudFlare CDN proxy. 0=off, 1=on. Default 0. # priority: MX/SRV priority. Default 0. # ps: Remark. Optional. # # Workflow: DNSRecordList -> DNSRecordEdit (get current values first). # """ # try: # from class_v2.ssl_domainModelV2.api import DomainObject # params = { # "id": id, "pid": pid, "domain": domain, "record": record, # "record_type": record_type, "record_value": record_value, # "ttl": ttl, "proxy": proxy, "priority": priority, "ps": ps, # } # data = DomainObject().edit_dns_record(_to_obj(params)) # return _xml_response("DNSRecordEdit", "done", json.dumps(data, ensure_ascii=False)) # except Exception as e: # return _xml_response("DNSRecordEdit", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # Plugin & Software Management # ═══════════════════════════════════════════════════════════════ # @register_tool(category="Plugin", name_cn="List Software Store", risk_level="low") # def PluginSoftList(type: str = "0", query: str = "", p: int = 1, limit: int = 15) -> str: # """ # List software in the aaPanel app store. Supports type and keyword filtering. # # Args: # #type: "0" = all (default), "-1" = installed only. # query: Search keyword. e.g. "nginx"/"docker"/"php". Optional. # p: Page number. Default 1. # limit: Items per page. Default 15. # # Returns: Paginated list with versions info. Extract sName and m_version for PluginInstall. # # Workflow: PluginSoftList -> PluginInstall. # """ # try: # from class_v2.panel_plugin_v2 import panelPlugin # params = {"type": type, "p": p, "limit": limit} # if query: # params["query"] = query # data = panelPlugin().get_soft_list(_to_obj(params)) # return _xml_response("PluginSoftList", "done", json.dumps(data, ensure_ascii=False)) # except Exception as e: # return _xml_response("PluginSoftList", "error", str(e)) # # # @register_tool(category="Plugin", name_cn="Install Software", risk_level="high") # def PluginInstall(sName: str, version: str) -> str: # """ # Install a software package from the app store. This is an ASYNC trigger. # # Args: # sName: Software name from PluginSoftList. Required. # version: m_version from PluginSoftList. Required. e.g. "1.24"/"8.2"/"5.7". # # Workflow: PluginSoftList -> PluginInstall. # # IMPORTANT: This is an asynchronous operation. The install runs in the background. # This tool ONLY triggers the install and returns immediately. # NEVER poll, wait, retry, or check install progress after calling this tool. # Just report the trigger result (success/failure) to the user and stop. # """ # try: # from class_v2.panel_plugin_v2 import panelPlugin # params = {"sName": sName, "version": version} # data = panelPlugin().install_plugin(_to_obj(params)) # return _xml_response("PluginInstall", "done", json.dumps(data, ensure_ascii=False)) # except Exception as e: # return _xml_response("PluginInstall", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # Service Management # ═══════════════════════════════════════════════════════════════ def _service_op(service: str, action: str) -> str: """管理面板服务: 主路 system().ServiceAdmin (走 /etc/init.d/); 主路失败 (返回 status!=0 或抛异常) 才 fallback 到 systemctl, 用 is-active 验证成败. action: "restart" 或 "stop". 模块级唯一对外入口, 内部封装 fallback 细节. """ tool = "ServiceRestart" if action == "restart" else "ServiceStop" def _msg(data) -> str: # 从 return_message 结构 ({status, message:{result}}) 提取可读错误信息 if isinstance(data, dict): msg = data.get("message") if isinstance(msg, dict) and "result" in msg: return str(msg["result"]) return json.dumps(data, ensure_ascii=False) return str(data) def _run_systemctl(svc: str, act: str): # 执行 systemctl , 用 is-active 验证. 返回 (ok, detail) try: public.ExecShell("systemctl {} {}".format(act, svc)) except Exception as e: return False, "exec error: " + str(e) res = public.ExecShell("systemctl is-active {}".format(svc)) if isinstance(res, tuple): state = (res[0] or "").strip() elif isinstance(res, str): state = res.strip() else: state = "" if act == "restart": return state == "active", "is-active={}".format(state) return state in ("inactive", "failed", "deactivating"), "is-active={}".format(state) primary_msg = None # 主路: ServiceAdmin 优先, 保持当前工具行为 try: from class_v2.system_v2 import system data = system().ServiceAdmin(_to_obj({"name": service, "type": action})) time.sleep(1) if isinstance(data, dict) and data.get("status") == 0: return _xml_response(tool, "done", json.dumps(data, ensure_ascii=False)) primary_msg = _msg(data) except Exception as e: primary_msg = str(e) # 兜底: systemctl + is-active 验证 ok, detail = _run_systemctl(service, action) time.sleep(1) if ok: return _xml_response(tool, "done", json.dumps( {"status": 0, "fallback": "systemctl", "primary_error": primary_msg, "detail": detail}, ensure_ascii=False)) return _xml_response(tool, "error", "ServiceAdmin failed [{}]; systemctl fallback failed: {}".format(primary_msg, detail)) @register_tool(category="Service", name_cn="Get Service Status", risk_level="low") def ServiceStatus(name_list: List[str]) -> str: """ Query install/running status and version for one or more services. Args: name_list: Service names. Required. e.g. ["nginx", "php-74", "mysql"]. Returns: One line per service, e.g. "nginx: installed, running, v1.24" or "mysql: not installed". Workflow: ServiceStatus -> ServiceRestart / ServiceStop. """ try: names = name_list if isinstance(name_list, list) else [name_list] report = _ServiceStatusEnricher.query(names) return _xml_response("ServiceStatus", "done", report) except Exception as e: return _xml_response("ServiceStatus", "error", str(e)) @register_tool(category="Service", name_cn="Restart Service", risk_level="high") def ServiceRestart(service: str) -> str: """ Restart a panel-managed service. Use with caution on production servers. Routes to ServiceAdmin (/etc/init.d/) first; on failure it falls back to `systemctl restart` and confirms via `systemctl is-active`. Prefer this over `RunCommand systemctl restart` for panel-managed services — it keeps panel state in sync. Args: service: Panel-managed service name. Required. e.g. "nginx"/"mysql"/"apache"/"php-fpm". Workflow: ServiceStatus -> ServiceRestart. """ return _service_op(service, "restart") @register_tool(category="Service", name_cn="Stop Service", risk_level="high") def ServiceStop(service: str) -> str: """ Stop a panel-managed service. Related functionality will become unavailable. Routes to ServiceAdmin (/etc/init.d/) first; on failure it falls back to `systemctl stop` and confirms via `systemctl is-active`. Prefer this over `RunCommand systemctl stop` for panel-managed services — it keeps panel state in sync. Args: service: Panel-managed service name. Required. e.g. "nginx"/"mysql"/"apache"/"php-fpm". Workflow: ServiceStatus -> ServiceStop. """ return _service_op(service, "stop") # ═══════════════════════════════════════════════════════════════ # FTP Account Management # ═══════════════════════════════════════════════════════════════ @register_tool(category="FTP", name_cn="List FTP Accounts", risk_level="low") def FTPList() -> str: """ List FTP accounts with id, name, path, status. Returns: JSON array. Use id for management. Workflow: FTPList -> FTPCreate. """ try: rows = public.M('ftps').field('id,name,path,status,ps,addtime').select() return _xml_response("FTPList", "done", json.dumps(rows, ensure_ascii=False)) except Exception as e: return _xml_response("FTPList", "error", str(e)) @register_tool(category="FTP", name_cn="Create FTP Account", risk_level="medium") def FTPCreate(ftp_username: str, ftp_password: str, path: str, ps: str = "") -> str: """ Create an FTP account. Args: ftp_username: Username. Required. Min 3 chars, no special characters. ftp_password: Password. Required. Min 6 chars. path: Root directory. Required. e.g. "/www/wwwroot/ftptest". ps: Remark. Optional. Workflow: FTPList -> FTPCreate. """ try: from class_v2.ftp_v2 import ftp params = {"ftp_username": ftp_username, "ftp_password": ftp_password, "path": path, "ps": ps} data = ftp().AddUser(_to_obj(params)) return _xml_response("FTPCreate", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FTPCreate", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # Cron Job Management # ═══════════════════════════════════════════════════════════════ @register_tool(category="Cron", name_cn="List Cron Jobs", risk_level="low") def CronList(limit: int = 100) -> str: """ List cron jobs with id, name, type, schedule, status, and command. Returns: JSON array. Use id for CronDelete. Workflow: CronList -> CronCreate / CronDelete. Args: limit: Max items to return (default 100). Items beyond are omitted. """ try: from class_v2.crontab_v2 import crontab data = crontab().GetCrontab(_to_obj({})) data = _truncate_result(data, limit) return _xml_response("CronList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("CronList", "error", str(e)) @register_tool(category="Cron", name_cn="Create Cron Job", risk_level="medium") def CronCreate(name: str, type: str, where1: str, sBody: str, sType: str = "toShell", hour: int = 0, minute: int = 0, user: str = "root", save: str = "", backupTo: str = "", sName: str = "", urladdress: str = "", db_type: str = "", notice: int = 0, notice_channel: str = "", save_local: int = 0) -> str: """ Create a scheduled task. Args: name: Job name. Required. e.g. "Daily Backup". type: Schedule type. Required. "minute-n" -> where1: "*/5" (every N minutes) "hour" -> where1: "1" (at minute N of each hour) "hour-n" -> where1: "2" (every N hours) "day" -> where1: "" (daily, use hour+minute for time) "day-n" -> where1: "3" (every N days) "week" -> where1: "1" (day of week 0=Sun-6=Sat, use hour+minute for time) "month" -> where1: "15" (day of month, use hour+minute for time) "once" -> where1: "2025-12-31 01:30" (one-time) where1: Cycle/frequency value. Required. Format depends on type (see above). sBody: Shell command or script content. Required. Will be executed by cron when sType="toShell". sType: Task type. "toShell" (shell command), "toUrl" (request URL), "database"/"site"/"path" (backup), "logs"/"webshell"/"sync_time"/"startup_services" etc. hour: Execution hour for day/week/month types. Default 0. minute: Execution minute for day/week/month/hour types. Default 0. user: Execute user. Default "root". save: Backup retention count. Optional. backupTo: Backup storage location. Optional. sName: Backup scope for database/site type. Optional. urladdress: URL for toUrl type. Optional. db_type: Database type for database backup. Optional. notice: Enable notification. 0=off, 1=on. Default 0. notice_channel: Notification channel. Optional. save_local: Keep local copy. 0=off, 1=on. Default 0. Workflow: CronList -> CronCreate. """ try: from class_v2.crontab_v2 import crontab params = { "name": name, "type": type, "where1": where1, "sBody": sBody, "sType": sType, "hour": hour, "minute": minute, "user": user, "save": save, "backupTo": backupTo, "sName": sName, "urladdress": urladdress, "db_type": db_type, "notice": notice, "notice_channel": notice_channel, "save_local": save_local, } data = crontab().AddCrontab(_to_obj(params)) return _xml_response("CronCreate", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("CronCreate", "error", str(e)) @register_tool(category="Cron", name_cn="Delete Cron Job", risk_level="high") def CronDelete(id: int) -> str: """ Delete a cron job. Irreversible. Args: id: Job ID from CronList. Required. Workflow: CronList -> CronDelete. """ try: from class_v2.crontab_v2 import crontab data = crontab().DelCrontab(_to_obj({"id": id})) return _xml_response("CronDelete", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("CronDelete", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # SSH Security # ═══════════════════════════════════════════════════════════════ @register_tool(category="SSH", name_cn="SSH Login Logs", risk_level="low") def SSHLogList(limit: int = 100) -> str: """ Get SSH login history: login time, IP, username, and result. Returns: JSON array of SSH login records. Workflow: SSHLogList -> SSHIntrusionList (if anomalies detected). Args: limit: Max records to return (default 100). Records beyond are omitted. """ try: from mod.modController import Controller args = _to_obj({"mod_name": "ssh", "sub_mod_name": "com", "def_name": "get_ssh_list", "data": "{}"}) data = Controller().model(args) data = _truncate_result(data, limit) return _xml_response("SSHLogList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("SSHLogList", "error", str(e)) @register_tool(category="SSH", name_cn="SSH Intrusion Detection", risk_level="low") def SSHIntrusionList(limit: int = 100) -> str: """ Get SSH intrusion detection records: brute-force attempts, anomalous logins. Returns: JSON array of intrusion events. Workflow: SSHLogList -> SSHIntrusionList -> FirewallPortRuleList / FirewallIPRuleList (block suspicious IPs). Args: limit: Max records to return (default 100). Records beyond are omitted. """ try: from mod.modController import Controller args = _to_obj({"mod_name": "ssh", "sub_mod_name": "com", "def_name": "get_ssh_intrusion", "data": "{}"}) data = Controller().model(args) data = _truncate_result(data, limit) return _xml_response("SSHIntrusionList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("SSHIntrusionList", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # Firewall # ═══════════════════════════════════════════════════════════════ @register_tool(category="Firewall", name_cn="List Port Rules", risk_level="low") def FirewallPortRuleList(query: str = "", p: int = 1, limit: int = 20) -> str: """ List port rules with pagination. Each rule has id, ports, protocol, address, types (accept/drop), brief. Args: query: Search keyword for port/brief/address. Optional. p: Page number. Default 1. limit: Items per page. Default 20. Returns: Paginated list of port rules. Workflow: FirewallPortRuleList -> FirewallPortRuleDelete. """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain data = FirewallMain().get_rules_list(_to_obj({"query": query, "p": p, "limit": limit})) return _xml_response("FirewallPortRuleList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallPortRuleList", "error", str(e)) @register_tool(category="Firewall", name_cn="List IP Rules", risk_level="low") def FirewallIPRuleList(limit: int = 200) -> str: """ List IP rules. Each rule has id, address (IP/CIDR), types (accept/drop), brief. Returns: JSON array of IP rules. Workflow: FirewallIPRuleList -> FirewallIPRuleDelete. Args: limit: Max rules to return (default 200). Rules beyond are omitted. """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain data = FirewallMain().get_ip_rules_list(_to_obj({})) data = _truncate_result(data, limit) return _xml_response("FirewallIPRuleList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallIPRuleList", "error", str(e)) @register_tool(category="Firewall", name_cn="List Port Forward Rules", risk_level="low") def FirewallForwardList(query: str = "", p: int = 1, limit: int = 20) -> str: """ (read-only) List port forwarding rules. Each rule has id, start_port, ended_ip, ended_port, protocol. Args: query: Search keyword for port. Optional. p: Page number. Default 1. limit: Items per page. Default 20. Returns: Paginated list of port forwarding rules. Read-only: no tool exists to create/delete forwarding rules. Manage them via the aaPanel Web GUI (Security -> Firewall). """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain data = FirewallMain().get_forward_list(_to_obj({"query": query, "p": p, "limit": limit})) return _xml_response("FirewallForwardList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallForwardList", "error", str(e)) @register_tool(category="Firewall", name_cn="List Country Rules", risk_level="low") def FirewallCountryList(query: str = "", p: int = 1, limit: int = 20) -> str: """ (read-only) List country/region blocking rules. Each rule has id, country, brief. Args: query: Search keyword for country name. Optional. p: Page number. Default 1. limit: Items per page. Default 20. Returns: Paginated list of country rules. Read-only: no tool exists to create/delete country rules. Manage them via the aaPanel Web GUI (Security -> Firewall). """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain data = FirewallMain().get_country_list(_to_obj({"query": query, "p": p, "limit": limit})) return _xml_response("FirewallCountryList", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallCountryList", "error", str(e)) @register_tool(category="Firewall", name_cn="Create Port Rule", risk_level="high") def FirewallPortRuleCreate(ports: str, protocol: str, types: str, address: str, brief: str = "") -> str: """ Create a port firewall rule (accept or drop) for a single source IP. Prefer this over `RunCommand iptables/firewall-cmd` — it persists rules via the panel firewall manager (survives reboots and panel sync). Confirm independent SSH/console access before any firewall change (anti-lockout). Args: ports: Port number. Required. e.g. "80" or "80,443". protocol: "tcp" or "udp". Required. types: "accept" or "drop". Required. address: Source IPv4. Required. Must be a single IP (e.g. "192.168.1.100"). All-IPs ("0.0.0.0/0") and CIDR ranges are not supported. brief: Remark. Optional. Workflow: FirewallPortRuleList -> FirewallPortRuleCreate. """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain params = {"ports": ports, "protocol": protocol, "types": types, "address": address, "source": address, "brief": brief} data = FirewallMain().create_rules(_to_obj(params)) return _xml_response("FirewallPortRuleCreate", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallPortRuleCreate", "error", str(e)) @register_tool(category="Firewall", name_cn="Delete Port Rule", risk_level="high") def FirewallPortRuleDelete(id: int, ports: str, address: str, types: str, protocol: str) -> str: """ Delete a port firewall rule. Irreversible. Args: id: Rule ID from FirewallPortRuleList. Required. ports: Port number. Required. address: Source IP. Required. types: "accept" or "drop". Required. protocol: "tcp" or "udp". Required. Workflow: FirewallPortRuleList -> FirewallPortRuleDelete. """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain params = {"id": id, "ports": ports, "address": address, "types": types, "protocol": protocol} data = FirewallMain().remove_rules(_to_obj(params)) return _xml_response("FirewallPortRuleDelete", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallPortRuleDelete", "error", str(e)) @register_tool(category="Firewall", name_cn="Create IP Rule", risk_level="high") def FirewallIPRuleCreate(address: str, types: str, protocol: str = "tcp", brief: str = "") -> str: """ Create an IP firewall rule (accept or drop) for a single IP across all ports. Prefer this over `RunCommand iptables/firewall-cmd` — it persists rules via the panel firewall manager. Confirm independent SSH/console access before any firewall change (anti-lockout). Args: address: Source IP. Required. Must be a single IP (e.g. "192.168.1.100"). CIDR ranges are not supported. types: "accept" or "drop". Required. protocol: "tcp" or "udp". Default "tcp". brief: Remark. Optional. Workflow: FirewallIPRuleList -> FirewallIPRuleCreate. """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain params = {"address": address, "source": address, "types": types, "protocol": protocol, "brief": brief} data = FirewallMain().create_ip_rules(_to_obj(params)) return _xml_response("FirewallIPRuleCreate", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallIPRuleCreate", "error", str(e)) @register_tool(category="Firewall", name_cn="Delete IP Rule", risk_level="high") def FirewallIPRuleDelete(id: int, address: str, types: str) -> str: """ Delete an IP firewall rule. Irreversible. Args: id: Rule ID from FirewallIPRuleList. Required. address: IP address. Required. types: "accept" or "drop". Required. Workflow: FirewallIPRuleList -> FirewallIPRuleDelete. """ try: from class_v2.safeModelV2.firewallModel import main as FirewallMain params = {"id": id, "address": address, "types": types} data = FirewallMain().remove_ip_rules(_to_obj(params)) return _xml_response("FirewallIPRuleDelete", "done", json.dumps(data, ensure_ascii=False)) except Exception as e: return _xml_response("FirewallIPRuleDelete", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # Notification (mod/base/push_mod) # ═══════════════════════════════════════════════════════════════ @register_tool(category="Notification", name_cn="List Push Senders", risk_level="low") def ListPushSenders() -> str: """ List configured push channels (notification targets): id, sender_type, used, title. Returns: JSON array. Use sender_type in Notify `channels` to target specific channels. Workflow: ListPushSenders -> Notify. """ try: from mod.base.push_mod.mods import SenderConfig senders = [{ "id": s.get("id"), "sender_type": s.get("sender_type"), "used": s.get("used"), "title": (s.get("data") or {}).get("title", s.get("sender_type")), } for s in SenderConfig().config] senders = _truncate_result(senders, 100) return _xml_response("ListPushSenders", "done", json.dumps(senders, ensure_ascii=False)) except Exception as e: return _xml_response("ListPushSenders", "error", str(e)) @register_tool(category="Notification", name_cn="Send Notification", risk_level="high") def Notify(title: str, message: str, channels: List[str] = None) -> str: """ Send a notification to configured push channels. HIGH RISK - sends to EXTERNAL services (mail/dingding/feishu/webhook/weixin/tg/discord); ensure the message has no secrets. Returns: JSON {sent, total, results:[{channel, ok, error}]}. Workflow: ListPushSenders -> Notify. Args: title: Notification title. Required. message: Notification body. Required. channels: Optional sender_type list, e.g. ["mail","discord"]. Empty = all enabled (sms skipped). Note: After delivering a summary, analysis, or large result, proactively offer to send it via this tool (e.g. "want this conclusion sent to your mail/feishu/discord?"). """ try: from script.notify_cli import send_notify # shared with the standalone CLI (single source of truth) results = send_notify(title, message, channels) summary = {"sent": sum(1 for r in results if r["ok"]), "total": len(results), "results": results} return _xml_response("Notify", "done", json.dumps(summary, ensure_ascii=False)) except Exception as e: return _xml_response("Notify", "error", str(e)) # ═══════════════════════════════════════════════════════════════ # Security Baseline (read-only scan) # ═══════════════════════════════════════════════════════════════ @register_tool(category="System", name_cn="Security Baseline Scan", risk_level="low") def SecurityBaselineScan(components: List[str] = None) -> str: """ (read-only) Scan the security baseline; returns FAILED checks per component. Changes nothing. Args: components: Subset to scan. Default ["ssh","panel","php","mysql"]. Available: ssh, panel, php, mysql, redis, memcache. Workflow: SecurityBaselineScan -> (report findings; repair manually via panel). """ try: from class_v2.san_baseline_v2 import san_baseline sb = san_baseline() mapping = { "ssh": "ssh_security", "panel": "panel_security", "php": "php_security", "mysql": "mysql_security", "redis": "redis_security", "memcache": "memcache_security", } targets = components if components else ["ssh", "panel", "php", "mysql"] results = {} for c in targets: method = mapping.get(c) if not method: results[c] = [{"error": "unknown component '{}'".format(c)}] continue try: results[c] = getattr(sb, method)() except Exception as e: results[c] = [{"error": str(e)}] return _xml_response("SecurityBaselineScan", "done", json.dumps(results, ensure_ascii=False, default=str)) except Exception as e: return _xml_response("SecurityBaselineScan", "error", str(e))