mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-02 16:29:29 +08:00
Compare commits
No commits in common. "v1.3.33" and "main" have entirely different histories.
37 changed files with 12327 additions and 589 deletions
40
.github/workflows/release.yml
vendored
Normal file
40
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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
Normal file
52
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# 数据库
|
||||
*.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/
|
||||
481
CHANGELOG.md
Normal file
481
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
# 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
|
||||
14
README.md
14
README.md
|
|
@ -3,6 +3,8 @@
|
|||
🛡️ 安全高效的 Linux 网站管理面板,聚焦建站核心功能,开源免费。
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/zhang-pu/tpanel/releases/tag/v1.3.14)
|
||||
[](https://www.python.org)
|
||||
|
||||
## 特点
|
||||
|
||||
|
|
@ -12,6 +14,9 @@
|
|||
- 💾 **备份恢复** - 本地备份、远程 rsync 备份,定时自动执行
|
||||
- 🛡️ **安全防护** - 站点用户隔离、每日自动安全更新、防火墙规则
|
||||
- 📁 **文件管理** - 在线浏览、上传、编辑,权限可视化修改
|
||||
- 🐘 **phpMyAdmin** - UI 一键安装,8443 端口独立反代,IP 直访,账号复用站点 db_user/db_pass
|
||||
- 🧩 **多 PHP 版本** - PHP 5.6 ~ 8.3 一键装,切站点时选版本
|
||||
- ⚡ **软件市场** - 软件列表 + 后台任务流(SSE 实时进度),apt/yum 自动适配
|
||||
|
||||
## 系统要求
|
||||
|
||||
|
|
@ -21,11 +26,16 @@
|
|||
|
||||
## 安装
|
||||
|
||||
一行命令安装(自动适配 Ubuntu / Debian / CentOS):
|
||||
|
||||
```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`
|
||||
|
||||
## 技术栈
|
||||
|
|
|
|||
39
SPEC.md
39
SPEC.md
|
|
@ -310,8 +310,45 @@ 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(待开发)
|
||||
- 多 PHP 版本切换
|
||||
- Node.js 支持
|
||||
- 日志查看器(nginx access/error log)
|
||||
|
||||
|
|
|
|||
|
|
@ -55,8 +55,11 @@ def get_setting(key, default=''):
|
|||
return row[0] if row else default
|
||||
|
||||
def set_setting(key, value):
|
||||
"""v1.3.43+: busy_timeout + WAL 防锁"""
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30)
|
||||
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 = ?",
|
||||
(key, value, value))
|
||||
conn.commit()
|
||||
|
|
@ -85,3 +88,24 @@ def is_domain_allowed(host):
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ def init_db():
|
|||
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,
|
||||
|
|
@ -96,6 +106,42 @@ def init_db():
|
|||
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
|
||||
cur.execute("SELECT id FROM admin WHERE username = ?", ('admin',))
|
||||
if not cur.fetchone():
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""
|
||||
TPanel - 文件管理模块
|
||||
v1.3.42 修复:支持管理员模式任意目录读写 + sudo提权
|
||||
"""
|
||||
import os
|
||||
import zipfile
|
||||
|
|
@ -8,10 +9,13 @@ import shutil
|
|||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
def _run(cmd, timeout=30):
|
||||
def _run(cmd, timeout=30, sudo=False):
|
||||
"""执行命令,支持sudo提权"""
|
||||
try:
|
||||
if isinstance(cmd, str):
|
||||
cmd = cmd.split()
|
||||
if sudo:
|
||||
cmd = ['sudo', '-n'] + cmd
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
|
|
@ -19,14 +23,23 @@ def _run(cmd, timeout=30):
|
|||
except Exception as e:
|
||||
return -1, '', str(e)
|
||||
|
||||
def list_directory(path, site_user=None):
|
||||
"""列出目录内容,带安全和权限信息"""
|
||||
def list_directory(path, site_user=None, admin_mode=False):
|
||||
"""列出目录内容,带安全和权限信息
|
||||
admin_mode=True:允许浏览任意目录(管理员模式)
|
||||
"""
|
||||
# 安全检查:防止路径遍历
|
||||
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):
|
||||
if not admin_mode and not any(real_path.startswith(base) for base in allowed_base):
|
||||
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):
|
||||
return None, '目录不存在'
|
||||
|
||||
|
|
@ -34,7 +47,15 @@ def list_directory(path, site_user=None):
|
|||
try:
|
||||
entries = os.listdir(path)
|
||||
except PermissionError:
|
||||
return None, '无权限访问'
|
||||
# 管理员模式下无权限尝试sudo
|
||||
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):
|
||||
fp = os.path.join(path, name)
|
||||
|
|
@ -44,8 +65,7 @@ def list_directory(path, site_user=None):
|
|||
|
||||
# 文件大小
|
||||
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
|
||||
size = 0
|
||||
else:
|
||||
size = stat.st_size
|
||||
|
||||
|
|
@ -57,8 +77,8 @@ def list_directory(path, site_user=None):
|
|||
'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),
|
||||
'readable': os.access(fp, os.R_OK) or admin_mode,
|
||||
'writable': os.access(fp, os.W_OK) or admin_mode,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
|
@ -79,43 +99,104 @@ 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):
|
||||
def read_file(path, max_size=50 * 1024 * 1024, admin_mode=False):
|
||||
"""读取文件内容(限制1MB)
|
||||
admin_mode=True:允许读取任意文本文件
|
||||
"""
|
||||
real_path = os.path.realpath(path)
|
||||
if not os.path.exists(real_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:
|
||||
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',
|
||||
'.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:
|
||||
# 尝试普通读取
|
||||
with open(real_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
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:
|
||||
return None, str(e)
|
||||
|
||||
def write_file(path, content):
|
||||
"""写入文件(仅限站点目录)"""
|
||||
def write_file(path, content, admin_mode=False):
|
||||
"""写入文件
|
||||
admin_mode=True:允许写入任意路径,自动sudo提权
|
||||
"""
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith('/opt/tpanel/sites'):
|
||||
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'):
|
||||
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:
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
# 先尝试普通写入
|
||||
with open(real_path, 'w', encoding='utf-8') as f:
|
||||
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, '文件已保存'
|
||||
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:
|
||||
return False, str(e)
|
||||
|
||||
def upload_file(upload_dir, file_obj, filename):
|
||||
"""上传文件到站点目录"""
|
||||
def upload_file(upload_dir, file_obj, filename, admin_mode=False):
|
||||
"""上传文件到目录
|
||||
admin_mode=True:允许上传到任意路径
|
||||
"""
|
||||
real_path = os.path.realpath(upload_dir)
|
||||
if not real_path.startswith('/opt/tpanel/sites'):
|
||||
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'):
|
||||
return False, '路径不在允许范围内'
|
||||
|
||||
# 限制文件类型
|
||||
|
|
@ -131,66 +212,193 @@ def upload_file(upload_dir, file_obj, filename):
|
|||
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}'
|
||||
|
||||
# 非管理员模式下修正权限
|
||||
if not admin_mode:
|
||||
_run(f'chown tpanel:tpanel {dest}', sudo=True)
|
||||
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:
|
||||
return False, str(e)
|
||||
|
||||
def delete_file(path):
|
||||
"""删除文件或目录"""
|
||||
def delete_file(path, admin_mode=False):
|
||||
"""删除文件或目录
|
||||
admin_mode=True:允许删除任意路径,自动sudo提权
|
||||
"""
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith('/opt/tpanel/sites'):
|
||||
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'):
|
||||
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:
|
||||
if os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
os.remove(path)
|
||||
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:
|
||||
return False, str(e)
|
||||
|
||||
def chmod_file(path, mode):
|
||||
"""修改文件权限(限制范围)"""
|
||||
def chmod_file(path, mode, admin_mode=False):
|
||||
"""修改文件权限(限制范围)
|
||||
admin_mode=True:允许修改任意路径权限
|
||||
"""
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith('/opt/tpanel/sites'):
|
||||
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'):
|
||||
return False, '路径不在允许范围内'
|
||||
|
||||
# 限制权限范围
|
||||
if mode & 0o777 not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]:
|
||||
return False, '权限值不允许'
|
||||
# 解析权限
|
||||
try:
|
||||
if isinstance(mode, str):
|
||||
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:
|
||||
os.chmod(path, mode & 0o777)
|
||||
return True, f'权限已修改为 {oct(mode & 0o777)}'
|
||||
os.chmod(path, perm)
|
||||
return True, f'权限已修改为 {oct(perm)}'
|
||||
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:
|
||||
return False, str(e)
|
||||
|
||||
def create_directory(path, dirname):
|
||||
"""创建目录"""
|
||||
def create_directory(path, dirname, admin_mode=False):
|
||||
"""创建目录
|
||||
admin_mode=True:允许在任意路径创建目录
|
||||
"""
|
||||
real_path = os.path.realpath(path)
|
||||
if not real_path.startswith('/opt/tpanel/sites'):
|
||||
if not admin_mode and not real_path.startswith('/opt/tpanel/sites'):
|
||||
return False, '路径不在允许范围内'
|
||||
|
||||
new_path = os.path.join(path, dirname)
|
||||
try:
|
||||
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}'
|
||||
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:
|
||||
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}'
|
||||
|
|
|
|||
1124
backend/main.py
1124
backend/main.py
File diff suppressed because it is too large
Load diff
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
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
|
||||
|
|
@ -10,6 +10,31 @@ 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:
|
||||
|
|
@ -72,7 +97,11 @@ def apply_letsencrypt(site_id, domain):
|
|||
为站点申请 Let's Encrypt 证书
|
||||
流程:创建验证目录 → 生成 cert → 部署 nginx 配置 → 写入数据库
|
||||
"""
|
||||
site_path = f'/opt/tpanel/sites/{domain}/public'
|
||||
# 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)
|
||||
|
||||
|
|
@ -98,43 +127,51 @@ server {{
|
|||
}}
|
||||
'''
|
||||
conf_path = f'/etc/nginx/sites-available/{domain}.ssl.conf'
|
||||
with open(conf_path, 'w') as f:
|
||||
# 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 not os.path.exists(enabled_path):
|
||||
os.symlink(conf_path, enabled_path)
|
||||
if os.path.exists(enabled_path):
|
||||
_run(['sudo', 'rm', '-f', enabled_path])
|
||||
_run(['sudo', 'ln', '-sf', conf_path, enabled_path])
|
||||
|
||||
code, out, err = _run(['nginx', '-t'])
|
||||
code, out, err = _run(['sudo', 'nginx', '-t'])
|
||||
if code != 0:
|
||||
return False, f'Nginx 配置错误: {err}'
|
||||
|
||||
_run(['nginx', '-s', 'reload'])
|
||||
_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 = [
|
||||
'certbot', 'certonly',
|
||||
'sudo', 'certbot', 'certonly',
|
||||
'--webroot',
|
||||
'-w', site_path,
|
||||
'-d', domain,
|
||||
'--agree-tos',
|
||||
'--non-interactive',
|
||||
'--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)
|
||||
|
||||
if code != 0:
|
||||
# 清理失败配置
|
||||
# 清理失败配置(v1.3.21+:用 sudo 删软链)
|
||||
if os.path.exists(enabled_path):
|
||||
os.remove(enabled_path)
|
||||
_run(['sudo', 'rm', '-f', enabled_path])
|
||||
return False, f'证书申请失败: {err}'
|
||||
|
||||
cert_path = os.path.join(le_dir, 'fullchain.pem')
|
||||
key_path = os.path.join(le_dir, 'privkey.pem')
|
||||
# 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, '证书文件未生成'
|
||||
|
|
@ -223,15 +260,18 @@ def renew_all_expiring(days_before=30):
|
|||
def deploy_ssl(domain):
|
||||
"""
|
||||
将已有证书部署到 Nginx(更新 nginx 配置启用 HTTPS)
|
||||
v1.3.25: 从 /etc/letsencrypt/live/<domain>/ 读证书(certbot 默认位置)
|
||||
"""
|
||||
le_dir = os.path.join(SSL_DIR, domain)
|
||||
cert_path = os.path.join(le_dir, 'fullchain.pem')
|
||||
key_path = os.path.join(le_dir, 'privkey.pem')
|
||||
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
|
||||
|
|
@ -262,7 +302,7 @@ server {{
|
|||
|
||||
location ~ \\.php$ {{
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php/php-fpm8.1.sock;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}}
|
||||
|
|
@ -274,37 +314,49 @@ server {{
|
|||
'''
|
||||
conf_path = f'/etc/nginx/sites-available/{domain}.conf'
|
||||
|
||||
# 清理旧的 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)
|
||||
# v1.3.34 修复:用 sudo rm 清理(前面已经会 rm -f,这里简化)
|
||||
|
||||
with open(conf_path, 'w') as f:
|
||||
f.write(nginx_conf)
|
||||
|
||||
if not os.path.exists(f'/etc/nginx/sites-enabled/{domain}.conf'):
|
||||
os.symlink(conf_path, f'/etc/nginx/sites-enabled/{domain}.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(['nginx', '-t'])
|
||||
code, out, err = _run(['sudo', 'nginx', '-t'])
|
||||
if code != 0:
|
||||
return False, f'Nginx 配置错误: {err}'
|
||||
|
||||
_run(['nginx', '-s', 'reload'])
|
||||
_run(['sudo', 'nginx', '-s', 'reload'])
|
||||
|
||||
# 更新数据库 ssl_enabled
|
||||
# 更新数据库 ssl_enabled + ssl_certs 表
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
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, f'HTTPS 已启用'
|
||||
return True, "HTTPS 已启用,到期 " + expire_date
|
||||
|
||||
def check_certs_status():
|
||||
"""
|
||||
|
|
|
|||
71
backend/ssl_sync.py
Normal file
71
backend/ssl_sync.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#!/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)
|
||||
59
backend/sync_pma_bridge.py
Executable file
59
backend/sync_pma_bridge.py
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/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()
|
||||
1065
backend/system.py
1065
backend/system.py
File diff suppressed because it is too large
Load diff
431
backend/task_manager.py
Normal file
431
backend/task_manager.py
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
"""
|
||||
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()
|
||||
1484
frontend/index.html
1484
frontend/index.html
File diff suppressed because it is too large
Load diff
2
frontend/xterm-addon-fit.js
Normal file
2
frontend/xterm-addon-fit.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
!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
|
||||
209
frontend/xterm.css
Normal file
209
frontend/xterm.css
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
2
frontend/xterm.js
Normal file
2
frontend/xterm.js
Normal file
File diff suppressed because one or more lines are too long
216
install.sh
216
install.sh
|
|
@ -1,216 +0,0 @@
|
|||
#!/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 ""
|
||||
|
|
@ -3,3 +3,4 @@ flask-cors==4.0.0
|
|||
APScheduler==3.10.4
|
||||
python-dotenv==1.0.1
|
||||
certbot==2.11.0
|
||||
bcrypt==4.2.1
|
||||
|
|
|
|||
108
tpanel-v1.3.34-pkg/backend/config.py
Normal file
108
tpanel-v1.3.34-pkg/backend/config.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""
|
||||
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()
|
||||
258
tpanel-v1.3.34-pkg/backend/cron_manager.py
Normal file
258
tpanel-v1.3.34-pkg/backend/cron_manager.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"""
|
||||
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, '格式正确'
|
||||
146
tpanel-v1.3.34-pkg/backend/db_init.py
Normal file
146
tpanel-v1.3.34-pkg/backend/db_init.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""
|
||||
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()
|
||||
204
tpanel-v1.3.34-pkg/backend/file_manager.py
Normal file
204
tpanel-v1.3.34-pkg/backend/file_manager.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""
|
||||
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)
|
||||
1402
tpanel-v1.3.34-pkg/backend/main.py
Normal file
1402
tpanel-v1.3.34-pkg/backend/main.py
Normal file
File diff suppressed because it is too large
Load diff
218
tpanel-v1.3.34-pkg/backend/remote_backup.py
Normal file
218
tpanel-v1.3.34-pkg/backend/remote_backup.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""
|
||||
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,
|
||||
}
|
||||
388
tpanel-v1.3.34-pkg/backend/ssl_manager.py
Normal file
388
tpanel-v1.3.34-pkg/backend/ssl_manager.py
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
"""
|
||||
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,
|
||||
}
|
||||
59
tpanel-v1.3.34-pkg/backend/sync_pma_bridge.py
Executable file
59
tpanel-v1.3.34-pkg/backend/sync_pma_bridge.py
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/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()
|
||||
522
tpanel-v1.3.34-pkg/backend/system.py
Normal file
522
tpanel-v1.3.34-pkg/backend/system.py
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
"""
|
||||
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, "密码修改成功"
|
||||
426
tpanel-v1.3.34-pkg/backend/task_manager.py
Normal file
426
tpanel-v1.3.34-pkg/backend/task_manager.py
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
"""
|
||||
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()
|
||||
3120
tpanel-v1.3.34-pkg/frontend/index.html
Normal file
3120
tpanel-v1.3.34-pkg/frontend/index.html
Normal file
File diff suppressed because it is too large
Load diff
56
tpanel-v1.3.34-pkg/nginx/tpanel-https-with-pma.conf
Normal file
56
tpanel-v1.3.34-pkg/nginx/tpanel-https-with-pma.conf
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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;
|
||||
}
|
||||
33
tpanel-v1.3.34-pkg/nginx/update-nginx.sh
Executable file
33
tpanel-v1.3.34-pkg/nginx/update-nginx.sh
Executable file
|
|
@ -0,0 +1,33 @@
|
|||
#!/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"
|
||||
35
tpanel-v1.3.34-pkg/php-bridge/README.md
Normal file
35
tpanel-v1.3.34-pkg/php-bridge/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 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 (自动登录)
|
||||
```
|
||||
|
||||
114
tpanel-v1.3.34-pkg/php-bridge/tpanel-bridge.php
Normal file
114
tpanel-v1.3.34-pkg/php-bridge/tpanel-bridge.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?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;
|
||||
7
tpanel-v1.3.34-pkg/php-bridge/tpanel-signon.php
Normal file
7
tpanel-v1.3.34-pkg/php-bridge/tpanel-signon.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?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";
|
||||
Loading…
Reference in a new issue