mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
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:
213
internal/httpapi/server.go
Normal file
213
internal/httpapi/server.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user