[优化] 增加 FRPS WebUI 支持,添加相关配置和数据库迁移

This commit is contained in:
ryan
2026-06-01 21:59:42 +08:00
parent d6e7e2baa2
commit fefe205158
14 changed files with 352 additions and 42 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ func main() {
stateStore := state.NewStore(cfg.StatePath)
_ = stateStore // In the future we may use stateStore for auth caching
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir)
frpsManager := frps.NewManager(cfg.FrpsPath, cfg.DataDir, cfg.InitialAuthToken())
slog.Info("detected frps version", "version", frpsManager.GetVersion())
+155 -8
View File
@@ -3,8 +3,10 @@ package frps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -19,6 +21,7 @@ type Manager struct {
frpsPath string
dataDir string
configPath string
agentToken string
mu sync.RWMutex
activeConfig *service.RelayConfig
@@ -34,15 +37,18 @@ type RuntimeStatus struct {
LastError string
Connections int
ProxyCount int
ClientCount int
Proxies []service.RelayProxyStat
ProcessAlive bool
}
func NewManager(frpsPath string, dataDir string) *Manager {
func NewManager(frpsPath string, dataDir string, agentToken string) *Manager {
return &Manager{
frpsPath: frpsPath,
dataDir: dataDir,
configPath: filepath.Join(dataDir, "frps.toml"),
status: "unhealthy",
agentToken: agentToken,
}
}
@@ -65,15 +71,139 @@ func (m *Manager) GetStatus() string {
return m.status
}
type frpsServerInfo struct {
Version string `json:"version"`
BindPort int `json:"bind_port"`
CurConns int `json:"cur_conns"`
ClientCounts int `json:"client_counts"`
ProxyTypeCount map[string]int `json:"proxy_type_count"`
}
type frpsProxyInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
ClientAddr string `json:"client_addr"`
LastStartTime string `json:"last_start_time"`
LastCloseTime string `json:"last_close_time"`
}
type frpsProxiesResponse struct {
Proxies []frpsProxyInfo `json:"proxies"`
}
func (m *Manager) fetchFrpsTelemetry() (connections int, proxyCount int, clientCount int, proxies []service.RelayProxyStat, err error) {
m.mu.RLock()
activeConfig := m.activeConfig
m.mu.RUnlock()
if activeConfig == nil {
return 0, 0, 0, nil, fmt.Errorf("activeConfig is nil")
}
dashboardPort := activeConfig.BindPort + 500
client := &http.Client{
Timeout: 2 * time.Second,
}
// 1. Fetch serverinfo
infoURL := fmt.Sprintf("http://127.0.0.1:%d/api/serverinfo", dashboardPort)
req, err := http.NewRequest(http.MethodGet, infoURL, nil)
if err != nil {
return 0, 0, 0, nil, err
}
password := m.agentToken
if password == "" {
password = "admin"
}
req.SetBasicAuth("admin", password)
resp, err := client.Do(req)
if err != nil {
return 0, 0, 0, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, 0, 0, nil, fmt.Errorf("serverinfo returned status %d", resp.StatusCode)
}
var info frpsServerInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return 0, 0, 0, nil, err
}
connections = info.CurConns
clientCount = info.ClientCounts
totalProxies := 0
for _, count := range info.ProxyTypeCount {
totalProxies += count
}
proxyCount = totalProxies
// 2. Fetch HTTP proxies list
proxiesURL := fmt.Sprintf("http://127.0.0.1:%d/api/proxy/http", dashboardPort)
req2, err := http.NewRequest(http.MethodGet, proxiesURL, nil)
if err != nil {
return connections, proxyCount, clientCount, nil, nil
}
req2.SetBasicAuth("admin", password)
resp2, err := client.Do(req2)
if err != nil {
return connections, proxyCount, clientCount, nil, nil
}
defer resp2.Body.Close()
if resp2.StatusCode == http.StatusOK {
var listResp frpsProxiesResponse
if err := json.NewDecoder(resp2.Body).Decode(&listResp); err == nil {
for _, p := range listResp.Proxies {
proxies = append(proxies, service.RelayProxyStat{
Name: p.Name,
Type: p.Type,
Status: p.Status,
ClientAddr: p.ClientAddr,
LastStartTime: p.LastStartTime,
LastCloseTime: p.LastCloseTime,
})
}
}
}
return connections, proxyCount, clientCount, proxies, nil
}
func (m *Manager) GetRuntimeStatus() RuntimeStatus {
m.mu.RLock()
defer m.mu.RUnlock()
status := m.status
lastError := m.lastError
cmd := m.cmd
m.mu.RUnlock()
connections := 0
proxyCount := 0
clientCount := 0
var proxies []service.RelayProxyStat
if cmd != nil && cmd.Process != nil && status == "healthy" {
if conns, pCount, cCount, pxs, err := m.fetchFrpsTelemetry(); err == nil {
connections = conns
proxyCount = pCount
clientCount = cCount
proxies = pxs
} else {
slog.Debug("failed to fetch frps telemetry", "error", err)
}
}
return RuntimeStatus{
Status: m.status,
LastError: m.lastError,
Connections: 0,
ProxyCount: 0,
ProcessAlive: m.cmd != nil && m.cmd.Process != nil,
Status: status,
LastError: lastError,
Connections: connections,
ProxyCount: proxyCount,
ClientCount: clientCount,
Proxies: proxies,
ProcessAlive: cmd != nil && cmd.Process != nil,
}
}
@@ -88,7 +218,8 @@ func (m *Manager) UpdateConfig(cfg *service.RelayConfig) {
if m.activeConfig != nil &&
m.activeConfig.BindPort == cfg.BindPort &&
m.activeConfig.VhostHTTPPort == cfg.VhostHTTPPort &&
m.activeConfig.AuthToken == cfg.AuthToken {
m.activeConfig.AuthToken == cfg.AuthToken &&
m.activeConfig.WebServerEnabled == cfg.WebServerEnabled {
if m.cmd == nil && !m.stopping {
slog.Warn("frps config unchanged but process is not running, restarting")
if err := m.restartProcess(); err != nil {
@@ -136,6 +267,22 @@ func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
buf.WriteString(fmt.Sprintf("token = \"%s\"\n", cfg.AuthToken))
}
// WebServer configuration
buf.WriteString("\n[webServer]\n")
if cfg.WebServerEnabled {
buf.WriteString("addr = \"0.0.0.0\"\n")
} else {
buf.WriteString("addr = \"127.0.0.1\"\n")
}
buf.WriteString(fmt.Sprintf("port = %d\n", cfg.BindPort+500))
buf.WriteString("user = \"admin\"\n")
password := m.agentToken
if password == "" {
password = "admin"
}
buf.WriteString(fmt.Sprintf("password = \"%s\"\n", password))
return os.WriteFile(m.configPath, buf.Bytes(), 0644)
}
+12 -10
View File
@@ -54,16 +54,18 @@ func (s *Service) doHeartbeat(ctx context.Context) {
runtimeStatus := s.frpsManager.GetRuntimeStatus()
payload := service.RelayHeartbeatPayload{
Version: config.Version,
ExtVersion: s.frpsManager.GetVersion(),
RelayStatus: runtimeStatus.Status,
FrpsConnCount: runtimeStatus.Connections,
FrpsProxyCount: runtimeStatus.ProxyCount,
Name: s.config.NodeName,
IP: s.config.NodeIP,
Profile: observability.BuildProfile(s.config, s.stateStore),
Snapshot: observability.BuildSnapshot(s.config, s.stateStore),
HealthEvents: observability.BuildHealthEvents(runtimeStatus),
Version: config.Version,
ExtVersion: s.frpsManager.GetVersion(),
RelayStatus: runtimeStatus.Status,
FrpsConnCount: runtimeStatus.Connections,
FrpsProxyCount: runtimeStatus.ProxyCount,
FrpsClientCount: runtimeStatus.ClientCount,
FrpsProxies: runtimeStatus.Proxies,
Name: s.config.NodeName,
IP: s.config.NodeIP,
Profile: observability.BuildProfile(s.config, s.stateStore),
Snapshot: observability.BuildSnapshot(s.config, s.stateStore),
HealthEvents: observability.BuildHealthEvents(runtimeStatus),
}
resp, err := s.client.Heartbeat(ctx, payload)
+58
View File
@@ -0,0 +1,58 @@
package migrate
import (
"fmt"
"gorm.io/gorm"
)
type nodeV17 struct{}
func (nodeV17) TableName() string {
return "nodes"
}
func init() {
Register(V17())
}
func V17() Migration {
return Migration{
FromVersion: 16,
ToVersion: 17,
Migrate: migrateV17,
Validate: validateV17,
}
}
func migrateV17(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
return nil
}
func validateV17(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 16); err != nil {
return err
}
if db == nil {
return fmt.Errorf("database handle is nil")
}
migrator := db.Migrator()
if !migrator.HasColumn(&nodeV17{}, "relay_web_server_enabled") {
return fmt.Errorf("column nodes.relay_web_server_enabled is missing")
}
// Validate columns on a sharded partition table
for _, shard := range []string{"node_observation_frps_00"} {
for _, column := range []string{"frps_client_count", "frps_proxies"} {
if !migrator.HasColumn(shard, column) {
return fmt.Errorf("column %s.%s is missing", shard, column)
}
}
}
return nil
}
+27
View File
@@ -86,6 +86,8 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
return validateDatabaseSchemaV15(db, backend)
case 16:
return validateDatabaseSchemaV16(db, backend)
case 17:
return validateDatabaseSchemaV17(db, backend)
default:
return fmt.Errorf("database schema validation for v%d is not defined", version)
}
@@ -1301,6 +1303,31 @@ func validateDatabaseSchemaV16(db *gorm.DB, backend string) error {
return nil
}
func validateDatabaseSchemaV17(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV16(db, backend); err != nil {
return err
}
if db == nil {
return fmt.Errorf("database handle is nil")
}
migrator := db.Migrator()
if !migrator.HasColumn(&Node{}, "relay_web_server_enabled") {
return fmt.Errorf("column nodes.relay_web_server_enabled is missing")
}
// Validate columns on a sharded partition table
for _, shard := range []string{"node_observation_frps_00"} {
for _, column := range []string{"frps_client_count", "frps_proxies"} {
if !migrator.HasColumn(shard, column) {
return fmt.Errorf("column %s.%s is missing", shard, column)
}
}
}
return nil
}
func databaseSchemaMigrations() []databaseSchemaMigration {
ctx := databaseSchemaMigrationContext{}
migrations := []databaseSchemaMigration{}
+1
View File
@@ -38,6 +38,7 @@ type Node struct {
RelayClientAccessAddr string `json:"relay_client_access_addr" gorm:"size:255"`
RelayClientProxyURL string `json:"relay_client_proxy_url" gorm:"size:512"`
RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"`
RelayWebServerEnabled bool `json:"relay_web_server_enabled" gorm:"not null;default:false"`
}
func ListNodes() (nodes []*Node, err error) {
@@ -13,6 +13,8 @@ type NodeObservationFrps struct {
CapturedAt time.Time `json:"captured_at" gorm:"index"`
FrpsConnections int `json:"frps_connections"`
FrpsProxyCount int `json:"frps_proxy_count"`
FrpsClientCount int `json:"frps_client_count"`
FrpsProxies string `json:"frps_proxies" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
}
+1
View File
@@ -171,6 +171,7 @@ type NodeView struct {
RelayClientAccessAddr string `json:"relay_client_access_addr"`
RelayClientProxyURL string `json:"relay_client_proxy_url"`
RelayStatus string `json:"relay_status"`
RelayWebServerEnabled bool `json:"relay_web_server_enabled"`
}
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) {
+4
View File
@@ -31,6 +31,7 @@ type NodeInput struct {
RelayAgentAccessAddr string `json:"relay_agent_access_addr"`
RelayClientAccessAddr string `json:"relay_client_access_addr"`
RelayClientProxyURL string `json:"relay_client_proxy_url"`
RelayWebServerEnabled bool `json:"relay_web_server_enabled"`
}
type NodeAgentUpdateInput struct {
@@ -100,6 +101,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
node.RelayWebServerEnabled = input.RelayWebServerEnabled
}
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
@@ -137,6 +139,7 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
node.RelayWebServerEnabled = input.RelayWebServerEnabled
if input.RelayBindPort > 0 {
node.RelayBindPort = input.RelayBindPort
}
@@ -354,6 +357,7 @@ func buildNodeView(node *model.Node) *NodeView {
view.RelayClientAccessAddr = node.RelayClientAccessAddr
view.RelayClientProxyURL = node.RelayClientProxyURL
view.RelayStatus = node.RelayStatus
view.RelayWebServerEnabled = node.RelayWebServerEnabled
view.Version = node.Version
view.ExtVersion = node.ExtVersion
return view
+26 -5
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"errors"
"openflare/model"
"time"
@@ -146,24 +147,44 @@ func buildRelayDashboardSnapshot(node *model.Node, obs *model.NodeObservationFrp
}
totalProxies := 0
totalConnections := 0
clientCounts := 0
proxies := []RelayProxyStat{}
if obs != nil {
totalProxies = obs.FrpsProxyCount
totalConnections = obs.FrpsConnections
clientCounts = obs.FrpsClientCount
if obs.FrpsProxies != "" {
var decoded []RelayProxyStat
if err := json.Unmarshal([]byte(obs.FrpsProxies), &decoded); err == nil {
proxies = decoded
}
}
}
if totalProxies < 0 {
totalProxies = 0
}
onlineProxies := totalProxies
if node.RelayStatus != "healthy" {
onlineProxies = 0
onlineProxies := 0
for _, p := range proxies {
if p.Status == "online" {
onlineProxies++
}
}
// Fallback for backward compatibility
if len(proxies) == 0 {
onlineProxies = totalProxies
if node.RelayStatus != "healthy" {
onlineProxies = 0
}
}
return &RelayDashboardSnapshot{
TotalProxies: totalProxies,
OnlineProxies: onlineProxies,
OfflineProxies: totalProxies - onlineProxies,
Proxies: []RelayProxyStat{},
Proxies: proxies,
TotalConnections: maxInt(totalConnections, 0),
ClientCounts: 0,
ClientCounts: maxInt(clientCounts, 0),
}
}
+32 -18
View File
@@ -1,6 +1,7 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -16,26 +17,29 @@ import (
// RelayHeartbeatPayload is the payload sent by OpenFlareRelay in each heartbeat.
type RelayHeartbeatPayload struct {
Version string `json:"version"`
ExtVersion string `json:"frp_version"`
RelayStatus string `json:"relay_status"`
FrpsConnCount int `json:"frps_connections"`
FrpsProxyCount int `json:"frps_proxy_count"`
Name string `json:"name"`
IP string `json:"ip"`
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
HealthEvents []AgentNodeHealthEvent `json:"health_events,omitempty"`
Version string `json:"version"`
ExtVersion string `json:"frp_version"`
RelayStatus string `json:"relay_status"`
FrpsConnCount int `json:"frps_connections"`
FrpsProxyCount int `json:"frps_proxy_count"`
FrpsClientCount int `json:"frps_client_count"`
FrpsProxies []RelayProxyStat `json:"frps_proxies,omitempty"`
Name string `json:"name"`
IP string `json:"ip"`
Profile *AgentNodeSystemProfile `json:"profile,omitempty"`
Snapshot *AgentNodeMetricSnapshot `json:"snapshot,omitempty"`
HealthEvents []AgentNodeHealthEvent `json:"health_events,omitempty"`
}
const relayFrpsUnhealthyEventType = "frps_unhealthy"
// RelayConfig is the frps configuration sent to the Relay.
type RelayConfig struct {
BindPort int `json:"bind_port"`
VhostHTTPPort int `json:"vhost_http_port"`
AuthToken string `json:"auth_token"`
LogLevel string `json:"log_level"`
BindPort int `json:"bind_port"`
VhostHTTPPort int `json:"vhost_http_port"`
AuthToken string `json:"auth_token"`
LogLevel string `json:"log_level"`
WebServerEnabled bool `json:"web_server_enabled"`
}
// RelaySettings contains runtime settings for the Relay.
@@ -148,11 +152,20 @@ func persistRelayHeartbeatObservability(nodeID string, payload RelayHeartbeatPay
HealthEvents: payload.HealthEvents,
}, reportedAt)
var proxiesJSON string
if len(payload.FrpsProxies) > 0 {
if data, err := json.Marshal(payload.FrpsProxies); err == nil {
proxiesJSON = string(data)
}
}
frpsObs := &model.NodeObservationFrps{
NodeID: nodeID,
CapturedAt: reportedAt,
FrpsConnections: payload.FrpsConnCount,
FrpsProxyCount: payload.FrpsProxyCount,
FrpsClientCount: payload.FrpsClientCount,
FrpsProxies: proxiesJSON,
}
_ = frpsObs.Insert()
}
@@ -162,10 +175,11 @@ func buildRelayConfig(node *model.Node) *RelayConfig {
return nil
}
return &RelayConfig{
BindPort: node.RelayBindPort,
VhostHTTPPort: node.RelayVhostHTTPPort,
AuthToken: node.RelayAuthToken,
LogLevel: "info",
BindPort: node.RelayBindPort,
VhostHTTPPort: node.RelayVhostHTTPPort,
AuthToken: node.RelayAuthToken,
LogLevel: "info",
WebServerEnabled: node.RelayWebServerEnabled,
}
}
@@ -51,6 +51,7 @@ const nodeEditorSchema = z
relay_client_access_addr: z.string().trim(),
relay_agent_access_addr: z.string().trim(),
relay_client_proxy_url: z.string().trim(),
relay_web_server_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_region: z.string(),
geo_name: z.string().trim().max(128, '位置名不能超过 128 个字符'),
@@ -142,6 +143,7 @@ const defaultValues: NodeEditorValues = {
relay_client_access_addr: '',
relay_agent_access_addr: '',
relay_client_proxy_url: '',
relay_web_server_enabled: false,
geo_manual_override: false,
geo_region: '',
geo_name: '',
@@ -233,6 +235,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
relay_client_access_addr: node.relay_client_access_addr ?? '',
relay_agent_access_addr: node.relay_agent_access_addr ?? '',
relay_client_proxy_url: node.relay_client_proxy_url ?? '',
relay_web_server_enabled: node.relay_web_server_enabled ?? false,
geo_manual_override: node.geo_manual_override ?? false,
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
geo_name: node.geo_name ?? '',
@@ -259,6 +262,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
relay_client_access_addr: values.type === 'tunnel_relay' ? values.relay_client_access_addr.trim() : undefined,
relay_agent_access_addr: values.type === 'tunnel_relay' ? values.relay_agent_access_addr.trim() : undefined,
relay_client_proxy_url: values.type === 'tunnel_relay' ? values.relay_client_proxy_url.trim() : undefined,
relay_web_server_enabled: values.type === 'tunnel_relay' ? values.relay_web_server_enabled : undefined,
};
if (!values.geo_manual_override) {
@@ -426,6 +430,18 @@ export function NodeEditorModal({
>
<ResourceInput placeholder="例如: http://10.0.0.1:3128" {...form.register('relay_client_proxy_url')} />
</ResourceField>
<ToggleField
label="开启 FRPS WebUI"
description="开启后可以通过浏览器直接访问 FRPS 的 Web 界面。安全起见,访问密码固定为当前中继节点的接入 Server Token。"
checked={form.watch('relay_web_server_enabled')}
onChange={(checked) =>
form.setValue('relay_web_server_enabled', checked, {
shouldDirty: true,
shouldValidate: true,
})
}
/>
</div>
)}
@@ -866,6 +866,21 @@ export function RelayDetailPage({ node }: { node: NodeItem }) {
<p>Relay 中继版本:{node.version || 'unknown'}</p>
<p>frps 核心版本:{node.ext_version || 'unknown'}</p>
<p>中继网络接入:{node.relay_agent_access_addr || '—'}</p>
{node.relay_web_server_enabled ? (
<p>
FRPS WebUI:
<a
href={`http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`}
target="_blank"
rel="noreferrer"
className="text-[var(--accent-strong)] hover:underline font-medium"
>
点击打开 Web 界面
</a>
</p>
) : (
<p>FRPS WebUI:已禁用</p>
)}
</div>
</AppCard>
@@ -14,6 +14,7 @@ export interface NodeItem {
relay_client_proxy_url: string;
relay_auth_token: string;
relay_status: string;
relay_web_server_enabled: boolean;
relay_frps_connections: number;
relay_frps_proxy_count: number;
geo_name: string;
@@ -59,6 +60,7 @@ export interface NodeMutationPayload {
relay_client_access_addr?: string;
relay_agent_access_addr?: string;
relay_client_proxy_url?: string;
relay_web_server_enabled?: boolean;
auto_update_enabled: boolean;
geo_name: string;
geo_latitude?: number | null;