Files
2026-07-06 09:16:25 +08:00

1880 lines
78 KiB
Python

# 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/<name>, which misses
# php-fpm (real dir is /www/server/php/<ver>); 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-<ver>
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/<digits> 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/<service>);
主路失败 (返回 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 <act> <svc>, 用 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/<service>) 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/<service>) 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))