diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd352f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# ============================================================ +# frpc-console .gitignore +# ============================================================ + +# ---------- 构建产物 ---------- +/build/ +frpc-console +frpc-console.exe +*.exe +*.test + +# ---------- 运行时生成 ---------- +*.db +*.db-journal +frpc.log +frpc.pid +frpc.toml + +# ---------- IDE ---------- +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# ---------- OS 垃圾 ---------- +.DS_Store +Thumbs.db +desktop.ini + +# ---------- 临时文件 ---------- +/tmp/ +*.tmp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e69de29..68de892 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,47 @@ +# ============================================================ +# 构建阶段 +# ============================================================ +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# 复制依赖文件 +COPY go.mod go.sum ./ +RUN go mod download + +# 复制源码(确保 bin/ 和 static/ 目录存在) +COPY . . + +# 编译 Linux 版(CGO_ENABLED=0 保证静态编译) +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" \ + -o frpc-console . + +# ============================================================ +# 运行阶段 +# ============================================================ +FROM alpine:latest + +# 安装 ca-certificates 和 tzdata(确保 HTTPS 和时区正常) +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +# 从构建阶段复制编译好的二进制 +COPY --from=builder /app/frpc-console /app/frpc-console + +# 创建数据目录(用于持久化 db 和日志) +RUN mkdir -p /app/data + +# 暴露端口(默认 9300) +EXPOSE 9300 + +# 环境变量 +ENV PORT=9300 +ENV TZ=Asia/Shanghai + +# 数据卷(持久化数据库、配置、日志) +VOLUME ["/app/data"] + +# 启动 +ENTRYPOINT ["/app/frpc-console"] \ No newline at end of file diff --git a/api.go b/api.go index 9f07837..8acaf59 100644 --- a/api.go +++ b/api.go @@ -20,11 +20,10 @@ func SetupRouter() *gin.Engine { r := gin.Default() // 从 embed 读取前端静态文件 - // 注意:embed.FS 不支持直接作为 http.FileSystem 传参,需要用 http.FS 转换 staticSubFS, _ := fs.Sub(staticFS, "static") r.StaticFS("/static", http.FS(staticSubFS)) - // 根路由:直接返回 index.html + // 根路由 r.GET("/", func(c *gin.Context) { content, err := staticFS.ReadFile("static/index.html") if err != nil { @@ -39,40 +38,32 @@ func SetupRouter() *gin.Engine { c.String(200, "frpc-console 后端已启动 🎉") }) - // API 路由 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("/proxies", getProxiesHandler) auth.GET("/proxy/:id", getProxyHandler) auth.POST("/proxy", createProxyHandler) auth.PUT("/proxy/:id", updateProxyHandler) auth.DELETE("/proxy/:id", deleteProxyHandler) - // frpc 控制 auth.POST("/frpc/reload", reloadFrpcHandler) auth.POST("/frpc/start", startFrpcHandler) auth.POST("/frpc/stop", stopFrpcHandler) auth.GET("/frpc/status", getFrpcStatusHandler) - // 导入/导出 TOML auth.POST("/import/toml", importTomlHandler) auth.GET("/export/toml", ExportTomlHandler) - // 修改密码 auth.PUT("/user/password", changePasswordHandler) } } @@ -80,7 +71,8 @@ func SetupRouter() *gin.Engine { return r } -// ========== 检查是否有用户 ========== +// ========== 所有 Handler ========== + func checkUsersHandler(c *gin.Context) { count, err := CountUsers() if err != nil { @@ -89,14 +81,10 @@ func checkUsersHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ "code": 0, - "data": gin.H{ - "hasUsers": count > 0, - "count": count, - }, + "data": gin.H{"hasUsers": count > 0, "count": count}, }) } -// ========== 注册(仅首次启动可用) ========== func registerHandler(c *gin.Context) { var req struct { Username string `json:"username"` @@ -123,10 +111,7 @@ func registerHandler(c *gin.Context) { } if !ValidatePassword(req.Password) { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 1, - "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符", - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"}) return } @@ -154,7 +139,6 @@ func registerHandler(c *gin.Context) { }) } -// ========== 登录 ========== func loginHandler(c *gin.Context) { var req struct { Username string `json:"username"` @@ -189,7 +173,6 @@ func loginHandler(c *gin.Context) { }) } -// ========== 修改密码 ========== func changePasswordHandler(c *gin.Context) { var req struct { OldPassword string `json:"oldPassword"` @@ -218,10 +201,7 @@ func changePasswordHandler(c *gin.Context) { } if !ValidatePassword(req.NewPassword) { - c.JSON(http.StatusBadRequest, gin.H{ - "code": 1, - "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符", - }) + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "密码至少 8 位,需包含大小写字母、数字和特殊字符"}) return } @@ -239,7 +219,6 @@ func changePasswordHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "密码修改成功"}) } -// ========== 全局配置 ========== func getConfigHandler(c *gin.Context) { cfg, err := GetGlobalConfig() if err != nil { @@ -275,7 +254,6 @@ func updateConfigHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "配置更新成功"}) } -// ========== 隧道管理 ========== func getProxiesHandler(c *gin.Context) { proxies, err := GetProxies() if err != nil { @@ -366,7 +344,6 @@ func deleteProxyHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "隧道删除成功"}) } -// ========== frpc 控制 ========== func reloadFrpcHandler(c *gin.Context) { if err := GenerateFrpcConfig(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) @@ -404,7 +381,6 @@ func getFrpcStatusHandler(c *gin.Context) { 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 { @@ -455,6 +431,7 @@ func importTomlHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 2, "msg": "生成配置失败: " + err.Error()}) return } + if err := ReloadFrpc(); err != nil { c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -469,7 +446,6 @@ func importTomlHandler(c *gin.Context) { }) } -// ========== 导出 TOML ========== func ExportTomlHandler(c *gin.Context) { cfg, err := GetGlobalConfig() if err != nil { @@ -515,7 +491,6 @@ func ExportTomlHandler(c *gin.Context) { c.String(http.StatusOK, buf.String()) } -// ========== 辅助 ========== func generateAndReload() error { if err := GenerateFrpcConfig(); err != nil { return err diff --git a/build.exe b/build.exe index e3ad8f1..de4e3ad 100644 Binary files a/build.exe and b/build.exe differ diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..1070a85 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,330 @@ +#!/bin/bash + +# ============================================================ +# frpc-console 一键部署脚本 +# 支持:Linux x86_64 / ARM64 / ARMv7 +# 自动安装:git / curl / wget / Go / Docker +# ============================================================ + +set -e + +# ---------- 颜色输出 ---------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +# ---------- 配置 ---------- +REPO_URL="https://git.whitetop.xyz/lxh2875931338/frpc-console.git" +BRANCH="main" +WORK_DIR="/tmp/frpc-console-build" +DEPLOY_DIR="/opt/frpc-console" +DATA_DIR="${DEPLOY_DIR}/data" +DEFAULT_PORT=9300 +IMAGE_NAME="frpc-console" +GO_VERSION="1.21.5" + +# ---------- 打印函数 ---------- +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"; } + +# ---------- 检查 root ---------- +if [ "$EUID" -ne 0 ]; then + print_error "请使用 root 权限运行此脚本" + echo " sudo bash deploy.sh" + exit 1 +fi + +# ---------- 1. 检测操作系统 ---------- +print_step "检测操作系统..." +if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID + OS_VERSION=$VERSION_ID +else + print_error "无法识别操作系统" + exit 1 +fi +print_info "系统: $OS $OS_VERSION" + +# ---------- 2. 检测 CPU 架构 ---------- +print_step "检测 CPU 架构..." +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 +print_success "CPU 架构: $ARCH → Go 架构: $GO_ARCH" + +# ---------- 3. 安装必要工具 ---------- +print_step "检查必要工具..." + +# git +if ! command -v git &> /dev/null; then + print_warn "git 未安装,正在安装..." + case $OS in + opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll) + zypper install -y git + ;; + ubuntu|debian|linuxmint) + apt update -qq && apt install -y git + ;; + centos|rhel|fedora|rocky|almalinux) + yum install -y git + ;; + alpine) + apk add git + ;; + *) + print_error "无法识别包管理器,请手动安装 git" + exit 1 + ;; + esac + print_success "git 安装完成" +else + print_success "git 已安装: $(git --version | awk '{print $3}')" +fi + +# curl +if ! command -v curl &> /dev/null; then + print_warn "curl 未安装,正在安装..." + case $OS in + opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll) zypper install -y curl ;; + ubuntu|debian|linuxmint) apt install -y curl ;; + centos|rhel|fedora|rocky|almalinux) yum install -y curl ;; + alpine) apk add curl ;; + *) print_error "无法安装 curl"; exit 1 ;; + esac + print_success "curl 安装完成" +else + print_success "curl 已安装" +fi + +# wget(备用) +if ! command -v wget &> /dev/null; then + print_warn "wget 未安装,正在安装..." + case $OS in + opensuse*|suse*|opensuse-tumbleweed|opensuse-slowroll) zypper install -y wget ;; + ubuntu|debian|linuxmint) apt install -y wget ;; + centos|rhel|fedora|rocky|almalinux) yum install -y wget ;; + alpine) apk add wget ;; + *) print_warn "wget 未安装,但不影响主要功能" ;; + esac +else + print_success "wget 已安装" +fi + +# ---------- 4. 安装 Go ---------- +print_step "检查 Go 环境..." +if ! command -v go &> /dev/null; then + print_warn "Go 未安装,正在下载 Go ${GO_VERSION} (${GO_ARCH})..." + + GO_TMP="/tmp/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" + + if [ -f "$GO_TMP" ]; then + print_info "使用缓存: $GO_TMP" + else + print_info "正在下载..." + 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 + + # 安装 Go + 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 + + # 配置 Go 代理 + /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} (${GO_ARCH}) 安装完成" +else + print_success "Go 已安装: $(go version | awk '{print $3}') (架构: $(go env GOARCH))" + go env -w GOPROXY=https://goproxy.cn,direct 2>/dev/null || true + go env -w GOPRIVATE=git.whitetop.xyz 2>/dev/null || true +fi + +# ---------- 5. 检查 Docker ---------- +print_step "检查 Docker 环境..." +if ! command -v docker &> /dev/null; then + print_error "Docker 未安装,请先安装 Docker" + echo "" + echo " 快速安装:" + echo " curl -fsSL https://get.docker.com | bash" + echo " systemctl enable --now docker" + echo "" + exit 1 +else + print_success "Docker 已安装: $(docker --version | awk '{print $3}' | tr -d ',')" +fi + +# ---------- 6. 交互式配置 ---------- +print_step "部署配置" +read -p "监听端口 [${DEFAULT_PORT}]: " PORT +PORT=${PORT:-$DEFAULT_PORT} + +read -p "部署目录 [${DEPLOY_DIR}]: " DEPLOY_DIR_INPUT +DEPLOY_DIR=${DEPLOY_DIR_INPUT:-$DEPLOY_DIR} +DATA_DIR="${DEPLOY_DIR}/data" + +print_info "端口: $PORT" +print_info "部署目录: $DEPLOY_DIR" +print_info "数据目录: $DATA_DIR" + +# ---------- 7. 拉取代码 ---------- +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 "代码拉取完成" + +# ---------- 8. 修复 go.mod 版本(防止 toolchain 下载) ---------- +print_step "检查 go.mod..." +if grep -q "go 1.26" go.mod 2>/dev/null; then + print_warn "检测到 go.mod 版本过高,自动降级到 go 1.21" + sed -i 's/go 1.26.*/go 1.21/' go.mod +fi + +# ---------- 9. 下载依赖 ---------- +print_step "下载 Go 依赖..." +go mod download +print_success "依赖下载完成" + +# ---------- 10. 编译 ---------- +print_step "编译 frpc-console..." +CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" \ + -o frpc-console . + +if [ -f "frpc-console" ]; then + SIZE=$(du -h frpc-console | cut -f1) + print_success "编译完成 ($SIZE)" +else + print_error "编译失败" + exit 1 +fi + +# ---------- 11. 准备部署目录 ---------- +print_step "准备部署目录..." +mkdir -p "$DEPLOY_DIR" +mkdir -p "$DATA_DIR" + +# 如果已有数据,保留;否则创建空占位 +if [ ! -f "$DATA_DIR/frpc-console.db" ]; then + touch "$DATA_DIR/frpc-console.db" + print_info "新数据目录已创建" +else + print_info "已有数据目录,保留现有数据" +fi + +cp frpc-console "$DEPLOY_DIR/" +print_success "二进制已复制到 $DEPLOY_DIR" + +# ---------- 12. 构建 Docker 镜像 ---------- +print_step "构建 Docker 镜像..." +docker build -t "${IMAGE_NAME}:latest" . +print_success "Docker 镜像构建完成: ${IMAGE_NAME}:latest" + +# ---------- 13. 停止旧容器 ---------- +print_step "检查旧容器..." +if docker ps -a --format '{{.Names}}' | grep -q "^frpc-console$"; then + print_warn "发现旧容器,正在停止并删除..." + docker stop frpc-console 2>/dev/null || true + docker rm frpc-console 2>/dev/null || true + print_success "旧容器已清理" +fi + +# ---------- 14. 启动新容器 ---------- +print_step "启动 frpc-console 容器..." +docker run -d \ + --name frpc-console \ + --restart=always \ + -p ${PORT}:9300 \ + -v ${DATA_DIR}:/app/data \ + -e PORT=9300 \ + -e TZ=Asia/Shanghai \ + ${IMAGE_NAME}:latest + +if docker ps | grep -q frpc-console; then + print_success "容器启动成功!" +else + print_error "容器启动失败,请检查日志: docker logs frpc-console" + exit 1 +fi + +# ---------- 15. 检查 frpc 子进程 ---------- +print_step "检查 frpc 状态..." +sleep 3 +if docker exec frpc-console ps aux 2>/dev/null | grep -q "[f]rpc -c"; then + print_success "frpc 进程运行正常" +else + print_warn "frpc 进程未运行(可能配置为空,请在 WebUI 中导入 TOML)" +fi + +# ---------- 16. 清理临时文件 ---------- +print_step "清理临时文件..." +rm -rf "$WORK_DIR" +print_success "临时文件已清理" + +# ---------- 17. 输出信息 ---------- +echo "" +echo "==========================================" +echo -e "${GREEN}✅ frpc-console 部署完成!${NC}" +echo "==========================================" +echo "" +echo "📍 访问地址: http://$(hostname -I | awk '{print $1}'):${PORT}" +echo "📂 数据目录: ${DATA_DIR}" +echo "🐳 容器名称: frpc-console" +echo "" +echo "常用命令:" +echo " docker logs frpc-console # 查看日志" +echo " docker restart frpc-console # 重启服务" +echo " docker stop frpc-console # 停止服务" +echo " docker start frpc-console # 启动服务" +echo "" +echo "首次访问需要注册管理员账户" +echo "==========================================" \ No newline at end of file diff --git a/frp-unix.go b/frp-unix.go new file mode 100644 index 0000000..ebbe825 --- /dev/null +++ b/frp-unix.go @@ -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 +} diff --git a/frp.go b/frp.go index b7a6e6d..97ddcca 100644 --- a/frp.go +++ b/frp.go @@ -4,17 +4,16 @@ import ( "bytes" "embed" "fmt" - "io" "log" "os" "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" + "syscall" "text/template" - - "github.com/creack/pty" ) //go:embed bin/* @@ -36,7 +35,6 @@ func getFrpcPath() (string, error) { frpcPathMutex.Lock() defer frpcPathMutex.Unlock() - // 如果缓存有效,直接返回 if cachedFrpcPath != "" { if _, err := os.Stat(cachedFrpcPath); err == nil { return cachedFrpcPath, nil @@ -44,7 +42,6 @@ func getFrpcPath() (string, error) { cachedFrpcPath = "" } - // 确定当前平台的文件名 var fileName string switch { case runtime.GOOS == "windows" && runtime.GOARCH == "amd64": @@ -58,7 +55,6 @@ func getFrpcPath() (string, error) { case runtime.GOOS == "linux" && runtime.GOARCH == "arm": fileName = "frpc_linux_armv7" default: - // 不支持的平台,尝试从 PATH 找 path, err := exec.LookPath("frpc") if err == nil { cachedFrpcPath = path @@ -67,14 +63,14 @@ func getFrpcPath() (string, error) { return "", fmt.Errorf("不支持的平台: %s/%s", runtime.GOOS, runtime.GOARCH) } - // ===== 优先级 1:从当前目录 ./bin/ 查找 ===== + // 优先级1:本地 ./bin/ localPath := filepath.Join(".", "bin", fileName) if _, err := os.Stat(localPath); err == nil { cachedFrpcPath = localPath return localPath, nil } - // ===== 优先级 2:从 embed 读取并解压到临时目录 ===== + // 优先级2:embed 解压到临时目录 data, err := embeddedFrpc.ReadFile("bin/" + fileName) if err == nil { tmpPath := filepath.Join(os.TempDir(), "frpc") @@ -85,14 +81,13 @@ func getFrpcPath() (string, error) { cachedFrpcPath = tmpPath return tmpPath, nil } - // 写入失败时,检查是否已有可用的临时文件 if _, statErr := os.Stat(tmpPath); statErr == nil { cachedFrpcPath = tmpPath return tmpPath, nil } } - // ===== 优先级 3:降级到系统 PATH ===== + // 优先级3:系统 PATH path, err := exec.LookPath("frpc") if err == nil { cachedFrpcPath = path @@ -102,7 +97,7 @@ func getFrpcPath() (string, error) { return "", fmt.Errorf("未找到 frpc 文件 (本地 bin/、embed、PATH 均无)") } -// GenerateFrpcConfig 生成 frpc.toml 配置文件 +// GenerateFrpcConfig 生成 frpc.toml func GenerateFrpcConfig() error { cfg, err := GetGlobalConfig() if err != nil { @@ -129,7 +124,6 @@ func GenerateFrpcConfig() error { Proxies: activeProxies, } - // 优先使用外部模板文件,不存在则用 embed var tmplContent string if _, err := os.Stat("frpc.tmpl"); err == nil { content, readErr := os.ReadFile("frpc.tmpl") @@ -159,8 +153,36 @@ func GenerateFrpcConfig() error { return nil } -// StartFrpc 启动 frpc +// isFrpcRunning 通过 PID 文件检查 frpc 是否在运行 +func isFrpcRunning() bool { + pidData, err := os.ReadFile("./frpc.pid") + if err != nil { + return false + } + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + return false + } + if runtime.GOOS == "windows" { + // Windows 用 tasklist 检查 + 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)) + } + + // Linux/Unix: 用 kill 0 检查进程是否存在 + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} + +// StartFrpc 启动 frpc(独立进程,脱离父进程) func StartFrpc() error { frpcPath, err := getFrpcPath() if err != nil { @@ -173,37 +195,84 @@ func StartFrpc() error { } } - running, _ := GetFrpcStatus() - if running { + if isFrpcRunning() { return nil } + // 清理旧的 PID 文件 + os.Remove("./frpc.pid") + cmd := exec.Command(frpcPath, "-c", "./frpc.toml") setWindowHide(cmd) - // ✅ Linux 下分配伪终端,让 frpc 认为自己在真实终端中运行 - if runtime.GOOS != "windows" { - f, err := pty.Start(cmd) - if err != nil { - return fmt.Errorf("启动伪终端失败: %w", err) - } - // 可选:丢弃 pty 输出,或写入日志文件 - go io.Copy(io.Discard, f) - } else { - if err := cmd.Start(); err != nil { - return fmt.Errorf("启动 frpc 失败: %w", err) - } + // ✅ 设置进程属性(Unix: Setsid 脱离父进程,Windows: 不处理) + setSysProcAttr(cmd) + + // 把输出重定向到日志文件 + logFile, err := os.OpenFile("./frpc.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("打开日志文件失败: %w", err) + } + cmd.Stdout = logFile + cmd.Stderr = logFile + + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 frpc 失败: %w", err) + } + + // ✅ 保存 PID 到文件 + if err := os.WriteFile("./frpc.pid", []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil { + return fmt.Errorf("保存 PID 失败: %w", err) } return nil } +// StopFrpc 停止 frpc(通过 PID 文件精准杀进程) +func StopFrpc() error { + if runtime.GOOS == "windows" { + cmd := exec.Command("taskkill", "/F", "/IM", "frpc.exe") + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") { + return fmt.Errorf("停止 frpc 失败: %w", err) + } + os.Remove("./frpc.pid") + return nil + } + + // Linux: 读取 PID 文件精准杀进程 + pidData, err := os.ReadFile("./frpc.pid") + if err != nil { + // 降级到 pkill + cmd := exec.Command("pkill", "-f", "frpc") + if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") { + return fmt.Errorf("停止 frpc 失败: %w", err) + } + return nil + } + + pid, _ := strconv.Atoi(strings.TrimSpace(string(pidData))) + process, err := os.FindProcess(pid) + if err != nil { + os.Remove("./frpc.pid") + return nil + } + + if err := process.Kill(); err != nil { + return fmt.Errorf("杀死进程失败: %w", err) + } + + os.Remove("./frpc.pid") + return nil +} + +// GetFrpcStatus 获取 frpc 运行状态(通过 PID 文件) +func GetFrpcStatus() (bool, error) { + return isFrpcRunning(), nil +} + // ReloadFrpc 热加载,失败时自动降级为重启 func ReloadFrpc() error { - running, err := GetFrpcStatus() - if err != nil { - return fmt.Errorf("检查 frpc 状态失败: %w", err) - } + running := isFrpcRunning() if !running { return StartFrpc() } @@ -227,37 +296,3 @@ func ReloadFrpc() error { } return nil } - -// StopFrpc 停止 frpc -func StopFrpc() error { - if runtime.GOOS == "windows" { - cmd := exec.Command("taskkill", "/F", "/IM", "frpc.exe") - if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "not found") { - return fmt.Errorf("停止 frpc 失败: %w", err) - } - return nil - } - cmd := exec.Command("pkill", "-f", "frpc") - if err := cmd.Run(); err != nil && !strings.Contains(err.Error(), "no process") { - return fmt.Errorf("停止 frpc 失败: %w", err) - } - return nil -} - -// GetFrpcStatus 检查 frpc 是否在运行 -func GetFrpcStatus() (bool, error) { - if runtime.GOOS == "windows" { - cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq frpc.exe") - output, err := cmd.CombinedOutput() - if err != nil { - return false, nil - } - return strings.Contains(string(output), "frpc.exe"), nil - } - cmd := exec.Command("pgrep", "-f", "frpc") - output, err := cmd.Output() - if err != nil { - return false, nil - } - return len(output) > 0, nil -} diff --git a/frp_other.go b/frp_other.go index cc68b3b..068ef7f 100644 --- a/frp_other.go +++ b/frp_other.go @@ -1,4 +1,3 @@ -// frp_other.go //go:build !windows package main diff --git a/frp_windows.go b/frp_windows.go index b9baac1..b776362 100644 --- a/frp_windows.go +++ b/frp_windows.go @@ -7,10 +7,15 @@ import ( "syscall" ) -// setWindowHide 在 Windows 上隐藏 frpc 命令行窗口 +// 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,什么都不做 +} diff --git a/go.mod b/go.mod index ba3269b..4161340 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( 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/creack/pty v1.1.24 // 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 diff --git a/go.sum b/go.sum index bfa45f9..935b577 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,6 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD 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/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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= diff --git a/main.go b/main.go index 82c8d71..8e9c927 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "log" + "time" ) func main() { @@ -9,10 +10,7 @@ func main() { log.Fatal("❌ 数据库初始化失败:", err) } - // ✅ Web 注册已取代 CLI 初始化,不再需要终端交互 - // 前端会自动检测是否首次启动并显示注册页 - // InitAdminUser() - + // 启动时生成配置并启动 frpc if err := GenerateFrpcConfig(); err != nil { log.Println("⚠️ 生成配置文件失败:", err) } @@ -21,12 +19,32 @@ func main() { log.Println("⚠️ 启动 frpc 失败:", err) } + // ✅ 启动 Watchdog:每 30 秒检查一次 frpc 状态,挂了自动重启 + go startWatchdog() + r := SetupRouter() log.Println("🚀 frpc-console 启动成功!") log.Println("📍 访问地址: http://localhost:8080") log.Println("📍 API 地址: http://localhost:8080/api") - if err := r.Run(":8080"); err != nil { + if err := r.Run(":9300"); err != nil { log.Fatal("❌ 服务启动失败:", err) } } + +// Watchdog 协程:定期检查 frpc 是否存活 +func startWatchdog() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + if !isFrpcRunning() { + log.Println("⚠️ frpc 进程已停止,自动重启...") + if err := StartFrpc(); err != nil { + log.Printf("❌ 自动重启 frpc 失败: %v", err) + } else { + log.Println("✅ frpc 已自动重启") + } + } + } +} diff --git a/script/build.go b/script/build.go index ff354c7..41de78d 100644 --- a/script/build.go +++ b/script/build.go @@ -1,6 +1,5 @@ //go:build ignore -// build.go package main import ( @@ -15,8 +14,8 @@ import ( type Platform struct { OS string Arch string - Name string // 显示名称 - Ext string // 文件扩展名(Windows 需要 .exe) + Name string + Ext string } var platforms = []Platform{ @@ -34,22 +33,19 @@ const ( ) func main() { - // 解析命令行参数 if len(os.Args) > 1 { target := os.Args[1] - if target == "all" { + switch target { + case "all": buildAll() return - } - if target == "clean" { + case "clean": clean() return - } - if target == "list" { + case "list": listPlatforms() return } - // 尝试匹配平台 for _, p := range platforms { if target == p.OS+"/"+p.Arch { build(p, false) @@ -61,8 +57,6 @@ func main() { fmt.Println(" 或: all, list, clean") return } - - // 交互式菜单 interactiveMenu() } @@ -104,7 +98,6 @@ func interactiveMenu() { 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) @@ -136,8 +129,8 @@ func build(p Platform, silent bool) { outName := Binary + "-" + p.OS + "-" + p.Arch + p.Ext outPath := filepath.Join(outDir, outName) - // 构建命令 - cmd := exec.Command("go.exe", "build", + // 用 "go" 而不是硬编码路径,让系统去 PATH 里找 + cmd := exec.Command("go", "build", "-ldflags=-s -w -X main.version="+Version, "-o", outPath, ".", @@ -145,7 +138,7 @@ func build(p Platform, silent bool) { cmd.Env = append(os.Environ(), "GOOS="+p.OS, "GOARCH="+p.Arch, - "CGO_ENABLED=0", // 禁用 CGO,保证静态编译 + "CGO_ENABLED=0", ) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -157,14 +150,12 @@ func build(p Platform, silent bool) { 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) } @@ -175,14 +166,12 @@ func packageBinary(binPath string, p Platform) { dir := filepath.Dir(binPath) if p.Ext == ".exe" { - // Windows: zip 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 { - // Linux/macOS: tar.gz tarName := filepath.Join(dir, base+".tar.gz") cmd := exec.Command("tar", "-czf", tarName, "-C", dir, filepath.Base(binPath)) if err := cmd.Run(); err == nil {