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