mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-02 16:29:29 +08:00
✨ v1.3.44 更新内容: - 🔧 修复:强制刷新页面后链接点不了的 bug - 修复:DOMContentLoaded 中添加 checkAuth() 调用,自动验证登录态
1202 lines
44 KiB
Python
1202 lines
44 KiB
Python
"""
|
||
TPanel - 系统操作模块
|
||
仅使用白名单命令,禁止直接执行用户传入的原始 shell 字符串
|
||
"""
|
||
import subprocess
|
||
import os
|
||
import shutil
|
||
import tarfile
|
||
import datetime
|
||
import time
|
||
|
||
def _detect_pkg_manager():
|
||
"""检测系统包管理器"""
|
||
import shutil
|
||
for p in ['apt-get', 'yum', 'dnf']:
|
||
if shutil.which(p):
|
||
return p
|
||
return None
|
||
|
||
|
||
def _run(cmd, shell=False, capture=True, timeout=30):
|
||
"""执行命令,超时保护"""
|
||
try:
|
||
if isinstance(cmd, str) and not shell:
|
||
cmd = cmd.split()
|
||
result = subprocess.run(
|
||
cmd,
|
||
capture_output=capture,
|
||
text=True,
|
||
timeout=timeout,
|
||
shell=shell
|
||
)
|
||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||
except subprocess.TimeoutExpired:
|
||
return -1, '', 'Command timed out'
|
||
except Exception as e:
|
||
return -1, '', str(e)
|
||
|
||
def nginx_reload():
|
||
return _run(['sudo', 'nginx', '-t']) + _run(['sudo', 'nginx', '-s', 'reload'])
|
||
|
||
def nginx_stop():
|
||
return _run(['sudo', 'nginx', '-s', 'stop'])
|
||
|
||
def nginx_start():
|
||
return _run(['sudo', 'nginx'])
|
||
|
||
def nginx_status():
|
||
code, out, _ = _run(['ps', 'aux'], capture=True)
|
||
running = 'nginx: master' in out
|
||
return running
|
||
|
||
def mysql_status():
|
||
# Debian 12 默认是 mariadb,CentOS 是 mysql
|
||
for svc in ['mariadb', 'mysql']:
|
||
code, out, _ = _run(['systemctl', 'is-active', svc], capture=True)
|
||
if code == 0:
|
||
return True
|
||
return False
|
||
return out == 'active'
|
||
|
||
def create_site_user(username):
|
||
"""创建 Linux 用户,禁 shell,隔离目录(v1.3.11+ 改用 sudo)"""
|
||
# 检查用户是否存在
|
||
code, out, _ = _run(['id', username], capture=True)
|
||
if code == 0:
|
||
return True, '用户已存在'
|
||
|
||
# 创建用户,home 目录即网站根目录,禁 shell
|
||
code, out, err = _run(
|
||
['sudo', 'useradd', '-m', '-s', '/usr/sbin/nologin', '-d', f'/home/{username}', username]
|
||
)
|
||
if code != 0:
|
||
return False, err
|
||
return True, '用户创建成功'
|
||
|
||
def delete_site_user(username):
|
||
code, out, _ = _run(['id', username], capture=True)
|
||
if code != 0:
|
||
return True, '用户不存在,跳过'
|
||
|
||
# 把用户的所有进程 kill 掉再删
|
||
_run(['pkill', '-u', username], capture=True)
|
||
code, out, err = _run(['sudo', 'userdel', '-r', username])
|
||
if code != 0:
|
||
return False, err
|
||
return True, '用户删除成功'
|
||
|
||
def set_site_permissions(site_path, site_user):
|
||
"""设置站点目录权限"""
|
||
_run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', site_path])
|
||
_run(['sudo', 'chmod', '-R', '755', site_path])
|
||
_run(['sudo', 'chmod', '-R', '700', site_path + '/storage' if os.path.exists(site_path + '/storage') else site_path])
|
||
|
||
def get_php_fpm_port(php_version):
|
||
"""
|
||
v1.3.29: PHP 版本 → FPM 端口映射
|
||
- 8.2 继续用 9000(向后兼容老 conf / install.sh 默认配置)
|
||
- 其他版本: 90 + 小数点后两位(7.4→9074, 8.0→9080, 8.1→9081, 8.3→9083, 8.4→9084)
|
||
- 带小数点的老版本(5.6→9056, 7.0→9070, 7.1→9071, 7.2→9072, 7.3→9073)
|
||
- 解析失败的 default: 9000
|
||
"""
|
||
pv = (php_version or '').strip()
|
||
if pv == '8.2':
|
||
return 9000
|
||
try:
|
||
parts = pv.split('.')
|
||
major = int(parts[0])
|
||
minor = int(parts[1]) if len(parts) > 1 else 0
|
||
return 9000 + major * 10 + minor
|
||
except Exception:
|
||
return 9000
|
||
|
||
|
||
def write_nginx_config(domain, site_path, php_version='8.1', ssl=False, site_type='php'):
|
||
"""写入 Nginx 配置
|
||
v1.3.26 新增 site_type 参数:
|
||
- 'php'(默认):保留 PHP-FPM 反代 location
|
||
- 'static':不写 PHP-FPM 块(纯静态站点,不转发 *.php 到 FPM)
|
||
v1.3.29: PHP-FPM 端口随版本变化(多版本并存不冲突)
|
||
"""
|
||
# PHP-FPM 连接地址(v1.3.6+ 改用 TCP 避免 unix socket 问题,v1.3.29 起按版本分端口)
|
||
fpm_port = get_php_fpm_port(php_version)
|
||
fpm_sock = f'127.0.0.1:{fpm_port}'
|
||
|
||
# index 顺序 + try_files fallback 随类型不同
|
||
if site_type == 'static':
|
||
index_line = 'index index.html;'
|
||
try_files_line = 'try_files $uri $uri/ =404;'
|
||
php_block = '' # 静态站点完全不转发 .php
|
||
else:
|
||
index_line = 'index index.php index.html;'
|
||
try_files_line = 'try_files $uri $uri/ /index.php?$query_string;'
|
||
php_block = f'''
|
||
location ~ \\.php$ {{
|
||
include fastcgi_params;
|
||
fastcgi_pass {fpm_sock};
|
||
fastcgi_index index.php;
|
||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||
}}
|
||
'''
|
||
|
||
nginx_conf = f'''# TPanel - {domain} ({site_type})
|
||
server {{
|
||
listen 80;
|
||
server_name {domain};
|
||
|
||
root ' + site_path + ';
|
||
{index_line}
|
||
|
||
access_log /opt/tpanel/logs/{domain}.access.log;
|
||
error_log /opt/tpanel/logs/{domain}.error.log;
|
||
|
||
location /.well-known/acme-challenge/ {{
|
||
alias {site_path}/.well-known/acme-challenge/;
|
||
try_files $uri =404;
|
||
}}
|
||
|
||
# phpMyAdmin 反代(v1.3.43:自动加,任何站点都可点数据库跳 pma)
|
||
location ^~ /pma/ {{
|
||
proxy_pass http://127.0.0.1:8443/;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_set_header X-Forwarded-Host $host;
|
||
}}
|
||
location = /pma/tpanel-bridge.php {{
|
||
proxy_pass http://127.0.0.1:8443/tpanel-bridge.php;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}}
|
||
|
||
location / {{
|
||
{try_files_line}
|
||
}}
|
||
{php_block}
|
||
location ~ /\\.ht {{
|
||
deny all;
|
||
}}
|
||
}}
|
||
'''
|
||
if ssl:
|
||
nginx_conf = nginx_conf.replace('listen 80;', '''listen 80;
|
||
listen 443 ssl http2;''', 1)
|
||
|
||
conf_path = f'/etc/nginx/sites-available/{domain}.conf'
|
||
# v1.3.15+:tpanel 不可写 /etc/nginx,用 sudo tee(先写 /tmp 临时文件)
|
||
tmp_conf = f'/tmp/tpanel_nginx_{domain}.conf'
|
||
with open(tmp_conf, 'w') as f:
|
||
f.write(nginx_conf)
|
||
code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path])
|
||
if code != 0:
|
||
return False, f'写 conf 失败: {err}'
|
||
|
||
# 启用站点(v1.3.15+:软链在 sites-enabled 也需 sudo)
|
||
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf'
|
||
if os.path.exists(enabled_path):
|
||
_run(['sudo', 'rm', '-f', enabled_path])
|
||
_run(['sudo', 'ln', '-sf', conf_path, enabled_path])
|
||
|
||
code, out, err = _run(['sudo', 'nginx', '-t'])
|
||
if code != 0:
|
||
return False, err
|
||
|
||
_run(['sudo', 'nginx', '-s', 'reload'])
|
||
return True, 'Nginx 配置已更新'
|
||
|
||
def remove_nginx_config(domain):
|
||
"""删除站点 Nginx 配置(v1.3.15+ 用 sudo 删)"""
|
||
conf_path = f'/etc/nginx/sites-available/{domain}.conf'
|
||
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf'
|
||
|
||
if os.path.exists(enabled_path):
|
||
_run(['sudo', 'rm', '-f', enabled_path])
|
||
if os.path.exists(conf_path):
|
||
_run(['sudo', 'rm', '-f', conf_path])
|
||
|
||
_run(['sudo', 'nginx', '-s', 'reload'])
|
||
|
||
def create_mysql_db(name, db_user, db_pass):
|
||
"""创建 MySQL 数据库和用户(用 sudo 提权,避免 shell 注入)"""
|
||
# 校验 name/user 不含特殊字符(防止 SQL 注入)
|
||
import re
|
||
if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user):
|
||
return False, '数据库名/用户名只能包含字母数字下划线'
|
||
|
||
statements = [
|
||
f"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;",
|
||
f"CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{db_pass}';",
|
||
f"GRANT ALL PRIVILEGES ON `{name}`.* TO '{db_user}'@'localhost';",
|
||
"FLUSH PRIVILEGES;",
|
||
]
|
||
for stmt in statements:
|
||
code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False)
|
||
if code != 0:
|
||
return False, err
|
||
return True, '数据库创建成功'
|
||
|
||
def delete_mysql_db(name, db_user):
|
||
import re
|
||
if not re.match(r'^[a-zA-Z0-9_]+$', name) or not re.match(r'^[a-zA-Z0-9_]+$', db_user):
|
||
return False, '数据库名/用户名只能包含字母数字下划线'
|
||
statements = [
|
||
f"DROP DATABASE IF EXISTS `{name}`;",
|
||
f"DROP USER IF EXISTS '{db_user}'@'localhost';",
|
||
"FLUSH PRIVILEGES;",
|
||
]
|
||
for stmt in statements:
|
||
code, out, err = _run(['sudo', 'mysql', '-e', stmt], shell=False)
|
||
if code != 0:
|
||
return False, err
|
||
return True, '数据库删除成功'
|
||
|
||
def get_mysql_size():
|
||
"""获取 MySQL 数据目录大小(MB)"""
|
||
code, out, _ = _run("du -sm /var/lib/mysql 2>/dev/null || echo 0", shell=True)
|
||
try:
|
||
return int(out.split()[0])
|
||
except:
|
||
return 0
|
||
|
||
def backup_site(site_path, site_name, db_name=None, db_user=None, db_pass=None):
|
||
"""备份站点文件和数据库"""
|
||
import traceback
|
||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
# 清理站点名:ygbk.cn → ygbk.cn(保留点)
|
||
safe_name = site_name.replace('/', '_')
|
||
backup_name = f'{safe_name}_{timestamp}'
|
||
backup_path = f'/opt/tpanel/backups/{backup_name}.tar.gz'
|
||
|
||
# v1.3.10 修复:预检环境
|
||
try:
|
||
os.makedirs('/opt/tpanel/backups', exist_ok=True)
|
||
except Exception as e:
|
||
return False, f'无法创建 backups 目录: {e}', 0
|
||
if not os.path.isdir(site_path):
|
||
return False, f'站点目录不存在: ' + site_path + '', 0
|
||
if not os.access(site_path, os.R_OK):
|
||
return False, f'tpanel 用户无法读取 ' + site_path + '(chown 错了?ls -ld ' + site_path + ' 看看)', 0
|
||
|
||
try:
|
||
# 备份文件
|
||
with tarfile.open(backup_path, 'w:gz') as tar:
|
||
tar.add(site_path, arcname=os.path.basename(site_path))
|
||
|
||
# 备份数据库(v1.3.10 修复:用 list 参数防注入 + sudo)
|
||
if db_name:
|
||
dump_path = f'/opt/tpanel/backups/{backup_name}_db.sql.gz'
|
||
try:
|
||
if db_user and db_pass:
|
||
code, out, err = _run(
|
||
['sudo', 'mysqldump', '-u', db_user, f'-p{db_pass}', db_name],
|
||
shell=False, timeout=120
|
||
)
|
||
else:
|
||
code, out, err = _run(['sudo', 'mysqldump', db_name], shell=False, timeout=120)
|
||
if code == 0 and out:
|
||
import gzip
|
||
with open(dump_path, 'wb') as df:
|
||
df.write(gzip.compress(out.encode('utf-8') if isinstance(out, str) else out))
|
||
with tarfile.open(backup_path, 'a:gz') as tar:
|
||
tar.add(dump_path, arcname='database.sql.gz')
|
||
os.remove(dump_path)
|
||
except Exception as e:
|
||
# 数据库备份失败不阻断(文件备份可能成功)
|
||
pass
|
||
|
||
size = os.path.getsize(backup_path)
|
||
return True, backup_path, size
|
||
except PermissionError as e:
|
||
return False, f'权限错误: {e}(tpanel 读不到 ' + site_path + ',请 chown)', 0
|
||
except Exception as e:
|
||
return False, f'备份异常: {type(e).__name__}: {e}\n{traceback.format_exc()[-300:]}', 0
|
||
|
||
def restore_backup(backup_path, site_path, site_name):
|
||
"""恢复备份"""
|
||
try:
|
||
# v1.3.17+:先 sudo 删干净 site_path(因为可能有 root 拥有的文件,tpanel 删不掉)
|
||
# 用 sudo 替换为临时空目录,然后再解压
|
||
backup_site_path = site_path
|
||
if os.path.exists(backup_site_path):
|
||
# 移动到 .bak 路径(sudo 移)
|
||
bak_path = backup_site_path + '.bak.' + str(int(time.time()))
|
||
code, _, err = _run(['sudo', 'mv', backup_site_path, bak_path])
|
||
if code != 0:
|
||
return False, f'备份旧目录失败: {err}'
|
||
|
||
# 解压到临时目录
|
||
temp_dir = f'/opt/tpanel/backups/temp_{site_name}'
|
||
os.makedirs(temp_dir, exist_ok=True)
|
||
with tarfile.open(backup_path, 'r:gz') as tar:
|
||
tar.extractall(temp_dir)
|
||
|
||
# 找到网站目录内容
|
||
items = os.listdir(temp_dir)
|
||
src_dir = os.path.join(temp_dir, items[0]) if items else temp_dir
|
||
|
||
# 把整个 src 目录 sudo mv 到 site_path
|
||
code, _, err = _run(['sudo', 'mv', src_dir, backup_site_path])
|
||
if code != 0:
|
||
return False, f'恢复目录失败: {err}'
|
||
|
||
# v1.3.17+:从 site_path 反推 site_user
|
||
# /opt/tpanel/sites/zhangpu_tech/public → zhangpu_tech
|
||
path_parts = backup_site_path.rstrip('/').split('/')
|
||
site_user = path_parts[-1] if path_parts else site_name
|
||
_run(['sudo', 'chown', '-R', f'{site_user}:{site_user}', backup_site_path])
|
||
_run(['sudo', 'chmod', '-R', '755', backup_site_path])
|
||
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
return True, '恢复成功'
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
def run_security_update():
|
||
"""执行系统安全更新"""
|
||
code, out, err = _run(['sudo', 'apt-get', 'update'], timeout=120)
|
||
if code != 0:
|
||
return False, err
|
||
|
||
# v1.3.20+:apt-get upgrade 也加 sudo(不然 Permission denied dpkg lock)
|
||
code, out, err = _run(
|
||
['sudo', 'apt-get', 'upgrade', '-y', '--only-upgrade'],
|
||
timeout=300
|
||
)
|
||
if code == 0:
|
||
return True, f'安全更新完成'
|
||
else:
|
||
return False, err
|
||
|
||
def get_security_status():
|
||
"""获取安全状态"""
|
||
# 可升级的安全包数量
|
||
code, out, _ = _run(
|
||
"apt list --upgradable 2>/dev/null | grep -c security || echo 0",
|
||
shell=True
|
||
)
|
||
try:
|
||
updatable = int(out.strip())
|
||
except:
|
||
updatable = 0
|
||
|
||
# 最近的安全日志条数
|
||
code2, out2, _ = _run(
|
||
"journalctl --since '1 day ago' --priority=err 2>/dev/null | wc -l",
|
||
shell=True
|
||
)
|
||
try:
|
||
errors = int(out2.strip())
|
||
except:
|
||
errors = 0
|
||
|
||
return {'upgradable_security_packages': updatable, 'recent_errors': errors}
|
||
|
||
def get_system_stats():
|
||
"""获取系统状态"""
|
||
code, cpu_out, _ = _run("cat /proc/loadavg | awk '{print $1,$2,$3}'", shell=True)
|
||
code, mem_out, _ = _run("free -m | awk 'NR==2{print $3,$2}'", shell=True)
|
||
code, disk_out, _ = _run("df -h / | tail -1 | awk '{print $3,$4}'", shell=True)
|
||
code, cpu_pct, _ = _run("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | sed 's/%us,//'", shell=True)
|
||
|
||
# v1.3.10+ 新增:CPU 核心数 + 型号(用于仪表盘显示 + 负载颜色按核心数判断)
|
||
# v1.3.35 修复:容器/Docker 里 lscpu 无 "Model name" 行会导致 Unknown CPU
|
||
import os as _os
|
||
cpu_cores = _os.cpu_count() or 1
|
||
cpu_model = ''
|
||
# 1. 优先 lscpu "Model name"(KVM/Xen 等虚拟化都正常)
|
||
code, lscpu_out, _ = _run("lscpu | grep 'Model name' | head -1", shell=True)
|
||
if code == 0 and lscpu_out and ':' in lscpu_out:
|
||
cpu_model = lscpu_out.split(':', 1)[1].strip()
|
||
# 2. 兑底:/proc/cpuinfo 的 model name(v1.3.35 修复:必传 shell=True)
|
||
if not cpu_model:
|
||
code, cpuinfo_out, _ = _run("grep -m1 'model name' /proc/cpuinfo", shell=True)
|
||
if code == 0 and cpuinfo_out and ':' in cpuinfo_out:
|
||
cpu_model = cpuinfo_out.split(':', 1)[1].strip()
|
||
# 3. 兑底:/proc/cpuinfo 拼 vendor + family + model(容器里 lscpu 可能无 Model name)
|
||
if not cpu_model:
|
||
try:
|
||
with open('/proc/cpuinfo', 'r') as f:
|
||
ci = f.read()
|
||
vendor = family = model_name = ''
|
||
for line in ci.splitlines():
|
||
if line.startswith('vendor_id') and ':' in line and not vendor:
|
||
vendor = line.split(':', 1)[1].strip()
|
||
elif line.startswith('cpu family') and ':' in line and not family:
|
||
family = line.split(':', 1)[1].strip()
|
||
elif line.startswith('model name') and ':' in line and not model_name:
|
||
model_name = line.split(':', 1)[1].strip()
|
||
if model_name: break
|
||
if model_name:
|
||
cpu_model = model_name
|
||
elif vendor:
|
||
cpu_model = f'{vendor} CPU'
|
||
if family: cpu_model += f' (family {family})'
|
||
except Exception:
|
||
pass
|
||
# 4. 兑底:platform.processor()(老 Python 偶尔能拿到)
|
||
if not cpu_model:
|
||
try:
|
||
import platform
|
||
cpu_model = platform.processor() or ''
|
||
except Exception:
|
||
pass
|
||
# 5. 兑底:lscpu 看 Vendor ID + Model(某些云主机会输出这个)
|
||
if not cpu_model:
|
||
code, lscpu_v, _ = _run("lscpu | grep -E 'Vendor ID|Model:' | head -2", shell=True)
|
||
if code == 0 and lscpu_v:
|
||
parts = []
|
||
for line in lscpu_v.strip().splitlines():
|
||
if ':' in line:
|
||
parts.append(line.split(':', 1)[1].strip())
|
||
if parts:
|
||
cpu_model = ' '.join(parts) + ' CPU'
|
||
if not cpu_model:
|
||
cpu_model = 'Unknown CPU'
|
||
|
||
nginx_running = nginx_status()
|
||
mysql_running = mysql_status()
|
||
|
||
return {
|
||
'load': cpu_out,
|
||
'cpu_pct': cpu_pct.strip() + '%' if cpu_pct else 'N/A',
|
||
'cpu_cores': cpu_cores,
|
||
'cpu_model': cpu_model,
|
||
'mem_used_mb': mem_out.split()[0] if mem_out else '0',
|
||
'mem_total_mb': mem_out.split()[1] if mem_out else '0',
|
||
'disk_used': disk_out.split()[0] if disk_out else '0',
|
||
'disk_free': disk_out.split()[1] if disk_out else '0',
|
||
'nginx_running': nginx_running,
|
||
'mysql_running': mysql_running,
|
||
}
|
||
|
||
def write_log(event_type, details, ip=''):
|
||
"""写安全日志"""
|
||
import sqlite3
|
||
from config import DB_PATH
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("INSERT INTO security_logs (event_type, details, ip) VALUES (?, ?, ?)",
|
||
(event_type, details, ip))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def setup_php_fpm_listen(php_version):
|
||
"""
|
||
v1.3.29: 装完 PHP 后调用——设置 FPM listen 端口为版本专属端口,并启动服务
|
||
- 写 /etc/php/<ver>/fpm/pool.d/www.conf(备份原文件为 .bak)
|
||
- sudo systemctl enable --now php<ver>-fpm
|
||
返回: (ok, msg)
|
||
"""
|
||
port = get_php_fpm_port(php_version)
|
||
www_conf = f'/etc/php/{php_version}/fpm/pool.d/www.conf'
|
||
if not os.path.exists(www_conf):
|
||
return False, f'找不到 {www_conf}(该版本未安装?)'
|
||
|
||
# 备份(幂等:不重复备份)
|
||
bak = www_conf + '.tpanel.bak'
|
||
if not os.path.exists(bak):
|
||
code, _, err = _run(['sudo', 'cp', www_conf, bak])
|
||
if code != 0:
|
||
return False, f'备份 {www_conf} 失败: {err}'
|
||
|
||
# 修改 listen 行(用 sed 精准替换)
|
||
code, _, err = _run(['sudo', 'bash', '-c',
|
||
f"sed -i 's|^listen = .*|listen = 127.0.0.1:{port}|' {www_conf}"])
|
||
if code != 0:
|
||
return False, f'修改 listen 失败: {err}'
|
||
|
||
# 启用 + 启动
|
||
code, _, err = _run(['sudo', 'systemctl', 'enable', f'php{php_version}-fpm'])
|
||
if code != 0:
|
||
return False, f'enable php{php_version}-fpm 失败: {err}'
|
||
|
||
code, out, err = _run(['sudo', 'systemctl', 'restart', f'php{php_version}-fpm'])
|
||
if code != 0:
|
||
return False, f'restart php{php_version}-fpm 失败: {err}'
|
||
|
||
# 验证在监听
|
||
code, out, _ = _run(['sudo', 'ss', '-lntp'])
|
||
listening = f'127.0.0.1:{port}' in out
|
||
if not listening:
|
||
return False, f'php{php_version}-fpm 未在 127.0.0.1:{port} 监听(可能启动失败)'
|
||
|
||
return True, f'php{php_version}-fpm 已配置 listen 127.0.0.1:{port} 并启动'
|
||
|
||
def change_db_password(db_user, new_pass):
|
||
"""修改 MySQL 数据库用户密码(v1.3.34+)"""
|
||
import re
|
||
if not re.match(r"^[a-zA-Z0-9_]+$", db_user):
|
||
return False, "用户名只能包含字母数字下划线"
|
||
if not new_pass or len(new_pass) < 6:
|
||
return False, "密码至少 6 位"
|
||
escaped_pass = new_pass.replace("'", "''")
|
||
stmt = "ALTER USER '" + db_user + "'@'localhost' IDENTIFIED BY '" + escaped_pass + "';"
|
||
code, out, err = _run(["sudo", "mysql", "-e", stmt], shell=False)
|
||
if code != 0:
|
||
return False, err
|
||
code, _, err = _run(["sudo", "mysql", "-e", "FLUSH PRIVILEGES;"], shell=False)
|
||
if code != 0:
|
||
return False, err
|
||
return True, "密码修改成功"
|
||
|
||
|
||
# ====================== v1.3.37+ 生产增强功能 ======================
|
||
|
||
# ---------------------- 防火墙管理 ----------------------
|
||
|
||
def _detect_firewall():
|
||
"""检测系统使用的防火墙:ufw (Debian/Ubuntu) 或 firewalld (CentOS/RHEL)"""
|
||
if shutil.which('ufw'):
|
||
return 'ufw'
|
||
if shutil.which('firewall-cmd'):
|
||
return 'firewalld'
|
||
return None
|
||
|
||
def get_firewall_status():
|
||
"""获取防火墙状态和已开放端口"""
|
||
fw = _detect_firewall()
|
||
if not fw:
|
||
return {'enabled': False, 'type': None, 'rules': [], 'msg': '未检测到防火墙(ufw/firewalld)'}
|
||
|
||
if fw == 'ufw':
|
||
code, status, _ = _run(['sudo', 'ufw', 'status'])
|
||
enabled = 'Status: active' in status
|
||
# 解析规则
|
||
lines = status.split('\n')
|
||
rules = []
|
||
in_rules = False
|
||
for line in lines:
|
||
if '----' in line:
|
||
in_rules = True
|
||
continue
|
||
if in_rules and line.strip():
|
||
parts = line.split()
|
||
if len(parts) >= 3:
|
||
rules.append({
|
||
'port': parts[0],
|
||
'action': parts[1],
|
||
'from': parts[2] if len(parts) > 2 else 'Anywhere'
|
||
})
|
||
return {'enabled': enabled, 'type': 'ufw', 'rules': rules, 'status': status}
|
||
|
||
else: # firewalld
|
||
code, status, _ = _run(['sudo', 'firewall-cmd', '--state'])
|
||
enabled = status.strip() == 'running'
|
||
code, ports, _ = _run(['sudo', 'firewall-cmd', '--list-ports'])
|
||
rules = [{'port': p, 'action': 'allow', 'from': 'public'} for p in ports.split() if p.strip()]
|
||
return {'enabled': enabled, 'type': 'firewalld', 'rules': rules, 'status': status}
|
||
|
||
def firewall_enable():
|
||
"""启用防火墙并开放常用端口(SSH 80 443 + TPanel 端口)"""
|
||
fw = _detect_firewall()
|
||
if not fw:
|
||
# 自动安装 ufw
|
||
pkg = _detect_pkg_manager()
|
||
if pkg == 'apt-get':
|
||
code, _, err = _run(['sudo', 'apt-get', 'install', '-y', 'ufw'], timeout=120)
|
||
elif pkg in ['yum', 'dnf']:
|
||
code, _, err = _run(['sudo', pkg, 'install', '-y', 'firewalld'], timeout=120)
|
||
else:
|
||
return False, '不支持的系统包管理器'
|
||
if code != 0:
|
||
return False, f'安装防火墙失败: {err}'
|
||
fw = _detect_firewall()
|
||
|
||
if fw == 'ufw':
|
||
# 默认策略
|
||
_run(['sudo', 'ufw', 'default', 'deny', 'incoming'])
|
||
_run(['sudo', 'ufw', 'default', 'allow', 'outgoing'])
|
||
# 开放常用端口
|
||
for port in ['22', '80', '443', '8888']:
|
||
_run(['sudo', 'ufw', 'allow', port])
|
||
# 启用
|
||
code, _, err = _run(['sudo', 'bash', '-c', 'echo "y" | ufw enable'], shell=True)
|
||
return code == 0, '防火墙已启用,已开放 22/80/443/8888 端口'
|
||
|
||
else: # firewalld
|
||
_run(['sudo', 'systemctl', 'enable', '--now', 'firewalld'])
|
||
for port in ['22/tcp', '80/tcp', '443/tcp', '8888/tcp']:
|
||
_run(['sudo', 'firewall-cmd', '--permanent', '--add-port=' + port])
|
||
_run(['sudo', 'firewall-cmd', '--reload'])
|
||
return True, '防火墙已启用,已开放 22/80/443/8888 端口'
|
||
|
||
def firewall_open_port(port, proto='tcp'):
|
||
"""开放端口"""
|
||
if not port.isdigit() or int(port) < 1 or int(port) > 65535:
|
||
return False, '端口号无效(1-65535)'
|
||
|
||
fw = _detect_firewall()
|
||
if not fw:
|
||
return False, '未检测到防火墙,请先启用'
|
||
|
||
if fw == 'ufw':
|
||
code, _, err = _run(['sudo', 'ufw', 'allow', f'{port}/{proto}'])
|
||
return code == 0, f'端口 {port}/{proto} 已开放'
|
||
else:
|
||
_run(['sudo', 'firewall-cmd', '--permanent', f'--add-port={port}/{proto}'])
|
||
_run(['sudo', 'firewall-cmd', '--reload'])
|
||
return True, f'端口 {port}/{proto} 已开放'
|
||
|
||
def firewall_close_port(port, proto='tcp'):
|
||
"""关闭端口"""
|
||
if not port.isdigit() or int(port) < 1 or int(port) > 65535:
|
||
return False, '端口号无效'
|
||
|
||
fw = _detect_firewall()
|
||
if not fw:
|
||
return False, '未检测到防火墙'
|
||
|
||
if fw == 'ufw':
|
||
code, _, err = _run(['sudo', 'ufw', 'delete', 'allow', f'{port}/{proto}'])
|
||
return code == 0, f'端口 {port}/{proto} 已关闭'
|
||
else:
|
||
_run(['sudo', 'firewall-cmd', '--permanent', f'--remove-port={port}/{proto}'])
|
||
_run(['sudo', 'firewall-cmd', '--reload'])
|
||
return True, f'端口 {port}/{proto} 已关闭'
|
||
|
||
|
||
# ---------------------- 面板端口修改 ----------------------
|
||
|
||
def get_panel_port():
|
||
"""获取当前 TPanel 监听端口"""
|
||
# 先从 Nginx 配置查
|
||
code, out, _ = _run(['sudo', 'grep', '-r', 'listen', '/etc/nginx/sites-enabled/tpanel.conf'])
|
||
if code == 0:
|
||
for line in out.split('\n'):
|
||
if 'listen ' in line and 'default_server' not in line:
|
||
parts = line.strip().split()
|
||
if len(parts) >= 2 and parts[1].isdigit():
|
||
return int(parts[1])
|
||
# 查 systemd 服务
|
||
code, out, _ = _run(['grep', 'ExecStart', '/etc/systemd/system/tpanel.service'])
|
||
if code == 0 and '--port' in out:
|
||
idx = out.find('--port')
|
||
port_part = out[idx:].split()[1]
|
||
if port_part.isdigit():
|
||
return int(port_part)
|
||
return 8888 # 默认
|
||
|
||
def change_panel_port(new_port):
|
||
"""修改 TPanel 后台端口
|
||
1. 修改 Nginx 反向代理配置(8888 -> new_port)
|
||
2. 修改 systemd 服务启动端口
|
||
3. 防火墙开放新端口
|
||
4. 重启服务生效
|
||
"""
|
||
if not str(new_port).isdigit() or int(new_port) < 1000 or int(new_port) > 65535:
|
||
return False, '端口号无效(1000-65535)'
|
||
new_port = int(new_port)
|
||
old_port = get_panel_port()
|
||
|
||
if old_port == new_port:
|
||
return False, '新端口与当前端口相同'
|
||
|
||
# 1. 防火墙开放新端口
|
||
fw = _detect_firewall()
|
||
if fw and get_firewall_status()['enabled']:
|
||
firewall_open_port(str(new_port))
|
||
|
||
# 2. 修改 Nginx 配置
|
||
nginx_conf = '/etc/nginx/sites-enabled/tpanel.conf'
|
||
if os.path.exists(nginx_conf):
|
||
code, _, err = _run(['sudo', 'sed', '-i', f's/proxy_pass http:\/\/127.0.0.1:{old_port}/proxy_pass http://127.0.0.1:{new_port}/', nginx_conf])
|
||
if code != 0:
|
||
return False, f'修改 Nginx 配置失败: {err}'
|
||
# 修改 listen 端口
|
||
_run(['sudo', 'sed', '-i', f's/listen {old_port}/listen {new_port}/', nginx_conf])
|
||
|
||
# 3. 修改 systemd 服务
|
||
service_file = '/etc/systemd/system/tpanel.service'
|
||
if os.path.exists(service_file):
|
||
code, _, err = _run(['sudo', 'sed', '-i', f's/--port {old_port}/--port {new_port}/', service_file])
|
||
if code != 0:
|
||
return False, f'修改 systemd 服务失败: {err}'
|
||
|
||
# 4. 重新加载 daemon 并重启服务
|
||
_run(['sudo', 'systemctl', 'daemon-reload'])
|
||
code, _, err = _run(['sudo', 'systemctl', 'restart', 'tpanel'])
|
||
if code != 0:
|
||
return False, f'重启 TPanel 服务失败: {err}'
|
||
|
||
# 5. 重启 Nginx
|
||
_run(['sudo', 'nginx', '-s', 'reload'])
|
||
|
||
# 6. 关闭旧端口防火墙(如果之前开着)
|
||
if fw and get_firewall_status()['enabled']:
|
||
firewall_close_port(str(old_port))
|
||
|
||
return True, f'端口修改成功!新端口: {new_port},请刷新页面重新访问'
|
||
|
||
|
||
# ---------------------- 自动备份 ----------------------
|
||
|
||
def get_backup_settings():
|
||
"""获取备份配置状态"""
|
||
import sqlite3
|
||
from config import DB_PATH
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("SELECT * FROM backup_settings LIMIT 1")
|
||
cols = [d[0] for d in cur.description]
|
||
row = cur.fetchone()
|
||
conn.close()
|
||
if row:
|
||
return dict(zip(cols, row))
|
||
return {'enabled': 0, 'schedule': 'daily', 'keep_days': 7, 'backup_dir': '/backup'}
|
||
|
||
def save_backup_settings(enabled, schedule, keep_days, backup_dir='/backup'):
|
||
"""保存自动备份配置"""
|
||
import sqlite3
|
||
from config import DB_PATH
|
||
conn = sqlite3.connect(DB_PATH)
|
||
|
||
# 检查表是否存在
|
||
cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='backup_settings'")
|
||
if not cur.fetchone():
|
||
conn.execute("""
|
||
CREATE TABLE backup_settings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
enabled INTEGER DEFAULT 0,
|
||
schedule TEXT DEFAULT 'daily',
|
||
keep_days INTEGER DEFAULT 7,
|
||
backup_dir TEXT DEFAULT '/backup',
|
||
updated_at TEXT
|
||
)
|
||
""")
|
||
|
||
conn.execute("DELETE FROM backup_settings")
|
||
conn.execute("""
|
||
INSERT INTO backup_settings (enabled, schedule, keep_days, backup_dir, updated_at)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
""", (1 if enabled else 0, schedule, keep_days, backup_dir, datetime.datetime.now().isoformat()))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# 配置 cron 定时任务
|
||
if enabled:
|
||
if schedule == 'daily':
|
||
cron_expr = '0 3 * * *' # 每天凌晨3点
|
||
elif schedule == 'weekly':
|
||
cron_expr = '0 2 * * 0' # 每周日凌晨2点
|
||
else: # hourly
|
||
cron_expr = '0 * * * *' # 每小时
|
||
|
||
# 创建备份脚本
|
||
script_content = f'''#!/bin/bash
|
||
# TPanel 自动备份脚本
|
||
BACKUP_DIR="{backup_dir}"
|
||
KEEP_DAYS={keep_days}
|
||
DATE=$(date +%Y%m%d_%H%M%S)
|
||
|
||
# 创建备份目录
|
||
mkdir -p $BACKUP_DIR/sites
|
||
mkdir -p $BACKUP_DIR/databases
|
||
|
||
# 备份所有站点
|
||
for site_user in /opt/tpanel/sites/*/; do
|
||
site_name=$(basename "$site_user")
|
||
tar -czf "$BACKUP_DIR/sites/${site_name}_${DATE}.tar.gz" -C /opt/tpanel/sites "$site_name" 2>/dev/null
|
||
done
|
||
|
||
# 备份所有数据库
|
||
for db in $(sudo mysql -e "SHOW DATABASES;" | grep -vE "(Database|information_schema|performance_schema|mysql|sys)"); do
|
||
sudo mysqldump "$db" > "$BACKUP_DIR/databases/${db}_${DATE}.sql" 2>/dev/null
|
||
gzip -f "$BACKUP_DIR/databases/${db}_${DATE}.sql"
|
||
done
|
||
|
||
# 清理过期备份
|
||
find "$BACKUP_DIR/sites" -name "*.tar.gz" -mtime +$KEEP_DAYS -delete
|
||
find "$BACKUP_DIR/databases" -name "*.sql.gz" -mtime +$KEEP_DAYS -delete
|
||
|
||
echo "Backup completed at $(date)" >> /opt/tpanel/logs/backup.log
|
||
'''
|
||
os.makedirs('/opt/tpanel/scripts', exist_ok=True)
|
||
with open('/opt/tpanel/scripts/auto_backup.sh', 'w') as f:
|
||
f.write(script_content)
|
||
os.chmod('/opt/tpanel/scripts/auto_backup.sh', 0o755)
|
||
|
||
# 添加到 crontab
|
||
code, out, _ = _run('crontab -l 2>/dev/null || echo ""', shell=True)
|
||
lines = [l for l in out.split('\n') if 'auto_backup.sh' not in l and l.strip()]
|
||
lines.append(f'{cron_expr} /opt/tpanel/scripts/auto_backup.sh')
|
||
_run(f'echo "{chr(10).join(lines)}" | crontab -', shell=True)
|
||
|
||
else:
|
||
# 禁用:从 crontab 移除
|
||
code, out, _ = _run('crontab -l 2>/dev/null || echo ""', shell=True)
|
||
if code == 0:
|
||
lines = [l for l in out.split('\n') if 'auto_backup.sh' not in l and l.strip()]
|
||
_run(f'echo "{chr(10).join(lines)}" | crontab -', shell=True)
|
||
|
||
return True, '备份配置已保存'
|
||
|
||
def run_backup_now():
|
||
"""立即执行一次手动备份"""
|
||
script = '/opt/tpanel/scripts/auto_backup.sh'
|
||
if not os.path.exists(script):
|
||
return False, '备份脚本不存在,请先配置自动备份'
|
||
|
||
code, out, err = _run(['sudo', 'bash', script], timeout=300)
|
||
return code == 0, out if code == 0 else err
|
||
|
||
def list_backups():
|
||
"""列出所有备份文件"""
|
||
settings = get_backup_settings()
|
||
backup_dir = settings['backup_dir']
|
||
|
||
if not os.path.exists(backup_dir):
|
||
return {'sites': [], 'databases': [], 'total_size': '0 MB'}
|
||
|
||
sites = []
|
||
sites_dir = os.path.join(backup_dir, 'sites')
|
||
if os.path.exists(sites_dir):
|
||
for f in sorted(os.listdir(sites_dir), reverse=True):
|
||
path = os.path.join(sites_dir, f)
|
||
if os.path.isfile(path):
|
||
size_mb = round(os.path.getsize(path) / 1024 / 1024, 2)
|
||
sites.append({'name': f, 'size': f'{size_mb} MB', 'path': path, 'mtime': datetime.datetime.fromtimestamp(os.path.getmtime(path)).isoformat()})
|
||
|
||
dbs = []
|
||
dbs_dir = os.path.join(backup_dir, 'databases')
|
||
if os.path.exists(dbs_dir):
|
||
for f in sorted(os.listdir(dbs_dir), reverse=True):
|
||
path = os.path.join(dbs_dir, f)
|
||
if os.path.isfile(path):
|
||
size_mb = round(os.path.getsize(path) / 1024 / 1024, 2)
|
||
dbs.append({'name': f, 'size': f'{size_mb} MB', 'path': path, 'mtime': datetime.datetime.fromtimestamp(os.path.getmtime(path)).isoformat()})
|
||
|
||
# 计算总大小
|
||
total_size = sum(float(s['size'].split()[0]) for s in sites + dbs)
|
||
|
||
return {
|
||
'sites': sites,
|
||
'databases': dbs,
|
||
'total_size': f'{round(total_size, 2)} MB',
|
||
'backup_dir': backup_dir
|
||
}
|
||
|
||
def delete_backup(backup_type, filename):
|
||
"""删除备份文件"""
|
||
settings = get_backup_settings()
|
||
if backup_type not in ['sites', 'databases']:
|
||
return False, '类型无效'
|
||
|
||
path = os.path.join(settings['backup_dir'], backup_type, filename)
|
||
if not os.path.exists(path):
|
||
return False, '文件不存在'
|
||
|
||
try:
|
||
os.remove(path)
|
||
return True, '备份已删除'
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
# ====================== v1.3.39+ 在线升级功能 ======================
|
||
|
||
def get_server_info():
|
||
"""Get server info: IP / hostname / OS / kernel / uptime / public IP / panel version"""
|
||
import socket
|
||
import platform
|
||
|
||
info = {
|
||
"internal_ip": "",
|
||
"hostname": "",
|
||
"os": "",
|
||
"kernel": "",
|
||
"uptime": "",
|
||
"public_ip": "",
|
||
"panel_version": "",
|
||
}
|
||
|
||
# 1. Internal IP (multiple fallbacks)
|
||
# 1a. hostname -I (fastest)
|
||
code, out, _ = _run(["hostname", "-I"], shell=False)
|
||
if code == 0 and out:
|
||
info["internal_ip"] = out.strip().split()[0]
|
||
# 1b. Fallback: ip route get 1.1.1.1
|
||
if not info["internal_ip"]:
|
||
code, out, _ = _run(["bash", "-c", "ip route get 1.1.1.1 2>/dev/null | awk -F'src' '{print $2}' | awk '{print $1}'"], shell=False)
|
||
if code == 0 and out:
|
||
info["internal_ip"] = out.strip()
|
||
# 1c. Fallback: UDP socket
|
||
if not info["internal_ip"]:
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.settimeout(2)
|
||
s.connect(("1.1.1.1", 80))
|
||
info["internal_ip"] = s.getsockname()[0]
|
||
s.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. Hostname
|
||
try:
|
||
info["hostname"] = socket.gethostname()
|
||
except Exception:
|
||
pass
|
||
|
||
# 3. OS (try /etc/os-release PRETTY_NAME first)
|
||
code, out, _ = _run(["bash", "-c", "grep PRETTY_NAME /etc/os-release 2>/dev/null | head -1 | cut -d= -f2 | tr -d '\"'"], shell=False)
|
||
if code == 0 and out:
|
||
info["os"] = out.strip()
|
||
if not info["os"]:
|
||
info["os"] = platform.platform()
|
||
|
||
# 4. Kernel
|
||
info["kernel"] = platform.release()
|
||
|
||
# 5. Uptime (human-readable)
|
||
code, out, _ = _run(["uptime", "-p"], shell=False)
|
||
if code == 0 and out:
|
||
info["uptime"] = out.strip()
|
||
|
||
# 6. Public IP (ipify, 3s timeout; fall back to ip.cn)
|
||
try:
|
||
import urllib.request
|
||
import json as _json
|
||
req = urllib.request.Request("https://api.ipify.org?format=json", headers={"User-Agent": "TPanel/" + get_current_version()})
|
||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||
j = _json.loads(resp.read().decode("utf-8"))
|
||
info["public_ip"] = j.get("ip", "")
|
||
except Exception:
|
||
try:
|
||
import urllib.request
|
||
import json as _json
|
||
req = urllib.request.Request("https://ip.cn/api/index?ip=&type=0", headers={"User-Agent": "curl/7"})
|
||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||
j = _json.loads(resp.read().decode("utf-8"))
|
||
info["public_ip"] = j.get("ip", "")
|
||
except Exception:
|
||
pass
|
||
|
||
# 7. Panel version
|
||
info["panel_version"] = get_current_version()
|
||
|
||
return info
|
||
|
||
|
||
def get_server_info():
|
||
"""Get server info: IP / hostname / OS / kernel / uptime / public IP / panel version"""
|
||
import socket
|
||
import platform
|
||
|
||
info = {
|
||
"internal_ip": "",
|
||
"hostname": "",
|
||
"os": "",
|
||
"kernel": "",
|
||
"uptime": "",
|
||
"public_ip": "",
|
||
"panel_version": "",
|
||
}
|
||
|
||
# 1. Internal IP (multiple fallbacks)
|
||
# 1a. hostname -I (fastest)
|
||
code, out, _ = _run(["hostname", "-I"], shell=False)
|
||
if code == 0 and out:
|
||
info["internal_ip"] = out.strip().split()[0]
|
||
# 1b. Fallback: ip route get 1.1.1.1
|
||
if not info["internal_ip"]:
|
||
code, out, _ = _run(["bash", "-c", "ip route get 1.1.1.1 2>/dev/null | awk -F'src' '{print $2}' | awk '{print $1}'"], shell=False)
|
||
if code == 0 and out:
|
||
info["internal_ip"] = out.strip()
|
||
# 1c. Fallback: UDP socket
|
||
if not info["internal_ip"]:
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.settimeout(2)
|
||
s.connect(("1.1.1.1", 80))
|
||
info["internal_ip"] = s.getsockname()[0]
|
||
s.close()
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. Hostname
|
||
try:
|
||
info["hostname"] = socket.gethostname()
|
||
except Exception:
|
||
pass
|
||
|
||
# 3. OS (try /etc/os-release PRETTY_NAME first)
|
||
code, out, _ = _run(["bash", "-c", "grep PRETTY_NAME /etc/os-release 2>/dev/null | head -1 | cut -d= -f2 | tr -d '\"'"], shell=False)
|
||
if code == 0 and out:
|
||
info["os"] = out.strip()
|
||
if not info["os"]:
|
||
info["os"] = platform.platform()
|
||
|
||
# 4. Kernel
|
||
info["kernel"] = platform.release()
|
||
|
||
# 5. Uptime (human-readable)
|
||
code, out, _ = _run(["uptime", "-p"], shell=False)
|
||
if code == 0 and out:
|
||
info["uptime"] = out.strip()
|
||
|
||
# 6. Public IP (ipify, 3s timeout; fall back to ip.cn)
|
||
try:
|
||
import urllib.request
|
||
import json as _json
|
||
req = urllib.request.Request("https://api.ipify.org?format=json", headers={"User-Agent": "TPanel/" + get_current_version()})
|
||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||
j = _json.loads(resp.read().decode("utf-8"))
|
||
info["public_ip"] = j.get("ip", "")
|
||
except Exception:
|
||
try:
|
||
import urllib.request
|
||
import json as _json
|
||
req = urllib.request.Request("https://ip.cn/api/index?ip=&type=0", headers={"User-Agent": "curl/7"})
|
||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||
j = _json.loads(resp.read().decode("utf-8"))
|
||
info["public_ip"] = j.get("ip", "")
|
||
except Exception:
|
||
pass
|
||
|
||
# 7. Panel version
|
||
info["panel_version"] = get_current_version()
|
||
|
||
return info
|
||
|
||
|
||
def get_current_version():
|
||
"""获取当前版本号(从前端HTML里提取)"""
|
||
index_path = '/opt/tpanel/frontend/index.html'
|
||
if not os.path.exists(index_path):
|
||
return '1.0.0'
|
||
try:
|
||
with open(index_path, 'r') as f:
|
||
content = f.read()
|
||
import re
|
||
m = re.search(r'T面板 v([\d.]+)', content)
|
||
return m.group(1) if m else '1.0.0'
|
||
except Exception:
|
||
return '1.0.0'
|
||
|
||
def check_latest_version():
|
||
"""检测最新版本(GitHub API / 官方CDN)"""
|
||
# TODO: 以后有官方域名后换成真实地址,现在先返回本地版本
|
||
# 临时方案:返回当前版本 + 提示功能已就绪
|
||
return {
|
||
'current': get_current_version(),
|
||
'latest': get_current_version(),
|
||
'has_update': False,
|
||
'download_url': '',
|
||
'release_notes': '在线升级功能已就绪,支持手动上传安装包升级'
|
||
}
|
||
|
||
def backup_current_version():
|
||
"""升级前自动备份当前版本"""
|
||
import shutil
|
||
import datetime
|
||
version = get_current_version()
|
||
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||
backup_file = f'/opt/tpanel/backup_v{version}_{timestamp}.zip'
|
||
|
||
try:
|
||
# 备份 backend 和 frontend 目录
|
||
with zipfile.ZipFile(backup_file, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||
for root, dirs, files in os.walk('/opt/tpanel/backend'):
|
||
for file in files:
|
||
if not file.endswith('.pyc') and '__pycache__' not in root:
|
||
full_path = os.path.join(root, file)
|
||
arcname = os.path.relpath(full_path, '/opt/tpanel')
|
||
zf.write(full_path, arcname)
|
||
for root, dirs, files in os.walk('/opt/tpanel/frontend'):
|
||
for file in files:
|
||
full_path = os.path.join(root, file)
|
||
arcname = os.path.relpath(full_path, '/opt/tpanel')
|
||
zf.write(full_path, arcname)
|
||
return True, backup_file
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
def upgrade_from_zip(zip_path):
|
||
"""从上传的zip包升级"""
|
||
import zipfile
|
||
import shutil
|
||
|
||
# 1. 先备份当前版本
|
||
ok, backup_file = backup_current_version()
|
||
if not ok:
|
||
return False, f'备份失败: {backup_file}'
|
||
|
||
# 2. 验证zip包
|
||
if not os.path.exists(zip_path):
|
||
return False, '安装包不存在'
|
||
|
||
try:
|
||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||
# 检查必须的目录
|
||
names = zf.namelist()
|
||
has_backend = any('backend/' in n for n in names)
|
||
has_frontend = any('frontend/' in n for n in names)
|
||
if not has_backend or not has_frontend:
|
||
return False, '安装包格式错误:缺少 backend 或 frontend 目录'
|
||
except Exception as e:
|
||
return False, f'安装包损坏: {str(e)}'
|
||
|
||
# 3. 解压覆盖
|
||
try:
|
||
temp_dir = '/tmp/tpanel_upgrade'
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
os.makedirs(temp_dir, exist_ok=True)
|
||
|
||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||
zf.extractall(temp_dir)
|
||
|
||
# 覆盖文件
|
||
if os.path.exists(f'{temp_dir}/backend'):
|
||
_run(['sudo', 'cp', '-rf', f'{temp_dir}/backend/', '/opt/tpanel/'])
|
||
if os.path.exists(f'{temp_dir}/frontend'):
|
||
_run(['sudo', 'cp', '-rf', f'{temp_dir}/frontend/', '/opt/tpanel/'])
|
||
|
||
# 清理临时文件
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
os.remove(zip_path)
|
||
|
||
return True, f'升级成功!已备份旧版本到: {backup_file}'
|
||
except Exception as e:
|
||
return False, f'升级失败: {str(e)}'
|
||
|
||
def rollback_version(backup_file):
|
||
"""回滚到指定备份版本"""
|
||
import zipfile
|
||
if not os.path.exists(backup_file):
|
||
return False, '备份文件不存在'
|
||
|
||
try:
|
||
temp_dir = '/tmp/tpanel_rollback'
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
os.makedirs(temp_dir, exist_ok=True)
|
||
|
||
with zipfile.ZipFile(backup_file, 'r') as zf:
|
||
zf.extractall(temp_dir)
|
||
|
||
_run(['sudo', 'cp', '-rf', f'{temp_dir}/backend/', '/opt/tpanel/'])
|
||
_run(['sudo', 'cp', '-rf', f'{temp_dir}/frontend/', '/opt/tpanel/'])
|
||
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||
return True, '回滚成功'
|
||
except Exception as e:
|
||
return False, f'回滚失败: {str(e)}'
|
||
|
||
def list_backup_versions():
|
||
"""列出所有可回滚的版本备份"""
|
||
backups = []
|
||
try:
|
||
for f in os.listdir('/opt/tpanel'):
|
||
if f.startswith('backup_v') and f.endswith('.zip'):
|
||
stat = os.stat(f'/opt/tpanel/{f}')
|
||
size_mb = round(stat.st_size / 1024 / 1024, 2)
|
||
backups.append({
|
||
'name': f,
|
||
'path': f'/opt/tpanel/{f}',
|
||
'size': f'{size_mb} MB',
|
||
'mtime': datetime.datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
|
||
})
|
||
return sorted(backups, key=lambda x: x['mtime'], reverse=True)
|
||
except Exception:
|
||
return []
|