Compare commits

...

No commits in common. "main" and "v1.3.1" have entirely different histories.
main ... v1.3.1

37 changed files with 589 additions and 12327 deletions

View file

@ -1,40 +0,0 @@
name: Release
on:
push:
tags:
- v*
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create source package
run: |
zip -r tpanel-${{ github.ref_name }}-source.zip . \
-x ".git/*" \
-x ".github/*" \
-x "venv/*" \
-x "sites/*" \
-x "logs/*" \
-x "backups/*" \
-x "data/*" \
-x "ssl/*" \
-x ".well-known/*" \
-x "*.db" \
-x "*.sqlite" \
-x "*.log" \
-x "*.bak"
- name: Create Release
uses: softprops/action-gh-release@v1
with:
name: TPanel ${{ github.ref_name }}
generate_release_notes: true
files: tpanel-${{ github.ref_name }}-source.zip
fail_on_unmatched_filter: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

52
.gitignore vendored
View file

@ -1,52 +0,0 @@
# 数据库
*.db
*.sqlite
*.sqlite3
# 虚拟环境
venv/
env/
__pycache__/
*.pyc
*.pyo
# 日志
logs/
*.log
# 备份
backups/
*.zip
*.tar.gz
*.bak
*.bak.*
*.old
# 站点数据
sites/
data/
# SSL 证书
ssl/
*.pem
*.crt
*.key
# 临时文件
tmp/
*.tmp
.DS_Store
Thumbs.db
# 前端备份
frontend.bak*
*.html.bak*
# 后端备份
backend/*.bak*
backend/*bak*.py
# 其他
.well-known/
vendor/
node_modules/

View file

@ -1,481 +0,0 @@
# TPanel 变更日志(CHANGELOG)
## v1.3.43 (2026-06-28)
### 🆕 新增功能
1. **站点管理** - 操作列新增「强制开启SSL」按钮
- 一键为站点部署 Let's Encrypt 证书
- 自动配置 Nginx HTTPS 301 跳转
- 无需进入 SSL 页面单独配置
- 按钮图标:🔐
### 🐛 Bug 修复
1. **设置页面** - 修复管理员密码修改功能
- 从"开发中"改为真正可用
- 正确调用 /api/auth/change-password API
- 支持当前密码校验
- 新密码强度校验(至少6位)
- 修改成功后自动清空输入框
- 成功/失败状态提示清晰
### 🔧 优化
- SSL 部署按钮状态提示优化
- 密码修改接口返回信息更友好
> **作者**: Zhang Pu
> **官网**: https://tpanel.cn
> **GitHub**: https://github.com/zhang-pu/tpanel
> **协议**: MIT
> **发布周期**: 紧急修复为主,无固定周期
> **版本约定**: v1.3.X 中,X 是累计迭代号;只有经过实机验证的稳定版会发 GitHub Release
> **本日志涵盖**: v1.3.0 (2026-05-30) → v1.3.40 (2026-06-12)
---
## 📦 v1.3.40 — 2026-06-12 【正式发布】
> **重点**: phpMyAdmin 自动登入 + 装包自愈 + dpkg 二次校验
### 🐛 关键修复
1. **phpMyAdmin 装包失败后 files_exist 误判**
- dpkg 标记 `Status: install ok installed` 但 `/usr/share/phpmyadmin` 实际 missing(partial install)
- 前端 status 报"已装"但实际 502 / 找不到目录
- 修:加 `dpkg -V phpmyadmin` 二次校验 + 自动 `apt-get install --reinstall`
2. **setup_phpmyadmin_nginx 缺 self-heal**
- 找不到 pma 目录就 silently return False,没给运维任何提示
- 修:开头加 `dpkg -V` 探测 + `apt-get install --reinstall` 自动重装
- 修:加 `ss -tln | grep :9000` 探测 PHP-FPM listen,不通就 warn 日志(不再 silent 502)
3. **`/api/phpmyadmin/token/<id>` 端点缺失**(v1.3.34 前端依赖)
- 前端点数据库名时调 `/api/phpmyadmin/token/<id>` 拿 5 分钟 HMAC token
- 后端 main.py 一直没实现这个端点 → 404 → 前端 `window.open` 不执行 → 用户点"没反应"
- 修:新增 `api_phpmyadmin_token` + `api_phpmyadmin_signon` 端点
4. **api_phpmyadmin_signon 写 bridge.json Permission denied**
- `/usr/share/phpmyadmin` 是 root 755,tpanel 用户没写权限
- 修:用 `sudo mv /tmp/bridge.json` + `sudo chmod 644` 两步
5. **CONFIG 缺 SECRET_KEY**
- HMAC token 签发需要密钥,config.py 没有
- 修:启动时 `secrets.token_hex(32)` 随机生成 64 字符 hex
### ✨ 新增功能
1. **phpMyAdmin 自动登入**(点数据库名 → 直接进 pma)
- HMAC token 5 分钟有效,绑 db_id
- signon 端点写 `bridge.json`(db_user/db_pass/ts)
- nginx 8443 反代 + 8400 PHP-FPM listen
- `tpanel-bridge.php` 桥接 session → pma 自动用 db_user 登入
- `/etc/phpmyadmin/config.inc.php` 启用 `config` auth,读 signon data 自动填账号
### 📝 教训
- 永远别让 dpkg 静默失败——必须用 `dpkg -V` 二次校验
- v1.3.34 前端加了 token 端点,但后端 main.py 一直没合并过来——半年才被发现
- 写装包脚本必须在干净机器上真跑一遍(这次踩坑 6/10 装包 partial,6/12 才暴露)
---
## 📦 v1.3.39 — 2026-06-11 【开发版,未发 release】
### 🐛 修复
1. **软件卸载改用 `purge` 而非 `remove`**
- `remove` 保留配置(`/etc/nginx/sites-enabled/*.conf` 不删)
- 改 `purge` 完全删干净
2. **加 on_complete 钩子到卸载流程**
- 卸载 PHP 后自动 reload nginx + 改 8848 配置
3. **task_manager dpkg -s 误判修复**
- `dpkg -s` 对 `deinstall ok config-files` 状态返 0,会误判"已装"
- 加 explicit check 区分 installed / config-files
---
## 📦 v1.3.38 — 2026-06-11 【开发版,未发 release】
### 🐛 修复
1. **fastcgi_pass 端口按 PHP 版本动态选**
- 之前硬编码 `127.0.0.1:9000`,多 PHP 版本时 8.1/8.2/8.3 混用
- 修:ssl_manager.py 写 nginx conf 时按 db 的 php_version 字段选对应端口
2. **nginx reload 在 file change 事件触发**
- 修:inotify 等价实现(轮询 mtime)
---
## 📦 v1.3.37 — 2026-06-10 【开发版,未发 release】
### 🐛 修复
1. **on_complete 钩子无 Flask request context**
- 后台线程跑钩子时 `request.remote_addr` 不可用 → 报 AttributeError
- 修:钩子里 `try/except`,None 当 fallback
2. **write_log 接受 None ip**
- 部分场景 ip 是 None 时报 TypeError
---
## 📦 v1.3.36 — 2026-06-09 【开发版,未发 release】
### ✨ 新增
1. **get_installed_php_versions() 函数**
- system.py 新增,扫描 `/usr/bin/php*` + dpkg -l
- 前端创建站点时只列已装 PHP(避免选错导致 502)
2. **建站时 PHP 版本校验**
- 前端二次校验(即使后端也校验了)
- 未装的 PHP 选项 disabled
- 防止用户选 PHP 5.6 装了一半发现 FPM 没起来
---
## 📦 v1.3.35 — 2026-06-08 【正式发布】
> **重点**: 文件管理权限调整
### 🐛 修复
1. **lscpu 在容器/Docker 无 "Model name" 行导致 Unknown CPU**
- 仪表盘显示 `Unknown CPU`
- 修:`/proc/cpuinfo` fallback 读 model name
2. **chmod UI 修复**(前端)
- 数字校验、3 位 0-7 限制、rwx 实时预览
- 暴露 `parsePermMode` 处理 0o755 八进制 vs '755' 字符串两种输入
### 发布
- GitHub Release: `v1.3.35` 文件管理权限调
- 源码包: `/work/tpanel-v1.3.35-source.zip`
---
## 📦 v1.3.34 — 2026-06-07 【开发版,未发 release】
### ✨ 新增
1. **phpMyAdmin Signon 模式前端支持**
- 点数据库名 / 🐘 按钮调 `_openPmaWithAutoLogin(dbId, dbName)`
- 拿 5 分钟 HMAC token → 跳 `/api/phpmyadmin/signon?token=...&db=1`
- 跳 `/tpanel-bridge.php?token=...&db=1` → 302 到 pma
- **后端 main.py 当时没实现 token + signon 端点**(直到 v1.3.40 才补)
2. **改数据库密码前端 UI**
- 🔑 按钮 → 输入新密码 → 调 `/api/databases/<id>/password`
- 后端 v1.3.34 同时实现 change_db_password
---
## 📦 v1.3.33 — 2026-06-08 【正式发布】
### 杂项
- 一些 UI 调整
- cron manager 优化
### 发布
- GitHub Release: `v1.3.33`
---
## 📦 v1.3.30 — 2026-06-08 【正式发布】
> **重点**: 一键安装脚本 v1.3.30(合并 v1.3.22 ~ v1.3.29 累积修复)
### 🐛 合并自 1.3.22 ~ 1.3.29
- v1.3.22 移动端侧边栏 ☰ 按钮
- v1.3.23 phpMyAdmin URL 不再硬编码 127.0.0.1
- v1.3.24 SSL .well-known 查 sqlite 拿真 site_path
- v1.3.25 SSL 申请走任务流 + SSE 进度框
- v1.3.26 站点类型 php|static + 静态 index.html
- v1.3.27 SSE query string 传 token(EventSource 不能自定义 header)
- v1.3.28 add Sury PHP 源
- v1.3.29 on_complete 钩子自动配 PHP-FPM listen + 批量重写 nginx conf 按 PHP 版本
### 发布
- GitHub Release: `v1.3.30`
- 官网: https://tpanel.cn/install.sh 同步更新
---
## 📦 v1.3.14 — 2026-06-07 【正式发布】
> **重点**: `static-check.py` 静态分析工具
### 🐛 问题
v1.3.10 ~ v1.3.13 四轮迭代反复「装机后才发现」—— sandbox 里没 docker/systemd,不能跑完整 install.sh。但**所有 4 轮 bug 都是同一个模式**:「双向闭环」一边配一边调用漏一边。
### ✨ 解法
写个 `static-check.py` 静态分析工具,从源码挖双向闭环 bug:
1. 高危命令 `_run` 裸调(查 `_run(['cmd', ...]` 中 cmd 是否在 DANGEROUS_CMDS 且无 sudo)
2. install.sh sudoers 双向闭环
3. 前端 `/api/` 路由 vs main.py `@app.route` 一致性
4. import 模块存在性
5. 版本号一致性
6. Nginx 端口 vs main.py 监听端口
7. on_complete 钩子函数定义存在
8. phpMyAdmin 反代路径探测逻辑完整
9. Nginx SSE location 含 1800s timeout
10. sudoers 含 `!requiretty`
### 🐛 首跑挖出 v1.3.14 候选 bug
`ssl_manager.py` 108/112/291/295 行裸调 nginx(v1.3.11 漏改)—— 已修复。
### 发布
- `/work/tpanel-v1.3.14-source.zip` (79KB)
- `/work/tpanel-static-check.py` (16KB, md5=a87524aebffff4e81015ef4249bf3c9c)
- `/work/tpanel-v1.3.14-docs.zip` (16KB)
### 📝 教训
写 install.sh 必须在干净 VPS 上真跑一遍,5 分钟的事。考虑加 install-test 自动化测试。
---
## 📦 v1.3.13 — 2026-06-07 【正式发布】
### 🐛 修复
1. **sudo NOPASSWD 实际不生效 → `sudo: a terminal is required to read the password`**
- v1.3.11 硬编码 `/usr/sbin/useradd` 在某些 Debian minimal 镜像上不对
- 被 `Defaults requiretty` 全局设置挡住 NOPASSWD
- 修:install.sh 用 `command -v` 动态探测命令路径
- 修:sudoers 加 `Defaults:tpanel !requiretty` 关键声明
- 修:`visudo -c -f` 语法验证
- 修:装完立刻试跑 NOPASSWD
- 提供 `tpanel-fix-sudo.sh` 紧急补丁脚本
2. **提供 `tpanel-install-test.sh` 装完自检脚本**(6 节检查)
- 系统基本 / sudoers NOPASSWD / Nginx SSE / phpmyadmin 反代 / 端到端 API / 服务健康
---
## 📦 v1.3.12 — 2026-06-07 【正式发布】
### 🐛 修复
1. **SSE 连接断开**(用户装机实测)
- Nginx 默认 `proxy_read_timeout 60s`,apt install/upgrade 静默 30s+ 是常态
- 60s 到点 Nginx 主动断开代理,浏览器 EventSource 看到"连接断开"
- 修:install.sh Nginx 配置为 `/api/tasks/<id>/stream` 加专用 location
- `proxy_read_timeout 1800s` + `proxy_buffering off` + `X-Accel-Buffering: no` + `proxy_cache off`
- 提供 `tpanel-fix-sse.sh` 紧急补丁
---
## 📦 v1.3.11 — 2026-06-07 【正式发布】
### 🐛 修复
1. **全新装机新建站点报 `useradd: Permission denied`**
- v1.3.10 install.sh 只为 mysql 授权 sudoers
- system.py 中 useradd/userdel/chown/chmod/nginx -s reload 全是裸调
- 新建站点第一步创建系统用户就 100% 失败
- 修:install.sh 新增 `/etc/sudoers.d/tpanel-admin`
- NOPASSWD 授权 useradd/userdel/usermod/chown/chmod/nginx/systemctl
- `/usr/sbin/` + `/usr/bin/` 都列上(Debian/CentOS 路径不同)
- 修:system.py 全面加 sudo 前缀
### 📝 教训
凡是要 root 权限的命令(useradd/chown/nginx 等),system.py 写了 sudo 必须配 sudoers;反过来,install.sh 加 sudoers 必须 system.py 真的调了 sudo——两边要对得上
---
## 📦 v1.3.10 — 2026-06-07 【正式发布】
> **重点**: phpMyAdmin on_complete 钩子 + 软件市场 + 任务管理
### 🐛 修复
1. **phpMyAdmin 装完无反代 → 点 🐘 死循环 confirm**
- v1.3.10 装完 phpMyAdmin 后没有自动写 Nginx 8443 反代配置
- `/api/phpmyadmin/status` 永远返回 `nginx_ok=false`
- 前端 `setTimeout(..., 1000)` 跳走再调 status 又触发 confirm
- 修:`setup_phpmyadmin_nginx` 函数实现 + on_complete 钩子
2. **软件市场 + 任务管理**
- software 表 + tasks 表
- `create_task(name, cmd, on_complete=...)` 通用接口
- 实时进度通过 SSE 推前端
- 装完自动调 on_complete 钩子
3. **预检环境**
- `create_site` 前检查:磁盘空间 / 内存 / nginx 状态
### 发布
- `/work/tpanel-v1.3.10-source.zip` (61KB, md5=1b6b3ef02ce05ce6a609899d71b3ed0d)
- tpanel.cn/install.sh 同步更新
---
## 📦 v1.3.9 — 2026-06-06 【正式发布】
> **重点**: super release,合并 6/6 全部修复
### 🐛 关键修复
1. **登录后必须强制刷新才能看到后台**(v1.3.8 时代就有,**用户实测发现**)
- 根因:`showLogin()` 用 `document.body.innerHTML = '...'` 整个重写 body
- 把后台骨架(aside.sidebar + main.main)全部销毁
- 结果:login() 成功后 `initApp() → setupNav()` 找不到 `.nav-item[data-page]`
- `loadDashboard()` 静默失败(getElementById 返 null 被 try/catch 吞掉)
- 现象:登录后页面卡在登录页,必须 Ctrl+Shift+R 才能进后台
- **修法**:登录页改独立 `<div id="loginScreen">`,后台骨架包一层 `<div id="appShell">`
- `showLogin/hideLogin` 改 display 切换
- `logout()` 也改用 showLogin() 而非 location.reload()
### 📝 教训
- **永远别 innerHTML 重写 body**;前端 SPA 登录前后页面元素应该一直在 DOM 里
- **发布前必跑一次完整登录流程**,API 200 不等于 UI 正常
### 发布
- `/work/tpanel-v1.3.9-source.zip` (49KB, md5=69b461098fc2429533dec918f976f0ad)
---
## 📦 v1.3.8 — 2026-06-05 【正式发布】
> **重点**: 8 个隐藏 bug 一次性修
1. **zip 魔数校验**:用 `grep -q "PK\x03\x04"`(grep 文本模式不解析 \x),所有合法 zip 都被误判为 404 HTML
- 改用 `od -An -tx1 -N4` + hex 字符串比较
2. **解压漏复制**:只 `cp backend/ frontend/`,没复制根目录的 `requirements.txt`、`.gitignore` 等文件
3. **`/etc/nginx/tpanel` 权限**:config.py import 时就 `os.makedirs('/etc/nginx/tpanel')`,tpanel 用户无权限
- install.sh 补上 `mkdir -p && chown tpanel:tpanel`
4. **systemd ExecStart 没传端口**:ExecStart 写的是 `python main.py`(没传参)
5. **前端 JS 2 处语法错误**(v1.0.0 时代就有,从未暴露)
6. **PHP-FPM 完全没装**(最大坑!所有建 PHP 站的人全 404)
- install.sh 加 `php8.2-fpm + 扩展` 自动装
7. **PHP-FPM unix socket 在 systemd 环境失效**
- 改用 TCP `127.0.0.1:9000`
8. **MySQL/MariaDB 完全没装 + shell=True SQL 注入**(最危险!)
- install.sh 加 mariadb-server + sudoers
- system.py create_mysql_db/delete_mysql_db 改用 subprocess list + sudo + regex 校验
### 📝 教训
写 install.sh 必须在干净 VPS 上真跑一遍,5 分钟的事。考虑加 install-test 自动化测试。
---
## 📦 v1.3.4 — 2026-06-05 【开发版,未发 release】
3 个 install.sh 隐藏 bug 修复(同 v1.3.8 的 1/2/3 项)
---
## 📦 v1.3.3 — 2026-06-05 【开发版,未发 release】
小修补。
---
## 📦 v1.3.2 — 2026-06-05 【正式发布】
### 🐛 致命 bug
v1.3.1 的 install.sh 用 `grep -q "PK\x03\x04"` 校验 zip 魔数,但 grep 文本模式不解析 \x 转义,所以**永远拒绝所有 zip**,5 个下载源全挂。
### 修法
改用 `od -An -tx1 -N4` + hex 字符串比较(`504b0304`),端到端测试通过。
### 📝 教训
写校验代码必须真实验证,不能"看起来对"。
---
## 📦 v1.3.1 — 2026-06-04 【正式发布】
### ✨ install.sh 健壮性大幅提升(9.5KB)
- 4 个下载源自动回退:Release → latest → tag → main 分支
- zip 文件魔数校验(PK\x03\x04),自动识别 404 HTML 不再解压报错
- 本地兜底:自动扫描 `/tmp/tpanel*.zip`
- 修复:main.py 中 `get_panel_domain` 在空配置下的 500 错误
### 发布
- GitHub Release: `v1.3.1`
- tpanel.cn/install.sh 同步更新
- 源码包: `/work/tpanel-v1.3.1-source.zip` (47KB)
---
## 📦 v1.3.0 — 2026-05-30 【稳定版】
首次正式发版。Python Flask + SQLite + 原生 HTML/CSS/JS 单文件前端。
### 已实现
- 网站管理(增删改查 + nginx conf 自动写)
- 数据库管理(MariaDB)
- SSL 证书(Let's Encrypt 申请 / 续期)
- 备份(本地 + 远程 rsync)
- 文件管理(上传 / 编辑 / 权限)
- 定时任务(cron 增删 + 立即执行)
- 安全(每日 03:00 自动 apt upgrade + UFW 防火墙)
- 域名绑定(限制后台访问来源)
### 设计目标
- 单 VPS 80/443 端口默认站 + 多个 vhost
- 一键安装:`wget -O install.sh https://tpanel.cn/install.sh && bash install.sh`
- 默认账号 `admin / tpanel.cn`,服务路径 `/opt/tpanel`
---
## 📋 待办 / 路线图
### v1.3.44 (2026-06-28)
### 🐛 关键修复
1. **页面刷新后链接点不了** - 强制刷新后必须重新登录的 bug
- 原因:checkAuth() 函数定义了但没被调用
- 修复:在 DOMContentLoaded 中添加 checkAuth() 调用
- 现在刷新后自动验证 token,token 有效直接进入后台,所有功能正常可用
## v1.3.41+ 候选
- **自动注入 pma Signon session 优化**:当前靠 `/tmp/tpanel_signon_data.json` + config auth 凑合
- 下个版本尝试 pma 5.2 原生 `SignonSession` 模式(不用 config auth)
- **多 PHP 版本切换前端**(用户能选 7.4/8.0/8.1/8.2/8.3/8.4)
- **Node.js 支持**(类似 PHP 装包)
- **nginx 日志查看器**(v1.3+)
- **TPanel 密码修改功能**(前端已占位,后端缺实现)
- **打印机故障诊断**(图片识别问题待解决)
### 商业化(Freemium 模式)
- 免费版:全功能使用,页面必须保留作者链接(Powered by TBlog/TPanel)
- 专业版:付费去除链接,解锁高级功能(多站点管理、高级备份、技术支持)
- 技术方案:激活码许可证系统(类似 WordPress/JetBrains)
---
## 🔖 版本号约定
- 末位 +1 = 紧急修复(任何时机)
- 末位 +2 = 累积新功能(每月)
- 主版本不动(v1.3 → v2.0 是大重构)
- 注释里 `# v1.3.X` 必标(这个 changelog 才有依据)
---
**最后更新**: 2026-06-12 16:50 CST
**编辑**: Zhang Pu via OpenClaw MiniMax-M3

View file

@ -3,8 +3,6 @@
🛡️ 安全高效的 Linux 网站管理面板,聚焦建站核心功能,开源免费。 🛡️ 安全高效的 Linux 网站管理面板,聚焦建站核心功能,开源免费。
[![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-v1.3.14-blue.svg)](https://github.com/zhang-pu/tpanel/releases/tag/v1.3.14)
[![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org)
## 特点 ## 特点
@ -14,9 +12,6 @@
- 💾 **备份恢复** - 本地备份、远程 rsync 备份,定时自动执行 - 💾 **备份恢复** - 本地备份、远程 rsync 备份,定时自动执行
- 🛡️ **安全防护** - 站点用户隔离、每日自动安全更新、防火墙规则 - 🛡️ **安全防护** - 站点用户隔离、每日自动安全更新、防火墙规则
- 📁 **文件管理** - 在线浏览、上传、编辑,权限可视化修改 - 📁 **文件管理** - 在线浏览、上传、编辑,权限可视化修改
- 🐘 **phpMyAdmin** - UI 一键安装,8443 端口独立反代,IP 直访,账号复用站点 db_user/db_pass
- 🧩 **多 PHP 版本** - PHP 5.6 ~ 8.3 一键装,切站点时选版本
- ⚡ **软件市场** - 软件列表 + 后台任务流(SSE 实时进度),apt/yum 自动适配
## 系统要求 ## 系统要求
@ -26,16 +21,11 @@
## 安装 ## 安装
一行命令安装(自动适配 Ubuntu / Debian / CentOS):
```bash ```bash
wget -O install.sh https://tpanel.cn/install.sh && bash install.sh wget -O install.sh https://tpanel.cn/install.sh
bash install.sh
``` ```
**v1.3.14** 包含:站点管理、数据库、SSL、备份、文件管理、定时任务、安全防护、CPU 核心数/型号显示、软件市场(PHP 多版本 + phpMyAdmin)、phpMyAdmin 装完自动配 Nginx 8443 反代、sudoers 完整授权(动态路径 + !requiretty + visudo 验证 + 试跑)、SSE 流不断开、装完自检脚本(6 节)、静态分析工具(10 项检查,发布前必跑)。
详见 [CHANGELOG.md](CHANGELOG.md)
安装完成后访问 `https://your-server.com`,默认账号:`admin` / `tpanel.cn` 安装完成后访问 `https://your-server.com`,默认账号:`admin` / `tpanel.cn`
## 技术栈 ## 技术栈

39
SPEC.md
View file

@ -310,45 +310,8 @@ bash install.sh
- 连接测试工具 - 连接测试工具
- 备份统计面板 - 备份统计面板
### v1.3.14(2026-06-07)
- ✅ 修复 ssl_manager.py 裸调 nginx -t / nginx -s reload(v1.3.11 漏改)
- ✅ 新增 tpanel-static-check.py 静态分析工具(10 项检查,不装机能抱 80% 装完才暴露的 bug)
- ✅ 提供 run-static-check.sh 入口脚本,发布前必跑
### v1.3.13(2026-06-07)
- ✅ 修复 sudo NOPASSWD 没生效(requiretty 阻挡 / 命令路径不一致)问题
- ✅ install.sh 用 `command -v` 动态探测真实路径,加 `!requiretty` 声明,加 `visudo -c` 验证,加 NOPASSWD 试跑
- ✅ 提供 `tpanel-fix-sudo.sh` 紧急补丁脚本
- ✅ 提供 `tpanel-install-test.sh` 装完自检脚本(6 节检查覆盖 v1.3.10~v1.3.12 全部隐藏问题)
### v1.3.12(2026-06-07)
- ✅ 修复软件安装 / 安全更新 SSE 流 “连接断开” 问题
- ✅ Nginx 为 `/api/tasks/<id>/stream` 拉专用 location:`proxy_read_timeout 1800s` + `proxy_buffering off` + `X-Accel-Buffering: no`
- ✅ 提供 `tpanel-fix-sse.sh` 紧急补丁脚本,老用户一键修复(只 reload nginx,不动 tpanel 服务)
### v1.3.11(2026-06-07)
- ✅ 修复全新装机后新建站点 `useradd: Permission denied` 的 bug(sudoers 漏授权)
- ✅ install.sh 新增 `/etc/sudoers.d/tpanel-admin`:useradd/userdel/usermod/chown/chmod/nginx/systemctl NOPASSWD
- ✅ system.py 全面加 `sudo` 前缀(useradd/userdel/set_site_permissions 的 chown/chmod/nginx -t + reload/nginx stop + start/apt-get update)
### v1.3.10(2026-06-07)
- ✅ 修复 phpMyAdmin 装完无 Nginx 8443 反代导致点 🐘 死循环 confirm 的 bug
- ✅ `task_manager.create_task` 新增 `on_complete(task_id, status)` 钩子(success/failed 都调,通用联动机制)
- ✅ 新增 `task_manager.setup_phpmyadmin_nginx()`:自动探 PMA 路径 → `sudo mv` 写 `/etc/nginx/sites-enabled/phpmyadmin.conf` → `nginx -t` → `systemctl reload nginx`(含安全加固)
- ✅ `api_phpmyadmin_status` 改三维判断(`sw_installed AND files_exist AND nginx_ok`),返回详细字段
- ✅ 前端 `openPhpMyAdmin` 改轮询 `_pollPhpMyAdminReady(30s)`,等 nginx_ok=true 再跳
### v1.3.9(2026-06-06,super release)
- ✅ 修复登录后必须强制刷新才能看到后台的 bug
- ✅ 仪表盘显示 CPU 核心数 + 型号
- ✅ 负载颜色按核心数判断
### v1.3.x(2026-06-05)
- ✅ PHP 5.6 / 7.0 / 7.4 / 8.0 / 8.1 / 8.2 / 8.3 软件市场一键装
- ✅ phpMyAdmin UI 一键装(之前无反代配置,v1.3.10 修)
- ✅ 软件市场后台任务流(SSE 实时进度)
### v1.3.0(待开发) ### v1.3.0(待开发)
- 多 PHP 版本切换
- Node.js 支持 - Node.js 支持
- 日志查看器(nginx access/error log) - 日志查看器(nginx access/error log)

View file

@ -55,11 +55,8 @@ def get_setting(key, default=''):
return row[0] if row else default return row[0] if row else default
def set_setting(key, value): def set_setting(key, value):
"""v1.3.43+: busy_timeout + WAL 防锁"""
import sqlite3 import sqlite3
conn = sqlite3.connect(DB_PATH, timeout=30) conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
(key, value, value)) (key, value, value))
conn.commit() conn.commit()
@ -87,25 +84,4 @@ def is_domain_allowed(host):
if host_clean in safe_hosts: if host_clean in safe_hosts:
return True return True
return host_clean == allowed_clean or host == allowed return host_clean == allowed_clean or host == allowed
# v1.3.34+: 用于 phpMyAdmin 自动登录 token 签名
_SECRET_FILE = os.path.join(DATA_DIR, ".secret_key")
def get_secret_key():
"""加载或生成 SECRET_KEY(启动时一次,进程内复用)"""
if os.path.exists(_SECRET_FILE):
with open(_SECRET_FILE, "r") as f:
return f.read().strip()
sk = os.urandom(32).hex()
with open(_SECRET_FILE, "w") as f:
f.write(sk)
try:
os.chmod(_SECRET_FILE, 0o600)
import pwd
uid = pwd.getpwnam("tpanel").pw_uid
gid = pwd.getpwnam("tpanel").pw_gid
os.chown(_SECRET_FILE, uid, gid)
except Exception:
pass
return sk
SECRET_KEY = get_secret_key()

View file

@ -35,16 +35,6 @@ def init_db():
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''') )''')
# v1.3.26: 站点类型列(php / static),default 'php'(老站点全为 php)
# 先检查列是否存在,不存在才加(幂等)
cur.execute("PRAGMA table_info(sites)")
cols = {row[1] for row in cur.fetchall()}
if 'site_type' not in cols:
try:
cur.execute("ALTER TABLE sites ADD COLUMN site_type TEXT DEFAULT 'php'")
except Exception:
pass
cur.execute(''' cur.execute('''
CREATE TABLE IF NOT EXISTS databases ( CREATE TABLE IF NOT EXISTS databases (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -106,42 +96,6 @@ def init_db():
value TEXT value TEXT
)''') )''')
# v1.3.10+ 软件市场表
cur.execute('''
CREATE TABLE IF NOT EXISTS software (
name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
category TEXT NOT NULL,
installed INTEGER DEFAULT 0,
version TEXT,
last_check DATETIME,
last_install DATETIME
)''')
# v1.3.10+ 任务表(用于实时进度)
cur.execute('''
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
target TEXT,
status TEXT DEFAULT 'running',
log TEXT DEFAULT '',
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME,
exit_code INTEGER
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS backup_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
enabled INTEGER DEFAULT 0,
schedule TEXT DEFAULT ' 3 * * *',
keep_days INTEGER DEFAULT 7,
backup_dir TEXT DEFAULT '/backup',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
# 默认管理员账号 admin / tpanel.cn # 默认管理员账号 admin / tpanel.cn
cur.execute("SELECT id FROM admin WHERE username = ?", ('admin',)) cur.execute("SELECT id FROM admin WHERE username = ?", ('admin',))
if not cur.fetchone(): if not cur.fetchone():

View file

@ -1,6 +1,5 @@
""" """
TPanel - 文件管理模块 TPanel - 文件管理模块
v1.3.42 修复:支持管理员模式任意目录读写 + sudo提权
""" """
import os import os
import zipfile import zipfile
@ -9,13 +8,10 @@ import shutil
import subprocess import subprocess
from datetime import datetime from datetime import datetime
def _run(cmd, timeout=30, sudo=False): def _run(cmd, timeout=30):
"""执行命令,支持sudo提权"""
try: try:
if isinstance(cmd, str): if isinstance(cmd, str):
cmd = cmd.split() cmd = cmd.split()
if sudo:
cmd = ['sudo', '-n'] + cmd
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.returncode, result.stdout.strip(), result.stderr.strip() return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@ -23,22 +19,13 @@ def _run(cmd, timeout=30, sudo=False):
except Exception as e: except Exception as e:
return -1, '', str(e) return -1, '', str(e)
def list_directory(path, site_user=None, admin_mode=False): def list_directory(path, site_user=None):
"""列出目录内容,带安全和权限信息 """列出目录内容,带安全和权限信息"""
admin_mode=True:允许浏览任意目录(管理员模式)
"""
# 安全检查:防止路径遍历 # 安全检查:防止路径遍历
real_path = os.path.realpath(path) real_path = os.path.realpath(path)
allowed_base = ['/opt/tpanel/sites', '/opt/tpanel/backups'] allowed_base = ['/opt/tpanel/sites', '/opt/tpanel/backups']
if not admin_mode and not any(real_path.startswith(base) for base in allowed_base): if not any(real_path.startswith(base) for base in allowed_base):
return None, '路径不在允许范围内' return None, '路径不在允许范围内'
# 管理员模式下禁止访问系统关键目录
if admin_mode:
blocked_paths = ['/proc', '/sys', '/dev', '/run', '/var/lib/mysql', '/root/.ssh']
for blocked in blocked_paths:
if real_path.startswith(blocked):
return None, '系统关键目录不允许访问'
if not os.path.exists(path): if not os.path.exists(path):
return None, '目录不存在' return None, '目录不存在'
@ -47,15 +34,7 @@ def list_directory(path, site_user=None, admin_mode=False):
try: try:
entries = os.listdir(path) entries = os.listdir(path)
except PermissionError: except PermissionError:
# 管理员模式下无权限尝试sudo return None, '无权限访问'
if admin_mode:
code, stdout, stderr = _run(f'ls -1A {path}', sudo=True)
if code == 0:
entries = stdout.split('\n')
else:
return None, '无权限访问'
else:
return None, '无权限访问'
for name in sorted(entries): for name in sorted(entries):
fp = os.path.join(path, name) fp = os.path.join(path, name)
@ -65,7 +44,8 @@ def list_directory(path, site_user=None, admin_mode=False):
# 文件大小 # 文件大小
if is_dir: if is_dir:
size = 0 size = sum(os.path.getsize(os.path.join(dp, f))
for dp, dn, fn in os.walk(fp) for f in fn) if False else 0
else: else:
size = stat.st_size size = stat.st_size
@ -77,8 +57,8 @@ def list_directory(path, site_user=None, admin_mode=False):
'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'), 'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'),
'permissions': stat.st_mode & 0o777, 'permissions': stat.st_mode & 0o777,
'perm_str': format_permissions(stat.st_mode & 0o777), 'perm_str': format_permissions(stat.st_mode & 0o777),
'readable': os.access(fp, os.R_OK) or admin_mode, 'readable': os.access(fp, os.R_OK),
'writable': os.access(fp, os.W_OK) or admin_mode, 'writable': os.access(fp, os.W_OK),
}) })
except Exception: except Exception:
continue continue
@ -99,104 +79,43 @@ def format_permissions(mode):
chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'] chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7] return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7]
def read_file(path, max_size=50 * 1024 * 1024, admin_mode=False): def read_file(path, max_size=1024 * 1024):
"""读取文件内容(限制1MB) """读取文件内容(限制1MB)"""
admin_mode=True:允许读取任意文本文件 if not os.path.exists(path):
"""
real_path = os.path.realpath(path)
if not os.path.exists(real_path):
return None, '文件不存在' return None, '文件不存在'
if os.path.getsize(path) > max_size:
# 检查文件大小 return None, '文件超过 1MB 限制'
try:
file_size = os.path.getsize(real_path)
except:
if admin_mode:
code, stdout, stderr = _run(f'stat -c %s {real_path}', sudo=True)
if code == 0:
file_size = int(stdout.strip())
else:
return None, '无法获取文件大小'
else:
return None, '无权限读取文件'
if file_size > max_size:
return None, f'文件超过 {max_size//1024}KB 限制'
# 非管理员模式:只允许读取配置文件和常见文本格式 # 只允许读取配置文件和常见文本格式
if not admin_mode: allowed_ext = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md',
allowed_ext = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md', '.yaml', '.yml', '.xml', '.conf', '.ini', '.log', '.sql']
'.yaml', '.yml', '.xml', '.conf', '.ini', '.log', '.sql'] ext = os.path.splitext(path)[1].lower()
ext = os.path.splitext(path)[1].lower() if ext not in allowed_ext and not any(path.endswith(x) for x in ['/config.php', '/.htaccess']):
if ext not in allowed_ext and not any(path.endswith(x) for x in ['/config.php', '/.htaccess']): return None, '文件类型不允许读取'
return None, '文件类型不允许读取'
try: try:
# 尝试普通读取 with open(path, 'r', encoding='utf-8', errors='ignore') as f:
with open(real_path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read(), None return f.read(), None
except PermissionError:
if admin_mode:
# 管理员模式用sudo读取
code, stdout, stderr = _run(f'cat {real_path}', sudo=True)
if code == 0:
return stdout, None
else:
return None, f'读取失败: {stderr}'
else:
return None, '无权限读取文件'
except Exception as e: except Exception as e:
return None, str(e) return None, str(e)
def write_file(path, content, admin_mode=False): def write_file(path, content):
"""写入文件 """写入文件(仅限站点目录)"""
admin_mode=True:允许写入任意路径,自动sudo提权
"""
real_path = os.path.realpath(path) real_path = os.path.realpath(path)
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内' return False, '路径不在允许范围内'
# 管理员模式下禁止写入系统关键文件
if admin_mode:
blocked_paths = ['/proc', '/sys', '/dev', '/run', '/var/lib/mysql', '/root/.ssh', '/etc/sudoers', '/etc/passwd', '/etc/shadow']
for blocked in blocked_paths:
if real_path.startswith(blocked):
return False, '系统关键文件不允许修改'
try: try:
# 先尝试普通写入 with open(path, 'w', encoding='utf-8') as f:
with open(real_path, 'w', encoding='utf-8') as f:
f.write(content) f.write(content)
# 确保站点目录权限正确(非管理员模式)
if not admin_mode and real_path.startswith('/opt/tpanel/sites'):
_run(f'chown tpanel:tpanel {real_path}', sudo=True)
return True, '文件已保存' return True, '文件已保存'
except PermissionError:
if admin_mode or real_path.startswith('/opt/tpanel/sites'):
# 用sudo tee写入
proc = subprocess.run(
['sudo', '-n', 'tee', real_path],
input=content.encode('utf-8'),
capture_output=True,
timeout=10
)
if proc.returncode == 0:
# 确保文件权限正常
_run(f'chmod 644 {real_path}', sudo=True)
return True, '文件已保存'
else:
return False, f'写入失败: {proc.stderr.decode()}'
else:
return False, '无权限写入文件'
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
def upload_file(upload_dir, file_obj, filename, admin_mode=False): def upload_file(upload_dir, file_obj, filename):
"""上传文件到目录 """上传文件到站点目录"""
admin_mode=True:允许上传到任意路径
"""
real_path = os.path.realpath(upload_dir) real_path = os.path.realpath(upload_dir)
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内' return False, '路径不在允许范围内'
# 限制文件类型 # 限制文件类型
@ -212,193 +131,66 @@ def upload_file(upload_dir, file_obj, filename, admin_mode=False):
dest = os.path.join(upload_dir, filename) dest = os.path.join(upload_dir, filename)
try: try:
file_obj.save(dest) file_obj.save(dest)
# 非管理员模式下修正权限 # 自动解压 zip/tar.gz
if not admin_mode: if filename.endswith('.zip'):
_run(f'chown tpanel:tpanel {dest}', sudo=True) try:
with zipfile.ZipFile(dest, 'r') as zf:
zf.extractall(upload_dir)
return True, f'文件已上传并解压:{filename}'
except Exception:
return True, f'文件已上传(解压失败):{filename}'
elif filename.endswith(('.tar.gz', '.tgz')):
try:
with tarfile.open(dest, 'r:gz') as tf:
tf.extractall(upload_dir)
return True, f'文件已上传并解压:{filename}'
except Exception:
return True, f'文件已上传(解压失败):{filename}'
return True, f'文件已上传:{filename}' return True, f'文件已上传:{filename}'
except PermissionError:
if admin_mode or real_path.startswith('/opt/tpanel/sites'):
# 先写到临时文件再sudo移动
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as tmp:
file_obj.save(tmp.name)
tmp_path = tmp.name
code, stdout, stderr = _run(f'mv {tmp_path} {dest}', sudo=True)
if code == 0:
_run(f'chmod 644 {dest}', sudo=True)
return True, f'文件已上传:{filename}'
else:
os.unlink(tmp_path)
return False, f'上传失败: {stderr}'
else:
return False, '无权限上传文件'
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
def delete_file(path, admin_mode=False): def delete_file(path):
"""删除文件或目录 """删除文件或目录"""
admin_mode=True:允许删除任意路径,自动sudo提权
"""
real_path = os.path.realpath(path) real_path = os.path.realpath(path)
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内' return False, '路径不在允许范围内'
# 管理员模式下禁止删除系统关键目录
if admin_mode:
blocked_paths = ['/proc', '/sys', '/dev', '/run', '/var/lib/mysql', '/root/.ssh', '/etc', '/usr', '/bin', '/sbin', '/opt/tpanel/venv', '/opt/tpanel/backend']
for blocked in blocked_paths:
if real_path.startswith(blocked) and real_path != blocked.rstrip('/'):
return False, '系统关键目录不允许删除'
try: try:
if os.path.isdir(path): if os.path.isdir(path):
shutil.rmtree(path) shutil.rmtree(path)
else: else:
os.remove(path) os.remove(path)
return True, '已删除' return True, '已删除'
except PermissionError:
if admin_mode or real_path.startswith('/opt/tpanel/sites'):
if os.path.isdir(path):
code, stdout, stderr = _run(f'rm -rf {path}', sudo=True)
else:
code, stdout, stderr = _run(f'rm -f {path}', sudo=True)
if code == 0:
return True, '已删除'
else:
return False, f'删除失败: {stderr}'
else:
return False, '无权限删除'
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
def chmod_file(path, mode, admin_mode=False): def chmod_file(path, mode):
"""修改文件权限(限制范围) """修改文件权限(限制范围)"""
admin_mode=True:允许修改任意路径权限
"""
real_path = os.path.realpath(path) real_path = os.path.realpath(path)
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内' return False, '路径不在允许范围内'
# 解析权限 # 限制权限范围
try: if mode & 0o777 not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]:
if isinstance(mode, str): return False, '权限值不允许'
mode = int(mode, 8)
elif isinstance(mode, int) and mode < 0o1000:
mode = int(str(mode), 8) if mode < 1000 else mode
except (ValueError, TypeError):
return False, '权限值格式错误(应该是 755、644 这种)'
perm = mode & 0o777
if not admin_mode and perm not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]:
return False, f'权限值不允许({oct(perm)},可选 755/644/600/700/775/664)'
try: try:
os.chmod(path, perm) os.chmod(path, mode & 0o777)
return True, f'权限已修改为 {oct(perm)}' return True, f'权限已修改为 {oct(mode & 0o777)}'
except PermissionError:
if admin_mode or real_path.startswith('/opt/tpanel/sites'):
code, stdout, stderr = _run(f'chmod {oct(perm)[2:]} {path}', sudo=True)
if code == 0:
return True, f'权限已修改为 {oct(perm)}'
else:
return False, f'修改权限失败: {stderr}'
else:
return False, '无权限修改权限'
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
def create_directory(path, dirname, admin_mode=False): def create_directory(path, dirname):
"""创建目录 """创建目录"""
admin_mode=True:允许在任意路径创建目录
"""
real_path = os.path.realpath(path) real_path = os.path.realpath(path)
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'): if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内' return False, '路径不在允许范围内'
new_path = os.path.join(path, dirname) new_path = os.path.join(path, dirname)
try: try:
os.makedirs(new_path, exist_ok=True) os.makedirs(new_path, exist_ok=True)
if not admin_mode:
_run(f'chown -R tpanel:tpanel {new_path}', sudo=True)
return True, f'目录已创建:{dirname}' return True, f'目录已创建:{dirname}'
except PermissionError:
if admin_mode or real_path.startswith('/opt/tpanel/sites'):
code, stdout, stderr = _run(f'mkdir -p {new_path}', sudo=True)
if code == 0:
if not admin_mode:
_run(f'chown -R tpanel:tpanel {new_path}', sudo=True)
return True, f'目录已创建:{dirname}'
else:
return False, f'创建目录失败: {stderr}'
else:
return False, '无权限创建目录'
except Exception as e: except Exception as e:
return False, str(e) return False, str(e)
def extract_archive(archive_path, target_dir, delete_after=False, admin_mode=False):
"""解压压缩包到目标目录
支持 zip / tar / tar.gz / tgz
v1.3.41: 带 zip slip / tar slip 防护
"""
real_archive = os.path.realpath(archive_path)
real_target = os.path.realpath(target_dir)
# 安全:必须在允许的路径下
if not admin_mode and not real_archive.startswith('/opt/tpanel/sites'):
return False, '压缩包路径不在允许范围内'
if not admin_mode and not real_target.startswith('/opt/tpanel/sites'):
return False, '目标路径不在允许范围内'
if not os.path.isfile(real_archive):
return False, '压缩包不存在'
filename = os.path.basename(real_archive).lower()
file_count = 0
try:
if filename.endswith('.zip'):
with zipfile.ZipFile(real_archive, 'r') as zf:
# 防 zip slip: 拒绝 ../ 跳出 target
for member in zf.namelist():
member_path = os.path.realpath(os.path.join(real_target, member))
if not member_path.startswith(real_target):
return False, f'压缩包含非法路径: {member}'
zf.extractall(real_target)
file_count = len(zf.namelist())
elif filename.endswith('.tar.gz') or filename.endswith('.tgz'):
with tarfile.open(real_archive, 'r:gz') as tf:
for member in tf.getmembers():
member_path = os.path.realpath(os.path.join(real_target, member.name))
if not member_path.startswith(real_target):
return False, f'压缩包含非法路径: {member.name}'
tf.extractall(real_target)
file_count = len(tf.getmembers())
elif filename.endswith('.tar'):
with tarfile.open(real_archive, 'r') as tf:
for member in tf.getmembers():
member_path = os.path.realpath(os.path.join(real_target, member.name))
if not member_path.startswith(real_target):
return False, f'压缩包含非法路径: {member.name}'
tf.extractall(real_target)
file_count = len(tf.getmembers())
else:
return False, '仅支持 .zip / .tar.gz / .tgz / .tar 格式'
# 非管理员模式下修正权限
if not admin_mode and real_target.startswith('/opt/tpanel/sites'):
_run(f'chown -R tpanel:tpanel {real_target}', sudo=True)
except zipfile.BadZipFile:
return False, '不是有效的 zip 文件'
except tarfile.ReadError:
return False, '不是有效的 tar 文件'
except PermissionError:
return False, '无权限解压文件'
except Exception as e:
return False, f'解压失败: {str(e)}'
if delete_after:
try:
os.remove(real_archive)
except Exception as e:
return True, f'已解压 {file_count} 个文件(删除压缩包失败: {e})'
return True, f'已解压 {file_count} 个文件到 {os.path.relpath(real_target, "/opt/tpanel/sites") if real_target.startswith("/opt/tpanel/sites") else real_target}'

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
flask==3.0.3
flask-cors==4.0.0
APScheduler==3.10.4
python-dotenv==1.0.1
certbot==2.11.0
bcrypt==4.2.1

View file

@ -10,31 +10,6 @@ from config import DB_PATH, SSL_DIR
LETSENCRYPT_PATH = '/etc/letsencrypt/live' LETSENCRYPT_PATH = '/etc/letsencrypt/live'
def _get_real_site_path(domain, site_id):
"""
v1.3.24 修复:查 sqlite 拿站点的真实 site_path(里面是 zhangpu_tech 之类的下划线版),
这样 certbot 写 challenge 文件的路径才跟 nginx root 指向一致
返回 None 表示找不到(会回退到硬编码的 /opt/tpanel/sites/<domain>/public)
"""
try:
conn = sqlite3.connect(DB_PATH)
if site_id:
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
else:
cur = conn.execute("SELECT site_path FROM sites WHERE domain = ?", (domain,))
row = cur.fetchone()
conn.close()
if row and row[0]:
p = row[0]
# 确保末尾有 /public(site_path 存的可能就是 /public)
if not p.rstrip('/').endswith('/public'):
p = p.rstrip('/') + '/public'
if os.path.isdir(p):
return p
except Exception as e:
print(f'[ssl] _get_real_site_path failed: {e}', flush=True)
return None
def _run(cmd, timeout=120, shell=False): def _run(cmd, timeout=120, shell=False):
try: try:
if isinstance(cmd, str) and not shell: if isinstance(cmd, str) and not shell:
@ -97,11 +72,7 @@ def apply_letsencrypt(site_id, domain):
为站点申请 Let's Encrypt 证书 为站点申请 Let's Encrypt 证书
流程:创建验证目录 → 生成 cert → 部署 nginx 配置 → 写入数据库 流程:创建验证目录 → 生成 cert → 部署 nginx 配置 → 写入数据库
""" """
# v1.3.24 修复:不要再硬编码 /opt/tpanel/sites/<domain>/public site_path = f'/opt/tpanel/sites/{domain}/public'
# 建站时 domain 里的 . 被换成 _(zhangpu.tech → zhangpu_tech),
# certbot 写到 /opt/tpanel/sites/zhangpu.tech/(空目录),
# 但 nginx root 指向 zhangpu_tech/,LE 服务器拉 403
site_path = _get_real_site_path(domain, site_id)
le_dir = os.path.join(SSL_DIR, domain) le_dir = os.path.join(SSL_DIR, domain)
os.makedirs(le_dir, exist_ok=True) os.makedirs(le_dir, exist_ok=True)
@ -127,51 +98,43 @@ server {{
}} }}
''' '''
conf_path = f'/etc/nginx/sites-available/{domain}.ssl.conf' conf_path = f'/etc/nginx/sites-available/{domain}.ssl.conf'
# v1.3.21+:用 sudo mv 写 /etc/nginx/sites-available with open(conf_path, 'w') as f:
tmp_conf = f'/tmp/tpanel_ssl_{domain}.conf'
with open(tmp_conf, 'w') as f:
f.write(nginx_conf) f.write(nginx_conf)
code, out, err = _run(['sudo', 'mv', tmp_conf, conf_path])
if code != 0:
return False, f'写 SSL conf 失败: {err}'
enabled_path = f'/etc/nginx/sites-enabled/{domain}.ssl.conf' enabled_path = f'/etc/nginx/sites-enabled/{domain}.ssl.conf'
if os.path.exists(enabled_path): if not os.path.exists(enabled_path):
_run(['sudo', 'rm', '-f', enabled_path]) os.symlink(conf_path, enabled_path)
_run(['sudo', 'ln', '-sf', conf_path, enabled_path])
code, out, err = _run(['sudo', 'nginx', '-t']) code, out, err = _run(['nginx', '-t'])
if code != 0: if code != 0:
return False, f'Nginx 配置错误: {err}' return False, f'Nginx 配置错误: {err}'
_run(['sudo', 'nginx', '-s', 'reload']) _run(['nginx', '-s', 'reload'])
# 申请证书(standalone 模式 + webroot) # 申请证书(standalone 模式 + webroot)
# v1.3.25 修复:去掉 --cert-path/--key-path/--chain-path 自定义路径
# certbot 会忽略这些路径或写到默认位置(/etc/letsencrypt/live/<domain>/),
# 导致 TPanel 去 /opt/tpanel/ssl/<domain>/ 找时拿不到,报"证书文件未生成"
cmd = [ cmd = [
'sudo', 'certbot', 'certonly', 'certbot', 'certonly',
'--webroot', '--webroot',
'-w', site_path, '-w', site_path,
'-d', domain, '-d', domain,
'--agree-tos', '--agree-tos',
'--non-interactive', '--non-interactive',
'--email', f'admin@{domain}', '--email', f'admin@{domain}',
'--cert-path', os.path.join(le_dir, 'fullchain.pem'),
'--key-path', os.path.join(le_dir, 'privkey.pem'),
'--chain-path', os.path.join(le_dir, 'chain.pem'),
] ]
code, out, err = _run(cmd, timeout=120) code, out, err = _run(cmd, timeout=120)
if code != 0: if code != 0:
# 清理失败配置(v1.3.21+:用 sudo 删软链) # 清理失败配置
if os.path.exists(enabled_path): if os.path.exists(enabled_path):
_run(['sudo', 'rm', '-f', enabled_path]) os.remove(enabled_path)
return False, f'证书申请失败: {err}' return False, f'证书申请失败: {err}'
# v1.3.25: certbot 默认写到 /etc/letsencrypt/live/<domain>/,从那里读 cert_path = os.path.join(le_dir, 'fullchain.pem')
le_live = f'/etc/letsencrypt/live/{domain}' key_path = os.path.join(le_dir, 'privkey.pem')
cert_path = os.path.join(le_live, 'fullchain.pem')
key_path = os.path.join(le_live, 'privkey.pem')
if not os.path.exists(cert_path): if not os.path.exists(cert_path):
return False, '证书文件未生成' return False, '证书文件未生成'
@ -260,18 +223,15 @@ def renew_all_expiring(days_before=30):
def deploy_ssl(domain): def deploy_ssl(domain):
""" """
将已有证书部署到 Nginx(更新 nginx 配置启用 HTTPS) 将已有证书部署到 Nginx(更新 nginx 配置启用 HTTPS)
v1.3.25: 从 /etc/letsencrypt/live/<domain>/ 读证书(certbot 默认位置)
""" """
le_live = f'/etc/letsencrypt/live/{domain}' le_dir = os.path.join(SSL_DIR, domain)
cert_path = os.path.join(le_live, 'fullchain.pem') cert_path = os.path.join(le_dir, 'fullchain.pem')
key_path = os.path.join(le_live, 'privkey.pem') key_path = os.path.join(le_dir, 'privkey.pem')
if not os.path.exists(cert_path) or not os.path.exists(key_path): if not os.path.exists(cert_path) or not os.path.exists(key_path):
return False, '证书文件不存在' return False, '证书文件不存在'
site_path = f'/opt/tpanel/sites/{domain}/public' site_path = f'/opt/tpanel/sites/{domain}/public'
# v1.3.24: 同样查 sqlite 拿真路径
site_path = _get_real_site_path(domain, None) or site_path
# 写入 HTTPS + HTTP 重定向配置 # 写入 HTTPS + HTTP 重定向配置
nginx_conf = f'''# {domain} - HTTPS nginx_conf = f'''# {domain} - HTTPS
@ -302,7 +262,7 @@ server {{
location ~ \\.php$ {{ location ~ \\.php$ {{
include fastcgi_params; include fastcgi_params;
fastcgi_pass 127.0.0.1:9000; fastcgi_pass unix:/run/php/php-fpm8.1.sock;
fastcgi_index index.php; fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}} }}
@ -314,49 +274,37 @@ server {{
''' '''
conf_path = f'/etc/nginx/sites-available/{domain}.conf' conf_path = f'/etc/nginx/sites-available/{domain}.conf'
# v1.3.34 修复:用 sudo rm 清理(前面已经会 rm -f,这里简化) # 清理旧的 SSL 配置
for old_conf in [
f'/etc/nginx/sites-enabled/{domain}.ssl.conf',
f'/etc/nginx/sites-enabled/{domain}.conf',
]:
if os.path.exists(old_conf) and os.path.islink(old_conf):
os.remove(old_conf)
with open(conf_path, 'w') as f: with open(conf_path, 'w') as f:
f.write(nginx_conf) f.write(nginx_conf)
# v1.3.34 修复:用 sudo ln -sf (sites-enabled 目录 root-only 可写) if not os.path.exists(f'/etc/nginx/sites-enabled/{domain}.conf'):
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf' os.symlink(conf_path, f'/etc/nginx/sites-enabled/{domain}.conf')
# 先 rm 旧的(无论是 symlink 还是普通文件)
_run(['sudo', 'rm', '-f', enabled_path])
r = _run(['sudo', 'ln', '-sf', conf_path, enabled_path])
if r[0] != 0:
return False, f'创建 symlink 失败: {r[2]}'
code, out, err = _run(['sudo', 'nginx', '-t']) code, out, err = _run(['nginx', '-t'])
if code != 0: if code != 0:
return False, f'Nginx 配置错误: {err}' return False, f'Nginx 配置错误: {err}'
_run(['sudo', 'nginx', '-s', 'reload']) _run(['nginx', '-s', 'reload'])
# 更新数据库 ssl_enabled + ssl_certs 表 # 更新数据库 ssl_enabled
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,)) cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,))
site_row = cur.fetchone() row = cur.fetchone()
site_id = site_row[0] if site_row else None if row:
if site_id:
conn.execute("UPDATE sites SET ssl_enabled = 1, ssl_cert_path = ?, ssl_key_path = ? WHERE domain = ?", conn.execute("UPDATE sites SET ssl_enabled = 1, ssl_cert_path = ?, ssl_key_path = ? WHERE domain = ?",
(cert_path, key_path, domain)) (cert_path, key_path, domain))
# v1.3.34 修复:必须把证书插到 ssl_certs 表(前端列表才会显示)
info = get_cert_info(cert_path)
expire_date = info["expire_date"] if info else ""
cur2 = conn.execute("SELECT id FROM ssl_certs WHERE domain = ?", (domain,))
existing = cur2.fetchone()
if existing:
conn.execute("UPDATE ssl_certs SET cert_path = ?, key_path = ?, expire_date = ?, auto_renew = 1, site_id = ? WHERE domain = ?",
(cert_path, key_path, expire_date, site_id, domain))
else:
conn.execute("INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) VALUES (?, ?, ?, ?, ?, 1)",
(site_id, domain, cert_path, key_path, expire_date))
conn.commit() conn.commit()
conn.close() conn.close()
return True, "HTTPS 已启用,到期 " + expire_date return True, f'HTTPS 已启用'
def check_certs_status(): def check_certs_status():
""" """

View file

@ -1,71 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""v1.3.41 新增:SSL 证书同步工具(扫 /etc/letsencrypt/live/ 重建 ssl_certs)"""
import os
import sqlite3
import subprocess
from datetime import datetime
DB_PATH = "/opt/tpanel/data/tpanel.db"
SSL_DIR = "/etc/letsencrypt/live"
def sync_ssl_certs():
"""
场景:apply_letsencrypt 申请证书成功但忘了写数据库 / 升级后数据库丢失
返回: (added, updated, skipped, errors_list)
"""
if not os.path.isdir(SSL_DIR):
return (0, 0, 0, ["SSL 目录不存在: " + SSL_DIR])
cert_dirs = [d for d in os.listdir(SSL_DIR)
if os.path.isdir(os.path.join(SSL_DIR, d)) and d != "README"]
added, updated, skipped, errors = 0, 0, 0, []
conn = sqlite3.connect(DB_PATH)
for domain in cert_dirs:
cert_path = SSL_DIR + "/" + domain + "/fullchain.pem"
key_path = SSL_DIR + "/" + domain + "/privkey.pem"
if not (os.path.exists(cert_path) and os.path.exists(key_path)):
skipped += 1
continue
expire_date = None
try:
out = subprocess.run(
["openssl", "x509", "-in", cert_path, "-noout", "-enddate"],
capture_output=True, text=True, timeout=5
)
for line in out.stdout.splitlines():
if "notAfter=" in line:
raw = line.split("=", 1)[1].strip()
dt = datetime.strptime(raw, "%b %d %H:%M:%S %Y %Z")
expire_date = dt.strftime("%Y-%m-%d")
break
except Exception as e:
errors.append(domain + ": 解析证书失败 " + str(e))
continue
cur = conn.execute("SELECT id FROM sites WHERE domain=?", (domain,))
row = cur.fetchone()
site_id = row[0] if row else None
cur = conn.execute("SELECT id FROM ssl_certs WHERE domain=?", (domain,))
existing = cur.fetchone()
if existing:
conn.execute(
"UPDATE ssl_certs SET cert_path=?, key_path=?, expire_date=?, site_id=COALESCE(?, site_id) WHERE id=?",
(cert_path, key_path, expire_date, site_id, existing[0])
)
updated += 1
else:
conn.execute(
"INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) VALUES (?, ?, ?, ?, ?, 1)",
(site_id, domain, cert_path, key_path, expire_date)
)
added += 1
conn.commit()
conn.close()
return (added, updated, skipped, errors)

View file

@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""
TPanel → phpMyAdmin 自动登录桥接同步脚本(v1.3.34)
当数据库 db_pass 修改后调用,把 secret_key + 所有 db 凭证写到
/etc/phpmyadmin/conf.d/tpanel-bridge.json(PHP 端读)
"""
import json
import os
import sys
import sqlite3
import subprocess
import datetime
DB_PATH = '/opt/tpanel/data/tpanel.db'
BRIDGE_FILE = '/etc/phpmyadmin/conf.d/tpanel-bridge.json'
SECRET_FILE = '/opt/tpanel/data/.secret_key'
def sync_bridge():
"""同步所有数据库凭证到 bridge.json"""
if not os.path.exists(SECRET_FILE):
print('SECRET_KEY file missing', file=sys.stderr)
sys.exit(1)
with open(SECRET_FILE, 'r') as f:
secret_key = f.read().strip()
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT id, name, db_user, db_pass FROM databases")
dbs = {}
for row in cur.fetchall():
dbs[str(row[0])] = {
'name': row[1],
'user': row[2],
'pass': row[3],
}
conn.close()
payload = {
'secret_key': secret_key,
'dbs': dbs,
'updated_at': datetime.datetime.now().isoformat(),
}
# 先写到 /tmp(可写),再 sudo mv
tmp = '/tmp/tpanel-bridge.json.tmp'
with open(tmp, 'w') as f:
json.dump(payload, f)
os.chmod(tmp, 0o644)
r = subprocess.run(['sudo', 'mv', tmp, BRIDGE_FILE], capture_output=True, text=True)
if r.returncode != 0:
print(f'mv failed: {r.stderr}', file=sys.stderr)
sys.exit(1)
r = subprocess.run(['sudo', 'chmod', '644', BRIDGE_FILE], capture_output=True)
r = subprocess.run(['sudo', 'chown', 'www-data:www-data', BRIDGE_FILE], capture_output=True)
print(f'synced {len(dbs)} dbs to {BRIDGE_FILE}')
if __name__ == '__main__':
sync_bridge()

File diff suppressed because it is too large Load diff

View file

@ -1,431 +0,0 @@
"""
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:
# v1.3.40.1: 加 timeout 防卡死(v1.3.38 计划中的保护,此处补齐)
try:
r = subprocess.run(['dpkg', '-s', first_pkg], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=3).returncode
except subprocess.TimeoutExpired:
r = 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(force_refresh=False):
"""列出所有软件 + 状态(v1.3.40.1 修复 force_refresh 参数未定义)"""
# 注:force_refresh 参数当前未使用(保留接口),避免 TypeError 500
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()

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map

View file

@ -1,209 +0,0 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}

File diff suppressed because one or more lines are too long

216
install.sh Normal file
View file

@ -0,0 +1,216 @@
#!/bin/bash
# T面板 - 一键安装脚本
# 官网: https://tpanel.cn
# 作者: Zhang Pu
set -e
echo "========================================"
echo " 🌿 T面板 v1.0.0 安装程序"
echo " 官网: https://tpanel.cn"
echo " 作者: Zhang Pu"
echo "========================================"
echo ""
# 检查是否为 root
if [ "$EUID" -ne 0 ]; then
echo "❌ 请使用 root 权限运行此脚本:sudo bash install.sh"
exit 1
fi
# 检测系统
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VER=$VERSION_ID
echo "检测到系统: $PRETTY_NAME"
else
echo "❌ 无法识别系统版本"
exit 1
fi
if [[ "$OS" == "ubuntu" ]] || [[ "$OS" == "debian" ]]; then
PKG_MANAGER="apt-get"
elif [[ "$OS" == "centos" ]] || [[ "$OS" == "rocky" ]] || [[ "$OS" == "alma" ]]; then
PKG_MANAGER="yum"
else
echo "⚠️ 未测试的系统 ($OS),继续但可能出错"
fi
echo ""
echo "==> 1/7 更新软件源并升级系统..."
$PKG_MANAGER update -qq && $PKG_MANAGER upgrade -y
echo "==> 2/7 安装依赖包..."
if command -v nginx &>/dev/null; then
echo " Nginx 已安装,跳过"
else
$PKG_MANAGER install -y nginx
fi
if command -v php &>/dev/null; then
echo " PHP 已安装,跳过"
else
$PKG_MANAGER install -y php php-fpm php-mysql php-mbstring php-xml php-curl php-zip
fi
if command -v mariadb &>/dev/null; then
echo " MySQL 已安装,跳过"
else
$PKG_MANAGER install -y mariadb-server
systemctl enable mariadb
systemctl start mariadb
fi
# Python3、pip 和 venv(Debian/Ubuntu 虚拟环境支持)
$PKG_MANAGER install -y python3 python3-pip python3-venv python3-dev libxml2-dev libxslt1-dev
# certbot
if ! command -v certbot &>/dev/null; then
$PKG_MANAGER install -y certbot python3-certbot-nginx
fi
echo "==> 3/7 创建 T面板 用户和目录..."
useradd -m -s /bin/bash tpanel 2>/dev/null || true
mkdir -p /opt/tpanel
mkdir -p /opt/tpanel/sites
mkdir -p /opt/tpanel/backups
mkdir -p /opt/tpanel/ssl
mkdir -p /opt/tpanel/logs
mkdir -p /opt/tpanel/data
mkdir -p /opt/tpanel/config
# 复制源码
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TPANEL_PKG="/tmp/tpanel-v1.3.0.zip"
TPANEL_URL="https://github.com/zhang-pu/tpanel/releases/latest/download/tpanel-v1.3.0.zip"
if [ -f "$SCRIPT_DIR/backend/main.py" ]; then
echo " 使用本地源码"
cp -r "$SCRIPT_DIR/backend" /opt/tpanel/
cp -r "$SCRIPT_DIR/frontend" /opt/tpanel/
cp "$SCRIPT_DIR/requirements.txt" /opt/tpanel/ 2>/dev/null || true
cp "$SCRIPT_DIR/SPEC.md" /opt/tpanel/ 2>/dev/null || true
echo " 源码已复制到 /opt/tpanel"
else
echo " 本地源码未找到,从 GitHub 下载..."
cd /tmp
curl -sL "$TPANEL_URL" -o "$TPANEL_PKG"
if [ ! -f "$TPANEL_PKG" ]; then
echo "❌ 下载源码失败,请检查网络或手动上传源码"
echo " 可以从 https://github.com/zhang-pu/tpanel/releases 下载"
exit 1
fi
echo " 本地源码未找到,从 GitHub 下载..."
cd /tmp
curl -sL "$TPANEL_URL" -o "$TPANEL_PKG"
if [ ! -f "$TPANEL_PKG" ]; then
echo "❌ 下载源码失败,请检查网络或手动上传源码"
echo " 可以从 https://github.com/zhang-pu/tpanel/releases 下载"
exit 1
fi
unzip -q "$TPANEL_PKG" -d /tmp/
rm -f "$TPANEL_PKG"
# 找到解压出来的目录
TPANEL_SRC=$(find /tmp -maxdepth 1 -name "tpanel*" -type d | head -1)
if [ -z "$TPANEL_SRC" ] || [ ! -f "$TPANEL_SRC/requirements.txt" ]; then
echo "❌ 解压后未找到 requirements.txt,解压目录: $TPANEL_SRC"
ls /tmp/tpanel*/
exit 1
fi
cp -r "$TPANEL_SRC/backend" /opt/tpanel/
cp -r "$TPANEL_SRC/frontend" /opt/tpanel/
cp "$TPANEL_SRC/requirements.txt" /opt/tpanel/
echo " 源码已下载并复制到 /opt/tpanel"
rm -rf "$TPANEL_SRC"
fi
chown -R tpanel:tpanel /opt/tpanel
echo "==> 4/7 安装 Python 依赖..."
cd /opt/tpanel
# 检查 requirements.txt 是否存在
if [ ! -f requirements.txt ]; then
echo "❌ requirements.txt 未找到,复制失败"
ls -la /opt/tpanel/
exit 1
fi
python3 -m venv venv
source venv/bin/activate
pip install -q -r requirements.txt
deactivate
echo "==> 5/7 初始化数据库..."
cd /opt/tpanel/backend
chown -R tpanel:tpanel /opt/tpanel
sudo -u tpanel bash -c "source /opt/tpanel/venv/bin/activate && python3 db_init.py"
echo "==> 6/7 配置 Nginx 反向代理..."
cat > /etc/nginx/sites-available/tpanel.conf << 'EOF'
# TPanel - https://tpanel.cn
server {
listen 80;
server_name localhost;
client_max_body_size 50M;
location / {
proxy_pass http://127.0.0.1:8848;
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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOF
ln -sf /etc/nginx/sites-available/tpanel.conf /etc/nginx/sites-enabled/tpanel.conf
# 删除默认配置
rm -f /etc/nginx/sites-enabled/default
nginx -t && nginx -s reload
echo "==> 7/7 配置 Systemd 服务..."
cat > /etc/systemd/system/tpanel.service << 'EOF'
[Unit]
Description=TPanel - Linux Website Management Panel
Documentation=https://tpanel.cn
After=network.target mariadb.service
[Service]
Type=simple
User=tpanel
Group=tpanel
WorkingDirectory=/opt/tpanel/backend
Environment="PATH=/opt/tpanel/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
ExecStart=/opt/tpanel/venv/bin/python3 main.py 8848
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable tpanel
systemctl start tpanel
echo ""
echo "✅ T面板安装完成!"
echo ""
echo " 访问地址:http://localhost (或服务器 IP)"
echo " 默认账号:admin / tpanel.cn"
echo " 后台端口:8848"
echo ""
echo " 官方网址:https://tpanel.cn"
echo " 作者:Zhang Pu · https://zhangpu.dev"
echo ""
echo " 常用命令:"
echo " systemctl status tpanel # 查看状态"
echo " systemctl restart tpanel # 重启面板"
echo " journalctl -u tpanel -f # 查看日志"
echo ""

View file

@ -2,5 +2,4 @@ flask==3.0.3
flask-cors==4.0.0 flask-cors==4.0.0
APScheduler==3.10.4 APScheduler==3.10.4
python-dotenv==1.0.1 python-dotenv==1.0.1
certbot==2.11.0 certbot==2.11.0
bcrypt==4.2.1

View file

@ -1,108 +0,0 @@
"""
TPanel - T面板 配置模块
"""
import os
import json
BASE_DIR = '/opt/tpanel'
DATA_DIR = os.path.join(BASE_DIR, 'data')
LOG_DIR = os.path.join(BASE_DIR, 'logs')
SITES_DIR = os.path.join(BASE_DIR, 'sites')
BACKUP_DIR = os.path.join(BASE_DIR, 'backups')
SSL_DIR = os.path.join(BASE_DIR, 'ssl')
CONFIG_DIR = os.path.join(BASE_DIR, 'config')
NGINX_CONF_DIR = '/etc/nginx/tpanel'
DB_PATH = os.path.join(DATA_DIR, 'tpanel.db')
# Nginx 配置目录(由 install.sh 创建)
os.makedirs(NGINX_CONF_DIR, exist_ok=True)
os.makedirs(LOG_DIR, exist_ok=True)
os.makedirs(SITES_DIR, exist_ok=True)
os.makedirs(BACKUP_DIR, exist_ok=True)
os.makedirs(SSL_DIR, exist_ok=True)
os.makedirs(CONFIG_DIR, exist_ok=True)
def load_config():
path = os.path.join(CONFIG_DIR, 'tpanel.conf')
if os.path.exists(path):
with open(path, 'r') as f:
return json.load(f)
return {
'panel_port': 8848,
'panel_domain': '',
'php_versions': ['7.4', '8.0', '8.1', '8.2'],
'default_php': '8.1',
'auto_ssl_renew': True,
'backup_retention_days': 7,
'security_auto_update': True,
'firewall_enabled': True,
'ssh_port': 22,
}
def save_config(cfg):
path = os.path.join(CONFIG_DIR, 'tpanel.conf')
with open(path, 'w') as f:
json.dump(cfg, f, indent=2)
def get_setting(key, default=''):
"""从数据库读取设置"""
import sqlite3
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT value FROM settings WHERE key = ?", (key,))
row = cur.fetchone()
conn.close()
return row[0] if row else default
def set_setting(key, value):
import sqlite3
conn = sqlite3.connect(DB_PATH)
conn.execute("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
(key, value, value))
conn.commit()
conn.close()
def get_panel_domain():
"""获取面板绑定的域名,无绑定则返回空字符串"""
return get_setting('panel_domain', '')
def is_domain_allowed(host):
"""检查请求的 Host 是否在允许的域名列表中"""
allowed = get_panel_domain().strip()
if not allowed:
return True # 未绑定域名,不限制
allowed = allowed.lower().strip()
host = host.lower().strip()
# 支持带端口的 host(如 localhost:8848)
host_clean = host.split(':')[0]
allowed_clean = allowed.split(':')[0]
# 也允许 localhost 和 127.0.0.1
safe_hosts = ['localhost', '127.0.0.1', '::1']
if host_clean in safe_hosts:
return True
return host_clean == allowed_clean or host == allowed
# v1.3.34+: 用于 phpMyAdmin 自动登录 token 签名
_SECRET_FILE = os.path.join(DATA_DIR, ".secret_key")
def get_secret_key():
"""加载或生成 SECRET_KEY(启动时一次,进程内复用)"""
if os.path.exists(_SECRET_FILE):
with open(_SECRET_FILE, "r") as f:
return f.read().strip()
sk = os.urandom(32).hex()
with open(_SECRET_FILE, "w") as f:
f.write(sk)
try:
os.chmod(_SECRET_FILE, 0o600)
import pwd
uid = pwd.getpwnam("tpanel").pw_uid
gid = pwd.getpwnam("tpanel").pw_gid
os.chown(_SECRET_FILE, uid, gid)
except Exception:
pass
return sk
SECRET_KEY = get_secret_key()

View file

@ -1,258 +0,0 @@
"""
TPanel - 定时任务管理模块
"""
import os
import sqlite3
import subprocess
from datetime import datetime
from config import DB_PATH
def _run(cmd, timeout=30, shell=False):
try:
if isinstance(cmd, str) and not shell:
cmd = cmd.split()
result = subprocess.run(cmd, capture_output=True, 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 get_all_crons():
"""获取所有定时任务"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("""SELECT c.*, s.domain FROM cron_jobs c
LEFT JOIN sites s ON c.site_id = s.id
ORDER BY c.id DESC""")
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
conn.close()
return rows
def create_cron(site_id, name, schedule, command):
"""
创建定时任务
schedule: cron 表达式,如 "0 3 * * *" (每天3点)
command: 要执行的命令
"""
# 验证 cron 表达式格式
parts = schedule.strip().split()
if len(parts) != 5:
return None, 'Cron 表达式格式错误,需要 5 段:分 时 日 月 周'
# 生成一个唯一文件名
import hashlib
token = hashlib.md5(f'{site_id}{name}{command}{datetime.now()}'.encode()).hexdigest()[:12]
script_name = f'cron_{token}.sh'
# 写入站点目录的 cron 脚本
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT site_user FROM sites WHERE id = ?", (site_id,))
row = cur.fetchone()
conn.close()
if not row:
return None, '站点不存在'
site_user = row[0]
cron_dir = f'/opt/tpanel/sites/{site_user}/.cron'
os.makedirs(cron_dir, exist_ok=True)
script_path = os.path.join(cron_dir, script_name)
with open(script_path, 'w') as f:
f.write(f'#!/bin/bash\n{command}\n')
os.chmod(script_path, 0o755)
# 写入系统 crontab(用 sudo 切换到站点用户执行)
cron_line = f'{schedule} sudo -u {site_user} {script_path} >> /opt/tpanel/logs/cron_{token}.log 2>&1'
# 读取现有 crontab
code, out, err = _run(f'crontab -l 2>/dev/null || echo ""', shell=True)
existing = out if code == 0 else ''
# 检查是否已有同名任务
lines = [l for l in existing.split('\n') if script_name not in l and l.strip()]
lines.append(cron_line)
# 写回 crontab
new_cron = '\n'.join(lines) + '\n'
code, out, err = _run(f'echo "{new_cron}" | crontab -', shell=True, timeout=10)
if code != 0:
os.remove(script_path)
return None, f'Crontab 写入失败: {err}'
# 写入数据库
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("""INSERT INTO cron_jobs (site_id, name, schedule, command)
VALUES (?, ?, ?, ?)""",
(site_id, name, schedule, command))
conn.commit()
cron_id = cur.lastrowid
conn.close()
return cron_id, '定时任务创建成功'
def delete_cron(cron_id):
"""删除定时任务"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT name, command FROM cron_jobs WHERE id = ?", (cron_id,))
row = cur.fetchone()
if not row:
conn.close()
return False, '任务不存在'
name, command = row
# 从 crontab 移除
code, out, err = _run('crontab -l 2>/dev/null || echo ""', shell=True)
if code == 0 and out:
lines = [l for l in out.split('\n') if name not in l and l.strip()]
_run(f'echo "{chr(10).join(lines)}\n" | crontab -', shell=True, timeout=10)
# 删除脚本文件
cron_dir = '/opt/tpanel/sites'
for site_dir in os.listdir('/opt/tpanel/sites'):
script = os.path.join(cron_dir, site_dir, '.cron')
if os.path.exists(script):
for f in os.listdir(script):
if name in f:
try:
os.remove(os.path.join(script, f))
except:
pass
conn.execute("DELETE FROM cron_jobs WHERE id = ?", (cron_id,))
conn.commit()
conn.close()
return True, '任务已删除'
def enable_cron(cron_id, enabled):
"""启用/禁用定时任务"""
conn = sqlite3.connect(DB_PATH)
conn.execute("UPDATE cron_jobs SET enabled = ? WHERE id = ?", (1 if enabled else 0, cron_id))
# 如果禁用,从 crontab 注释掉;如果启用,恢复
cur = conn.execute("SELECT name, schedule, command FROM cron_jobs WHERE id = ?", (cron_id,))
row = cur.fetchone()
conn.close()
if not row:
return False, '任务不存在'
name, schedule, command = row
prefix = '' if enabled else '#'
# 简单处理:重新生成 crontab
# 获取所有启用的任务重新写入
conn2 = sqlite3.connect(DB_PATH)
cur2 = conn2.execute("SELECT name, schedule, command, enabled FROM cron_jobs WHERE enabled = 1")
enabled_rows = cur2.fetchall()
conn2.close()
lines = []
for r in enabled_rows:
n, s, c = r[0], r[1], r[2]
import hashlib
token = hashlib.md5(f'{n}{c}'.encode()).hexdigest()[:12]
lines.append(f'{s} sudo -u {get_site_user_by_name(n)} /opt/tpanel/sites/{get_site_user_by_name(n)}/.cron/cron_{token}.sh >> /opt/tpanel/logs/cron_{token}.log 2>&1')
if enabled:
_run(f'echo "{"".join([l + chr(10) for l in lines])}" | crontab -', shell=True, timeout=10)
return True, f'任务已{"启用" if enabled else "禁用"}'
def get_site_user_by_name(name):
"""根据任务名查找站点用户(辅助)"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT site_user FROM sites LIMIT 1")
row = cur.fetchone()
conn.close()
return row[0] if row else 'tpanel'
def run_cron_now(cron_id):
"""立即执行定时任务(手动触发)"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT site_id, name, command FROM cron_jobs WHERE id = ?", (cron_id,))
row = cur.fetchone()
conn.close()
if not row:
return False, '任务不存在'
site_id, name, command = row
conn2 = sqlite3.connect(DB_PATH)
cur2 = conn2.execute("SELECT site_user FROM sites WHERE id = ?", (site_id,))
row2 = cur2.fetchone()
conn2.close()
if not row2:
return False, '站点不存在'
site_user = row2[0]
# 以站点用户身份执行命令
code, out, err = _run(
f'sudo -u {site_user} bash -c "{command}"',
shell=True, timeout=60
)
# 更新最后执行时间
conn3 = sqlite3.connect(DB_PATH)
conn3.execute("UPDATE cron_jobs SET last_run = ? WHERE id = ?",
(datetime.now().isoformat(), cron_id))
conn3.commit()
conn3.close()
return code == 0, out if code == 0 else err
def validate_cron_expression(expr):
"""验证 cron 表达式是否有效"""
parts = expr.strip().split()
if len(parts) != 5:
return False, '需要 5 段:分 时 日 月 周'
labels = ['分', '时', '日', '月', '周']
ranges = [
(0, 59), # 分: 0-59
(0, 23), # 时: 0-23
(1, 31), # 日: 1-31
(1, 12), # 月: 1-12
(0, 6), # 周: 0-6 (0=周日)
]
for i, (part, (lo, hi)) in enumerate(zip(parts, ranges)):
if part == '*':
continue
if '/' in part:
base, step = part.split('/')
if not step.isdigit():
return False, f'{labels[i]} 步长必须是数字'
continue
if ',' in part:
for p in part.split(','):
try:
v = int(p)
if v < lo or v > hi:
return False, f'{labels[i]} 范围 {lo}-{hi}'
except:
return False, f'{labels[i]} 包含无效值'
continue
if '-' in part:
start, end = part.split('-')
try:
if int(start) < lo or int(end) > hi:
return False, f'{labels[i]} 范围 {lo}-{hi}'
except:
return False, f'{labels[i]} 格式错误'
continue
try:
v = int(part)
if v < lo or v > hi:
return False, f'{labels[i]} 范围 {lo}-{hi}'
except:
return False, f'{labels[i]} 包含无效字符'
return True, '格式正确'

View file

@ -1,146 +0,0 @@
"""
TPanel - 数据库初始化
"""
import sqlite3
import os
import bcrypt
from config import DB_PATH, BASE_DIR
def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
cur.execute('''
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
domain TEXT NOT NULL UNIQUE,
site_user TEXT NOT NULL UNIQUE,
site_path TEXT NOT NULL,
php_version TEXT DEFAULT '8.1',
status TEXT DEFAULT 'running',
ssl_enabled INTEGER DEFAULT 0,
ssl_cert_path TEXT,
ssl_key_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
# v1.3.26: 站点类型列(php / static),default 'php'(老站点全为 php)
# 先检查列是否存在,不存在才加(幂等)
cur.execute("PRAGMA table_info(sites)")
cols = {row[1] for row in cur.fetchall()}
if 'site_type' not in cols:
try:
cur.execute("ALTER TABLE sites ADD COLUMN site_type TEXT DEFAULT 'php'")
except Exception:
pass
cur.execute('''
CREATE TABLE IF NOT EXISTS databases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL UNIQUE,
db_user TEXT NOT NULL UNIQUE,
db_pass TEXT NOT NULL,
charset TEXT DEFAULT 'utf8mb4',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
type TEXT DEFAULT 'local',
file_path TEXT,
size INTEGER,
status TEXT DEFAULT 'success',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS ssl_certs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
domain TEXT NOT NULL,
cert_path TEXT NOT NULL,
key_path TEXT NOT NULL,
expire_date TEXT,
auto_renew INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS cron_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,
schedule TEXT NOT NULL,
command TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
last_run DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS security_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
details TEXT,
ip TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
cur.execute('''
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)''')
# v1.3.10+ 软件市场表
cur.execute('''
CREATE TABLE IF NOT EXISTS software (
name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
category TEXT NOT NULL,
installed INTEGER DEFAULT 0,
version TEXT,
last_check DATETIME,
last_install DATETIME
)''')
# v1.3.10+ 任务表(用于实时进度)
cur.execute('''
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
target TEXT,
status TEXT DEFAULT 'running',
log TEXT DEFAULT '',
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME,
exit_code INTEGER
)''')
# 默认管理员账号 admin / tpanel.cn
cur.execute("SELECT id FROM admin WHERE username = ?", ('admin',))
if not cur.fetchone():
pw_hash = bcrypt.hashpw(b'tpanel.cn', bcrypt.gensalt()).decode()
cur.execute("INSERT INTO admin (username, password_hash) VALUES (?, ?)",
('admin', pw_hash))
conn.commit()
conn.close()
print("[TPanel] 数据库初始化完成")
if __name__ == '__main__':
init_db()

View file

@ -1,204 +0,0 @@
"""
TPanel - 文件管理模块
"""
import os
import zipfile
import tarfile
import shutil
import subprocess
from datetime import datetime
def _run(cmd, timeout=30):
try:
if isinstance(cmd, str):
cmd = cmd.split()
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
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 list_directory(path, site_user=None):
"""列出目录内容,带安全和权限信息"""
# 安全检查:防止路径遍历
real_path = os.path.realpath(path)
allowed_base = ['/opt/tpanel/sites', '/opt/tpanel/backups']
if not any(real_path.startswith(base) for base in allowed_base):
return None, '路径不在允许范围内'
if not os.path.exists(path):
return None, '目录不存在'
items = []
try:
entries = os.listdir(path)
except PermissionError:
return None, '无权限访问'
for name in sorted(entries):
fp = os.path.join(path, name)
try:
stat = os.stat(fp)
is_dir = os.path.isdir(fp)
# 文件大小
if is_dir:
size = sum(os.path.getsize(os.path.join(dp, f))
for dp, dn, fn in os.walk(fp) for f in fn) if False else 0
else:
size = stat.st_size
items.append({
'name': name,
'type': 'dir' if is_dir else 'file',
'size': size,
'size_str': format_size(size),
'modified': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M'),
'permissions': stat.st_mode & 0o777,
'perm_str': format_permissions(stat.st_mode & 0o777),
'readable': os.access(fp, os.R_OK),
'writable': os.access(fp, os.W_OK),
})
except Exception:
continue
return items, None
def format_size(size):
if size < 1024:
return str(size) + ' B'
elif size < 1024 * 1024:
return f'{size / 1024:.1f} KB'
elif size < 1024 * 1024 * 1024:
return f'{size / (1024 * 1024):.1f} MB'
else:
return f'{size / (1024 * 1024 * 1024):.2f} GB'
def format_permissions(mode):
chars = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx']
return chars[(mode >> 6) & 7] + chars[(mode >> 3) & 7] + chars[mode & 7]
def read_file(path, max_size=1024 * 1024):
"""读取文件内容(限制1MB)"""
if not os.path.exists(path):
return None, '文件不存在'
if os.path.getsize(path) > max_size:
return None, '文件超过 1MB 限制'
# 只允许读取配置文件和常见文本格式
allowed_ext = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md',
'.yaml', '.yml', '.xml', '.conf', '.ini', '.log', '.sql']
ext = os.path.splitext(path)[1].lower()
if ext not in allowed_ext and not any(path.endswith(x) for x in ['/config.php', '/.htaccess']):
return None, '文件类型不允许读取'
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read(), None
except Exception as e:
return None, str(e)
def write_file(path, content):
"""写入文件(仅限站点目录)"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
try:
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
return True, '文件已保存'
except Exception as e:
return False, str(e)
def upload_file(upload_dir, file_obj, filename):
"""上传文件到站点目录"""
real_path = os.path.realpath(upload_dir)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
# 限制文件类型
allowed = ['.php', '.html', '.htm', '.css', '.js', '.json', '.txt', '.md',
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico',
'.zip', '.tar', '.gz', '.bz2',
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
'.woff', '.woff2', '.ttf', '.eot']
ext = os.path.splitext(filename)[1].lower()
if ext not in allowed:
return False, f'文件类型 {ext} 不允许上传'
dest = os.path.join(upload_dir, filename)
try:
file_obj.save(dest)
# 自动解压 zip/tar.gz
if filename.endswith('.zip'):
try:
with zipfile.ZipFile(dest, 'r') as zf:
zf.extractall(upload_dir)
return True, f'文件已上传并解压:{filename}'
except Exception:
return True, f'文件已上传(解压失败):{filename}'
elif filename.endswith(('.tar.gz', '.tgz')):
try:
with tarfile.open(dest, 'r:gz') as tf:
tf.extractall(upload_dir)
return True, f'文件已上传并解压:{filename}'
except Exception:
return True, f'文件已上传(解压失败):{filename}'
return True, f'文件已上传:{filename}'
except Exception as e:
return False, str(e)
def delete_file(path):
"""删除文件或目录"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
try:
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
return True, '已删除'
except Exception as e:
return False, str(e)
def chmod_file(path, mode):
"""修改文件权限(限制范围)"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
# 限制权限范围(v1.3.20+:接受 755/644 等十进制字符串)
try:
if isinstance(mode, str):
mode = int(mode, 8) # '755' -> 0o755 = 493
elif isinstance(mode, int) and mode < 0o1000:
# 看起来是 755 这种小数(不是 0o755),自动当八进制解释
mode = int(str(mode), 8) if mode < 1000 else mode
except (ValueError, TypeError):
return False, '权限值格式错误(应该是 755、644 这种)'
if mode & 0o777 not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]:
return False, f'权限值不允许({oct(mode & 0o777)},可选 755/644/600/700/775/664)'
try:
os.chmod(path, mode & 0o777)
return True, f'权限已修改为 {oct(mode & 0o777)}'
except Exception as e:
return False, str(e)
def create_directory(path, dirname):
"""创建目录"""
real_path = os.path.realpath(path)
if not real_path.startswith('/opt/tpanel/sites'):
return False, '路径不在允许范围内'
new_path = os.path.join(path, dirname)
try:
os.makedirs(new_path, exist_ok=True)
return True, f'目录已创建:{dirname}'
except Exception as e:
return False, str(e)

File diff suppressed because it is too large Load diff

View file

@ -1,218 +0,0 @@
"""
TPanel - 远程备份管理(rsync)
"""
import os
import sqlite3
import subprocess
import datetime
from config import DB_PATH
def _run(cmd, timeout=120, shell=False):
try:
if isinstance(cmd, str) and not shell:
cmd = cmd.split()
result = subprocess.run(cmd, capture_output=True, 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 test_rsync_connection(host, port, user, key_path):
"""测试到远程服务器的 rsync 连接"""
if not host or not user:
return False, '主机和用户名不能为空'
extra = ''
if port and str(port) != '22':
extra = f'-e "ssh -p {port}"'
key = f'-i {key_path}' if key_path else ''
cmd = f'ssh -o StrictHostKeyChecking=no {key} {user}@{host} "echo ok" {extra}'
code, out, err = _run(cmd, timeout=15, shell=True)
if code == 0 and 'ok' in out:
return True, '连接成功'
else:
return False, err or '连接失败'
def get_remote_backups(site_id):
"""获取某站点的远程备份列表(通过 rsync 列出远程目录)"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT s.site_user FROM sites s WHERE s.id = ?", (site_id,))
row = cur.fetchone()
conn.close()
if not row:
return [], '站点不存在'
site_user = row[0]
remote_bak_dir = f'/opt/tpanel/backups/{site_user}/'
# 尝试通过 SSH 查看远程备份(需要配置)
# 这里返回空列表,实际使用时由用户配置远程路径
return [], '请配置远程备份服务器'
def run_remote_backup(site_id, remote_host, remote_user, remote_port, remote_path, key_path=None, use_password=False, password=None):
"""
执行远程 rsync 备份
流程:
1. 打包本地站点文件
2. rsync 推送到远程
3. 记录备份日志
"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT site_user, domain FROM sites WHERE id = ?", (site_id,))
row = cur.fetchone()
conn.close()
if not row:
return False, '站点不存在'
site_user, domain = row
site_path = f'/opt/tpanel/sites/{site_user}/'
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
tar_name = f'{domain}_{timestamp}.tar.gz'
local_tar = f'/opt/tpanel/backups/{tar_name}'
# 1. 打包本地文件
try:
import tarfile
with tarfile.open(local_tar, 'w:gz') as tar:
tar.add(site_path, arcname=os.path.basename(site_path))
tar_size = os.path.getsize(local_tar)
except Exception as e:
return False, f'打包失败: {str(e)}'
# 2. 构建 rsync 命令
ssh_cmd = f'ssh -o StrictHostKeyChecking=no -p {remote_port or 22}'
if key_path and os.path.exists(key_path):
ssh_cmd += f' -i {key_path}'
rsync_cmd = [
'rsync', '-avz', '--progress',
'-e', ssh_cmd,
local_tar,
f'{remote_user}@{remote_host}:{remote_path}/{tar_name}'
]
code, out, err = _run(rsync_cmd, timeout=600)
# 删除本地 tar 包(节省空间)
try:
os.remove(local_tar)
except:
pass
if code != 0:
return False, f'rsync 失败: {err}'
# 3. 写入备份记录
conn = sqlite3.connect(DB_PATH)
conn.execute("""INSERT INTO backups (site_id, type, file_path, size, status)
VALUES (?, ?, ?, ?, ?)""",
(site_id, 'remote', f'{remote_host}:{remote_path}/{tar_name}', tar_size, 'success'))
conn.commit()
conn.close()
# 4. 写安全日志
from system import write_log
write_log('remote_backup', f'远程备份 {domain} -> {remote_host}', '')
return True, f'备份成功,已推送至 {remote_host}'
def sync_restore(backup_id, remote_host, remote_user, remote_port, remote_path, key_path=None):
"""
从远程恢复备份到本地
"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT site_id, file_path FROM backups WHERE id = ?", (backup_id,))
row = cur.fetchone()
conn.close()
if not row:
return False, '备份记录不存在'
site_id, remote_file = row
conn2 = sqlite3.connect(DB_PATH)
cur2 = conn2.execute("SELECT site_user, domain FROM sites WHERE id = ?", (site_id,))
row2 = cur2.fetchone()
conn2.close()
if not row2:
return False, '站点不存在'
site_user, domain = row2
local_dir = f'/opt/tpanel/backups/{site_user}'
os.makedirs(local_dir, exist_ok=True)
# rsync 从远程拉回
ssh_cmd = f'ssh -o StrictHostKeyChecking=no -p {remote_port or 22}'
if key_path and os.path.exists(key_path):
ssh_cmd += f' -i {key_path}'
local_tar = os.path.join(local_dir, os.path.basename(remote_file))
rsync_cmd = [
'rsync', '-avz',
'-e', ssh_cmd,
f'{remote_user}@{remote_host}:{remote_path}/{os.path.basename(remote_file)}',
local_dir + '/'
]
code, out, err = _run(rsync_cmd, timeout=600)
if code != 0:
return False, f'拉取失败: {err}'
# 解压恢复
if os.path.exists(local_tar):
import tarfile
try:
site_path = f'/opt/tpanel/sites/{site_user}/'
with tarfile.open(local_tar, 'r:gz') as tar:
tar.extractall('/opt/tpanel/backups/')
os.remove(local_tar)
except Exception as e:
return False, f'解压失败: {str(e)}'
from system import write_log
write_log('restore', f'远程恢复 {domain} from {remote_host}', '')
return True, '恢复成功'
def get_backup_stats():
"""获取备份统计信息"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("""SELECT type, COUNT(*) as cnt, SUM(size) as total_size
FROM backups GROUP BY type""")
rows = cur.fetchall()
conn.close()
total_local = 0
total_remote = 0
count = 0
for r in rows:
if r[0] == 'local':
total_local = r[2] or 0
count += r[1]
elif r[0] == 'remote':
total_remote = r[2] or 0
# 计算备份目录总大小
code, out, _ = _run("du -sm /opt/tpanel/backups 2>/dev/null | awk '{print $1}'", shell=True)
try:
disk_used = int(out.strip()) if out.strip().isdigit() else 0
except:
disk_used = total_local / (1024 * 1024)
return {
'total_backups': count,
'local_size_mb': round(total_local / (1024 * 1024), 1) if total_local else 0,
'remote_size_mb': round(total_remote / (1024 * 1024), 1) if total_remote else 0,
'disk_used_mb': disk_used,
}

View file

@ -1,388 +0,0 @@
"""
TPanel - SSL 证书管理 & 自动续期
"""
import os
import sqlite3
import subprocess
import re
from datetime import datetime, timedelta
from config import DB_PATH, SSL_DIR
LETSENCRYPT_PATH = '/etc/letsencrypt/live'
def _get_real_site_path(domain, site_id):
"""
v1.3.24 修复:查 sqlite 拿站点的真实 site_path(里面是 zhangpu_tech 之类的下划线版),
这样 certbot 写 challenge 文件的路径才跟 nginx root 指向一致
返回 None 表示找不到(会回退到硬编码的 /opt/tpanel/sites/<domain>/public)
"""
try:
conn = sqlite3.connect(DB_PATH)
if site_id:
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
else:
cur = conn.execute("SELECT site_path FROM sites WHERE domain = ?", (domain,))
row = cur.fetchone()
conn.close()
if row and row[0]:
p = row[0]
# 确保末尾有 /public(site_path 存的可能就是 /public)
if not p.rstrip('/').endswith('/public'):
p = p.rstrip('/') + '/public'
if os.path.isdir(p):
return p
except Exception as e:
print(f'[ssl] _get_real_site_path failed: {e}', flush=True)
return None
def _run(cmd, timeout=120, shell=False):
try:
if isinstance(cmd, str) and not shell:
cmd = cmd.split()
result = subprocess.run(cmd, capture_output=True, 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 get_cert_info(cert_path):
"""从 PEM 文件读取证书信息(到期日期等)"""
if not os.path.exists(cert_path):
return None
code, out, err = _run([
'openssl', 'x509', '-in', cert_path,
'-noout', '-dates', '-enddate'
], shell=False)
expire_str = None
if code == 0:
for line in out.split('\n'):
if 'notAfter=' in line:
expire_str = line.split('=')[1].strip()
break
if expire_str:
try:
expire_date = datetime.strptime(expire_str, '%b %d %H:%M:%S %Y %Z')
return {
'expire_date': expire_date.strftime('%Y-%m-%d'),
'days_left': (expire_date - datetime.now()).days,
'expire_raw': expire_str,
}
except Exception:
pass
return {'expire_date': '未知', 'days_left': 0, 'expire_raw': expire_str}
def get_all_certs():
"""获取所有证书(含到期信息)"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT * FROM ssl_certs ORDER BY id DESC")
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
conn.close()
result = []
for cert in rows:
info = get_cert_info(cert['cert_path'])
cert.update(info or {})
result.append(cert)
return result
def apply_letsencrypt(site_id, domain):
"""
为站点申请 Let's Encrypt 证书
流程:创建验证目录 → 生成 cert → 部署 nginx 配置 → 写入数据库
"""
# v1.3.24 修复:不要再硬编码 /opt/tpanel/sites/<domain>/public
# 建站时 domain 里的 . 被换成 _(zhangpu.tech → zhangpu_tech),
# certbot 写到 /opt/tpanel/sites/zhangpu.tech/(空目录),
# 但 nginx root 指向 zhangpu_tech/,LE 服务器拉 403
site_path = _get_real_site_path(domain, site_id)
le_dir = os.path.join(SSL_DIR, domain)
os.makedirs(le_dir, exist_ok=True)
# 写入 HTTP 验证文件到站点目录
well_known = os.path.join(site_path, '.well-known', 'acme-challenge')
os.makedirs(well_known, exist_ok=True)
# 先测试 nginx 配置能访问到验证文件
nginx_conf = f'''# SSL verification - {domain}
server {{
listen 80;
server_name {domain};
root {site_path};
location /.well-known/acme-challenge/ {{
alias {well_known}/;
try_files $uri =404;
}}
location / {{
return 301 https://$host$request_uri;
}}
}}
'''
conf_path = f'/etc/nginx/sites-available/{domain}.ssl.conf'
# v1.3.21+:用 sudo mv 写 /etc/nginx/sites-available
tmp_conf = f'/tmp/tpanel_ssl_{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'写 SSL conf 失败: {err}'
enabled_path = f'/etc/nginx/sites-enabled/{domain}.ssl.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, f'Nginx 配置错误: {err}'
_run(['sudo', 'nginx', '-s', 'reload'])
# 申请证书(standalone 模式 + webroot)
# v1.3.25 修复:去掉 --cert-path/--key-path/--chain-path 自定义路径
# certbot 会忽略这些路径或写到默认位置(/etc/letsencrypt/live/<domain>/),
# 导致 TPanel 去 /opt/tpanel/ssl/<domain>/ 找时拿不到,报"证书文件未生成"
cmd = [
'sudo', 'certbot', 'certonly',
'--webroot',
'-w', site_path,
'-d', domain,
'--agree-tos',
'--non-interactive',
'--email', f'admin@{domain}',
]
code, out, err = _run(cmd, timeout=120)
if code != 0:
# 清理失败配置(v1.3.21+:用 sudo 删软链)
if os.path.exists(enabled_path):
_run(['sudo', 'rm', '-f', enabled_path])
return False, f'证书申请失败: {err}'
# v1.3.25: certbot 默认写到 /etc/letsencrypt/live/<domain>/,从那里读
le_live = f'/etc/letsencrypt/live/{domain}'
cert_path = os.path.join(le_live, 'fullchain.pem')
key_path = os.path.join(le_live, 'privkey.pem')
if not os.path.exists(cert_path):
return False, '证书文件未生成'
# 写入数据库
info = get_cert_info(cert_path)
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("""INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew)
VALUES (?, ?, ?, ?, ?, 1)""",
(site_id, domain, cert_path, key_path, info['expire_date'] if info else ''))
conn.commit()
conn.close()
return True, f'证书申请成功,到期:{info["expire_date"] if info else "未知"}'
def renew_cert(cert_id=None, domain=None):
"""
续期证书(certbot renew)
"""
if cert_id:
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT domain FROM ssl_certs WHERE id = ?", (cert_id,))
row = cur.fetchone()
conn.close()
if row:
domain = row[0]
elif domain:
pass
else:
return False, '请指定证书 ID 或域名'
# certbot renew 只续期 30 天内到期的证书
code, out, err = _run(
['certbot', 'renew', '--cert-name', domain, '--quiet'],
timeout=120
)
if code != 0 and 'No renewals attempted' not in out and 'already valid' not in out:
return False, f'续期失败: {err}'
# 更新到期日期
le_dir = os.path.join(SSL_DIR, domain)
cert_path = os.path.join(le_dir, 'fullchain.pem')
info = get_cert_info(cert_path)
if info:
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("UPDATE ssl_certs SET expire_date = ? WHERE domain = ?",
(info['expire_date'], domain))
conn.commit()
conn.close()
return True, f'证书已续期,新到期:{info["expire_date"] if info else "未知"}'
def renew_all_expiring(days_before=30):
"""
续期所有即将到期的证书(供定时任务调用)
返回:(成功数量, 失败数量, 详情列表)
"""
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT * FROM ssl_certs WHERE auto_renew = 1")
rows = cur.fetchall()
conn.close()
if not rows:
return 0, 0, []
success, fail = 0, []
for row in rows:
cert_id, site_id, domain = row[0], row[1], row[2]
info = get_cert_info(row[3]) # cert_path
# 检查是否在 30 天内到期
if info and info['days_left'] <= days_before:
ok, msg = renew_cert(cert_id=cert_id, domain=domain)
if ok:
success += 1
else:
fail.append(f'{domain}: {msg}')
elif not info or info['days_left'] > days_before:
# 证书已过期或不存在
pass
return success, len(fail), fail
def deploy_ssl(domain):
"""
将已有证书部署到 Nginx(更新 nginx 配置启用 HTTPS)
v1.3.25: 从 /etc/letsencrypt/live/<domain>/ 读证书(certbot 默认位置)
"""
le_live = f'/etc/letsencrypt/live/{domain}'
cert_path = os.path.join(le_live, 'fullchain.pem')
key_path = os.path.join(le_live, 'privkey.pem')
if not os.path.exists(cert_path) or not os.path.exists(key_path):
return False, '证书文件不存在'
site_path = f'/opt/tpanel/sites/{domain}/public'
# v1.3.24: 同样查 sqlite 拿真路径
site_path = _get_real_site_path(domain, None) or site_path
# 写入 HTTPS + HTTP 重定向配置
nginx_conf = f'''# {domain} - HTTPS
server {{
listen 80;
server_name {domain};
return 301 https://$server_name$request_uri;
}}
server {{
listen 443 ssl http2;
server_name {domain};
ssl_certificate {cert_path};
ssl_certificate_key {key_path};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root {site_path};
index index.php index.html;
access_log /opt/tpanel/logs/{domain}.access.log;
error_log /opt/tpanel/logs/{domain}.error.log;
location / {{
try_files $uri $uri/ /index.php?$query_string;
}}
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;
}}
location ~ /\\.ht {{
deny all;
}}
}}
'''
conf_path = f'/etc/nginx/sites-available/{domain}.conf'
# v1.3.34 修复:用 sudo rm 清理(前面已经会 rm -f,这里简化)
with open(conf_path, 'w') as f:
f.write(nginx_conf)
# v1.3.34 修复:用 sudo ln -sf (sites-enabled 目录 root-only 可写)
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf'
# 先 rm 旧的(无论是 symlink 还是普通文件)
_run(['sudo', 'rm', '-f', enabled_path])
r = _run(['sudo', 'ln', '-sf', conf_path, enabled_path])
if r[0] != 0:
return False, f'创建 symlink 失败: {r[2]}'
code, out, err = _run(['sudo', 'nginx', '-t'])
if code != 0:
return False, f'Nginx 配置错误: {err}'
_run(['sudo', 'nginx', '-s', 'reload'])
# 更新数据库 ssl_enabled + ssl_certs 表
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,))
site_row = cur.fetchone()
site_id = site_row[0] if site_row else None
if site_id:
conn.execute("UPDATE sites SET ssl_enabled = 1, ssl_cert_path = ?, ssl_key_path = ? WHERE domain = ?",
(cert_path, key_path, domain))
# v1.3.34 修复:必须把证书插到 ssl_certs 表(前端列表才会显示)
info = get_cert_info(cert_path)
expire_date = info["expire_date"] if info else ""
cur2 = conn.execute("SELECT id FROM ssl_certs WHERE domain = ?", (domain,))
existing = cur2.fetchone()
if existing:
conn.execute("UPDATE ssl_certs SET cert_path = ?, key_path = ?, expire_date = ?, auto_renew = 1, site_id = ? WHERE domain = ?",
(cert_path, key_path, expire_date, site_id, domain))
else:
conn.execute("INSERT INTO ssl_certs (site_id, domain, cert_path, key_path, expire_date, auto_renew) VALUES (?, ?, ?, ?, ?, 1)",
(site_id, domain, cert_path, key_path, expire_date))
conn.commit()
conn.close()
return True, "HTTPS 已启用,到期 " + expire_date
def check_certs_status():
"""
检查所有证书状态,返回统计信息
"""
certs = get_all_certs()
expired = []
expiring = []
valid = []
for cert in certs:
info = get_cert_info(cert['cert_path'])
if info:
days = info['days_left']
if days < 0:
expired.append({**cert, **info})
elif days <= 7:
expiring.append({**cert, **info})
else:
valid.append({**cert, **info})
return {
'total': len(certs),
'valid': len(valid),
'expiring': len(expiring),
'expired': len(expired),
'expiring_list': expiring,
'expired_list': expired,
}

View file

@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""
TPanel → phpMyAdmin 自动登录桥接同步脚本(v1.3.34)
当数据库 db_pass 修改后调用,把 secret_key + 所有 db 凭证写到
/etc/phpmyadmin/conf.d/tpanel-bridge.json(PHP 端读)
"""
import json
import os
import sys
import sqlite3
import subprocess
import datetime
DB_PATH = '/opt/tpanel/data/tpanel.db'
BRIDGE_FILE = '/etc/phpmyadmin/conf.d/tpanel-bridge.json'
SECRET_FILE = '/opt/tpanel/data/.secret_key'
def sync_bridge():
"""同步所有数据库凭证到 bridge.json"""
if not os.path.exists(SECRET_FILE):
print('SECRET_KEY file missing', file=sys.stderr)
sys.exit(1)
with open(SECRET_FILE, 'r') as f:
secret_key = f.read().strip()
conn = sqlite3.connect(DB_PATH)
cur = conn.execute("SELECT id, name, db_user, db_pass FROM databases")
dbs = {}
for row in cur.fetchall():
dbs[str(row[0])] = {
'name': row[1],
'user': row[2],
'pass': row[3],
}
conn.close()
payload = {
'secret_key': secret_key,
'dbs': dbs,
'updated_at': datetime.datetime.now().isoformat(),
}
# 先写到 /tmp(可写),再 sudo mv
tmp = '/tmp/tpanel-bridge.json.tmp'
with open(tmp, 'w') as f:
json.dump(payload, f)
os.chmod(tmp, 0o644)
r = subprocess.run(['sudo', 'mv', tmp, BRIDGE_FILE], capture_output=True, text=True)
if r.returncode != 0:
print(f'mv failed: {r.stderr}', file=sys.stderr)
sys.exit(1)
r = subprocess.run(['sudo', 'chmod', '644', BRIDGE_FILE], capture_output=True)
r = subprocess.run(['sudo', 'chown', 'www-data:www-data', BRIDGE_FILE], capture_output=True)
print(f'synced {len(dbs)} dbs to {BRIDGE_FILE}')
if __name__ == '__main__':
sync_bridge()

View file

@ -1,522 +0,0 @@
"""
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 / {{
{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, "密码修改成功"

View file

@ -1,426 +0,0 @@
"""
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()

File diff suppressed because it is too large Load diff

View file

@ -1,56 +0,0 @@
server {
server_name zhangpu.tech;
client_max_body_size 100M;
location /static/ { alias /opt/tpanel/frontend/; }
# SSE 任务进度流(v1.3.12 修复"连接断开"):默认 proxy_read_timeout 60s 会主动断
location ~ ^/api/tasks/[0-9]+/stream$ {
proxy_pass http://127.0.0.1:8888;
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_read_timeout 1800s;
proxy_send_timeout 1800s;
proxy_buffering off;
proxy_cache off;
add_header X-Accel-Buffering no;
}
# v1.3.34+: phpMyAdmin 通过 /pma/ 路径访问(同域名 SSL,免8443端口)
# 用 rewrite 把 /pma/X 改成内部 /pma/X 然后 alias 指向 PMA 根目录
location /pma/ {
# v1.3.34 修复:用 alias + 不带 rewrite(alias 与 rewrite 互斥)
# nginx 会自动把 /pma/X 映射到 /usr/share/phpmyadmin/X
alias /usr/share/phpmyadmin/;
index index.php;
# 安全加固
location ~ ^/pma/(libraries|setup/frames|sql) { deny all; return 403; }
# PHP 处理
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
# 注意:$request_filename 已经包含 alias 解析后的真实路径
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_read_timeout 300;
}
}
location / {
proxy_pass http://127.0.0.1:8888;
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;
}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/zhangpu.tech/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/zhangpu.tech/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = zhangpu.tech) { return 301 https://$host$request_uri; }
listen 80;
server_name zhangpu.tech;
return 404;
}

View file

@ -1,33 +0,0 @@
#!/bin/bash
# v1.3.34: 升级 nginx 配置加 /pma/ 路径(用于 phpMyAdmin 自动登录)
# 同时移除 8443 独立 server block(避免 SSL 错误)
# 用法: sudo bash update-nginx.sh
set -e
echo "[1/4] 备份当前配置..."
sudo cp /etc/nginx/sites-enabled/tpanel /etc/nginx/sites-enabled/tpanel.bak-v1334-$(date +%s)
echo "[2/4] 替换 /etc/nginx/sites-enabled/tpanel..."
sudo cp tpanel-https-with-pma.conf /etc/nginx/sites-enabled/tpanel
# 也删掉旧的 8443 server(避免 SSL 问题)
if [ -f /etc/nginx/sites-enabled/phpmyadmin.conf ]; then
sudo rm /etc/nginx/sites-enabled/phpmyadmin.conf
echo " 移除了旧的 /etc/nginx/sites-enabled/phpmyadmin.conf"
fi
echo "[3/4] 部署 phpMyAdmin 桥接脚本..."
if [ ! -f php-bridge/tpanel-bridge.php ]; then
echo "ERROR: php-bridge/tpanel-bridge.php 不存在"
exit 1
fi
sudo cp php-bridge/tpanel-bridge.php /usr/share/phpmyadmin/
sudo chown www-data:www-data /usr/share/phpmyadmin/tpanel-bridge.php
sudo cp php-bridge/tpanel-signon.php /etc/phpmyadmin/conf.d/
php -l /etc/phpmyadmin/conf.d/tpanel-signon.php
echo "[4/4] nginx -t + reload..."
sudo nginx -t && sudo systemctl reload nginx
echo ""
echo "✅ v1.3.34 /pma/ 路径已生效!"
echo "测试: curl -sI https://你的域名/pma/ 应该返回 302 或 200"

View file

@ -1,35 +0,0 @@
# TPanel phpMyAdmin 自动登录桥接(v1.3.34+)
## 文件说明
| 文件 | 安装到 | 用途 |
|------|--------|------|
| `tpanel-bridge.php` | `/usr/share/phpmyadmin/tpanel-bridge.php` | Signon 端点:验 token + 启动 PHP session + 302 回 phpMyAdmin |
| `tpanel-signon.php` | `/etc/phpmyadmin/conf.d/tpanel-signon.php` | phpMyAdmin 配置:auth_type=signon + SignonSession=TPanelSignon + SignonURL 指向 bridge |
## 部署时机
由 `task_manager.setup_phpmyadmin_nginx` 在 phpMyAdmin 安装完成后自动部署。
不需要用户手动操作。
## 工作流程
```
点 db_name
↓
前端 GET /api/phpmyadmin/token/<db_id>
↓
后端签 5 分钟有效 HMAC token
↓
window.open("/api/phpmyadmin/signon?token=xxx&db=1")
↓
后端 302 到 /tpanel-bridge.php?token=xxx&db=1
↓
PHP bridge 验 token + 查 bridge.json 拿 db_user/db_pass
↓
session_start() + 设置 $_SESSION[PMA_single_signon_*]
↓
302 到 phpMyAdmin (自动登录)
```

View file

@ -1,114 +0,0 @@
<?php
/**
* TPanel phpMyAdmin 自动登录桥接 (v1.3.34+)
*
* phpMyAdmin Signon 模式要求 SignonURL 是一个 PHP 脚本:
* 1. 接收 ?token=<HMAC> & db=<id>
* 2. 验证 token(用 TPanel SECRET_KEY 同样的 HMAC 算法)
* 3. 从共享 JSON 文件拿 db_user / db_pass(TPanel 后端写,PHP 读)
* 4. session_start() + 设置 PMA_single_signon_* + 302 回 phpMyAdmin
*
* 安全:
* - SECRET_FILE 由 TPanel 后端 0600 tpanel:tpanel 拥有
* - 本脚本以 www-data 运行,需 sudo-less 读 tpanel.data 文件
* - 改用:把 secrets 写到 /etc/phpmyadmin/conf.d/tpanel-bridge.json 让 PHP 读
*/
declare(strict_types=1);
// 不显示 warning(生产友好)
error_reporting(E_ERROR | E_PARSE);
// 1. 读参数
$token = $_GET['token'] ?? '';
$db_id = $_GET['db'] ?? '';
if ($token === '' || $db_id === '' || !ctype_digit((string)$db_id)) {
http_response_code(400);
echo 'Missing token or db';
exit;
}
// 2. 验证 token - 用 HMAC-SHA256,secret 从 bridge.json 读
$bridge_cfg = '/etc/phpmyadmin/conf.d/tpanel-bridge.json';
if (!file_exists($bridge_cfg)) {
http_response_code(500);
echo 'Bridge not configured';
exit;
}
$cfg = json_decode(file_get_contents($bridge_cfg), true);
if (!is_array($cfg) || !isset($cfg['secret_key'])) {
http_response_code(500);
echo 'Bridge misconfigured';
exit;
}
$secret = $cfg['secret_key'];
// Token 格式: <payload_b64>.<sig_b64> (base64 + base64 padding 都保留)
$parts = explode('.', $token);
if (count($parts) !== 2) {
http_response_code(400);
echo 'Bad token';
exit;
}
[$payload_b64, $sig_b64] = $parts;
$expected = hash_hmac('sha256', $payload_b64, $secret);
$expected_b64 = rtrim(strtr(base64_encode(hex2bin($expected)), '+/', '-_'), '=');
// 补回 base64 padding(v1.3.34 修复:Python 签时带 padding,PHP 验时不 rstrip)
$pad = strlen($expected_b64) % 4;
if ($pad) { $expected_b64 .= str_repeat('=', 4 - $pad); }
if (!hash_equals($expected_b64, $sig_b64)) {
http_response_code(403);
echo 'Invalid token signature';
exit;
}
// 解码 payload(payload 自己也可能带 padding)
$payload_b64_padded = $payload_b64 . str_repeat('=', (-strlen($payload_b64)) % 4);
$payload_json = base64_decode(strtr($payload_b64_padded, '-_', '+/'), true);
if ($payload_json === false) {
http_response_code(400);
echo 'Bad payload';
exit;
}
$payload = json_decode($payload_json, true);
if (!is_array($payload) || !isset($payload['db_id'], $payload['exp'])) {
http_response_code(400);
echo 'Bad payload fields';
exit;
}
if ((int)$payload['db_id'] !== (int)$db_id) {
http_response_code(403);
echo 'DB id mismatch';
exit;
}
if ((int)$payload['exp'] < time()) {
http_response_code(403);
echo 'Token expired';
exit;
}
// 3. 拿 db_user / db_pass - 从 bridge.json 里读(TPanel 后端更新它)
if (!isset($cfg['dbs'][$db_id])) {
http_response_code(404);
echo 'DB not in bridge';
exit;
}
$db = $cfg['dbs'][$db_id];
// 4. 启动 PHP session,配置 session 名(与 conf.d/tpanel-signon.php 一致)
session_name('TPanelSignon');
session_start();
$_SESSION['PMA_single_signon_user'] = $db['user'];
$_SESSION['PMA_single_signon_password'] = $db['pass'];
$_SESSION['PMA_single_signon_host'] = '127.0.0.1';
$_SESSION['PMA_single_signon_port'] = '';
$_SESSION['PMA_single_signon_socket'] = '';
$_SESSION['PMA_single_signon_auth_type'] = 'config';
// 关 session + 302 回 phpMyAdmin
$db_name = $db['name'];
session_write_close();
header('Location: https://zhangpu.tech/pma/index.php?db=' . urlencode($db_name));
exit;

View file

@ -1,7 +0,0 @@
<?php
// TPanel phpMyAdmin 自动登录配置(v1.3.34)
// 用 /pma/ 路径走主域名 SSL,不再用 8443 端口
$cfg["Servers"][1]["auth_type"] = "signon";
$cfg["Servers"][1]["SignonSession"] = "TPanelSignon";
$cfg["Servers"][1]["SignonURL"] = "https://zhangpu.tech/pma/tpanel-bridge.php";
$cfg["Servers"][1]["LogoutURL"] = "https://zhangpu.tech/dashboard";