This commit is contained in:
zhang-pu 2026-06-01 10:15:58 +08:00
commit 7788ce489e
12 changed files with 5265 additions and 0 deletions

326
SPEC.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

175
install.sh Normal file
View 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
View 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