5 Commits

16 changed files with 1262 additions and 574 deletions
+27 -11
View File
@@ -1,38 +1,54 @@
# ============================================================
# 构建阶段
# 多阶段构建
# ============================================================
# ---------- 构建阶段 ----------
FROM golang:alpine AS builder
WORKDIR /app
# 复制依赖文件(如果存在 go.sum 则一并复制
# 复制依赖文件(兼容 go.sum 可能不存在的情况
COPY go.mod go.sum* ./
# 修复 go.mod 版本过高问题(frps 用 1.26.5,容器内 go 版本可能较低)
# 将 go 1.2x 降级为 go 1.21,确保兼容性
RUN sed -i 's/go 1.2[6-9].*/go 1.21/' go.mod && go mod tidy 2>/dev/null || true
# 设置 Go 代理(国内加速)
RUN go env -w GOPROXY=https://goproxy.cn,direct
# 下载依赖
RUN go mod download
# 复制全部源码(包含 bin/ 和 static/
COPY . .
# 编译 Linux 版CGO_ENABLED=0 静态编译,-p 1 降低内存峰值)
# 静态编译 Linux 版
# -p 1 降低并发编译内存峰值(适合低配机器)
# -s -w 去除调试信息,减小二进制体积
RUN CGO_ENABLED=0 GOOS=linux go build -p 1 -ldflags="-s -w" -o frps-console .
# ============================================================
# 运行阶段
# ============================================================
# ---------- 运行阶段 ----------
FROM alpine:latest
# 安装 ca-certificates 和 tzdata(确保 HTTPS 和时区正常)
RUN apk --no-cache add ca-certificates tzdata
# 安装必要工具
# - ca-certificates: HTTPS 请求(frps 需要调用 Dashboard API
# - tzdata: 时区支持
# - sqlite: 用于 deploy.sh 中检查数据库状态
RUN apk --no-cache add ca-certificates tzdata sqlite
WORKDIR /app
# 从构建阶段复制编译好的二进制
COPY --from=builder /app/frps-console /app/frps-console
# 创建数据目录(用于持久化 db 和日志
# 复制前端静态文件(从构建阶段
COPY --from=builder /app/static/ /app/static/
# 创建数据目录(用于持久化 db、配置、日志、pid)
RUN mkdir -p /app/data
# 暴露端口(与 deploy.sh 中默认端口一致)
# 暴露端口(与 deploy.sh 中 DEFAULT_PORT 保持一致)
EXPOSE 9365
# 环境变量(可被 docker run -e 覆盖)
@@ -42,5 +58,5 @@ ENV TZ=Asia/Shanghai
# 数据卷(持久化数据库、配置、日志)
VOLUME ["/app/data"]
# 启动
# 启动(使用 exec 确保信号正确传递)
ENTRYPOINT ["/app/frps-console"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 lxh2875931338XHLiang0
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+82 -129
View File
@@ -4,12 +4,12 @@ import (
"bytes"
"embed"
"encoding/json"
"io"
"io/fs" // 新增
"io/fs"
"net"
"net/http"
"os"
"strings"
"strconv"
"text/template"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
@@ -21,9 +21,11 @@ var staticFS embed.FS
func SetupRouter() *gin.Engine {
r := gin.Default()
// 从 embed 读取前端静态文件
staticSubFS, _ := fs.Sub(staticFS, "static")
r.StaticFS("/static", http.FS(staticSubFS))
// 根路由
r.GET("/", func(c *gin.Context) {
content, err := staticFS.ReadFile("static/index.html")
if err != nil {
@@ -33,25 +35,27 @@ func SetupRouter() *gin.Engine {
c.Data(http.StatusOK, "text/html; charset=utf-8", content)
})
// 健康检查
r.GET("/ping", func(c *gin.Context) {
c.String(200, "frps-console 后端已启动 🎉")
})
api := r.Group("/api")
{
// ---- 公开路由(不需要认证) ----
api.GET("/check/users", checkUsersHandler)
api.POST("/register", registerHandler)
api.POST("/login", loginHandler)
api.GET("/ping", pingHandler) // 新增:frps 延迟检测
// ---- 需要认证的路由 ----
auth := api.Group("/")
auth.Use(AuthMiddleware())
{
auth.GET("/config", getConfigHandler)
auth.PUT("/config", updateConfigHandler)
auth.GET("/frps/log", getFrpsLogHandler)
// frps 特有:客户端列表(从 Dashboard API 读取)
// frps 特有:从 Dashboard API 读取客户端和代理列表
auth.GET("/clients", getClientsHandler)
auth.GET("/proxies", getProxiesHandler)
@@ -59,6 +63,7 @@ func SetupRouter() *gin.Engine {
auth.POST("/frps/start", startFrpsHandler)
auth.POST("/frps/stop", stopFrpsHandler)
auth.GET("/frps/status", getFrpsStatusHandler)
auth.GET("/frps/log", getFrpsLogHandler)
auth.POST("/import/toml", importTomlHandler)
auth.GET("/export/toml", ExportTomlHandler)
@@ -70,9 +75,7 @@ func SetupRouter() *gin.Engine {
return r
}
// ============================================================
// 认证 Handler(与 frpc-console 相同,略)
// ============================================================
// ========== 认证 Handler(与 frps 相同) ==========
func checkUsersHandler(c *gin.Context) {
count, err := CountUsers()
@@ -112,7 +115,7 @@ func registerHandler(c *gin.Context) {
}
if !ValidatePassword(req.Password) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母数字和特殊字符"})
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母数字"})
return
}
@@ -202,7 +205,7 @@ func changePasswordHandler(c *gin.Context) {
}
if !ValidatePassword(req.NewPassword) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母数字和特殊字符"})
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母数字"})
return
}
@@ -220,9 +223,7 @@ func changePasswordHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "密码修改成功"})
}
// ============================================================
// 配置 Handler
// ============================================================
// ========== 配置 Handlerfrps 专用) ==========
func getConfigHandler(c *gin.Context) {
cfg, err := GetGlobalConfig()
@@ -239,6 +240,8 @@ func updateConfigHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return
}
// 强制 tcpMux = truefrps 推荐)
cfg.TcpMux = true
if err := UpdateGlobalConfig(&cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
@@ -258,11 +261,8 @@ func updateConfigHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"})
}
// ============================================================
// 客户端/代理列表(从 frps Dashboard API 读取)
// ============================================================
// ========== frps Dashboard API 读取 ==========
// FrpsDashboardClient frps Dashboard API 返回的客户端信息
type FrpsDashboardClient struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -273,7 +273,6 @@ type FrpsDashboardClient struct {
ConnTime string `json:"connTime"`
}
// FrpsDashboardProxy frps Dashboard API 返回的代理信息
type FrpsDashboardProxy struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -300,9 +299,8 @@ func getProxiesHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": proxies})
}
// fetchClientsFromDashboard 从 frps Dashboard API 读取客户端列表
func fetchClientsFromDashboard() ([]FrpsDashboardClient, error) {
// frps Dashboard 默认在 127.0.0.1:7500,后续可配置
// TODO: 从配置读取 Dashboard 地址(当前硬编码)
resp, err := http.Get("http://127.0.0.1:7500/api/v2/clients")
if err != nil {
return nil, err
@@ -318,7 +316,6 @@ func fetchClientsFromDashboard() ([]FrpsDashboardClient, error) {
return result.Data, nil
}
// fetchProxiesFromDashboard 从 frps Dashboard API 读取代理列表
func fetchProxiesFromDashboard() ([]FrpsDashboardProxy, error) {
resp, err := http.Get("http://127.0.0.1:7500/api/v2/proxies")
if err != nil {
@@ -335,9 +332,7 @@ func fetchProxiesFromDashboard() ([]FrpsDashboardProxy, error) {
return result.Data, nil
}
// ============================================================
// frps 进程管理 Handler
// ============================================================
// ========== frps 进程管理 Handler ==========
func reloadFrpsHandler(c *gin.Context) {
if err := GenerateFrpsConfig(); err != nil {
@@ -376,9 +371,62 @@ func getFrpsStatusHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"running": running}})
}
// ============================================================
// 导入/导出 TOML
// ============================================================
// ========== 日志 Handler ==========
func getFrpsLogHandler(c *gin.Context) {
// readTailLog 在 frps.go 中定义,读取 ./data/frps.log
lines, err := readTailLog("./data/frps.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),
},
})
}
// ========== Ping Handler(检测 frps 服务连通性) ==========
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.BindPort // frps 使用 bindPort
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")
@@ -407,11 +455,15 @@ func importTomlHandler(c *gin.Context) {
}
cfg := parsed.ToGlobalConfig()
cfg.TcpMux = true
if err := UpdateGlobalConfig(cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
return
}
// frps 没有隧道表,无需清空 proxies,但可以清空 clients 表(如果有)
// 目前 clients 表只做记录,可忽略
if err := GenerateFrpsConfig(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()})
return
@@ -435,6 +487,7 @@ func ExportTomlHandler(c *gin.Context) {
return
}
// 使用 frps 模板
tmpl, err := template.New("frps").Parse(FrpsTemplateContent)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()})
@@ -451,103 +504,3 @@ func ExportTomlHandler(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename=frps.toml")
c.String(http.StatusOK, buf.String())
}
// ============================================================
// 日志读取
// ============================================================
func getFrpsLogHandler(c *gin.Context) {
lines, err := readTailLog("./frps.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
}
+57 -11
View File
@@ -13,6 +13,22 @@ import (
"golang.org/x/crypto/bcrypt"
)
// ============================================================
// 密码策略常量(集中管理,便于后续调整)
// ============================================================
const (
// MinPasswordLength 密码最小长度
MinPasswordLength = 8
// PasswordRequirementMsg 统一错误提示(前端/后端共用)
PasswordRequirementMsg = "密码至少 8 位,需包含大小写字母和数字"
)
// ============================================================
// JWT 密钥管理
// ============================================================
var jwtSecretCache []byte
func getJwtSecret() []byte {
@@ -27,11 +43,13 @@ func getJwtSecret() []byte {
return jwtSecretCache
}
// Claims JWT 载荷
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// GenerateJWT 生成 JWT Token
func GenerateJWT(username string) (string, error) {
claims := Claims{
Username: username,
@@ -44,6 +62,7 @@ func GenerateJWT(username string) (string, error) {
return token.SignedString(getJwtSecret())
}
// ParseJWT 解析 JWT Token
func ParseJWT(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return getJwtSecret(), nil
@@ -57,25 +76,42 @@ func ParseJWT(tokenString string) (*Claims, error) {
return nil, errors.New("invalid token")
}
// ============================================================
// 密码校验(核心策略)
// ============================================================
// ValidatePassword 校验密码强度
// 规则:长度 ≥ 8 位,且同时包含大写字母、小写字母、数字
// 注意:不强制要求特殊字符(与之前版本的区别)
func ValidatePassword(pwd string) bool {
if len(pwd) < 8 {
if len(pwd) < MinPasswordLength {
return false
}
var hasUpper, hasLower, hasDigit, hasSpecial bool
var hasUpper, hasLower, hasDigit bool
for _, ch := range pwd {
if ch >= 'A' && ch <= 'Z' {
switch {
case ch >= 'A' && ch <= 'Z':
hasUpper = true
} else if ch >= 'a' && ch <= 'z' {
case ch >= 'a' && ch <= 'z':
hasLower = true
} else if ch >= '0' && ch <= '9' {
case ch >= '0' && ch <= '9':
hasDigit = true
} else if strings.ContainsAny(string(ch), "!@#$%^&*()_+-=[]{}|;:,.<>?") {
hasSpecial = true
}
// 提前退出优化:一旦三类字符都已满足,立即返回 true
if hasUpper && hasLower && hasDigit {
return true
}
}
return hasUpper && hasLower && hasDigit && hasSpecial
return hasUpper && hasLower && hasDigit
}
// ============================================================
// 命令行管理员初始化(首次启动交互式创建)
// ============================================================
// InitAdminUser 交互式创建管理员账户(仅在无用户时触发)
func InitAdminUser() {
count, err := CountUsers()
if err != nil {
@@ -85,6 +121,7 @@ func InitAdminUser() {
log.Println("✅ 已存在管理员账户,跳过初始化")
return
}
log.Println("⚠️ 首次启动,请设置管理员账户")
log.Print("用户名: ")
var username string
@@ -92,10 +129,12 @@ func InitAdminUser() {
if username == "" {
username = "admin"
}
for {
log.Print("密码 (至少8位,含大小写、数字、特殊字符): ")
log.Printf("密码 (至少 %d 位,需包含大小写字母和数字): ", MinPasswordLength)
var password string
fmt.Scanln(&password)
if ValidatePassword(password) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -110,12 +149,16 @@ func InitAdminUser() {
log.Println("✅ 管理员账户创建成功!")
break
} else {
log.Println("❌ 密码不符合复杂度要求,请重新输入")
log.Println(" 要求: 至少8位,包含大小写字母、数字和特殊字符")
log.Printf("❌ 密码不符合要求: %s", PasswordRequirementMsg)
}
}
}
// ============================================================
// Gin 中间件:JWT 认证
// ============================================================
// AuthMiddleware JWT 认证中间件
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
@@ -124,18 +167,21 @@ func AuthMiddleware() gin.HandlerFunc {
c.Abort()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "认证令牌格式错误"})
c.Abort()
return
}
claims, err := ParseJWT(parts[1])
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "无效的认证令牌"})
c.Abort()
return
}
c.Set("username", claims.Username)
c.Next()
}
+87
View File
@@ -0,0 +1,87 @@
package main
// ============================================================
// db-history.go - Schema 版本声明与字段映射(frps 版)
// ============================================================
type SchemaVersionDef struct {
Version string
TableName string
Columns map[string]ColumnDef
}
type ColumnDef struct {
Type string
NotNull bool
Default string
Primary bool
}
var schemaHistory = []SchemaVersionDef{
// v1frps 初始版本(与 2.0-lts 兼容)
{
Version: "v1",
TableName: "global_config",
Columns: map[string]ColumnDef{
"id": {Type: "INTEGER", Primary: true},
"bind_port": {Type: "INTEGER", NotNull: true, Default: "9358"},
"token": {Type: "TEXT", NotNull: true, Default: "'CHANGE_ME'"},
"log_level": {Type: "TEXT", NotNull: true, Default: "'info'"},
"log_max_days": {Type: "INTEGER", NotNull: true, Default: "3"},
"tcp_mux": {Type: "INTEGER", NotNull: true, Default: "1"},
"updated_at": {Type: "DATETIME", Default: "CURRENT_TIMESTAMP"},
},
},
// v2:当前版本(与 v1 相同,保留扩展)
{
Version: "v2",
TableName: "global_config",
Columns: map[string]ColumnDef{
"id": {Type: "INTEGER", Primary: true},
"bind_port": {Type: "INTEGER", NotNull: true, Default: "9358"},
"token": {Type: "TEXT", NotNull: true, Default: "'CHANGE_ME'"},
"log_level": {Type: "TEXT", NotNull: true, Default: "'info'"},
"log_max_days": {Type: "INTEGER", NotNull: true, Default: "3"},
"tcp_mux": {Type: "INTEGER", NotNull: true, Default: "1"},
"updated_at": {Type: "DATETIME", Default: "CURRENT_TIMESTAMP"},
},
},
}
func getSchemaDef(version string) *SchemaVersionDef {
for _, def := range schemaHistory {
if def.Version == version {
return &def
}
}
return nil
}
func getLatestSchemaDef() *SchemaVersionDef {
if len(schemaHistory) == 0 {
return nil
}
return &schemaHistory[len(schemaHistory)-1]
}
func schemaVersionsEqual(v1, v2 *SchemaVersionDef) bool {
if v1 == nil || v2 == nil {
return false
}
if v1.TableName != v2.TableName {
return false
}
if len(v1.Columns) != len(v2.Columns) {
return false
}
for name, col1 := range v1.Columns {
col2, ok := v2.Columns[name]
if !ok {
return false
}
if col1.Type != col2.Type || col1.NotNull != col2.NotNull || col1.Default != col2.Default {
return false
}
}
return true
}
+204 -67
View File
@@ -8,6 +8,8 @@ import (
"io"
"log"
"os"
"sort"
"strings"
"time"
_ "modernc.org/sqlite"
@@ -15,17 +17,9 @@ import (
var DB *sql.DB
// ============================================================
// 数据库版本常量
// ============================================================
const SchemaVersion = "v2"
const SchemaVersion = "2.0.0"
// ============================================================
// 数据模型
// ============================================================
// GlobalConfig frps 全局配置
// GlobalConfig 对应 frps 配置
type GlobalConfig struct {
ID int `json:"id"`
BindPort int `json:"bindPort"`
@@ -35,7 +29,15 @@ type GlobalConfig struct {
TcpMux bool `json:"tcpMux"`
}
// Client 客户端连接记录
// User 保持不变
type User struct {
ID int `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
CreatedAt string `json:"createdAt"`
}
// Client 记录(从 Dashboard 同步,可选)
type Client struct {
ID int `json:"id"`
Name string `json:"name"`
@@ -47,21 +49,15 @@ type Client struct {
UpdatedAt string `json:"updatedAt"`
}
// User 用户表
type User struct {
ID int `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
CreatedAt string `json:"createdAt"`
}
// ============================================================
// 数据库初始化
// ============================================================
func InitDB() error {
// 确保 data 目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
return fmt.Errorf("创建数据目录失败: %w", err)
}
dbPath := "./data/frps-console.db"
var err error
DB, err = sql.Open("sqlite", "./frps-console.db")
DB, err = sql.Open("sqlite", dbPath)
if err != nil {
return err
}
@@ -78,16 +74,12 @@ func InitDB() error {
return err
}
log.Println("✅ 数据库初始化完成 (Schema v" + SchemaVersion + ")")
log.Println("✅ 数据库初始化完成 (Schema " + SchemaVersion + ")")
return nil
}
// ============================================================
// 建表
// ============================================================
func createTables() error {
// 用户
// users
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -101,7 +93,7 @@ func createTables() error {
return err
}
// 全局配置表(frps 专用)
// global_config 表(frps 专用)
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS global_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -117,7 +109,7 @@ func createTables() error {
return err
}
// 客户端记录表
// clients 表(用于存储从 Dashboard 拉取的客户端,可选)
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -134,7 +126,7 @@ func createTables() error {
return err
}
// 应用配置
// app_config
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
@@ -151,9 +143,8 @@ func createTables() error {
DB.QueryRow("SELECT COUNT(*) FROM global_config").Scan(&count)
if count == 0 {
_, err = DB.Exec(`
INSERT INTO global_config (
id, bind_port, token, log_level, log_max_days, tcp_mux
) VALUES (1, 9358, 'CHANGE_ME', 'info', 3, 1)
INSERT INTO global_config (id, bind_port, token, log_level, log_max_days, tcp_mux)
VALUES (1, 9358, 'CHANGE_ME', 'info', 3, 1)
`)
if err != nil {
return err
@@ -164,23 +155,23 @@ func createTables() error {
return nil
}
// ============================================================
// 迁移引擎
// ============================================================
// ========== 迁移引擎(复用 frps 的 db-history.go ==========
func getSchemaVersion() string {
func getCurrentSchemaVersion() 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 {
// 尝试判断是否为旧版本
var count int
DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count > 0 {
return "1.0.0"
return "v1"
}
return SchemaVersion
}
return "1.0.0"
log.Printf("⚠️ 读取 Schema 版本失败: %v", err)
return "v1"
}
return version
}
@@ -194,13 +185,13 @@ func setSchemaVersion(version string) error {
}
func backupDatabase() (string, error) {
src := "./frps-console.db"
src := "./data/frps-console.db"
if _, err := os.Stat(src); os.IsNotExist(err) {
return "", nil
}
timestamp := time.Now().Format("20060102_150405")
dst := fmt.Sprintf("./frps-console.db.pre-v%s.%s", SchemaVersion, timestamp)
dst := fmt.Sprintf("./data/frps-console.db.pre-%s.%s", SchemaVersion, timestamp)
srcFile, err := os.Open(src)
if err != nil {
@@ -229,7 +220,7 @@ func restoreDatabase(backupPath string) error {
}
defer srcFile.Close()
dstFile, err := os.Create("./frps-console.db")
dstFile, err := os.Create("./data/frps-console.db")
if err != nil {
return err
}
@@ -244,11 +235,23 @@ func restoreDatabase(backupPath string) error {
}
func runMigrations() error {
currentVersion := getSchemaVersion()
log.Printf("📌 当前数据库 Schema: %s, 目标版本: %s", currentVersion, SchemaVersion)
currentVer := getCurrentSchemaVersion()
targetVer := SchemaVersion
if currentVersion != SchemaVersion {
log.Printf("🔄 检测到版本变更 (%s → %s),开始迁移...", currentVersion, SchemaVersion)
log.Printf("📌 当前数据库 Schema: %s, 目标版本: %s", currentVer, targetVer)
if currentVer == targetVer {
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
}
log.Printf("🔄 检测到版本变更 (%s → %s),开始迁移...", currentVer, targetVer)
backupPath, err := backupDatabase()
if err != nil {
@@ -258,21 +261,161 @@ func runMigrations() error {
log.Printf("📦 备份文件: %s", backupPath)
}
if err := setSchemaVersion(SchemaVersion); err != nil {
currentSchema := getSchemaDef(currentVer)
targetSchema := getSchemaDef(targetVer)
if targetSchema == nil {
return fmt.Errorf("目标 Schema 版本 %s 未在 schemaHistory 中定义", targetVer)
}
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 {
if backupPath != "" {
log.Printf("❌ 迁移失败,尝试恢复备份: %s", backupPath)
if restoreErr := restoreDatabase(backupPath); restoreErr != nil {
log.Printf("⚠️ 恢复备份失败: %v", restoreErr)
}
}
return fmt.Errorf("重型迁移失败: %w", err)
}
}
if err := setSchemaVersion(targetVer); err != nil {
return fmt.Errorf("更新 Schema 版本失败: %w", err)
}
log.Printf("✅ 版本迁移完成")
} else {
log.Println("✅ Schema 已是最新,无需迁移")
}
log.Printf("✅ 迁移完成,当前 Schema: %s", targetVer)
return nil
}
// ============================================================
// JWT 密钥管理
// ============================================================
func heavyMigration(oldDef, newDef *SchemaVersionDef) error {
if oldDef == nil {
return fmt.Errorf("旧 Schema 定义为空,无法执行重型迁移")
}
oldTable := oldDef.TableName
newTable := oldTable + "_new"
createSQL := buildCreateTableSQL(newTable, newDef)
log.Printf(" 创建新表: %s", newTable)
if _, err := DB.Exec(createSQL); err != nil {
return fmt.Errorf("创建新表失败: %w", err)
}
insertSQL, err := buildInsertSQL(oldTable, newTable, oldDef, newDef)
if err != nil {
return fmt.Errorf("构建数据迁移 SQL 失败: %w", err)
}
log.Printf(" 迁移数据: %s → %s", oldTable, newTable)
if _, err := DB.Exec(insertSQL); err != nil {
return fmt.Errorf("数据迁移失败: %w", err)
}
var oldCount, newCount int
DB.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", oldTable)).Scan(&oldCount)
DB.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", newTable)).Scan(&newCount)
if oldCount != newCount {
return fmt.Errorf("数据迁移不完整: 旧表 %d 行,新表 %d 行", oldCount, newCount)
}
log.Printf(" 数据迁移验证通过: %d 行", newCount)
tempTable := oldTable + "_old_temp"
if _, err := DB.Exec(fmt.Sprintf("ALTER TABLE %s RENAME TO %s", oldTable, tempTable)); err != nil {
return fmt.Errorf("重命名旧表失败: %w", err)
}
if _, err := DB.Exec(fmt.Sprintf("ALTER TABLE %s RENAME TO %s", newTable, oldTable)); err != nil {
DB.Exec(fmt.Sprintf("ALTER TABLE %s RENAME TO %s", tempTable, oldTable))
return fmt.Errorf("重命名新表失败: %w", err)
}
if _, err := DB.Exec(fmt.Sprintf("DROP TABLE %s", tempTable)); err != nil {
log.Printf("⚠️ 删除临时表失败(不影响使用): %v", err)
}
log.Printf(" 表交换完成: %s (新表已生效)", oldTable)
return nil
}
func buildCreateTableSQL(tableName string, def *SchemaVersionDef) string {
var cols []string
var primaryKey string
names := make([]string, 0, len(def.Columns))
for name := range def.Columns {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
col := def.Columns[name]
parts := []string{name, col.Type}
if col.NotNull {
parts = append(parts, "NOT NULL")
}
if col.Default != "" {
parts = append(parts, "DEFAULT "+col.Default)
}
if col.Primary {
primaryKey = "PRIMARY KEY (" + name + ")"
} else {
cols = append(cols, strings.Join(parts, " "))
}
}
if primaryKey != "" {
cols = append(cols, primaryKey)
}
return fmt.Sprintf("CREATE TABLE %s (\n %s\n)", tableName, strings.Join(cols, ",\n "))
}
func buildInsertSQL(oldTable, newTable string, oldDef, newDef *SchemaVersionDef) (string, error) {
newCols := make([]string, 0, len(newDef.Columns))
for name := range newDef.Columns {
newCols = append(newCols, name)
}
sort.Strings(newCols)
var selectParts []string
var colNames []string
for _, name := range newCols {
colNames = append(colNames, name)
if _, ok := oldDef.Columns[name]; ok {
selectParts = append(selectParts, name)
} else {
colDef := newDef.Columns[name]
if colDef.Default != "" {
selectParts = append(selectParts, colDef.Default+" AS "+name)
} else if colDef.Type == "INTEGER" {
selectParts = append(selectParts, "0 AS "+name)
} else if colDef.Type == "TEXT" {
selectParts = append(selectParts, "'' AS "+name)
} else {
selectParts = append(selectParts, "NULL AS "+name)
}
}
}
return fmt.Sprintf(
"INSERT INTO %s (%s) SELECT %s FROM %s",
newTable,
strings.Join(colNames, ", "),
strings.Join(selectParts, ", "),
oldTable,
), nil
}
// ========== JWT 密钥 ==========
func ensureJwtSecret() error {
var value string
@@ -306,9 +449,7 @@ func GetJwtSecret() (string, error) {
return secret, nil
}
// ============================================================
// 全局配置 CRUD
// ============================================================
// ========== GlobalConfig CRUD ==========
func GetGlobalConfig() (*GlobalConfig, error) {
var cfg GlobalConfig
@@ -338,9 +479,7 @@ func UpdateGlobalConfig(cfg *GlobalConfig) error {
return err
}
// ============================================================
// 客户端记录 CRUD
// ============================================================
// ========== Clients CRUD(用于同步 Dashboard ==========
func SaveClient(client *Client) error {
_, err := DB.Exec(`
@@ -372,9 +511,7 @@ func GetClients() ([]Client, error) {
return clients, rows.Err()
}
// ============================================================
// 用户 CRUD
// ============================================================
// ========== User CRUD ==========
func GetUserByUsername(username string) (*User, error) {
var u User
+324 -179
View File
@@ -1,59 +1,41 @@
#!/bin/bash
# ============================================================
# frps-console 一键部署脚本
# 支持:Linux x86_64 / ARM64 / ARMv7
# 自动安装:git / curl / wget / Go / Docker
#
# frps-console 一键部署脚本 (Docker 优先)
# 用法:
# ./deploy.sh # 完整交互流程
# ./deploy.sh --yes # 跳过确认,直接执行
# ./deploy.sh --check # 只检测环境,不执行
# ./deploy.sh --dry-run # 显示将执行的操作,不实际执行
# run-deploy.sh 引导后自动执行
# ============================================================
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/frps-console.git"
BRANCH="main"
WORK_DIR="/tmp/frps-console-build"
DEFAULT_PORT=9365
DEFAULT_DEPLOY_DIR="/opt/frps-console"
IMAGE_NAME="frps-console"
GO_VERSION="1.25.0"
KEEP_IMAGES=3
# 状态变量
OS=""
OS_VERSION=""
ARCH=""
GO_ARCH=""
HAS_GIT=false
HAS_CURL=false
HAS_WGET=false
HAS_GO=false
HAS_DOCKER=false
NEED_INSTALL_GIT=false
NEED_INSTALL_CURL=false
NEED_INSTALL_WGET=false
NEED_INSTALL_GO=false
# ---------- 状态变量 ----------
OS=""; OS_VERSION=""; ARCH=""
HAS_GIT=false; HAS_CURL=false; HAS_WGET=false; HAS_DOCKER=false
NEED_INSTALL_GIT=false; NEED_INSTALL_CURL=false; NEED_INSTALL_WGET=false
PORT=${DEFAULT_PORT}
DEPLOY_DIR=${DEFAULT_DEPLOY_DIR}
DATA_DIR="${DEPLOY_DIR}/data"
CONTAINER_EXISTS=false
CONTAINER_RUNNING=false
SKIP_CONFIRM=false
CHECK_ONLY=false
DRY_RUN=false
CONTAINER_EXISTS=false; CONTAINER_RUNNING=false
SKIP_CONFIRM=false; CHECK_ONLY=false; DRY_RUN=false
CHANNEL=""; BRANCH=""; TARGET_VERSION=""; IMAGE_TAG=""
CURRENT_VERSION=""
# ---------- 打印函数 ----------
print_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
print_success() { echo -e "${GREEN}[✓]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[⚠]${NC} $1"; }
@@ -62,7 +44,7 @@ print_step() { echo -e "\n${CYAN}▶${NC} $1"; }
print_title() { echo -e "\n${MAGENTA}════════════════════════════════════════════════════════${NC}"; }
print_subtitle() { echo -e "${MAGENTA} $1${NC}"; }
# ---------- 解析参数 ----------
# ---------- 解析命令行参数 ----------
parse_args() {
for arg in "$@"; do
case $arg in
@@ -71,9 +53,8 @@ parse_args() {
--dry-run) DRY_RUN=true ;;
--help|-h)
echo "用法: ./deploy.sh [选项]"
echo ""
echo "选项:"
echo " --yes, -y 跳过所有确认提示,直接执行"
echo " --yes, -y 跳过所有确认提示"
echo " --check 只检测环境,不执行部署"
echo " --dry-run 显示将执行的操作,不实际执行"
echo " --help, -h 显示帮助信息"
@@ -84,7 +65,6 @@ parse_args() {
done
}
# ---------- 检查 root ----------
check_root() {
if [ "$EUID" -ne 0 ]; then
print_error "请使用 root 权限运行此脚本"
@@ -93,12 +73,10 @@ check_root() {
fi
}
# ---------- 检测环境 ----------
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
OS_VERSION=$VERSION_ID
OS=$ID; OS_VERSION=$VERSION_ID
else
print_error "无法识别操作系统"
exit 1
@@ -108,10 +86,13 @@ detect_os() {
detect_arch() {
ARCH=$(uname -m)
case $ARCH in
x86_64|amd64) GO_ARCH="amd64" ;;
aarch64|arm64) GO_ARCH="arm64" ;;
armv7l|armhf) GO_ARCH="armv6l" ;;
*) print_error "不支持的 CPU 架构: $ARCH"; exit 1 ;;
x86_64|amd64|aarch64|arm64|armv7l|armhf)
print_success "CPU 架构: $ARCH"
;;
*)
print_error "不支持的 CPU 架构: $ARCH"
exit 1
;;
esac
}
@@ -119,7 +100,6 @@ check_tools() {
command -v git &> /dev/null && HAS_GIT=true || NEED_INSTALL_GIT=true
command -v curl &> /dev/null && HAS_CURL=true || NEED_INSTALL_CURL=true
command -v wget &> /dev/null && HAS_WGET=true || NEED_INSTALL_WGET=true
command -v go &> /dev/null && HAS_GO=true || NEED_INSTALL_GO=true
command -v docker &> /dev/null && HAS_DOCKER=true
}
@@ -132,13 +112,95 @@ check_container() {
fi
}
# ---------- 打印环境摘要 ----------
# ---------- 版本管理 ----------
read_version_from_remote() {
local branch="$1"
local version_url="https://git.whitetop.xyz/lxh2875931338/frps-console/raw/${branch}/version.ini"
local content
content=$(curl -sSL "$version_url" 2>/dev/null)
if [ -z "$content" ]; then
print_error "无法从远程读取 version.ini: $version_url"
return 1
fi
SEMVER=$(echo "$content" | grep -v "^[;#]" | head -1 | awk '{print $1}')
echo "$SEMVER"
}
generate_target_version() {
local channel="$1"
local branch="${2:-$BRANCH}"
SEMVER=$(read_version_from_remote "$branch")
if [ -z "$SEMVER" ]; then
print_error "读取语义版本失败"
exit 1
fi
local minor="${SEMVER#*.}"
if [ "$minor" = "0" ] || [ "$minor" = "5" ]; then
CHANNEL_TYPE="lts"
else
CHANNEL_TYPE="preview"
fi
if [ "$channel" = "lts" ] || [ "$CHANNEL_TYPE" = "lts" ]; then
TARGET_VERSION="${SEMVER}-lts-$(date +%Y%m%d)"
IMAGE_TAG="${SEMVER}-lts-$(date +%Y%m%d)"
else
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
TARGET_VERSION="${SEMVER}-preview-${TIMESTAMP}"
IMAGE_TAG="preview-${TIMESTAMP}"
fi
}
get_current_version() {
local version_file="${DEPLOY_DIR}/data/version.ini"
if [ -f "$version_file" ]; then
grep -v "^[;#]" "$version_file" | head -1 | tr -d '\n'
else
if [ "$CONTAINER_EXISTS" = true ]; then
docker exec frps-console cat /app/data/version.ini 2>/dev/null | grep -v "^[;#]" | head -1 | tr -d '\n' || echo ""
else
echo ""
fi
fi
}
# ---------- 通道选择 ----------
select_channel() {
if [ -n "$DEPLOY_CHANNEL" ] && [ -n "$DEPLOY_BRANCH" ]; then
CHANNEL="$DEPLOY_CHANNEL"
BRANCH="$DEPLOY_BRANCH"
print_info "使用环境变量: ${CHANNEL} 通道 (${BRANCH} 分支)"
return
fi
if [ -n "$CHANNEL" ]; then
if [ "$CHANNEL" != "lts" ] && [ "$CHANNEL" != "preview" ]; then
print_error "无效通道: $CHANNEL"
exit 1
fi
BRANCH="$([ "$CHANNEL" = "preview" ] && echo "test" || echo "main")"
return
fi
echo ""
echo -e "${CYAN}请选择部署通道:${NC}"
echo " 1. LTS (稳定版) [默认] → main 分支"
echo " 2. Preview (技术预览版) → test 分支"
echo ""
read -p "请选择 [1]: " CHANNEL_INPUT </dev/tty
CHANNEL_INPUT=${CHANNEL_INPUT:-1}
case $CHANNEL_INPUT in
1|"") CHANNEL="lts"; BRANCH="main" ;;
2) CHANNEL="preview"; BRANCH="test" ;;
*) print_error "无效选择"; exit 1 ;;
esac
print_info "已选择: ${CHANNEL} 通道 (${BRANCH} 分支)"
}
# ---------- 环境汇总 ----------
print_environment_summary() {
print_title
print_subtitle "环境检测结果"
echo ""
echo -e " ${CYAN}操作系统:${NC} $OS $OS_VERSION"
echo -e " ${CYAN}CPU 架构:${NC} $ARCH → Go 架构: $GO_ARCH"
echo -e " ${CYAN}CPU 架构:${NC} $ARCH"
echo ""
echo " ${CYAN}必要工具:${NC}"
if [ "$HAS_GIT" = true ]; then
@@ -157,23 +219,12 @@ print_environment_summary() {
echo " wget ❌ 未安装 (将自动安装)"
fi
echo ""
echo " ${CYAN}Go 环境:${NC}"
if [ "$HAS_GO" = true ]; then
echo -e " go ✅ 已安装 ($(go version | awk '{print $3}'))"
else
echo " go ❌ 未安装 (将自动安装 Go ${GO_VERSION})"
fi
echo ""
echo " ${CYAN}Docker 环境:${NC}"
if [ "$HAS_DOCKER" = true ]; then
echo -e " docker ✅ 已安装 ($(docker --version | awk '{print $3}' | tr -d ','))"
else
echo " docker ❌ 未安装"
print_error "Docker 未安装,请先安装 Docker"
echo ""
echo " 快速安装:"
echo " curl -fsSL https://get.docker.com | bash"
echo " systemctl enable --now docker"
exit 1
fi
if [ "$CONTAINER_EXISTS" = true ]; then
@@ -182,13 +233,15 @@ print_environment_summary() {
if [ "$CONTAINER_RUNNING" = true ]; then
echo -e " frps-console ✅ 运行中"
else
echo -e " frps-console ⏸️ 已存在但未运行"
echo -e " frps-console ⏸️ 已停止"
fi
CURRENT_VERSION=$(get_current_version)
[ -n "$CURRENT_VERSION" ] && echo -e " ${CYAN}当前版本:${NC} $CURRENT_VERSION"
fi
echo ""
}
# ---------- 生成部署计划 ----------
# ---------- 部署计划 ----------
generate_plan() {
PLAN=""
if [ "$NEED_INSTALL_GIT" = true ] || [ "$NEED_INSTALL_CURL" = true ] || [ "$NEED_INSTALL_WGET" = true ]; then
@@ -198,12 +251,9 @@ generate_plan() {
[ "$NEED_INSTALL_WGET" = true ] && pkgs="${pkgs} wget"
PLAN="${PLAN} • 安装必要工具:${pkgs}\n"
fi
if [ "$NEED_INSTALL_GO" = true ]; then
PLAN="${PLAN} • 安装 Go ${GO_VERSION}\n"
fi
PLAN="${PLAN} • 拉取 frps-console 源码 (${BRANCH} 分支)\n"
PLAN="${PLAN}编译 frps-console 二进制\n"
PLAN="${PLAN}构建 Docker 镜像\n"
PLAN="${PLAN}构建 Docker 镜像: ${IMAGE_NAME}:${IMAGE_TAG}\n"
PLAN="${PLAN}目标版本: ${TARGET_VERSION}\n"
if [ "$CONTAINER_EXISTS" = true ]; then
PLAN="${PLAN} • 停止并删除旧容器\n"
fi
@@ -221,22 +271,19 @@ print_deployment_plan() {
echo -e " ────────────────────────────────────"
echo -e " 监听端口 : ${PORT}"
echo -e " 部署目录 : ${DEPLOY_DIR}"
echo -e " 数据目录 : ${DATA_DIR}"
echo -e " 目标版本 : ${TARGET_VERSION}"
echo -e " 镜像标签 : ${IMAGE_TAG}"
echo -e " 源码分支 : ${BRANCH}"
echo -e " ────────────────────────────────────"
if [ "$CONTAINER_EXISTS" = true ]; then
echo ""
echo -e " ${YELLOW}⚠ 检测到已存在的 frps-console 容器${NC}"
if [ "$CONTAINER_RUNNING" = true ]; then
echo -e " 状态: 运行中 → 将被停止并重新创建"
else
echo -e " 状态: 已停止 → 将被删除并重新创建"
fi
echo -e " ${YELLOW}数据目录中的数据库文件将被保留${NC}"
echo -e " 当前版本: ${CURRENT_VERSION}"
echo -e " 目标版本: ${TARGET_VERSION}"
fi
echo ""
}
# ---------- 确认与自定义 ----------
confirm_deploy() {
if [ "$SKIP_CONFIRM" = true ]; then
echo -e "${GREEN}▶ 已启用 --yes,自动确认${NC}"
@@ -275,7 +322,37 @@ do_deploy() {
print_subtitle "开始部署"
echo ""
# 安装工具
# ----- 事务前钩子:备份数据库(仅降级时) -----
BACKUP_DIR=""
if [ -n "$CURRENT_VERSION" ] && [ -n "$TARGET_VERSION" ]; then
CUR_SEMVER=$(echo "$CURRENT_VERSION" | sed -E 's/^([0-9]+\.[0-9]+).*$/\1/')
TGT_SEMVER=$(echo "$TARGET_VERSION" | sed -E 's/^([0-9]+\.[0-9]+).*$/\1/')
if [ "$TGT_SEMVER" \< "$CUR_SEMVER" ] 2>/dev/null; then
print_warn "检测到降级操作: $CURRENT_VERSION$TARGET_VERSION"
echo ""
echo -e "${YELLOW}⚠ 降级可能导致数据不兼容${NC}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/frps-console-backups/${TIMESTAMP}_${CHANNEL}"
echo -e "${CYAN}备份目录: ${BACKUP_DIR}${NC}"
read -p "确认执行降级? [y/N]: " CONFIRM_DOWNGRADE </dev/tty
if [[ ! "$CONFIRM_DOWNGRADE" =~ ^[Yy]$ ]]; then
print_info "已取消降级操作"
exit 0
fi
print_step "执行降级前备份..."
mkdir -p "$BACKUP_DIR"
if [ -d "$DEPLOY_DIR" ]; then
cp -r "$DEPLOY_DIR" "$BACKUP_DIR/frps-console"
print_success "备份完成: $BACKUP_DIR"
else
print_warn "部署目录不存在,跳过备份"
fi
fi
else
print_info "首次部署,跳过版本比对"
fi
# ----- 安装必要工具 -----
if [ "$NEED_INSTALL_GIT" = true ] || [ "$NEED_INSTALL_CURL" = true ] || [ "$NEED_INSTALL_WGET" = true ]; then
print_step "安装必要工具..."
local pkgs=""
@@ -284,7 +361,12 @@ do_deploy() {
[ "$NEED_INSTALL_WGET" = true ] && pkgs="${pkgs} wget"
case $OS in
opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll|opensuse-leap)
zypper install -y $pkgs ;;
if [ "$NEED_INSTALL_GIT" = true ]; then
zypper install -y git-core
pkgs=$(echo "$pkgs" | sed -E 's/(^| )git( |$)/ /g' | sed 's/ */ /g' | sed 's/^ //;s/ $//')
fi
[ -n "$pkgs" ] && zypper install -y $pkgs
;;
ubuntu|debian|linuxmint) apt update -qq && apt install -y $pkgs ;;
centos|rhel|fedora|rocky|almalinux) yum install -y $pkgs ;;
alpine) apk add $pkgs ;;
@@ -294,96 +376,17 @@ do_deploy() {
print_success "必要工具安装完成"
fi
# 安装 Go
if [ "$NEED_INSTALL_GO" = true ]; then
print_step "安装 Go ${GO_VERSION} (${GO_ARCH})..."
GO_TMP="/tmp/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
if [ ! -f "$GO_TMP" ]; then
MIRRORS=(
"https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
"https://mirrors.tuna.tsinghua.edu.cn/golang/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
"https://golang.google.cn/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
"https://dl.google.com/go/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
)
DOWNLOADED=false
for MIRROR in "${MIRRORS[@]}"; do
print_info "尝试: $MIRROR"
if curl -# -f -L -o "$GO_TMP" "$MIRROR"; then
print_success "下载成功"
DOWNLOADED=true
break
else
print_warn "失败,尝试下一个镜像..."
rm -f "$GO_TMP"
fi
done
if [ "$DOWNLOADED" = false ]; then
print_error "所有镜像源均下载失败"
exit 1
fi
fi
rm -rf /usr/local/go
tar -C /usr/local -xzf "$GO_TMP"
export PATH=$PATH:/usr/local/go/bin
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
/usr/local/go/bin/go env -w GOPROXY=https://goproxy.cn,direct
/usr/local/go/bin/go env -w GOPRIVATE=git.whitetop.xyz
print_success "Go ${GO_VERSION} 安装完成"
fi
export PATH=$PATH:/usr/local/go/bin
# 拉取代码
# ----- 拉取代码 -----
print_step "拉取代码..."
rm -rf "$WORK_DIR"
git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$WORK_DIR"
cd "$WORK_DIR"
mkdir -p bin static
print_success "代码拉取完成"
print_success "代码拉取完成 (分支: ${BRANCH})"
# 修复 go.mod
if grep -q "go 1.2[6-9]" go.mod 2>/dev/null; then
print_warn "检测到 go.mod 版本过高,自动降级到 go 1.21"
sed -i 's/go 1.2[6-9].*/go 1.21/' go.mod
fi
# 下载依赖
print_step "下载 Go 依赖..."
go mod download
print_success "依赖下载完成"
# 编译
print_step "编译 frps-console..."
CGO_ENABLED=0 GOOS=linux go build -p 1 -ldflags="-s -w" -o frps-console .
if [ -f "frps-console" ]; then
SIZE=$(du -h frps-console | cut -f1)
print_success "编译完成 ($SIZE)"
else
print_error "编译失败"
exit 1
fi
# 准备目录
print_step "准备部署目录..."
mkdir -p "$DEPLOY_DIR"
mkdir -p "$DATA_DIR"
if [ ! -f "$DATA_DIR/frps-console.db" ]; then
touch "$DATA_DIR/frps-console.db"
print_info "新数据目录已创建"
else
print_info "已有数据目录,保留现有数据"
fi
cp frps-console "$DEPLOY_DIR/"
print_success "二进制已复制到 $DEPLOY_DIR"
# 构建镜像
print_step "构建 Docker 镜像..."
docker build -t "${IMAGE_NAME}:latest" .
print_success "Docker 镜像构建完成"
# 处理旧容器
# ----- 停止旧容器 -----
if [ "$CONTAINER_EXISTS" = true ]; then
print_step "处理旧容器..."
print_step "停止旧容器..."
if [ "$CONTAINER_RUNNING" = true ]; then
docker stop frps-console 2>/dev/null || true
fi
@@ -391,16 +394,87 @@ do_deploy() {
print_success "旧容器已清理"
fi
# 启动容器
# ----- 准备部署目录 -----
print_step "准备部署目录..."
mkdir -p "$DEPLOY_DIR"
mkdir -p "$DATA_DIR"
# ----- 检测并迁移旧版本文件(从根目录迁移到 data/)-----
print_step "检测旧版本文件..."
OLD_DB="${DEPLOY_DIR}/frps-console.db"
OLD_TOML="${DEPLOY_DIR}/frps.toml"
OLD_LOG="${DEPLOY_DIR}/frps.log"
OLD_PID="${DEPLOY_DIR}/frps.pid"
OLD_VERSION="${DEPLOY_DIR}/version.ini"
NEW_DB="${DATA_DIR}/frps-console.db"
if [ -f "$OLD_DB" ] && [ ! -f "$NEW_DB" ]; then
print_warn "检测到旧版本数据库文件在根目录,将迁移到 data/ 子目录"
BACKUP_OLD_DIR="${DEPLOY_DIR}/.old_backup_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_OLD_DIR"
for file in frps-console.db frps.toml frps.log frps.pid version.ini; do
if [ -f "${DEPLOY_DIR}/${file}" ]; then
cp "${DEPLOY_DIR}/${file}" "${BACKUP_OLD_DIR}/" 2>/dev/null || true
print_info " 已备份: ${file}"
fi
done
for file in frps-console.db frps.toml frps.log frps.pid version.ini; do
if [ -f "${DEPLOY_DIR}/${file}" ]; then
cp "${DEPLOY_DIR}/${file}" "${DATA_DIR}/" 2>/dev/null || true
print_info " 已迁移: ${file} → data/"
fi
done
print_success "旧文件已迁移到 data/ 目录"
print_info "旧文件备份位置: ${BACKUP_OLD_DIR}"
print_info "确认服务正常后,可手动删除备份目录"
else
print_info "未检测到需要迁移的旧文件"
fi
# ----- 构建 Docker 镜像 -----
print_step "构建 Docker 镜像..."
docker build --no-cache \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
.
if [ $? -ne 0 ]; then
print_error "Docker 构建失败"
exit 1
fi
print_success "Docker 镜像构建完成: ${IMAGE_NAME}:${IMAGE_TAG}"
# 验证镜像内容
print_step "验证镜像内容..."
if docker run --rm --entrypoint ls "${IMAGE_NAME}:${IMAGE_TAG}" -l /app/frps-console 2>/dev/null; then
print_success "二进制文件存在于镜像中"
else
print_error "镜像中缺少 /app/frps-console"
exit 1
fi
# 标记通道标签
if [ "$CHANNEL" = "lts" ]; then
docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:lts"
print_info "已标记 ${IMAGE_NAME}:lts"
else
docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:preview"
print_info "已标记 ${IMAGE_NAME}:preview"
fi
# ----- 启动新容器 -----
print_step "启动 frps-console 容器..."
docker run -d \
--name frps-console \
--restart=always \
--network host \
-v ${DATA_DIR}:/app/data \
-v ${DEPLOY_DIR}/data:/app/data \
-e PORT=${PORT} \
-e TZ=Asia/Shanghai \
${IMAGE_NAME}:latest
${IMAGE_NAME}:${IMAGE_TAG}
if docker ps | grep -q frps-console; then
print_success "容器启动成功!"
@@ -409,33 +483,99 @@ do_deploy() {
exit 1
fi
# 检查 frps 子进程
# ----- 写入版本文件 -----
print_step "写入版本文件..."
echo "$TARGET_VERSION" > "${DEPLOY_DIR}/data/version.ini"
print_success "版本写入: ${TARGET_VERSION}"
# ----- 检查 frps 子进程(基于 PID 文件)-----
print_step "检查 frps 状态..."
sleep 3
if docker exec frps-console ps aux 2>/dev/null | grep -q "[f]rps -c"; then
print_success "frps 进程运行正常"
PID_FILE="${DEPLOY_DIR}/data/frps.pid"
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE" 2>/dev/null | tr -d ' \n')
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
print_success "frps 进程运行正常 (PID: $PID)"
else
print_warn "frps 进程未运行(可能配置为空,请在 WebUI 中导入 TOML"
print_warn "frps 进程未运行(PID 文件存在但进程已退出,可能配置为空)"
fi
else
print_warn "frps 进程未运行(PID 文件不存在,请导入 TOML 后重试)"
fi
# 清理
# ----- 事务后钩子:检测数据库 -----
print_step "验证数据库状态..."
if docker exec frps-console sqlite3 /app/data/frps-console.db "SELECT COUNT(*) FROM users;" 2>/dev/null | grep -q "^[0-9]"; then
print_success "数据库可用"
else
print_warn "数据库为空或不可用,请通过 WebUI 注册管理员账户"
fi
# ----- 验证版本 -----
print_step "验证版本..."
sleep 1
CONTAINER_VERSION=$(docker exec frps-console cat /app/data/version.ini 2>/dev/null | grep -v "^[;#]" | head -1 | tr -d '\n' || echo "unknown")
echo -e " 容器版本: ${CONTAINER_VERSION}"
echo -e " 目标版本: ${TARGET_VERSION}"
if [ "$CONTAINER_VERSION" = "$TARGET_VERSION" ]; then
print_success "版本写入成功"
else
print_warn "版本不匹配,请检查"
fi
# ----- 清理旧镜像 -----
print_step "清理旧镜像..."
CURRENT_IMAGE_ID=$(docker images -q "${IMAGE_NAME}:${IMAGE_TAG}" 2>/dev/null)
if [ -n "$CURRENT_IMAGE_ID" ]; then
ALL_IMAGES=$(docker images "${IMAGE_NAME}:*" --format "{{.ID}} {{.CreatedAt}}" 2>/dev/null | sort -k2 -r | awk '{print $1}')
COUNTER=0
TO_DELETE=""
for IMG_ID in $ALL_IMAGES; do
if [ "$IMG_ID" = "$CURRENT_IMAGE_ID" ]; then
continue
fi
COUNTER=$((COUNTER + 1))
if [ $COUNTER -le $KEEP_IMAGES ]; then
continue
fi
TO_DELETE="$TO_DELETE $IMG_ID"
done
if [ -n "$TO_DELETE" ]; then
docker rmi -f $TO_DELETE 2>/dev/null || true
print_info "已清理旧镜像"
else
print_info "无需清理,镜像数量未超过保留策略"
fi
fi
# ----- 备份目录提示 -----
if [ -n "$BACKUP_DIR" ]; then
echo ""
print_warn "降级前数据已备份到: $BACKUP_DIR"
echo " 如需回退: cp -r $BACKUP_DIR/frps-console/* $DEPLOY_DIR/"
fi
# ----- 清理临时文件 -----
print_step "清理临时文件..."
rm -rf "$WORK_DIR"
print_success "临时文件已清理"
# ----- 输出 -----
print_title
print_success "frps-console 部署完成!"
print_title
echo ""
echo -e " ${CYAN}📍 访问地址:${NC} http://$(hostname -I | awk '{print $1}'):${PORT}"
echo -e " ${CYAN}📂 数据目录:${NC} ${DATA_DIR}"
echo -e " ${CYAN}📂 数据目录:${NC} ${DEPLOY_DIR}"
echo -e " ${CYAN}🐳 容器名称:${NC} frps-console"
echo -e " ${CYAN}🏷️ 版本:${NC} ${TARGET_VERSION}"
echo -e " ${CYAN}🌿 分支:${NC} ${BRANCH}"
echo ""
echo -e " ${CYAN}常用命令:${NC}"
echo " docker logs frps-console # 查看日志"
echo " docker restart frps-console # 重启服务"
echo " docker stop frps-console # 停止服务"
echo " docker start frps-console # 启动服务"
echo " cat ${DEPLOY_DIR}/data/version.ini # 查看当前版本"
echo ""
echo -e " ${YELLOW}首次访问需要注册管理员账户${NC}"
echo ""
@@ -454,6 +594,14 @@ main() {
check_tools
check_container
select_channel
generate_target_version "$CHANNEL" "$BRANCH"
CURRENT_VERSION=""
if [ "$CONTAINER_EXISTS" = true ]; then
CURRENT_VERSION=$(get_current_version)
fi
print_environment_summary
if [ "$CHECK_ONLY" = true ]; then
@@ -462,11 +610,7 @@ main() {
fi
if [ "$HAS_DOCKER" = false ]; then
print_error "Docker 未安装,请先安装 Docker"
echo ""
echo " 快速安装:"
echo " curl -fsSL https://get.docker.com | bash"
echo " systemctl enable --now docker"
print_error "Docker 未安装"
exit 1
fi
@@ -475,6 +619,7 @@ main() {
if ! confirm_deploy; then
custom_config
generate_plan
print_deployment_plan
if ! confirm_deploy; then
print_info "已取消部署"
@@ -483,7 +628,7 @@ main() {
fi
if [ "$DRY_RUN" = true ]; then
print_info "演练模式(--dry-run,不实际执行部署"
print_info "演练模式(--dry-run"
exit 0
fi
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"syscall"
)
// setSysProcAttr 为 Unix 系统设置 Setsid,让 frpc 进程脱离父进程独立运行
// setSysProcAttr 为 Unix 系统设置 Setsid,让 frps 进程脱离父进程独立运行
func setSysProcAttr(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
+95 -25
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"embed"
"fmt"
"io"
"log"
"os"
"os/exec"
@@ -27,10 +28,6 @@ var (
frpsPathMutex sync.Mutex
)
// ============================================================
// 获取 frps 路径
// ============================================================
func getFrpsPath() (string, error) {
frpsPathMutex.Lock()
defer frpsPathMutex.Unlock()
@@ -92,17 +89,13 @@ func getFrpsPath() (string, error) {
return "", fmt.Errorf("未找到 frps 文件")
}
// ============================================================
// 生成 frps.toml
// ============================================================
func GenerateFrpsConfig() error {
cfg, err := GetGlobalConfig()
if err != nil {
return fmt.Errorf("读取全局配置失败: %w", err)
}
// 直接传递 cfg 即可,模板里能读到 .WireProtocolV2
// 直接使用 cfg,模板使用 frps.tmpl
var tmplContent string
if _, err := os.Stat("frps.tmpl"); err == nil {
content, readErr := os.ReadFile("frps.tmpl")
@@ -125,19 +118,18 @@ func GenerateFrpsConfig() error {
return fmt.Errorf("渲染模板失败: %w", err)
}
if err := os.WriteFile("./frps.toml", buf.Bytes(), 0644); err != nil {
if err := os.MkdirAll("./data", 0755); err != nil {
return fmt.Errorf("创建 data 目录失败: %w", err)
}
if err := os.WriteFile("./data/frps.toml", buf.Bytes(), 0644); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
return nil
}
// ============================================================
// frps 进程管理
// ============================================================
func isFrpsRunning() bool {
pidData, err := os.ReadFile("./frps.pid")
pidData, err := os.ReadFile("./data/frps.pid")
if err != nil {
return false
}
@@ -168,7 +160,11 @@ func StartFrps() error {
return fmt.Errorf("获取 frps 路径失败: %w", err)
}
if _, err := os.Stat("./frps.toml"); os.IsNotExist(err) {
if err := os.MkdirAll("./data", 0755); err != nil {
return fmt.Errorf("创建 data 目录失败: %w", err)
}
if _, err := os.Stat("./data/frps.toml"); os.IsNotExist(err) {
if err := GenerateFrpsConfig(); err != nil {
return fmt.Errorf("生成配置文件失败: %w", err)
}
@@ -178,13 +174,13 @@ func StartFrps() error {
return nil
}
os.Remove("./frps.pid")
os.Remove("./data/frps.pid")
cmd := exec.Command(frpsPath, "-c", "./frps.toml")
cmd := exec.Command(frpsPath, "-c", "./data/frps.toml")
setWindowHide(cmd)
setSysProcAttr(cmd)
logFile, err := os.OpenFile("./frps.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
logFile, err := os.OpenFile("./data/frps.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("打开日志文件失败: %w", err)
}
@@ -195,7 +191,7 @@ func StartFrps() error {
return fmt.Errorf("启动 frps 失败: %w", err)
}
if err := os.WriteFile("./frps.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil {
if err := os.WriteFile("./data/frps.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil {
return fmt.Errorf("保存 PID 失败: %w", err)
}
@@ -208,11 +204,11 @@ func StopFrps() error {
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") {
return fmt.Errorf("停止 frps 失败: %w", err)
}
os.Remove("./frps.pid")
os.Remove("./data/frps.pid")
return nil
}
pidData, err := os.ReadFile("./frps.pid")
pidData, err := os.ReadFile("./data/frps.pid")
if err != nil {
cmd := exec.Command("pkill", "-f", "frps")
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") {
@@ -224,7 +220,7 @@ func StopFrps() error {
pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData)))
process, err := os.FindProcess(pid)
if err != nil {
os.Remove("./frps.pid")
os.Remove("./data/frps.pid")
return nil
}
@@ -232,7 +228,7 @@ func StopFrps() error {
return fmt.Errorf("杀死进程失败: %w", err)
}
os.Remove("./frps.pid")
os.Remove("./data/frps.pid")
return nil
}
@@ -251,7 +247,7 @@ func ReloadFrps() error {
return fmt.Errorf("获取 frps 路径失败: %w", err)
}
cmd := exec.Command(frpsPath, "reload", "-c", "./frps.toml")
cmd := exec.Command(frpsPath, "reload", "-c", "./data/frps.toml")
_, err = cmd.CombinedOutput()
if err != nil {
log.Printf("⚠️ 热加载失败 (%v),自动降级为重启 frps", err)
@@ -265,3 +261,77 @@ func ReloadFrps() error {
}
return nil
}
// readTailLog 通用
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
}
+1 -1
View File
@@ -2,7 +2,7 @@ bindPort = {{.BindPort}}
auth.token = "{{.Token}}"
log.to = "./frpc.log"
log.to = "./frps.log"
log.level = "{{.LogLevel}}"
log.maxDays = {{.LogMaxDays}}
+15
View File
@@ -2,10 +2,25 @@ package main
import (
"log"
"os"
"strings"
"time"
)
func main() {
// 确保 data 目录存在
if err := os.MkdirAll("./data", 0755); err != nil {
log.Printf("⚠️ 创建 data 目录失败: %v", err)
}
// 读取 version.ini 显示版本
if data, err := os.ReadFile("./data/version.ini"); err == nil {
version := strings.TrimSpace(string(data))
log.Printf("📌 版本: %s", version)
} else {
log.Printf("📌 版本: (未记录)")
}
if err := InitDB(); err != nil {
log.Fatal("❌ 数据库初始化失败:", err)
}
+214 -131
View File
@@ -1,177 +1,260 @@
好的,我先去看了你 frpc-console 的 README 风格——**冷峻、务实、带点极客式骄傲,把“哲学”和“代码”焊在一起**。我按这个调性帮你写 frps-console 的 README,直接可粘贴。
---
<p align="center">
<img src="static/logo.svg" alt="frps-console" width="360" />
</p>
# frps-console
> 🚀 轻量级 frps 服务端管理面板 | 管理面与控制面分离 | 内存 < 100MB
> 轻量级 frps 服务端管理面板 —— 让流量中转尽在掌握
**frps-console** 是专为 **frps(服务端)** 设计的轻量 Web 管理工具。内嵌多平台 frps 二进制文件(v0.70.0),提供极简 Web UI 进行配置管理、客户端监控、日志查看。
与同类工具最本质的区别是:**frps 业务进程与 Web 管理进程完全解耦**。即使面板意外崩溃、升级重启,已建立的穿透隧道也绝不中断。
[![Release](https://img.shields.io/gitea/v/release/lxh2875931338/frps-console?gitea_url=https://git.whitetop.xyz)](https://git.whitetop.xyz/lxh2875931338/frps-console/releases)
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
---
## 🧘 设计哲学:“阴主导阳”
## 📖 简介
这是整个项目的底层逻辑:
**frps-console** 是专为 [frp](https://github.com/fatedier/frp) 服务端(frps)设计的轻量级 Web 管理工具。
与 [frps-console](https://git.whitetop.xyz/lxh2875931338/frps-console) 形成姊妹篇,一个管理客户端,一个管理服务端。
- **阴(frps 业务)**:内网穿透的核心业务,独立运行,不依赖任何管理进程存活
- **阳(frps-console 面板)**:观察和调整业务的辅助工具,它的存在是为了更好地服务业务,而非成为业务的负担。
本项目作为 frp-console 系列套件的项目之一,延续 frps-console 的设计哲学:**以无限接近原生占用的情况下,实现完全图形化的控制工具**。实测内存占用约 **92MB**(面板 60MB + frps 32MB),512MB 云服务器轻松跑
**代码层面的保障**
- 启动 frps 时使用 `Setsid` 创建独立会话,父进程退出后 frps 被 init(PID 1)接管。
- 内置 Watchdog(看门狗),每 30 秒探测一次 frps 存活状态,异常时自动拉起。
- 配置修改采用“先写文件,再热加载/重启”策略,确保配置操作的原子性。
### ✨ 核心特性
用一句话概括:**业务是目的,面板是手段。面板存在的意义,是让业务运行得更清晰、更可控。**
## 📌 版本状态
**当前稳定版:v2.0.0-LTS** (2026-07-27)
- 基于 frp **v0.70.0**
- 数据库 Schema **v2.0.0**(支持未来平滑升级)
- 已通过实际生产环境验证(内存 92MB,无僵尸堆积)
- 承诺长期维护,**跟随上游 frp 官方 LTS 版本同步更新**
**计划路线图**
- [x] 内嵌多平台 frps 二进制
- [x] WebUI 配置热加载
- [x] Watchdog 自动保活
- [x] 数据库迁移引擎(1.0.0 → 2.0.0
- [ ] 工具的关于信息独立页面
- [ ] 7500 dashboard 的数据解析(客户端列表、代理列表、流量统计)
- 🔐 **首次启动引导** —— Web 端完成管理员注册,无需 CLI 交互
- 🖥️ **服务端配置管理** —— 通过 WebUI 管理 frps 核心参数(`bindPort` / `token` / `log` / `tcpMux`),告别手动编辑 TOML
- 📊 **客户端状态概览** —— 实时查看连接到 frps 的所有客户端列表,包含在线状态、连接时间、协议版本(v1/v2)
- 📄 **运行日志面板** —— WebUI 内实时查看 frps 运行日志,8 秒自动轮询
- 🔄 **配置热加载** —— 修改配置后一键热加载,无需重启 frps 服务
- 🖥️ **多平台支持** —— Linux AMD64 / ARM64 / ARMv7 全平台兼容
- 🐳 **容器化就绪** —— 提供 Docker 镜像与一键部署脚本,开箱即用
- 🎨 **深色磨砂玻璃 UI** —— 与 frps-console 保持统一的视觉风格
---
## ✨ 核心特性
## 🚀 快速开始
- 📦 **单二进制交付**:内嵌 frps 0.70.0 多平台二进制(Linux x86_64/ARM64/ARMv7 + Windows),无需额外下载。
- 🖥️ **极简 Web UI**:Vue 3 CDN 开发,无构建工具,修改 `static/` 下文件即生效。
- 🗄️ **SQLite 存储**:零外部依赖,数据即文件,备份迁移只需复制 `.db` 文件。
- 🔄 **一键部署**`deploy.sh` 自动识别 OSopenSUSE/Ubuntu/Debian/CentOS),自动安装 Go/Docker,自动降级 `go.mod` 兼容老系统。
- 📉 **资源极度克制**:实测总内存占用约 **92MB**(面板 60MB + frps 32MB),512MB 云服务器轻松跑。
- 🛡️ **数据库迁移引擎**Schema v2.0.0,版本升级时自动备份(`*.pre-v2.0.0.*`),失败可回滚。
- 🐳 **Docker 优先**:部署产物为容器,`--restart=always` + 数据卷持久化,升级不丢数据。
### 🐧 二进制安装(推荐)
---
适用于 Linux 服务器,无需 Docker,单文件运行。
## 🚀 快速开始(一键部署)
👉 详见:[二进制安装指南](./INSTALL_BINARY.md)
在全新 Linux 服务器(Debian/Ubuntu/CentOS)上,以 root 权限执行:
### 🐳 Docker 部署(一键脚本)
适用于 Linux 服务器,自动编译 + 自动部署。
```bash
curl -sSL https://git.whitetop.xyz/lxh2875931338/frps-console/raw/main/deploy.sh | sudo bash
```
**脚本做了什么?**
👉 详见:[Docker 安装指南](./INSTALL_DOCKER.md)
1. 检测 CPU 架构(x86_64/ARM64/ARMv7)和发行版(甚至优先适配 openSUSE,因为作者用它)。
2. 自动安装依赖(Git、Curl、Wget、Go 1.25.0)。
3.`go.mod` 中 Go 版本过高(如 1.25),自动降级为 `go 1.21` 以兼容老旧系统。
4. 编译静态二进制,构建 Docker 镜像,以 `--network host` 启动容器。
5. 数据目录 `/opt/frps-console/data` 持久化挂载,容器删除重建数据不丢。
6. 编译时已加 `-p 1` 限制并发,避免低内存机器 OOM Killer。
### 🔧 源码编译
> **内存不足?** 脚本已内置防 OOM 措施,如果云服务器内存是在过小,建议配合 4GB Swap`fallocate -l 4G /swapfile`)食用更佳
适合开发者或需要自定义配置的用户
```bash
git clone https://git.whitetop.xyz/lxh2875931338/frps-console.git
cd frps-console
go mod tidy
go build -o frps-console .
./frps-console
```
首次访问 `http://localhost:9365` 注册管理员账户即可开始使用。
---
## 配置与访问
## 🗂 项目结构
部署成功后,浏览器访问:
```
http://你的服务器IP:9365
```
- **首次启动**:检测到无管理员账户,自动进入注册页(用户名 ≥5 位,密码需含大小写/数字/特殊字符)。
- **全局配置**:修改 `bindPort``Token`、日志级别,保存即热加载 frps(若热加载失败则自动降级为重启)。
- **客户端列表**:通过 frps 内置 Dashboard API`127.0.0.1:7500`)实时读取连接状态,无需额外配置。
**环境变量(Docker**
| 变量 | 默认值 | 说明 |
| :--- | :--- | :--- |
| `PORT` | 9365 | Web 服务监听端口 |
| `TZ` | Asia/Shanghai | 时区设置 |
---
## 🧬 进程模型与高可用
```text
┌─────────────────┐ Setsid ┌─────────────┐
│ frps-console │ ──────────────▶ │ frps │
│ (Web 面板) │ 独立会话 │ (业务进程) │
│ │ │ │
│ Watchdog │ ◀── 每 30s ──── │ 隧道保持 │
│ 自动保活 │ 探测存活 │ 永不中断 │
└─────────────────┘ └─────────────┘
```
- **场景一**`docker restart frps-console` → frps 被 init 接管,隧道零中断。
- **场景二**frps 异常崩溃 → Watchdog 自动执行 `StartFrps()` 拉起。
- **场景三**:修改配置保存 → 优先执行 `frps reload`,若失败则降级为 `stop && start`
**为什么能做到业务不中断?**
因为 frps 启动时通过 `Setsid` 脱离了父进程的进程组。当父进程(frps-console)退出时,frps 不会被 SIGHUP 信号杀死,而是被 PID 1init 或 Docker 的 tini)接管。这是 Unix 进程管理的经典设计,我只是把它用在了 frp 管理上。
---
## 📌 版本状态
**当前稳定版:v2.0.0-LTS** (2026-07-27)
- 基于 frp **v0.70.0**
- 数据库 Schema **v2.0.0**(支持未来平滑升级)
- 已通过实际生产环境验证(内存 92MB,无僵尸堆积)
- 适用于长期支持场景(LTS
**计划路线图**
- [x] 内嵌多平台 frps 二进制
- [x] WebUI 配置热加载
- [x] Watchdog 自动保活
- [x] 数据库迁移引擎(1.0.0 → 2.0.0
- [ ] 工具的关于信息独立页面
- [ ] dashboard的数据解析(通过 frps 7500 端口的 /api/v2/* 数据,展示隧道流量、连接时长、带宽等指标)
---
## 📂 目录结构(Docker 容器内)
```
/app/
├── frps-console # 主二进制
├── frps.toml # 生成的 frps 配置文件
├── frps.log # frps 运行日志(尾部 200 行展示在 WebUI)
├── frps.pid # frps 进程 PID 文件
├── data/
│ └── frps-console.db # SQLite 数据库(持久化)
└── static/ # 前端静态资源(内嵌)
```txt
frps-console/
├── main.go # 入口
├── api.go # HTTP 路由 & Handler
├── db.go # SQLite 数据库操作
├── auth.go # JWT 认证 & 密码管理
├── frps.go # frps 管理核心逻辑
├── toml_parser.go # TOML 解析器
├── static/ # 前端静态资源
│ ├── index.html
│ ├── app.js
│ ├── style-1.css # 全局基础样式
│ ├── style-2.css # 登录页样式
│ ├── style-3.css # 主界面样式
│ └── style-4.css
├── bin/ # 内嵌 frps 二进制 (多平台)
│ ├── frps_windows_amd64.exe
│ ├── frps_linux_amd64
│ ├── frps_linux_arm64
│ └── frps_linux_arm_hf
├── frps.tmpl # frps 配置模板
├── Dockerfile
└── go.mod
```
---
## 🙏 致谢 & 生态定位
## ⚙️ 配置说明
- 核心引擎:[fatedier/frp](https://github.com/fatedier/frp)
### 环境变量
| 变量 | 说明 | 默认值 |
|---|---|---|
| PORT | 监听端口 | 9365 |
- 同系项目:[frpc-console](https://git.whitetop.xyz/lxh2875931338/frpc-console)(客户端管理面板,形成“云端 + 客户端”双星架构)
### 数据存储
· 数据库文件:`./frps-console.db`</br>
· frps 配置文件:`./frps.toml`</br>
· frps 日志文件:`./frps.log`</br>
---
## 🛠️ 开发指南
```bash
# 克隆项目
git clone https://git.whitetop.xyz/lxh2875931338/frps-console.git
cd frps-console
# 安装依赖
go mod tidy
# 开发模式运行
go run .
# 编译生产版本
go build -ldflags="-s -w" -o frps-console .
```
### 前端开发
前端使用 Vue 3 CDN,无需额外构建工具。修改 `static/` 目录下的文件后,刷新浏览器即可预览效果。
---
## 🔧 常见问题
#### Q: 如何修改管理员密码?
登录后,在「全局配置」页面顶部找到「账户管理」区域,输入当前密码和新密码即可。
#### Q: frps 启动失败怎么办?
首次启动的话,console会创建一个全新的、符合官方规范的toml配置文件。</br>
如果使用中出现启动失败问题,可以在console内重新配置一次服务端参数,或者导入已有的toml即可恢复</br>
如果依旧无效,请查看 ./frps.log 日志文件。
#### Q: 支持哪些 frp 版本?
目前支持情况如下:
| 系统 | 架构版本 | 版本号 |
|---|---|---|
| Linux | AMD64/x86-64 | 0.70.0 |
| Linux | ARM64/Aarch64 | 0.70.0 |
| Linux | ARM_hf/ARMv7l | 0.70.0 |
#### Q: frps-console 和 frps-console 有什么区别?
| 对比项 | frps-console | frps-console |
|---|---|---|
| 管理对象 | frps(客户端) | frps(服务端) |
| 适用场景 | 软路由、小主机、本地服务穿透 | VPS、云主机、流量中转 |
| 默认端口 | 9300 | 9365 |
| 核心功能 | 隧道增删改查 + 导入导出 TOML | 服务端配置 + 客户端状态概览 |
#### Q: 如果 frps-console 进程挂了,frps 本身会受影响吗?
**不会。**
这是 frp-console 系列工具与同类项目最核心的区别之一。我们称之为 **“阴阳模式”**。
**什么是“阴阳模式”?**
以人的观察为出发点:
- **阴**:看不见的业务流(frps 进程、toml 配置文件、隧道连接)
- **阳**:看得见的管理面板(Web 界面、API 服务)
大多数管理工具走的是 **“阳阴模式”**:面板是“大脑”,业务是“肢体”。大脑一旦停止工作,肢体也就瘫痪了。
**frps-console 走的是“阴阳模式”:业务是根基,面板是工具。**
- **阴主导阳**:业务不依赖面板存活,面板只是用来观察和调整业务状态的手段
- **阳依附于阴**:面板的存在是为了服务业务,而不是反过来
**二进制部署:**
frps-console 启动时,会以子进程的方式拉起 frps,并为它创建一个独立的进程会话(`Setsid`)。即使 SSH 断开导致 frps-console 进程退出,frps 也会被系统的 init 进程(PID 1)接管,继续在后台稳定运行。配置文件(`frps.toml`)是持久化文件,不依赖 console 进程存活。
**Docker 部署:**
容器使用 `--restart=always` 策略,frps-console 进程意外退出时,Docker 守护进程会自动重启整个容器。重启后重新拉起 frps 子进程。数据目录通过卷挂载持久化,容器重启不会丢失任何配置。
**核心原则:**
console 只做“配置的保管者”和“进程的启动者”,而不是“配置的持有者”或“进程的绑定者”。即使 console 完全离线,已经生成的 `frps.toml` 依然存在,frps 进程依然可以独立运行。
> **管理面板可以丢,业务功能打死不能停。**
#### Q: 支持 frps 的所有功能吗?
frps-console 覆盖了 frps 最核心的配置管理功能,包括 `bindPort``token``log``tcpMux` 等。如果你有更复杂的需求(如 Dashboard API 数据可视化、多 frps 管理),欢迎提 issue,我们会评估是否加入。
---
## 🧠 设计哲学
frps-console 遵循 **“够用就好”** 的原则:
1. **frp 是轻量工具,管理工具也应该是轻量的。**
2. **能用 SQLite 就不用 PostgreSQL,能用 Vue CDN 就不用 React 全家桶。**
3. **前端能做的事,后端不加额外复杂度。**
4. **删掉一个容器等于删掉所有垃圾,所以用 Docker 隔离是对的。**
5. **面板的存在应该是为了服务业务的,而不该是反过来主导业务的。**
---
## 📝 更新日志
### 2.0-ReleaseLTS,首次公开发布,与 frps-console 2.0-LTS 同步)
> 这是 frps-console 的首次正式发布。frps-console 没有 1.x 系列——在 v1.x 阶段,它尚处于内部测试形态,从未公开发布。首次公开发布直接定为 **2.0-LTS**,与 frps-console 的 LTS 版本对齐,后续版本号将与 frps-console 保持同步。
· 🚀 **服务端配置管理** —— 通过 WebUI 管理 frps 核心参数,告别手动编辑 TOML</br>
· 📊 **客户端状态概览** —— 实时查看连接到 frps 的所有客户端列表</br>
· 📄 **运行日志面板** —— WebUI 内实时查看 frps 日志,8 秒自动轮询</br>
· 🔄 **配置热加载** —— 修改配置后一键热加载,无需重启 frps</br>
· 🔐 **首次启动 Web 注册** —— 无需命令行交互即可完成管理员账户创建</br>
· 🖥️ **多平台支持** —— Linux AMD64 / ARM64 / ARMv7 全平台兼容</br>
· 🐳 **容器化就绪** —— Docker 镜像 + 一键部署脚本,开箱即用</br>
· 🎨 **深色磨砂玻璃 UI** —— 与 frps-console 保持统一的视觉风格</br>
· 🔒 **数据库自动迁移** —— 升级时自动备份 + 迁移,数据零丢失</br>
· 🧩 **frp v2 协议就绪** —— frps 已升级至 0.70.0 LTS,原生支持 v2 协议(v2 开关在 frps 侧开启)
---
## 📄 许可证
MIT License
MIT License © 2026 lxh2875931338XHLiang0
---
**如果这个项目对你有用,欢迎给它点个 Star ⭐**
**问题反馈**Git Issues(仓库链接)
## 🙏 相关项目援引
### FRP 生态互补工具
· [MoonProxy](https://github.com/MoonProxyHQ/moonproxy-desktop) —— 基于 Tauri v2 + Vue 3 + Rust 构建的跨平台 FRP 桌面客户端(frps GUI),面向 macOS 与 WindowsMIT 协议开源。
· [frps-console](https://git.whitetop.xyz/lxh2875931338/frps-console) —— 姊妹篇,管理 frps 客户端,已发布 2.0-LTS,同步 LTS 维护。
---
## 🙏 致谢
· fatedier/frp —— 强大的内网穿透工具</br>
· gin-gonic/gin —— 高性能 Go Web 框架</br>
· vuejs/vue —— 渐进式 JavaScript 框架
```
+115
View File
@@ -0,0 +1,115 @@
#!/bin/bash
# ============================================================
# frps-console 部署入口(前置引导脚本)
# 此脚本固定在 main 分支,永不变化。
# 职责:引导用户选择通道,拉取对应分支的 deploy.sh 并执行
#
# 用法:
# curl -sSL https://git.whitetop.xyz/.../run-deploy.sh | sudo bash
# curl -sSL https://git.whitetop.xyz/.../run-deploy.sh | sudo bash -s -- --channel preview
# ============================================================
set -e
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
print_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
print_error() { echo -e "${RED}[✗]${NC} $1"; }
REPO_URL="https://git.whitetop.xyz/lxh2875931338/frps-console.git"
WORK_DIR="/tmp/frps-console-deploy"
CHANNEL=""
BRANCH=""
parse_args() {
for arg in "$@"; do
case $arg in
--channel)
shift; CHANNEL="$1"
;;
--channel=*)
CHANNEL="${arg#*=}"
;;
--help|-h)
echo "用法: curl .../run-deploy.sh | sudo bash -s -- [选项]"
echo ""
echo "选项:"
echo " --channel lts 使用 LTS 通道 (main 分支)"
echo " --channel preview 使用 Preview 通道 (test 分支)"
echo " --help, -h 显示帮助信息"
exit 0
;;
*)
print_error "未知选项: $arg"
echo "使用 --help 查看帮助"
exit 1
;;
esac
done
}
select_channel() {
if [ -n "$CHANNEL" ]; then
if [ "$CHANNEL" != "lts" ] && [ "$CHANNEL" != "preview" ]; then
print_error "无效通道: $CHANNEL (仅支持 lts / preview)"
exit 1
fi
BRANCH="$([ "$CHANNEL" = "preview" ] && echo "test" || echo "main")"
print_info "使用命令行参数: ${CHANNEL} 通道 (${BRANCH} 分支)"
return
fi
echo ""
echo -e "${CYAN}请选择部署通道:${NC}"
echo " 1. LTS (稳定版,生产推荐) [默认] → main 分支"
echo " 2. Preview (技术预览版,包含新特性) → test 分支"
echo ""
read -p "请选择 [1]: " CHANNEL_INPUT </dev/tty
CHANNEL_INPUT=${CHANNEL_INPUT:-1}
case $CHANNEL_INPUT in
1|"") CHANNEL="lts"; BRANCH="main" ;;
2) CHANNEL="preview"; BRANCH="test" ;;
*) print_error "无效选择"; exit 1 ;;
esac
print_info "已选择: ${CHANNEL} 通道 (${BRANCH} 分支)"
}
fetch_and_run() {
local deploy_url="https://git.whitetop.xyz/lxh2875931338/frps-console/raw/${BRANCH}/deploy.sh"
echo ""
echo -e "${CYAN}▶ 从 ${BRANCH} 分支拉取 deploy.sh...${NC}"
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"
if ! curl -sSL -o deploy.sh "$deploy_url"; then
print_error "无法下载 deploy.sh: $deploy_url"
exit 1
fi
chmod +x deploy.sh
echo -e "${GREEN}[✓] deploy.sh 已拉取${NC}"
echo ""
echo -e "${CYAN}▶ 执行部署脚本...${NC}"
echo ""
export DEPLOY_CHANNEL="$CHANNEL"
export DEPLOY_BRANCH="$BRANCH"
exec ./deploy.sh "$@"
}
main() {
clear 2>/dev/null || true
echo -e "${MAGENTA}════════════════════════════════════════════════════════${NC}"
echo -e "${MAGENTA} frps-console 部署入口${NC}"
echo -e "${MAGENTA}════════════════════════════════════════════════════════${NC}"
echo ""
parse_args "$@"
select_channel
fetch_and_run "$@"
}
main "$@"
+2 -2
View File
@@ -239,10 +239,10 @@
<div class="v2-switch-wrapper">
<span class="status-dot active"></span>
<span class="status-text active">已就绪</span>
<span class="v2-badge v2-enabled">frps 已支持 v2 协议,请在 frpc 侧开启</span>
<span class="v2-badge v2-enabled">frps 已支持 v2 协议,请在 frps 侧开启</span>
</div>
<span class="hint-text v2-hint">
frps 已内置 v2 协议支持,无需额外配置。如需启用,请在 frpc 配置中设置 transport.wireProtocol = "v2"
frps 已内置 v2 协议支持,无需额外配置。如需启用,请在 frps 配置中设置 transport.wireProtocol = "v2"
</span>
</div>
</div>
-6
View File
@@ -13,13 +13,11 @@ type FrpsToml struct {
Token string `json:"token"`
} `json:"auth"`
Log struct {
To string `json:"to"`
Level string `json:"level"`
MaxDays int `json:"maxDays"`
} `json:"log"`
Transport struct {
TcpMux bool `json:"tcpMux"`
WireProtocolV2 bool `json:"wireProtocolV2"`
} `json:"transport"`
}
@@ -28,10 +26,8 @@ func ParseToml(content string) (*FrpsToml, error) {
result := &FrpsToml{
Transport: struct {
TcpMux bool `json:"tcpMux"`
WireProtocolV2 bool `json:"wireProtocolV2"`
}{
TcpMux: true,
WireProtocolV2: false,
},
}
@@ -63,8 +59,6 @@ func ParseToml(content string) (*FrpsToml, error) {
result.Log.MaxDays, _ = strconv.Atoi(value)
case "tcpMux":
result.Transport.TcpMux = value == "true"
case "wireProtocol":
result.Transport.WireProtocolV2 = value == "v2"
}
}
+6
View File
@@ -0,0 +1,6 @@
; frps-console 版本声明文件
; 格式: {语义版本号} [{LTS发布日期}]
; 示例:
; 2.4 # Preview 版本,无日期
; 2.5 20260729 # LTS 版本,带发布日期
2.4