Files
webui4frpc/internal/httpapi/server.go

456 lines
13 KiB
Go

// Package httpapi serves the web UI and REST API.
package httpapi
import (
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"
"time"
"webui4frpc/internal/cluster"
"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()
// Cluster is the peer-to-peer binary registry (M6). Nil disables M6 routes.
Cluster *cluster.Registry
}
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(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
h.handleSettingsPut(w, r)
return
}
h.handleSettingsGet(w, r)
}))
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))
// M6: cluster nodes + per-node cached versions (UI + discovery).
mux.HandleFunc(apiPrefix+"/cluster/nodes", auth(h.handleClusterNodes))
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
// Not under /api so peers hit it directly; auth still applied.
mux.HandleFunc("/frpc/", auth(h.handleFrpcBinary))
// 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 ""
}
}
// proxyState is a per-proxy status row reported by the local frpc admin
// API (GET /api/status). Status is one of frpc's proxy phases:
// new | wait start | start error | running | check failed | closed.
type proxyState struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Err string `json:"err,omitempty"`
LocalAddr string `json:"local_addr"`
RemoteAddr string `json:"remote_addr"`
}
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"`
// ConnState is the derived frps connection state:
// connected | connecting | failed | not_started | disabled.
ConnState string `json:"connState"`
// AdminEnabled is true when the remote configures a local frpc admin
// API (adminPort > 0); then ProxyStates reflects real proxy health.
AdminEnabled bool `json:"adminEnabled"`
ProxyStates []proxyState `json:"proxyStates,omitempty"`
// GroupCount is the number of distinct LB groups across this remote's
// forwards (M3 display).
GroupCount int `json:"groupCount,omitempty"`
}
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)
p := profileStatus{
Name: rv.Name, Enabled: rv.Enabled, Status: st, HasProc: has, Forwards: fwd,
AdminEnabled: rv.AdminPort > 0,
GroupCount: groupCountOf(locals, fwd),
}
if rv.AdminPort > 0 {
// Query the local frpc admin API for true per-proxy state.
if states, err := fetchProxyStates(rv.AdminAddr, rv.AdminPort, rv.AdminUser, rv.AdminPassword); err == nil {
p.ProxyStates = states
}
}
// Prefer admin-derived conn state; fall back to log inference.
if p.AdminEnabled && len(p.ProxyStates) > 0 {
p.ConnState = connStateAdmin(p.ProxyStates)
} else {
p.ConnState = connStateOf(h.Process.LogPath(rv.Name), rv.Enabled, st)
}
profiles = append(profiles, p)
}
// 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)
}
// handleClusterNodes reports registered cluster nodes and their cached frpc
// versions (M6). Used by discovery and the frontend cluster page.
func (h *Handler) handleClusterNodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w)
return
}
if h.Cluster == nil {
http.Error(w, "cluster registry not enabled", http.StatusNotFound)
return
}
nodes := h.Cluster.NodeList()
type nodeResp struct {
Addr string `json:"addr"`
Cache []string `json:"cache,omitempty"`
Version string `json:"version,omitempty"`
}
out := make([]nodeResp, 0, len(nodes))
for _, n := range nodes {
out = append(out, nodeResp{Addr: n.Addr, Version: n.Version, Cache: n.Cache})
}
out = append(out, nodeResp{
Addr: "self",
Cache: h.Cluster.CachedVersions(),
Version: currentVersion(),
})
writeJSON(w, http.StatusOK, map[string]any{"nodes": out})
}
// handleFrpcBinary serves a cached frpc binary to peer nodes over
// GET /frpc/{version}. Basic Auth is required (same creds as the manager).
func (h *Handler) handleFrpcBinary(w http.ResponseWriter, r *http.Request) {
if h.Cluster == nil {
http.NotFound(w, r)
return
}
name := strings.TrimPrefix(r.URL.Path, "/frpc/")
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") {
http.Error(w, "bad version", http.StatusBadRequest)
return
}
path, checksum, err := h.Cluster.Provide(name)
if err != nil {
http.NotFound(w, r)
return
}
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Frpc-SHA256", checksum)
_, _ = io.Copy(w, f)
}
// currentVersion returns the app version for cluster node reporting.
func currentVersion() string {
return "0.1.0"
}
// connStateOf derives the frps connection state from the local worker process
// state plus a probe of its log. We never control the remote frps: the worker is
// our local frpc; "connected" means frpc has successfully logged in to frps.
// - disabled -> remote disabled, worker not started
// - running+login -> frpc logged in to frps (connected)
// - running -> frpc up but not yet logged in (connecting)
// - starting/restarting -> connecting
// - crashed/exit nonzero -> failed
// - stopped clean / no worker -> not_started
func connStateOf(workerLog string, enabled bool, st process.Status) string {
if !enabled {
return "disabled"
}
switch st.State {
case "running":
tail, _ := tailFile(workerLog, 64*1024)
if tail != "" {
// frpc prints "login to server success" once the control link is up.
if strings.Contains(tail, "login to server success") ||
strings.Contains(tail, "start proxy success") {
return "connected"
}
if strings.Contains(tail, "login to server error") ||
strings.Contains(tail, "connect to server error") ||
strings.Contains(tail, "connect server error") {
return "failed"
}
}
return "connecting"
case "starting", "restarting":
return "connecting"
case "crashed":
return "failed"
case "stopped":
if st.ExitCode != 0 || st.Err != "" {
return "failed"
}
return "not_started"
default:
return "not_started"
}
}
// fetchProxyStates queries a worker's local frpc admin API (webServer) for
// true per-proxy status: GET /api/status returns a map keyed by proxy type,
// each value a list of { name, type, status, err, local_addr, remote_addr }.
// status is one of frpc's proxy phases:
//
// new | wait start | start error | running | check failed | closed
func fetchProxyStates(addr string, port int, user, pass string) ([]proxyState, error) {
if addr == "" {
addr = "127.0.0.1"
}
url := fmt.Sprintf("http://%s:%d/api/status", addr, port)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(user, pass)
cli := &http.Client{Timeout: 3 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("admin status HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
var m map[string][]proxyState
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
var out []proxyState
for _, arr := range m {
out = append(out, arr...)
}
return out, nil
}
// connStateAdmin derives the overall worker connection state from the per-proxy
// states reported by the local frpc admin API.
func connStateAdmin(states []proxyState) string {
if len(states) == 0 {
return "connecting"
}
anyRunning := false
for _, s := range states {
switch s.Status {
case "running":
anyRunning = true
case "wait start":
return "connecting"
}
}
if anyRunning {
return "connected"
}
return "failed"
}
// groupCountOf returns the number of distinct non-empty LB groups a remote
// participates in, by mapping each forward's service back to its local.
func groupCountOf(locals []store.Local, fwd []store.Forward) int {
groupByLocal := map[string]string{}
for _, l := range locals {
if l.LBGroup != "" {
groupByLocal[l.Name] = l.LBGroup
}
}
seen := map[string]bool{}
for _, f := range fwd {
if g := groupByLocal[f.Service]; g != "" {
seen[g] = true
}
}
return len(seen)
}
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)
}