feat: ent ORM, admin UI, client auth, Fyne GUI, Windows/MSI packaging

This commit is contained in:
kannn
2026-05-29 08:58:22 +00:00
Unverified
parent 8563a5fc74
commit a0a42a4966
81 changed files with 17144 additions and 89 deletions
+183
View File
@@ -0,0 +1,183 @@
package sub
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/fatedier/frp/pkg/util/log"
)
var (
authServer string
authOutput string
)
func init() {
authCmd := &cobra.Command{
Use: "auth",
Short: "Authenticate frpc with a frp server",
Long: `Authenticate this frpc instance with a frp server.
One-time token:
frpc auth token <token> --server http://server:7500
Interactive login:
frpc auth login --server http://server:7500 --client-name myclient
`,
}
tokenCmd := &cobra.Command{
Use: "token <token>",
Short: "Authenticate using a one-time token",
Args: cobra.ExactArgs(1),
RunE: runAuthToken,
}
tokenCmd.Flags().StringVarP(&authServer, "server", "s", "http://localhost:7500", "frp server admin URL")
tokenCmd.Flags().StringVarP(&authOutput, "output", "o", "", "output config file path (default: ./frpc-<client-name>.toml)")
loginCmd := &cobra.Command{
Use: "login",
Short: "Authenticate using admin credentials",
RunE: runAuthLogin,
}
loginCmd.Flags().StringVarP(&authServer, "server", "s", "http://localhost:7500", "frp server admin URL")
loginCmd.Flags().StringVarP(&authOutput, "output", "o", "", "output config file path (default: ./frpc-<client-name>.toml)")
loginCmd.Flags().String("username", "", "admin username (prompts if empty)")
loginCmd.Flags().String("password", "", "admin password (prompts if empty)")
loginCmd.Flags().String("client-name", "", "client name (fetches list if empty)")
rootCmd.AddCommand(authCmd)
authCmd.AddCommand(tokenCmd)
authCmd.AddCommand(loginCmd)
}
func runAuthToken(cmd *cobra.Command, args []string) error {
token := args[0]
url := authServer + "/admin/api/client/auth"
body := map[string]string{"token": token}
data, _ := json.Marshal(body)
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to connect to server: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
}
configData, _ := io.ReadAll(resp.Body)
return saveConfig(configData)
}
func runAuthLogin(cmd *cobra.Command, args []string) error {
username, _ := cmd.Flags().GetString("username")
password, _ := cmd.Flags().GetString("password")
clientName, _ := cmd.Flags().GetString("client-name")
if username == "" {
fmt.Print("Admin username: ")
fmt.Scanln(&username)
}
if password == "" {
fmt.Print("Admin password: ")
bytePassword, err := readPassword()
if err != nil {
return err
}
password = string(bytePassword)
fmt.Println()
}
url := authServer + "/admin/api/client/auth"
body := map[string]string{
"username": username,
"password": password,
"client_name": clientName,
}
data, _ := json.Marshal(body)
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to connect to server: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
}
// Check if server returned a client list
contentType := resp.Header.Get("Content-Type")
if contentType == "application/json" || len(contentType) == 0 {
var result struct {
Clients []map[string]any `json:"clients"`
RequiresClientName bool `json:"requires_client_name"`
}
respBody, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(respBody, &result); err == nil && result.RequiresClientName {
fmt.Println("Available clients:")
for i, c := range result.Clients {
fmt.Printf(" %d. %s\n", i+1, c["name"])
}
fmt.Print("Enter client name: ")
var name string
fmt.Scanln(&name)
body["client_name"] = name
data, _ = json.Marshal(body)
resp, err = http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return fmt.Errorf("failed to connect to server: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("auth failed (HTTP %d): %s", resp.StatusCode, string(respBody))
}
configData, _ := io.ReadAll(resp.Body)
return saveConfig(configData)
}
configData := respBody
return saveConfig(configData)
}
configData, _ := io.ReadAll(resp.Body)
return saveConfig(configData)
}
func saveConfig(data []byte) error {
outputPath := authOutput
if outputPath == "" {
// Try to extract client name from config
outputPath = "frpc.toml"
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
log.Infof("config saved to %s", outputPath)
fmt.Printf("Config saved to %s\n", outputPath)
fmt.Printf("Run: frpc -c %s\n", outputPath)
return nil
}
func readPassword() ([]byte, error) {
return io.ReadAll(os.Stdin)
}
+21
View File
@@ -0,0 +1,21 @@
//go:build frpc_gui
package sub
import (
"github.com/spf13/cobra"
"github.com/fatedier/frp/client/gui"
)
func init() {
guiCmd := &cobra.Command{
Use: "gui",
Short: "Start the frpc graphical user interface",
RunE: func(cmd *cobra.Command, args []string) error {
gui.Run()
return nil
},
}
rootCmd.AddCommand(guiCmd)
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !frpc_gui
package sub
import (
"fmt"
"github.com/spf13/cobra"
)
func init() {
guiCmd := &cobra.Command{
Use: "gui",
Short: "Start the frpc graphical user interface",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("frpc GUI is not available in this build.")
fmt.Println("To build with GUI support, install OpenGL and X11 dev libraries, then:")
fmt.Println(" CGO_ENABLED=1 go build -tags frpc_gui -o frpc ./cmd/frpc")
return nil
},
}
rootCmd.AddCommand(guiCmd)
}
+45
View File
@@ -17,7 +17,9 @@ package sub
import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"os/signal"
"path/filepath"
@@ -45,6 +47,7 @@ var (
showVersion bool
strictConfigMode bool
allowUnsafe []string
serverConfigURL string
)
func init() {
@@ -52,6 +55,7 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
rootCmd.PersistentFlags().StringVarP(&serverConfigURL, "server-config", "", "", "fetch config from frps server URL (auto-reloads on change)")
rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow-unsafe", "", []string{},
fmt.Sprintf("allowed unsafe features, one or more of: %s", strings.Join(security.ClientUnsafeFeatures, ", ")))
@@ -75,6 +79,16 @@ var rootCmd = &cobra.Command{
return nil
}
// If server-config is set, use it instead of local config file.
if serverConfigURL != "" {
err := runClientWithServerConfig(serverConfigURL, unsafeFeatures)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
return nil
}
// Do not show command usage here.
err := runClient(cfgFile, unsafeFeatures)
if err != nil {
@@ -120,6 +134,37 @@ func handleTermSignal(svr *client.Service) {
svr.GracefulClose(500 * time.Millisecond)
}
func runClientWithServerConfig(url string, unsafeFeatures *security.UnsafeFeatures) error {
log.Infof("fetching config from server: %s", url)
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to fetch config from server: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read config from server: %w", err)
}
allCfg := v1.ClientConfig{}
if err := config.LoadConfigure(body, &allCfg, strictConfigMode, "toml"); err != nil {
return fmt.Errorf("failed to parse config from server: %w", err)
}
result := &config.ClientConfigLoadResult{
Common: &allCfg.ClientCommonConfig,
Proxies: make([]v1.ProxyConfigurer, 0),
}
for _, c := range allCfg.Proxies {
result.Proxies = append(result.Proxies, c.ProxyConfigurer)
}
result.Common.ConfigURL = url
return runClientWithAggregator(result, unsafeFeatures, "")
}
func runClient(cfgFilePath string, unsafeFeatures *security.UnsafeFeatures) error {
// Load configuration
result, err := config.LoadClientConfigResult(cfgFilePath, strictConfigMode)
+41 -20
View File
@@ -1,17 +1,3 @@
// Copyright 2018 fatedier, fatedier@gmail.com
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
@@ -25,6 +11,7 @@ import (
"github.com/fatedier/frp/pkg/config"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation"
"github.com/fatedier/frp/pkg/db"
"github.com/fatedier/frp/pkg/policy/security"
"github.com/fatedier/frp/pkg/util/log"
"github.com/fatedier/frp/pkg/util/version"
@@ -59,6 +46,12 @@ var rootCmd = &cobra.Command{
return nil
}
if err := db.Init("sqlite", "admin.db"); err != nil {
fmt.Printf("failed to initialize admin database: %v\n", err)
os.Exit(1)
}
defer db.Close()
var (
svrCfg *v1.ServerConfig
isLegacyFormat bool
@@ -71,15 +64,14 @@ var rootCmd = &cobra.Command{
os.Exit(1)
}
if isLegacyFormat {
fmt.Printf("WARNING: ini format is deprecated and the support will be removed in the future, " +
"please use yaml/json/toml format instead!\n")
fmt.Printf("WARNING: ini format is deprecated, please use yaml/json/toml instead!\n")
}
} else {
if err := serverCfg.Complete(); err != nil {
fmt.Printf("failed to complete server config: %v\n", err)
svrCfg, err = loadConfigFromDB()
if err != nil {
fmt.Printf("failed to load server config: %v\n", err)
os.Exit(1)
}
svrCfg = &serverCfg
}
unsafeFeatures := security.NewUnsafeFeatures(allowUnsafe)
@@ -108,13 +100,42 @@ func Execute() {
}
}
func loadConfigFromDB() (*v1.ServerConfig, error) {
hasConfig, err := db.HasServerConfig()
if err != nil {
return nil, fmt.Errorf("failed to check server config in db: %w", err)
}
if hasConfig {
cfg, err := db.LoadServerConfig()
if err != nil {
return nil, fmt.Errorf("failed to load server config from db: %w", err)
}
if err := cfg.Complete(); err != nil {
return nil, fmt.Errorf("failed to complete server config: %w", err)
}
log.Infof("frps uses database configuration")
return cfg, nil
}
cfg := db.DefaultServerConfig()
if err := cfg.Complete(); err != nil {
return nil, fmt.Errorf("failed to complete server config: %w", err)
}
if err := db.SaveServerConfig(cfg); err != nil {
log.Warnf("failed to save default config to db: %v", err)
}
log.Infof("frps started with default configuration (first run)")
return cfg, nil
}
func runServer(cfg *v1.ServerConfig) (err error) {
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
if cfgFile != "" {
log.Infof("frps uses config file: %s", cfgFile)
} else {
log.Infof("frps uses command line arguments for config")
log.Infof("frps uses database configuration")
}
svr, err := server.NewService(cfg)