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" "kanhole/pkg/db" "kanhole/pkg/db/ent" "kanhole/pkg/db/ent/frpcclient" v1 "kanhole/pkg/config/v1" "kanhole/pkg/db/ent/proxy" "kanhole/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://kanhole/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(`# kanholec config for %s # Auto-generated by kanhole 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 # kanhole 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://kanhole/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

`, token, token, r.Host) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "token": token, "expires": expires, }) } func (h *Handler) handleClientAuth(w http.ResponseWriter, r *http.Request) { var req struct { Token string `json:"token"` Username string `json:"username"` Password string `json:"password"` ClientName string `json:"client_name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } ctx := context.Background() // One-time token auth if req.Token != "" { all, err := db.Client().FrpcClient.Query().All(ctx) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } for _, c := range all { meta := map[string]any{} if c.Metadata != "" && c.Metadata != "{}" { json.Unmarshal([]byte(c.Metadata), &meta) } storedToken, _ := meta["one_time_token"].(string) if storedToken == "" || storedToken != req.Token { continue } expires, _ := meta["one_time_token_expires"].(float64) if time.Now().Unix() > int64(expires) { http.Error(w, "token expired", http.StatusUnauthorized) return } // Clear the one-time token (one-time use) delete(meta, "one_time_token") delete(meta, "one_time_token_expires") metaBytes, _ := json.Marshal(meta) db.Client().FrpcClient.Update().SetMetadata(string(metaBytes)).Where(frpcclient.ID(c.ID)).Exec(ctx) h.writeClientConfig(w, ctx, c.ID) return } http.Error(w, "invalid token", http.StatusUnauthorized) return } // Login auth if req.Username != "" && req.Password != "" { u, err := db.Client().User.Query().Where(user.UsernameEQ(req.Username)).Only(ctx) if err != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } if req.ClientName == "" { // Return list of client names all, _ := db.Client().FrpcClient.Query().Select(frpcclient.FieldName, frpcclient.FieldID).All(ctx) names := make([]map[string]any, 0, len(all)) for _, c := range all { names = append(names, map[string]any{"id": c.ID, "name": c.Name}) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"clients": names, "requires_client_name": true}) return } client, err := db.Client().FrpcClient.Query().Where(frpcclient.NameEQ(req.ClientName)).Only(ctx) if err != nil { http.Error(w, "client not found", http.StatusNotFound) return } h.writeClientConfig(w, ctx, client.ID) return } http.Error(w, "provide token or username/password", http.StatusBadRequest) } func (h *Handler) writeClientConfig(w http.ResponseWriter, ctx context.Context, clientID int) { c, err := db.Client().FrpcClient.Get(ctx, clientID) if err != nil { http.Error(w, "client not found", http.StatusNotFound) return } cfg, err := db.LoadServerConfig() if err != nil { http.Error(w, "server config error", http.StatusInternalServerError) return } proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(c.ID)).All(ctx) content := fmt.Sprintf(`# kanholec config for %s # Auto-generated by kanhole admin server 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.Write([]byte(content)) } func (h *Handler) handleFrpcProxyConfig(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) key := vars["key"] if key == "" { http.Error(w, "missing key", http.StatusBadRequest) return } ctx := context.Background() client, err := db.Client().FrpcClient.Query().Where(frpcclient.KeyEQ(key)).Only(ctx) if err != nil { http.Error(w, "invalid key", http.StatusNotFound) return } cfg, err := db.LoadServerConfig() if err != nil { http.Error(w, "server config error", http.StatusInternalServerError) return } proxies, _ := db.Client().Proxy.Query().Where(proxy.ClientIDEQ(client.ID)).All(ctx) content := fmt.Sprintf(`# kanholec config for %s # Auto-generated by kanhole admin server serverAddr = %q serverPort = %d auth.token = %q `, client.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.Write([]byte(content)) } func (h *Handler) handleNewProxy(w http.ResponseWriter, r *http.Request) { ctx := context.Background() allClients, err := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } selectedClient, _ := strconv.Atoi(r.URL.Query().Get("client_id")) renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{ "Clients": allClients, "SelectedClient": selectedClient, }) } func (h *Handler) handleNewProxyPost(w http.ResponseWriter, r *http.Request) { r.ParseForm() ctx := context.Background() name := r.FormValue("name") proxyType := r.FormValue("proxy_type") localIP := r.FormValue("local_ip") localPortS := r.FormValue("local_port") remotePortS := r.FormValue("remote_port") clientIDS := r.FormValue("client_id") customDomains := r.FormValue("custom_domains") if name == "" || localIP == "" || localPortS == "" || clientIDS == "" { allClients, _ := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx) renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{ "Clients": allClients, "Error": "Name, local IP, local port, and client are required.", }) return } localPort := atoi(localPortS, 0) remotePort := atoi(remotePortS, 0) clientID := atoi(clientIDS, 0) if localPort == 0 { allClients, _ := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx) renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{ "Clients": allClients, "Error": "Invalid local port.", }) return } if proxyType == "" { proxyType = "tcp" } _, err := db.Client().Proxy.Create(). SetName(name). SetProxyType(proxyType). SetLocalIP(localIP). SetLocalPort(localPort). SetRemotePort(remotePort). SetClientID(clientID). SetCustomDomains(customDomains). SetStatus("inactive"). SetMetadata("{}"). Save(ctx) if err != nil { allClients, _ := db.Client().FrpcClient.Query().Order(ent.Asc(frpcclient.FieldName)).All(ctx) renderPage(w, r, "New Proxy", "proxy_new.html", map[string]any{ "Clients": allClients, "Error": "Failed to create proxy: " + err.Error(), }) return } http.Redirect(w, r, "/admin/proxies", http.StatusFound) } func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { cfg, err := db.LoadServerConfig() if err != nil { http.Error(w, "failed to load config", http.StatusInternalServerError) return } renderPage(w, r, "Settings", "settings.html", map[string]any{ "Config": cfg, "Saved": r.URL.Query().Get("saved") == "1", }) } func (h *Handler) handleSettingsSave(w http.ResponseWriter, r *http.Request) { r.ParseForm() cfg, err := db.LoadServerConfig() if err != nil { http.Error(w, "failed to load config", http.StatusInternalServerError) return } 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("dashboard_user"); v != "" { cfg.WebServer.User = v } if v := r.FormValue("dashboard_password"); v != "" { cfg.WebServer.Password = v } if v := r.FormValue("auth_token"); v != "" { cfg.Auth.Token = v } if v := r.FormValue("auth_method"); v != "" { cfg.Auth.Method = v1.AuthMethod(v) } if v := r.FormValue("log_to"); v != "" { cfg.Log.To = v } if v := r.FormValue("log_level"); v != "" { cfg.Log.Level = v } if v := r.FormValue("log_max_days"); v != "" { cfg.Log.MaxDays = int64(atoi(v, 3)) } if v := r.FormValue("kcp_bind_port"); v != "" { cfg.KCPBindPort = atoi(v, 0) } if v := r.FormValue("quic_bind_port"); v != "" { cfg.QUICBindPort = atoi(v, 0) } if v := r.FormValue("vhost_http_port"); v != "" { cfg.VhostHTTPPort = atoi(v, 0) } if v := r.FormValue("vhost_https_port"); v != "" { cfg.VhostHTTPSPort = atoi(v, 0) } if v := r.FormValue("subdomain_host"); v != "" { cfg.SubDomainHost = v } if r.FormValue("tcp_mux") == "true" { val := true cfg.Transport.TCPMux = &val } else if r.Form.Has("tcp_mux") { val := false cfg.Transport.TCPMux = &val } if v := r.FormValue("max_pool_count"); v != "" { cfg.Transport.MaxPoolCount = int64(atoi(v, 0)) } if v := r.FormValue("proxy_bind_addr"); v != "" { cfg.ProxyBindAddr = v } cfg.EnablePrometheus = r.FormValue("enable_prometheus") == "true" if err := db.SaveServerConfig(cfg); err != nil { http.Error(w, "failed to save config: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/admin/settings?saved=1", http.StatusFound) } func atoi(s string, defaultVal int) int { v := 0 fmt.Sscanf(s, "%d", &v) if v == 0 { return defaultVal } return v } func renderPage(w http.ResponseWriter, r *http.Request, title, tmplName string, data map[string]any) { content := renderToString(tmplName, data) w.Header().Set("Content-Type", "text/html; charset=utf-8") if r.Header.Get("HX-Request") == "true" { w.Write([]byte(content)) } else { if err := renderLayout(w, title, template.HTML(content)); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } }