diff --git a/build.exe b/build.exe index 8193196..e72931a 100644 Binary files a/build.exe and b/build.exe differ diff --git a/db.go b/db.go index ec458d1..9e0f969 100644 --- a/db.go +++ b/db.go @@ -4,15 +4,31 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "fmt" + "io" "log" + "os" "strings" + "time" _ "modernc.org/sqlite" ) var DB *sql.DB -// ===== 全局配置表(A表)===== +// ============================================================ +// 数据库版本常量 +// ============================================================ + +const ( + SchemaVersion = "2.0.0" // 当前数据库 Schema 版本,与项目版本同步 +) + +// ============================================================ +// 数据模型 +// ============================================================ + +// GlobalConfig 全局配置表 type GlobalConfig struct { ID int `json:"id"` ServerAddr string `json:"serverAddr"` @@ -25,10 +41,10 @@ type GlobalConfig struct { HeartbeatInterval int `json:"heartbeatInterval"` HeartbeatTimeout int `json:"heartbeatTimeout"` PoolCount int `json:"poolCount"` - WireProtocolV2 bool `json:"wireProtocolV2"` // frp v2 协议开关 (v2.0 启用) + WireProtocolV2 bool `json:"wireProtocolV2"` // v2.0 正式启用 } -// ===== 隧道表(B表)===== +// Proxy 隧道表 type Proxy struct { ID int `json:"id"` Name string `json:"name"` @@ -39,7 +55,7 @@ type Proxy struct { Enabled bool `json:"enabled"` } -// ===== 用户表(C表)===== +// User 用户表 type User struct { ID int `json:"id"` Username string `json:"username"` @@ -58,8 +74,32 @@ func InitDB() error { return err } - // ---- 用户表 ---- - _, err = DB.Exec(` + // ---- 创建所有表 ---- + if err := createTables(); err != nil { + return err + } + + // ---- 执行版本迁移 ---- + if err := runMigrations(); err != nil { + return err + } + + // ---- 确保 JWT 密钥存在 ---- + if err := ensureJwtSecret(); err != nil { + return err + } + + log.Println("✅ 数据库初始化完成 (Schema v" + SchemaVersion + ")") + return nil +} + +// ============================================================ +// 建表 +// ============================================================ + +func createTables() error { + // 用户表 + _, err := DB.Exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, @@ -72,7 +112,7 @@ func InitDB() error { return err } - // ---- 全局配置表 ---- + // 全局配置表 _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS global_config ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -94,29 +134,7 @@ func InitDB() error { return err } - // ---- 迁移:自动补全缺失的字段 ---- - if err := migrateGlobalConfigTable(); err != nil { - log.Printf("⚠️ 迁移警告: %v", err) - } - - // ---- 初始化默认配置(仅当表为空时) ---- - var count int - DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count) - if count == 0 { - _, err = DB.Exec(` - INSERT INTO global_config ( - id, server_addr, server_port, token, log_level, log_max_days, - tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count, - wire_protocol_v2 - ) VALUES (1, 'frp.example.com', 7000, 'CHANGE_ME', 'info', 3, 1, 30, 15, 70, 8, 0) - `) - if err != nil { - return err - } - log.Println("✅ 全局配置初始化完成") - } - - // ---- 隧道表 ---- + // 隧道表 _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS proxies ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -134,7 +152,7 @@ func InitDB() error { return err } - // ---- 应用配置表 ---- + // 应用配置表(存储 JWT 密钥、Schema 版本等) _, err = DB.Exec(` CREATE TABLE IF NOT EXISTS app_config ( key TEXT PRIMARY KEY, @@ -146,66 +164,214 @@ func InitDB() error { return err } - if err := ensureJwtSecret(); err != nil { - return err + // 初始化默认配置(仅当表为空时) + var count int + DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count) + if count == 0 { + _, err = DB.Exec(` + INSERT INTO global_config ( + id, server_addr, server_port, token, log_level, log_max_days, + tcp_mux, tcp_mux_keepalive, heartbeat_interval, heartbeat_timeout, pool_count, + wire_protocol_v2 + ) VALUES (1, 'frp.example.com', 7000, 'CHANGE_ME', 'info', 3, 1, 30, 15, 70, 8, 0) + `) + if err != nil { + return err + } + log.Println("✅ 全局配置初始化完成") } - log.Println("✅ 数据库初始化完成") return nil } // ============================================================ -// Schema 迁移:自动补全缺失的字段 +// 迁移引擎 // ============================================================ -// expectedColumns 定义所有期望的列,格式: 列名 => 完整定义 -// 新增字段时,在这里加一行即可 -var expectedColumns = map[string]string{ - "id": "id INTEGER PRIMARY KEY CHECK (id = 1)", - "server_addr": "server_addr TEXT NOT NULL DEFAULT 'frp.example.com'", - "server_port": "server_port INTEGER NOT NULL DEFAULT 7000", - "token": "token TEXT NOT NULL DEFAULT 'CHANGE_ME'", - "log_level": "log_level TEXT NOT NULL DEFAULT 'info'", - "log_max_days": "log_max_days INTEGER NOT NULL DEFAULT 3", - "tcp_mux": "tcp_mux INTEGER NOT NULL DEFAULT 1", - "tcp_mux_keepalive": "tcp_mux_keepalive INTEGER NOT NULL DEFAULT 30", - "heartbeat_interval": "heartbeat_interval INTEGER NOT NULL DEFAULT 15", - "heartbeat_timeout": "heartbeat_timeout INTEGER NOT NULL DEFAULT 70", - "pool_count": "pool_count INTEGER NOT NULL DEFAULT 8", - "wire_protocol_v2": "wire_protocol_v2 INTEGER NOT NULL DEFAULT 0", // v1.5 新增 - "updated_at": "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP", +// getSchemaVersion 读取当前数据库的 Schema 版本 +func getSchemaVersion() string { + var version string + err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'schema_version'").Scan(&version) + if err != nil { + if err == sql.ErrNoRows { + // 没有版本记录 → 首次启动或 v1.x 升级 + // 检查是否已有数据(通过 users 表判断) + var count int + DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if count > 0 { + // 有用户数据 → 这是 v1.x 升级,标记为 1.5 + return "1.5.0" + } + // 全新安装 → 直接标记为当前版本 + return SchemaVersion + } + log.Printf("⚠️ 读取 Schema 版本失败: %v", err) + return "1.5.0" // 保守降级 + } + return version } -func migrateGlobalConfigTable() error { - // 1. 获取当前表的列名 - currentCols, err := getCurrentColumns("global_config") - if err != nil { - return err +// setSchemaVersion 更新 Schema 版本 +func setSchemaVersion(version string) error { + _, err := DB.Exec(` + INSERT INTO app_config (key, value) VALUES ('schema_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + `, version) + return err +} + +// backupDatabase 备份数据库文件 +func backupDatabase() (string, error) { + src := "./frpc-console.db" + if _, err := os.Stat(src); os.IsNotExist(err) { + return "", nil // 数据库不存在,无需备份 } - // 2. 比对并补全缺失的列 - var added []string - for colName, colDef := range expectedColumns { - if !contains(currentCols, colName) { - alterSQL := "ALTER TABLE global_config ADD COLUMN " + colDef - if _, err := DB.Exec(alterSQL); err != nil { - log.Printf("⚠️ 添加列 %s 失败: %v", colName, err) - continue + timestamp := time.Now().Format("20060102_150405") + dst := fmt.Sprintf("./frpc-console.db.pre-v%s.%s", SchemaVersion, timestamp) + + srcFile, err := os.Open(src) + if err != nil { + return "", fmt.Errorf("打开源数据库失败: %w", err) + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return "", fmt.Errorf("创建备份文件失败: %w", err) + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return "", fmt.Errorf("复制数据库失败: %w", err) + } + + log.Printf("✅ 数据库备份完成: %s", dst) + return dst, nil +} + +// runMigrations 执行版本迁移 +func runMigrations() error { + currentVersion := getSchemaVersion() + log.Printf("📌 当前数据库 Schema: %s, 目标版本: %s", currentVersion, SchemaVersion) + + if currentVersion == SchemaVersion { + log.Println("✅ Schema 已是最新,无需迁移") + return nil + } + + log.Printf("🔄 检测到版本变更 (%s → %s),开始迁移...", currentVersion, SchemaVersion) + + // ---- 1. 备份数据库 ---- + backupPath, err := backupDatabase() + if err != nil { + return fmt.Errorf("备份数据库失败: %w", err) + } + if backupPath != "" { + log.Printf("📦 备份文件: %s", backupPath) + } else { + log.Println("📦 数据库为空,跳过备份") + } + + // ---- 2. 执行迁移 ---- + // 按照版本号逐个升级 + migrations := []struct { + from string + upgrade func() error + }{ + {"1.5.0", migrateFrom1_5_0}, + {"2.0.0", migrateFrom2_0_0}, // 预留,实际无操作 + } + + applied := 0 + for _, m := range migrations { + if currentVersion == m.from { + log.Printf(" 执行迁移: %s → %s", m.from, SchemaVersion) + if err := m.upgrade(); err != nil { + // 迁移失败,尝试恢复备份 + if backupPath != "" { + log.Printf("❌ 迁移失败,尝试恢复备份: %s", backupPath) + if restoreErr := restoreDatabase(backupPath); restoreErr != nil { + log.Printf("⚠️ 恢复备份失败: %v", restoreErr) + } + } + return fmt.Errorf("迁移失败: %w", err) } - added = append(added, colName) + applied++ + currentVersion = SchemaVersion + break } } - if len(added) > 0 { - log.Printf("✅ 数据库迁移完成,新增字段: %v", added) - } else { - log.Println("✅ 数据库 Schema 已是最新") + // ---- 3. 更新 Schema 版本 ---- + if err := setSchemaVersion(SchemaVersion); err != nil { + return fmt.Errorf("更新 Schema 版本失败: %w", err) } + log.Printf("✅ 迁移完成,应用了 %d 个迁移", applied) return nil } -// getCurrentColumns 查询指定表的所有列名 +// restoreDatabase 从备份恢复数据库 +func restoreDatabase(backupPath string) error { + srcFile, err := os.Open(backupPath) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create("./frpc-console.db") + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + + log.Printf("✅ 数据库已从备份恢复: %s", backupPath) + return nil +} + +// ============================================================ +// 迁移函数(各版本) +// ============================================================ + +// migrateFrom1_5_0: v1.5 → v2.0 +// v1.5 已经有 wire_protocol_v2 字段(灰标占位),v2.0 无需新增字段 +// 但需要确保字段存在(兼容从 v1.0 直接升级的场景) +func migrateFrom1_5_0() error { + log.Println(" 迁移: v1.5.0 → v2.0.0") + + // 检查并补全 wire_protocol_v2 字段(兼容从 v1.0 直接升级的场景) + cols, err := getCurrentColumns("global_config") + if err != nil { + return fmt.Errorf("获取列信息失败: %w", err) + } + + if !contains(cols, "wire_protocol_v2") { + log.Println(" 添加字段: wire_protocol_v2") + _, err := DB.Exec("ALTER TABLE global_config ADD COLUMN wire_protocol_v2 INTEGER NOT NULL DEFAULT 0") + if err != nil { + return fmt.Errorf("添加 wire_protocol_v2 字段失败: %w", err) + } + } + + log.Println(" ✅ v1.5.0 → v2.0.0 迁移完成") + return nil +} + +// migrateFrom2_0_0: 预留,v2.0 → 未来版本 +func migrateFrom2_0_0() error { + log.Println(" v2.0.0 已是当前版本,无需迁移") + return nil +} + +// ============================================================ +// 辅助函数 +// ============================================================ + func getCurrentColumns(tableName string) ([]string, error) { rows, err := DB.Query("PRAGMA table_info(" + tableName + ")") if err != nil { @@ -263,7 +429,7 @@ func ensureJwtSecret() error { if err != nil { return err } - log.Printf("✅ JWT 密钥已生成并保存到数据库") + log.Printf("✅ JWT 密钥已生成") return nil } diff --git a/frp.go b/frp.go index 917c808..85b63b9 100644 --- a/frp.go +++ b/frp.go @@ -27,10 +27,10 @@ var ( frpcPathMutex sync.Mutex ) -// getFrpcPath 获取 frpc 路径,优先级: -// 1. 当前目录 ./bin/ (便于用户手动管理 frpc 版本) -// 2. embed 解压到 /tmp (便于开箱即用) -// 3. 系统 PATH 中的 frpc (降级方案) +// ============================================================ +// 获取 frpc 路径 +// ============================================================ + func getFrpcPath() (string, error) { frpcPathMutex.Lock() defer frpcPathMutex.Unlock() @@ -68,7 +68,7 @@ func getFrpcPath() (string, error) { return localPath, nil } - // 优先级2:embed 解压到临时目录 + // 优先级2:embed 解压 data, err := embeddedFrpc.ReadFile("bin/" + fileName) if err == nil { tmpPath := filepath.Join(os.TempDir(), "frpc") @@ -92,10 +92,13 @@ func getFrpcPath() (string, error) { return path, nil } - return "", fmt.Errorf("未找到 frpc 文件 (本地 bin/、embed、PATH 均无)") + return "", fmt.Errorf("未找到 frpc 文件") } -// GenerateFrpcConfig 生成 frpc.toml +// ============================================================ +// 生成 frpc.toml(v2.0 支持 wireProtocol) +// ============================================================ + func GenerateFrpcConfig() error { cfg, err := GetGlobalConfig() if err != nil { @@ -114,14 +117,24 @@ func GenerateFrpcConfig() error { } } + // 构建模板数据 data := struct { *GlobalConfig - Proxies []Proxy + Proxies []Proxy + WireProtocolLine string // v2 协议配置行(为空则不输出) }{ GlobalConfig: cfg, Proxies: activeProxies, } + // v2.0: 如果启用 v2 协议,生成配置行 + if cfg.WireProtocolV2 { + data.WireProtocolLine = `wireProtocol = "v2"` + } else { + data.WireProtocolLine = "" // 不输出,使用默认 v1 + } + + // 读取模板 var tmplContent string if _, err := os.Stat("frpc.tmpl"); err == nil { content, readErr := os.ReadFile("frpc.tmpl") @@ -151,7 +164,10 @@ func GenerateFrpcConfig() error { return nil } -// isFrpcRunning 通过 PID 文件检查 frpc 是否在运行 +// ============================================================ +// frpc 进程管理 +// ============================================================ + func isFrpcRunning() bool { pidData, err := os.ReadFile("./frpc.pid") if err != nil { @@ -163,7 +179,6 @@ func isFrpcRunning() bool { } if runtime.GOOS == "windows" { - // Windows 用 tasklist 检查 cmd := exec.Command("tasklist", "/FI", "PID eq", strconv.Itoa(pid)) output, err := cmd.CombinedOutput() if err != nil { @@ -172,7 +187,6 @@ func isFrpcRunning() bool { return strings.Contains(string(output), strconv.Itoa(pid)) } - // Linux/Unix: 用 kill 0 检查进程是否存在 process, err := os.FindProcess(pid) if err != nil { return false @@ -180,7 +194,6 @@ func isFrpcRunning() bool { return process.Signal(syscall.Signal(0)) == nil } -// StartFrpc 启动 frpc(独立进程,脱离父进程) func StartFrpc() error { frpcPath, err := getFrpcPath() if err != nil { @@ -197,16 +210,12 @@ func StartFrpc() error { return nil } - // 清理旧的 PID 文件 os.Remove("./frpc.pid") cmd := exec.Command(frpcPath, "-c", "./frpc.toml") setWindowHide(cmd) - - // ✅ 设置进程属性(Unix: Setsid 脱离父进程,Windows: 不处理) setSysProcAttr(cmd) - // 把输出重定向到日志文件 logFile, err := os.OpenFile("./frpc.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { return fmt.Errorf("打开日志文件失败: %w", err) @@ -218,7 +227,6 @@ func StartFrpc() error { return fmt.Errorf("启动 frpc 失败: %w", err) } - // ✅ 保存 PID 到文件 if err := os.WriteFile("./frpc.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil { return fmt.Errorf("保存 PID 失败: %w", err) } @@ -226,7 +234,6 @@ func StartFrpc() error { return nil } -// StopFrpc 停止 frpc(通过 PID 文件精准杀进程) func StopFrpc() error { if runtime.GOOS == "windows" { cmd := exec.Command("taskkill", "/F", "/IM", "frpc.exe") @@ -237,10 +244,8 @@ func StopFrpc() error { return nil } - // Linux: 读取 PID 文件精准杀进程 pidData, err := os.ReadFile("./frpc.pid") if err != nil { - // 降级到 pkill cmd := exec.Command("pkill", "-f", "frpc") if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") { return fmt.Errorf("停止 frpc 失败: %w", err) @@ -263,12 +268,10 @@ func StopFrpc() error { return nil } -// GetFrpcStatus 获取 frpc 运行状态(通过 PID 文件) func GetFrpcStatus() (bool, error) { return isFrpcRunning(), nil } -// ReloadFrpc 热加载,失败时自动降级为重启 func ReloadFrpc() error { running := isFrpcRunning() if !running { diff --git a/frpc.tmpl b/frpc.tmpl index ffe8e6f..cc2e575 100644 --- a/frpc.tmpl +++ b/frpc.tmpl @@ -15,6 +15,9 @@ tcpMuxKeepaliveInterval = {{.TcpMuxKeepalive}} heartbeatInterval = {{.HeartbeatInterval}} heartbeatTimeout = {{.HeartbeatTimeout}} poolCount = {{.PoolCount}} +{{if .WireProtocolLine}} +{{.WireProtocolLine}} +{{end}} [webServer] addr = "127.0.0.1:7400" diff --git a/readme.md b/readme.md index 67753a9..1dbbfc4 100644 --- a/readme.md +++ b/readme.md @@ -201,7 +201,7 @@ frpc-console 遵循 **“够用就好”** 的原则: ## 📝 更新日志 -### 1.0-Release (2026-07-24) +### 1.0-Release (2026-07-24)(non-LTS) · 🎉 首次发布
· ✨ 支持隧道增删改查
@@ -211,7 +211,7 @@ frpc-console 遵循 **“够用就好”** 的原则: · ✨ 多平台 frpc 自动适配
· 🐳 Docker 镜像支持 -### 1.5-Release (2026-07-25) +### 1.5-Release (2026-07-25)(non-LTS) · ✨ 全局配置页面卡片式重构(服务器连接 / 传输配置 / 日志配置)
· ✨ 新增运行日志面板(实时查看 frpc 日志,8 秒自动刷新)
@@ -221,7 +221,16 @@ frpc-console 遵循 **“够用就好”** 的原则: · 🎨 样式拆分与优化
· 🐛 修复配置页面结构错乱、日志行泛光异常等问题
-### 2.0-Release (猫猫正在修整,等待后续开发) +### 2.0-Release (LTS,跟随上游提供支持) +· 🔐 **首次启动引导** —— Web 端完成管理员注册,无需 CLI 交互 +· 📋 **隧道全生命周期管理** —— 增删改查 + 一键启用/禁用 +· 📦 **导入/导出 TOML** —— 无缝迁移现有 frpc 配置 +· 🔄 **配置热加载** —— 修改即生效,无需重启 frpc +· 🖥️ **多平台支持** —— Windows / Linux / ARM 全平台兼容 +· 🐳 **容器化就绪** —— 提供 Docker 镜像,开箱即用 +· 🎨 **深色磨砂玻璃 UI** —— 现代化视觉体验,日夜皆宜 +· 🚀 **frp v2 协议支持** —— 支持 frp 0.70.0 LTS 的 Wire Protocol v2 +· 🔒 **数据库自动迁移** —— 升级时自动备份 + 迁移,数据零丢失 --- diff --git a/script/build.go b/script/build.go index 4675a15..2369f10 100644 --- a/script/build.go +++ b/script/build.go @@ -26,8 +26,8 @@ var platforms = []Platform{ } const ( - Version = "1.5.0" - BuildTime = "2026-07-25" + Version = "2.0.0" + BuildTime = "2026-07-26" Binary = "frpc-console" ) diff --git a/static/index.html b/static/index.html index 2eed4fd..fe34cbc 100644 --- a/static/index.html +++ b/static/index.html @@ -267,10 +267,9 @@
- 即将推出
此功能为 frp 0.70.0 以上版本才能开启,若要打开必须搭配同版本 frps 使用,否则此功能不生效 diff --git a/static/style-4.css b/static/style-4.css index ff34185..86b75b7 100644 --- a/static/style-4.css +++ b/static/style-4.css @@ -155,21 +155,6 @@ padding: 6px 0; } -/* 禁用状态开关 - 灰标 */ -.switch.disabled { - opacity: 0.4; - cursor: not-allowed; - pointer-events: none; -} -.switch.disabled .slider { - background: rgba(255, 255, 255, 0.08); -} -.switch.disabled input:checked + .slider { - background: rgba(74, 124, 255, 0.25); -} -.switch.disabled .slider::before { - background: #3a3a4a; -} /* "即将推出" 徽章 */ .v2-badge { @@ -183,6 +168,12 @@ font-weight: 500; } +.v2-badge.v2-enabled { + background: rgba(99, 226, 183, 0.12); + border-color: rgba(99, 226, 183, 0.2); + color: #63e2b7; +} + .v2-hint { font-size: 12px; color: var(--text-dim);