首次推送

This commit is contained in:
2026-07-27 00:00:27 +08:00
commit d6ef302b8c
28 changed files with 4760 additions and 0 deletions
+553
View File
@@ -0,0 +1,553 @@
package main
import (
"bytes"
"embed"
"encoding/json"
"io"
"io/fs" // 新增
"net/http"
"os"
"strings"
"text/template"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
//go:embed static/*
var staticFS embed.FS
func SetupRouter() *gin.Engine {
r := gin.Default()
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 {
c.String(500, "加载前端页面失败")
return
}
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)
auth := api.Group("/")
auth.Use(AuthMiddleware())
{
auth.GET("/config", getConfigHandler)
auth.PUT("/config", updateConfigHandler)
auth.GET("/frps/log", getFrpsLogHandler)
// frps 特有:客户端列表(从 Dashboard API 读取)
auth.GET("/clients", getClientsHandler)
auth.GET("/proxies", getProxiesHandler)
auth.POST("/frps/reload", reloadFrpsHandler)
auth.POST("/frps/start", startFrpsHandler)
auth.POST("/frps/stop", stopFrpsHandler)
auth.GET("/frps/status", getFrpsStatusHandler)
auth.POST("/import/toml", importTomlHandler)
auth.GET("/export/toml", ExportTomlHandler)
auth.PUT("/user/password", changePasswordHandler)
}
}
return r
}
// ============================================================
// 认证 Handler(与 frpc-console 相同,略)
// ============================================================
func checkUsersHandler(c *gin.Context) {
count, err := CountUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": gin.H{"hasUsers": count > 0, "count": count},
})
}
func registerHandler(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return
}
count, err := CountUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询用户失败"})
return
}
if count > 0 {
c.JSON(http.StatusForbidden, gin.H{"code": 1, "msg": "已存在管理员账户,请登录"})
return
}
if len(req.Username) < 5 {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "用户名至少 5 位"})
return
}
if !ValidatePassword(req.Password) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "密码加密失败"})
return
}
if err := CreateUser(req.Username, string(hash)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "创建用户失败: " + err.Error()})
return
}
token, err := GenerateJWT(req.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成Token失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "注册成功",
"data": gin.H{"token": token},
})
}
func loginHandler(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return
}
user, err := GetUserByUsername(req.Username)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "用户名或密码错误"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "用户名或密码错误"})
return
}
token, err := GenerateJWT(user.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成Token失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "登录成功",
"data": gin.H{"token": token},
})
}
func changePasswordHandler(c *gin.Context) {
var req struct {
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return
}
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未登录"})
return
}
user, err := GetUserByUsername(username.(string))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 1, "msg": "用户不存在"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "当前密码错误"})
return
}
if !ValidatePassword(req.NewPassword) {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "密码加密失败"})
return
}
if err := UpdatePassword(username.(string), string(hash)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新密码失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "密码修改成功"})
}
// ============================================================
// 配置 Handler
// ============================================================
func getConfigHandler(c *gin.Context) {
cfg, err := GetGlobalConfig()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": cfg})
}
func updateConfigHandler(c *gin.Context) {
var cfg GlobalConfig
if err := c.ShouldBindJSON(&cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请求参数错误"})
return
}
if err := UpdateGlobalConfig(&cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
return
}
if err := GenerateFrpsConfig(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置文件失败: " + err.Error()})
return
}
if err := ReloadFrps(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"})
}
// ============================================================
// 客户端/代理列表(从 frps Dashboard API 读取)
// ============================================================
// FrpsDashboardClient frps Dashboard API 返回的客户端信息
type FrpsDashboardClient struct {
ID int `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
Status string `json:"status"`
ConnTime string `json:"connTime"`
}
// FrpsDashboardProxy frps Dashboard API 返回的代理信息
type FrpsDashboardProxy struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
LocalAddr string `json:"localAddr"`
RemoteAddr string `json:"remoteAddr"`
}
func getClientsHandler(c *gin.Context) {
clients, err := fetchClientsFromDashboard()
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": []interface{}{}, "msg": "无法连接 frps Dashboard: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": clients})
}
func getProxiesHandler(c *gin.Context) {
proxies, err := fetchProxiesFromDashboard()
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": []interface{}{}, "msg": "无法连接 frps Dashboard: " + err.Error()})
return
}
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,后续可配置
resp, err := http.Get("http://127.0.0.1:7500/api/v2/clients")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []FrpsDashboardClient `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
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 {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data []FrpsDashboardProxy `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Data, nil
}
// ============================================================
// frps 进程管理 Handler
// ============================================================
func reloadFrpsHandler(c *gin.Context) {
if err := GenerateFrpsConfig(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()})
return
}
if err := ReloadFrps(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "热加载失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "热加载成功"})
}
func startFrpsHandler(c *gin.Context) {
if err := StartFrps(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "启动失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frps 启动成功"})
}
func stopFrpsHandler(c *gin.Context) {
if err := StopFrps(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "停止失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "frps 已停止"})
}
func getFrpsStatusHandler(c *gin.Context) {
running, err := GetFrpsStatus()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "查询状态失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"running": running}})
}
// ============================================================
// 导入/导出 TOML
// ============================================================
func importTomlHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请选择文件"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取文件失败"})
return
}
defer f.Close()
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(f); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取文件失败"})
return
}
parsed, err := ParseToml(buf.String())
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "解析 TOML 失败: " + err.Error()})
return
}
cfg := parsed.ToGlobalConfig()
if err := UpdateGlobalConfig(cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "更新配置失败: " + err.Error()})
return
}
if err := GenerateFrpsConfig(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()})
return
}
if err := ReloadFrps(); err != nil {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "导入成功,但热加载失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "导入成功"})
}
func ExportTomlHandler(c *gin.Context) {
cfg, err := GetGlobalConfig()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "读取配置失败: " + err.Error()})
return
}
tmpl, err := template.New("frps").Parse(FrpsTemplateContent)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "解析模板失败: " + err.Error()})
return
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "渲染模板失败: " + err.Error()})
return
}
c.Header("Content-Type", "text/plain; charset=utf-8")
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
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var jwtSecretCache []byte
func getJwtSecret() []byte {
if len(jwtSecretCache) > 0 {
return jwtSecretCache
}
secret, err := GetJwtSecret()
if err != nil {
log.Fatal("❌ 获取 JWT 密钥失败:", err)
}
jwtSecretCache = []byte(secret)
return jwtSecretCache
}
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
func GenerateJWT(username string) (string, error) {
claims := Claims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(getJwtSecret())
}
func ParseJWT(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return getJwtSecret(), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
func ValidatePassword(pwd string) bool {
if len(pwd) < 8 {
return false
}
var hasUpper, hasLower, hasDigit, hasSpecial bool
for _, ch := range pwd {
if ch >= 'A' && ch <= 'Z' {
hasUpper = true
} else if ch >= 'a' && ch <= 'z' {
hasLower = true
} else if ch >= '0' && ch <= '9' {
hasDigit = true
} else if strings.ContainsAny(string(ch), "!@#$%^&*()_+-=[]{}|;:,.<>?") {
hasSpecial = true
}
}
return hasUpper && hasLower && hasDigit && hasSpecial
}
func InitAdminUser() {
count, err := CountUsers()
if err != nil {
log.Fatal("❌ 检查用户表失败:", err)
}
if count > 0 {
log.Println("✅ 已存在管理员账户,跳过初始化")
return
}
log.Println("⚠️ 首次启动,请设置管理员账户")
log.Print("用户名: ")
var username string
fmt.Scanln(&username)
if username == "" {
username = "admin"
}
for {
log.Print("密码 (至少8位,含大小写、数字、特殊字符): ")
var password string
fmt.Scanln(&password)
if ValidatePassword(password) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Println("❌ 密码加密失败:", err)
continue
}
err = CreateUser(username, string(hash))
if err != nil {
log.Println("❌ 创建用户失败:", err)
continue
}
log.Println("✅ 管理员账户创建成功!")
break
} else {
log.Println("❌ 密码不符合复杂度要求,请重新输入")
log.Println(" 要求: 至少8位,包含大小写字母、数字和特殊字符")
}
}
}
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"code": 1, "msg": "未提供认证令牌"})
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()
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+410
View File
@@ -0,0 +1,410 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"time"
_ "modernc.org/sqlite"
)
var DB *sql.DB
// ============================================================
// 数据库版本常量
// ============================================================
const SchemaVersion = "2.0.0"
// ============================================================
// 数据模型
// ============================================================
// GlobalConfig frps 全局配置
type GlobalConfig struct {
ID int `json:"id"`
BindPort int `json:"bindPort"`
Token string `json:"token"`
LogLevel string `json:"logLevel"`
LogMaxDays int `json:"logMaxDays"`
TcpMux bool `json:"tcpMux"`
}
// Client 客户端连接记录
type Client struct {
ID int `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
Arch string `json:"arch"`
Version string `json:"version"`
Status string `json:"status"`
ConnTime string `json:"connTime"`
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 {
var err error
DB, err = sql.Open("sqlite", "./frps-console.db")
if err != nil {
return err
}
if err := createTables(); err != nil {
return err
}
if err := runMigrations(); err != nil {
return err
}
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,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// 全局配置表(frps 专用)
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS global_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
bind_port INTEGER NOT NULL DEFAULT 9358,
token TEXT NOT NULL DEFAULT 'CHANGE_ME',
log_level TEXT NOT NULL DEFAULT 'info',
log_max_days INTEGER NOT NULL DEFAULT 3,
tcp_mux INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// 客户端记录表
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
os TEXT,
arch TEXT,
version TEXT,
status TEXT,
conn_time DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if err != nil {
return err
}
// 应用配置表
_, err = DB.Exec(`
CREATE TABLE IF NOT EXISTS app_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
if 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, bind_port, token, log_level, log_max_days, tcp_mux
) VALUES (1, 9358, 'CHANGE_ME', 'info', 3, 1)
`)
if err != nil {
return err
}
log.Println("✅ 全局配置初始化完成")
}
return nil
}
// ============================================================
// 迁移引擎
// ============================================================
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 {
var count int
DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if count > 0 {
return "1.0.0"
}
return SchemaVersion
}
return "1.0.0"
}
return version
}
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
}
func backupDatabase() (string, error) {
src := "./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)
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
}
func restoreDatabase(backupPath string) error {
srcFile, err := os.Open(backupPath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create("./frps-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
}
func runMigrations() error {
currentVersion := getSchemaVersion()
log.Printf("📌 当前数据库 Schema: %s, 目标版本: %s", currentVersion, SchemaVersion)
if currentVersion != SchemaVersion {
log.Printf("🔄 检测到版本变更 (%s → %s),开始迁移...", currentVersion, SchemaVersion)
backupPath, err := backupDatabase()
if err != nil {
return fmt.Errorf("备份数据库失败: %w", err)
}
if backupPath != "" {
log.Printf("📦 备份文件: %s", backupPath)
}
if err := setSchemaVersion(SchemaVersion); err != nil {
return fmt.Errorf("更新 Schema 版本失败: %w", err)
}
log.Printf("✅ 版本迁移完成")
} else {
log.Println("✅ Schema 已是最新,无需迁移")
}
return nil
}
// ============================================================
// JWT 密钥管理
// ============================================================
func ensureJwtSecret() error {
var value string
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&value)
if err == nil && value != "" {
return nil
}
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return err
}
secret := hex.EncodeToString(bytes)
_, err = DB.Exec(`
INSERT INTO app_config (key, value) VALUES ('jwt_secret', ?)
`, secret)
if err != nil {
return err
}
log.Printf("✅ JWT 密钥已生成")
return nil
}
func GetJwtSecret() (string, error) {
var secret string
err := DB.QueryRow("SELECT value FROM app_config WHERE key = 'jwt_secret'").Scan(&secret)
if err != nil {
return "", err
}
return secret, nil
}
// ============================================================
// 全局配置 CRUD
// ============================================================
func GetGlobalConfig() (*GlobalConfig, error) {
var cfg GlobalConfig
err := DB.QueryRow(`
SELECT id, bind_port, token, log_level, log_max_days, tcp_mux
FROM global_config WHERE id = 1
`).Scan(
&cfg.ID, &cfg.BindPort, &cfg.Token,
&cfg.LogLevel, &cfg.LogMaxDays,
&cfg.TcpMux,
)
if err != nil {
return nil, err
}
cfg.TcpMux = true
return &cfg, nil
}
func UpdateGlobalConfig(cfg *GlobalConfig) error {
_, err := DB.Exec(`
UPDATE global_config SET
bind_port = ?, token = ?, log_level = ?, log_max_days = ?,
tcp_mux = 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
`, cfg.BindPort, cfg.Token, cfg.LogLevel, cfg.LogMaxDays)
return err
}
// ============================================================
// 客户端记录 CRUD
// ============================================================
func SaveClient(client *Client) error {
_, err := DB.Exec(`
INSERT INTO clients (name, os, arch, version, status, conn_time)
VALUES (?, ?, ?, ?, ?, ?)
`, client.Name, client.OS, client.Arch, client.Version, client.Status, client.ConnTime)
return err
}
func GetClients() ([]Client, error) {
rows, err := DB.Query(`
SELECT id, name, os, arch, version, status, conn_time, updated_at
FROM clients ORDER BY id DESC LIMIT 100
`)
if err != nil {
return nil, err
}
defer rows.Close()
var clients []Client
for rows.Next() {
var c Client
err := rows.Scan(&c.ID, &c.Name, &c.OS, &c.Arch, &c.Version, &c.Status, &c.ConnTime, &c.UpdatedAt)
if err != nil {
return nil, err
}
clients = append(clients, c)
}
return clients, rows.Err()
}
// ============================================================
// 用户 CRUD
// ============================================================
func GetUserByUsername(username string) (*User, error) {
var u User
err := DB.QueryRow(`
SELECT id, username, password_hash, created_at
FROM users WHERE username = ?
`, username).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func CreateUser(username, passwordHash string) error {
_, err := DB.Exec(`
INSERT INTO users (username, password_hash) VALUES (?, ?)
`, username, passwordHash)
return err
}
func UpdatePassword(username, passwordHash string) error {
_, err := DB.Exec(`
UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?
`, passwordHash, username)
return err
}
func CountUsers() (int, error) {
var count int
err := DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
return count, err
}
+493
View File
@@ -0,0 +1,493 @@
#!/bin/bash
# ============================================================
# frps-console 一键部署脚本
# 支持:Linux x86_64 / ARM64 / ARMv7
# 自动安装:git / curl / wget / Go / Docker
#
# 用法:
# ./deploy.sh # 完整交互流程
# ./deploy.sh --yes # 跳过确认,直接执行
# ./deploy.sh --check # 只检测环境,不执行
# ./deploy.sh --dry-run # 显示将执行的操作,不实际执行
# ============================================================
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'
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"
# 状态变量
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
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
# ---------- 打印函数 ----------
print_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
print_success() { echo -e "${GREEN}[✓]${NC} $1"; }
print_warn() { echo -e "${YELLOW}[⚠]${NC} $1"; }
print_error() { echo -e "${RED}[✗]${NC} $1"; }
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
--yes|-y) SKIP_CONFIRM=true ;;
--check) CHECK_ONLY=true ;;
--dry-run) DRY_RUN=true ;;
--help|-h)
echo "用法: ./deploy.sh [选项]"
echo ""
echo "选项:"
echo " --yes, -y 跳过所有确认提示,直接执行"
echo " --check 只检测环境,不执行部署"
echo " --dry-run 显示将执行的操作,不实际执行"
echo " --help, -h 显示帮助信息"
exit 0
;;
*) print_error "未知选项: $arg"; exit 1 ;;
esac
done
}
# ---------- 检查 root ----------
check_root() {
if [ "$EUID" -ne 0 ]; then
print_error "请使用 root 权限运行此脚本"
echo " sudo bash deploy.sh"
exit 1
fi
}
# ---------- 检测环境 ----------
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
OS_VERSION=$VERSION_ID
else
print_error "无法识别操作系统"
exit 1
fi
}
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 ;;
esac
}
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
}
check_container() {
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -q "^frps-console$"; then
CONTAINER_EXISTS=true
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^frps-console$"; then
CONTAINER_RUNNING=true
fi
fi
}
# ---------- 打印环境摘要 ----------
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 ""
echo " ${CYAN}必要工具:${NC}"
if [ "$HAS_GIT" = true ]; then
echo -e " git ✅ 已安装 ($(git --version | awk '{print $3}'))"
else
echo " git ❌ 未安装 (将自动安装)"
fi
if [ "$HAS_CURL" = true ]; then
echo " curl ✅ 已安装"
else
echo " curl ❌ 未安装 (将自动安装)"
fi
if [ "$HAS_WGET" = true ]; then
echo " wget ✅ 已安装"
else
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
echo ""
echo " ${CYAN}容器状态:${NC}"
if [ "$CONTAINER_RUNNING" = true ]; then
echo -e " frps-console ✅ 运行中"
else
echo -e " frps-console ⏸️ 已存在但未运行"
fi
fi
echo ""
}
# ---------- 生成部署计划 ----------
generate_plan() {
PLAN=""
if [ "$NEED_INSTALL_GIT" = true ] || [ "$NEED_INSTALL_CURL" = true ] || [ "$NEED_INSTALL_WGET" = true ]; then
local pkgs=""
[ "$NEED_INSTALL_GIT" = true ] && pkgs="${pkgs} git"
[ "$NEED_INSTALL_CURL" = true ] && pkgs="${pkgs} curl"
[ "$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"
if [ "$CONTAINER_EXISTS" = true ]; then
PLAN="${PLAN} • 停止并删除旧容器\n"
fi
PLAN="${PLAN} • 启动 frps-console 容器 (端口 ${PORT})"
}
print_deployment_plan() {
print_title
print_subtitle "部署计划"
echo ""
echo -e " ${CYAN}将执行以下操作:${NC}"
echo -e "$(echo -e "$PLAN")"
echo ""
echo -e " ${CYAN}配置信息:${NC}"
echo -e " ────────────────────────────────────"
echo -e " 监听端口 : ${PORT}"
echo -e " 部署目录 : ${DEPLOY_DIR}"
echo -e " 数据目录 : ${DATA_DIR}"
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}"
fi
echo ""
}
# ---------- 确认与自定义 ----------
confirm_deploy() {
if [ "$SKIP_CONFIRM" = true ]; then
echo -e "${GREEN}▶ 已启用 --yes,自动确认${NC}"
return 0
fi
echo -e -n "${CYAN}确认执行? 输入 Y 继续,输入 n 自定义配置 [Y/n]: ${NC}"
read -r CONFIRM </dev/tty
case $CONFIRM in
n|N) return 1 ;;
*) return 0 ;;
esac
}
custom_config() {
print_title
print_subtitle "自定义配置"
echo ""
read -p "请输入监听端口 [${DEFAULT_PORT}]: " INPUT_PORT </dev/tty
PORT=${INPUT_PORT:-$DEFAULT_PORT}
read -p "请输入部署目录 [${DEFAULT_DEPLOY_DIR}]: " INPUT_DEPLOY_DIR </dev/tty
DEPLOY_DIR=${INPUT_DEPLOY_DIR:-$DEFAULT_DEPLOY_DIR}
DATA_DIR="${DEPLOY_DIR}/data"
echo ""
echo -e " ${CYAN}更新后的配置:${NC}"
echo -e " ────────────────────────────────────"
echo -e " 监听端口 : ${PORT}"
echo -e " 部署目录 : ${DEPLOY_DIR}"
echo -e " 数据目录 : ${DATA_DIR}"
echo -e " ────────────────────────────────────"
echo ""
}
# ---------- 执行部署 ----------
do_deploy() {
print_title
print_subtitle "开始部署"
echo ""
# 安装工具
if [ "$NEED_INSTALL_GIT" = true ] || [ "$NEED_INSTALL_CURL" = true ] || [ "$NEED_INSTALL_WGET" = true ]; then
print_step "安装必要工具..."
local pkgs=""
[ "$NEED_INSTALL_GIT" = true ] && pkgs="${pkgs} git"
[ "$NEED_INSTALL_CURL" = true ] && pkgs="${pkgs} curl"
[ "$NEED_INSTALL_WGET" = true ] && pkgs="${pkgs} wget"
case $OS in
opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll|opensuse-leap)
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 ;;
arch|manjaro|endeavouros) pacman -S --needed --noconfirm $pkgs ;;
*) print_error "无法识别包管理器"; exit 1 ;;
esac
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 "代码拉取完成"
# 修复 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 -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 "处理旧容器..."
if [ "$CONTAINER_RUNNING" = true ]; then
docker stop frps-console 2>/dev/null || true
fi
docker rm frps-console 2>/dev/null || true
print_success "旧容器已清理"
fi
# 启动容器
print_step "启动 frps-console 容器..."
docker run -d \
--name frps-console \
--restart=always \
--network host \
-v ${DATA_DIR}:/app/data \
-e PORT=${PORT} \
-e TZ=Asia/Shanghai \
${IMAGE_NAME}:latest
if docker ps | grep -q frps-console; then
print_success "容器启动成功!"
else
print_error "容器启动失败,请检查日志: docker logs frps-console"
exit 1
fi
# 检查 frps 子进程
print_step "检查 frps 状态..."
sleep 3
if docker exec frps-console ps aux 2>/dev/null | grep -q "[f]rps -c"; then
print_success "frps 进程运行正常"
else
print_warn "frps 进程未运行(可能配置为空,请在 WebUI 中导入 TOML"
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} frps-console"
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 ""
echo -e " ${YELLOW}首次访问需要注册管理员账户${NC}"
echo ""
print_title
}
# ---------- 主流程 ----------
main() {
clear 2>/dev/null || true
parse_args "$@"
check_root
print_step "检测环境..."
detect_os
detect_arch
check_tools
check_container
print_environment_summary
if [ "$CHECK_ONLY" = true ]; then
print_info "环境检测完成(--check 模式)"
exit 0
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"
exit 1
fi
generate_plan
print_deployment_plan
if ! confirm_deploy; then
custom_config
print_deployment_plan
if ! confirm_deploy; then
print_info "已取消部署"
exit 0
fi
fi
if [ "$DRY_RUN" = true ]; then
print_info "演练模式(--dry-run),不实际执行部署"
exit 0
fi
do_deploy
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
//go:build linux || darwin || freebsd || netbsd || openbsd || solaris
package main
import (
"os/exec"
"syscall"
)
// setSysProcAttr 为 Unix 系统设置 Setsid,让 frpc 进程脱离父进程独立运行
func setSysProcAttr(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setsid = true
}
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"bytes"
"embed"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"text/template"
)
//go:embed bin/*
var embeddedFrps embed.FS
//go:embed frps.tmpl
var FrpsTemplateContent string
var (
cachedFrpsPath string
frpsPathMutex sync.Mutex
)
// ============================================================
// 获取 frps 路径
// ============================================================
func getFrpsPath() (string, error) {
frpsPathMutex.Lock()
defer frpsPathMutex.Unlock()
if cachedFrpsPath != "" {
if _, err := os.Stat(cachedFrpsPath); err == nil {
return cachedFrpsPath, nil
}
cachedFrpsPath = ""
}
var fileName string
switch {
case runtime.GOOS == "windows" && runtime.GOARCH == "amd64":
fileName = "frps_windows_amd64.exe"
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
fileName = "frps_linux_amd64"
case runtime.GOOS == "linux" && runtime.GOARCH == "arm64":
fileName = "frps_linux_arm64"
case runtime.GOOS == "linux" && runtime.GOARCH == "arm":
fileName = "frps_linux_arm_hf"
default:
path, err := exec.LookPath("frps")
if err == nil {
cachedFrpsPath = path
return path, nil
}
return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH)
}
localPath := filepath.Join(".", "bin", fileName)
if _, err := os.Stat(localPath); err == nil {
cachedFrpsPath = localPath
return localPath, nil
}
data, err := embeddedFrps.ReadFile("bin/" + fileName)
if err == nil {
tmpPath := filepath.Join(os.TempDir(), "frps")
if runtime.GOOS == "windows" {
tmpPath += ".exe"
}
if err := os.WriteFile(tmpPath, data, 0755); err == nil {
cachedFrpsPath = tmpPath
return tmpPath, nil
}
if _, statErr := os.Stat(tmpPath); statErr == nil {
cachedFrpsPath = tmpPath
return tmpPath, nil
}
}
path, err := exec.LookPath("frps")
if err == nil {
cachedFrpsPath = path
return path, nil
}
return "", fmt.Errorf("未找到 frps 文件")
}
// ============================================================
// 生成 frps.toml
// ============================================================
func GenerateFrpsConfig() error {
cfg, err := GetGlobalConfig()
if err != nil {
return fmt.Errorf("读取全局配置失败: %w", err)
}
// 直接传递 cfg 即可,模板里能读到 .WireProtocolV2
var tmplContent string
if _, err := os.Stat("frps.tmpl"); err == nil {
content, readErr := os.ReadFile("frps.tmpl")
if readErr == nil {
tmplContent = string(content)
} else {
tmplContent = FrpsTemplateContent
}
} else {
tmplContent = FrpsTemplateContent
}
tmpl, err := template.New("frps").Parse(tmplContent)
if err != nil {
return fmt.Errorf("解析模板失败: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, cfg); err != nil {
return fmt.Errorf("渲染模板失败: %w", err)
}
if err := os.WriteFile("./frps.toml", buf.Bytes(), 0644); err != nil {
return fmt.Errorf("写入配置文件失败: %w", err)
}
return nil
}
// ============================================================
// frps 进程管理
// ============================================================
func isFrpsRunning() bool {
pidData, err := os.ReadFile("./frps.pid")
if err != nil {
return false
}
pid, err := strconv.Atoi(strings.TrimSpace(string(pidData)))
if err != nil {
return false
}
if runtime.GOOS == "windows" {
cmd := exec.Command("tasklist", "/FI", "PID eq", strconv.Itoa(pid))
output, err := cmd.CombinedOutput()
if err != nil {
return false
}
return strings.Contains(string(output), strconv.Itoa(pid))
}
process, err := os.FindProcess(pid)
if err != nil {
return false
}
return process.Signal(syscall.Signal(0)) == nil
}
func StartFrps() error {
frpsPath, err := getFrpsPath()
if err != nil {
return fmt.Errorf("获取 frps 路径失败: %w", err)
}
if _, err := os.Stat("./frps.toml"); os.IsNotExist(err) {
if err := GenerateFrpsConfig(); err != nil {
return fmt.Errorf("生成配置文件失败: %w", err)
}
}
if isFrpsRunning() {
return nil
}
os.Remove("./frps.pid")
cmd := exec.Command(frpsPath, "-c", "./frps.toml")
setWindowHide(cmd)
setSysProcAttr(cmd)
logFile, err := os.OpenFile("./frps.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("打开日志文件失败: %w", err)
}
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 frps 失败: %w", err)
}
if err := os.WriteFile("./frps.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil {
return fmt.Errorf("保存 PID 失败: %w", err)
}
return nil
}
func StopFrps() error {
if runtime.GOOS == "windows" {
cmd := exec.Command("taskkill", "/F", "/IM", "frps.exe")
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") {
return fmt.Errorf("停止 frps 失败: %w", err)
}
os.Remove("./frps.pid")
return nil
}
pidData, err := os.ReadFile("./frps.pid")
if err != nil {
cmd := exec.Command("pkill", "-f", "frps")
if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") {
return fmt.Errorf("停止 frps 失败: %w", err)
}
return nil
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData)))
process, err := os.FindProcess(pid)
if err != nil {
os.Remove("./frps.pid")
return nil
}
if err := process.Kill(); err != nil {
return fmt.Errorf("杀死进程失败: %w", err)
}
os.Remove("./frps.pid")
return nil
}
func GetFrpsStatus() (bool, error) {
return isFrpsRunning(), nil
}
func ReloadFrps() error {
running := isFrpsRunning()
if !running {
return StartFrps()
}
frpsPath, err := getFrpsPath()
if err != nil {
return fmt.Errorf("获取 frps 路径失败: %w", err)
}
cmd := exec.Command(frpsPath, "reload", "-c", "./frps.toml")
_, err = cmd.CombinedOutput()
if err != nil {
log.Printf("⚠️ 热加载失败 (%v),自动降级为重启 frps", err)
if stopErr := StopFrps(); stopErr != nil {
return fmt.Errorf("停止 frps 失败: %w", stopErr)
}
if startErr := StartFrps(); startErr != nil {
return fmt.Errorf("启动 frps 失败: %w", startErr)
}
return nil
}
return nil
}
+10
View File
@@ -0,0 +1,10 @@
bindPort = {{.BindPort}}
auth.token = "{{.Token}}"
log.to = "./frps.log"
log.level = "{{.LogLevel}}"
log.maxDays = {{.LogMaxDays}}
[transport]
tcpMux = {{.TcpMux}}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "os/exec"
func setWindowHide(cmd *exec.Cmd) {
// 非 Windows 平台什么都不做
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows
package main
import (
"os/exec"
"syscall"
)
// setWindowHide Windows 隐藏窗口
func setWindowHide(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.HideWindow = true
}
// setSysProcAttr Windows 不需要 Setsid,空操作
func setSysProcAttr(cmd *exec.Cmd) {
// Windows 不需要 Setsid,什么都不做
}
+48
View File
@@ -0,0 +1,48 @@
module frps-console
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.54.0
modernc.org/sqlite v1.54.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+137
View File
@@ -0,0 +1,137 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"log"
"time"
)
func main() {
if err := InitDB(); err != nil {
log.Fatal("❌ 数据库初始化失败:", err)
}
if err := GenerateFrpsConfig(); err != nil {
log.Println("⚠️ 生成配置文件失败:", err)
}
if err := StartFrps(); err != nil {
log.Println("⚠️ 启动 frps 失败:", err)
}
go startWatchdog()
r := SetupRouter()
log.Println("🚀 frps-console 启动成功!")
log.Println("📍 访问地址: http://localhost:9365")
log.Println("📍 API 地址: http://localhost:9365/api")
if err := r.Run(":9365"); err != nil {
log.Fatal("❌ 服务启动失败:", err)
}
}
func startWatchdog() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if !isFrpsRunning() {
log.Println("⚠️ frps 进程已停止,自动重启...")
if err := StartFrps(); err != nil {
log.Printf("❌ 自动重启 frps 失败: %v", err)
} else {
log.Println("✅ frps 已自动重启")
}
}
}
}
+197
View File
@@ -0,0 +1,197 @@
//go:build ignore
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
type Platform struct {
OS string
Arch string
Name string
Ext string
}
var platforms = []Platform{
{"windows", "amd64", "Windows x86-64", ".exe"},
{"linux", "amd64", "Linux x86-64", ""},
{"linux", "arm64", "Linux ARM64", ""},
{"linux", "arm", "Linux ARMv7l", ""},
}
const (
Version = "2.0.0"
BuildTime = "2026-07-26"
Binary = "frps-console"
)
func main() {
if len(os.Args) > 1 {
target := os.Args[1]
switch target {
case "all":
buildAll()
return
case "clean":
clean()
return
case "list":
listPlatforms()
return
}
for _, p := range platforms {
if target == p.OS+"/"+p.Arch {
build(p, false)
return
}
}
fmt.Println("❌ 不支持的平台:", target)
fmt.Println(" 可用: windows/amd64, linux/amd64, linux/arm64, linux/arm")
fmt.Println(" 或: all, list, clean")
return
}
interactiveMenu()
}
func listPlatforms() {
fmt.Println("可用平台:")
for i, p := range platforms {
fmt.Printf(" %d. %s (%s/%s)\n", i+1, p.Name, p.OS, p.Arch)
}
}
func interactiveMenu() {
fmt.Println("========================================")
fmt.Println(" frps-console 多平台构建工具 v" + Version)
fmt.Println("========================================")
fmt.Println()
for i, p := range platforms {
fmt.Printf(" %d. %s (%s/%s)\n", i+1, p.Name, p.OS, p.Arch)
}
fmt.Println(" a. 全部构建")
fmt.Println(" c. 清理构建产物")
fmt.Println(" q. 退出")
fmt.Println()
reader := bufio.NewReader(os.Stdin)
fmt.Print("请选择: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(strings.ToLower(input))
switch input {
case "q":
fmt.Println("已退出")
return
case "c":
clean()
return
case "a":
buildAll()
return
default:
var idx int
if n, err := fmt.Sscanf(input, "%d", &idx); n == 1 && err == nil && idx >= 1 && idx <= len(platforms) {
build(platforms[idx-1], false)
return
}
fmt.Println("❌ 无效选择")
interactiveMenu()
}
}
func buildAll() {
for _, p := range platforms {
build(p, true)
}
fmt.Println("\n✅ 全部构建完成!产物位于 build/ 目录")
}
func build(p Platform, silent bool) {
if !silent {
fmt.Printf("🔨 构建 %s (%s/%s)...\n", p.Name, p.OS, p.Arch)
}
outDir := "./build"
if err := os.MkdirAll(outDir, 0755); err != nil {
fmt.Printf("❌ 创建目录失败: %v\n", err)
return
}
outName := Binary + "-" + p.OS + "-" + p.Arch
if p.Arch == "arm" {
outName += "v7"
}
outName += p.Ext
outPath := filepath.Join(outDir, outName)
cmd := exec.Command("go", "build",
"-ldflags=-s -w -X main.version="+Version,
"-o", outPath,
".",
)
cmd.Env = append(os.Environ(),
"GOOS="+p.OS,
"GOARCH="+p.Arch,
"CGO_ENABLED=0",
)
if p.Arch == "arm" {
cmd.Env = append(cmd.Env, "GOARM=7")
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("❌ 构建失败: %v\n", err)
return
}
if !silent {
fmt.Printf("✅ 构建完成: %s\n", outPath)
if info, err := os.Stat(outPath); err == nil {
size := float64(info.Size()) / 1024 / 1024
fmt.Printf(" 📦 %.2f MB\n", size)
}
}
if !silent {
packageBinary(outPath, p)
}
}
func packageBinary(binPath string, p Platform) {
base := strings.TrimSuffix(filepath.Base(binPath), p.Ext)
dir := filepath.Dir(binPath)
if p.Ext == ".exe" {
zipName := filepath.Join(dir, base+".zip")
cmd := exec.Command("zip", "-j", zipName, binPath)
if err := cmd.Run(); err == nil {
fmt.Printf(" 📦 已打包: %s\n", zipName)
}
} else {
tarName := filepath.Join(dir, base+".tar.gz")
cmd := exec.Command("tar", "-czf", tarName, "-C", dir, filepath.Base(binPath))
if err := cmd.Run(); err == nil {
fmt.Printf(" 📦 已打包: %s\n", tarName)
}
}
}
func clean() {
fmt.Println("🧹 清理构建产物...")
if err := os.RemoveAll("./build"); err != nil {
fmt.Printf("❌ 清理失败: %v\n", err)
return
}
fmt.Println("✅ 清理完成")
}
+607
View File
@@ -0,0 +1,607 @@
// app.js - 普通脚本(非模块)
const { createApp, ref, reactive, computed, onMounted, nextTick, watch } = Vue;
// ============================================================
// 1. 基础 API
// ============================================================
const API_BASE = "/api";
function getToken() {
return localStorage.getItem("frps_token") || "";
}
function getHeaders() {
return {
"Content-Type": "application/json",
Authorization: "Bearer " + getToken(),
};
}
async function apiFetch(endpoint, options = {}) {
const url = API_BASE + endpoint;
const headers =
options.body instanceof FormData
? { Authorization: "Bearer " + getToken() }
: getHeaders();
const res = await fetch(url, {
...options,
headers: { ...headers, ...(options.headers || {}) },
});
return res.json();
}
// ============================================================
// 2. Auth
// ============================================================
function parseJwtExpire(token) {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
return payload.exp * 1000;
} catch {
return Date.now() + 7 * 24 * 60 * 60 * 1000;
}
}
function saveAuthState(token, username) {
const expire = parseJwtExpire(token);
localStorage.setItem("frps_token", token);
localStorage.setItem("frps_token_expire", String(expire));
localStorage.setItem("frps_username", username);
return expire;
}
function loadAuthState() {
const token = localStorage.getItem("frps_token");
const expire = parseInt(localStorage.getItem("frps_token_expire") || "0", 10);
const username = localStorage.getItem("frps_username") || "";
if (token && expire > Date.now()) {
return { token, expire, username, valid: true };
}
clearAuthState();
return { token: "", expire: 0, username: "", valid: false };
}
function clearAuthState() {
localStorage.removeItem("frps_token");
localStorage.removeItem("frps_token_expire");
localStorage.removeItem("frps_username");
}
async function login(username, password) {
const data = await apiFetch("/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
if (data.code === 0) {
const token = data.data.token;
const expire = saveAuthState(token, username);
return { success: true, token, expire };
}
return { success: false, error: data.msg || "登录失败" };
}
// ============================================================
// 3. Config
// ============================================================
const defaultConfig = {
bindPort: 9358,
token: "CHANGE_ME",
logLevel: "info",
logMaxDays: 3,
tcpMux: true,
};
async function loadConfig() {
try {
const data = await apiFetch("/config");
if (data.code === 0) {
return { ...defaultConfig, ...data.data };
}
} catch (e) {
console.warn("加载配置失败", e);
}
return { ...defaultConfig };
}
async function saveConfigApi(config) {
const data = await apiFetch("/config", {
method: "PUT",
body: JSON.stringify(config),
});
return data;
}
// ============================================================
// 4. Clients
// ============================================================
async function loadClients() {
try {
const data = await apiFetch("/clients");
if (data.code === 0) {
return data.data || [];
}
} catch (e) {
console.warn("加载客户端失败", e);
}
return [];
}
function filterClients(list, keyword) {
if (!keyword) return list;
const kw = keyword.toLowerCase();
return list.filter(
(c) =>
(c.name || "").toLowerCase().includes(kw) ||
String(c.id).includes(kw) ||
(c.os || "").toLowerCase().includes(kw)
);
}
// ============================================================
// 5. Frps
// ============================================================
async function getFrpsStatus() {
try {
const data = await apiFetch("/frps/status");
if (data.code === 0) {
return data.data.running || false;
}
} catch (e) {
console.warn("获取 frps 状态失败", e);
}
return false;
}
// ============================================================
// 6. Logs
// ============================================================
async function fetchLogsApi() {
try {
const data = await apiFetch("/frps/log");
if (data.code === 0) {
return { lines: data.data.lines || [], total: data.data.total || 0, error: data.data.error || "" };
}
return { lines: [], total: 0, error: "加载失败" };
} catch (e) {
return { lines: [], total: 0, error: "网络错误" };
}
}
// ============================================================
// 7. UI State
// ============================================================
const activeTab = ref("clients");
const searchKeyword = ref("");
const showToken = ref(false);
// ============================================================
// 8. Vue App
// ============================================================
const app = createApp({
setup() {
// ---- 登录态 ----
const loggedIn = ref(false);
const loading = ref(false);
const loginError = ref("");
const loginForm = reactive({ username: "", password: "" });
const token = ref("");
const tokenExpireTime = ref(0);
const transitioning = ref(false);
const contentVisible = ref(false);
let expireTimer = null;
// ---- 注册 ----
const isFirstRun = ref(false);
const registerForm = reactive({
username: "",
password: "",
confirmPassword: "",
});
const registerError = ref("");
const registering = ref(false);
// ---- 数据 ----
const globalConfig = reactive({});
const clients = ref([]);
const clientsLoaded = ref(false);
const frpsRunning = ref(false);
// ---- 修改密码 ----
const passwordChange = reactive({
oldPassword: "",
newPassword: "",
confirmPassword: "",
});
const passwordChangeError = ref("");
const passwordChangeSuccess = ref("");
// ---- 日志 ----
const logLines = ref([]);
const logTotal = ref(0);
const logError = ref("");
const logLastUpdate = ref("");
const logAutoScroll = ref(true);
let logTimer = null;
let logFetching = false;
// ---- 初始化 ----
onMounted(() => {
const saved = loadAuthState();
if (saved.valid) {
token.value = saved.token;
tokenExpireTime.value = saved.expire;
loginForm.username = saved.username;
loggedIn.value = true;
loadAllData().then(() => {
contentVisible.value = true;
startExpireTimer();
});
} else {
checkUsers();
}
});
// ---- Tab 切换监听日志轮询 ----
watch(activeTab, (newTab) => {
if (newTab === "logs") {
startLogPolling();
} else {
stopLogPolling();
}
if (newTab === "clients") {
fetchClients();
}
});
async function checkUsers() {
try {
const res = await fetch("/api/check/users");
const data = await res.json();
if (data.code === 0) {
isFirstRun.value = !data.data.hasUsers;
}
} catch (e) {
console.warn("检查用户状态失败", e);
}
}
function startExpireTimer() {
if (expireTimer) clearInterval(expireTimer);
expireTimer = setInterval(() => {
if (Date.now() >= tokenExpireTime.value) {
doLogout();
alert("登录已超时,请重新登录");
}
}, 5000);
}
async function loadAllData() {
const [cfg, running] = await Promise.all([
loadConfig(),
getFrpsStatus(),
]);
Object.assign(globalConfig, cfg);
frpsRunning.value = running;
await fetchClients();
}
async function fetchClients() {
clientsLoaded.value = false;
const list = await loadClients();
clients.value = list;
clientsLoaded.value = true;
}
// ---- 日志轮询 ----
async function fetchLogs() {
if (logFetching) return;
logFetching = true;
try {
const result = await fetchLogsApi();
logLines.value = result.lines;
logTotal.value = result.total;
logError.value = result.error;
logLastUpdate.value = new Date().toLocaleTimeString();
if (logAutoScroll.value) {
await nextTick();
const container = document.getElementById("logContainer");
if (container) {
container.scrollTop = container.scrollHeight;
}
}
} catch (e) {
logError.value = "网络错误";
} finally {
logFetching = false;
}
}
function startLogPolling() {
if (logTimer) return;
fetchLogs();
logTimer = setInterval(fetchLogs, 8000);
}
function stopLogPolling() {
if (logTimer) {
clearInterval(logTimer);
logTimer = null;
}
}
function refreshLogs() {
fetchLogs();
}
function scrollLogToBottom() {
const container = document.getElementById("logContainer");
if (container) {
container.scrollTop = container.scrollHeight;
}
}
// ---- 登录 ----
const doLogin = async () => {
if (!loginForm.username || !loginForm.password) {
loginError.value = "请输入用户名和密码";
return;
}
loading.value = true;
loginError.value = "";
const result = await login(loginForm.username, loginForm.password);
if (result.success) {
token.value = result.token;
tokenExpireTime.value = result.expire;
transitioning.value = true;
loggedIn.value = true;
setTimeout(async () => {
transitioning.value = false;
await loadAllData();
loading.value = false;
setTimeout(() => {
contentVisible.value = true;
startExpireTimer();
}, 300);
}, 500);
} else {
loginError.value = result.error;
loading.value = false;
}
};
// ---- 注册 ----
const doRegister = async () => {
if (registerForm.username.length < 5) {
registerError.value = "用户名至少 5 位";
return;
}
if (registerForm.password !== registerForm.confirmPassword) {
registerError.value = "两次输入的密码不一致";
return;
}
if (registerForm.password.length < 8) {
registerError.value = "密码至少 8 位";
return;
}
registering.value = true;
registerError.value = "";
try {
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: registerForm.username,
password: registerForm.password,
}),
});
const data = await res.json();
if (data.code === 0) {
isFirstRun.value = false;
const token = data.data.token;
const expire = saveAuthState(token, registerForm.username);
token.value = token;
tokenExpireTime.value = expire;
loginForm.username = registerForm.username;
loggedIn.value = true;
transitioning.value = true;
setTimeout(async () => {
transitioning.value = false;
await loadAllData();
setTimeout(() => {
contentVisible.value = true;
startExpireTimer();
}, 300);
}, 500);
} else {
registerError.value = data.msg || "注册失败";
}
} catch (e) {
registerError.value = "网络错误,请重试";
}
registering.value = false;
};
// ---- 退出 ----
const doLogout = () => {
if (expireTimer) clearInterval(expireTimer);
stopLogPolling();
contentVisible.value = false;
loggedIn.value = false;
token.value = "";
tokenExpireTime.value = 0;
loginForm.username = "";
loginForm.password = "";
loading.value = false;
transitioning.value = false;
clearAuthState();
isFirstRun.value = false;
registerForm.username = "";
registerForm.password = "";
registerForm.confirmPassword = "";
registerError.value = "";
registering.value = false;
};
// ---- 修改密码 ----
const changePassword = async () => {
passwordChangeError.value = "";
passwordChangeSuccess.value = "";
if (passwordChange.newPassword !== passwordChange.confirmPassword) {
passwordChangeError.value = "两次输入的密码不一致";
return;
}
if (passwordChange.newPassword.length < 8) {
passwordChangeError.value = "密码至少 8 位";
return;
}
try {
const res = await fetch("/api/user/password", {
method: "PUT",
headers: getHeaders(),
body: JSON.stringify({
oldPassword: passwordChange.oldPassword,
newPassword: passwordChange.newPassword,
}),
});
const data = await res.json();
if (data.code === 0) {
passwordChangeSuccess.value = "密码修改成功!";
passwordChange.oldPassword = "";
passwordChange.newPassword = "";
passwordChange.confirmPassword = "";
} else {
passwordChangeError.value = data.msg || "修改失败";
}
} catch (e) {
passwordChangeError.value = "网络错误,请重试";
}
};
// ---- 保存配置 ----
const saveConfig = async () => {
const result = await saveConfigApi(globalConfig);
if (result.code === 0) {
frpsRunning.value = await getFrpsStatus();
alert("配置已更新并热加载");
} else {
alert("保存失败: " + result.msg);
}
};
// ---- 导入导出 ----
const triggerImport = () => {
document.getElementById("tomlFileInput").click();
};
const handleImport = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/import/toml", {
method: "POST",
headers: { Authorization: "Bearer " + getToken() },
body: formData,
});
const data = await res.json();
if (data.code === 0) {
alert(data.msg);
await loadAllData();
} else {
alert("导入失败: " + data.msg);
}
} catch (err) {
alert("网络错误: " + err.message);
}
e.target.value = "";
};
const exportToml = async () => {
try {
const res = await fetch("/api/export/toml", {
method: "GET",
headers: { Authorization: "Bearer " + getToken() },
});
if (!res.ok) {
const data = await res.json();
alert("导出失败: " + (data.msg || "未知错误"));
return;
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "frps.toml";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
} catch (e) {
alert("导出失败: " + e.message);
}
};
// ---- 计算属性 ----
const filteredClients = computed(() =>
filterClients(clients.value, searchKeyword.value)
);
const showDefaultTip = computed(() => {
const token = globalConfig.token;
return !token || token === "CHANGE_ME" || token.trim() === "";
});
// ---- 返回 ----
return {
loggedIn,
loading,
loginError,
loginForm,
doLogin,
doLogout,
isFirstRun,
registerForm,
registerError,
registering,
doRegister,
activeTab,
globalConfig,
saveConfig,
clients,
clientsLoaded,
filteredClients,
fetchClients,
frpsRunning,
searchKeyword,
transitioning,
contentVisible,
triggerImport,
handleImport,
exportToml,
passwordChange,
passwordChangeError,
passwordChangeSuccess,
changePassword,
logLines,
logTotal,
logError,
logLastUpdate,
logAutoScroll,
refreshLogs,
scrollLogToBottom,
showToken,
showDefaultTip,
};
},
});
app.mount("#app");
+324
View File
@@ -0,0 +1,324 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frps-console</title>
<!-- Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- 样式 -->
<link rel="stylesheet" href="/static/style-1.css" />
<link rel="stylesheet" href="/static/style-2.css" />
<link rel="stylesheet" href="/static/style-3.css" />
<link rel="stylesheet" href="/static/style-4.css" />
</head>
<body>
<div id="app">
<!-- 背景层 -->
<div class="app-backdrop" :class="{ 'is-logged-in': loggedIn }"></div>
<div class="loading-ring" :class="{ visible: transitioning }"></div>
<!-- 主容器 -->
<div class="app-container" :class="{ 'is-logged-in': loggedIn }">
<!-- ====== 登录/注册页 ====== -->
<div v-if="!loggedIn" class="login-card">
<!-- Logo 区域 -->
<div class="login-logo">
<img src="/static/logo.svg" alt="frps-console" class="logo-img" />
<span class="logo-text">frps-console</span>
</div>
<!-- 首次启动:注册页 -->
<template v-if="isFirstRun">
<div class="login-header">
<h1>首次设置</h1>
</div>
<div class="login-form">
<div class="input-group">
<label>用户名(至少 5 位)</label>
<input v-model="registerForm.username" type="text" placeholder="设置管理员用户名"
@keydown.enter="doRegister" />
</div>
<div class="input-group">
<label>密码</label>
<input v-model="registerForm.password" type="password" placeholder="至少8位,含大小写/数字/特殊字符"
@keydown.enter="doRegister" />
</div>
<div class="input-group">
<label>确认密码</label>
<input v-model="registerForm.confirmPassword" type="password" placeholder="再次输入密码"
@keydown.enter="doRegister" />
</div>
<button class="login-btn" @click="doRegister" :disabled="registering">
{{ registering ? '注册中...' : '注册' }}
</button>
<p v-if="registerError" class="login-error">{{ registerError }}</p>
</div>
</template>
<!-- 正常:登录页 -->
<template v-else>
<div class="login-header">
<h1>console login</h1>
</div>
<div class="login-form">
<div class="input-group">
<label>user</label>
<input v-model="loginForm.username" type="text" placeholder="用户名"
@keydown.enter="doLogin" />
</div>
<div class="input-group">
<label>passwd</label>
<input v-model="loginForm.password" type="password" placeholder="密码"
@keydown.enter="doLogin" />
</div>
<button class="login-btn" @click="doLogin" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
<p v-if="loginError" class="login-error">{{ loginError }}</p>
</div>
</template>
</div>
<!-- ====== 主界面 ====== -->
<div v-else class="main-panel" :class="{ visible: contentVisible }">
<!-- 顶部导航 -->
<div class="top-bar">
<div class="top-left">
<img src="/static/logo.svg" alt="frps-console" class="nav-logo" />
<div class="brand-info">
<span class="logo-text">frps-console</span>
<span class="status-wrapper">
<span class="status-dot" :class="{ active: frpsRunning }"></span>
<span class="status-text">{{ frpsRunning ? 'frps 运行中' : 'frps 已停止' }}</span>
</span>
</div>
</div>
<div class="top-right">
<span class="user-name">{{ loginForm.username }}</span>
<button class="logout-btn" @click="doLogout">退出</button>
</div>
</div>
<!-- Tab 栏 -->
<div class="tab-bar">
<span class="tab-item" :class="{ active: activeTab === 'clients' }"
@click="activeTab = 'clients'">客户端列表</span>
<span class="tab-item" :class="{ active: activeTab === 'config' }"
@click="activeTab = 'config'">全局配置信息</span>
<span class="tab-item" :class="{ active: activeTab === 'logs' }"
@click="activeTab = 'logs'">运行日志</span>
</div>
<!-- 内容区 -->
<div class="content-area">
<!-- ====== 客户端列表 ====== -->
<div v-if="activeTab === 'clients'" class="tab-content">
<div class="toolbar">
<div class="toolbar-left">
<button class="btn-import" @click="triggerImport">导入 TOML</button>
<button class="btn-export" @click="exportToml">导出 TOML</button>
</div>
<div class="toolbar-right">
<input class="search-input" placeholder="搜索客户端..." v-model="searchKeyword" />
</div>
</div>
<div class="proxy-list">
<div v-for="c in filteredClients" :key="c.id" class="proxy-card">
<div class="proxy-row">
<div class="proxy-left">
<span class="status-dot" :class="{ active: c.status === 'online' }"></span>
<span class="proxy-name">{{ c.name || c.id }}</span>
<span class="proxy-tag">{{ c.os }} / {{ c.arch }}</span>
<span class="proxy-tag">v{{ c.version }}</span>
</div>
<div class="proxy-right">
<span class="remote-port">{{ c.status === 'online' ? '在线' : '离线' }}</span>
</div>
</div>
<div class="proxy-footer">
<span class="status-label">连接时间: {{ c.connTime || '--' }}</span>
<span class="status-label">ID: {{ c.id }}</span>
</div>
</div>
<div v-if="filteredClients.length === 0" class="empty-state">
{{ clientsLoaded ? '暂无客户端连接' : '加载中...' }}
</div>
</div>
</div>
<!-- ====== 全局配置信息 ====== -->
<div v-if="activeTab === 'config'" class="tab-content config-tab-content">
<!-- 页面标题 -->
<div class="config-page-header">
<h2>全局配置信息</h2>
<p class="config-subtitle">管理 frps 服务端参数与传输设置</p>
</div>
<!-- 首次使用提示 -->
<div v-if="showDefaultTip" class="config-tip">
首次使用请修改「认证令牌」为您的真实 frps 配置
</div>
<!-- ===== 账户管理 ===== -->
<div class="profile-section">
<div class="profile-header">
<span>账户管理</span>
<span class="profile-username">{{ loginForm.username }}</span>
</div>
<div class="profile-form">
<div class="form-row">
<label>当前密码</label>
<input v-model="passwordChange.oldPassword" type="password" placeholder="输入当前密码" />
</div>
<div class="form-row">
<label>新密码</label>
<input v-model="passwordChange.newPassword" type="password"
placeholder="至少8位,含大小写/数字/特殊字符" />
</div>
<div class="form-row">
<label>确认新密码</label>
<input v-model="passwordChange.confirmPassword" type="password"
placeholder="再次输入新密码" />
</div>
<button class="save-btn" @click="changePassword">修改密码</button>
<p v-if="passwordChangeError" class="login-error">{{ passwordChangeError }}</p>
<p v-if="passwordChangeSuccess" class="login-success">{{ passwordChangeSuccess }}</p>
</div>
</div>
<!-- ===== 卡片1: 服务器连接 ===== -->
<div class="config-card">
<div class="config-card-header">
<span class="card-title">服务器连接</span>
</div>
<div class="config-card-body">
<div class="form-row">
<label>绑定端口</label>
<input type="number" v-model="globalConfig.bindPort" placeholder="9358" />
</div>
<div class="form-row" style="grid-column: 1 / -1;">
<label>认证令牌</label>
<div class="token-input-wrapper">
<input :type="showToken ? 'text' : 'password'" v-model="globalConfig.token"
placeholder="CHANGE_ME" />
<button class="token-toggle-btn" @click="showToken = !showToken">
{{ showToken ? '隐藏' : '显示' }}
</button>
</div>
</div>
</div>
</div>
<!-- ===== 卡片2: 传输配置 ===== -->
<div class="config-card">
<div class="config-card-header">
<span class="card-title">传输配置</span>
</div>
<div class="config-card-body">
<!-- TCP 多路复用(只读,占满一行) -->
<div class="form-row" style="grid-column: 1 / -1;">
<label>TCP 多路复用</label>
<div class="readonly-value">
<span class="status-dot active"></span>
<span class="status-text active">已强制开启</span>
<span class="hint-text">优化连接性能,减少延迟,frp 官方推荐开启</span>
</div>
</div>
<!-- v2 协议状态(只读展示,无实际配置) -->
<div class="form-row v2-switch-row">
<label>frp v2 协议支持</label>
<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>
</div>
<span class="hint-text v2-hint">
frps 已内置 v2 协议支持,无需额外配置。如需启用,请在 frpc 配置中设置 transport.wireProtocol = "v2"
</span>
</div>
</div>
</div>
<!-- ===== 卡片3: 日志配置 ===== -->
<div class="config-card">
<div class="config-card-header">
<span class="card-title">日志配置</span>
</div>
<div class="config-card-body">
<div class="form-row">
<label>日志级别</label>
<select v-model="globalConfig.logLevel">
<option value="trace">trace</option>
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</div>
<div class="form-row">
<label>日志保留天数(天)</label>
<input type="number" v-model="globalConfig.logMaxDays" />
</div>
</div>
</div>
<!-- 保存按钮 -->
<button class="save-config-btn" @click="saveConfig">保存配置并热加载</button>
</div>
<!-- ====== 运行日志 ====== -->
<div v-else-if="activeTab === 'logs'" class="tab-content log-tab-content">
<!-- 工具栏 -->
<div class="log-toolbar">
<div class="log-toolbar-left">
<span class="log-info-badge">共 {{ logTotal }} 行</span>
<span v-if="logError" class="log-error-badge">{{ logError }}</span>
</div>
<div class="log-toolbar-right">
<label class="log-auto-scroll-label">
<input type="checkbox" v-model="logAutoScroll" />
自动滚动
</label>
<button class="log-btn" @click="refreshLogs">刷新</button>
<button class="log-btn" @click="scrollLogToBottom">↓ 滚动到底部</button>
</div>
</div>
<!-- 日志显示区域 -->
<div class="log-terminal" id="logContainer">
<div v-if="logLines.length === 0 && !logError" class="log-empty">
暂无日志,frps 尚未产生输出
</div>
<div v-else class="log-lines">
<div v-for="(line, idx) in logLines" :key="idx" class="log-line">{{ line }}</div>
</div>
</div>
<!-- 底部状态 -->
<div class="log-footer">
<span>上次更新: {{ logLastUpdate || '--:--:--' }}</span>
<span class="log-polling-status">自动刷新中 (8s)</span>
</div>
</div>
</div>
</div>
</div>
<!-- 隐藏文件选择器 -->
<input type="file" id="tomlFileInput" accept=".toml" style="display:none" @change="handleImport" />
</div>
<script src="/static/app.js"></script>
</body>
</html>
+15
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

+184
View File
@@ -0,0 +1,184 @@
/* ===== style-1.css - 全局基础 ===== */
/* ---------- 字体定义 ---------- */
@font-face {
font-family: 'HarmonyOS Sans SC';
src: local('HarmonyOS Sans SC'),
local('PingFang SC'),
local('Microsoft YaHei'),
local('Helvetica Neue');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'HarmonyOS Sans SC';
src: local('HarmonyOS Sans SC Medium'),
local('PingFang SC Medium');
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: 'HarmonyOS Sans SC';
src: local('HarmonyOS Sans SC Bold'),
local('PingFang SC Semibold');
font-weight: 700;
font-display: swap;
}
/* ---------- 重置 ---------- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: #0a0a0f;
height: 100vh;
overflow: hidden;
color: #e0e0e0;
}
/* ---------- 数字专用字体(加粗) ---------- */
.digit,
.big-number,
.remote-port,
.proxy-addr,
.metric-value .digit,
.metric-value .big-number,
.login-header h1,
.login-btn,
.save-btn,
.btn-confirm {
font-weight: 600 !important;
letter-spacing: 0.02em;
}
/* 数字特别加粗(适用于大数字展示) */
.big-number {
font-weight: 700 !important;
letter-spacing: -0.01em;
}
/* 代码/端口类数字使用等宽字体,但保持加粗 */
.digit,
.remote-port,
.proxy-addr {
font-family: 'HarmonyOS Sans SC', 'JetBrains Mono', monospace;
font-weight: 600 !important;
}
/* ---------- 全局颜色变量 ---------- */
:root {
--primary-blue: #4a7cff;
--primary-blue-dim: rgba(74, 124, 255, 0.15);
--primary-blue-glow: rgba(74, 124, 255, 0.3);
--primary-blue-border: rgba(74, 124, 255, 0.2);
--bg-deep: #0a0a18;
--bg-card: rgba(13, 13, 26, 0.6);
--bg-metric: rgba(255, 255, 255, 0.03);
--border-subtle: rgba(255, 255, 255, 0.04);
--text-dim: rgba(255, 255, 255, 0.25);
--text-muted: rgba(255, 255, 255, 0.35);
--text-secondary: rgba(255, 255, 255, 0.6);
--text-primary: rgba(255, 255, 255, 0.85);
}
/* ---------- 滚动条 ---------- */
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(74, 124, 255, 0.25);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(74, 124, 255, 0.4);
}
/* ---------- 背景磨砂层 ---------- */
.app-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background:
radial-gradient(ellipse at 20% 50%, rgba(30, 60, 120, 0.3) 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, rgba(80, 30, 120, 0.2) 0%, transparent 60%),
#0a0a0f;
z-index: 0;
}
/* ---------- 主容器 ---------- */
.app-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1;
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(40px) saturate(1.2);
-webkit-backdrop-filter: blur(40px) saturate(1.2);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 32px;
box-shadow: 0 40px 80px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05);
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
/* 登录态尺寸 */
.app-container:not(.is-logged-in) {
width: 600px;
height: 500px;
}
/* 登录后尺寸 */
.app-container.is-logged-in {
width: min(1600px, 92vw);
height: min(900px, 88vh);
}
/* ---------- 旋转圈 ---------- */
.loading-ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.04);
border-top: 3px solid var(--primary-blue);
border-radius: 50%;
animation: spin 0.8s linear infinite;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
z-index: 5;
}
.loading-ring.visible {
opacity: 1;
}
@keyframes spin {
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* ---------- 主内容淡入 ---------- */
.main-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 20px 28px; /* ← 补上内边距 */
opacity: 0;
transition: opacity 0.3s ease;
}
.main-panel.visible {
opacity: 1;
}
+130
View File
@@ -0,0 +1,130 @@
/* ===== style-2.css - 登录页 ===== */
.login-card {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 32px 48px 48px 48px; /* 上左右不变,下边距加大 */
}
.login-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.login-icon {
font-size: 28px;
}
.login-header h1 {
font-size: 22px;
font-weight: 300;
letter-spacing: 4px;
color: var(--text-primary);
text-transform: lowercase;
}
.login-form {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 14px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.input-group label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-dim);
}
.input-group input {
height: 44px;
padding: 0 20px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.3s;
}
.input-group input:focus {
border-color: var(--primary-blue-border);
}
.input-group input::placeholder {
color: var(--text-dim);
}
.login-btn {
height: 44px;
margin-top: 6px;
background: linear-gradient(135deg, var(--primary-blue), #2a5adf);
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
color: #0a0a0f;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.login-btn:hover:not(:disabled) {
transform: scale(1.02);
box-shadow: 0 8px 24px var(--primary-blue-glow);
}
.login-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-error {
color: #f87171;
font-size: 14px;
text-align: center;
margin-top: 4px;
min-height: 24px;
}
.login-form .hint {
font-size: 12px;
color: var(--text-dim);
text-align: center;
margin-top: -6px;
}
/* ---------- Logo 区域 ---------- */
.login-logo {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.login-logo .logo-text {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
letter-spacing: 2px;
}
.login-logo img {
width: 64px;
height: 64px;
flex-shrink: 0;
}
/* ---------- 登录页补充优化 ---------- */
.login-card .login-form {
margin-bottom: 4px;
}
/* 注册按钮距上方输入框远一点 */
.login-card .login-form .login-btn {
margin-top: 16px;
}
+737
View File
@@ -0,0 +1,737 @@
/* ===== style-3.css - 主界面核心 ===== */
/* ---------- 顶部导航 ---------- */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.top-left {
display: flex;
align-items: center;
gap: 14px;
}
.top-left .nav-logo {
width: 48px;
height: 48px;
flex-shrink: 0;
display: block;
object-fit: contain;
}
.brand-info {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
}
.brand-info .logo-text {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
letter-spacing: 0.5px;
}
.status-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.status-wrapper .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #444;
transition: background 0.3s;
flex-shrink: 0;
}
.status-wrapper .status-dot.active {
background: #63e2b7;
box-shadow: 0 0 12px rgba(99, 226, 183, 0.4);
}
.status-wrapper .status-text {
font-size: 13px;
color: var(--text-muted);
}
.top-right {
display: flex;
align-items: center;
gap: 16px;
}
.user-name {
font-size: 13px;
color: var(--text-secondary);
}
.logout-btn {
padding: 6px 16px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.logout-btn:hover {
background: rgba(255, 70, 70, 0.15);
border-color: rgba(255, 70, 70, 0.3);
color: #f87171;
}
/* ---------- Tab 栏 ---------- */
.tab-bar {
display: flex;
gap: 32px;
padding: 14px 0 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
}
.tab-item {
font-size: 14px;
color: var(--text-muted);
cursor: pointer;
padding: 4px 0;
transition: color 0.3s, border-color 0.3s;
border-bottom: 2px solid transparent;
}
.tab-item:hover {
color: var(--text-secondary);
}
.tab-item.active {
color: var(--primary-blue);
border-bottom-color: var(--primary-blue);
}
/* ---------- 内容区 ---------- */
.content-area {
flex: 1;
overflow-y: auto;
padding-top: 16px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.06) transparent;
}
.content-area::-webkit-scrollbar {
width: 4px;
}
.content-area::-webkit-scrollbar-track {
background: transparent;
}
.content-area::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
}
.tab-content {
height: 100%;
}
/* ---------- 账户管理区域 ---------- */
.profile-section {
background: rgba(255, 255, 255, 0.02);
border-radius: 16px;
padding: 16px 20px;
border: 1px solid var(--border-subtle);
margin-bottom: 20px;
}
.profile-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 12px;
color: var(--text-secondary);
font-size: 14px;
}
.profile-username {
color: var(--primary-blue);
font-weight: 500;
}
.profile-form {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px 16px;
align-items: end;
}
.profile-form .form-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.profile-form .form-row label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
.profile-form .form-row input {
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.3s;
}
.profile-form .form-row input:focus {
border-color: var(--primary-blue-border);
}
.login-success {
color: #63e2b7;
font-size: 14px;
text-align: center;
margin-top: 4px;
min-height: 24px;
}
/* ---------- 统一保存按钮 ---------- */
.save-btn,
.save-config-btn {
padding: 10px 24px;
background: linear-gradient(135deg, var(--primary-blue), #2a5adf);
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
color: #0a0a0f;
cursor: pointer;
transition: filter 0.3s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
/* 确保按钮本身不裁切阴影 */
isolation: isolate;
}
.save-btn:hover,
.save-config-btn:hover {
filter: brightness(1.25);
transform: none;
}
.save-btn:active,
.save-config-btn:active {
filter: brightness(0.9);
transform: scale(0.97);
}
/* 账户管理中的修改密码按钮(覆盖尺寸) */
.profile-form .save-btn {
grid-column: auto;
padding: 8px 20px;
margin-top: 0;
height: 40px;
font-size: 13px;
border-radius: 8px;
}
/* 保存配置按钮全宽(在全局配置页使用) */
.save-config-btn {
width: 100%;
margin-top: 8px;
padding: 12px;
font-size: 16px;
border-radius: 12px;
}
/* ---------- 隧道列表 ---------- */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.toolbar-left {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.toolbar-left button {
padding: 8px 18px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.toolbar-left button:hover {
background: rgba(255, 255, 255, 0.06);
}
.btn-import:hover {
border-color: var(--primary-blue-border);
color: var(--primary-blue);
}
.btn-export:hover {
border-color: rgba(99, 150, 255, 0.3);
color: #6a9fff;
}
.btn-add {
background: var(--primary-blue-dim) !important;
border-color: var(--primary-blue-border) !important;
color: var(--primary-blue) !important;
}
.btn-add:hover {
background: rgba(74, 124, 255, 0.2) !important;
}
.toolbar-right .search-input {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-subtle);
border-radius: 10px;
color: #fff;
font-size: 13px;
outline: none;
width: 200px;
transition: border-color 0.3s;
}
.toolbar-right .search-input:focus {
border-color: var(--primary-blue-border);
}
.toolbar-right .search-input::placeholder {
color: var(--text-dim);
}
/* ---------- 隧道卡片 ---------- */
.proxy-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.proxy-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-subtle);
border-radius: 14px;
padding: 12px 16px;
transition: border-color 0.3s;
}
.proxy-card:hover {
border-color: rgba(255, 255, 255, 0.08);
}
.proxy-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.proxy-left {
display: flex;
align-items: center;
gap: 12px;
}
.proxy-name {
font-size: 15px;
font-weight: 500;
color: #fff;
}
.proxy-addr {
font-size: 13px;
color: var(--text-dim);
font-family: 'JetBrains Mono', monospace;
}
.proxy-tag {
font-size: 10px;
padding: 2px 10px;
background: rgba(255, 255, 255, 0.04);
border-radius: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.proxy-right {
display: flex;
align-items: center;
gap: 12px;
}
.remote-port {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--text-secondary);
}
.icon-btn {
background: none;
border: none;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
padding: 4px 6px;
transition: color 0.2s;
border-radius: 6px;
}
.icon-btn:hover {
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.04);
}
.icon-btn.danger:hover {
color: #f87171;
background: rgba(248, 113, 113, 0.1);
}
.proxy-footer {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-subtle);
display: flex;
align-items: center;
gap: 10px;
}
.status-label {
font-size: 12px;
color: var(--text-dim);
}
.empty-state {
padding: 48px 0;
text-align: center;
color: var(--text-dim);
font-size: 14px;
}
/* ---------- 开关 ---------- */
.switch {
position: relative;
width: 44px;
height: 24px;
display: inline-block;
cursor: pointer;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.switch .slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.1);
border-radius: 24px;
transition: background 0.3s;
}
.switch .slider::before {
content: '';
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: #1a1a24;
border-radius: 50%;
transition: transform 0.3s;
}
.switch input:checked + .slider {
background: var(--primary-blue);
}
.switch input:checked + .slider::before {
transform: translateX(20px);
background: #0a0a0f;
}
.switch.small {
width: 32px;
height: 18px;
}
.switch.small .slider::before {
height: 12px;
width: 12px;
left: 3px;
bottom: 3px;
}
.switch.small input:checked + .slider::before {
transform: translateX(14px);
}
/* ---------- 状态点(只读展示) ---------- */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #333;
transition: background 0.3s;
}
.status-dot.active {
background: #63e2b7;
box-shadow: 0 0 10px rgba(99, 226, 183, 0.3);
}
/* ---------- 弹窗 ---------- */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
}
.dialog-card {
background: #1a1a24;
border: 1px solid var(--border-subtle);
border-radius: 20px;
padding: 28px 32px;
width: 480px;
max-width: 90vw;
box-shadow: 0 32px 64px rgba(0, 0, 0, 0.6);
}
.dialog-card h3 {
font-size: 18px;
font-weight: 500;
color: #fff;
margin-bottom: 20px;
}
.dialog-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.dialog-form .form-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.dialog-form .form-row label {
font-size: 12px;
color: var(--text-dim);
}
.dialog-form .form-row input,
.dialog-form .form-row select {
padding: 8px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-subtle);
border-radius: 10px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.3s;
}
.dialog-form .form-row input:focus,
.dialog-form .form-row select:focus {
border-color: var(--primary-blue-border);
}
.dialog-actions {
display: flex;
gap: 12px;
margin-top: 20px;
justify-content: flex-end;
}
.dialog-actions button {
padding: 8px 24px;
border-radius: 10px;
border: none;
font-size: 14px;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.btn-cancel {
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
}
.btn-confirm {
background: linear-gradient(135deg, var(--primary-blue), #2a5adf);
color: #0a0a0f;
font-weight: 600;
}
.btn-confirm:hover {
transform: scale(1.02);
box-shadow: 0 4px 16px var(--primary-blue-glow);
}
/* ---------- 自定义 Select(墨蓝风格) ---------- */
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background: rgba(13, 13, 26, 0.85) !important;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid var(--primary-blue-border) !important;
border-radius: 10px !important;
color: #e0e0e0 !important;
padding: 10px 14px !important;
font-size: 14px;
font-family: inherit;
cursor: pointer;
outline: none;
transition: border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
width: 100%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%234a7cff' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
padding-right: 40px !important;
}
select:hover {
border-color: rgba(74, 124, 255, 0.4) !important;
background: rgba(20, 20, 40, 0.9) !important;
}
select:focus {
border-color: var(--primary-blue) !important;
box-shadow: 0 0 0 3px rgba(74, 124, 255, 0.15), inset 0 0 0 1px rgba(74, 124, 255, 0.05);
}
select option {
background: #0d0d1a !important;
color: #e0e0e0 !important;
padding: 8px 14px !important;
border: none !important;
}
select option:hover,
select option:checked,
select option:focus {
background: rgba(74, 124, 255, 0.2) !important;
color: #ffffff !important;
}
select:-moz-focusring {
color: transparent;
text-shadow: 0 0 0 #e0e0e0;
}
select:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
/* ---------- 弹窗入场/出场动画 ---------- */
.dialog-enter-active,
.dialog-leave-active {
transition: all 0.3s ease;
}
.dialog-enter-from,
.dialog-leave-to {
opacity: 0;
}
.dialog-enter-from .dialog-card,
.dialog-leave-to .dialog-card {
transform: scale(0.85) translateY(-10px);
opacity: 0;
}
.dialog-enter-to .dialog-card,
.dialog-leave-from .dialog-card {
transform: scale(1) translateY(0);
opacity: 1;
}
/* ---------- 响应式调整 ---------- */
@media (max-width: 768px) {
.profile-form {
grid-template-columns: 1fr;
}
.proxy-list {
grid-template-columns: 1fr 1fr;
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-right .search-input {
width: 100%;
}
}
@media (max-width: 480px) {
.proxy-list {
grid-template-columns: 1fr;
}
.top-left .nav-logo {
width: 36px;
height: 36px;
}
.brand-info .logo-text {
font-size: 18px;
}
.dialog-card {
padding: 20px;
}
}
+327
View File
@@ -0,0 +1,327 @@
/* ===== style-4.css - v1.5 新增样式 ===== */
/* ---------- 配置页面 ---------- */
.config-tab-content {
padding-bottom: 20px;
}
.config-page-header {
margin-bottom: 12px;
}
.config-page-header h2 {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.config-subtitle {
font-size: 13px;
color: var(--text-dim);
}
/* 首次使用提示 */
.config-tip {
background: rgba(255, 200, 50, 0.08);
border: 1px solid rgba(255, 200, 50, 0.15);
border-radius: 10px;
padding: 10px 16px;
font-size: 13px;
color: #f0c040;
margin-bottom: 16px;
}
/* ---------- 配置卡片 ---------- */
.config-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-subtle);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 14px;
}
.config-card-header {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 14px;
}
.card-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.config-card-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
}
.config-card-body .form-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.config-card-body .form-row label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
.config-card-body .form-row input,
.config-card-body .form-row select {
padding: 8px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.3s;
width: 100%;
}
.config-card-body .form-row input:focus,
.config-card-body .form-row select:focus {
border-color: var(--primary-blue-border);
}
/* 数字输入框填满列宽 */
.config-card-body .form-row input[type="number"] {
width: 100%;
}
/* 只读行(TCP Mux */
.readonly-value {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
}
.readonly-value .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #63e2b7;
box-shadow: 0 0 10px rgba(99, 226, 183, 0.3);
flex-shrink: 0;
}
.readonly-value .status-text.active {
color: #63e2b7;
font-size: 13px;
}
.readonly-value .hint-text {
font-size: 12px;
color: var(--text-dim);
margin-left: 8px;
}
/* Token 输入框 + 显示按钮 */
.token-input-wrapper {
display: flex;
gap: 8px;
}
.token-input-wrapper input {
flex: 1;
}
.token-toggle-btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.token-toggle-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
/* ---------- v2 协议开关(灰标禁用) ---------- */
.v2-switch-row {
grid-column: 1 / -1 !important;
padding-top: 4px;
border-top: 1px solid var(--border-subtle);
margin-top: 4px;
}
.v2-switch-wrapper {
display: flex;
align-items: center;
gap: 14px;
padding: 6px 0;
}
/* "即将推出" 徽章 */
.v2-badge {
font-size: 11px;
padding: 2px 12px;
background: rgba(255, 200, 50, 0.12);
border: 1px solid rgba(255, 200, 50, 0.15);
border-radius: 12px;
color: #f0c040;
letter-spacing: 0.5px;
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);
line-height: 1.5;
padding: 2px 0 4px;
display: block;
}
/* ---------- 日志面板 ---------- */
.log-tab-content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 400px;
}
.log-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0 12px;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
}
.log-toolbar-left {
display: flex;
align-items: center;
gap: 14px;
}
.log-toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.log-info-badge {
font-size: 13px;
color: var(--text-secondary);
}
.log-error-badge {
font-size: 13px;
color: #f87171;
}
.log-auto-scroll-label {
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.log-auto-scroll-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary-blue);
cursor: pointer;
}
.log-btn {
padding: 6px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.log-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
/* 终端样式(类似命令行) */
.log-terminal {
flex: 1;
background: rgba(0, 0, 0, 0.6);
border-radius: 12px;
border: 1px solid var(--border-subtle);
padding: 12px 16px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 13px;
line-height: 1.7;
min-height: 300px;
max-height: 500px;
}
.log-terminal::-webkit-scrollbar {
width: 6px;
}
.log-terminal::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
}
.log-terminal::-webkit-scrollbar-thumb {
background: rgba(74, 124, 255, 0.25);
border-radius: 4px;
}
.log-terminal::-webkit-scrollbar-thumb:hover {
background: rgba(74, 124, 255, 0.4);
}
.log-lines {
display: flex;
flex-direction: column;
}
.log-line {
color: #c0c0c0;
white-space: pre-wrap;
word-break: break-all;
padding: 1px 0;
}
.log-empty {
color: var(--text-dim);
text-align: center;
padding: 40px 0;
}
/* 日志底部状态 */
.log-footer {
display: flex;
justify-content: space-between;
padding: 10px 4px 0;
font-size: 12px;
color: var(--text-dim);
flex-shrink: 0;
}
.log-polling-status {
color: var(--primary-blue);
}
/* ---------- 响应式调整 ---------- */
@media (max-width: 768px) {
.config-card-body {
grid-template-columns: 1fr;
}
.log-toolbar {
flex-direction: column;
align-items: stretch;
}
.log-toolbar-right {
flex-wrap: wrap;
}
.token-input-wrapper {
flex-wrap: wrap;
}
.v2-switch-wrapper {
flex-wrap: wrap;
}
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"fmt"
"strconv"
"strings"
)
// FrpsToml 对应 frps.toml 结构
type FrpsToml struct {
BindPort int `json:"bindPort"`
Auth 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"`
}
func ParseToml(content string) (*FrpsToml, error) {
lines := strings.Split(content, "\n")
result := &FrpsToml{
Transport: struct {
TcpMux bool `json:"tcpMux"`
WireProtocolV2 bool `json:"wireProtocolV2"`
}{
TcpMux: true,
WireProtocolV2: false,
},
}
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") {
continue
}
if !strings.Contains(line, "=") {
continue
}
parts := strings.SplitN(line, "=", 2)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
value = strings.Trim(value, `"`)
switch key {
case "bindPort":
result.BindPort, _ = strconv.Atoi(value)
case "token":
result.Auth.Token = value
case "level":
result.Log.Level = value
case "maxDays":
result.Log.MaxDays, _ = strconv.Atoi(value)
case "tcpMux":
result.Transport.TcpMux = value == "true"
case "wireProtocol":
result.Transport.WireProtocolV2 = value == "v2"
}
}
if result.BindPort == 0 {
return nil, fmt.Errorf("未找到 bindPort 字段")
}
return result, nil
}
func (f *FrpsToml) ToGlobalConfig() *GlobalConfig {
return &GlobalConfig{
BindPort: f.BindPort,
Token: f.Auth.Token,
LogLevel: f.Log.Level,
LogMaxDays: f.Log.MaxDays,
TcpMux: f.Transport.TcpMux,
}
}