diff --git a/api.go b/api.go index 2e59b3f..e514d8d 100644 --- a/api.go +++ b/api.go @@ -49,7 +49,7 @@ func SetupRouter() *gin.Engine { api.GET("/check/users", checkUsersHandler) api.POST("/register", registerHandler) api.POST("/login", loginHandler) - api.GET("/ping", pingHandler) // ← 移到这里 + api.GET("/ping", pingHandler) // ---- 需要认证的路由 ---- auth := api.Group("/") @@ -80,7 +80,7 @@ func SetupRouter() *gin.Engine { return r } -// ========== 所有 Handler ========== +// ========== 认证 Handler ========== func checkUsersHandler(c *gin.Context) { count, err := CountUsers() @@ -148,36 +148,6 @@ func registerHandler(c *gin.Context) { }) } -// Ping 延迟检测 -func pingHandler(c *gin.Context) { - target := c.Query("target") - if target == "" { - c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "缺少 target 参数"}) - return - } - - // 获取全局配置(获取服务端端口) - cfg, err := GetGlobalConfig() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"}) - return - } - - port := cfg.ServerPort - address := net.JoinHostPort(target, strconv.Itoa(port)) - - start := time.Now() - conn, err := net.DialTimeout("tcp", address, 5*time.Second) - if err != nil { - c.JSON(http.StatusOK, gin.H{"code": 1, "msg": "ping 失败", "latency": -1}) - return - } - conn.Close() - - latency := time.Since(start).Milliseconds() - c.JSON(http.StatusOK, gin.H{"code": 0, "latency": latency}) -} - func loginHandler(c *gin.Context) { var req struct { Username string `json:"username"` @@ -258,6 +228,8 @@ func changePasswordHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "密码修改成功"}) } +// ========== 配置 Handler ========== + func getConfigHandler(c *gin.Context) { cfg, err := GetGlobalConfig() if err != nil { @@ -293,6 +265,8 @@ func updateConfigHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"}) } +// ========== 隧道 Handler ========== + func getProxiesHandler(c *gin.Context) { proxies, err := GetProxies() if err != nil { @@ -383,6 +357,8 @@ func deleteProxyHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道删除成功"}) } +// ========== frpc 进程管理 Handler ========== + func reloadFrpcHandler(c *gin.Context) { if err := GenerateFrpcConfig(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) @@ -420,6 +396,144 @@ func getFrpcStatusHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"running": running}}) } +// ========== 日志 Handler ========== + +func getFrpcLogHandler(c *gin.Context) { + lines, err := readTailLog("./frpc.log", 200) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "data": gin.H{ + "lines": []string{}, + "total": 0, + "error": err.Error(), + }, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "data": gin.H{ + "lines": lines, + "total": len(lines), + }, + }) +} + +// readTailLog 读取文件末尾 n 行(倒序读取,返回正序) +func readTailLog(filePath string, n int) ([]string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + // 获取文件大小 + info, err := file.Stat() + if err != nil { + return nil, err + } + fileSize := info.Size() + if fileSize == 0 { + return []string{}, nil + } + + // 从末尾开始读 + const chunkSize = 4096 + var lines []string + var leftover []byte + offset := fileSize + + for len(lines) < n && offset > 0 { + // 计算读取起点 + readSize := chunkSize + if offset < int64(chunkSize) { + readSize = int(offset) + } + offset -= int64(readSize) + + // 读取这一块 + buf := make([]byte, readSize) + _, err := file.ReadAt(buf, offset) + if err != nil && err != io.EOF { + return nil, err + } + + // 将当前块与之前剩下的拼接 + data := append(buf, leftover...) + leftover = nil + + // 按行分割(从后往前) + start := 0 + for i := len(data) - 1; i >= 0; i-- { + if data[i] == '\n' { + if i+1 < len(data) { + line := string(data[i+1:]) + if line != "" { + lines = append([]string{line}, lines...) + if len(lines) >= n { + break + } + } + } + start = i + } + } + + // 如果还没凑够,把未处理的部分留到下一轮 + if len(lines) < n && start > 0 { + leftover = data[:start] + } + } + + // 处理最后剩余部分(文件开头) + if len(lines) < n && len(leftover) > 0 { + parts := strings.Split(string(leftover), "\n") + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "" { + lines = append([]string{parts[i]}, lines...) + if len(lines) >= n { + break + } + } + } + } + + return lines, nil +} + +// ========== Ping Handler ========== + +func pingHandler(c *gin.Context) { + target := c.Query("target") + if target == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "缺少 target 参数"}) + return + } + + cfg, err := GetGlobalConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"}) + return + } + + port := cfg.ServerPort + address := net.JoinHostPort(target, strconv.Itoa(port)) + + start := time.Now() + conn, err := net.DialTimeout("tcp", address, 5*time.Second) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "msg": "ping 失败", "latency": -1}) + return + } + conn.Close() + + latency := time.Since(start).Milliseconds() + c.JSON(http.StatusOK, gin.H{"code": 0, "latency": latency}) +} + +// ========== 导入/导出 TOML ========== + func importTomlHandler(c *gin.Context) { file, err := c.FormFile("file") if err != nil { @@ -505,14 +619,22 @@ func ExportTomlHandler(c *gin.Context) { } } + // 构建与 GenerateFrpcConfig 一致的数据结构 data := struct { *GlobalConfig - Proxies []Proxy + Proxies []Proxy + WireProtocolLine string }{ GlobalConfig: cfg, Proxies: activeProxies, } + if cfg.WireProtocolV2 { + data.WireProtocolLine = `wireProtocol = "v2"` + } else { + data.WireProtocolLine = "" + } + tmpl, err := template.New("frpc").Parse(FrpcTemplateContent) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()}) @@ -530,114 +652,6 @@ func ExportTomlHandler(c *gin.Context) { c.String(http.StatusOK, buf.String()) } -// ========== 日志读取 ========== - -func getFrpcLogHandler(c *gin.Context) { - // 读取 ./frpc.log,最多返回 200 行(最新的 200 行) - lines, err := readTailLog("./frpc.log", 200) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "data": gin.H{ - "lines": []string{}, - "total": 0, - "error": err.Error(), - }, - }) - return - } - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "data": gin.H{ - "lines": lines, - "total": len(lines), - }, - }) -} - -// readTailLog 读取文件末尾 n 行(倒序读取,返回正序) -func readTailLog(filePath string, n int) ([]string, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - defer file.Close() - - // 获取文件大小 - info, err := file.Stat() - if err != nil { - return nil, err - } - fileSize := info.Size() - if fileSize == 0 { - return []string{}, nil - } - - // 从末尾开始读 - const chunkSize = 4096 - var lines []string - var leftover []byte - offset := fileSize - - for len(lines) < n && offset > 0 { - // 计算读取起点 - readSize := chunkSize - if offset < int64(chunkSize) { - readSize = int(offset) - } - offset -= int64(readSize) - - // 读取这一块 - buf := make([]byte, readSize) - _, err := file.ReadAt(buf, offset) - if err != nil && err != io.EOF { - return nil, err - } - - // 将当前块与之前剩下的拼接 - data := append(buf, leftover...) - leftover = nil - - // 按行分割 - start := 0 - for i := len(data) - 1; i >= 0; i-- { - if data[i] == '\n' { - if i+1 < len(data) { - line := string(data[i+1:]) - if line != "" { - lines = append([]string{line}, lines...) - if len(lines) >= n { - break - } - } - } - start = i - } - } - - // 如果还没凑够,把未处理的部分留到下一轮 - if len(lines) < n && start > 0 { - leftover = data[:start] - } - } - - // 处理最后剩余部分(文件开头) - if len(lines) < n && len(leftover) > 0 { - // 从 leftover 中提取行 - parts := strings.Split(string(leftover), "\n") - for i := len(parts) - 1; i >= 0; i-- { - if parts[i] != "" { - lines = append([]string{parts[i]}, lines...) - if len(lines) >= n { - break - } - } - } - } - - return lines, nil -} - func generateAndReload() error { if err := GenerateFrpcConfig(); err != nil { return err diff --git a/db-history.go b/db-history.go index 076df75..62417b9 100644 --- a/db-history.go +++ b/db-history.go @@ -2,30 +2,23 @@ package main // ============================================================ // db-history.go - Schema 版本声明与字段映射 -// 这是整个迁移引擎的“数据源”,记录每个版本的完整 Schema 定义。 -// 迁移工具通过对比当前 Schema 与目标 Schema 的差异来决定迁移路径。 // ============================================================ -// SchemaVersionDef 记录一个 Schema 版本的完整字段定义 type SchemaVersionDef struct { - Version string // 如 "v1", "v2", "v3" - TableName string // 表名,如 "proxies" - Columns map[string]ColumnDef // 字段名 → 字段定义 + Version string + TableName string + Columns map[string]ColumnDef } -// ColumnDef 描述一个字段的结构 type ColumnDef struct { - Type string // 如 "INTEGER", "TEXT", "BOOLEAN" - NotNull bool // 是否 NOT NULL - Default string // 默认值表达式(如 "0"、"'CHANGE_ME'") - Primary bool // 是否主键 + Type string + NotNull bool + Default string + Primary bool } -// schemaHistory 存储所有已知的 Schema 版本(从旧到新排列) -// 每个版本记录的是“完整的表结构”,而不是增量变更。 -// 新增版本时,在这里追加一条记录即可。 var schemaHistory = []SchemaVersionDef{ - // v1:初始版本(frpc-console 1.0) + // v1:初始版本 { Version: "v1", TableName: "proxies", @@ -42,7 +35,6 @@ var schemaHistory = []SchemaVersionDef{ }, }, // v2:当前版本(frpc-console 2.0 LTS) - // 注意:wire_protocol_v2 是全局配置(global_config),不在 proxies 表中 { Version: "v2", TableName: "proxies", @@ -58,18 +50,8 @@ var schemaHistory = []SchemaVersionDef{ "updated_at": {Type: "DATETIME", Default: "CURRENT_TIMESTAMP"}, }, }, - // v3:未来版本(规划中) - // 示例:新增隧道分组、流量统计等字段 - // { - // Version: "v3", - // TableName: "proxies", - // Columns: map[string]ColumnDef{ - // // ... 完整字段定义 - // }, - // }, } -// getSchemaDef 根据版本号获取 Schema 定义 func getSchemaDef(version string) *SchemaVersionDef { for _, def := range schemaHistory { if def.Version == version { @@ -79,7 +61,6 @@ func getSchemaDef(version string) *SchemaVersionDef { return nil } -// getLatestSchemaDef 获取最新的 Schema 版本定义 func getLatestSchemaDef() *SchemaVersionDef { if len(schemaHistory) == 0 { return nil @@ -87,7 +68,6 @@ func getLatestSchemaDef() *SchemaVersionDef { return &schemaHistory[len(schemaHistory)-1] } -// schemaVersionsEqual 判断两个 Schema 版本是否完全相同 func schemaVersionsEqual(v1, v2 *SchemaVersionDef) bool { if v1 == nil || v2 == nil { return false diff --git a/db.go b/db.go index f14a0d0..c6eb933 100644 --- a/db.go +++ b/db.go @@ -22,14 +22,13 @@ var DB *sql.DB // ============================================================ const ( - SchemaVersion = "v2" // 当前数据库 Schema 版本(与 schemaHistory 中的版本对应) + SchemaVersion = "v2" // 当前数据库 Schema 版本 ) // ============================================================ // 数据模型 // ============================================================ -// GlobalConfig 全局配置表 type GlobalConfig struct { ID int `json:"id"` ServerAddr string `json:"serverAddr"` @@ -42,10 +41,9 @@ type GlobalConfig struct { HeartbeatInterval int `json:"heartbeatInterval"` HeartbeatTimeout int `json:"heartbeatTimeout"` PoolCount int `json:"poolCount"` - WireProtocolV2 bool `json:"wireProtocolV2"` // v2 协议全局开关 + WireProtocolV2 bool `json:"wireProtocolV2"` } -// Proxy 隧道表(不含 wire_protocol_v2) type Proxy struct { ID int `json:"id"` Name string `json:"name"` @@ -56,7 +54,6 @@ type Proxy struct { Enabled bool `json:"enabled"` } -// User 用户表 type User struct { ID int `json:"id"` Username string `json:"username"` @@ -186,7 +183,6 @@ func createTables() error { // 迁移引擎 // ============================================================ -// getCurrentSchemaVersion 读取当前数据库的 Schema 版本 func getCurrentSchemaVersion() string { var version string err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'schema_version'").Scan(&version) @@ -205,7 +201,6 @@ func getCurrentSchemaVersion() string { return version } -// setSchemaVersion 更新 Schema 版本 func setSchemaVersion(version string) error { _, err := DB.Exec(` INSERT INTO app_config (key, value) VALUES ('schema_version', ?) @@ -214,7 +209,6 @@ func setSchemaVersion(version string) error { return err } -// backupDatabase 备份数据库文件 func backupDatabase() (string, error) { src := "./frpc-console.db" if _, err := os.Stat(src); os.IsNotExist(err) { @@ -244,7 +238,6 @@ func backupDatabase() (string, error) { return dst, nil } -// restoreDatabase 从备份恢复数据库 func restoreDatabase(backupPath string) error { srcFile, err := os.Open(backupPath) if err != nil { @@ -266,7 +259,6 @@ func restoreDatabase(backupPath string) error { return nil } -// runMigrations 执行迁移 func runMigrations() error { currentVer := getCurrentSchemaVersion() targetVer := SchemaVersion @@ -274,7 +266,14 @@ func runMigrations() error { log.Printf("📌 当前数据库 Schema: %s, 目标版本: %s", currentVer, targetVer) if currentVer == targetVer { - log.Println("✅ Schema 已是最新,无需迁移") + // 检查数据库是否包含有效数据 + var userCount int + err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) + if err != nil || userCount == 0 { + log.Println(" 数据库为空或无效,无需迁移,直接初始化") + return nil + } + log.Println("✅ Schema 已是最新,数据库有效") return nil } @@ -297,6 +296,14 @@ func runMigrations() error { if currentSchema == nil || schemaVersionsEqual(currentSchema, targetSchema) { log.Println(" 迁移类型: 轻量复制(Schema 无变更)") + // 验证数据库是否有效 + var userCount int + err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) + if err != nil || userCount == 0 { + log.Println(" 数据库为空或无效,跳过迁移,直接初始化") + return nil + } + log.Println(" 数据库有效,继续使用") } else { log.Println(" 迁移类型: 重型迁移(Schema 有变更,新建表 + 搬数据)") if err := heavyMigration(currentSchema, targetSchema); err != nil { @@ -318,7 +325,6 @@ func runMigrations() error { return nil } -// heavyMigration 重型迁移 func heavyMigration(oldDef, newDef *SchemaVersionDef) error { if oldDef == nil { return fmt.Errorf("旧 Schema 定义为空,无法执行重型迁移") @@ -366,7 +372,6 @@ func heavyMigration(oldDef, newDef *SchemaVersionDef) error { return nil } -// buildCreateTableSQL 根据 Schema 定义生成 CREATE TABLE 语句 func buildCreateTableSQL(tableName string, def *SchemaVersionDef) string { var cols []string var primaryKey string @@ -400,7 +405,6 @@ func buildCreateTableSQL(tableName string, def *SchemaVersionDef) string { return fmt.Sprintf("CREATE TABLE %s (\n %s\n)", tableName, strings.Join(cols, ",\n ")) } -// buildInsertSQL 构建 INSERT INTO new_table SELECT ... FROM old_table func buildInsertSQL(oldTable, newTable string, oldDef, newDef *SchemaVersionDef) (string, error) { newCols := make([]string, 0, len(newDef.Columns)) for name := range newDef.Columns { diff --git a/deploy.sh b/deploy.sh index b3c2971..06fc506 100644 --- a/deploy.sh +++ b/deploy.sh @@ -14,14 +14,24 @@ set -e -# ---------- 颜色输出 ---------- -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -MAGENTA='\033[0;35m' -NC='\033[0m' +# ---------- 颜色检测 ---------- +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + MAGENTA='\033[0;35m' + NC='\033[0m' +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + CYAN='' + MAGENTA='' + NC='' +fi # ---------- 配置 ---------- REPO_URL="https://git.whitetop.xyz/lxh2875931338/frpc-console.git" @@ -144,35 +154,30 @@ detect_arch() { # ---------- 检测工具 ---------- check_tools() { - # git if command -v git &> /dev/null; then HAS_GIT=true else NEED_INSTALL_GIT=true fi - # curl if command -v curl &> /dev/null; then HAS_CURL=true else NEED_INSTALL_CURL=true fi - # wget if command -v wget &> /dev/null; then HAS_WGET=true else NEED_INSTALL_WGET=true fi - # Go if command -v go &> /dev/null; then HAS_GO=true else NEED_INSTALL_GO=true fi - # Docker if command -v docker &> /dev/null; then HAS_DOCKER=true fi @@ -188,30 +193,6 @@ check_container() { fi } -# ---------- 获取系统包管理器 ---------- -get_package_manager() { - case $OS in - opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll|opensuse-leap) - echo "zypper" - ;; - ubuntu|debian|linuxmint) - echo "apt" - ;; - centos|rhel|fedora|rocky|almalinux) - echo "yum" - ;; - alpine) - echo "apk" - ;; - arch|manjaro|endeavouros) - echo "pacman" - ;; - *) - echo "unknown" - ;; - esac -} - # ---------- 检测结果汇总 ---------- print_environment_summary() { print_title @@ -226,7 +207,7 @@ print_environment_summary() { if [ "$HAS_GIT" = true ]; then echo -e " git ✅ 已安装 ($(git --version | awk '{print $3}'))" else - echo -e " git ❌ 未安装 (将自动安装)" + echo " git ❌ 未安装 (将自动安装)" fi if [ "$HAS_CURL" = true ]; then echo " curl ✅ 已安装" @@ -262,15 +243,15 @@ print_environment_summary() { exit 1 fi - # 容器状态 if [ "$CONTAINER_EXISTS" = true ]; then echo "" echo " ${CYAN}容器状态:${NC}" if [ "$CONTAINER_RUNNING" = true ]; then - echo -e " frpc-console ✅ 运行中" + echo -e " frpc-console ✅ 运行中 (将停止并重建)" else - echo -e " frpc-console ⏸️ 已存在但未运行" + echo -e " frpc-console ⏸️ 已停止 (将重建)" fi + echo -e " ${YELLOW}数据目录中的数据库文件将被保留${NC}" fi echo "" @@ -335,12 +316,17 @@ confirm_deploy() { echo -e "${GREEN}▶ 已启用 --yes,自动确认${NC}" return 0 fi + echo -e -n "${CYAN}确认执行? 输入 Y 继续,输入 n 自定义配置 [Y/n]: ${NC}" - # 强制从 /dev/tty 读取,而不是继承 stdin read -r CONFIRM /dev/null || true - parse_args "$@" check_root - # ---- 环境检测 ---- - print_step "正在检测环境..." + print_step "检测环境..." detect_os detect_arch check_tools check_container - # ---- 展示检测结果 ---- print_environment_summary - # ---- 如果只检测 ---- if [ "$CHECK_ONLY" = true ]; then print_info "环境检测完成(--check 模式,不执行部署)" exit 0 fi - # ---- 如果 Docker 未安装 ---- if [ "$HAS_DOCKER" = false ]; then print_error "Docker 未安装,请先安装 Docker" echo "" @@ -610,13 +597,10 @@ main() { exit 1 fi - # ---- 生成部署计划 ---- generate_plan - # ---- 展示部署计划 ---- print_deployment_plan - # ---- 确认或自定义 ---- if ! confirm_deploy; then custom_config print_deployment_plan @@ -626,15 +610,12 @@ main() { fi fi - # ---- 如果只是演练 ---- if [ "$DRY_RUN" = true ]; then print_info "演练模式(--dry-run),不实际执行部署" exit 0 fi - # ---- 执行部署 ---- do_deploy } -# ---------- 入口 ---------- main "$@" \ No newline at end of file diff --git a/static/app.js b/static/app.js index d64c224..585cb6b 100644 --- a/static/app.js +++ b/static/app.js @@ -29,7 +29,6 @@ async function apiFetch(endpoint, options = {}) { headers: { ...headers, ...(options.headers || {}) }, }); - // ---- 统一处理 401 认证失效 ---- if (res.status === 401) { clearAuthState(); window.dispatchEvent(new CustomEvent("auth:expired")); @@ -298,7 +297,7 @@ const app = createApp({ let logTimer = null; let logFetching = false; - // ---- Ping 延迟检测(移到 setup 内部) ---- + // ---- Ping 延迟检测 ---- const pingLatency = ref(null); let pingTimer = null; const PING_INTERVAL_MS = 30000; @@ -313,7 +312,6 @@ const app = createApp({ const pingIcon = computed(() => { if (pingLatency.value === null) { - // 未接入/失败 —— 断开图标 return ` @@ -323,13 +321,11 @@ const app = createApp({ `; } if (pingLatency.value < 1000) { - // 延迟好 —— 实心信号图标(青色/绿色) return ` `; } - // 延迟一般(1000-5000ms)—— 空心信号图标(黄色/橙色) return ` @@ -337,7 +333,6 @@ const app = createApp({ }); async function doPing() { - // 如果 frpc 未运行,直接显示 --ms if (!frpcRunning.value) { pingLatency.value = null; return; @@ -347,9 +342,7 @@ const app = createApp({ try { const res = await fetch( `/api/ping?target=${encodeURIComponent(addr)}`, - { - signal: AbortSignal.timeout(PING_TIMEOUT_MS), - }, + { signal: AbortSignal.timeout(PING_TIMEOUT_MS) }, ); if (!res.ok) throw new Error("Ping failed"); const end = performance.now(); @@ -408,7 +401,7 @@ const app = createApp({ watch(frpcRunning, (running) => { if (!running) { stopPingPolling(); - pingLatency.value = null; // 显示 --ms + pingLatency.value = null; } else if (loggedIn.value) { startPingPolling(); } @@ -810,7 +803,6 @@ const app = createApp({ showToken, - // ---- 新增 Ping 相关导出 ---- pingLatency, pingStatusClass, pingIcon, @@ -818,4 +810,4 @@ const app = createApp({ }, }); -app.mount("#app"); +app.mount("#app"); \ No newline at end of file