feat: ent ORM, admin UI, client auth, Fyne GUI, Windows/MSI packaging
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
//go:build frpc_gui
|
||||
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/fatedier/frp/pkg/config"
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/util/log"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
fyneApp fyne.App
|
||||
window fyne.Window
|
||||
|
||||
serverURL *widget.Entry
|
||||
authToken *widget.Entry
|
||||
clientKey *widget.Entry
|
||||
statusLabel *widget.Label
|
||||
configView *widget.Entry
|
||||
proxyList *widget.List
|
||||
startBtn *widget.Button
|
||||
stopBtn *widget.Button
|
||||
connectBtn *widget.Button
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
configData string
|
||||
}
|
||||
|
||||
func New() *App {
|
||||
a := &App{}
|
||||
a.fyneApp = app.New()
|
||||
a.window = a.fyneApp.NewWindow("frpc GUI")
|
||||
a.window.Resize(fyne.NewSize(850, 620))
|
||||
a.window.SetMaster()
|
||||
a.setupUI()
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *App) saveSettings() {
|
||||
p := a.fyneApp.Preferences()
|
||||
p.SetString("server_url", a.serverURL.Text)
|
||||
}
|
||||
|
||||
func (a *App) loadSettings() {
|
||||
p := a.fyneApp.Preferences()
|
||||
if url := p.StringWithFallback("server_url", "http://localhost:7500"); url != "" {
|
||||
a.serverURL.SetText(url)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) setupUI() {
|
||||
a.serverURL = widget.NewEntry()
|
||||
a.serverURL.SetText("http://localhost:7500")
|
||||
a.serverURL.PlaceHolder = "http://frps-server:7500"
|
||||
|
||||
a.authToken = widget.NewEntry()
|
||||
a.authToken.PlaceHolder = "One-time token"
|
||||
|
||||
a.clientKey = widget.NewEntry()
|
||||
a.clientKey.PlaceHolder = "Client key (from server)"
|
||||
|
||||
a.statusLabel = widget.NewLabel("Not connected")
|
||||
a.statusLabel.Importance = widget.MediumImportance
|
||||
|
||||
a.configView = widget.NewMultiLineEntry()
|
||||
a.configView.SetMinRowsVisible(8)
|
||||
a.configView.Disable()
|
||||
|
||||
a.proxyList = widget.NewList(
|
||||
func() int { return 0 },
|
||||
func() fyne.CanvasObject { return widget.NewLabel("") },
|
||||
func(id widget.ListItemID, o fyne.CanvasObject) {},
|
||||
)
|
||||
|
||||
a.connectBtn = widget.NewButtonWithIcon("Connect", theme.NavigateNextIcon(), a.onConnect)
|
||||
a.startBtn = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), a.onStart)
|
||||
a.startBtn.Disable()
|
||||
a.stopBtn = widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), a.onStop)
|
||||
a.stopBtn.Disable()
|
||||
|
||||
// Settings form
|
||||
settingsForm := widget.NewForm(
|
||||
widget.NewFormItem("Server URL", a.serverURL),
|
||||
)
|
||||
settingsForm.OnSubmit = func() { a.saveSettings() }
|
||||
settingsBtn := widget.NewButtonWithIcon("Save Settings", theme.DocumentSaveIcon(), a.saveSettings)
|
||||
|
||||
settingsTab := container.NewVBox(
|
||||
widget.NewLabelWithStyle("Connection Settings", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsForm,
|
||||
settingsBtn,
|
||||
layout.NewSpacer(),
|
||||
)
|
||||
|
||||
// Auth form
|
||||
authForm := widget.NewForm(
|
||||
widget.NewFormItem("Server URL", a.serverURL),
|
||||
widget.NewFormItem("One-Time Token", a.authToken),
|
||||
widget.NewFormItem("Client Key", a.clientKey),
|
||||
)
|
||||
|
||||
statusLine := container.NewHBox(
|
||||
widget.NewLabelWithStyle("Status:", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
a.statusLabel,
|
||||
layout.NewSpacer(),
|
||||
a.connectBtn,
|
||||
)
|
||||
|
||||
controls := container.NewHBox(
|
||||
a.startBtn,
|
||||
a.stopBtn,
|
||||
)
|
||||
|
||||
connectTab := container.NewVBox(
|
||||
widget.NewLabelWithStyle("frp Client GUI", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
authForm,
|
||||
statusLine,
|
||||
controls,
|
||||
container.NewBorder(
|
||||
widget.NewLabelWithStyle("Proxies", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
nil, nil, nil,
|
||||
a.proxyList,
|
||||
),
|
||||
)
|
||||
|
||||
// Config tab
|
||||
configTab := container.NewBorder(
|
||||
widget.NewLabelWithStyle("Configuration (read-only)", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
nil, nil, nil,
|
||||
a.configView,
|
||||
)
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon("Connect", theme.ComputerIcon(), connectTab),
|
||||
container.NewTabItemWithIcon("Config", theme.DocumentIcon(), configTab),
|
||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsTab),
|
||||
)
|
||||
tabs.SelectIndex(0)
|
||||
|
||||
a.window.SetContent(tabs)
|
||||
a.loadSettings()
|
||||
}
|
||||
|
||||
func (a *App) onConnect() {
|
||||
url := strings.TrimRight(a.serverURL.Text, "/")
|
||||
token := a.authToken.Text
|
||||
key := a.clientKey.Text
|
||||
a.saveSettings()
|
||||
|
||||
if token == "" && key == "" {
|
||||
a.statusLabel.SetText("Error: provide token or client key")
|
||||
return
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
apiURL := url + "/admin/api/client/auth"
|
||||
body := fmt.Sprintf(`{"token":"%s"}`, token)
|
||||
resp, err := http.Post(apiURL, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
a.statusLabel.SetText(fmt.Sprintf("Auth failed (%d): %s", resp.StatusCode, string(respBody)))
|
||||
return
|
||||
}
|
||||
|
||||
configData, _ := io.ReadAll(resp.Body)
|
||||
a.configData = string(configData)
|
||||
a.configView.SetText(a.configData)
|
||||
a.statusLabel.SetText("Connected (token auth)")
|
||||
a.startBtn.Enable()
|
||||
a.connectBtn.SetText("Reconnect")
|
||||
} else if key != "" {
|
||||
apiURL := fmt.Sprintf("%s/admin/api/frpc/proxy-config/%s", url, key)
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
a.statusLabel.SetText(fmt.Sprintf("Error: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
a.statusLabel.SetText(fmt.Sprintf("Config fetch failed (%d)", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
configData, _ := io.ReadAll(resp.Body)
|
||||
a.configData = string(configData)
|
||||
a.configView.SetText(a.configData)
|
||||
a.statusLabel.SetText("Connected (key auth)")
|
||||
a.startBtn.Enable()
|
||||
a.connectBtn.SetText("Reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) onStart() {
|
||||
if a.configData == "" {
|
||||
a.statusLabel.SetText("Error: no config loaded")
|
||||
return
|
||||
}
|
||||
|
||||
var cfg v1.ClientConfig
|
||||
if err := config.LoadConfigure([]byte(a.configData), &cfg, false, "toml"); err != nil {
|
||||
dialog.ShowError(fmt.Errorf("invalid config: %w", err), a.window)
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.running = true
|
||||
a.mu.Unlock()
|
||||
|
||||
a.startBtn.Disable()
|
||||
a.stopBtn.Enable()
|
||||
a.statusLabel.SetText("Running")
|
||||
}
|
||||
|
||||
func (a *App) onStop() {
|
||||
a.mu.Lock()
|
||||
a.running = false
|
||||
a.mu.Unlock()
|
||||
|
||||
a.startBtn.Enable()
|
||||
a.stopBtn.Disable()
|
||||
a.statusLabel.SetText("Stopped")
|
||||
}
|
||||
|
||||
func (a *App) Run() {
|
||||
a.window.ShowAndRun()
|
||||
}
|
||||
|
||||
func Run() {
|
||||
gui := New()
|
||||
log.Infof("starting frpc GUI")
|
||||
gui.Run()
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -146,6 +147,9 @@ type Service struct {
|
||||
// string if no configuration file was used.
|
||||
configFilePath string
|
||||
|
||||
// configURL is the URL to fetch configuration from on startup and periodically.
|
||||
configURL string
|
||||
|
||||
// service context
|
||||
ctx context.Context
|
||||
// call cancel to stop service
|
||||
@@ -199,6 +203,7 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
common: options.Common,
|
||||
reloadCommon: options.Common,
|
||||
configFilePath: options.ConfigFilePath,
|
||||
configURL: options.Common.ConfigURL,
|
||||
unsafeFeatures: options.UnsafeFeatures,
|
||||
proxyCfgs: proxyCfgs,
|
||||
visitorCfgs: visitorCfgs,
|
||||
@@ -265,6 +270,10 @@ func (svr *Service) Run(ctx context.Context) error {
|
||||
|
||||
go svr.keepControllerWorking()
|
||||
|
||||
if svr.configURL != "" {
|
||||
go svr.pollConfigURL()
|
||||
}
|
||||
|
||||
<-svr.ctx.Done()
|
||||
svr.stop()
|
||||
return nil
|
||||
@@ -513,3 +522,74 @@ func (svr *Service) reloadConfigFromSourcesLocked() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (svr *Service) pollConfigURL() {
|
||||
url := svr.configURL
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("starting config URL poller: %s", url)
|
||||
lastBody := ""
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-svr.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
log.Warnf("failed to fetch config from %s: %v", url, err)
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
log.Warnf("failed to read config from %s: %v", url, err)
|
||||
continue
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
if bodyStr == lastBody {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Infof("config changed, reloading from %s", url)
|
||||
|
||||
allCfg := v1.ClientConfig{}
|
||||
if err := config.LoadConfigure(body, &allCfg, false, "toml"); err != nil {
|
||||
log.Warnf("failed to parse config from %s: %v", url, err)
|
||||
continue
|
||||
}
|
||||
|
||||
proxyCfgs := make([]v1.ProxyConfigurer, 0)
|
||||
for _, c := range allCfg.Proxies {
|
||||
proxyCfgs = append(proxyCfgs, c.ProxyConfigurer)
|
||||
}
|
||||
visitorCfgs := make([]v1.VisitorConfigurer, 0)
|
||||
for _, c := range allCfg.Visitors {
|
||||
visitorCfgs = append(visitorCfgs, c.VisitorConfigurer)
|
||||
}
|
||||
|
||||
// Update common config for new proxies
|
||||
if allCfg.ClientCommonConfig.Log.To != "" {
|
||||
svr.cfgMu.Lock()
|
||||
svr.reloadCommon = &allCfg.ClientCommonConfig
|
||||
svr.cfgMu.Unlock()
|
||||
}
|
||||
|
||||
if err := svr.UpdateConfigSource(&allCfg.ClientCommonConfig, proxyCfgs, visitorCfgs); err != nil {
|
||||
log.Warnf("failed to apply config from %s: %v", url, err)
|
||||
continue
|
||||
}
|
||||
|
||||
lastBody = bodyStr
|
||||
log.Infof("config reloaded successfully from %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user