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

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)
}
}