mirror of
https://github.com/zhang-pu/tpanel.git
synced 2026-10-02 16:29:29 +08:00
tpanel
This commit is contained in:
commit
7788ce489e
12 changed files with 5265 additions and 0 deletions
326
SPEC.md
Normal file
326
SPEC.md
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
# T面板 - 规格说明书
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
- **名称**:T面板(tpanel)
|
||||||
|
- **官方网址**:https://tpanel.cn
|
||||||
|
- **定位**:轻量级 Linux 网站管理面板,聚焦建站核心功能
|
||||||
|
- **目标用户**:个人站长、中小型网站管理者
|
||||||
|
|
||||||
|
## 2. 核心功能
|
||||||
|
|
||||||
|
### 2.1 网站管理
|
||||||
|
- 创建站点(绑定域名、选择PHP版本、设置目录)
|
||||||
|
- 删除站点(含数据确认)
|
||||||
|
- 站点列表(域名、状态、创建时间、备份状态)
|
||||||
|
- 站点起停(nginx reload/restart)
|
||||||
|
- 流量统计(nginx access log 解析)
|
||||||
|
|
||||||
|
### 2.2 SSL 证书
|
||||||
|
- Let's Encrypt 免费证书申请(HTTP验证)
|
||||||
|
- 证书自动续期(systemd timer 触发 certbot)
|
||||||
|
- 一键部署到站点
|
||||||
|
- 证书状态监控(剩余天数)
|
||||||
|
|
||||||
|
### 2.3 数据库管理
|
||||||
|
- 创建 MySQL 数据库 + 用户
|
||||||
|
- 删除数据库
|
||||||
|
- phpMyAdmin 一键安装(可选)
|
||||||
|
- 数据库列表(名称、大小、字符集)
|
||||||
|
|
||||||
|
### 2.4 备份系统
|
||||||
|
- 本地备份(tar + mysql dump)
|
||||||
|
- 远程备份(rsync 到另一台服务器)
|
||||||
|
- 定时备份(cron 表达式)
|
||||||
|
- 下载备份文件
|
||||||
|
- 恢复备份
|
||||||
|
|
||||||
|
### 2.5 安全功能
|
||||||
|
- 系统更新(apt-get security update)
|
||||||
|
- 站点数据隔离(Linux 用户分离)
|
||||||
|
- SSH 密钥管理(可选)
|
||||||
|
- 防火墙规则(UFW)
|
||||||
|
- 异常登录告警
|
||||||
|
- 自动漏洞修复(cron 定期执行)
|
||||||
|
|
||||||
|
### 2.6 文件管理
|
||||||
|
- 在线文件浏览
|
||||||
|
- 上传文件(压缩包自动解压)
|
||||||
|
- 编辑配置文件(高亮)
|
||||||
|
- 权限管理
|
||||||
|
|
||||||
|
## 3. 技术架构
|
||||||
|
|
||||||
|
### 3.1 目录结构
|
||||||
|
```
|
||||||
|
/opt/tpanel/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # Flask 入口
|
||||||
|
│ ├── system.py # 系统操作(nginx/mysql/backup)
|
||||||
|
│ ├── site.py # 站点管理
|
||||||
|
│ ├── database.py # 数据库管理
|
||||||
|
│ ├── ssl.py # SSL 管理
|
||||||
|
│ ├── firewall.py # 防火墙
|
||||||
|
│ ├── security.py # 安全更新
|
||||||
|
│ ├── config.py # 配置读写
|
||||||
|
│ └── utils.py # 工具函数
|
||||||
|
├── frontend/
|
||||||
|
│ ├── index.html # 主面板
|
||||||
|
│ ├── css/
|
||||||
|
│ ├── js/
|
||||||
|
│ └── assets/
|
||||||
|
├── data/ # SQLite 数据库
|
||||||
|
├── sites/ # 站点目录 /www/tpanel/sites/
|
||||||
|
├── backups/ # 备份目录
|
||||||
|
├── logs/ # 日志
|
||||||
|
├── ssl/ # SSL 证书目录
|
||||||
|
└── config/ # Nginx 配置 /etc/nginx/tpanel/
|
||||||
|
|
||||||
|
/etc/systemd/system/tpanel.service
|
||||||
|
/etc/nginx/tpanel-api.conf # Nginx 反向代理
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 数据库表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 管理员账号
|
||||||
|
CREATE TABLE admin (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL, -- bcrypt
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 站点
|
||||||
|
CREATE TABLE sites (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL UNIQUE,
|
||||||
|
site_user TEXT NOT NULL UNIQUE, -- Linux 用户名
|
||||||
|
site_path TEXT NOT NULL,
|
||||||
|
php_version TEXT DEFAULT '8.1',
|
||||||
|
status TEXT DEFAULT 'running', -- running/stopped
|
||||||
|
ssl_enabled INTEGER DEFAULT 0,
|
||||||
|
ssl_cert_path TEXT,
|
||||||
|
ssl_key_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 数据库
|
||||||
|
CREATE TABLE databases (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER REFERENCES sites(id),
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 备份记录
|
||||||
|
CREATE TABLE backups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER REFERENCES sites(id),
|
||||||
|
type TEXT DEFAULT 'local', -- local/remote
|
||||||
|
file_path TEXT,
|
||||||
|
size INTEGER,
|
||||||
|
status TEXT DEFAULT 'success', -- success/failed
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SSL 证书
|
||||||
|
CREATE TABLE ssl_certs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER REFERENCES sites(id),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
cert_path TEXT NOT NULL,
|
||||||
|
key_path TEXT NOT NULL,
|
||||||
|
expire_date DATETIME,
|
||||||
|
auto_renew INTEGER DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 定时任务
|
||||||
|
CREATE TABLE cron_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
site_id INTEGER REFERENCES sites(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
schedule TEXT NOT NULL, -- cron 表达式
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
last_run DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 安全日志
|
||||||
|
CREATE TABLE security_logs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
ip TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 设置
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 安全机制
|
||||||
|
|
||||||
|
1. **站点隔离**:每个站点一个 Linux 用户,Home 目录即网站根目录,禁 shell
|
||||||
|
2. **最小权限原则**:MySQL 用户权限精确到站点数据库
|
||||||
|
3. **CSRF 防护**:所有 POST 请求带 token 验证
|
||||||
|
4. **命令白名单**:仅允许预设的 shell 命令,无 shell 注入
|
||||||
|
5. **HTTPS**:面板强制 HTTPS,API 只能用 Token 认证
|
||||||
|
6. **文件上传限制**:仅允许 .zip/.tar.gz 上传,自动解压到站点目录
|
||||||
|
7. **日志审计**:所有操作写 security_logs
|
||||||
|
8. **自动修复**:每日 3:00 执行 apt-get update && apt-get upgrade -y
|
||||||
|
|
||||||
|
### 3.4 防火墙规则(UFW)
|
||||||
|
|
||||||
|
- 默认只开放 22(SSH)、80(HTTP)、443(HTTPS)
|
||||||
|
- 管理面板端口(例如 8848)仅限 localhost 访问
|
||||||
|
- Nginx 反向代理到面板后端
|
||||||
|
|
||||||
|
## 4. 面板设计
|
||||||
|
|
||||||
|
### 4.1 界面风格
|
||||||
|
|
||||||
|
- 风格:深色主题 + 绿色点缀(科技感、安全感)
|
||||||
|
- 字体:JetBrains Mono(代码)+ Inter(界面)
|
||||||
|
- 配色:主色 #22c55e(绿色),背景 #0f172a(深蓝黑),卡片 #1e293b
|
||||||
|
|
||||||
|
### 4.2 布局
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ [Logo] T面板 [站点数] [状态] [安全更新] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ [仪表盘] [网站] [数据库] [SSL] [备份] [安全] │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ │ 内容区域 │ │
|
||||||
|
│ └─────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. API 设计
|
||||||
|
|
||||||
|
### 5.1 认证
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/auth/login { username, password }
|
||||||
|
POST /api/auth/logout
|
||||||
|
GET /api/auth/check (Header: Authorization: Bearer <token>)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 站点
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/sites 列表
|
||||||
|
POST /api/sites 创建 { domain, php_version, path }
|
||||||
|
GET /api/sites/:id 详情
|
||||||
|
PUT /api/sites/:id 更新 { status, ssl }
|
||||||
|
DELETE /api/sites/:id 删除
|
||||||
|
POST /api/sites/:id/start 启动
|
||||||
|
POST /api/sites/:id/stop 停止
|
||||||
|
POST /api/sites/:id/backup 触发备份
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 数据库
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/databases
|
||||||
|
POST /api/databases 创建 { site_id, name, user, pass }
|
||||||
|
DELETE /api/databases/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 SSL
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/ssl/apply { site_id, domain }
|
||||||
|
GET /api/ssl/certs 列表
|
||||||
|
POST /api/ssl/renew/:id 续期
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 备份
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/backups 列表
|
||||||
|
POST /api/backups 创建 { site_id, type }
|
||||||
|
GET /api/backups/:id/download
|
||||||
|
POST /api/backups/:id/restore
|
||||||
|
DELETE /api/backups/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 安全
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/security/logs 安全日志
|
||||||
|
POST /api/security/update 执行系统更新
|
||||||
|
GET /api/security/status 安全状态(已安装更新数、漏洞数)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 安装流程
|
||||||
|
|
||||||
|
### 6.1 一键安装脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget -O install.sh https://tpanel.cn/install.sh
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
安装脚本做的事情:
|
||||||
|
1. 检测系统(Debian 10+ / Ubuntu 20.04+)
|
||||||
|
2. 安装 Nginx、MySQL、Python3、certbot
|
||||||
|
3. 创建 tpanel 用户和目录
|
||||||
|
4. 初始化 SQLite 数据库
|
||||||
|
5. 配置 systemd 服务
|
||||||
|
6. 配置 Nginx 反向代理
|
||||||
|
7. 申请 Let's Encrypt 面板证书
|
||||||
|
8. 启动服务
|
||||||
|
|
||||||
|
### 6.2 默认端口
|
||||||
|
|
||||||
|
- 面板访问:https://localhost:8848 (仅本地访问)
|
||||||
|
- 通过 Nginx 反代到域名,例如 https://tpanel.cn
|
||||||
|
|
||||||
|
## 7. 版本规划
|
||||||
|
|
||||||
|
### v1.0.0(首发)
|
||||||
|
- 站点 CRUD
|
||||||
|
- MySQL 数据库 CRUD
|
||||||
|
- Let's Encrypt SSL
|
||||||
|
- 本地备份
|
||||||
|
- 系统安全更新
|
||||||
|
|
||||||
|
### v1.1.0
|
||||||
|
- 远程备份(rsync)
|
||||||
|
- 定时任务
|
||||||
|
- 文件管理
|
||||||
|
|
||||||
|
### v1.2.0
|
||||||
|
- Cron 表达式验证
|
||||||
|
- 远程 rsync 备份 + 恢复
|
||||||
|
- 连接测试工具
|
||||||
|
- 备份统计面板
|
||||||
|
|
||||||
|
### v1.3.0(待开发)
|
||||||
|
- 多 PHP 版本切换
|
||||||
|
- Node.js 支持
|
||||||
|
- 日志查看器(nginx access/error log)
|
||||||
|
|
||||||
|
## 8. 开源协议
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 9. 作者
|
||||||
|
|
||||||
|
- 作者:Zhang Pu
|
||||||
|
- 网站:https://zhangpu.dev
|
||||||
|
- 面板官网:https://tpanel.cn
|
||||||
87
backend/config.py
Normal file
87
backend/config.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
258
backend/cron_manager.py
Normal file
258
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, '格式正确'
|
||||||
111
backend/db_init.py
Normal file
111
backend/db_init.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)''')
|
||||||
|
|
||||||
|
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
|
||||||
|
)''')
|
||||||
|
|
||||||
|
# 默认管理员账号 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()
|
||||||
196
backend/file_manager.py
Normal file
196
backend/file_manager.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"""
|
||||||
|
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, '路径不在允许范围内'
|
||||||
|
|
||||||
|
# 限制权限范围
|
||||||
|
if mode & 0o777 not in [0o755, 0o644, 0o600, 0o700, 0o775, 0o664]:
|
||||||
|
return False, '权限值不允许'
|
||||||
|
|
||||||
|
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)
|
||||||
867
backend/main.py
Normal file
867
backend/main.py
Normal file
|
|
@ -0,0 +1,867 @@
|
||||||
|
"""
|
||||||
|
TPanel - T面板 主程序
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import secrets
|
||||||
|
import bcrypt
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, session, redirect
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
# 导入各模块
|
||||||
|
from config import DB_PATH, load_config, save_config, set_setting, get_setting
|
||||||
|
from system import (
|
||||||
|
nginx_status, nginx_reload, mysql_status,
|
||||||
|
create_site_user, delete_site_user,
|
||||||
|
write_nginx_config, remove_nginx_config,
|
||||||
|
create_mysql_db, delete_mysql_db,
|
||||||
|
backup_site, restore_backup,
|
||||||
|
run_security_update, get_security_status, get_system_stats, write_log
|
||||||
|
)
|
||||||
|
from file_manager import list_directory, read_file, write_file, upload_file, delete_file, chmod_file, create_directory
|
||||||
|
from ssl_manager import get_all_certs, apply_letsencrypt, renew_cert, renew_all_expiring, deploy_ssl, check_certs_status
|
||||||
|
from cron_manager import get_all_crons, create_cron, delete_cron, enable_cron, run_cron_now, validate_cron_expression
|
||||||
|
from remote_backup import run_remote_backup, sync_restore, test_rsync_connection, get_backup_stats
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder='../frontend')
|
||||||
|
app.secret_key = secrets.token_hex(32)
|
||||||
|
CORS(app, supports_credentials=True)
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 域名绑定中间件
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def check_panel_domain():
|
||||||
|
"""如果面板绑定了域名,只允许该域名访问"""
|
||||||
|
allowed = get_panel_domain().strip()
|
||||||
|
if not allowed:
|
||||||
|
return # 未绑定,不限制
|
||||||
|
|
||||||
|
host = request.host.lower()
|
||||||
|
allowed_clean = allowed.lower().strip().split(':')[0]
|
||||||
|
host_clean = host.split(':')[0]
|
||||||
|
|
||||||
|
safe = ['localhost', '127.0.0.1', '::1']
|
||||||
|
if host_clean in safe:
|
||||||
|
return
|
||||||
|
|
||||||
|
if host_clean != allowed_clean:
|
||||||
|
return jsonify({'code': 403, 'msg': f'面板已绑定域名 {allowed},请使用该域名访问'}), 403
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 装饰器
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
def require_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||||
|
expected = get_setting('api_token', '')
|
||||||
|
if not expected or token != expected:
|
||||||
|
# 也检查 session
|
||||||
|
if 'admin' not in session:
|
||||||
|
return jsonify({'code': 401, 'msg': '未授权'}), 401
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 认证 API
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/auth/login', methods=['POST'])
|
||||||
|
def api_login():
|
||||||
|
data = request.json or {}
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT id, password_hash FROM admin WHERE username = ?", (username,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 401, 'msg': '用户名或密码错误'})
|
||||||
|
|
||||||
|
if bcrypt.checkpw(password.encode(), row[1].encode()):
|
||||||
|
session['admin'] = True
|
||||||
|
session['username'] = username
|
||||||
|
|
||||||
|
# 生成 API token
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
set_setting('api_token', token)
|
||||||
|
set_setting('last_login', datetime.now().isoformat())
|
||||||
|
|
||||||
|
write_log('login', f'用户 {username} 登录成功', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '登录成功', 'token': token})
|
||||||
|
|
||||||
|
write_log('login_fail', f'用户 {username} 登录失败', request.remote_addr)
|
||||||
|
return jsonify({'code': 401, 'msg': '用户名或密码错误'})
|
||||||
|
|
||||||
|
@app.route('/api/auth/logout', methods=['POST'])
|
||||||
|
def api_logout():
|
||||||
|
username = session.get('username', 'unknown')
|
||||||
|
session.clear()
|
||||||
|
write_log('logout', f'用户 {username} 退出', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '已退出'})
|
||||||
|
|
||||||
|
@app.route('/api/auth/check', methods=['GET'])
|
||||||
|
def api_check():
|
||||||
|
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||||
|
if token == get_setting('api_token', ''):
|
||||||
|
return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')})
|
||||||
|
if 'admin' in session:
|
||||||
|
return jsonify({'code': 0, 'msg': '有效', 'username': session.get('username', 'admin')})
|
||||||
|
return jsonify({'code': 401, 'msg': '无效'}), 401
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 系统状态
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/system/stats', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_system_stats():
|
||||||
|
stats = get_system_stats()
|
||||||
|
security = get_security_status()
|
||||||
|
stats.update(security)
|
||||||
|
return jsonify({'code': 0, 'data': stats})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 站点管理
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/sites', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_sites_list():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT * FROM sites ORDER BY id DESC")
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 0, 'data': rows})
|
||||||
|
|
||||||
|
@app.route('/api/sites', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_sites_create():
|
||||||
|
data = request.json or {}
|
||||||
|
domain = data.get('domain', '').strip().lower()
|
||||||
|
name = data.get('name', domain)
|
||||||
|
php_version = data.get('php_version', '8.1')
|
||||||
|
site_user = domain.replace('.', '_')
|
||||||
|
|
||||||
|
if not domain:
|
||||||
|
return jsonify({'code': 400, 'msg': '域名不能为空'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,))
|
||||||
|
if cur.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 400, 'msg': '站点已存在'})
|
||||||
|
|
||||||
|
site_path = f'/opt/tpanel/sites/{site_user}'
|
||||||
|
|
||||||
|
# 1. 创建 Linux 用户
|
||||||
|
ok, msg = create_site_user(site_user)
|
||||||
|
if not ok:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 500, 'msg': f'创建系统用户失败: {msg}'})
|
||||||
|
|
||||||
|
# 2. 创建目录并写入 index.php
|
||||||
|
os.makedirs(site_path, exist_ok=True)
|
||||||
|
os.makedirs(f'{site_path}/public', exist_ok=True)
|
||||||
|
with open(f'{site_path}/public/index.php', 'w') as f:
|
||||||
|
f.write(f'<?php\n// Site: {domain}\n// Managed by TPanel\nphpinfo();\n')
|
||||||
|
os.makedirs(f'{site_path}/logs', exist_ok=True)
|
||||||
|
|
||||||
|
# 3. 写 Nginx 配置
|
||||||
|
ok, msg = write_nginx_config(domain, f'{site_path}/public', php_version)
|
||||||
|
if not ok:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 500, 'msg': f'Nginx 配置失败: {msg}'})
|
||||||
|
|
||||||
|
# 4. 写入数据库
|
||||||
|
cur.execute("""INSERT INTO sites (name, domain, site_user, site_path, php_version, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
|
(name, domain, site_user, f'{site_path}/public', php_version, 'running'))
|
||||||
|
conn.commit()
|
||||||
|
site_id = cur.lastrowid
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
write_log('site_create', f'创建站点 {domain}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '站点创建成功', 'data': {'id': site_id}})
|
||||||
|
|
||||||
|
@app.route('/api/sites/<int:site_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def api_sites_delete(site_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT domain, site_user, site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
domain, site_user, site_path = row
|
||||||
|
|
||||||
|
# 1. 删除 Nginx 配置
|
||||||
|
remove_nginx_config(domain)
|
||||||
|
|
||||||
|
# 2. 删除系统用户和目录
|
||||||
|
delete_site_user(site_user)
|
||||||
|
import shutil
|
||||||
|
parent_path = os.path.dirname(site_path)
|
||||||
|
if os.path.exists(os.path.join(parent_path, site_user)):
|
||||||
|
shutil.rmtree(os.path.join(parent_path, site_user), ignore_errors=True)
|
||||||
|
|
||||||
|
# 3. 删除数据库
|
||||||
|
cur2 = conn.execute("SELECT name, db_user FROM databases WHERE site_id = ?", (site_id,))
|
||||||
|
for db_row in cur2.fetchall():
|
||||||
|
delete_mysql_db(db_row[0], db_row[1])
|
||||||
|
|
||||||
|
# 4. 删除站点记录
|
||||||
|
conn.execute("DELETE FROM sites WHERE id = ?", (site_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
write_log('site_delete', f'删除站点 {domain}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '站点已删除'})
|
||||||
|
|
||||||
|
@app.route('/api/sites/<int:site_id>/start', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_sites_start(site_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT domain FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
_, msg = nginx_reload()
|
||||||
|
return jsonify({'code': 0, 'msg': msg or '已启动'})
|
||||||
|
|
||||||
|
@app.route('/api/sites/<int:site_id>/stop', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_sites_stop(site_id):
|
||||||
|
return jsonify({'code': 0, 'msg': '停止站点需要 reload nginx,建议通过 Nginx 命令管理'})
|
||||||
|
|
||||||
|
@app.route('/api/sites/<int:site_id>/ssl', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_sites_ssl(site_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT domain, site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
domain, site_path = row
|
||||||
|
return jsonify({'code': 0, 'msg': 'SSL 功能开发中,请手动配置 certbot'})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 数据库
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/databases', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_databases_list():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT * FROM databases ORDER BY id DESC")
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 0, 'data': rows})
|
||||||
|
|
||||||
|
@app.route('/api/databases', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_databases_create():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
db_name = data.get('name', '').strip()
|
||||||
|
db_user = data.get('user', '').strip()
|
||||||
|
db_pass = data.get('pass', '')
|
||||||
|
|
||||||
|
if not all([site_id, db_name, db_user, db_pass]):
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
ok, msg = create_mysql_db(db_name, db_user, db_pass)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'code': 500, 'msg': msg})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("""INSERT INTO databases (site_id, name, db_user, db_pass) VALUES (?, ?, ?, ?)""",
|
||||||
|
(site_id, db_name, db_user, db_pass))
|
||||||
|
conn.commit()
|
||||||
|
db_id = cur.lastrowid
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
write_log('db_create', f'创建数据库 {db_name}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '数据库创建成功', 'data': {'id': db_id}})
|
||||||
|
|
||||||
|
@app.route('/api/databases/<int:db_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def api_databases_delete(db_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT name, db_user FROM databases WHERE id = ?", (db_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 404, 'msg': '数据库不存在'})
|
||||||
|
delete_mysql_db(row[0], row[1])
|
||||||
|
conn.execute("DELETE FROM databases WHERE id = ?", (db_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
write_log('db_delete', f'删除数据库 {row[0]}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '数据库已删除'})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 备份
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/backups', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_backups_list():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("""SELECT b.*, s.domain FROM backups b
|
||||||
|
LEFT JOIN sites s ON b.site_id = s.id
|
||||||
|
ORDER BY b.id DESC LIMIT 50""")
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 0, 'data': rows})
|
||||||
|
|
||||||
|
@app.route('/api/backups', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_backups_create():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path, domain FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
site_path, domain = row
|
||||||
|
|
||||||
|
# 查找该站点的数据库
|
||||||
|
conn2 = sqlite3.connect(DB_PATH)
|
||||||
|
cur2 = conn2.execute("SELECT name, db_user, db_pass FROM databases WHERE site_id = ?", (site_id,))
|
||||||
|
db_row = cur2.fetchone()
|
||||||
|
conn2.close()
|
||||||
|
|
||||||
|
db_name, db_user, db_pass = (db_row if db_row else (None, None, None))
|
||||||
|
|
||||||
|
ok, path, size = backup_site(site_path, domain, db_name, db_user, db_pass)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'code': 500, 'msg': f'备份失败: {path}'})
|
||||||
|
|
||||||
|
conn3 = sqlite3.connect(DB_PATH)
|
||||||
|
conn3.execute("INSERT INTO backups (site_id, type, file_path, size, status) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(site_id, 'local', path, size, 'success'))
|
||||||
|
conn3.commit()
|
||||||
|
backup_id = conn3.lastrowid
|
||||||
|
conn3.close()
|
||||||
|
|
||||||
|
write_log('backup', f'备份站点 {domain}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '备份成功', 'data': {'id': backup_id, 'path': path, 'size': size}})
|
||||||
|
|
||||||
|
@app.route('/api/backups/<int:backup_id>/restore', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_backups_restore(backup_id):
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT file_path, site_id FROM backups WHERE id = ?", (backup_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '备份不存在'})
|
||||||
|
|
||||||
|
file_path, site_id = row
|
||||||
|
|
||||||
|
conn2 = sqlite3.connect(DB_PATH)
|
||||||
|
cur2 = conn2.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
site_row = cur2.fetchone()
|
||||||
|
conn2.close()
|
||||||
|
|
||||||
|
if not site_row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
ok, msg = restore_backup(file_path, site_row[0], str(site_id))
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'code': 500, 'msg': msg})
|
||||||
|
|
||||||
|
write_log('restore', f'恢复备份 {file_path}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': '恢复成功'})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 安全
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/security/logs', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_security_logs():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT * FROM security_logs ORDER BY id DESC LIMIT 100")
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'code': 0, 'data': rows})
|
||||||
|
|
||||||
|
@app.route('/api/security/update', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_security_run_update():
|
||||||
|
ok, msg = run_security_update()
|
||||||
|
write_log('security_update', msg, request.remote_addr)
|
||||||
|
return jsonify({'code': 0 if ok else 500, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/security/status', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_security_status():
|
||||||
|
status = get_security_status()
|
||||||
|
return jsonify({'code': 0, 'data': status})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 文件管理
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/files/list', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_list():
|
||||||
|
site_id = request.args.get('site_id', type=int)
|
||||||
|
path = request.args.get('path', '')
|
||||||
|
|
||||||
|
if not site_id:
|
||||||
|
return jsonify({'code': 400, 'msg': '缺少 site_id'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
base_path = row[0]
|
||||||
|
if path:
|
||||||
|
target_path = os.path.join(base_path, path)
|
||||||
|
else:
|
||||||
|
target_path = base_path
|
||||||
|
|
||||||
|
items, err = list_directory(target_path)
|
||||||
|
if err:
|
||||||
|
return jsonify({'code': 400, 'msg': err})
|
||||||
|
|
||||||
|
return jsonify({'code': 0, 'data': items, 'base_path': base_path})
|
||||||
|
|
||||||
|
@app.route('/api/files/read', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_read():
|
||||||
|
site_id = request.args.get('site_id', type=int)
|
||||||
|
filepath = request.args.get('path', '')
|
||||||
|
|
||||||
|
if not site_id or not filepath:
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
full_path = os.path.join(row[0], filepath)
|
||||||
|
content, err = read_file(full_path)
|
||||||
|
if err:
|
||||||
|
return jsonify({'code': 400, 'msg': err})
|
||||||
|
|
||||||
|
return jsonify({'code': 0, 'data': content})
|
||||||
|
|
||||||
|
@app.route('/api/files/write', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_write():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
filepath = data.get('path', '')
|
||||||
|
content = data.get('content', '')
|
||||||
|
|
||||||
|
if not site_id or not filepath:
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
full_path = os.path.join(row[0], filepath)
|
||||||
|
ok, msg = write_file(full_path, content)
|
||||||
|
if ok:
|
||||||
|
write_log('file_edit', f'编辑文件 {filepath}', request.remote_addr)
|
||||||
|
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/files/upload', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_upload():
|
||||||
|
site_id = request.form.get('site_id', type=int)
|
||||||
|
path = request.form.get('path', '')
|
||||||
|
file = request.files.get('file')
|
||||||
|
|
||||||
|
if not site_id or not file:
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
upload_dir = os.path.join(row[0], path) if path else row[0]
|
||||||
|
ok, msg = upload_file(upload_dir, file, file.filename)
|
||||||
|
if ok:
|
||||||
|
write_log('file_upload', f'上传文件 {file.filename}', request.remote_addr)
|
||||||
|
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/files/delete', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_delete():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
filepath = data.get('path', '')
|
||||||
|
|
||||||
|
if not site_id or not filepath:
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
full_path = os.path.join(row[0], filepath)
|
||||||
|
ok, msg = delete_file(full_path)
|
||||||
|
if ok:
|
||||||
|
write_log('file_delete', f'删除文件 {filepath}', request.remote_addr)
|
||||||
|
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/files/mkdir', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_mkdir():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
dirpath = data.get('path', '')
|
||||||
|
dirname = data.get('name', '')
|
||||||
|
|
||||||
|
if not site_id or not dirname:
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
full_path = os.path.join(row[0], dirpath) if dirpath else row[0]
|
||||||
|
ok, msg = create_directory(full_path, dirname)
|
||||||
|
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/files/chmod', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_files_chmod():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
filepath = data.get('path', '')
|
||||||
|
mode = data.get('mode', 0)
|
||||||
|
|
||||||
|
if not site_id or not filepath:
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
|
||||||
|
full_path = os.path.join(row[0], filepath)
|
||||||
|
ok, msg = chmod_file(full_path, mode)
|
||||||
|
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# SSL 证书
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/ssl/certs', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_ssl_list():
|
||||||
|
certs = get_all_certs()
|
||||||
|
status = check_certs_status()
|
||||||
|
return jsonify({'code': 0, 'data': certs, 'status': status})
|
||||||
|
|
||||||
|
@app.route('/api/ssl/apply', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_ssl_apply():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
domain = data.get('domain', '')
|
||||||
|
|
||||||
|
if not site_id:
|
||||||
|
# 查找站点
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT domain, site_path FROM sites WHERE id = ?", (site_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({'code': 404, 'msg': '站点不存在'})
|
||||||
|
domain = row[0]
|
||||||
|
|
||||||
|
if not domain:
|
||||||
|
return jsonify({'code': 400, 'msg': '域名不能为空'})
|
||||||
|
|
||||||
|
ok, msg = apply_letsencrypt(site_id, domain)
|
||||||
|
if ok:
|
||||||
|
# 部署 SSL
|
||||||
|
deploy_ok, deploy_msg = deploy_ssl(domain)
|
||||||
|
write_log('ssl_apply', f'申请 SSL 证书 {domain}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': f'证书申请成功,{deploy_msg}'})
|
||||||
|
else:
|
||||||
|
return jsonify({'code': 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/ssl/renew/<int:cert_id>', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_ssl_renew(cert_id):
|
||||||
|
ok, msg = renew_cert(cert_id=cert_id)
|
||||||
|
if ok:
|
||||||
|
write_log('ssl_renew', f'续期证书 ID {cert_id}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/ssl/renew-all', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_ssl_renew_all():
|
||||||
|
success, fail_count, fail_list = renew_all_expiring(days_before=30)
|
||||||
|
msg = f'续期完成:成功 {success} 个'
|
||||||
|
if fail_count > 0:
|
||||||
|
msg += f',失败 {fail_count} 个:{"; ".join(fail_list)}'
|
||||||
|
write_log('ssl_renew_all', msg, request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': msg, 'success': success, 'failed': fail_count})
|
||||||
|
|
||||||
|
@app.route('/api/ssl/deploy/<domain>', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_ssl_deploy(domain):
|
||||||
|
ok, msg = deploy_ssl(domain)
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 定时任务(安全自动更新 + SSL 续期)
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/cron/run', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_cron_run():
|
||||||
|
"""手动触发定时任务(安全更新 + SSL 续期)"""
|
||||||
|
# 安全更新
|
||||||
|
ok_sec, msg_sec = run_security_update()
|
||||||
|
|
||||||
|
# SSL 续期
|
||||||
|
success_ssl, fail_ssl, fail_list = renew_all_expiring(days_before=30)
|
||||||
|
|
||||||
|
msg = f'安全更新:{"成功" if ok_sec else "失败:" + msg_sec}。'
|
||||||
|
msg += f' SSL 续期:成功 {success_ssl} 个'
|
||||||
|
if fail_ssl > 0:
|
||||||
|
msg += f',失败 {fail_ssl} 个'
|
||||||
|
|
||||||
|
write_log('cron_run', msg, '')
|
||||||
|
return jsonify({'code': 0, 'msg': msg})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 定时任务 API
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/cron/jobs', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_cron_list():
|
||||||
|
jobs = get_all_crons()
|
||||||
|
return jsonify({'code': 0, 'data': jobs})
|
||||||
|
|
||||||
|
@app.route('/api/cron/jobs', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_cron_create():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
schedule = data.get('schedule', '').strip()
|
||||||
|
command = data.get('command', '').strip()
|
||||||
|
|
||||||
|
if not all([site_id, name, schedule, command]):
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
# 验证 cron 表达式
|
||||||
|
ok, err = validate_cron_expression(schedule)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'code': 400, 'msg': f'Cron 格式错误: {err}'})
|
||||||
|
|
||||||
|
cron_id, msg = create_cron(site_id, name, schedule, command)
|
||||||
|
if cron_id:
|
||||||
|
write_log('cron_create', f'创建定时任务 {name} ({schedule})', request.remote_addr)
|
||||||
|
return jsonify({'code': 0, 'msg': msg, 'data': {'id': cron_id}})
|
||||||
|
else:
|
||||||
|
return jsonify({'code': 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/cron/jobs/<int:cron_id>', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def api_cron_delete(cron_id):
|
||||||
|
ok, msg = delete_cron(cron_id)
|
||||||
|
if ok:
|
||||||
|
write_log('cron_delete', f'删除定时任务 ID {cron_id}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/cron/jobs/<int:cron_id>/toggle', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_cron_toggle(cron_id):
|
||||||
|
data = request.json or {}
|
||||||
|
enabled = data.get('enabled', True)
|
||||||
|
ok, msg = enable_cron(cron_id, enabled)
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/cron/jobs/<int:cron_id>/run', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_cron_run_now(cron_id):
|
||||||
|
ok, msg = run_cron_now(cron_id)
|
||||||
|
if ok:
|
||||||
|
write_log('cron_run_now', f'手动执行定时任务 ID {cron_id}', request.remote_addr)
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 远程备份 API
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/api/backups/remote', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_remote_backup():
|
||||||
|
data = request.json or {}
|
||||||
|
site_id = data.get('site_id')
|
||||||
|
remote_host = data.get('remote_host', '').strip()
|
||||||
|
remote_user = data.get('remote_user', '').strip()
|
||||||
|
remote_port = data.get('remote_port', 22)
|
||||||
|
remote_path = data.get('remote_path', '').strip()
|
||||||
|
key_path = data.get('key_path', '').strip()
|
||||||
|
|
||||||
|
if not all([site_id, remote_host, remote_user, remote_path]):
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
ok, msg = run_remote_backup(
|
||||||
|
site_id, remote_host, remote_user, remote_port,
|
||||||
|
remote_path, key_path=key_path if key_path else None
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return jsonify({'code': 0, 'msg': msg})
|
||||||
|
else:
|
||||||
|
return jsonify({'code': 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/backups/remote/restore/<int:backup_id>', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_remote_restore(backup_id):
|
||||||
|
data = request.json or {}
|
||||||
|
remote_host = data.get('remote_host', '').strip()
|
||||||
|
remote_user = data.get('remote_user', '').strip()
|
||||||
|
remote_port = data.get('remote_port', 22)
|
||||||
|
remote_path = data.get('remote_path', '').strip()
|
||||||
|
key_path = data.get('key_path', '').strip()
|
||||||
|
|
||||||
|
if not all([remote_host, remote_user, remote_path]):
|
||||||
|
return jsonify({'code': 400, 'msg': '参数不完整'})
|
||||||
|
|
||||||
|
ok, msg = sync_restore(
|
||||||
|
backup_id, remote_host, remote_user, remote_port,
|
||||||
|
remote_path, key_path=key_path if key_path else None
|
||||||
|
)
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/backups/remote/test', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_test_rsync():
|
||||||
|
data = request.json or {}
|
||||||
|
host = data.get('host', '').strip()
|
||||||
|
port = data.get('port', 22)
|
||||||
|
user = data.get('user', '').strip()
|
||||||
|
key_path = data.get('key_path', '').strip()
|
||||||
|
|
||||||
|
if not host or not user:
|
||||||
|
return jsonify({'code': 400, 'msg': '主机和用户名不能为空'})
|
||||||
|
|
||||||
|
ok, msg = test_rsync_connection(host, port, user, key_path if key_path else None)
|
||||||
|
return jsonify({'code': 0 if ok else 400, 'msg': msg})
|
||||||
|
|
||||||
|
@app.route('/api/backups/stats', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_backup_stats():
|
||||||
|
stats = get_backup_stats()
|
||||||
|
return jsonify({'code': 0, 'data': stats})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 设置
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/settings', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def api_settings_get():
|
||||||
|
cfg = load_config()
|
||||||
|
return jsonify({'code': 0, 'data': cfg})
|
||||||
|
|
||||||
|
@app.route('/api/settings', methods=['PUT'])
|
||||||
|
@require_auth
|
||||||
|
def api_settings_put():
|
||||||
|
data = request.json or {}
|
||||||
|
cfg = load_config()
|
||||||
|
cfg.update({k: v for k, v in data.items() if k in [
|
||||||
|
'panel_domain', 'default_php', 'backup_retention_days',
|
||||||
|
'auto_ssl_renew', 'security_auto_update', 'firewall_enabled'
|
||||||
|
]})
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify({'code': 0, 'msg': '设置已保存'})
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 静态文件
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def serve_index():
|
||||||
|
return app.send_static_file('index.html')
|
||||||
|
|
||||||
|
@app.route('/<path:path>')
|
||||||
|
def serve_static(path):
|
||||||
|
full = os.path.join(app.static_folder, path)
|
||||||
|
if os.path.exists(full) and not os.path.isdir(full):
|
||||||
|
return app.send_static_file(path)
|
||||||
|
return app.send_static_file('index.html')
|
||||||
|
|
||||||
|
# ==========================
|
||||||
|
# 健康检查
|
||||||
|
# ==========================
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
return jsonify({'status': 'ok', 'time': datetime.now().isoformat()})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8848
|
||||||
|
# 初始化数据库
|
||||||
|
from db_init import init_db
|
||||||
|
init_db()
|
||||||
|
# 生成初始 API token
|
||||||
|
if not get_setting('api_token'):
|
||||||
|
set_setting('api_token', secrets.token_hex(32))
|
||||||
|
print(f"[TPanel] 启动于端口 {port}")
|
||||||
|
app.run(host='127.0.0.1', port=port, debug=False)
|
||||||
218
backend/remote_backup.py
Normal file
218
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,
|
||||||
|
}
|
||||||
336
backend/ssl_manager.py
Normal file
336
backend/ssl_manager.py
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
"""
|
||||||
|
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 _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 配置 → 写入数据库
|
||||||
|
"""
|
||||||
|
site_path = f'/opt/tpanel/sites/{domain}/public'
|
||||||
|
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'
|
||||||
|
with open(conf_path, 'w') as f:
|
||||||
|
f.write(nginx_conf)
|
||||||
|
|
||||||
|
enabled_path = f'/etc/nginx/sites-enabled/{domain}.ssl.conf'
|
||||||
|
if not os.path.exists(enabled_path):
|
||||||
|
os.symlink(conf_path, enabled_path)
|
||||||
|
|
||||||
|
code, out, err = _run(['nginx', '-t'])
|
||||||
|
if code != 0:
|
||||||
|
return False, f'Nginx 配置错误: {err}'
|
||||||
|
|
||||||
|
_run(['nginx', '-s', 'reload'])
|
||||||
|
|
||||||
|
# 申请证书(standalone 模式 + webroot)
|
||||||
|
cmd = [
|
||||||
|
'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:
|
||||||
|
# 清理失败配置
|
||||||
|
if os.path.exists(enabled_path):
|
||||||
|
os.remove(enabled_path)
|
||||||
|
return False, f'证书申请失败: {err}'
|
||||||
|
|
||||||
|
cert_path = os.path.join(le_dir, 'fullchain.pem')
|
||||||
|
key_path = os.path.join(le_dir, '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)
|
||||||
|
"""
|
||||||
|
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')
|
||||||
|
|
||||||
|
if not os.path.exists(cert_path) or not os.path.exists(key_path):
|
||||||
|
return False, '证书文件不存在'
|
||||||
|
|
||||||
|
site_path = f'/opt/tpanel/sites/{domain}/public'
|
||||||
|
|
||||||
|
# 写入 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 unix:/run/php/php-fpm8.1.sock;
|
||||||
|
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'
|
||||||
|
|
||||||
|
# 清理旧的 SSL 配置
|
||||||
|
for old_conf in [
|
||||||
|
f'/etc/nginx/sites-enabled/{domain}.ssl.conf',
|
||||||
|
f'/etc/nginx/sites-enabled/{domain}.conf',
|
||||||
|
]:
|
||||||
|
if os.path.exists(old_conf) and os.path.islink(old_conf):
|
||||||
|
os.remove(old_conf)
|
||||||
|
|
||||||
|
with open(conf_path, 'w') as f:
|
||||||
|
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')
|
||||||
|
|
||||||
|
code, out, err = _run(['nginx', '-t'])
|
||||||
|
if code != 0:
|
||||||
|
return False, f'Nginx 配置错误: {err}'
|
||||||
|
|
||||||
|
_run(['nginx', '-s', 'reload'])
|
||||||
|
|
||||||
|
# 更新数据库 ssl_enabled
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.execute("SELECT id FROM sites WHERE domain = ?", (domain,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute("UPDATE sites SET ssl_enabled = 1, ssl_cert_path = ?, ssl_key_path = ? WHERE domain = ?",
|
||||||
|
(cert_path, key_path, domain))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return True, f'HTTPS 已启用'
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
309
backend/system.py
Normal file
309
backend/system.py
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
"""
|
||||||
|
TPanel - 系统操作模块
|
||||||
|
仅使用白名单命令,禁止直接执行用户传入的原始 shell 字符串
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tarfile
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
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(['nginx', '-t']) + _run(['nginx', '-s', 'reload'])
|
||||||
|
|
||||||
|
def nginx_stop():
|
||||||
|
return _run(['nginx', '-s', 'stop'])
|
||||||
|
|
||||||
|
def nginx_start():
|
||||||
|
return _run(['nginx'])
|
||||||
|
|
||||||
|
def nginx_status():
|
||||||
|
code, out, _ = _run(['ps', 'aux'], capture=True)
|
||||||
|
running = 'nginx: master' in out
|
||||||
|
return running
|
||||||
|
|
||||||
|
def mysql_status():
|
||||||
|
code, out, _ = _run(['systemctl', 'is-active', 'mysql'])
|
||||||
|
return out == 'active'
|
||||||
|
|
||||||
|
def create_site_user(username):
|
||||||
|
"""创建 Linux 用户,禁 shell,隔离目录"""
|
||||||
|
# 检查用户是否存在
|
||||||
|
code, out, _ = _run(['id', username], capture=True)
|
||||||
|
if code == 0:
|
||||||
|
return True, '用户已存在'
|
||||||
|
|
||||||
|
# 创建用户,home 目录即网站根目录,禁 shell
|
||||||
|
code, out, err = _run(
|
||||||
|
['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(['userdel', '-r', username])
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
return True, '用户删除成功'
|
||||||
|
|
||||||
|
def set_site_permissions(site_path, site_user):
|
||||||
|
"""设置站点目录权限"""
|
||||||
|
_run(['chown', '-R', f'{site_user}:{site_user}', site_path])
|
||||||
|
_run(['chmod', '-R', '755', site_path])
|
||||||
|
_run(['chmod', '-R', '700', site_path + '/storage' if os.path.exists(site_path + '/storage') else site_path])
|
||||||
|
|
||||||
|
def write_nginx_config(domain, site_path, php_version='8.1', ssl=False):
|
||||||
|
"""写入 Nginx 配置"""
|
||||||
|
# PHP-FPM socket 路径(根据版本)
|
||||||
|
fpm_sock = f'/run/php/php-fpm-{php_version}.sock'
|
||||||
|
if not os.path.exists(f'/run/php/php-fpm-{php_version}.sock'):
|
||||||
|
fpm_sock = '/run/php/php-fpm8.1.sock'
|
||||||
|
|
||||||
|
nginx_conf = f'''# TPanel - {domain}
|
||||||
|
server {{
|
||||||
|
listen 80;
|
||||||
|
server_name {domain};
|
||||||
|
|
||||||
|
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 unix:{fpm_sock};
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
}}
|
||||||
|
|
||||||
|
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'
|
||||||
|
with open(conf_path, 'w') as f:
|
||||||
|
f.write(nginx_conf)
|
||||||
|
|
||||||
|
# 启用站点
|
||||||
|
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf'
|
||||||
|
if not os.path.exists(enabled_path):
|
||||||
|
os.symlink(conf_path, enabled_path)
|
||||||
|
|
||||||
|
code, out, err = _run(['nginx', '-t'])
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
_run(['nginx', '-s', 'reload'])
|
||||||
|
return True, 'Nginx 配置已更新'
|
||||||
|
|
||||||
|
def remove_nginx_config(domain):
|
||||||
|
"""删除站点 Nginx 配置"""
|
||||||
|
conf_path = f'/etc/nginx/sites-available/{domain}.conf'
|
||||||
|
enabled_path = f'/etc/nginx/sites-enabled/{domain}.conf'
|
||||||
|
|
||||||
|
if os.path.exists(enabled_path):
|
||||||
|
os.remove(enabled_path)
|
||||||
|
if os.path.exists(conf_path):
|
||||||
|
os.remove(conf_path)
|
||||||
|
|
||||||
|
_run(['nginx', '-s', 'reload'])
|
||||||
|
|
||||||
|
def create_mysql_db(name, db_user, db_pass):
|
||||||
|
"""创建 MySQL 数据库和用户"""
|
||||||
|
code, out, err = _run(
|
||||||
|
f"mysql -e \"CREATE DATABASE IF NOT EXISTS `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\"",
|
||||||
|
shell=True
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
code, out, err = _run(
|
||||||
|
f"mysql -e \"CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{db_pass}';\"",
|
||||||
|
shell=True
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
code, out, err = _run(
|
||||||
|
f"mysql -e \"GRANT ALL PRIVILEGES ON `{name}`.* TO '{db_user}'@'localhost';\"",
|
||||||
|
shell=True
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
_run("mysql -e \"FLUSH PRIVILEGES;\"", shell=True)
|
||||||
|
return True, '数据库创建成功'
|
||||||
|
|
||||||
|
def delete_mysql_db(name, db_user):
|
||||||
|
code, out, err = _run(f"mysql -e \"DROP DATABASE IF EXISTS `{name}`;\"", shell=True)
|
||||||
|
_run(f"mysql -e \"DROP USER IF EXISTS '{db_user}'@'localhost';\"", shell=True)
|
||||||
|
_run("mysql -e \"FLUSH PRIVILEGES;\"", shell=True)
|
||||||
|
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):
|
||||||
|
"""备份站点文件和数据库"""
|
||||||
|
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
backup_name = f'{site_name}_{timestamp}'
|
||||||
|
backup_path = f'/opt/tpanel/backups/{backup_name}.tar.gz'
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 备份文件
|
||||||
|
with tarfile.open(backup_path, 'w:gz') as tar:
|
||||||
|
tar.add(site_path, arcname=os.path.basename(site_path))
|
||||||
|
|
||||||
|
# 备份数据库
|
||||||
|
if db_name:
|
||||||
|
dump_path = f'/opt/tpanel/backups/{backup_name}_db.sql.gz'
|
||||||
|
code, out, err = _run(
|
||||||
|
f"mysqldump -u {'root'} -p'' {db_name} | gzip > {dump_path}",
|
||||||
|
shell=True, timeout=120
|
||||||
|
)
|
||||||
|
if code == 0:
|
||||||
|
tar.add(dump_path, arcname='database.sql.gz')
|
||||||
|
os.remove(dump_path)
|
||||||
|
|
||||||
|
size = os.path.getsize(backup_path)
|
||||||
|
return True, backup_path, size
|
||||||
|
except Exception as e:
|
||||||
|
return False, '', 0
|
||||||
|
|
||||||
|
def restore_backup(backup_path, site_path, site_name):
|
||||||
|
"""恢复备份"""
|
||||||
|
try:
|
||||||
|
# 解压到临时目录
|
||||||
|
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
|
||||||
|
|
||||||
|
# 复制回站点目录
|
||||||
|
for item in os.listdir(src_dir):
|
||||||
|
src = os.path.join(src_dir, item)
|
||||||
|
dst = os.path.join(site_path, item)
|
||||||
|
if os.path.isdir(src):
|
||||||
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
return True, '恢复成功'
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def run_security_update():
|
||||||
|
"""执行系统安全更新"""
|
||||||
|
code, out, err = _run(['apt-get', 'update'], timeout=120)
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
|
||||||
|
# 只安装安全更新(带 --only-upgrade 是安全的做法)
|
||||||
|
code, out, err = _run(
|
||||||
|
['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)
|
||||||
|
|
||||||
|
nginx_running = nginx_status()
|
||||||
|
mysql_running = mysql_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'load': cpu_out,
|
||||||
|
'cpu_pct': cpu_pct.strip() + '%' if cpu_pct else 'N/A',
|
||||||
|
'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()
|
||||||
2377
frontend/index.html
Normal file
2377
frontend/index.html
Normal file
File diff suppressed because it is too large
Load diff
175
install.sh
Normal file
175
install.sh
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
#!/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
|
||||||
|
|
||||||
|
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 mysql &>/dev/null; then
|
||||||
|
echo " MySQL 已安装,跳过"
|
||||||
|
else
|
||||||
|
$PKG_MANAGER install -y mysql-server
|
||||||
|
systemctl enable mysql
|
||||||
|
systemctl start mysql
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Python3 和 pip
|
||||||
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
$PKG_MANAGER install -y python3 python3-pip python3-venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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)"
|
||||||
|
if [ -f "$SCRIPT_DIR/backend/main.py" ]; then
|
||||||
|
cp -r "$SCRIPT_DIR/backend" /opt/tpanel/
|
||||||
|
cp -r "$SCRIPT_DIR/frontend" /opt/tpanel/
|
||||||
|
cp "$SCRIPT_DIR/requirements.txt" /opt/tpanel/
|
||||||
|
cp "$SCRIPT_DIR/SPEC.md" /opt/tpanel/
|
||||||
|
echo " 源码已复制到 /opt/tpanel"
|
||||||
|
fi
|
||||||
|
|
||||||
|
chown -R tpanel:tpanel /opt/tpanel
|
||||||
|
|
||||||
|
echo "==> 4/7 安装 Python 依赖..."
|
||||||
|
cd /opt/tpanel
|
||||||
|
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 mysql.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 ""
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
flask==3.0.3
|
||||||
|
flask-cors==4.0.0
|
||||||
|
APScheduler==3.10.4
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
certbot==2.11.0
|
||||||
Loading…
Reference in a new issue