package admin import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "html/template" "net/http" "strconv" "sync" "time" "github.com/gorilla/mux" "golang.org/x/crypto/bcrypt" "github.com/fatedier/frp/pkg/db" "github.com/fatedier/frp/pkg/db/ent" "github.com/fatedier/frp/pkg/db/ent/frpcclient" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/db/ent/proxy" "github.com/fatedier/frp/pkg/db/ent/user" ) type sessionInfo struct { UserID int Username string Expires time.Time } var ( sessionsMu sync.RWMutex sessions = map[string]*sessionInfo{} ) const sessionCookieName = "frp_admin_session" const sessionMaxAge = 24 * time.Hour func generateSessionID() string { b := make([]byte, 32) rand.Read(b) return hex.EncodeToString(b) } func getSession(r *http.Request) *sessionInfo { c, err := r.Cookie(sessionCookieName) if err != nil { return nil } sessionsMu.RLock() s := sessions[c.Value] sessionsMu.RUnlock() if s == nil || time.Now().After(s.Expires) { if s != nil { sessionsMu.Lock() delete(sessions, c.Value) sessionsMu.Unlock() } return nil } return s } func authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s := getSession(r) if s == nil { http.Redirect(w, r, "/admin/login", http.StatusFound) return } next.ServeHTTP(w, r) }) } type Handler struct{} func NewHandler() *Handler { return &Handler{} } func (h *Handler) RegisterRoutes(r *mux.Router) { s := r.PathPrefix("/admin").Subrouter() s.HandleFunc("/setup", h.handleSetup).Methods("GET") s.HandleFunc("/setup", h.handleSetupPost).Methods("POST") s.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/setup", http.StatusFound) }) s.HandleFunc("/login", h.handleLogin).Methods("GET") s.HandleFunc("/login", h.handleLoginPost).Methods("POST") s.HandleFunc("/logout", h.handleLogout).Methods("POST") // Frpc config API — key-based auth, no session required s.HandleFunc("/api/frpc/proxy-config/{key}", h.handleFrpcProxyConfig).Methods("GET") p := s.NewRoute().Subrouter() p.Use(func(next http.Handler) http.Handler { return authMiddleware(next) }) // Client auth API — no session required s.HandleFunc("/api/client/auth", h.handleClientAuth).Methods("POST") p.HandleFunc("/dashboard", h.handleDashboard).Methods("GET") p.HandleFunc("/clients", h.handleClients).Methods("GET") p.HandleFunc("/clients/new", h.handleNewClient).Methods("GET") p.HandleFunc("/clients/new", h.handleNewClientPost).Methods("POST") p.HandleFunc("/clients/{id:[0-9]+}", h.handleClientDetail).Methods("GET") p.HandleFunc("/clients/{id:[0-9]+}/provision", h.handleClientProvision).Methods("GET") p.HandleFunc("/clients/{id:[0-9]+}/config", h.handleClientConfigDownload).Methods("GET") p.HandleFunc("/clients/{id:[0-9]+}/generate-token", h.handleClientGenerateToken).Methods("POST") p.HandleFunc("/proxies", h.handleProxies).Methods("GET") p.HandleFunc("/proxies/new", h.handleNewProxy).Methods("GET") p.HandleFunc("/proxies/new", h.handleNewProxyPost).Methods("POST") p.HandleFunc("/proxies/{id:[0-9]+}", h.handleProxyDetail).Methods("GET") p.HandleFunc("/settings", h.handleSettings).Methods("GET") p.HandleFunc("/settings", h.handleSettingsSave).Methods("POST") } func (h *Handler) handleSetup(w http.ResponseWriter, r *http.Request) { if db.HasAdmin() { http.Redirect(w, r, "/admin/login", http.StatusFound) return } renderTemplate(w, "setup.html", map[string]any{ "Error": "", }) } func (h *Handler) handleSetupPost(w http.ResponseWriter, r *http.Request) { if db.HasAdmin() { http.Redirect(w, r, "/admin/login", http.StatusFound) return } r.ParseForm() username := r.FormValue("username") password := r.FormValue("password") confirmPassword := r.FormValue("confirm_password") name := r.FormValue("name") if username == "" || password == "" { renderTemplate(w, "setup.html", map[string]any{ "Error": "Username and password are required.", }) return } if password != confirmPassword { renderTemplate(w, "setup.html", map[string]any{ "Error": "Passwords do not match.", }) return } if name == "" { name = username } if err := db.CreateAdmin(username, password, name); err != nil { renderTemplate(w, "setup.html", map[string]any{ "Error": "Failed to create admin user: " + err.Error(), }) return } cfg := db.DefaultServerConfig() if v := r.FormValue("bind_addr"); v != "" { cfg.BindAddr = v } if v := r.FormValue("bind_port"); v != "" { cfg.BindPort = atoi(v, 7000) } if v := r.FormValue("dashboard_addr"); v != "" { cfg.WebServer.Addr = v } if v := r.FormValue("dashboard_port"); v != "" { cfg.WebServer.Port = atoi(v, 7500) } if v := r.FormValue("auth_token"); v != "" { cfg.Auth.Token = v } if err := db.SaveServerConfig(cfg); err != nil { renderTemplate(w, "setup.html", map[string]any{ "Error": "Failed to save configuration: " + err.Error(), }) return } http.Redirect(w, r, "/admin/login", http.StatusFound) } func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { if !db.HasAdmin() { http.Redirect(w, r, "/admin/setup", http.StatusFound) return } if getSession(r) != nil { http.Redirect(w, r, "/admin/dashboard", http.StatusFound) return } renderTemplate(w, "login.html", map[string]any{ "Error": "", }) } func (h *Handler) handleLoginPost(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") if username == "" || password == "" { renderTemplate(w, "login.html", map[string]any{ "Error": "Username and password are required.", }) return } ctx := context.Background() u, err := db.Client().User.Query().Where(user.UsernameEQ(username)).Only(ctx) if err != nil { renderTemplate(w, "login.html", map[string]any{ "Error": "Invalid username or password.", }) return } if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil { renderTemplate(w, "login.html", map[string]any{ "Error": "Invalid username or password.", }) return } sessionID := generateSessionID() sessionsMu.Lock() sessions[sessionID] = &sessionInfo{ UserID: u.ID, Username: u.Username, Expires: time.Now().Add(sessionMaxAge), } sessionsMu.Unlock() http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sessionID, Path: "/admin", HttpOnly: true, MaxAge: int(sessionMaxAge.Seconds()), SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/admin/dashboard", http.StatusFound) } func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { c, err := r.Cookie(sessionCookieName) if err == nil { sessionsMu.Lock() delete(sessions, c.Value) sessionsMu.Unlock() } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/admin", MaxAge: -1, HttpOnly: true, }) http.Redirect(w, r, "/admin/login", http.StatusFound) } func (h *Handler) handleDashboard(w http.ResponseWriter, r *http.Request) { ctx := context.Background() c := db.Client() totalClients, _ := c.FrpcClient.Query().Count(ctx) onlineClients, _ := c.FrpcClient.Query().Where(frpcclient.StatusEQ("online")).Count(ctx) offlineClients, _ := c.FrpcClient.Query().Where(frpcclient.StatusEQ("offline")).Count(ctx) totalUsers, _ := c.User.Query().Count(ctx) activeProxies, _ := c.Proxy.Query().Where(proxy.StatusEQ("active")).Count(ctx) allProxies, _ := c.Proxy.Query().All(ctx) typeCounts := map[string]int{} for _, p := range allProxies { typeCounts[p.ProxyType]++ } breakdown := make([]proxyBreakdown, 0, len(typeCounts)) for t, n := range typeCounts { breakdown = append(breakdown, proxyBreakdown{Type: t, Count: n}) } renderPage(w, r, "Dashboard", "dashboard.html", map[string]any{ "ActiveProxies": activeProxies, "TotalClients": totalClients, "TotalUsers": totalUsers, "Uptime": "—", "OnlineClients": onlineClients, "OfflineClients": offlineClients, "ProxyBreakdown": breakdown, }) } type proxyBreakdown struct { Type string Count int } type clientRow struct { ID int Name string Addr string Port int Key string Status string LastSeen string } func (h *Handler) handleClients(w http.ResponseWriter, r *http.Request) { ctx := context.Background() all, err := db.Client().FrpcClient.Query().Order(ent.Desc(frpcclient.FieldCreatedAt)).All(ctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rows := make([]clientRow, 0, len(all)) for _, c := range all { lastSeen := "" if !c.LastSeen.IsZero() { lastSeen = c.LastSeen.Format("2006-01-02 15:04") } rows = append(rows, clientRow{ ID: c.ID, Name: c.Name, Addr: c.Addr, Port: c.Port, Key: c.Key, Status: c.Status, LastSeen: lastSeen, }) } renderPage(w, r, "Clients", "clients.html", map[string]any{"Clients": rows}) } func (h *Handler) handleClientDetail(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } ctx := context.Background() c, err := db.Client().FrpcClient.Get(ctx, id) if err != nil { http.Error(w, "client not found", http.StatusNotFound) return } proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(c.ID)).All(ctx) type proxyRow struct { ID int Name string ProxyType string LocalIP string LocalPort int RemotePort int Status string } prows := make([]proxyRow, 0, len(proxies)) for _, p := range proxies { prows = append(prows, proxyRow{ ID: p.ID, Name: p.Name, ProxyType: p.ProxyType, LocalIP: p.LocalIP, LocalPort: p.LocalPort, RemotePort: p.RemotePort, Status: p.Status, }) } lastSeen := "" if !c.LastSeen.IsZero() { lastSeen = c.LastSeen.Format("2006-01-02 15:04") } renderPage(w, r, "Client: "+c.Name, "client_detail.html", map[string]any{ "Client": map[string]any{ "ID": c.ID, "Name": c.Name, "Addr": c.Addr, "Port": c.Port, "Key": c.Key, "Status": c.Status, "CreatedAt": c.CreatedAt.Format("2006-01-02 15:04"), "LastSeen": lastSeen, "Metadata": c.Metadata, }, "Proxies": prows, }) } type proxyRow struct { ID int Name string ProxyType string ClientName string LocalIP string LocalPort int RemotePort int Status string } func (h *Handler) handleProxies(w http.ResponseWriter, r *http.Request) { ctx := context.Background() all, err := db.Client().Proxy.Query().Order(ent.Desc(proxy.FieldCreatedAt)).All(ctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rows := make([]proxyRow, 0, len(all)) for _, p := range all { clientName := "" if p.ClientID > 0 { c, err := db.Client().FrpcClient.Get(ctx, p.ClientID) if err == nil { clientName = c.Name } } rows = append(rows, proxyRow{ ID: p.ID, Name: p.Name, ProxyType: p.ProxyType, ClientName: clientName, LocalIP: p.LocalIP, LocalPort: p.LocalPort, RemotePort: p.RemotePort, Status: p.Status, }) } renderPage(w, r, "Proxies", "proxies.html", map[string]any{"Proxies": rows}) } func (h *Handler) handleProxyDetail(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } ctx := context.Background() p, err := db.Client().Proxy.Get(ctx, id) if err != nil { http.Error(w, "proxy not found", http.StatusNotFound) return } clientName := "" if p.ClientID > 0 { c, err := db.Client().FrpcClient.Get(ctx, p.ClientID) if err == nil { clientName = c.Name } } renderPage(w, r, "Proxy: "+p.Name, "proxy_detail.html", map[string]any{ "Proxy": map[string]any{ "ID": p.ID, "Name": p.Name, "ProxyType": p.ProxyType, "LocalIP": p.LocalIP, "LocalPort": p.LocalPort, "RemotePort": p.RemotePort, "Status": p.Status, "CustomDomains": p.CustomDomains, "ClientID": p.ClientID, "CreatedAt": p.CreatedAt.Format("2006-01-02 15:04"), "Metadata": p.Metadata, }, "ClientName": clientName, }) } func generateClientKey() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } func (h *Handler) handleNewClient(w http.ResponseWriter, r *http.Request) { renderPage(w, r, "New Client", "client_new.html", nil) } func (h *Handler) handleNewClientPost(w http.ResponseWriter, r *http.Request) { r.ParseForm() name := r.FormValue("name") if name == "" { renderPage(w, r, "New Client", "client_new.html", map[string]any{ "Error": "Client name is required.", }) return } ctx := context.Background() key := generateClientKey() c, err := db.Client().FrpcClient.Create(). SetName(name). SetKey(key). SetStatus("offline"). SetMetadata("{}"). Save(ctx) if err != nil { if ent.IsConstraintError(err) { renderPage(w, r, "New Client", "client_new.html", map[string]any{ "Error": "A client with this name already exists.", }) return } renderPage(w, r, "New Client", "client_new.html", map[string]any{ "Error": "Failed to create client: " + err.Error(), }) return } http.Redirect(w, r, fmt.Sprintf("/admin/clients/%d/provision", c.ID), http.StatusFound) } func (h *Handler) handleClientProvision(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } ctx := context.Background() c, err := db.Client().FrpcClient.Get(ctx, id) if err != nil { http.Error(w, "client not found", http.StatusNotFound) return } cfg, err := db.LoadServerConfig() if err != nil { http.Error(w, "failed to load config", http.StatusInternalServerError) return } serverAddr := cfg.BindAddr serverPort := cfg.BindPort authToken := cfg.Auth.Token frpcConfig := fmt.Sprintf(`serverAddr = %q serverPort = %d auth.token = %q `, serverAddr, serverPort, authToken) oneLiner := fmt.Sprintf(`curl -sL https://github.com/fatedier/frp/releases/latest/download/frp_linux_amd64.tar.gz | tar xz && cat > frpc.toml << 'EOF' %s EOF ./frp_*/frpc -c frpc.toml`, frpcConfig) renderPage(w, r, "Provision: "+c.Name, "client_provision.html", map[string]any{ "Client": map[string]any{ "ID": c.ID, "Name": c.Name, "Key": c.Key, }, "ServerAddr": serverAddr, "ServerPort": serverPort, "AuthToken": authToken, "FrpcConfig": frpcConfig, "OneLiner": oneLiner, }) } func (h *Handler) handleClientConfigDownload(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } ctx := context.Background() c, err := db.Client().FrpcClient.Get(ctx, id) if err != nil { http.Error(w, "client not found", http.StatusNotFound) return } cfg, err := db.LoadServerConfig() if err != nil { http.Error(w, "failed to load config", http.StatusInternalServerError) return } proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(c.ID)).All(ctx) content := fmt.Sprintf(`# frpc config for %s # Auto-generated by frp admin dashboard serverAddr = %q serverPort = %d auth.token = %q `, c.Name, cfg.BindAddr, cfg.BindPort, cfg.Auth.Token) for _, p := range proxies { content += fmt.Sprintf(` [[proxies]] name = %q type = %q localIP = %q localPort = %d remotePort = %d `, p.Name, p.ProxyType, p.LocalIP, p.LocalPort, p.RemotePort) if p.CustomDomains != "" { content += fmt.Sprintf("customDomains = [%q]\n", p.CustomDomains) } } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=frpc-%s.toml", c.Name)) w.Write([]byte(content)) } func (h *Handler) handleClientProvisionScript(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } ctx := context.Background() c, err := db.Client().FrpcClient.Get(ctx, id) if err != nil { http.Error(w, "client not found", http.StatusNotFound) return } key := c.Key serverHost := r.Host script := fmt.Sprintf(`#!/bin/bash # frp auto-provisioning for: %s set -e CONFIG_URL="http://%s/admin/api/frpc/proxy-config/%s" echo "==> Downloading frpc with server-config support..." curl -sL https://github.com/fatedier/frp/releases/latest/download/frp_linux_amd64.tar.gz | tar xz FRPC_DIR=$(ls -d frp_* 2>/dev/null | head -1) echo "==> Starting frpc for %s..." "$FRPC_DIR/frpc" --server-config "$CONFIG_URL" `, c.Name, serverHost, key, c.Name) w.Header().Set("Content-Type", "text/x-shellscript; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=provision-%s.sh", c.Name)) w.Write([]byte(script)) } func (h *Handler) handleClientGenerateToken(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } ctx := context.Background() c, err := db.Client().FrpcClient.Get(ctx, id) if err != nil { http.Error(w, "client not found", http.StatusNotFound) return } tokenBytes := make([]byte, 32) rand.Read(tokenBytes) token := hex.EncodeToString(tokenBytes) expires := time.Now().Add(30 * time.Minute).Unix() meta := map[string]any{} if c.Metadata != "" && c.Metadata != "{}" { json.Unmarshal([]byte(c.Metadata), &meta) } meta["one_time_token"] = token meta["one_time_token_expires"] = expires metaBytes, _ := json.Marshal(meta) db.Client().FrpcClient.Update().SetMetadata(string(metaBytes)).Where(frpcclient.ID(id)).Exec(ctx) if r.Header.Get("HX-Request") == "true" { html := fmt.Sprintf(`
One-Time Token:
%s
Valid for 30 minutes. Use with: frpc auth token %s --server http://%s