webui4frpc: 独立可用的可视化 frpc 控制器 (M0)

- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus)
- 画布多对多连线,渲染 tcp/udp/http/https frpc 配置
- worker 进程管理:自愈、日志轮转、崩溃退避重启
- frpc 一键安装 (GitHub Releases) + 手动指定路径
- 三页 UI:状态(默认)/连接配置/设置
- 状态页实时节点/转发状态,节点可增删改启停
- 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存
- backend 单测覆盖 store/render/process/httpapi/install
- plan.md + FRPC_FEATURES_AUDIT.md 文档
This commit is contained in:
2026-08-17 11:00:26 +08:00
commit c1936887a2
40 changed files with 7868 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# 依赖
node_modules/
# 前端构建产物internal/httpapi/dist 供 Go embed需提交以便开箱编译
web/dist/
# Go 二进制
*.exe
*.test
webui-frpc
webui4frpc
# 运行时数据
*.db
data/
# 编辑器 / 系统
.DS_Store
*.log

109
FRPC_FEATURES_AUDIT.md Normal file
View File

@ -0,0 +1,109 @@
# webui4frpc 对 frpc 高级功能支持审查报告
> 审查基准frp v0.71 的 frpc 配置能力(官方文档支持的 proxy 类型、认证、传输、插件等)。
> 审查范围:当前 webui4frpc 的画布数据模型Local/Remote/Link、渲染器internal/render能表达和输出什么。
## 一、总体结论
当前实现聚焦「最小可用」:**TCP/HTTP 单端口转发 + token 认证** 已打通并验证(多个远程节点、多对多连线、真实连通)。但 frpc 的**大部分高级能力尚未建模**,且**已有能力因模型缺字段而不可配置**。适合作为 1.0 基线,但要覆盖更广使用场景需系统性扩展。
## 二、能力支持矩阵
### 2.1 代理类型proxy type
| 类型 | 状态 | 说明 |
|---|---|---|
| tcp | ✅ | 完整支持LocalIP/LocalPort/RemotePort |
| udp | 🟡 部分 | 渲染支持但协处理器KCP/加密等)未建模 |
| http | 🟡 部分 | 支持 customDomains 默认域名,但无法配置 locations/headers/负载均衡 |
| https | 🟡 部分 | 同 http未建模证书/SNI |
| stcp | ❌ | 加密点对点穿透完全不支持 |
| sudp | ❌ | 同 stcp |
| xtcp | ❌ | P2P TCP 模拟(依赖 kcp-go不支持 |
| tcpmux | ❌ | HTTP/2 多路复用multiplexer/http2不支持 |
### 2.2 认证auth
| 能力 | 状态 | 说明 |
|---|---|---|
| 服务端 token | ✅ | Remote 有 Token 字段 |
| OIDC | ❌ | frpc 支持 openid connectcoreos/go-oidc未建模 |
| TLS 客户端证书 | ❌ | 未建模 |
### 2.3 传输transport
| 能力 | 状态 | 说明 |
|---|---|---|
| tcp / kcp / websocket / quic | 🟡 | Remote 无 protocol 字段,无法选择 |
| 加密 / 压缩useEncryption/useCompression | ❌ | 未建模 |
| 带宽限制bandwidthLimit | ❌ | 未建模 |
| 连接池poolCount | ❌ | 未建模 |
| TLS 传输transport.tls.enable | ❌ | 未建模 |
| Connect Server 超时/重连 | ❌ | 未建模(有默认值) |
### 2.4 负载均衡与健康检查
| 能力 | 状态 | 说明 |
|---|---|---|
| 多个 local 提供同一组group/loadBalancer | ❌ | 未建模 group/groupKey |
| health checktcp/http失败剔除 | ❌ | 未建模 HealthCheckConfig |
### 2.5 域名与路由http/https
| 能力 | 状态 | 说明 |
|---|---|---|
| customDomains 多域名 | 🟡 | 可输出,但画布 local 无域名字段(写死 name.local |
| subdomain | 🟡 | render 支持生成,但依赖服务器 subdomain_host演示环境未启用 |
| locations路径路由 | ❌ | 未建模 |
| httpHeader 改写 / hostHeaderRewrite | ❌ | 未建模 |
| basicAuth站点访问认证 | ❌ | 未建模 |
### 2.6 插件plugin与虚拟 IP
| 能力 | 状态 | 说明 |
|---|---|---|
| client pluginhttp2https/static/server 等) | ❌ | 未建模
| vmnet虚拟 IP | ❌ | 未建模 |
### 2.7 其它
| 能力 | 状态 | 说明 |
|---|---|---|
| 元数据 metadatas / annotations | ❌ | Remote 有 URL 但无 metadatas |
| start (enabled 控制) | ✅ | Remote 有 Enabled |
| 全局配置byteLimit 等) | ❌ | 未建模 |
## 三、具体缺口细节
1. **Local/Remote 模型过简**Local 只有 name/ip/port/protocol缺 encryption/compression/bandwidth/plugin/metadatas/域名/路径/健康检查等。Remote 缺 protocol(kcp/quic/ws)、tls、oidc、pool 等。
2. **渲染器只输出 6 个字段**`brpcProxy` 仅 name/type/localIP/localPort/remotePort/customDomains/subdomain。**transport 段只有空 protocol**frpc 其它传输参数全部缺失。
3. **画布节点编辑只暴露 ip/port/协议**:连已有的 token/url 也只在部分入口可编辑,更别说高级字段。
4. **udp 渲染无 UDPPacketSize 等**、**https 无证书流程**。
## 四、用户实际会遇到的典型场景覆盖
| 场景 | 现状 |
|---|---|
| 内网 Web 服务对外http+tcp | ✅ 覆盖 |
| 普通 TCP 服务(数据库/SSH/自建协议) | ✅ 覆盖 |
| 加密内网通信stcp/sudp | ❌ 若用户需要点对点加密穿透则无法完成 |
| 多域名多路径挂多个站点到一台 frps | 🟡 部分(域名选择没做) |
| 限速 / 压缩 / 加密传输 | ❌ |
| 负载均衡 / 健康检查 | ❌ |
| kcp/quic 弱网优化 | ❌ |
## 五、优先级建议
| 优先级 | 项 | 理由 |
|---|---|---|
| P0 | 画布冲突检查(本次已实现) | 防止重复 remotePort / 域名重复导致配置直接失败 |
| P0 | Local 增加「连接参数」高级区encryption / compression / bandwidthLimit / poolCount / metadatas | 高频项,改动小(渲染器 + 表单) |
| P1 | Remote 增加 transport 选择protocol(tcp/quic/kcp/websocket) 、tls.enable、poolCount | 让 kcp/quic/web 场景可用 |
| P1 | local http/https 增加域名字段customDomains/subdomain 可编辑)+ locations | 覆盖多站点部署 |
| P2 | 健康检查 / 负载均衡group | 中频 |
| P2 | 插件 stub / 虚拟 IP | 低频,且 UI 复杂 |
| P3 | OIDC / TLS 证书 / 高级插件 | 低频,可延后 |
## 六、结论
webui4frpc 已具备**生产可用的核心**tcp/http 多节点多对多转发 + 状态页 + worker 管理),且面向「避免运维手写配置」的目标价值明确。但要覆盖 frpc 完整能力需分阶段补齐:**先做 P0冲突检查已做+ P0 高级传输参数建模**,再逐级推进,避免一上来堆字段导致画布复杂化。建议 UI 采用「折叠高级区」或「按类型动态显示字段」,保持画布简洁。

93
README.md Normal file
View File

@ -0,0 +1,93 @@
# webui4frpc
一个**独立于 frp 源码**的可视化 frpc 控制器。用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,免去运维反复手写 frpc 配置的麻烦。
> 本仓库**不捆绑、不包含任何 frp 源码**。frpc 可执行文件由用户一键从官方 GitHub Releases 下载,或手动指定路径。
## 特性
- **Scratch 风格画布**本地转发项local与远程服务器remote可视化连线一个 local 可连多个 remote一个 remote 可连多个 local
- **曲线连线 + 端口标签**:每条连线独立曲线,标签为远程端口,可拖拽调整曲线位置,端口可点开编辑
- **一键生成配置**:保存画布即渲染每台 remote 的 frpc JSON 配置,自动拉起/重启对应 worker
- **worker 自愈**:崩溃自动重启(指数退避),随 webui 启停
- **frpc 一键安装**:从官方 Releases 下载任意版本 frpc或手动指定二进制路径
- **token 可视化**remote 支持 token/URL 等字段
## 构建
### 后端Go 1.25+
```bash
go build -o webui4frpc ./cmd/webui4frpc
```
### 前端Node 20+
```bash
cd web && npm install && npm run build
```
前端产物会进入 `web/dist`,需要拷贝到 `internal/httpapi/dist` 以便 Go `embed` 打包进单一二进制:
```bash
rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
```
## 运行
```bash
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
```
打开 http://127.0.0.1:7500/ ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc或手动指定路径然后回到画布创建 local / remote 并连线、保存。
参数:
| 参数 | 默认 | 说明 |
|---|---|---|
| `-addr` | `127.0.0.1:7500` | 监听地址 |
| `-user` / `-password` | `admin`/`admin` | Basic 认证 |
| `-workdir` | `./webui4frpc` | 数据目录db/配置/日志/bin |
| `-bin` | 空 | 初始 frpc 路径(可选) |
## 数据目录
```
<workdir>
├── manager.db # SQLite 状态locals/remotes/links/settings
├── configs/<name>.json # 每台 remote 渲染的 frpc 配置
├── logs/<name>.log # worker 日志(按大小轮转)
└── bin/frpc-<v> # 一键安装的 frpc
```
## 架构
```
webui4frpc单二进制无 frp 依赖)
├── cmds/webui4frpc 入口HTTP 服务 + 生命周期
├── internal/store SQLite 持久化
├── internal/canvas 画布模型local/remote/link
├── internal/render 渲染 frpc JSON 配置
├── internal/process worker 进程管理spawn/stop/restart/自愈)
├── internal/httpapi REST API + 静态资源
└── internal/install 一键下载官方 frpc
```
## API
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/manager/status` | 总览状态 |
| GET/PUT | `/api/manager/canvas` | 读写画布 |
| GET/PUT | `/api/manager/settings` | 读写运行策略 |
| POST | `/api/manager/binary/install` | 一键安装 frpc |
| POST | `/api/manager/profiles/{name}/start\|stop\|restart` | worker 启停 |
| GET | `/api/manager/profiles/{name}/config\|logs` | 查看配置/日志 |
## 测试
```bash
go test ./...
# 前端
cd web && npm run type-check
```

17
go.mod Normal file
View File

@ -0,0 +1,17 @@
module webui4frpc
go 1.25.0
require modernc.org/sqlite v1.55.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

51
go.sum Normal file
View File

@ -0,0 +1,51 @@
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/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/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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
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.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.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=

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
internal/httpapi/dist/index.html vendored Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
<script type="module" crossorigin src="/assets/index-Db-ysDeU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_HiVU-N.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,283 @@
package httpapi
import (
"encoding/json"
"net/http"
"os"
"strings"
"webui4frpc/internal/store"
)
// canvasData is the full drawing canvas exchanged with the frontend.
type canvasData struct {
Locals []store.Local `json:"locals"`
Remotes []store.Remote `json:"remotes"`
Links []store.Link `json:"links"`
}
func (h *Handler) handleCanvasGet(w http.ResponseWriter, _ *http.Request) {
locals, _ := h.Store.ListLocals()
remotes, _ := h.Store.ListRemotes()
links, _ := h.Store.ListLinks()
writeJSON(w, http.StatusOK, canvasData{Locals: locals, Remotes: remotes, Links: links})
}
func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleCanvasGet(w, r)
case http.MethodPut:
h.saveCanvas(w, r)
default:
methodNotAllowed(w)
}
}
func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
var canvas canvasData
if err := json.NewDecoder(r.Body).Decode(&canvas); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
s := h.Store
// Upsert locals.
for _, l := range canvas.Locals {
if err := s.UpsertLocal(l); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// Delete locals not present.
if existing, err := s.ListLocals(); err == nil {
keep := map[string]bool{}
for _, l := range canvas.Locals {
keep[l.Name] = true
}
for _, old := range existing {
if !keep[old.Name] {
_ = s.DeleteLocal(old.Name)
}
}
}
// Upsert remotes.
for _, rem := range canvas.Remotes {
if err := s.UpsertRemote(rem); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if existing, err := s.ListRemotes(); err == nil {
keep := map[string]bool{}
for _, rem := range canvas.Remotes {
keep[rem.Name] = true
}
for _, old := range existing {
if !keep[old.Name] {
_ = s.DeleteRemote(old.Name)
}
}
}
// Replace links wholesale.
if err := s.ReplaceLinks(canvas.Links); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Restart affected running workers so changes take effect immediately.
if h.SyncWorkers != nil {
h.SyncWorkers()
}
h.handleCanvasGet(w, r)
}
// ---- Settings ----
func (h *Handler) handleSettingsGet(w http.ResponseWriter, r *http.Request) {
settings, _ := h.Store.Settings()
writeJSON(w, http.StatusOK, settings)
}
func (h *Handler) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
var st store.Settings
if err := json.NewDecoder(r.Body).Decode(&st); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.Store.UpdateSettings(st); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
h.handleSettingsGet(w, r)
}
// ---- Binary ----
func (h *Handler) handleBinaryStatus(w http.ResponseWriter, _ *http.Request) {
resp := map[string]any{"binaryPath": ""}
if h.BinaryPath != nil {
resp["binaryPath"] = h.BinaryPath()
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) handleBinaryInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w)
return
}
var req struct {
Version string `json:"version"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if h.InstallBinary == nil {
http.Error(w, "install not configured", http.StatusInternalServerError)
return
}
path, version, err := h.InstallBinary(req.Version)
if err != nil {
http.Error(w, "install failed: "+err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, http.StatusOK, map[string]any{"path": path, "version": version})
}
// ---- Single remote upsert/delete (used by the status page) ----
func (h *Handler) handleRemoteUpsert(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
methodNotAllowed(w)
return
}
var rem store.Remote
if err := json.NewDecoder(r.Body).Decode(&rem); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if rem.Name == "" || rem.IP == "" || rem.Port <= 0 || rem.Port > 65535 {
http.Error(w, "name/ip/port required, port in [1,65535]", http.StatusBadRequest)
return
}
if err := h.Store.UpsertRemote(rem); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Start the worker if enabled.
if rem.Enabled {
_ = h.Process.Start(rem.Name)
}
writeJSON(w, http.StatusOK, rem)
}
func (h *Handler) handleRemoteDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
methodNotAllowed(w)
return
}
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/remotes/")
if name == "" {
http.Error(w, "remote name required", http.StatusBadRequest)
return
}
_ = h.Process.Stop(name)
if err := h.Store.DeleteRemote(name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// ---- Profile lifecycle ----
func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
// Path: /api/manager/profiles/{name}/{action?}
rel := strings.TrimPrefix(r.URL.Path, apiPrefix+"/profiles/")
parts := strings.Split(rel, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "profile name required", http.StatusBadRequest)
return
}
name := parts[0]
action := ""
if len(parts) > 1 {
action = parts[1]
}
switch action {
case "":
// GET profile status
st, has := h.Process.Status(name)
forwards, _ := h.Store.LinksForRemote(name)
writeJSON(w, http.StatusOK, map[string]any{
"name": name, "process": st, "hasProcess": has, "forwards": forwards,
})
case "start", "stop", "restart":
switch r.Method {
case http.MethodPost:
var err error
if action == "start" {
err = h.Process.Start(name)
} else if action == "stop" {
err = h.Process.Stop(name)
} else {
err = h.Process.Restart(name)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
default:
methodNotAllowed(w)
}
case "config":
data, err := os.ReadFile(h.Process.ConfigPath(name))
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
case "logs":
path := h.Process.LogPath(name)
data, err := tailFile(path, 64*1024)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(data))
default:
http.NotFound(w, r)
}
}
// ---- helpers ----
// tailFile returns the last maxBytes bytes of a file.
func tailFile(path string, maxBytes int64) (string, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
size := min(info.Size(), maxBytes)
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
buf := make([]byte, size)
if _, err := f.ReadAt(buf, info.Size()-size); err != nil {
return "", err
}
return string(buf), nil
}

213
internal/httpapi/server.go Normal file
View File

@ -0,0 +1,213 @@
// Package httpapi serves the web UI and REST API.
package httpapi
import (
"embed"
"encoding/json"
"io/fs"
"net/http"
"strings"
"webui4frpc/internal/process"
"webui4frpc/internal/store"
)
//go:embed all:dist
var distFS embed.FS
// Handler bundles dependencies for the HTTP API.
type Handler struct {
Store *store.Store
Process *process.Manager
WorkDir string
BinDir string
User string
Password string
// InstallBinary downloads and activates a frpc binary. Set by the app to
// avoid an import cycle with the install package.
InstallBinary func(version string) (path, ver string, err error)
// BinaryPath resolves the current worker binary.
BinaryPath func() string
// RunInstall is a hook to trigger canary tasks after canvas save.
SyncWorkers func()
}
const (
healthzPath = "/healthz"
apiPrefix = "/api/manager"
)
// NewServeMux builds the full HTTP handler.
func NewServeMux(h *Handler) (http.Handler, error) {
mux := http.NewServeMux()
auth := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || u != h.User || p != h.Password {
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
next(w, r)
}
}
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
// API routes (basic auth).
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
mux.HandleFunc(apiPrefix+"/settings", auth(h.handleSettingsGet))
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
// Profile lifecycle routes.
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
// Static assets (also basic auth) under /.
mux.HandleFunc("/", h.handleStatic)
return mux, nil
}
// handleStatic serves the embedded web build. Paths map to dist files; / and
// unknown paths serve index.html for SPA routing. The SPA uses hash-based
// routing, so returning index.html for "/" is enough — a redirect here would
// loop (the hash fragment never reaches the server).
func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
http.Error(w, "assets unavailable", http.StatusInternalServerError)
return
}
name := strings.TrimPrefix(r.URL.Path, "/")
if name == "" {
name = "index.html"
}
data, err := fs.ReadFile(sub, name)
if err != nil {
// SPA fallback: any non-file path serves index.html.
data, err = fs.ReadFile(sub, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
name = "index.html"
}
if ct := contentTypeFor(name); ct != "" {
w.Header().Set("Content-Type", ct)
}
_, _ = w.Write(data)
}
func contentTypeFor(name string) string {
switch {
case strings.HasSuffix(name, ".html"):
return "text/html; charset=utf-8"
case strings.HasSuffix(name, ".js"):
return "application/javascript"
case strings.HasSuffix(name, ".css"):
return "text/css"
case strings.HasSuffix(name, ".svg"):
return "image/svg+xml"
case strings.HasSuffix(name, ".png"):
return "image/png"
case strings.HasSuffix(name, ".ico"):
return "image/x-icon"
default:
return ""
}
}
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
locals, _ := h.Store.ListLocals()
remotes, _ := h.Store.ListRemotes()
settings, _ := h.Store.Settings()
type profileStatus struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
Status process.Status `json:"process"`
HasProc bool `json:"hasProcess"`
Forwards []store.Forward `json:"forwards"`
}
profiles := make([]profileStatus, 0, len(remotes))
binary := ""
if h.BinaryPath != nil {
binary = h.BinaryPath()
}
for _, rv := range remotes {
st, has := h.Process.Status(rv.Name)
fwd, _ := h.Store.LinksForRemote(rv.Name)
profiles = append(profiles, profileStatus{
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
})
}
// Local status: each local plus its forwarding targets and whether each
// target's worker is healthy (running).
type localTargetStatus struct {
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
WorkerState string `json:"workerState"`
}
type localStatus struct {
Local store.Local `json:"local"`
Targets []localTargetStatus `json:"targets"`
}
localStatuses := make([]localStatus, 0, len(locals))
for _, l := range locals {
targets, err := h.Store.LinksForLocal(l.Name)
if err != nil {
targets = nil
}
ts := make([]localTargetStatus, 0, len(targets))
for _, tg := range targets {
st, _ := h.Process.Status(tg.Remote)
ts = append(ts, localTargetStatus{
Remote: tg.Remote,
RemotePort: tg.RemotePort,
WorkerState: st.State,
})
}
localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts})
}
resp := map[string]any{
"version": "0.1.0",
"workDir": h.WorkDir,
"settings": settings,
"services": locals,
"remotes": remotes,
"binaryPath": binary,
"profiles": profiles,
"localStatus": localStatuses,
}
writeJSON(w, http.StatusOK, resp)
}
func methodNotAllowed(w http.ResponseWriter) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = fmtJSONEncode(w, v)
}
func fmtJSONEncode(w http.ResponseWriter, v any) error {
return json.NewEncoder(w).Encode(v)
}

View File

@ -0,0 +1,99 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"webui4frpc/internal/process"
"webui4frpc/internal/store"
)
func newTestHandler(t *testing.T) (*Handler, *httptest.Server) {
t.Helper()
dir := t.TempDir()
st, err := store.New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
pm := process.NewManager(process.Options{
ConfigsDir: filepath.Join(dir, "configs"),
LogsDir: filepath.Join(dir, "logs"),
BinaryPath: func() string { return "" },
Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
AutoRestart: func(string) bool { return false },
RestartInterval: func() int { return 5 },
})
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw"}
mux, err := NewServeMux(h)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
t.Cleanup(func() { _ = st.Close() })
return h, ts
}
func TestAuthRequired(t *testing.T) {
_, ts := newTestHandler(t)
resp, err := http.Get(ts.URL + "/api/manager/status")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestCanvasRoundTrip(t *testing.T) {
_, ts := newTestHandler(t)
client := ts.Client()
body := `{
"locals": [{"name":"web","ip":"127.0.0.1","port":8080,"protocol":"tcp"}],
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
"links": [{"local":"web","remote":"srv-a","remotePort":8080}]
}`
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
req.SetBasicAuth("admin", "pw")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("save canvas status = %d", resp.StatusCode)
}
var got canvasData
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if len(got.Locals) != 1 || got.Locals[0].Name != "web" {
t.Fatalf("locals = %+v", got.Locals)
}
if len(got.Links) != 1 || got.Links[0].RemotePort != 8080 {
t.Fatalf("links = %+v", got.Links)
}
// GET again to confirm persistence via store.
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/manager/canvas", nil)
req2.SetBasicAuth("admin", "pw")
resp2, err2 := client.Do(req2)
if err2 != nil {
t.Fatal(err2)
}
defer resp2.Body.Close()
var got2 canvasData
_ = json.NewDecoder(resp2.Body).Decode(&got2)
if len(got2.Remotes) != 1 || got2.Remotes[0].Name != "srv-a" {
t.Fatalf("remotes after reload = %+v", got2.Remotes)
}
}

203
internal/install/install.go Normal file
View File

@ -0,0 +1,203 @@
// Package install downloads official frpc binaries from GitHub releases.
package install
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
const (
releasesBase = "https://github.com/fatedier/frp/releases/download"
// maxArchiveFileSize caps a single extracted file against decompression bombs.
maxArchiveFileSize = 256 << 20 // 256 MiB
)
// latestAPI is a var so tests can point it at a local server.
var latestAPI = "https://api.github.com/repos/fatedier/frp/releases/latest"
// Install downloads the frpc release for the requested version (empty = latest)
// into binDir, verifies it runs, and returns the path and installed version.
func Install(ctx context.Context, binDir, version string) (path, installedVersion string, err error) {
if version == "" {
version, err = latestVersion()
if err != nil {
return "", "", fmt.Errorf("resolve latest version: %w", err)
}
}
version = strings.TrimPrefix(version, "v")
platform := platformName()
pkgName := fmt.Sprintf("frp_%s_%s.tar.gz", version, platform)
url := fmt.Sprintf("%s/v%s/%s", releasesBase, version, pkgName)
if err := os.MkdirAll(binDir, 0o755); err != nil {
return "", "", err
}
tmpArchive := filepath.Join(binDir, pkgName+".download")
if err := download(ctx, url, tmpArchive); err != nil {
return "", "", fmt.Errorf("download %s: %w", url, err)
}
defer os.Remove(tmpArchive)
extractDir := filepath.Join(binDir, "frpc-"+version)
if err := os.RemoveAll(extractDir); err != nil {
return "", "", err
}
if err := extractTarGz(tmpArchive, extractDir); err != nil {
return "", "", fmt.Errorf("extract: %w", err)
}
// Locate the frpc executable in the package's top-level directory.
src, err := findFrpc(extractDir)
if err != nil {
_ = os.RemoveAll(extractDir)
return "", "", err
}
dst := filepath.Join(extractDir, "frpc")
if err := os.Rename(src, dst); err != nil {
_ = os.RemoveAll(extractDir)
return "", "", err
}
_ = os.Chmod(dst, 0o755)
if out, err := exec.Command(dst, "--version").Output(); err != nil {
_ = os.RemoveAll(extractDir)
return "", "", fmt.Errorf("downloaded binary not runnable: %w", err)
} else if ver := strings.TrimSpace(string(out)); ver != "" {
version = ver
}
return dst, version, nil
}
// latestVersion returns the newest release tag from the GitHub API.
func latestVersion() (string, error) {
c := &http.Client{Timeout: 30 * time.Second}
resp, err := c.Get(latestAPI)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
}
var rel struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return "", err
}
return rel.TagName, nil
}
func download(ctx context.Context, url, path string) error {
c := &http.Client{Timeout: 90 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
func platformName() string {
arch := runtime.GOARCH
if arch == "x86_64" {
arch = "amd64"
}
return runtime.GOOS + "_" + arch
}
func extractTarGz(archive, dest string) error {
f, err := os.Open(archive)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
name := filepath.Clean(hdr.Name)
if filepath.IsAbs(name) || strings.HasPrefix(name, "..") {
continue // path traversal guard
}
target := filepath.Join(dest, name)
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, io.LimitReader(tr, maxArchiveFileSize)); err != nil {
_ = out.Close()
return err
}
_ = out.Close()
}
}
return nil
}
func findFrpc(dir string) (string, error) {
var found string
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && d.Name() == "frpc" {
found = path
return io.EOF
}
return nil
})
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
if found == "" {
return "", fmt.Errorf("no frpc file under %s", dir)
}
return found, nil
}

View File

@ -0,0 +1,33 @@
package install
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestLatestVersion(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"tag_name":"v0.71.0"}`))
}))
defer ts.Close()
old := latestAPI
latestAPI = ts.URL
defer func() { latestAPI = old }()
v, err := latestVersion()
if err != nil {
t.Fatal(err)
}
if v != "v0.71.0" {
t.Fatalf("version = %q", v)
}
}
func TestPlatformName(t *testing.T) {
p := platformName()
if p == "" || len(p) < 5 {
t.Fatalf("platform = %q", p)
}
}

293
internal/process/process.go Normal file
View File

@ -0,0 +1,293 @@
// Package process supervises worker frpc processes, one per remote.
package process
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"time"
)
// Status describes the current state of a worker process.
type Status struct {
State string `json:"state"` // stopped | starting | running | restarting | crashed
Pid int `json:"pid,omitempty"`
StartTime int64 `json:"startTime,omitempty"`
RestartCount int `json:"restartCount"`
ExitCode int `json:"exitCode,omitempty"`
Err string `json:"err,omitempty"`
}
const (
stopGraceTimeout = 10 * time.Second
maxRestartDelay = 60 * time.Second
)
// Options configures a Manager.
type Options struct {
// ConfigsDir is where rendered JSON configs are written.
ConfigsDir string
// LogsDir is where worker output is captured.
LogsDir string
// BinaryPath returns the frpc binary to spawn (resolved dynamically).
BinaryPath func() string
// Render returns the config bytes for a remote.
Render func(remoteName string) ([]byte, error)
// AutoRestart returns whether to auto-restart a remote's worker.
AutoRestart func(remoteName string) bool
// RestartInterval returns the base restart interval in seconds.
RestartInterval func() int
}
type worker struct {
name string
proc *exec.Cmd
logFile *rotatingFile
stopOnce sync.Once
stopCh chan struct{}
doneCh chan struct{}
mu sync.Mutex
status Status
}
// Manager supervises all workers.
type Manager struct {
opts Options
mu sync.Mutex
workers map[string]*worker
}
// NewManager builds a worker supervisor.
func NewManager(opts Options) *Manager {
return &Manager{opts: opts, workers: make(map[string]*worker)}
}
func (m *Manager) getStatus(w *worker) Status {
w.mu.Lock()
defer w.mu.Unlock()
return w.status
}
func (m *Manager) setState(w *worker, s Status) {
w.mu.Lock()
w.status = s
w.mu.Unlock()
}
// Start renders and spawns a remote's worker. Idempotent if already running.
func (m *Manager) Start(name string) error {
m.mu.Lock()
w := m.workers[name]
m.mu.Unlock()
if w != nil && statusRunning(w) {
return nil
}
w = &worker{
name: name,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
m.mu.Lock()
m.workers[name] = w
m.mu.Unlock()
if err := m.spawn(w); err != nil {
m.mu.Lock()
delete(m.workers, name)
m.mu.Unlock()
return err
}
return nil
}
func statusRunning(w *worker) bool {
select {
case <-w.doneCh:
return false
default:
return w.proc != nil && w.proc.Process != nil
}
}
func (m *Manager) spawn(w *worker) error {
data, err := m.opts.Render(w.name)
if err != nil {
return fmt.Errorf("render config for %q: %w", w.name, err)
}
cfgPath := filepath.Join(m.opts.ConfigsDir, w.name+".json")
if err := os.MkdirAll(m.opts.ConfigsDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(cfgPath, data, 0o600); err != nil {
return err
}
logWriter, err := newRotatingFile(filepath.Join(m.opts.LogsDir, w.name+".log"), 10*1024*1024)
if err != nil {
return err
}
cmd := exec.Command(m.opts.BinaryPath(), "-c", cfgPath)
cmd.Stdout = logWriter
cmd.Stderr = logWriter
setSysProcAttr(cmd)
if err := cmd.Start(); err != nil {
_ = logWriter.Close()
return fmt.Errorf("start worker %q: %w", w.name, err)
}
restartCount := m.getStatus(w).RestartCount
w.proc = cmd
w.logFile = logWriter
m.setState(w, Status{State: "running", Pid: cmd.Process.Pid, StartTime: time.Now().Unix(), RestartCount: restartCount})
go m.supervise(w)
return nil
}
func (m *Manager) supervise(w *worker) {
defer close(w.doneCh)
for {
err := w.proc.Wait()
exitCode := 0
if ee, ok := err.(*exec.ExitError); ok {
exitCode = ee.ExitCode()
}
m.mu.Lock()
if w.logFile != nil {
_ = w.logFile.Close()
w.logFile = nil
}
w.proc = nil
stopRequested := false
select {
case <-w.stopCh:
stopRequested = true
default:
}
m.mu.Unlock()
m.setState(w, Status{State: "stopped", ExitCode: exitCode, RestartCount: m.getStatus(w).RestartCount})
if stopRequested {
return
}
if !m.opts.AutoRestart(w.name) {
return
}
rc := m.getStatus(w).RestartCount + 1
m.setState(w, Status{State: "restarting", RestartCount: rc})
select {
case <-time.After(m.restartDelay(rc)):
case <-w.stopCh:
m.setState(w, Status{State: "stopped", RestartCount: rc})
return
}
if err := m.spawn(w); err != nil {
m.setState(w, Status{State: "crashed", Err: err.Error(), RestartCount: rc})
}
}
}
func (m *Manager) restartDelay(count int) time.Duration {
base := 5 * time.Second
if m.opts.RestartInterval != nil {
if s := m.opts.RestartInterval(); s > 0 {
base = time.Duration(s) * time.Second
}
}
d := base
for i := 1; i < count; i++ {
d *= 2
if d >= maxRestartDelay {
return maxRestartDelay
}
}
return d
}
// Stop gracefully stops a worker and waits for exit.
func (m *Manager) Stop(name string) error {
m.mu.Lock()
w := m.workers[name]
m.mu.Unlock()
if w == nil {
return nil
}
w.stopOnce.Do(func() { close(w.stopCh) })
m.mu.Lock()
proc := w.proc
m.mu.Unlock()
if proc != nil {
signalGroup(proc, syscall.SIGTERM)
}
select {
case <-w.doneCh:
case <-time.After(stopGraceTimeout):
m.mu.Lock()
proc := w.proc
m.mu.Unlock()
if proc != nil {
signalGroup(proc, syscall.SIGKILL)
}
<-w.doneCh
}
m.mu.Lock()
delete(m.workers, name)
m.mu.Unlock()
return nil
}
// Restart stops and starts a worker.
func (m *Manager) Restart(name string) error {
if err := m.Stop(name); err != nil {
return err
}
return m.Start(name)
}
// Status returns the current status of a remote's worker.
func (m *Manager) Status(name string) (Status, bool) {
m.mu.Lock()
w := m.workers[name]
m.mu.Unlock()
if w == nil {
return Status{State: "stopped"}, false
}
return m.getStatus(w), true
}
// StopAll stops all workers (used on manager shutdown).
func (m *Manager) StopAll() {
m.mu.Lock()
names := make([]string, 0, len(m.workers))
for n := range m.workers {
names = append(names, n)
}
m.mu.Unlock()
var wg sync.WaitGroup
for _, n := range names {
wg.Add(1)
go func(name string) {
defer wg.Done()
_ = m.Stop(name)
}(n)
}
wg.Wait()
}
// LogPath returns the log file path for a remote.
func (m *Manager) LogPath(name string) string {
return filepath.Join(m.opts.LogsDir, name+".log")
}
// ConfigPath returns the rendered config path for a remote.
func (m *Manager) ConfigPath(name string) string {
return filepath.Join(m.opts.ConfigsDir, name+".json")
}

View File

@ -0,0 +1,59 @@
package process
import (
"os"
"path/filepath"
"testing"
)
func newTestManager(t *testing.T) *Manager {
t.Helper()
dir := t.TempDir()
script := filepath.Join(dir, "fake-frpc")
// A fake frpc that sleeps (simulating a healthy worker).
if err := os.WriteFile(script, []byte("#!/bin/sh\nsleep 60\n"), 0o700); err != nil {
t.Fatal(err)
}
opts := Options{
ConfigsDir: filepath.Join(dir, "configs"),
LogsDir: filepath.Join(dir, "logs"),
BinaryPath: func() string { return script },
Render: func(name string) ([]byte, error) {
return []byte(`{"name":"` + name + `"}`), nil
},
AutoRestart: func(string) bool { return false },
RestartInterval: func() int { return 1 },
}
return NewManager(opts)
}
func TestStartStop(t *testing.T) {
pm := newTestManager(t)
if err := pm.Start("srv-a"); err != nil {
t.Fatal(err)
}
st, has := pm.Status("srv-a")
if !has || st.State != "running" || st.Pid <= 0 {
t.Fatalf("status = %+v has=%v", st, has)
}
if err := pm.Stop("srv-a"); err != nil {
t.Fatal(err)
}
st, has = pm.Status("srv-a")
if has {
t.Fatalf("after stop has=%v", has)
}
}
func TestConfigWritten(t *testing.T) {
pm := newTestManager(t)
_ = pm.Start("srv-b")
defer pm.Stop("srv-b")
data, err := os.ReadFile(pm.ConfigPath("srv-b"))
if err != nil {
t.Fatal(err)
}
if string(data) != `{"name":"srv-b"}` {
t.Fatalf("config = %s", data)
}
}

View File

@ -0,0 +1,21 @@
//go:build !windows
package process
import (
"os/exec"
"syscall"
)
// setSysProcAttr runs the worker in its own process group.
func setSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// signalGroup delivers sig to the worker and its whole process group.
func signalGroup(cmd *exec.Cmd, sig syscall.Signal) {
if cmd.Process == nil {
return
}
_ = syscall.Kill(-cmd.Process.Pid, sig)
}

View File

@ -0,0 +1,21 @@
//go:build windows
package process
import (
"os/exec"
"syscall"
)
// setSysProcAttr runs the worker in a new process group on Windows.
func setSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP}
}
// signalGroup signals only the direct process on Windows.
func signalGroup(cmd *exec.Cmd, sig syscall.Signal) {
if cmd.Process == nil {
return
}
_ = cmd.Process.Signal(sig)
}

View File

@ -0,0 +1,73 @@
package process
import (
"os"
"path/filepath"
"sync"
)
// rotatingFile appends to a log file and rotates it once it exceeds maxSize.
type rotatingFile struct {
mu sync.Mutex
path string
maxSize int64
f *os.File
size int64
}
func newRotatingFile(path string, maxSize int64) (*rotatingFile, error) {
if maxSize <= 0 {
maxSize = 10 * 1024 * 1024
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
if err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, err
}
return &rotatingFile{path: path, maxSize: maxSize, f: f, size: info.Size()}, nil
}
func (r *rotatingFile) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.f == nil {
f, err := os.OpenFile(r.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
if err != nil {
return 0, err
}
r.f = f
}
if r.size > 0 && r.size+int64(len(p)) > r.maxSize {
_ = r.f.Close()
_ = os.Rename(r.path, r.path+".1")
f, err := os.OpenFile(r.path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
r.f = nil
return 0, err
}
r.f = f
r.size = 0
}
n, err := r.f.Write(p)
r.size += int64(n)
return n, err
}
func (r *rotatingFile) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.f != nil {
err := r.f.Close()
r.f = nil
return err
}
return nil
}

104
internal/render/render.go Normal file
View File

@ -0,0 +1,104 @@
// Package render generates frpc worker configuration (JSON) from canvas data.
package render
import (
"encoding/json"
"fmt"
"webui4frpc/internal/store"
)
// Proxy is a single proxy to render inside a worker config. It carries enough
// info to build any supported frpc proxy type.
type Proxy struct {
// Name is the display name (the local service name).
Name string
// Type is the proxy type: tcp | udp | http | https.
Type string
// LocalIP and LocalPort are the backend address.
LocalIP string
LocalPort int
// RemotePort is the exposed port on the frps server (tcp/udp only).
RemotePort int
// SubDomain is used by http/https proxies when set.
SubDomain string
// CustomDomains is used by http/https proxies when set.
CustomDomains []string
}
// frpcAuth mirrors frpc's auth section.
type frpcAuth struct {
Method string `json:"method"`
Token string `json:"token,omitempty"`
}
// frpcTransport is the transport section used by frpc.
type frpcTransport struct {
Protocol string `json:"protocol,omitempty"`
}
// frpcProxy is the serialized proxy object. Different types use different
// fields; empty ones are omitted so the JSON is accepted by frpc.
type frpcProxy struct {
Name string `json:"name"`
Type string `json:"type"`
LocalIP string `json:"localIP,omitempty"`
LocalPort int `json:"localPort"`
RemotePort int `json:"remotePort,omitempty"`
CustomDomains []string `json:"customDomains,omitempty"`
SubDomain string `json:"subdomain,omitempty"`
}
// Config is a complete frpc configuration document.
type Config struct {
ServerAddr string `json:"serverAddr"`
ServerPort int `json:"serverPort"`
Auth frpcAuth `json:"auth"`
Transport frpcTransport `json:"transport,omitempty"`
LoginFailExit bool `json:"loginFailExit"`
Proxies []frpcProxy `json:"proxies"`
}
// Render builds the worker config JSON for a remote server from its setting
// plus the list of proxies to forward.
func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
cfg := Config{
ServerAddr: remote.IP,
ServerPort: remote.Port,
Auth: frpcAuth{Method: "token", Token: remote.Token},
LoginFailExit: false,
Proxies: make([]frpcProxy, 0, len(proxies)),
}
nameCounts := make(map[string]int)
for _, p := range proxies {
frpcP := frpcProxy{
Name: p.Name,
Type: p.Type,
LocalIP: p.LocalIP,
LocalPort: p.LocalPort,
SubDomain: p.SubDomain,
}
// http/https proxies use customDomains instead of remotePort. The
// default domain is <name>.local; frps matches it by Host header.
if p.Type == "http" || p.Type == "https" {
frpcP.RemotePort = 0
if frpcP.SubDomain == "" && len(frpcP.CustomDomains) == 0 {
frpcP.CustomDomains = []string{p.Name + ".local"}
}
} else {
frpcP.RemotePort = p.RemotePort
}
if len(p.CustomDomains) > 0 {
frpcP.CustomDomains = p.CustomDomains
}
// Duplicate service name -> suffix with the remote port so frpc accepts
// multiple proxies for the same local service.
nameCounts[frpcP.Name]++
if nameCounts[frpcP.Name] > 1 {
frpcP.Name = fmt.Sprintf("%s-%d", frpcP.Name, frpcP.RemotePort)
}
cfg.Proxies = append(cfg.Proxies, frpcP)
}
return json.MarshalIndent(cfg, "", " ")
}

View File

@ -0,0 +1,48 @@
package render
import (
"encoding/json"
"testing"
"webui4frpc/internal/store"
)
func TestRenderTCPProxy(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "secret"}
data, err := Render(remote, []Proxy{
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
if cfg.ServerAddr != "1.2.3.4" || cfg.Auth.Token != "secret" {
t.Fatalf("cfg = %+v", cfg)
}
if len(cfg.Proxies) != 1 || cfg.Proxies[0].RemotePort != 8080 {
t.Fatalf("proxies = %+v", cfg.Proxies)
}
}
func TestRenderDuplicateServiceGetsPortSuffix(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
proxy := []Proxy{
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8080},
{Name: "web", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 8080, RemotePort: 8081},
}
data, err := Render(remote, proxy)
if err != nil {
t.Fatal(err)
}
var cfg Config
_ = json.Unmarshal(data, &cfg)
if len(cfg.Proxies) != 2 {
t.Fatalf("proxies = %+v", cfg.Proxies)
}
if cfg.Proxies[1].Name != "web-8081" {
t.Fatalf("second proxy name = %q", cfg.Proxies[1].Name)
}
}

336
internal/store/store.go Normal file
View File

@ -0,0 +1,336 @@
// Package store implements the persistence layer for webui-frpc.
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// Local is a local forward service node on the canvas.
type Local struct {
Name string `json:"name"`
IP string `json:"ip"`
Port int `json:"port"`
Protocol string `json:"protocol"` // tcp | udp | http | https
}
// Remote is a remote server node on the canvas.
type Remote struct {
Name string `json:"name"`
IP string `json:"ip"`
Port int `json:"port"` // port to connect to frps
Token string `json:"token,omitempty"`
URL string `json:"url,omitempty"`
Enabled bool `json:"enabled"`
}
// Link connects one local to one remote.
type Link struct {
ID int64 `json:"id,omitempty"`
Local string `json:"local"`
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
OffsetX int `json:"offsetX,omitempty"`
OffsetY int `json:"offsetY,omitempty"`
}
// Settings holds runtime options.
type Settings struct {
AutoStartProfiles bool `json:"autoStartProfiles"`
RestartOnExit bool `json:"restartOnExit"`
RestartIntervalSeconds int `json:"restartIntervalSeconds"`
BinaryPath string `json:"binaryPath,omitempty"`
}
// Forward is a rendered link row attached to a remote.
type Forward struct {
Service string `json:"service"`
RemotePort int `json:"remotePort"`
LocalPort int `json:"localPort,omitempty"`
OffsetX int `json:"offsetX,omitempty"`
OffsetY int `json:"offsetY,omitempty"`
}
const schema = `
CREATE TABLE IF NOT EXISTS locals (
name TEXT PRIMARY KEY,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp'
);
CREATE TABLE IF NOT EXISTS remotes (
name TEXT PRIMARY KEY,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
token TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
local TEXT NOT NULL REFERENCES locals(name) ON DELETE CASCADE,
remote TEXT NOT NULL REFERENCES remotes(name) ON DELETE CASCADE,
remote_port INTEGER NOT NULL DEFAULT 0,
offset_x INTEGER NOT NULL DEFAULT 0,
offset_y INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_links_remote ON links(remote);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
// Store is the SQLite persistence layer.
type Store struct {
db *sql.DB
}
// New opens the database at path, creating the schema if needed.
func New(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create dir: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enable fk: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
db.Close()
return nil, fmt.Errorf("secure db: %w", err)
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// ---- Locals ----
func (s *Store) ListLocals() ([]Local, error) {
rows, err := s.db.Query("SELECT name, ip, port, protocol FROM locals ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var out []Local
for rows.Next() {
var l Local
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (s *Store) GetLocal(name string) (Local, bool) {
var l Local
row := s.db.QueryRow("SELECT name, ip, port, protocol FROM locals WHERE name = ?", name)
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol); err != nil {
return Local{}, false
}
return l, true
}
func (s *Store) UpsertLocal(l Local) error {
_, err := s.db.Exec(
"INSERT INTO locals(name, ip, port, protocol) VALUES(?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol",
l.Name, l.IP, l.Port, l.Protocol,
)
return err
}
func (s *Store) DeleteLocal(name string) error {
_, err := s.db.Exec("DELETE FROM locals WHERE name = ?", name)
return err
}
// ---- Remotes ----
func (s *Store) ListRemotes() ([]Remote, error) {
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled FROM remotes ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var out []Remote
for rows.Next() {
var r Remote
var en int
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
return nil, err
}
r.Enabled = en != 0
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetRemote(name string) (Remote, bool) {
var r Remote
var en int
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled FROM remotes WHERE name = ?", name)
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en); err != nil {
return Remote{}, false
}
r.Enabled = en != 0
return r, true
}
func (s *Store) UpsertRemote(r Remote) error {
_, err := s.db.Exec(
"INSERT INTO remotes(name, ip, port, token, url, enabled) VALUES(?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled),
)
return err
}
func (s *Store) DeleteRemote(name string) error {
_, err := s.db.Exec("DELETE FROM remotes WHERE name = ?", name)
return err
}
// ---- Links ----
func (s *Store) ListLinks() ([]Link, error) {
rows, err := s.db.Query("SELECT id, local, remote, remote_port, offset_x, offset_y FROM links")
if err != nil {
return nil, err
}
defer rows.Close()
var out []Link
for rows.Next() {
var l Link
if err := rows.Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// LocalTarget describes one outgoing forward of a local service.
type LocalTarget struct {
Remote string `json:"remote"`
RemotePort int `json:"remotePort"`
}
// LinksForLocal returns the targets a local service forwards to.
func (s *Store) LinksForLocal(local string) ([]LocalTarget, error) {
links, err := s.ListLinks()
if err != nil {
return nil, err
}
var out []LocalTarget
for _, l := range links {
if l.Local != local {
continue
}
out = append(out, LocalTarget{Remote: l.Remote, RemotePort: l.RemotePort})
}
return out, nil
}
// LinksForRemote returns forwards of one remote with local port resolved.
func (s *Store) LinksForRemote(remote string) ([]Forward, error) {
links, err := s.ListLinks()
if err != nil {
return nil, err
}
var out []Forward
for _, l := range links {
if l.Remote != remote {
continue
}
loc, ok := s.GetLocal(l.Local)
if !ok {
continue
}
out = append(out, Forward{
Service: l.Local,
RemotePort: l.RemotePort,
LocalPort: loc.Port,
OffsetX: l.OffsetX,
OffsetY: l.OffsetY,
})
}
return out, nil
}
// ReplaceLinks clears all links and inserts the given set in one transaction.
func (s *Store) ReplaceLinks(links []Link) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec("DELETE FROM links"); err != nil {
return err
}
for _, l := range links {
if _, err := tx.Exec(
"INSERT INTO links(local, remote, remote_port, offset_x, offset_y) VALUES(?,?,?,?,?)",
l.Local, l.Remote, l.RemotePort, l.OffsetX, l.OffsetY,
); err != nil {
return err
}
}
return tx.Commit()
}
// ---- Settings ----
func (s *Store) Settings() (Settings, error) {
var st Settings
row := s.db.QueryRow("SELECT value FROM settings WHERE key = 'settings'")
var raw string
if err := row.Scan(&raw); err != nil {
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
}
if err := json.Unmarshal([]byte(raw), &st); err != nil {
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
}
return st, nil
}
func (s *Store) UpdateSettings(st Settings) error {
raw, err := json.Marshal(st)
if err != nil {
return err
}
_, err = s.db.Exec(
"INSERT INTO settings(key, value) VALUES('settings', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
string(raw),
)
return err
}
var (
ErrNotFound = errors.New("not found")
ErrInvalid = errors.New("invalid argument")
ErrAlreadyExists = errors.New("already exists")
)
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View File

@ -0,0 +1,60 @@
package store
import (
"path/filepath"
"testing"
)
func TestLocalRemoteLinkRoundTrip(t *testing.T) {
dir := t.TempDir()
st, err := New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.UpsertLocal(Local{Name: "web", IP: "127.0.0.1", Port: 8080, Protocol: "tcp"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertRemote(Remote{Name: "srv-a", IP: "1.2.3.4", Port: 7000, Token: "tok", Enabled: true}); err != nil {
t.Fatal(err)
}
if err := st.ReplaceLinks([]Link{{Local: "web", Remote: "srv-a", RemotePort: 8080}}); err != nil {
t.Fatal(err)
}
locals, _ := st.ListLocals()
if len(locals) != 1 || locals[0].Name != "web" {
t.Fatalf("locals = %+v", locals)
}
remotes, _ := st.ListRemotes()
if len(remotes) != 1 || remotes[0].Token != "tok" {
t.Fatalf("remotes = %+v", remotes)
}
fwds, err := st.LinksForRemote("srv-a")
if err != nil || len(fwds) != 1 {
t.Fatalf("forwards = %+v err=%v", fwds, err)
}
if fwds[0].LocalPort != 8080 || fwds[0].RemotePort != 8080 {
t.Fatalf("forward = %+v", fwds[0])
}
}
func TestReplaceLinksClearsOld(t *testing.T) {
dir := t.TempDir()
st, _ := New(filepath.Join(dir, "test.db"))
defer st.Close()
_ = st.UpsertLocal(Local{Name: "a", IP: "127.0.0.1", Port: 1, Protocol: "tcp"})
_ = st.UpsertLocal(Local{Name: "b", IP: "127.0.0.1", Port: 2, Protocol: "tcp"})
_ = st.UpsertRemote(Remote{Name: "r", IP: "1.2.3.4", Port: 7000, Enabled: true})
if err := st.ReplaceLinks([]Link{{Local: "a", Remote: "r", RemotePort: 1}}); err != nil {
t.Fatal(err)
}
if err := st.ReplaceLinks([]Link{{Local: "b", Remote: "r", RemotePort: 2}}); err != nil {
t.Fatal(err)
}
links, _ := st.ListLinks()
if len(links) != 1 || links[0].Local != "b" {
t.Fatalf("links = %+v", links)
}
}

68
plan.md Normal file
View File

@ -0,0 +1,68 @@
# webui4frpc 开发计划
> webui4frpc —— 独立于 frp 源码的可视化 frpc 控制器(单二进制,零 frp 代码依赖frpc 运行时一键从官方 Releases 下载或手动指定)。
## 目标
用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,避免运维反复手写 frpc 配置。
## 已完成M0骨架与核心
- [x] 独立仓库module `webui4frpc`,仅依赖 `modernc.org/sqlite`,零 frp 源码引用
- [x] 目录结构cmd / internal/{store,render,process,httpapi,install} + webVue3 + VueFlow + Element Plus
- [x] 画布模型local / remote / link 多对多连线,节点可编辑、可添加
- [x] 渲染器tcp/udp/http/https → frpc JSON 配置http 用 customDomains=`<name>.local`
- [x] worker 进程管理spawn/stop/restart、日志轮转、崩溃自愈指数退避
- [x] 一键安装 frpcGitHub Releases 手动指定路径
- [x] REST APIstatus/canvas/settings/remotes/profiles/binary
- [x] 三页 UI状态默认/ 连接配置 / 设置
- [x] 状态页:远程节点状态框 + 本地服务转发表(每 5s 轮询),节点可增删改启停
- [x] 画布可用性检查:端口冲突 / http 域名唯一性,标红 + 弹窗提示,保存前拦截
- [x] 演示环境3 本地 × 3 远程多对多全连通tcp + http 转发实测返回数据)
- [x] 后端单元测试store / render / process / httpapi / install
## 里程碑
### M1 高级传输参数P0建议优先
目标:让常用 frpc 能力可配置,保持画布简洁(折叠高级区)。
- [ ] Local 增加高级字段useEncryption / useCompression / bandwidthLimit / poolCount / metadatas / annotations渲染器 + 编辑表单 + 折叠 UI
- [ ] Remote 增加 transportprotocoltcp/quic/kcp/websocket、tls.enable、poolCount
- [ ] 渲染器补齐 transport 段输出;增加 render 单测覆盖新字段
- [ ] 高级字段随 canvas API 持久化store 表扩展 + 迁移)
### M2 HTTP/HTTPS 完善
- [ ] local http/https 增加可编辑域名字段customDomains / subdomain
- [ ] locations路径路由多配置
- [ ] httpHeaderRewrite / hostHeaderRewrite / basicAuth站点访问认证
- [ ] frps vhostHTTPPort 的显示与状态映射(状态页展示)
### M3 负载均衡与健康检查
- [ ] group / groupKey多后端负载均衡
- [ ] health checktcp / http失败剔除与恢复
- [ ] 状态页体现 LB 组与健康状态
### M4 更多代理类型
- [ ] tcpmuxHTTP/2 多路复用)
- [ ] stcp / sudp点对点加密穿透
- [ ] xtcpP2P需 kcp
- [ ] 按类型动态显示字段的表单
### M5 运维与发布
- [ ] git init + 首次 commit + .gitignore排除 node_modules/dist
- [ ] 一键构建脚本web→dist→embed→单二进制
- [ ] GitHub Actionsrelease 构建linux/windows/mac
- [ ] 配置导出/导入(备份)
- [ ] 日志查看页worker 日志 tail UI
## 审查结论摘要(详见 FRPC_FEATURES_AUDIT.md
- 已支持tcp / httpcustomDomains、token 认证、多对多、worker 管理
- 未支持stcp/sudp/xtcp/tcpmux、OIDC/TLS 证书、加密/压缩/限速/连接池、LB/健康检查、http 路由高级、插件/虚拟 IP
- 优先级P0 高级传输参数 → P1 域名字段+transport → P2 LB/健康检查 → P3 低频项
- UI 原则:高级字段折叠式 / 按类型动态显示,保画布简洁

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui-frpc</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2887
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
web/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "webui4frpc-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vue-flow/background": "^1.3.2",
"@vue-flow/core": "^1.48.2",
"element-plus": "^2.14.3",
"pinia": "^3.0.4",
"vue": "^3.5.40",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.3",
"@vue/tsconfig": "^0.8.1",
"sass-embedded": "^1.102.0",
"typescript": "^5.9.3",
"vite": "^7.3.0",
"vue-tsc": "^3.3.8"
}
}

95
web/src/App.vue Normal file
View File

@ -0,0 +1,95 @@
<!-- 简单 SPA 外壳顶部切换 状态 / 连接配置 / 设置 -->
<template>
<div class="app-shell">
<header class="topbar">
<span class="brand">webui4frpc</span>
<nav class="nav">
<button class="nav-btn" :class="{ active: view === 'status' }" @click="view = 'status'">
状态
</button>
<button class="nav-btn" :class="{ active: view === 'canvas' }" @click="view = 'canvas'">
连接配置
</button>
<button class="nav-btn" :class="{ active: view === 'settings' }" @click="view = 'settings'">
设置
</button>
</nav>
</header>
<main class="content">
<CanvasView v-if="view === 'canvas'" />
<SettingsView v-else-if="view === 'settings'" />
<StatusView v-else />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import CanvasView from './views/CanvasView.vue'
import SettingsView from './views/SettingsView.vue'
import StatusView from './views/StatusView.vue'
const view = ref<'canvas' | 'settings' | 'status'>('status')
</script>
<style>
html,
body,
#app {
margin: 0;
height: 100%;
}
.app-shell {
height: 100%;
display: flex;
flex-direction: column;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
background: #f7f8fa;
}
.topbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 24px;
padding: 0 20px;
height: 52px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
.brand {
font-weight: 600;
font-size: 16px;
}
.nav {
display: flex;
gap: 8px;
}
.nav-btn {
border: none;
background: transparent;
padding: 6px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #606266;
&:hover {
background: #f2f3f5;
}
&.active {
background: #303133;
color: #fff;
}
}
.content {
flex: 1;
min-height: 0;
}
</style>

84
web/src/api.ts Normal file
View File

@ -0,0 +1,84 @@
// HTTP client and API functions for webui4frpc.
import type {
BinaryStatus,
CanvasData,
InstallResult,
Remote,
Settings,
StatusResp,
} from './types'
class HTTPError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: 'same-origin',
...options,
})
if (!response.ok) {
throw new HTTPError(response.status, `HTTP ${response.status}`)
}
const ct = response.headers.get('content-type') || ''
if (ct.includes('application/json')) {
return response.json() as Promise<T>
}
return response.text() as unknown as Promise<T>
}
const json = (body: unknown): RequestInit => ({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
export const api = {
status: () => request<StatusResp>('/api/manager/status'),
canvas: () => request<CanvasData>('/api/manager/canvas'),
saveCanvas: (data: CanvasData) =>
request<CanvasData>('/api/manager/canvas', json(data)),
settings: () => request<Settings>('/api/manager/settings'),
saveSettings: (s: Settings) =>
request<Settings>('/api/manager/settings', json(s)),
binaryStatus: () => request<BinaryStatus>('/api/manager/binary/status'),
installBinary: (version?: string) =>
request<InstallResult>('/api/manager/binary/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: version ? JSON.stringify({ version }) : undefined,
}),
saveRemote: (remote: Remote) =>
request<Remote>('/api/manager/remotes', json(remote)),
deleteRemote: (name: string) =>
request<void>(`/api/manager/remotes/${encodeURIComponent(name)}`, {
method: 'DELETE',
}),
profileStart: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
method: 'POST',
}),
profileStop: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
method: 'POST',
}),
profileRestart: (name: string) =>
request<void>(
`/api/manager/profiles/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
),
profileConfig: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
profileLogs: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
}

View File

@ -0,0 +1,166 @@
<template>
<div class="local-node" :class="{ selected, conflicted }">
<!-- source handle (right) connects to remote -->
<Handle type="source" :position="Position.Right" />
<div class="node-head">
<span class="node-icon"></span>
<input v-model="nameField" class="name-input" />
<button
class="del-btn"
title="删除"
@click.stop="emit('remove', props.data.name)"
>
×
</button>
</div>
<div class="fields">
<label>IP <input v-model="ipField" /></label>
<label>端口 <input v-model.number="portField" type="number" /></label>
<label
>协议
<select v-model="protocolField">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
<option value="http">http</option>
<option value="https">https</option>
</select>
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { CanvasLocal } from '../types'
const props = defineProps<{
data: CanvasLocal
selected?: boolean
conflicted?: boolean
}>()
const emit = defineEmits<{
(e: 'update:data', data: CanvasLocal): void
(e: 'remove', name: string): void
}>()
const field = <K extends keyof CanvasLocal>(key: K) =>
computed({
get: () => props.data[key],
set: (val: CanvasLocal[K]) => {
emit('update:data', { ...props.data, [key]: val })
},
})
const nameField = field('name')
const ipField = field('ip')
const portField = field('port')
const protocolField = field('protocol')
</script>
<style scoped lang="scss">
.local-node {
min-width: 190px;
border-radius: 14px 14px 14px 4px;
background: #e8f5e9;
border: 2px solid #4caf50;
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.18);
padding: 8px 10px;
&.selected {
border-color: #ffd54f;
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
}
&.conflicted {
border-color: #f5222d;
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@keyframes conflict-pulse {
0%,
100% {
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
}
50% {
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
}
}
.node-head {
display: flex;
align-items: center;
gap: 6px;
.node-icon {
color: #4caf50;
font-size: 16px;
}
}
.name-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-weight: 600;
font-size: 14px;
color: #2e7d32;
&:focus {
outline: 1px solid #4caf50;
}
}
.del-btn {
border: none;
background: transparent;
color: #81c784;
font-size: 16px;
cursor: pointer;
line-height: 1;
&:hover {
color: #c62828;
}
}
.fields {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #558b2f;
input,
select {
flex: 1;
min-width: 0;
border: 1px solid #a5d6a7;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #4caf50;
}
}
input[type='number'] {
width: 64px;
flex: 0 0 64px;
}
}
}
</style>

View File

@ -0,0 +1,330 @@
<template>
<g class="port-edge" :class="{ selected }">
<path
:d="path"
class="port-edge-path"
:class="{ selected, conflicted }"
fill="none"
:stroke="strokeColor"
:stroke-width="selected ? 3.5 : 2.5"
/>
<!-- Draggable port label. Dragging it moves the curve (and its midpoint)
so users can place the port anywhere along/around the connection. -->
<EdgeLabelRenderer>
<div
ref="labelEl"
class="port-label"
:class="{ dragging }"
:style="labelStyle"
@pointerdown.stop.prevent="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
{{ displayPort }}
</div>
</EdgeLabelRenderer>
</g>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { EdgeLabelRenderer } from '@vue-flow/core'
import type { EdgeProps } from '@vue-flow/core'
const props = withDefaults(
defineProps<EdgeProps & { conflicted?: boolean }>(),
{
selected: false,
conflicted: false,
},
)
const emit = defineEmits<{
(e: 'label-click', payload: { edgeId: string }): void
(
e: 'label-drag',
payload: { edgeId: string; offset: { x: number; y: number } },
): void
}>()
// User-dragged offset relative to the curve midpoint, initialized from the
// edge data so it survives saves.
const dragOffset = ref<{ x: number; y: number }>({
x: Number(props.data?.offsetX ?? 0),
y: Number(props.data?.offsetY ?? 0),
})
// Press-to-drag using Pointer Events + pointer capture. Once the pointer is
// captured on the label element, all subsequent pointer events (even outside
// the element) are delivered to it, and pointerup reliably ends the drag.
const labelEl = ref<HTMLElement | null>(null)
const clickSlop = 2 // px of total travel that still counts as a click
let moved = false
let startX = 0
let startY = 0
let startOff = { x: 0, y: 0 }
let dragging = false
const onPointerDown = (e: PointerEvent) => {
// Never let this press reach the Vue Flow pane (which would pan the canvas).
e.stopPropagation()
dragging = true
moved = false
startX = e.clientX
startY = e.clientY
startOff = { ...dragOffset.value }
if (labelEl.value) {
try {
labelEl.value.setPointerCapture(e.pointerId)
} catch {
// Capture can fail in rare cases; the pointermove/up listeners below
// still work while the pointer stays over the label.
}
}
e.preventDefault()
}
const onPointerMove = (e: PointerEvent) => {
if (!dragging) return
const dx = e.clientX - startX
const dy = e.clientY - startY
if (Math.abs(dx) > clickSlop || Math.abs(dy) > clickSlop) {
moved = true
}
// The label lives in canvas (world) coordinates; pointer deltas are in
// screen pixels, so divide by the current viewport zoom.
const z = readZoom()
dragOffset.value = {
x: startOff.x + dx / z,
y: startOff.y + dy / z,
}
emit('label-drag', {
edgeId: props.id,
offset: { ...dragOffset.value },
})
}
const onPointerUp = (e: PointerEvent) => {
e.stopPropagation()
const wasDrag = dragging
dragging = false
if (labelEl.value && e.pointerId != null) {
try {
labelEl.value.releasePointerCapture?.(e.pointerId)
} catch {
// releasePointerCapture may throw if capture was never established.
}
}
// Press without real movement = tap: open the port editor.
if (!wasDrag) return
if (!moved) {
emit('label-click', { edgeId: props.id })
}
}
// readZoom parses the current zoom level from the Vue Flow viewport's
// transform (e.g. "translate(10px, 20px) scale(0.85)"). Falls back to 1.
function readZoom(): number {
const vp = document.querySelector('.vue-flow__viewport') as HTMLElement | null
if (!vp) return 1
const m = /scale\(([0-9.]+)\)/.exec(vp.style.transform || '')
if (!m) return 1
const z = Number(m[1])
return z > 0 ? z : 1
}
// {{ layer }} is a per-pair index assigned by the canvas editor. It bends this
// edge away from its siblings so multiple links between the same two nodes do
// not overlap. Rendering order follows the layer too, so later links are drawn
// above earlier ones.
const layer = computed(() => Number(props.data?.layer ?? 0))
const displayPort = computed(() => {
const t = String(props.label ?? '')
return t || '?'
})
// Direction of the base bend alternates so pairs of links spread symmetrically
// above and below the straight line.
const baseBend = computed(() => {
const l = layer.value
const dir = l % 2 === 0 ? 1 : -1
const mag = (Math.floor(l / 2) + 1) * 16
return dir * mag
})
// totalBend adds the user's vertical drag onto the base layer bend so the
// curve follows the dragged label.
const totalBend = computed(() => baseBend.value + dragOffset.value.y)
const path = computed(() => {
const { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } =
props
return buildBentPath(
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
totalBend.value,
)
})
// The label sits at the curve midpoint, shifted horizontally by the user's
// drag offset (vertical drag is already absorbed into totalBend).
const mid = computed(() => midpointOf(path.value))
const labelStyle = computed(() => ({
position: 'absolute' as const,
transform: `translate(${mid.value.x + dragOffset.value.x}px, ${mid.value.y}px) translate(-50%, -50%)`,
pointerEvents: 'all' as const,
cursor: 'grab',
zIndex: 10,
}))
const strokeColor = computed(() => {
if (props.conflicted) {
return '#f5222d'
}
const l = layer.value
// Muted, UI-friendly palette that matches the existing theme (Element Plus
// primary / success / warning / danger / info), cycled per layer.
const hues = [
'#409eff',
'#67c23a',
'#e6a23c',
'#f56c6c',
'#909399',
'#409eff',
]
return hues[l % hues.length]
})
// buildBentPath draws a cubic bezier from the source handle to the target
// handle, bending the control points vertically by (bend) pixels so parallel
// links between the same nodes separate visually.
function buildBentPath(
sourceX: number,
sourceY: number,
sourcePosition: unknown,
targetX: number,
targetY: number,
targetPosition: unknown,
bend: number,
): string {
const horizontal = Math.abs(targetX - sourceX)
const handleLen = Math.max(horizontal * 0.45, 40)
let sx = sourceX
let sy = sourceY
let tx = targetX
let ty = targetY
if (sourcePosition === 'Right') {
sx = sourceX
} else if (sourcePosition === 'Left') {
sx = sourceX - handleLen
} else if (sourcePosition === 'Top') {
sx = sourceX
sy = sourceY
} else {
sx = sourceX
}
if (targetPosition === 'Left') {
tx = targetX
} else if (targetPosition === 'Right') {
tx = targetX + handleLen
} else if (targetPosition === 'Top') {
ty = targetY
}
const c1x = sx + (tx - sx) * 0.5
const c1y = sy + bend
const c2x = tx - (tx - sx) * 0.5
const c2y = ty + bend
return `M ${sx} ${sy} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${tx} ${ty}`
}
// midpointOf approximates the curve center using the quadratic mean of the two
// evaluated bezier points at t=0.5 (single evaluation is enough for labels).
function midpointOf(path: string): { x: number; y: number } {
// Parse the cubic bezier control points out of the path string.
const nums = path
.replace('M', '')
.replace('C', '')
.split(/[ ,]+/)
.filter((s) => s.length > 0)
.map(Number)
if (nums.length < 8) {
return { x: 0, y: 0 }
}
const x0 = nums[0] ?? 0
const y0 = nums[1] ?? 0
const cx1 = nums[2] ?? 0
const cy1 = nums[3] ?? 0
const cx2 = nums[4] ?? 0
const cy2 = nums[5] ?? 0
const x1 = nums[6] ?? 0
const y1 = nums[7] ?? 0
const t = 0.5
const mt = 1 - t
const x =
mt * mt * mt * x0 +
3 * mt * mt * t * cx1 +
3 * mt * t * t * cx2 +
t * t * t * x1
const y =
mt * mt * mt * y0 +
3 * mt * mt * t * cy1 +
3 * mt * t * t * cy2 +
t * t * t * y1
return { x, y }
}
</script>
<style scoped lang="scss">
.port-edge-path {
&.selected {
filter: drop-shadow(0 0 4px rgba(244, 67, 54, 0.6));
}
&.conflicted {
filter: drop-shadow(0 0 6px rgba(245, 34, 45, 0.8));
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@keyframes conflict-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
.port-label {
background: rgba(255, 255, 255, 0.92);
color: $color-text-primary;
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 2px 10px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
user-select: none;
transition:
box-shadow $transition-fast,
transform $transition-fast;
&.dragging {
cursor: grabbing;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.22);
}
}
</style>

View File

@ -0,0 +1,172 @@
<template>
<div class="remote-node" :class="{ selected, conflicted }">
<!-- target handle (left) receives connections from locals -->
<Handle type="target" :position="Position.Left" />
<div class="node-head">
<span class="node-icon"></span>
<input v-model="nameField" class="name-input" />
<label class="enabled">
<input v-model="enabledField" type="checkbox" />
启用
</label>
<button
class="del-btn"
title="删除"
@click.stop="emit('remove', props.data.name)"
>
×
</button>
</div>
<div class="fields">
<label>IP <input v-model="ipField" /></label>
<label>端口 <input v-model.number="portField" type="number" /></label>
<label>令牌 <input v-model="tokenField" /></label>
<label>URL <input v-model="urlField" /></label>
</div>
</div>
</template>
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import { computed } from 'vue'
import type { CanvasRemote } from '../types'
const props = defineProps<{
data: CanvasRemote
selected?: boolean
conflicted?: boolean
}>()
const emit = defineEmits<{
(e: 'update:data', data: CanvasRemote): void
(e: 'remove', name: string): void
}>()
const field = <K extends keyof CanvasRemote>(key: K) =>
computed({
get: () => props.data[key],
set: (val: CanvasRemote[K]) => {
emit('update:data', { ...props.data, [key]: val })
},
})
const nameField = field('name')
const ipField = field('ip')
const portField = field('port')
const tokenField = field('token')
const urlField = field('url')
const enabledField = field('enabled')
</script>
<style scoped lang="scss">
.remote-node {
min-width: 200px;
border-radius: 14px 14px 4px 14px;
background: #fff3e0;
border: 2px solid #ff9800;
box-shadow: 0 2px 8px rgba(255, 152, 0, 0.18);
padding: 8px 10px;
&.selected {
border-color: #ffd54f;
box-shadow: 0 0 0 3px rgba(255, 213, 79, 0.45);
}
&.conflicted {
border-color: #f5222d;
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
animation: conflict-pulse 1.2s ease-in-out infinite;
}
}
@keyframes conflict-pulse {
0%,
100% {
box-shadow: 0 0 0 3px rgba(245, 34, 45, 0.4);
}
50% {
box-shadow: 0 0 0 5px rgba(245, 34, 45, 0.7);
}
}
.node-head {
display: flex;
align-items: center;
gap: 6px;
.node-icon {
color: #ff9800;
font-size: 16px;
}
}
.name-input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-weight: 600;
font-size: 14px;
color: #e65100;
&:focus {
outline: 1px solid #ff9800;
}
}
.enabled {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: #ef6c00;
}
.del-btn {
border: none;
background: transparent;
color: #ffb74d;
font-size: 16px;
cursor: pointer;
line-height: 1;
&:hover {
color: #c62828;
}
}
.fields {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
label {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #ef6c00;
input {
flex: 1;
min-width: 0;
border: 1px solid #ffcc80;
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
background: #fff;
color: #333;
&:focus {
outline: none;
border-color: #ff9800;
}
}
input[type='number'] {
width: 64px;
flex: 0 0 64px;
}
}
}
</style>

7
web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

10
web/src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,40 @@
// Global SCSS variables for webui4frpc (light theme).
$color-text-primary: #303133;
$color-text-secondary: #606266;
$color-text-muted: #909399;
$color-text-light: #c0c4cc;
$color-bg-primary: #ffffff;
$color-bg-secondary: #f9f9f9;
$color-bg-tertiary: #fafafa;
$color-bg-muted: #f4f4f5;
$color-bg-hover: #efefef;
$color-bg-active: #eaeaea;
$color-border: #dcdfe6;
$color-border-light: #e4e7ed;
$color-border-lighter: #ebeef5;
$color-border-extra-light: #f2f6fc;
$color-primary: #409eff;
$color-success: #67c23a;
$color-warning: #e6a23c;
$color-danger: #f56c6c;
$color-info: #909399;
$color-btn-primary: #303133;
$color-btn-primary-hover: #4a4d5c;
$font-size-xs: 12px;
$font-size-sm: 13px;
$font-size-md: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-weight-medium: 500;
$font-weight-semibold: 600;
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 24px;
$spacing-xl: 32px;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 14px;
$transition-fast: 0.2s ease;

96
web/src/types.ts Normal file
View File

@ -0,0 +1,96 @@
// Data models shared with the Go backend.
// CanvasLocal / CanvasRemote are aliases kept for node components migrated
// from the original prototype; they equal Local / Remote.
export type CanvasLocal = Local
export type CanvasRemote = Remote
// CanvasLink mirrors the store.Link JSON shape used by the canvas editor.
export type CanvasLink = Link
export interface Local {
name: string
ip: string
port: number
protocol: string // tcp | udp | http | https
}
export interface Remote {
name: string
ip: string
port: number // frps connect port
token?: string
url?: string
enabled: boolean
}
export interface Link {
id?: number
local: string
remote: string
remotePort: number
offsetX?: number
offsetY?: number
}
export interface CanvasData {
locals: Local[]
remotes: Remote[]
links: Link[]
}
export interface Settings {
autoStartProfiles: boolean
restartOnExit: boolean
restartIntervalSeconds: number
binaryPath?: string
}
export interface ProcessStatus {
state: string
pid?: number
startTime?: number
restartCount: number
exitCode?: number
err?: string
}
export interface StatusProfile {
name: string
enabled: boolean
process: ProcessStatus
hasProcess: boolean
forwards: { service: string; remotePort: number; localPort?: number }[]
}
export interface StatusResp {
version: string
workDir: string
settings: Settings
services: Local[]
remotes: Remote[]
binaryPath: string
profiles: StatusProfile[]
localStatus: LocalStatus[]
}
export interface LocalTargetStatus {
remote: string
remotePort: number
workerState: string
}
export interface LocalStatus {
local: Local
targets: LocalTargetStatus[]
}
export interface BinaryStatus {
binaryPath: string
version?: string
}
export interface InstallResult {
path: string
version: string
}

View File

@ -0,0 +1,785 @@
<template>
<div class="canvas-editor">
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<span class="toolbar-title">连接配置</span>
<button class="btn local-add" @click="addLocal"> 本地转发项</button>
<button class="btn remote-add" @click="addRemote"> 远程节点</button>
<button class="btn warn" @click="autoLayout">自动排列</button>
</div>
<div class="toolbar-right">
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
<button class="btn save" :disabled="saving" @click="save">
保存配置
</button>
</div>
</div>
<div class="flow-wrap">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 0.85 }"
:min-zoom="0.3"
:max-zoom="2"
fit-view-on-init
:delete-key-code="null"
:edges-updatable="false"
:edges-reconnectable="false"
class="flow-canvas"
@connect="onConnect"
@pane-click="deselectAll"
@edge-click="onEdgeClick"
>
<Background pattern-color="#cfd8dc" :gap="20" />
<template #node-local="slotProps">
<LocalNode
:data="(slotProps as any).data"
:selected="Boolean((slotProps as any).selected)"
:conflicted="isLocalConflicted((slotProps as any).data?.name)"
@remove="removeLocal"
@update:data="onLocalData"
/>
</template>
<template #node-remote="slotProps">
<RemoteNode
:data="(slotProps as any).data"
:selected="Boolean((slotProps as any).selected)"
:conflicted="isRemoteConflicted((slotProps as any).data?.name)"
@remove="removeRemote"
@update:data="onRemoteData"
/>
</template>
<template #edge-portedge="edgeProps">
<PortEdge
v-bind="edgeProps as any"
:conflicted="isEdgeConflicted((edgeProps as any).id)"
@label-click="onEdgeLabelClick"
@label-drag="onLabelDrag"
/>
</template>
</VueFlow>
</div>
<!-- Connect dialog (port editor) -->
<el-dialog
v-model="portDlg.visible"
:title="portDlg.isNew ? '新建连线' : '编辑远程端口'"
width="400px"
>
<div class="dlg-row">
<label>连接</label>
<span class="dlg-value"
>{{ portDlg.local }} {{ portDlg.remote }}</span
>
</div>
<div class="dlg-row">
<label>远程端口</label>
<el-input
v-model="portInput"
type="number"
min="1"
max="65535"
placeholder="默认 = 本地端口"
class="port-input"
@keyup.enter="confirmPort"
/>
</div>
<template #footer>
<button class="btn" @click="portDlg.visible = false">取消</button>
<button class="btn save" @click="confirmPort">确定</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import { ref, computed, onMounted } from 'vue'
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Edge, Connection } from '@vue-flow/core'
import type { CanvasLocal, CanvasRemote, CanvasLink } from '../types'
import LocalNode from '../components/LocalNode.vue'
import RemoteNode from '../components/RemoteNode.vue'
import PortEdge from '../components/PortEdge.vue'
import { api } from '../api'
const nodes = ref<any[]>([])
const edges = ref<Edge[]>([])
const loading = ref(false)
// ---- canvas conflict validation ----
interface CanvasConflict {
type: 'port' | 'domain'
remote: string
remotePort?: number
domain?: string
locals: string[] // the conflicting local service names
edgeIds: string[]
}
const conflicts = ref<CanvasConflict[]>([])
const conflictedEdgeIds = computed(() => {
const s = new Set<string>()
for (const c of conflicts.value) {
for (const id of c.edgeIds) s.add(id)
}
return s
})
const saving = ref(false)
const dirty = ref(false)
const portDlg = ref({
visible: false,
isNew: false,
local: '',
remote: '',
remotePort: 0,
pendingEdge: null as Edge | null,
})
// portInput is the plain text control for the remote port in the dialog.
const portInput = ref('')
// openPortDlg opens the port editor dialog, seeding the input.
const openPortDlg = (
local: string,
remote: string,
port: number,
isNew: boolean,
pending?: Edge | null,
) => {
portDlg.value = {
visible: true,
isNew,
local,
remote,
remotePort: port,
pendingEdge: pending ?? null,
}
portInput.value = String(port)
}
const localOf = (name: string) =>
nodes.value.find((n) => n.type === 'local' && n.data?.name === name)?.data as
CanvasLocal | undefined
// validateCanvas checks every remote node for conflicts among the links that
// arrive at it: duplicate remote ports (tcp/udp) or duplicate domains
// (http/https). Returns the list of conflicts and stores it in conflicts.
const validateCanvas = (): CanvasConflict[] => {
const found: CanvasConflict[] = []
const byRemote = new Map<string, { edgeId: string; local: string; remotePort: number; type: string }[]>()
for (const e of edges.value) {
const localName = e.source.startsWith('local::') ? e.source.slice(7) : ''
const remoteName = e.target.startsWith('remote::') ? e.target.slice(8) : ''
if (!localName || !remoteName) continue
const loc = localOf(localName)
const rp = Number(e.label)
if (!loc || isNaN(rp) || rp <= 0) continue
if (!byRemote.has(remoteName)) byRemote.set(remoteName, [])
byRemote.get(remoteName)!.push({
edgeId: e.id,
local: localName,
remotePort: rp,
type: loc.protocol,
})
}
for (const [remote, items] of byRemote) {
// 1) duplicate remote ports among tcp/udp links
const byPort = new Map<number, { local: string; edgeId: string }[]>()
for (const it of items) {
if (it.type !== 'tcp' && it.type !== 'udp') continue
if (!byPort.has(it.remotePort)) byPort.set(it.remotePort, [])
byPort.get(it.remotePort)!.push({ local: it.local, edgeId: it.edgeId })
}
for (const [port, arr] of byPort) {
if (arr.length < 2) continue
found.push({
type: 'port',
remote,
remotePort: port,
locals: arr.map((a) => a.local),
edgeIds: arr.map((a) => a.edgeId),
})
}
// 2) duplicate domains among http/https links (same local name currently
// maps to <name>.local).
const byDomain = new Map<string, { local: string; edgeId: string }[]>()
for (const it of items) {
if (it.type !== 'http' && it.type !== 'https') continue
const domain = it.local + '.local'
if (!byDomain.has(domain)) byDomain.set(domain, [])
byDomain.get(domain)!.push({ local: it.local, edgeId: it.edgeId })
}
for (const [domain, arr] of byDomain) {
if (arr.length < 2) continue
found.push({
type: 'domain',
remote,
domain,
locals: arr.map((a) => a.local),
edgeIds: arr.map((a) => a.edgeId),
})
}
}
conflicts.value = found
return found
}
const isRemoteConflicted = (name: string): boolean =>
conflicts.value.some((c) => c.remote === name)
const isLocalConflicted = (name: string): boolean =>
conflicts.value.some((c) => c.locals.includes(name))
const isEdgeConflicted = (id: string): boolean =>
conflictedEdgeIds.value.has(id)
const nodeId = (kind: 'local' | 'remote', name: string) => `${kind}::${name}`
const addLocal = () => {
const name = `local-${nodes.value.filter((n) => n.type === 'local').length + 1}`
const data: CanvasLocal = {
name,
ip: '127.0.0.1',
port: 8080,
protocol: 'tcp',
}
nodes.value.push({
id: nodeId('local', name),
type: 'local',
position: {
x: 60,
y: 90 + nodes.value.filter((n) => n.type === 'local').length * 140,
},
data,
})
dirty.value = true
}
const addRemote = () => {
const name = `remote-${nodes.value.filter((n) => n.type === 'remote').length + 1}`
const data: CanvasRemote = {
name,
ip: '127.0.0.1',
port: 7000,
token: '',
url: '',
enabled: true,
}
nodes.value.push({
id: nodeId('remote', name),
type: 'remote',
position: {
x: 760,
y: 90 + nodes.value.filter((n) => n.type === 'remote').length * 160,
},
data,
})
dirty.value = true
}
const removeLocal = (name: string) => {
nodes.value = nodes.value.filter(
(n) => !(n.type === 'local' && n.data?.name === name),
)
edges.value = edges.value.filter((e) => e.source !== nodeId('local', name))
dirty.value = true
validateCanvas()
}
const removeRemote = (name: string) => {
nodes.value = nodes.value.filter(
(n) => !(n.type === 'remote' && n.data?.name === name),
)
edges.value = edges.value.filter((e) => e.target !== nodeId('remote', name))
dirty.value = true
validateCanvas()
}
// edgeId builds a unique id for a link. The remote port is part of the id so
// that several links between the same local and remote nodes (different ports)
// are kept separate instead of being merged into one edge.
const edgeId = (source: string, target: string, remotePort: number) =>
`${source}-->${target}@${remotePort}`
const onConnect = (conn: Connection) => {
const localName = conn.source.startsWith('local::')
? conn.source.slice(7)
: ''
const remoteName = conn.target.startsWith('remote::')
? conn.target.slice(8)
: ''
if (!localName || !remoteName) return
const loc = localOf(localName)
if (!loc) return
openPortDlg(localName, remoteName, loc.port, true, {
id: edgeId(conn.source, conn.target, loc.port),
source: conn.source,
target: conn.target,
} as Edge)
}
const confirmPort = () => {
const d = portDlg.value
const parsed = Number(portInput.value)
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
ElMessage.warning('请输入 1-65535 的整数端口')
return
}
d.remotePort = parsed
if (d.isNew && d.pendingEdge) {
const newId = edgeId(
d.pendingEdge.source,
d.pendingEdge.target,
d.remotePort,
)
const existing = edges.value.find((e) => e.id === newId)
if (existing) {
existing.label = String(d.remotePort)
} else {
edges.value.push({
id: newId,
source: d.pendingEdge.source,
target: d.pendingEdge.target,
label: String(d.remotePort),
type: 'portedge',
animated: false,
data: {},
})
assignLayers()
}
} else if (d.pendingEdge) {
// Editing an existing link's remote port: update its label and id so the
// edge stays unique when the port changes.
const old = d.pendingEdge
const newId = edgeId(old.source, old.target, d.remotePort)
const conflict = edges.value.find((e) => e.id === newId && e !== old)
if (conflict) {
ElMessage.warning('该端口已被同一条连线占用')
return
}
if (old.id !== newId) {
old.id = newId
}
old.label = String(d.remotePort)
}
portDlg.value.visible = false
dirty.value = true
}
const editEdge = (edge: Edge) => {
const localName = edge.source.startsWith('local::')
? edge.source.slice(7)
: ''
const remoteName = edge.target.startsWith('remote::')
? edge.target.slice(8)
: ''
const loc = localOf(localName)
openPortDlg(
localName,
remoteName,
Number(edge.label) || (loc ? loc.port : 0),
false,
edge,
)
}
// onEdgeLabelClick finds the edge by id and opens its port editor.
const onEdgeLabelClick = ({ edgeId }: { edgeId: string }) => {
const e = edges.value.find((x) => x.id === edgeId)
if (e) editEdge(e)
}
// onLabelDrag stores the user's port label offset on the edge so the custom
// edge re-renders the curve and label at the dragged position, and saves it.
const onLabelDrag = ({
edgeId,
offset,
}: {
edgeId: string
offset: { x: number; y: number }
}) => {
const e = edges.value.find((x) => x.id === edgeId)
if (e) {
e.data = { ...(e.data as object), offsetX: offset.x, offsetY: offset.y }
dirty.value = true
}
}
// assignLayers re-computes the layer (per-pair index) of every edge so custom
// port edges bend away from their siblings. Returns the same array mutated in
// place; also used to keep ordering consistent.
const assignLayers = () => {
const seen = new Map<string, number>()
for (const e of edges.value) {
const key = `${e.source}||${e.target}`
const n = seen.get(key) ?? 0
seen.set(key, n + 1)
e.data = { ...(e.data as object), layer: n }
}
// Re-run availability checks after the canvas topology changes so conflicts
// are flagged (red) immediately.
validateCanvas()
}
const onLocalData = (data: CanvasLocal) => {
const n = nodes.value.find(
(x) => x.type === 'local' && x.data?.name === data.name,
)
if (n) n.data = { ...data }
dirty.value = true
}
const onRemoteData = (data: CanvasRemote) => {
const n = nodes.value.find(
(x) => x.type === 'remote' && x.data?.name === data.name,
)
if (n) n.data = { ...data }
dirty.value = true
}
const onEdgeClick = ({ edge }: { edge: Edge }) => {
editEdge(edge)
}
// checkAndWarn validates the canvas availability (port/domain conflicts)
// and shows a dialog listing the offending local -> remote forwards.
const checkAndWarn = () => {
const found = validateCanvas()
if (found.length === 0) {
return true
}
const lines = found.map((c) => {
const loc = c.locals.join('、')
if (c.type === 'port') {
return `· 本地服务「${loc}」→ 远程节点「${c.remote}」:远程端口 ${c.remotePort} 重复`
}
return `· 本地服务「${loc}」→ 远程节点「${c.remote}」:域名 ${c.domain} 重复(一个域名只能绑定一个 http/https 服务)`
})
void ElMessageBox.alert(
`<div class="conflict-box">${lines.join('<br/>')}</div>`,
'发现连接冲突,请检查标红的节点与连线',
{
confirmButtonText: '知道了',
dangerouslyUseHTMLString: true,
type: 'warning',
},
)
return false
}
const deselectAll = () => {
validateCanvas() // 点击画布:可用性检查(标红)
nodes.value.forEach((n) => {
n.selected = false
})
}
const localNodes = computed(() => nodes.value.filter((n) => n.type === 'local'))
const remoteNodes = computed(() =>
nodes.value.filter((n) => n.type === 'remote'),
)
const autoLayout = () => {
let li = 0
let ri = 0
for (const n of nodes.value) {
if (n.type === 'local') {
n.position = { x: 60, y: 90 + li * 140 }
li++
} else {
n.position = { x: 760, y: 90 + ri * 160 }
ri++
}
}
}
const load = async () => {
loading.value = true
try {
const data = await api.canvas()
nodes.value = []
edges.value = []
let li = 0
let ri = 0
for (const l of data.locals || []) {
nodes.value.push({
id: nodeId('local', l.name),
type: 'local',
position: { x: 60, y: 90 + li * 140 },
data: { ...l },
})
li++
}
for (const r of data.remotes || []) {
nodes.value.push({
id: nodeId('remote', r.name),
type: 'remote',
position: { x: 760, y: 90 + ri * 160 },
data: { ...r },
})
ri++
}
for (const link of data.links || []) {
const s = nodeId('local', link.local)
const t = nodeId('remote', link.remote)
if (
!nodes.value.some((n) => n.id === s) ||
!nodes.value.some((n) => n.id === t)
)
continue
const rp = link.remotePort || localOf(link.local)?.port || 0
edges.value.push({
id: edgeId(s, t, rp),
source: s,
target: t,
label: String(rp),
type: 'portedge',
animated: false,
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
})
}
assignLayers()
dirty.value = false
} finally {
loading.value = false
}
}
const buildPayload = () => {
const locals: CanvasLocal[] = localNodes.value.map((n) => ({
...(n.data as CanvasLocal),
}))
const remotes: CanvasRemote[] = remoteNodes.value.map((n) => ({
...(n.data as CanvasRemote),
}))
const links: CanvasLink[] = []
for (const e of edges.value) {
const localName = e.source.startsWith('local::') ? e.source.slice(7) : ''
const remoteName = e.target.startsWith('remote::') ? e.target.slice(8) : ''
const rp = Number(e.label)
links.push({
local: localName,
remote: remoteName,
remotePort: isNaN(rp) || rp <= 0 ? 0 : rp,
offsetX: (e.data as any)?.offsetX ?? 0,
offsetY: (e.data as any)?.offsetY ?? 0,
})
}
return { locals, remotes, links }
}
const save = async () => {
// 保存前可用性检查:有冲突则提示并阻止保存,保证配置可直接生效
if (!checkAndWarn()) {
return
}
saving.value = true
try {
const data = await api.saveCanvas(buildPayload())
const posOf = new Map(nodes.value.map((n) => [n.id, n.position]))
nodes.value = []
let li = 0
let ri = 0
for (const l of data.locals || []) {
const id = nodeId('local', l.name)
nodes.value.push({
id,
type: 'local',
position: posOf.get(id) || { x: 60, y: 90 + li * 140 },
data: { ...l },
})
li++
}
for (const r of data.remotes || []) {
const id = nodeId('remote', r.name)
nodes.value.push({
id,
type: 'remote',
position: posOf.get(id) || { x: 760, y: 90 + ri * 160 },
data: { ...r },
})
ri++
}
edges.value = []
for (const link of data.links || []) {
const s = nodeId('local', link.local)
const t = nodeId('remote', link.remote)
const rp = link.remotePort || localOf(link.local)?.port || 0
edges.value.push({
id: edgeId(s, t, rp),
source: s,
target: t,
label: String(rp),
type: 'portedge',
data: { offsetX: link.offsetX ?? 0, offsetY: link.offsetY ?? 0 },
})
}
assignLayers()
dirty.value = false
ElMessage.success('配置已保存')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.message || e))
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped lang="scss">
.canvas-editor {
height: 100%;
display: flex;
flex-direction: column;
background: $color-bg-secondary;
}
.toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: $color-bg-primary;
border-bottom: 1px solid $color-border-light;
z-index: 20;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-title {
font-weight: $font-weight-semibold;
font-size: $font-size-lg;
margin-right: 8px;
}
.btn {
border: none;
border-radius: 8px;
padding: 6px 12px;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-fast;
background: $color-bg-muted;
color: $color-text-primary;
&:hover {
background: $color-bg-hover;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.btn.local-add {
background: #4caf50;
color: #fff;
&:hover {
background: #43a047;
}
}
.btn.remote-add {
background: #ff9800;
color: #fff;
&:hover {
background: #f57c00;
}
}
.btn.warn {
background: #78909c;
color: #fff;
&:hover {
background: #607d8b;
}
}
.btn.save {
background: $color-btn-primary;
color: #fff;
&:hover {
background: $color-btn-primary-hover;
}
}
.save-hint {
font-size: $font-size-sm;
color: $color-text-muted;
}
.flow-wrap {
flex: 1;
position: relative;
min-height: 0;
}
.flow-canvas {
width: 100%;
height: 100%;
background: #f5f7fa;
}
.dlg-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
label {
min-width: 70px;
font-size: $font-size-md;
color: $color-text-secondary;
}
}
.dlg-value {
font-weight: $font-weight-medium;
}
.dlg-hint {
font-size: $font-size-sm;
color: $color-text-muted;
}
:deep(.vue-flow__edge-label) {
background: rgba(0, 0, 0, 0.72);
color: #fff;
border-radius: 10px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
:deep(.vue-flow__handle) {
width: 12px;
height: 12px;
border: 2px solid #fff;
}
:deep(.vue-flow__node) {
cursor: grab;
&.dragging {
cursor: grabbing;
}
}
</style>

View File

@ -0,0 +1,319 @@
<template>
<div class="settings-page">
<div class="page-top">
<div class="page-header">
<h2 class="page-title">运行时设置</h2>
<p class="page-subtitle">worker 二进制与运行策略</p>
</div>
</div>
<div v-loading="loading" class="page-content">
<!-- frpc 二进制 -->
<section class="panel">
<h3 class="panel-title">frpc 可执行文件</h3>
<p class="panel-desc">
用于拉起各 remote profile frpc worker
进程留空则使用管理器自身进程
</p>
<div class="binary-row">
<el-input
v-model="binaryPathInput"
placeholder="如 /usr/local/bin/frpc留空用当前进程"
clearable
size="large"
class="binary-input"
@blur="applyBinaryPath"
@keyup.enter="applyBinaryPath"
/>
<button
class="btn install-btn"
:disabled="installing"
@click="installBinary"
>
<el-icon v-if="installing" class="spin"><Loading /></el-icon>
{{ installing ? '安装中…' : '一键安装 frpc' }}
</button>
</div>
<div v-if="binaryStatus" class="binary-info">
<span class="info-label">当前路径</span>
<code class="info-value">{{ binaryStatus.binaryPath }}</code>
<span v-if="binaryStatus.version" class="info-version">
{{ binaryStatus.version }}
</span>
</div>
</section>
<!-- 运行策略 -->
<section class="panel">
<h3 class="panel-title">运行策略</h3>
<div class="setting-row">
<div class="setting-text">
<span class="setting-label">自动启动 profile</span>
<span class="setting-hint"
>管理器启动时自动拉起所有已启用的 remote</span
>
</div>
<el-switch
v-model="settings.autoStartProfiles"
@change="saveSettings"
/>
</div>
<div class="setting-row">
<div class="setting-text">
<span class="setting-label">异常退出自动重启</span>
<span class="setting-hint">worker 崩溃后带退避重启</span>
</div>
<el-switch v-model="settings.restartOnExit" @change="saveSettings" />
</div>
<div class="setting-row">
<div class="setting-text">
<span class="setting-label">重启间隔</span>
<span class="setting-hint">崩溃重启的指数退避基数</span>
</div>
<el-input-number
v-model="settings.restartIntervalSeconds"
:min="1"
:max="3600"
@change="saveSettings"
/>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { api } from '../api'
import type {
BinaryStatus,
Settings as ManagerSettings,
} from '../types'
const loading = ref(false)
const installing = ref(false)
const binaryPathInput = ref('')
const binaryStatus = ref<BinaryStatus | null>(null)
const settings = ref<ManagerSettings>({
autoStartProfiles: true,
restartOnExit: true,
restartIntervalSeconds: 5,
})
const load = async () => {
loading.value = true
try {
settings.value = await api.settings()
binaryPathInput.value = settings.value.binaryPath || ''
try {
binaryStatus.value = await api.binaryStatus()
} catch {
binaryStatus.value = null
}
} finally {
loading.value = false
}
}
const applyBinaryPath = async () => {
try {
const s = await api.settings()
s.binaryPath = binaryPathInput.value.trim()
await api.saveSettings(s)
settings.value = s
const saved = binaryPathInput.value.trim()
ElMessage.success(
saved ? `worker 将使用 ${saved}` : 'worker 将使用管理器自身(默认)',
)
await load()
} catch (e: any) {
ElMessage.error('保存 frpc 路径失败: ' + (e.message || e))
}
}
const installBinary = async () => {
installing.value = true
try {
const result = await api.installBinary()
binaryPathInput.value = result.path
ElMessage.success(`已安装 frpc ${result.version}`)
await load()
} catch (e: any) {
ElMessage.error('安装失败: ' + (e.message || e))
} finally {
installing.value = false
}
}
const saveSettings = async () => {
try {
await api.saveSettings(settings.value)
ElMessage.success('运行策略已保存')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.message || e))
}
}
onMounted(load)
</script>
<style scoped lang="scss">
.settings-page {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.page-top {
flex-shrink: 0;
padding: $spacing-lg $spacing-xl;
border-bottom: 1px solid $color-border-light;
}
.page-header {
.page-title {
margin: 0;
}
.page-subtitle {
margin: 4px 0 0;
color: $color-text-muted;
font-size: $font-size-md;
}
}
.page-content {
padding: $spacing-lg $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-lg;
max-width: 760px;
}
.panel {
border: 1px solid $color-border-light;
border-radius: $radius-md;
padding: $spacing-lg;
}
.panel-title {
margin: 0 0 4px;
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
}
.panel-desc {
margin: 0 0 $spacing-md;
color: $color-text-muted;
font-size: $font-size-sm;
}
.binary-row {
display: flex;
gap: $spacing-sm;
}
.binary-input {
flex: 1;
}
.btn {
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: $font-size-md;
font-weight: $font-weight-medium;
cursor: pointer;
transition: all $transition-fast;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
}
.install-btn {
background: $color-btn-primary;
color: #fff;
&:hover {
background: $color-btn-primary-hover;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.spin {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
.binary-info {
margin-top: $spacing-md;
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
.info-label {
color: $color-text-muted;
font-size: $font-size-sm;
}
.info-value {
background: $color-bg-muted;
border-radius: 6px;
padding: 2px 8px;
font-size: $font-size-sm;
word-break: break-all;
}
.info-version {
background: rgba(64, 158, 255, 0.12);
color: $color-primary;
border-radius: 10px;
padding: 2px 8px;
font-size: $font-size-sm;
}
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
padding: $spacing-sm 0;
border-bottom: 1px solid $color-border-lighter;
&:last-child {
border-bottom: none;
}
}
.setting-text {
display: flex;
flex-direction: column;
}
.setting-label {
font-size: $font-size-md;
font-weight: $font-weight-medium;
}
.setting-hint {
font-size: $font-size-sm;
color: $color-text-muted;
}
</style>

View File

@ -0,0 +1,497 @@
<template>
<div class="status-page">
<div class="status-top">
<h2 class="page-title">状态</h2>
<span class="refresh-hint"> 5 秒自动刷新</span>
<button class="refresh-btn" @click="loadStatus">刷新</button>
<button class="add-btn" @click="openAdd"> 添加远程节点</button>
</div>
<div class="status-body" v-if="status">
<!-- 远程节点状态 -->
<section class="status-section">
<h3 class="status-title">远程节点状态</h3>
<div class="status-cards">
<div
v-for="p in status.profiles"
:key="p.name"
class="node-status-card"
:class="{ healthy: workerHealthy(p.process.state) }"
>
<div class="ns-head">
<span class="ns-name">{{ p.name }}</span>
<span class="ns-badge" :class="{ ok: workerHealthy(p.process.state) }">
{{ workerStateLabel(p.process.state) }}
</span>
<span class="ns-actions">
<button v-if="p.process.state === 'running'" class="mini-btn" @click="stopRemote(p.name)">停止</button>
<button v-else class="mini-btn" @click="startRemote(p.name)">启动</button>
<button class="mini-btn" @click="openEdit(p)">编辑</button>
<button class="mini-btn danger" @click="removeRemote(p.name)">删除</button>
</span>
</div>
<div class="ns-meta">
<span v-if="p.process.pid">pid {{ p.process.pid }}</span>
<span class="ns-err" v-else-if="p.process.err">{{ p.process.err }}</span>
</div>
<div class="ns-forwards" v-if="p.forwards.length">
<span v-for="f in p.forwards" :key="f.service" class="ns-fwd">
{{ f.service }} :{{ f.remotePort }}
</span>
</div>
</div>
<div v-if="!status.profiles.length" class="ns-empty">尚未配置远程节点</div>
</div>
</section>
<!-- 本地服务转发状态 -->
<section class="status-section">
<h3 class="status-title">本地服务转发</h3>
<div class="local-status-list">
<div v-for="ls in status.localStatus" :key="ls.local.name" class="local-status-card">
<div class="ls-head">
<span class="ls-name">{{ ls.local.name }}</span>
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="ls-proto">{{ ls.local.protocol }}</span>
</div>
<div class="ls-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-target">
<span class="ls-arrow"></span>
<span class="ls-remote">{{ t.remote }}</span>
<span class="ls-port">:{{ t.remotePort }}</span>
<span class="ls-state" :class="{ ok: workerHealthy(t.workerState) }">
{{ workerStateLabel(t.workerState) }}
</span>
</div>
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
</div>
</div>
<div v-if="!status.localStatus.length" class="ls-empty">尚未配置本地服务</div>
</div>
</section>
</div>
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
<div class="form-row">
<label>名称</label>
<el-input v-model="dialog.remote.name" :disabled="!dialog.isNew" placeholder="如 aliyun-hz" />
</div>
<div class="form-row">
<label>服务器 IP</label>
<el-input v-model="dialog.remote.ip" placeholder="frps 地址" />
</div>
<div class="form-row">
<label>端口</label>
<el-input-number v-model="dialog.remote.port" :min="1" :max="65535" />
</div>
<div class="form-row">
<label>Token</label>
<el-input v-model="dialog.remote.token" placeholder="可选" />
</div>
<div class="form-row">
<label>URL</label>
<el-input v-model="dialog.remote.url" placeholder="可选" />
</div>
<div class="form-row">
<label>启用</label>
<el-switch v-model="dialog.remote.enabled" />
</div>
<template #footer>
<button class="refresh-btn" @click="dialog.visible = false">取消</button>
<button class="add-btn" @click="saveRemote">保存</button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import type { Remote, StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
interface RemoteDialog {
visible: boolean
isNew: boolean
remote: Remote
}
const newRemote = (): Remote => ({
name: '',
ip: '',
port: 7000,
token: '',
url: '',
enabled: true,
})
const dialog = reactive<RemoteDialog>({
visible: false,
isNew: true,
remote: newRemote(),
})
const openAdd = () => {
dialog.isNew = true
dialog.remote = newRemote()
dialog.visible = true
}
const openEdit = (p: { name: string }) => {
const found = status.value?.remotes.find((r) => r.name === p.name)
dialog.isNew = false
dialog.remote = found
? { ...found }
: { name: p.name, ip: '', port: 7000, token: '', url: '', enabled: true }
dialog.visible = true
}
const saveRemote = async () => {
const d = dialog.remote
if (!d.name || !d.ip || d.port <= 0) {
ElMessage.warning('请填写名称、IP、端口')
return
}
try {
await api.saveRemote(d)
dialog.visible = false
ElMessage.success(dialog.isNew ? '节点已添加' : '节点已更新')
await loadStatus()
} catch (e: any) {
ElMessage.error('保存失败: ' + (e.message || e))
}
}
const startRemote = async (name: string) => {
try {
await api.profileStart(name)
await loadStatus()
} catch (e: any) {
ElMessage.error('启动失败: ' + (e.message || e))
}
}
const stopRemote = async (name: string) => {
try {
await api.profileStop(name)
await loadStatus()
} catch (e: any) {
ElMessage.error('停止失败: ' + (e.message || e))
}
}
const removeRemote = async (name: string) => {
if (!window.confirm(`删除远程节点 ${name}`)) return
try {
await api.deleteRemote(name)
await loadStatus()
} catch (e: any) {
ElMessage.error('删除失败: ' + (e.message || e))
}
}
const loadStatus = async () => {
try {
status.value = await api.status()
} catch {
// keep last known status on transient errors
}
}
const workerStateLabel = (s?: string) => {
switch (s) {
case 'running':
return '通畅'
case 'starting':
return '启动中'
case 'restarting':
return '重启中'
case 'crashed':
return '异常'
default:
return '未启动'
}
}
const workerHealthy = (s?: string) => s === 'running'
onMounted(() => {
loadStatus()
timer = window.setInterval(loadStatus, 5000)
})
onBeforeUnmount(() => {
if (timer !== null) {
window.clearInterval(timer)
timer = null
}
})
</script>
<style scoped lang="scss">
.add-btn {
margin-left: auto;
border: none;
border-radius: 6px;
padding: 5px 14px;
background: $color-btn-primary;
color: #fff;
cursor: pointer;
font-size: 13px;
&:hover {
background: $color-btn-primary-hover;
}
}
.form-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
label {
width: 72px;
font-size: 13px;
color: $color-text-secondary;
flex-shrink: 0;
}
}
.mini-btn {
border: 1px solid $color-border;
border-radius: 5px;
padding: 1px 8px;
background: #fff;
font-size: 12px;
cursor: pointer;
color: $color-text-secondary;
&:hover {
background: $color-bg-hover;
}
&.danger:hover {
color: $color-danger;
}
}
.ns-actions {
display: flex;
gap: 4px;
}
.status-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.status-top {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid $color-border-light;
background: #fff;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.refresh-hint {
font-size: 12px;
color: $color-text-muted;
}
.refresh-btn {
border: 1px solid $color-border;
border-radius: 6px;
padding: 3px 12px;
background: #fff;
cursor: pointer;
font-size: 13px;
&:hover {
background: $color-bg-hover;
}
}
.status-body {
flex: 1;
display: flex;
gap: 20px;
padding: 20px;
overflow: hidden;
}
.status-section {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.status-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
}
.status-cards,
.local-status-list {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
padding-right: 6px;
}
.node-status-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 10px 14px;
background: #fafafa;
border-left: 4px solid $color-danger;
&.healthy {
border-left-color: $color-success;
}
}
.ns-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.ns-name {
font-weight: 600;
margin-right: 8px;
}
.ns-badge {
font-size: 12px;
padding: 1px 8px;
border-radius: 8px;
background: #fef0f0;
color: $color-danger;
&.ok {
background: #f0f9eb;
color: $color-success;
}
}
.ns-meta {
font-size: 12px;
color: $color-text-muted;
margin-top: 2px;
}
.ns-err {
color: $color-danger;
}
.ns-forwards {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.ns-fwd {
font-size: 11px;
background: $color-bg-muted;
border-radius: 6px;
padding: 1px 6px;
color: $color-text-secondary;
}
.ns-empty,
.ls-empty {
color: $color-text-muted;
font-size: 13px;
padding: 8px;
}
.local-status-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 10px 14px;
background: #fafafa;
}
.ls-head {
display: flex;
align-items: center;
gap: 8px;
}
.ls-name {
font-weight: 600;
}
.ls-addr {
font-size: 12px;
color: $color-text-secondary;
}
.ls-proto {
font-size: 11px;
background: #ecf5ff;
color: #409eff;
border-radius: 6px;
padding: 1px 6px;
}
.ls-targets {
margin-top: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.ls-target {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.ls-arrow {
color: $color-text-muted;
}
.ls-remote {
font-weight: 500;
}
.ls-port {
color: $color-text-secondary;
}
.ls-state {
margin-left: auto;
font-size: 12px;
color: $color-danger;
&.ok {
color: $color-success;
}
}
.ls-none {
color: $color-text-light;
font-size: 12px;
}
</style>

20
web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

28
web/vite.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
// The frontend is served by the Go binary at / (embedded dist), and talks to
// the same origin via /api/manager/*.
export default defineConfig({
plugins: [vue()],
base: '/',
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`,
},
},
},
build: {
outDir: 'dist',
},
server: {
proxy: {
'/api': 'http://127.0.0.1:17650',
},
},
})