commit 7788ce489ed7587f00c217e059753f778a90104d Author: zhang-pu <4738@163.com> Date: Mon Jun 1 10:15:58 2026 +0800 tpanel diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..934254c --- /dev/null +++ b/SPEC.md @@ -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 ) +``` + +### 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 \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..57751f8 --- /dev/null +++ b/backend/config.py @@ -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 \ No newline at end of file diff --git a/backend/cron_manager.py b/backend/cron_manager.py new file mode 100644 index 0000000..195e2aa --- /dev/null +++ b/backend/cron_manager.py @@ -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, '格式正确' \ No newline at end of file diff --git a/backend/db_init.py b/backend/db_init.py new file mode 100644 index 0000000..8b6edf0 --- /dev/null +++ b/backend/db_init.py @@ -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() \ No newline at end of file diff --git a/backend/file_manager.py b/backend/file_manager.py new file mode 100644 index 0000000..6919f25 --- /dev/null +++ b/backend/file_manager.py @@ -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) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..208a8d4 --- /dev/null +++ b/backend/main.py @@ -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'', 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//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//stop', methods=['POST']) +@require_auth +def api_sites_stop(site_id): + return jsonify({'code': 0, 'msg': '停止站点需要 reload nginx,建议通过 Nginx 命令管理'}) + +@app.route('/api/sites//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/', 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//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/', 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/', 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/', 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//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//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/', 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('/') +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) \ No newline at end of file diff --git a/backend/remote_backup.py b/backend/remote_backup.py new file mode 100644 index 0000000..fe9f3e9 --- /dev/null +++ b/backend/remote_backup.py @@ -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, + } \ No newline at end of file diff --git a/backend/ssl_manager.py b/backend/ssl_manager.py new file mode 100644 index 0000000..43e519f --- /dev/null +++ b/backend/ssl_manager.py @@ -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, + } \ No newline at end of file diff --git a/backend/system.py b/backend/system.py new file mode 100644 index 0000000..d56ed38 --- /dev/null +++ b/backend/system.py @@ -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() \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2bc4088 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,2377 @@ + + + + + +T面板 - Linux 网站管理面板 + + + + + + + + + + +
+
+
+

仪表盘

+
+
+
+
+ 系统正常 +
+ 官方网站 + +
+
+ +
+ + +
+
+
+
📁 网站总数
+
-
+
个站点
+
+
+
🗄️ 数据库
+
-
+
个数据库
+
+
+
💾 备份状态
+
-
+
份备份
+
+
+
🔐 安全更新
+
-
+
个待安装
+
+
+ +
+
🛡️
+
+
安全状态:已启用自动更新
+
系统每日 03:00 自动执行安全更新
+
+ +
+ +
+
+
+ ⚡ 服务状态 +
+
+
+
+ 🌿 +
+
Nginx
+
Web 服务器
+
+
+ 运行中 +
+
+
+ 🐬 +
+
MySQL
+
数据库服务
+
+
+ 运行中 +
+
+
+ 🐘 +
+
PHP-FPM
+
PHP 解释器
+
+
+ 运行中 +
+
+
+ 🛡️ +
+
UFW 防火墙
+
端口防护
+
+
+ 已启用 +
+
+
+ +
+
+ 💻 系统资源 +
+
+
+
+
CPU 负载
+
+ - +
+
+
+
内存
+
+ - +
+
+
+
磁盘
+
+ - +
+
+
+
负载均值
+
+ - +
+
+
+
+
+ + +
+
+
+

网站列表

+ +
+
+ + + + + + + + + + + + + +
域名PHP路径状态SSL创建时间操作
+
+
+ + +
+
+
+

数据库

+ +
+
+ + + + + + + + + + + +
数据库名用户字符集创建时间操作
+
+
+ + +
+
+
+

SSL 证书

+ +
+
+ + + + + + + + + + + +
域名证书路径到期日期自动续期操作
+
+
+ + +
+
+
+

备份记录

+ +
+
+ + + + + + + + + + + + +
站点类型文件大小状态时间操作
+
+
+ + +
+
+
+

安全中心

+ +
+
+
+
🛡️ 安全更新
+
-
+
个可用更新
+
+
+
❌ 近期错误
+
-
+
条系统错误
+
+
+
🔒 防火墙
+
-
+
状态
+
+
+
+
+ 🛡️ 自动漏洞修复 + +
+
+

T面板每日凌晨 03:00 自动执行 apt-get update && apt-get upgrade -y,修复已知安全漏洞。

+

最近执行:-

+
+
+
+ + + +
+
+
+

📁 文件管理

+
+ + + + +
+
+ + +
+ / +
+ + +
+ + + + + + + + + + + +
名称大小修改时间权限操作
+
+ + + + + + + + + +
+ + + +
+
+
+

⏰ 定时任务

+ +
+
+ + + + + + + + + + + + + +
任务名称站点执行周期命令状态上次执行操作
+
+ + + +
+ + +
+
+ +
+

👤 修改管理员密码

+
+
+ + +
+
+ + +
+
+ +
+ +
+

⚙️ 系统设置

+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+

🔗 面板域名绑定

+
+ + + + 绑定后只能通过该域名访问后台(如留空则不限制访问来源)。绑定后请确保 DNS 已解析到此服务器。 + +
+
+ +
+ ⚠️ 修改域名绑定后需重启面板服务:systemctl restart tpanel +
+
+ +
+

🌐 官方信息

+
+
+ + +
+
+ + +
+
+
+
+ + +
+

📋 安全日志

+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..8e267df --- /dev/null +++ b/install.sh @@ -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 "" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..992492a --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file