mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-03 00:39:28 +08:00
✨ v1.3.44 更新内容: - 🔧 修复:强制刷新页面后链接点不了的 bug - 修复:DOMContentLoaded 中添加 checkAuth() 调用,自动验证登录态
426 lines
17 KiB
Python
426 lines
17 KiB
Python
"""
|
||
TPanel - 任务管理器
|
||
用于软件安装、安全更新等长任务的执行 + 实时进度推送
|
||
"""
|
||
import sqlite3
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
import os
|
||
import json
|
||
import re
|
||
import shutil
|
||
from datetime import datetime
|
||
from config import DB_PATH
|
||
|
||
|
||
def _detect_pkg_manager():
|
||
"""检测系统包管理器(apt/yum/dnf)"""
|
||
for p in ['apt-get', 'yum', 'dnf']:
|
||
if shutil.which(p):
|
||
return p
|
||
return None
|
||
|
||
|
||
def get_apt_cmd():
|
||
"""获取系统包管理器 + sudo"""
|
||
pkg = _detect_pkg_manager()
|
||
if pkg == 'apt-get':
|
||
return ['sudo', 'apt-get', '-y']
|
||
elif pkg == 'yum':
|
||
return ['sudo', 'yum', '-y']
|
||
elif pkg == 'dnf':
|
||
return ['sudo', 'dnf', '-y']
|
||
else:
|
||
raise Exception('不支持的包管理器')
|
||
|
||
|
||
|
||
|
||
|
||
def _short_version(v):
|
||
'''把 debian '7.0.33-89+0~20260514.116+debian12~1.gbpfef6bb' 短化成 '7.0.33'
|
||
- 剥 epoch (4:)
|
||
- 取 主版本号 (数字.数字.数字)
|
||
- 失败返回原值
|
||
'''
|
||
if not v:
|
||
return None
|
||
v = re.sub(r"^\d+:", "", v)
|
||
m = re.match(r"(\d+\.\d+\.\d+)", v)
|
||
return m.group(1) if m else v
|
||
|
||
def init_software_table():
|
||
"""初始化软件列表(幂等)"""
|
||
pkg = _detect_pkg_manager()
|
||
is_deb = pkg == 'apt-get'
|
||
|
||
# 软件白名单:name / 显示名 / 分类 / apt 包名(多个用逗号)
|
||
catalog = [
|
||
('php5.6', 'PHP 5.6', 'PHP',
|
||
'php5.6-fpm,php5.6-cli,php5.6-mysql,php5.6-curl,php5.6-mbstring,php5.6-xml,php5.6-zip,php5.6-gd'
|
||
if is_deb else 'php56-php-fpm,php56-php-cli,php56-php-mysqlnd'),
|
||
('php7.0', 'PHP 7.0', 'PHP',
|
||
'php7.0-fpm,php7.0-cli,php7.0-mysql,php7.0-curl,php7.0-mbstring,php7.0-xml,php7.0-zip,php7.0-gd'
|
||
if is_deb else 'php70-php-fpm,php70-php-cli,php70-php-mysqlnd'),
|
||
('php7.4', 'PHP 7.4', 'PHP',
|
||
'php7.4-fpm,php7.4-cli,php7.4-mysql,php7.4-curl,php7.4-mbstring,php7.4-xml,php7.4-zip,php7.4-gd'
|
||
if is_deb else 'php74-php-fpm,php74-php-cli,php74-php-mysqlnd'),
|
||
('php8.0', 'PHP 8.0', 'PHP',
|
||
'php8.0-fpm,php8.0-cli,php8.0-mysql,php8.0-curl,php8.0-mbstring,php8.0-xml,php8.0-zip,php8.0-gd'
|
||
if is_deb else 'php80-php-fpm,php80-php-cli,php80-php-mysqlnd'),
|
||
('php8.1', 'PHP 8.1', 'PHP',
|
||
'php8.1-fpm,php8.1-cli,php8.1-mysql,php8.1-curl,php8.1-mbstring,php8.1-xml,php8.1-zip,php8.1-gd'
|
||
if is_deb else 'php81-php-fpm,php81-php-cli,php81-php-mysqlnd'),
|
||
('php8.2', 'PHP 8.2', 'PHP',
|
||
'php8.2-fpm,php8.2-cli,php8.2-mysql,php8.2-curl,php8.2-mbstring,php8.2-xml,php8.2-zip,php8.2-gd'
|
||
if is_deb else 'php82-php-fpm,php82-php-cli,php82-php-mysqlnd'),
|
||
('php8.3', 'PHP 8.3', 'PHP',
|
||
'php8.3-fpm,php8.3-cli,php8.3-mysql,php8.3-curl,php8.3-mbstring,php8.3-xml,php8.3-zip,php8.3-gd'
|
||
if is_deb else 'php83-php-fpm,php83-php-cli,php83-php-mysqlnd'),
|
||
# v1.3.29: 补上 PHP 8.4(Sury 源已支持)
|
||
('php8.4', 'PHP 8.4', 'PHP',
|
||
'php8.4-fpm,php8.4-cli,php8.4-mysql,php8.4-curl,php8.4-mbstring,php8.4-xml,php8.4-zip,php8.4-gd'
|
||
if is_deb else 'php84-php-fpm,php84-php-cli,php84-php-mysqlnd'),
|
||
('phpmyadmin', 'phpMyAdmin', '数据库', 'phpmyadmin' if is_deb else 'phpMyAdmin'),
|
||
]
|
||
|
||
conn = sqlite3.connect(DB_PATH)
|
||
for name, display, cat, pkgs in catalog:
|
||
# 探测实际安装状态
|
||
installed = 0
|
||
version = None
|
||
first_pkg = pkgs.split(',')[0].split('/')[0]
|
||
if is_deb:
|
||
r = os.system(f'dpkg -s {first_pkg} >/dev/null 2>&1')
|
||
if r == 0:
|
||
installed = 1
|
||
# 拿版本
|
||
try:
|
||
v = subprocess.check_output(
|
||
['dpkg-query', '-f=${Version}', '-W', first_pkg],
|
||
stderr=subprocess.DEVNULL, timeout=5
|
||
).decode().strip()
|
||
version = _short_version(v) if v else None
|
||
except Exception:
|
||
pass
|
||
else:
|
||
r = os.system(f'rpm -q {first_pkg} >/dev/null 2>&1')
|
||
if r == 0:
|
||
installed = 1
|
||
try:
|
||
v = subprocess.check_output(
|
||
['rpm', '-q', '--queryformat', '%{VERSION}', first_pkg],
|
||
stderr=subprocess.DEVNULL, timeout=5
|
||
).decode().strip()
|
||
version = _short_version(v) if v else None
|
||
except Exception:
|
||
pass
|
||
|
||
# 已有则更新状态(不覆盖显示名等)
|
||
row = conn.execute("SELECT name FROM software WHERE name = ?", (name,)).fetchone()
|
||
if row:
|
||
conn.execute("""UPDATE software SET installed = ?, version = ?, last_check = ?
|
||
WHERE name = ?""",
|
||
(installed, version, datetime.now().isoformat(), name))
|
||
else:
|
||
conn.execute("""INSERT INTO software (name, display_name, category, installed, version, last_check)
|
||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||
(name, display, cat, installed, version, datetime.now().isoformat()))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
def list_software():
|
||
"""列出所有软件 + 状态"""
|
||
init_software_table()
|
||
conn = sqlite3.connect(DB_PATH)
|
||
rows = conn.execute("""SELECT name, display_name, category, installed, version, last_install
|
||
FROM software ORDER BY category, name""").fetchall()
|
||
conn.close()
|
||
return [{
|
||
'name': r[0], 'display_name': r[1], 'category': r[2],
|
||
'installed': bool(r[3]), 'version': r[4], 'last_install': r[5]
|
||
} for r in rows]
|
||
|
||
|
||
def get_software(name):
|
||
"""获取单个软件信息"""
|
||
conn = sqlite3.connect(DB_PATH)
|
||
row = conn.execute("""SELECT name, display_name, category, installed, version, last_install
|
||
FROM software WHERE name = ?""", (name,)).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return None
|
||
return {
|
||
'name': row[0], 'display_name': row[1], 'category': row[2],
|
||
'installed': bool(row[3]), 'version': row[4], 'last_install': row[5]
|
||
}
|
||
|
||
|
||
def get_apt_packages(name):
|
||
"""从软件名反查 apt 包列表"""
|
||
init_software_table()
|
||
conn = sqlite3.connect(DB_PATH)
|
||
row = conn.execute("SELECT name FROM software WHERE name = ?", (name,)).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return None
|
||
# 直接从 catalog 重算(不存包名到 DB,因为跨系统不一样)
|
||
pkg = _detect_pkg_manager()
|
||
is_deb = pkg == 'apt-get'
|
||
catalog = {
|
||
'php5.6': 'php5.6-fpm,php5.6-cli,php5.6-mysql,php5.6-curl,php5.6-mbstring,php5.6-xml,php5.6-zip,php5.6-gd' if is_deb else 'php56-php-fpm,php56-php-cli',
|
||
'php7.0': 'php7.0-fpm,php7.0-cli,php7.0-mysql,php7.0-curl,php7.0-mbstring,php7.0-xml,php7.0-zip,php7.0-gd' if is_deb else 'php70-php-fpm,php70-php-cli',
|
||
'php7.4': 'php7.4-fpm,php7.4-cli,php7.4-mysql,php7.4-curl,php7.4-mbstring,php7.4-xml,php7.4-zip,php7.4-gd' if is_deb else 'php74-php-fpm,php74-php-cli',
|
||
'php8.0': 'php8.0-fpm,php8.0-cli,php8.0-mysql,php8.0-curl,php8.0-mbstring,php8.0-xml,php8.0-zip,php8.0-gd' if is_deb else 'php80-php-fpm,php80-php-cli',
|
||
'php8.1': 'php8.1-fpm,php8.1-cli,php8.1-mysql,php8.1-curl,php8.1-mbstring,php8.1-xml,php8.1-zip,php8.1-gd' if is_deb else 'php81-php-fpm,php81-php-cli',
|
||
'php8.2': 'php8.2-fpm,php8.2-cli,php8.2-mysql,php8.2-curl,php8.2-mbstring,php8.2-xml,php8.2-zip,php8.2-gd' if is_deb else 'php82-php-fpm,php82-php-cli',
|
||
'php8.3': 'php8.3-fpm,php8.3-cli,php8.3-mysql,php8.3-curl,php8.3-mbstring,php8.3-xml,php8.3-zip,php8.3-gd' if is_deb else 'php83-php-fpm,php83-php-cli',
|
||
'php8.4': 'php8.4-fpm,php8.4-cli,php8.4-mysql,php8.4-curl,php8.4-mbstring,php8.4-xml,php8.4-zip,php8.4-gd' if is_deb else 'php84-php-fpm,php84-php-cli',
|
||
'phpmyadmin': 'phpmyadmin' if is_deb else 'phpMyAdmin',
|
||
}
|
||
return catalog.get(name)
|
||
|
||
|
||
def setup_phpmyadmin_nginx(task_id=None):
|
||
"""phpMyAdmin 装完后自动配置 Nginx 8443 反代(v1.3.10 新增)
|
||
|
||
写 /etc/nginx/sites-enabled/phpmyadmin.conf + nginx -t + reload
|
||
失败时把错误追加到任务日志(如果有 task_id)
|
||
"""
|
||
# 1. 找 phpMyAdmin 实际路径(Debian/Ubuntu 装完默认在这里)
|
||
candidates = ['/usr/share/phpmyadmin', '/usr/share/phpmyadmin/htdocs']
|
||
pma_dir = None
|
||
for c in candidates:
|
||
if os.path.isdir(c) and os.path.exists(os.path.join(c, 'index.php')):
|
||
pma_dir = c
|
||
break
|
||
if not pma_dir:
|
||
msg = 'setup_phpmyadmin_nginx: 找不到 phpMyAdmin 目录(/usr/share/phpmyadmin 不存在)'
|
||
print(f'[TPanel] {msg}', flush=True)
|
||
if task_id:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?",
|
||
(f'\n\n{msg}', task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
return False
|
||
|
||
# 2. 写 Nginx 配置文件
|
||
conf = f"""# TPanel phpMyAdmin 反代配置(v1.3.10 自动写入)
|
||
# 管理命令:sudo nginx -t && sudo systemctl reload nginx
|
||
server {{
|
||
listen 8443 default_server;
|
||
listen [::]:8443 default_server;
|
||
server_name _;
|
||
|
||
root {pma_dir};
|
||
index index.php index.html;
|
||
|
||
access_log /var/log/nginx/phpmyadmin.access.log;
|
||
error_log /var/log/nginx/phpmyadmin.error.log;
|
||
|
||
# 安全加固:屏蔽 phpMyAdmin 已知信息泄露路径
|
||
location ~* /(libraries|setup/frames|sql) {{
|
||
deny all;
|
||
return 403;
|
||
}}
|
||
|
||
location / {{
|
||
try_files $uri $uri/ /index.php?$args;
|
||
}}
|
||
|
||
location ~ \.php$ {{
|
||
include fastcgi_params;
|
||
fastcgi_pass 127.0.0.1:9000;
|
||
fastcgi_index index.php;
|
||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||
fastcgi_read_timeout 300;
|
||
}}
|
||
}}
|
||
"""
|
||
conf_path = '/etc/nginx/sites-enabled/phpmyadmin.conf'
|
||
try:
|
||
# 写文件用 sudo(tpanel 用户没权限写 /etc/nginx)
|
||
with open('/tmp/phpmyadmin.conf.tmp', 'w') as f:
|
||
f.write(conf)
|
||
r = subprocess.run(['sudo', 'mv', '/tmp/phpmyadmin.conf.tmp', conf_path],
|
||
capture_output=True, text=True, timeout=10)
|
||
if r.returncode != 0:
|
||
raise Exception(f'sudo mv 失败: {r.stderr.strip()}')
|
||
except Exception as e:
|
||
msg = f'setup_phpmyadmin_nginx: 写 {conf_path} 失败: {e}'
|
||
print(f'[TPanel] {msg}', flush=True)
|
||
if task_id:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?",
|
||
(f'\n\n{msg}', task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
return False
|
||
|
||
# 3. nginx -t 验证
|
||
r = subprocess.run(['sudo', 'nginx', '-t'], capture_output=True, text=True, timeout=10)
|
||
if r.returncode != 0:
|
||
msg = f'setup_phpmyadmin_nginx: nginx -t 失败:\n{r.stderr.strip()}'
|
||
print(f'[TPanel] {msg}', flush=True)
|
||
if task_id:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?",
|
||
(f'\n\n{msg}', task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
return False
|
||
|
||
# 4. reload nginx
|
||
r = subprocess.run(['sudo', 'systemctl', 'reload', 'nginx'],
|
||
capture_output=True, text=True, timeout=10)
|
||
if r.returncode != 0:
|
||
# reload 失败就 try restart
|
||
r2 = subprocess.run(['sudo', 'systemctl', 'restart', 'nginx'],
|
||
capture_output=True, text=True, timeout=10)
|
||
if r2.returncode != 0:
|
||
msg = f'setup_phpmyadmin_nginx: nginx reload/restart 失败: {r2.stderr.strip()}'
|
||
print(f'[TPanel] {msg}', flush=True)
|
||
if task_id:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?",
|
||
(f'\n\n{msg}', task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
return False
|
||
|
||
# 5. 确认 8443 端口没被占
|
||
r = subprocess.run(['sudo', 'ss', '-tlnp'], capture_output=True, text=True, timeout=5)
|
||
if ':8443' not in r.stdout:
|
||
msg = 'setup_phpmyadmin_nginx: 警告 - 8443 端口没在监听'
|
||
print(f'[TPanel] {msg}', flush=True)
|
||
# 不算失败,配置已写入
|
||
|
||
success_msg = f'setup_phpmyadmin_nginx: 成功 - {conf_path} 已写入,nginx 已 reload'
|
||
print(f'[TPanel] {success_msg}', flush=True)
|
||
if task_id:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?",
|
||
(f'\n\n{success_msg}', task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
return True
|
||
|
||
|
||
def create_task(task_type, target, cmd, on_complete=None):
|
||
"""创建任务 + 启动后台进程
|
||
|
||
on_complete(v1.3.10 新增):可选回调函数,签名 on_complete(task_id, status)
|
||
在任务结束(success/failed)后、software 表更新后调用。
|
||
用于实现"装完 X 自动配 Y"这种联动。
|
||
"""
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.execute("INSERT INTO tasks (type, target, status) VALUES (?, ?, 'running')",
|
||
(task_type, target))
|
||
task_id = cur.lastrowid
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
def _run():
|
||
try:
|
||
proc = subprocess.Popen(
|
||
cmd, shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, bufsize=1
|
||
)
|
||
log_buffer = []
|
||
for line in iter(proc.stdout.readline, ''):
|
||
line = line.rstrip()
|
||
log_buffer.append(line)
|
||
# 写最新 200 行到 DB
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = ? WHERE id = ?",
|
||
('\n'.join(log_buffer[-200:]), task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
proc.wait()
|
||
status = 'success' if proc.returncode == 0 else 'failed'
|
||
except Exception as e:
|
||
status = 'failed'
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?",
|
||
(f'\n\nERROR: {e}', task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
# on_complete 也要在异常路径上调用(status='failed')
|
||
if on_complete:
|
||
try:
|
||
on_complete(task_id, 'failed')
|
||
except Exception as e2:
|
||
print(f'[TPanel] on_complete 异常: {e2}', flush=True)
|
||
return
|
||
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET status = ?, exit_code = ?, finished_at = ? WHERE id = ?",
|
||
(status, proc.returncode, datetime.now().isoformat(), task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
# 安装成功:更新 software 表
|
||
if status == 'success' and task_type == 'software_install':
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE software SET installed = 1, last_install = ? WHERE name = ?",
|
||
(datetime.now().isoformat(), target))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
# on_complete 钩子(v1.3.10):success/failed 后都调,让钩子自己判断
|
||
if on_complete:
|
||
try:
|
||
on_complete(task_id, status)
|
||
except Exception as e:
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("UPDATE tasks SET log = log || ? WHERE id = ?",
|
||
(f'\n\non_complete 异常: {e}', task_id))
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
t = threading.Thread(target=_run, daemon=True)
|
||
t.start()
|
||
return task_id
|
||
|
||
|
||
def get_task(task_id):
|
||
"""获取任务状态 + 日志"""
|
||
conn = sqlite3.connect(DB_PATH)
|
||
row = conn.execute("""SELECT id, type, target, status, log, started_at, finished_at, exit_code
|
||
FROM tasks WHERE id = ?""", (task_id,)).fetchone()
|
||
conn.close()
|
||
if not row:
|
||
return None
|
||
return {
|
||
'id': row[0], 'type': row[1], 'target': row[2], 'status': row[3],
|
||
'log': row[4] or '', 'started_at': row[5], 'finished_at': row[6],
|
||
'exit_code': row[7]
|
||
}
|
||
|
||
|
||
def get_running_task_by_type(task_type, target=None):
|
||
"""获取正在运行的同类型任务(防并发)"""
|
||
conn = sqlite3.connect(DB_PATH)
|
||
if target is not None:
|
||
row = conn.execute("""SELECT id FROM tasks
|
||
WHERE type = ? AND target = ? AND status = 'running'""",
|
||
(task_type, target)).fetchone()
|
||
else:
|
||
row = conn.execute("""SELECT id FROM tasks
|
||
WHERE type = ? AND status = 'running'""",
|
||
(task_type,)).fetchone()
|
||
conn.close()
|
||
return row[0] if row else None
|
||
|
||
|
||
def cleanup_old_tasks(days=7):
|
||
"""清理 N 天前的已完成任务"""
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.execute("""DELETE FROM tasks
|
||
WHERE status != 'running'
|
||
AND finished_at < datetime('now', ?)""",
|
||
(f'-{days} days',))
|
||
conn.commit()
|
||
conn.close()
|