mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
[优化] 更新节点类型支持,添加隧道客户端,重构相关路由和配置
This commit is contained in:
@@ -9,24 +9,15 @@ COPY openflare_relay /openflare_relay
|
||||
WORKDIR /openflare_relay
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o openflare-relay ./cmd/relay
|
||||
|
||||
# Extract frps binary from official image
|
||||
FROM fatedier/frps:v0.69.0 AS frps
|
||||
|
||||
# Final runtime image
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
FROM fatedier/frps:v0.69.0
|
||||
|
||||
# Copy openflare-relay binary
|
||||
COPY --from=builder /openflare_relay/openflare-relay /usr/local/bin/openflare-relay
|
||||
|
||||
# Copy frps binary from official image
|
||||
COPY --from=frps /usr/bin/frps /usr/local/bin/frps
|
||||
|
||||
VOLUME ["/var/lib/openflare-relay"]
|
||||
|
||||
ENV OPENFLARE_FRPS_PATH=/usr/local/bin/frps
|
||||
ENV OPENFLARE_FRPS_PATH=/usr/bin/frps
|
||||
ENV OPENFLARE_DATA_DIR=/var/lib/openflare-relay
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/openflare-relay"]
|
||||
CMD ["-config", "/etc/openflare-relay/relay.json"]
|
||||
|
||||
@@ -89,6 +89,23 @@ func AgentHeartbeat(c *gin.Context) {
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/agent/config-versions/active [get]
|
||||
func AgentGetActiveConfig(c *gin.Context) {
|
||||
authNode, ok := c.Get("node")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "Node object missing from context")
|
||||
return
|
||||
}
|
||||
node := authNode.(*model.Node)
|
||||
|
||||
if node.NodeType == "tunnel_client" {
|
||||
config, err := service.GetFlaredTunnelConfig(node)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法生成隧道配置: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, config)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := service.GetActiveConfigForAgent()
|
||||
if err != nil {
|
||||
respondFailure(c, "当前没有激活版本")
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
// FlaredHeartbeat godoc
|
||||
// @Summary Report OpenFlared client heartbeat
|
||||
// @Tags Flared
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security TunnelTokenAuth
|
||||
// @Param payload body service.FlaredHeartbeatPayload true "Flared heartbeat payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/flared/heartbeat [post]
|
||||
func FlaredHeartbeat(c *gin.Context) {
|
||||
var payload service.FlaredHeartbeatPayload
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
authTunnel, ok := c.Get("tunnel")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作")
|
||||
return
|
||||
}
|
||||
tunnel := authTunnel.(*model.Tunnel)
|
||||
result, err := service.HeartbeatFlared(tunnel, payload)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
// FlaredGetActiveConfig godoc
|
||||
// @Summary Get active tunnel config for OpenFlared client
|
||||
// @Tags Flared
|
||||
// @Produce json
|
||||
// @Security TunnelTokenAuth
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/flared/config/active [get]
|
||||
func FlaredGetActiveConfig(c *gin.Context) {
|
||||
authTunnel, ok := c.Get("tunnel")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作")
|
||||
return
|
||||
}
|
||||
tunnel := authTunnel.(*model.Tunnel)
|
||||
config, err := service.GetFlaredTunnelConfig(tunnel)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, config)
|
||||
}
|
||||
|
||||
// FlaredReportApplyLog godoc
|
||||
// @Summary Report apply log for OpenFlared client
|
||||
// @Tags Flared
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security TunnelTokenAuth
|
||||
// @Param payload body service.ApplyLogPayload true "Apply log payload"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]interface{}
|
||||
// @Router /api/flared/apply-log [post]
|
||||
func FlaredReportApplyLog(c *gin.Context) {
|
||||
var payload service.ApplyLogPayload
|
||||
if !bindJSON(c, &payload) {
|
||||
return
|
||||
}
|
||||
authTunnel, ok := c.Get("tunnel")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作")
|
||||
return
|
||||
}
|
||||
tunnel := authTunnel.(*model.Tunnel)
|
||||
payload.NodeID = tunnel.TunnelID
|
||||
log, err := service.ReportApplyLog(payload)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, log)
|
||||
}
|
||||
|
||||
// FlaredWebSocket godoc
|
||||
// @Summary Upgrade OpenFlared connection to websocket
|
||||
// @Tags Flared
|
||||
// @Security TunnelTokenAuth
|
||||
// @Router /api/flared/ws [get]
|
||||
func FlaredWebSocket(c *gin.Context) {
|
||||
authTunnel, ok := c.Get("tunnel")
|
||||
if !ok {
|
||||
respondUnauthorized(c, "无权进行此操作")
|
||||
return
|
||||
}
|
||||
tunnel := authTunnel.(*model.Tunnel)
|
||||
slog.Debug("flared ws upgrade requested", "tunnel_id", tunnel.TunnelID, "remote", c.Request.RemoteAddr)
|
||||
websocket.Handler(func(conn *websocket.Conn) {
|
||||
client := service.RegisterFlaredWSClient(tunnel.TunnelID)
|
||||
defer service.UnregisterFlaredWSClient(client)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
slog.Debug("flared ws connection closed", "tunnel_id", tunnel.TunnelID)
|
||||
}()
|
||||
|
||||
slog.Debug("flared ws upgrade succeeded", "tunnel_id", tunnel.TunnelID, "remote", c.Request.RemoteAddr)
|
||||
|
||||
go func() {
|
||||
<-client.Done()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
go streamFlaredWSMessages(c, conn, client)
|
||||
|
||||
for {
|
||||
var message service.WSMessage
|
||||
_ = conn.SetReadDeadline(time.Now().Add(agentWSReadTimeout()))
|
||||
if err := websocket.JSON.Receive(conn, &message); err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
slog.Debug("flared ws receive timeout", "tunnel_id", tunnel.TunnelID)
|
||||
return
|
||||
}
|
||||
slog.Debug("flared ws receive failed", "tunnel_id", tunnel.TunnelID, "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("flared ws message received", "tunnel_id", tunnel.TunnelID, "type", message.Type)
|
||||
switch message.Type {
|
||||
case "status":
|
||||
// Handle status if needed for flared
|
||||
case "ping":
|
||||
if !service.SendFlaredWSPong(tunnel.TunnelID) {
|
||||
slog.Debug("flared ws pong enqueue failed", "tunnel_id", tunnel.TunnelID)
|
||||
}
|
||||
case "pong":
|
||||
slog.Debug("flared ws pong received", "tunnel_id", tunnel.TunnelID)
|
||||
default:
|
||||
slog.Debug("flared ws unsupported message type", "tunnel_id", tunnel.TunnelID, "type", message.Type)
|
||||
}
|
||||
}
|
||||
}).ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
func streamFlaredWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
case <-client.Done():
|
||||
return
|
||||
case message, ok := <-client.Messages():
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(agentWSWriteTimeout()))
|
||||
if err := websocket.JSON.Send(conn, message); err != nil {
|
||||
slog.Debug("flared ws send failed", "tunnel_id", client.ID(), "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"openflare/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetTunnels(c *gin.Context) {
|
||||
tunnels, err := service.ListTunnels()
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, tunnels)
|
||||
}
|
||||
|
||||
func GetTunnel(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tunnel, err := service.GetTunnel(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, tunnel)
|
||||
}
|
||||
|
||||
func CreateTunnel(c *gin.Context) {
|
||||
var input service.TunnelInput
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
tunnel, err := service.CreateTunnel(input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, tunnel)
|
||||
}
|
||||
|
||||
func UpdateTunnel(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input service.TunnelInput
|
||||
if !bindJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
tunnel, err := service.UpdateTunnel(id, input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, tunnel)
|
||||
}
|
||||
|
||||
func DeleteTunnel(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := service.DeleteTunnel(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, nil)
|
||||
}
|
||||
|
||||
func RotateTunnelToken(c *gin.Context) {
|
||||
id, ok := parseIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tunnel, err := service.RotateTunnelToken(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, tunnel)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
)
|
||||
|
||||
// TunnelAuth authenticates OpenFlared client requests using X-Tunnel-Token.
|
||||
func TunnelAuth() func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Tunnel-Token")
|
||||
tunnel, err := service.AuthenticateTunnelToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,Tunnel Token 无效",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("tunnel", tunnel)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func registeredModels() []any {
|
||||
&ProxyRoute{},
|
||||
&ConfigVersion{},
|
||||
&Node{},
|
||||
&Tunnel{},
|
||||
|
||||
&NodeSystemProfile{},
|
||||
&ApplyLog{},
|
||||
&NodeMetricSnapshot{},
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type proxyRouteV19 struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
TunnelID *uint `gorm:"column:tunnel_id"`
|
||||
TunnelNodeID *uint `gorm:"column:tunnel_node_id"`
|
||||
UpstreamType string `gorm:"column:upstream_type"`
|
||||
}
|
||||
|
||||
func (proxyRouteV19) TableName() string {
|
||||
return "proxy_routes"
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(V19())
|
||||
}
|
||||
|
||||
func V19() Migration {
|
||||
return Migration{
|
||||
FromVersion: 18,
|
||||
ToVersion: 19,
|
||||
Migrate: migrateV19,
|
||||
Validate: validateV19,
|
||||
}
|
||||
}
|
||||
|
||||
func migrateV19(ctx Context, db *gorm.DB, backend string) error {
|
||||
// Drop tunnels table
|
||||
if db.Migrator().HasTable("tunnels") {
|
||||
if err := db.Migrator().DropTable("tunnels"); err != nil {
|
||||
return fmt.Errorf("failed to drop tunnels table: %w", err)
|
||||
}
|
||||
slog.Info("dropped tunnels table")
|
||||
}
|
||||
|
||||
// Add tunnel_node_id column
|
||||
if !db.Migrator().HasColumn(&proxyRouteV19{}, "tunnel_node_id") {
|
||||
if err := db.Migrator().AddColumn(&proxyRouteV19{}, "TunnelNodeID"); err != nil {
|
||||
return fmt.Errorf("failed to add tunnel_node_id to proxy_routes: %w", err)
|
||||
}
|
||||
slog.Info("added tunnel_node_id column to proxy_routes")
|
||||
}
|
||||
|
||||
// Drop old tunnel_id column
|
||||
if db.Migrator().HasColumn(&proxyRouteV19{}, "tunnel_id") {
|
||||
// Update routes that previously used tunnel to be disabled or direct to prevent dangling refs
|
||||
db.Model(&proxyRouteV19{}).Where("upstream_type = ?", "tunnel").Update("upstream_type", "direct")
|
||||
if err := db.Migrator().DropColumn(&proxyRouteV19{}, "tunnel_id"); err != nil {
|
||||
return fmt.Errorf("failed to drop tunnel_id column from proxy_routes: %w", err)
|
||||
}
|
||||
slog.Info("dropped tunnel_id column from proxy_routes")
|
||||
}
|
||||
|
||||
return ctx.ApplyCurrentSchema(db, backend)
|
||||
}
|
||||
|
||||
func validateV19(ctx Context, db *gorm.DB, backend string) error {
|
||||
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 18); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if db.Migrator().HasTable("tunnels") {
|
||||
return fmt.Errorf("table tunnels should be dropped in v19")
|
||||
}
|
||||
|
||||
if !db.Migrator().HasColumn(&proxyRouteV19{}, "tunnel_node_id") {
|
||||
return fmt.Errorf("column proxy_routes.tunnel_node_id is missing")
|
||||
}
|
||||
|
||||
if db.Migrator().HasColumn(&proxyRouteV19{}, "tunnel_id") {
|
||||
return fmt.Errorf("column proxy_routes.tunnel_id should be dropped in v19")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1164,7 +1164,7 @@ func validateDatabaseSchemaV16(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV15(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasTable(&Tunnel{}) {
|
||||
if !db.Migrator().HasTable("tunnels") {
|
||||
return fmt.Errorf("table tunnels is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&Node{}, "node_type") {
|
||||
|
||||
@@ -31,7 +31,7 @@ type ProxyRoute struct {
|
||||
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
UpstreamType string `json:"upstream_type" gorm:"size:32;not null;default:'direct'"`
|
||||
TunnelID *uint `json:"tunnel_id" gorm:"index"`
|
||||
TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"`
|
||||
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
|
||||
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -92,7 +92,7 @@ func (route *ProxyRoute) Update() error {
|
||||
"basic_auth_password": route.BasicAuthPassword,
|
||||
"remark": route.Remark,
|
||||
"upstream_type": route.UpstreamType,
|
||||
"tunnel_id": route.TunnelID,
|
||||
"tunnel_node_id": route.TunnelNodeID,
|
||||
"tunnel_target_addr": route.TunnelTargetAddr,
|
||||
"tunnel_target_protocol": route.TunnelTargetProtocol,
|
||||
}).Error
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Tunnel struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
TunnelID string `json:"tunnel_id" gorm:"uniqueIndex;size:64;not null"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
TunnelToken string `json:"-" gorm:"size:128;index"`
|
||||
Status string `json:"status" gorm:"size:16;not null;default:'offline'"`
|
||||
ClientVersion string `json:"client_version" gorm:"size:64"`
|
||||
FrpVersion string `json:"frp_version" gorm:"size:64"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
LastError string `json:"last_error" gorm:"type:text"`
|
||||
CurrentVersion string `json:"current_version" gorm:"size:32"`
|
||||
CurrentChecksum string `json:"current_checksum" gorm:"size:64"`
|
||||
ConnectedRelays string `json:"connected_relays" gorm:"type:text;not null;default:'[]'"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ListTunnels() (tunnels []*Tunnel, err error) {
|
||||
err = DB.Order("id desc").Find(&tunnels).Error
|
||||
return tunnels, err
|
||||
}
|
||||
|
||||
func GetTunnelByID(id uint) (*Tunnel, error) {
|
||||
tunnel := &Tunnel{}
|
||||
err := DB.First(tunnel, id).Error
|
||||
return tunnel, err
|
||||
}
|
||||
|
||||
func GetTunnelByTunnelID(tunnelID string) (*Tunnel, error) {
|
||||
tunnel := &Tunnel{}
|
||||
err := DB.Where("tunnel_id = ?", tunnelID).First(tunnel).Error
|
||||
return tunnel, err
|
||||
}
|
||||
|
||||
func GetTunnelByTunnelToken(token string) (*Tunnel, error) {
|
||||
tunnel := &Tunnel{}
|
||||
err := DB.Where("tunnel_token = ?", token).First(tunnel).Error
|
||||
return tunnel, err
|
||||
}
|
||||
|
||||
func (tunnel *Tunnel) Insert() error {
|
||||
return DB.Create(tunnel).Error
|
||||
}
|
||||
|
||||
func (tunnel *Tunnel) Update() error {
|
||||
return DB.Save(tunnel).Error
|
||||
}
|
||||
|
||||
func (tunnel *Tunnel) Delete() error {
|
||||
return DB.Delete(tunnel).Error
|
||||
}
|
||||
@@ -198,16 +198,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
applyLogRoute.GET("/", controller.GetApplyLogs)
|
||||
applyLogRoute.POST("/cleanup", controller.CleanupApplyLogs)
|
||||
}
|
||||
tunnelRoute := apiRouter.Group("/tunnels")
|
||||
tunnelRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
tunnelRoute.GET("/", controller.GetTunnels)
|
||||
tunnelRoute.GET("/:id", controller.GetTunnel)
|
||||
tunnelRoute.POST("/", controller.CreateTunnel)
|
||||
tunnelRoute.POST("/:id/update", controller.UpdateTunnel)
|
||||
tunnelRoute.POST("/:id/delete", controller.DeleteTunnel)
|
||||
tunnelRoute.POST("/:id/rotate-token", controller.RotateTunnelToken)
|
||||
}
|
||||
|
||||
accessLogRoute := apiRouter.Group("/access-logs")
|
||||
accessLogRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
@@ -240,13 +231,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
relayRoute.POST("/heartbeat", controller.RelayHeartbeat)
|
||||
relayRoute.GET("/ws", controller.RelayWebSocket)
|
||||
}
|
||||
flaredRoute := apiRouter.Group("/flared")
|
||||
flaredRoute.Use(middleware.TunnelAuth())
|
||||
{
|
||||
flaredRoute.POST("/heartbeat", controller.FlaredHeartbeat)
|
||||
flaredRoute.GET("/config/active", controller.FlaredGetActiveConfig)
|
||||
flaredRoute.POST("/apply-log", controller.FlaredReportApplyLog)
|
||||
flaredRoute.GET("/ws", controller.FlaredWebSocket)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +356,6 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error)
|
||||
Checksum: record.Checksum,
|
||||
}
|
||||
BroadcastAgentWSActiveConfig(activeConfig)
|
||||
BroadcastFlaredWSActiveConfig(activeConfig)
|
||||
return &ReleaseResult{
|
||||
Version: record,
|
||||
Routes: bundle.Routes,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var DefaultFlaredWSHub = NewWSHub("flared")
|
||||
|
||||
func RegisterFlaredWSClient(tunnelID string) *WSClient {
|
||||
return DefaultFlaredWSHub.Register(tunnelID)
|
||||
}
|
||||
|
||||
func UnregisterFlaredWSClient(client *WSClient) {
|
||||
DefaultFlaredWSHub.Unregister(client)
|
||||
}
|
||||
|
||||
func IsFlaredWSConnected(tunnelID string) bool {
|
||||
return DefaultFlaredWSHub.IsConnected(tunnelID)
|
||||
}
|
||||
|
||||
func SendFlaredWSPong(tunnelID string) bool {
|
||||
return DefaultFlaredWSHub.SendMessage(tunnelID, WSMessage{
|
||||
Type: "pong",
|
||||
})
|
||||
}
|
||||
|
||||
func SendFlaredWSActiveConfig(tunnelID string, activeConfig *ActiveConfigMeta) bool {
|
||||
if activeConfig == nil {
|
||||
return false
|
||||
}
|
||||
return DefaultFlaredWSHub.SendMessage(tunnelID, WSMessage{
|
||||
Type: "active_config",
|
||||
Payload: activeConfig,
|
||||
})
|
||||
}
|
||||
|
||||
func BroadcastFlaredWSActiveConfig(activeConfig *ActiveConfigMeta) WSBroadcastResult {
|
||||
if activeConfig == nil {
|
||||
slog.Debug("flared ws broadcast skipped because active config is nil")
|
||||
return WSBroadcastResult{}
|
||||
}
|
||||
res := DefaultFlaredWSHub.Broadcast(WSMessage{
|
||||
Type: "active_config",
|
||||
Payload: activeConfig,
|
||||
})
|
||||
slog.Debug("flared ws broadcast active config",
|
||||
"version", activeConfig.Version,
|
||||
"checksum", activeConfig.Checksum,
|
||||
"client_count", res.ClientCount,
|
||||
"success_count", res.SuccessCount,
|
||||
"failed_tunnels", res.FailedIDs,
|
||||
)
|
||||
return res
|
||||
}
|
||||
@@ -634,6 +634,8 @@ func normalizeNodeType(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "tunnel_relay":
|
||||
return "tunnel_relay"
|
||||
case "tunnel_client":
|
||||
return "tunnel_client"
|
||||
default:
|
||||
return "edge_node"
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ type ProxyRouteInput struct {
|
||||
BasicAuthPassword string `json:"basic_auth_password"`
|
||||
Remark string `json:"remark"`
|
||||
UpstreamType string `json:"upstream_type"`
|
||||
TunnelID *uint `json:"tunnel_id"`
|
||||
TunnelNodeID *uint `json:"tunnel_node_id"`
|
||||
TunnelTargetAddr string `json:"tunnel_target_addr"`
|
||||
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
|
||||
}
|
||||
@@ -101,7 +101,7 @@ type ProxyRouteView struct {
|
||||
BasicAuthPassword string `json:"basic_auth_password"`
|
||||
Remark string `json:"remark"`
|
||||
UpstreamType string `json:"upstream_type"`
|
||||
TunnelID *uint `json:"tunnel_id"`
|
||||
TunnelNodeID *uint `json:"tunnel_node_id"`
|
||||
TunnelTargetAddr string `json:"tunnel_target_addr"`
|
||||
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -327,11 +327,11 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
||||
route.Remark = remark
|
||||
route.UpstreamType = upstreamType
|
||||
if upstreamType == "tunnel" {
|
||||
route.TunnelID = input.TunnelID
|
||||
route.TunnelNodeID = input.TunnelNodeID
|
||||
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
|
||||
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
|
||||
} else {
|
||||
route.TunnelID = nil
|
||||
route.TunnelNodeID = nil
|
||||
route.TunnelTargetAddr = ""
|
||||
route.TunnelTargetProtocol = ""
|
||||
}
|
||||
@@ -421,7 +421,7 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
|
||||
BasicAuthPassword: route.BasicAuthPassword,
|
||||
Remark: route.Remark,
|
||||
UpstreamType: route.UpstreamType,
|
||||
TunnelID: route.TunnelID,
|
||||
TunnelNodeID: route.TunnelNodeID,
|
||||
TunnelTargetAddr: route.TunnelTargetAddr,
|
||||
TunnelTargetProtocol: route.TunnelTargetProtocol,
|
||||
CreatedAt: route.CreatedAt,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"openflare/common"
|
||||
@@ -162,42 +161,10 @@ type FlaredProxyEntry struct {
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
}
|
||||
|
||||
// HeartbeatFlared processes an OpenFlared client heartbeat.
|
||||
func HeartbeatFlared(tunnel *model.Tunnel, payload FlaredHeartbeatPayload) (*FlaredHeartbeatResponse, error) {
|
||||
if tunnel == nil {
|
||||
return nil, fmt.Errorf("tunnel is nil")
|
||||
}
|
||||
slog.Debug("flared heartbeat received", "tunnel_id", tunnel.TunnelID)
|
||||
|
||||
now := time.Now()
|
||||
tunnel.ClientVersion = strings.TrimSpace(payload.ClientVersion)
|
||||
tunnel.FrpVersion = strings.TrimSpace(payload.FrpVersion)
|
||||
tunnel.Status = "online"
|
||||
tunnel.LastSeenAt = now
|
||||
tunnel.CurrentVersion = strings.TrimSpace(payload.CurrentVersion)
|
||||
tunnel.CurrentChecksum = strings.TrimSpace(payload.CurrentChecksum)
|
||||
|
||||
relaysJSON, err := json.Marshal(payload.ConnectedRelays)
|
||||
if err == nil {
|
||||
tunnel.ConnectedRelays = string(relaysJSON)
|
||||
}
|
||||
|
||||
if err := tunnel.Update(); err != nil {
|
||||
return nil, fmt.Errorf("update tunnel heartbeat: %w", err)
|
||||
}
|
||||
|
||||
activeConfig, _ := GetActiveConfigMetaForAgent()
|
||||
|
||||
return &FlaredHeartbeatResponse{
|
||||
ActiveConfig: activeConfig,
|
||||
TunnelSettings: buildRelaySettings(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetFlaredTunnelConfig builds the full tunnel routing config for an OpenFlared client.
|
||||
func GetFlaredTunnelConfig(tunnel *model.Tunnel) (*FlaredTunnelConfigResponse, error) {
|
||||
if tunnel == nil {
|
||||
return nil, fmt.Errorf("tunnel is nil")
|
||||
func GetFlaredTunnelConfig(node *model.Node) (*FlaredTunnelConfigResponse, error) {
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("node is nil")
|
||||
}
|
||||
|
||||
activeVersion, err := model.GetActiveConfigVersion()
|
||||
@@ -237,7 +204,7 @@ func GetFlaredTunnelConfig(tunnel *model.Tunnel) (*FlaredTunnelConfigResponse, e
|
||||
// Build proxy entries from routes
|
||||
proxies := make([]FlaredProxyEntry, 0)
|
||||
for _, route := range routes {
|
||||
if route.UpstreamType != "tunnel" || route.TunnelID == nil || *route.TunnelID != tunnel.ID {
|
||||
if route.UpstreamType != "tunnel" || route.TunnelNodeID == nil || *route.TunnelNodeID != node.ID {
|
||||
continue
|
||||
}
|
||||
if !route.Enabled {
|
||||
@@ -249,7 +216,7 @@ func GetFlaredTunnelConfig(tunnel *model.Tunnel) (*FlaredTunnelConfigResponse, e
|
||||
}
|
||||
localAddr, localPort := parseTunnelTargetAddr(route.TunnelTargetAddr)
|
||||
proxies = append(proxies, FlaredProxyEntry{
|
||||
Name: fmt.Sprintf("%s-%s", tunnel.TunnelID, sanitizeProxyName(domains[0])),
|
||||
Name: fmt.Sprintf("%s-%s", node.NodeID, sanitizeProxyName(domains[0])),
|
||||
Type: "http",
|
||||
LocalAddr: localAddr,
|
||||
LocalPort: localPort,
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"openflare/model"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TunnelInput struct {
|
||||
Name string `json:"name"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type TunnelView struct {
|
||||
ID uint `json:"id"`
|
||||
TunnelID string `json:"tunnel_id"`
|
||||
Name string `json:"name"`
|
||||
TunnelToken string `json:"tunnel_token"`
|
||||
Status string `json:"status"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
FrpVersion string `json:"frp_version"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
LastError string `json:"last_error"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
CurrentChecksum string `json:"current_checksum"`
|
||||
ConnectedRelays []string `json:"connected_relays"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func ListTunnels() ([]*TunnelView, error) {
|
||||
tunnels, err := model.ListTunnels()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views := make([]*TunnelView, 0, len(tunnels))
|
||||
for _, tunnel := range tunnels {
|
||||
views = append(views, buildTunnelView(tunnel))
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func GetTunnel(id uint) (*TunnelView, error) {
|
||||
tunnel, err := model.GetTunnelByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildTunnelView(tunnel), nil
|
||||
}
|
||||
|
||||
func CreateTunnel(input TunnelInput) (*TunnelView, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("Tunnel 名称不能为空")
|
||||
}
|
||||
tunnelID, err := newTunnelID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tunnelToken, err := newRandomToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tunnel := &model.Tunnel{
|
||||
TunnelID: tunnelID,
|
||||
Name: name,
|
||||
TunnelToken: tunnelToken,
|
||||
Status: "offline",
|
||||
Remark: strings.TrimSpace(input.Remark),
|
||||
}
|
||||
if err := tunnel.Insert(); err != nil {
|
||||
if model.IsUniqueConstraintError(err) {
|
||||
return nil, errors.New("Tunnel 标识生成冲突,请重试")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("tunnel created", "name", tunnel.Name, "tunnel_id", tunnel.TunnelID)
|
||||
return buildTunnelView(tunnel), nil
|
||||
}
|
||||
|
||||
func UpdateTunnel(id uint, input TunnelInput) (*TunnelView, error) {
|
||||
tunnel, err := model.GetTunnelByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, errors.New("Tunnel 名称不能为空")
|
||||
}
|
||||
tunnel.Name = name
|
||||
tunnel.Remark = strings.TrimSpace(input.Remark)
|
||||
if err := tunnel.Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("tunnel updated", "name", tunnel.Name, "tunnel_id", tunnel.TunnelID)
|
||||
return buildTunnelView(tunnel), nil
|
||||
}
|
||||
|
||||
func DeleteTunnel(id uint) error {
|
||||
tunnel, err := model.GetTunnelByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("tunnel deleted", "name", tunnel.Name, "tunnel_id", tunnel.TunnelID)
|
||||
return tunnel.Delete()
|
||||
}
|
||||
|
||||
func RotateTunnelToken(id uint) (*TunnelView, error) {
|
||||
tunnel, err := model.GetTunnelByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newToken, err := newRandomToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tunnel.TunnelToken = newToken
|
||||
if err := tunnel.Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("tunnel token rotated", "tunnel_id", tunnel.TunnelID)
|
||||
return buildTunnelView(tunnel), nil
|
||||
}
|
||||
|
||||
func AuthenticateTunnelToken(token string) (*model.Tunnel, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, errors.New("缺少 Tunnel Token")
|
||||
}
|
||||
tunnel, err := model.GetTunnelByTunnelToken(token)
|
||||
if err != nil {
|
||||
return nil, errors.New("Tunnel Token 无效")
|
||||
}
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
func buildTunnelView(tunnel *model.Tunnel) *TunnelView {
|
||||
if tunnel == nil {
|
||||
return nil
|
||||
}
|
||||
relays := decodeTunnelConnectedRelays(tunnel.ConnectedRelays)
|
||||
return &TunnelView{
|
||||
ID: tunnel.ID,
|
||||
TunnelID: tunnel.TunnelID,
|
||||
Name: tunnel.Name,
|
||||
TunnelToken: tunnel.TunnelToken,
|
||||
Status: tunnel.Status,
|
||||
ClientVersion: tunnel.ClientVersion,
|
||||
FrpVersion: tunnel.FrpVersion,
|
||||
LastSeenAt: tunnel.LastSeenAt,
|
||||
LastError: tunnel.LastError,
|
||||
CurrentVersion: tunnel.CurrentVersion,
|
||||
CurrentChecksum: tunnel.CurrentChecksum,
|
||||
ConnectedRelays: relays,
|
||||
Remark: tunnel.Remark,
|
||||
CreatedAt: tunnel.CreatedAt,
|
||||
UpdatedAt: tunnel.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeTunnelConnectedRelays(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return make([]string, 0)
|
||||
}
|
||||
var relays []string
|
||||
if err := json.Unmarshal([]byte(raw), &relays); err != nil {
|
||||
return make([]string, 0)
|
||||
}
|
||||
return relays
|
||||
}
|
||||
|
||||
func newTunnelID() (string, error) {
|
||||
token, err := newRandomToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "tun-" + token, nil
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { TunnelsPage } from '@/features/tunnels/components/tunnels-page';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '内网穿透 - OpenFlare',
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <TunnelsPage />;
|
||||
}
|
||||
@@ -746,39 +746,77 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
: '当前没有活动中的健康事件'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="当前窗口请求"
|
||||
value={
|
||||
trafficSummary
|
||||
? trafficSummary.request_count.toLocaleString('zh-CN')
|
||||
: '—'
|
||||
}
|
||||
hint={
|
||||
trafficSummary
|
||||
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
|
||||
: '当前没有可展示的请求窗口摘要'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="容量压力"
|
||||
value={
|
||||
healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'
|
||||
}
|
||||
hint={
|
||||
latestMetricSnapshot
|
||||
? `CPU ${formatPercent(latestMetricSnapshot.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
|
||||
: '当前没有资源快照'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="来源信号"
|
||||
value={topSourceCountry?.key ?? '—'}
|
||||
hint={
|
||||
topSourceCountry
|
||||
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
|
||||
: '当前没有来源分布数据'
|
||||
}
|
||||
/>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<>
|
||||
<SummaryStat
|
||||
label="隧道总数"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.total_proxies}`
|
||||
: '—'
|
||||
}
|
||||
hint={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.online_proxies} 个在线,${observability.relay_dashboard.offline_proxies} 个离线`
|
||||
: '暂无隧道统计'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="当前连接数"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.total_connections}`
|
||||
: '—'
|
||||
}
|
||||
hint="隧道网络承载的活动连接总数"
|
||||
/>
|
||||
<SummaryStat
|
||||
label="活动客户端"
|
||||
value={
|
||||
observability?.relay_dashboard
|
||||
? `${observability.relay_dashboard.client_counts}`
|
||||
: '—'
|
||||
}
|
||||
hint="已连接并活跃的客户端数量"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SummaryStat
|
||||
label="当前窗口请求"
|
||||
value={
|
||||
trafficSummary
|
||||
? trafficSummary.request_count.toLocaleString('zh-CN')
|
||||
: '—'
|
||||
}
|
||||
hint={
|
||||
trafficSummary
|
||||
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
|
||||
: '当前没有可展示的请求窗口摘要'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="容量压力"
|
||||
value={
|
||||
healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'
|
||||
}
|
||||
hint={
|
||||
latestMetricSnapshot
|
||||
? `CPU ${formatPercent(latestMetricSnapshot.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
|
||||
: '当前没有资源快照'
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label="来源信号"
|
||||
value={topSourceCountry?.key ?? '—'}
|
||||
hint={
|
||||
topSourceCountry
|
||||
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
|
||||
: '当前没有来源分布数据'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
@@ -940,7 +978,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="网络流量">
|
||||
{node.node_type === 'edge_node' ? (
|
||||
<AppCard title="网络流量">
|
||||
{observabilityQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : observabilityQuery.isError ? (
|
||||
@@ -1054,9 +1093,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
{node.node_type === 'edge_node' ? (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<AppCard
|
||||
title="24 小时请求趋势"
|
||||
description="按小时聚合该节点的请求量和错误量。"
|
||||
@@ -1202,12 +1244,64 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</AppCard>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<div className="grid gap-6 xl:grid-cols-1">
|
||||
<AppCard title="隧道运行状态" description="当前中继节点上活跃的代理通道。">
|
||||
{observability?.relay_dashboard?.proxies ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-[var(--foreground-secondary)]">
|
||||
<thead className="border-b border-[var(--border-default)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">代理名称</th>
|
||||
<th className="px-4 py-3 font-medium">类型</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">客户端地址</th>
|
||||
<th className="px-4 py-3 font-medium">最近连接</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{observability.relay_dashboard.proxies.map((proxy) => (
|
||||
<tr key={proxy.name}>
|
||||
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{proxy.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">{proxy.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge
|
||||
label={proxy.status === 'online' ? '在线' : '离线'}
|
||||
variant={proxy.status === 'online' ? 'success' : 'info'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">{proxy.client_addr}</td>
|
||||
<td className="px-4 py-3">
|
||||
{isMeaningfulTime(proxy.last_start_time)
|
||||
? formatRelativeTime(proxy.last_start_time)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无通道信息"
|
||||
description="当前节点未上报映射通道状态,或尚未有客户端连接。"
|
||||
/>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||
<AppCard
|
||||
title="请求结构分布"
|
||||
{node.node_type === 'edge_node' ? (
|
||||
<AppCard
|
||||
title="请求结构分布"
|
||||
description="聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。"
|
||||
>
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-3">
|
||||
@@ -1273,6 +1367,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="健康事件时间线"
|
||||
@@ -1519,8 +1614,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="OpenResty 健康与控制"
|
||||
{node.node_type === 'edge_node' ? (
|
||||
<AppCard
|
||||
title="OpenResty 健康与控制"
|
||||
description="OpenResty 当前健康状态。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -1569,6 +1665,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<AppCard
|
||||
|
||||
@@ -37,7 +37,7 @@ type RegionOption = {
|
||||
|
||||
const nodeEditorSchema = z
|
||||
.object({
|
||||
type: z.enum(['edge_node', 'tunnel_relay']),
|
||||
type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -48,6 +48,7 @@ const nodeEditorSchema = z
|
||||
auto_update_enabled: z.boolean(),
|
||||
relay_bind_port: z.string().trim(),
|
||||
relay_client_access_addr: z.string().trim(),
|
||||
relay_agent_access_addr: z.string().trim(),
|
||||
relay_client_proxy_url: z.string().trim(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_region: z.string(),
|
||||
@@ -129,6 +130,7 @@ const defaultValues: NodeEditorValues = {
|
||||
auto_update_enabled: false,
|
||||
relay_bind_port: '7000',
|
||||
relay_client_access_addr: '',
|
||||
relay_agent_access_addr: '',
|
||||
relay_client_proxy_url: '',
|
||||
geo_manual_override: false,
|
||||
geo_region: '',
|
||||
@@ -218,6 +220,7 @@ function buildFormValues(node?: Partial<NodeItem> | null): NodeEditorValues {
|
||||
auto_update_enabled: node.auto_update_enabled ?? false,
|
||||
relay_bind_port: String(node.relay_bind_port ?? 7000),
|
||||
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 ?? '',
|
||||
geo_manual_override: node.geo_manual_override ?? false,
|
||||
geo_region: node.geo_manual_override ? (node.geo_name ?? '') : '',
|
||||
@@ -242,6 +245,7 @@ function toPayload(values: NodeEditorValues): NodeMutationPayload {
|
||||
auto_update_enabled: values.auto_update_enabled,
|
||||
relay_bind_port: values.type === 'tunnel_relay' ? Number(values.relay_bind_port) : undefined,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -353,6 +357,7 @@ export function NodeEditorModal({
|
||||
<ResourceSelect {...form.register('type')} disabled={!!node}>
|
||||
<option value="edge_node">边缘节点 (Edge Node)</option>
|
||||
<option value="tunnel_relay">隧道中继节点 (Tunnel Relay)</option>
|
||||
<option value="tunnel_client">隧道客户端 (Tunnel Client)</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
@@ -386,6 +391,14 @@ export function NodeEditorModal({
|
||||
<ResourceInput placeholder="例如: relay.example.com:7000" {...form.register('relay_client_access_addr')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="边缘节点接入地址 (Edge Node Access Addr)"
|
||||
hint="可选,用于 Edge Node 转发请求。默认与 Client Access Addr 或 IP + 绑定端口一致。"
|
||||
error={form.formState.errors.relay_agent_access_addr?.message}
|
||||
>
|
||||
<ResourceInput placeholder="例如: 10.0.0.1:7000" {...form.register('relay_agent_access_addr')} />
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="代理地址 (Proxy URL)"
|
||||
hint="可选,下发给客户端。当客户端连接中继需要经过 HTTP 代理时使用。"
|
||||
|
||||
@@ -296,7 +296,7 @@ export function NodesPage() {
|
||||
<th className="px-3 py-3 font-medium">节点</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">
|
||||
Agent / OpenResty
|
||||
Version
|
||||
</th>
|
||||
<th className="px-3 py-3 font-medium">运行健康</th>
|
||||
<th className="px-3 py-3 font-medium">当前版本</th>
|
||||
@@ -318,6 +318,10 @@ export function NodesPage() {
|
||||
<span className="rounded-full border border-blue-500/20 bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
Relay
|
||||
</span>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<span className="rounded-full border border-purple-500/20 bg-purple-50 px-2 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
Client
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-gray-500/20 bg-gray-50 px-2 py-0.5 text-xs text-gray-700 dark:bg-gray-800/50 dark:text-gray-300">
|
||||
Edge
|
||||
@@ -339,9 +343,8 @@ export function NodesPage() {
|
||||
variant={getNodeStatusVariant(node.status)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{node.agent_version || 'unknown'} /{' '}
|
||||
{node.nginx_version || 'unknown'}
|
||||
<td className="px-3 py-4 text-sm text-[var(--foreground-secondary)]">
|
||||
{node.agent_version || 'unknown'}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
@@ -350,6 +353,8 @@ export function NodesPage() {
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
variant={getRelayStatusVariant(node.relay_status)}
|
||||
/>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<span className="text-sm text-[var(--foreground-secondary)]">-</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label={getOpenrestyStatusLabel(
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { ReleaseChannel } from '@/features/update/types';
|
||||
export interface NodeItem {
|
||||
id: number;
|
||||
node_id: string;
|
||||
node_type: 'edge_node' | 'tunnel_relay';
|
||||
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
|
||||
name: string;
|
||||
ip: string;
|
||||
ip_manual_override: boolean;
|
||||
relay_bind_port: number;
|
||||
relay_client_access_addr: string;
|
||||
relay_agent_access_addr: string;
|
||||
relay_client_proxy_url: string;
|
||||
relay_auth_token: string;
|
||||
relay_status: string;
|
||||
@@ -46,12 +47,13 @@ export interface NodeBootstrapToken {
|
||||
}
|
||||
|
||||
export interface NodeMutationPayload {
|
||||
node_type: 'edge_node' | 'tunnel_relay';
|
||||
node_type: 'edge_node' | 'tunnel_relay' | 'tunnel_client';
|
||||
name: string;
|
||||
ip: string;
|
||||
ip_manual_override: boolean;
|
||||
relay_bind_port?: number;
|
||||
relay_client_access_addr?: string;
|
||||
relay_agent_access_addr?: string;
|
||||
relay_client_proxy_url?: string;
|
||||
auto_update_enabled: boolean;
|
||||
geo_name: string;
|
||||
@@ -207,6 +209,25 @@ export interface NodeHealthEvent {
|
||||
resolved_at?: string | null;
|
||||
}
|
||||
|
||||
export interface RelayProxyStat {
|
||||
name: string;
|
||||
type: string;
|
||||
status: 'online' | 'offline';
|
||||
client_version: string;
|
||||
last_start_time: string;
|
||||
last_close_time: string;
|
||||
client_addr: string;
|
||||
}
|
||||
|
||||
export interface RelayDashboardSnapshot {
|
||||
total_proxies: number;
|
||||
online_proxies: number;
|
||||
offline_proxies: number;
|
||||
proxies: RelayProxyStat[];
|
||||
total_connections: number;
|
||||
client_counts: number;
|
||||
}
|
||||
|
||||
export interface NodeObservability {
|
||||
node_id: string;
|
||||
profile: NodeSystemProfile | null;
|
||||
@@ -215,4 +236,5 @@ export interface NodeObservability {
|
||||
health_events: NodeHealthEvent[];
|
||||
analytics: NodeObservabilityAnalytics;
|
||||
trends: NodeObservabilityTrends;
|
||||
relay_dashboard?: RelayDashboardSnapshot | null;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
getWAFSiteRuleGroups,
|
||||
replaceWAFSiteRuleGroups,
|
||||
} from '@/features/waf/api/waf';
|
||||
import { getTunnels } from '@/features/tunnels/api/tunnels';
|
||||
import { getNodes } from '@/features/nodes/api/nodes';
|
||||
import {
|
||||
buildDomainRowsFromRoute,
|
||||
DomainListInput,
|
||||
@@ -549,9 +549,11 @@ function ReverseProxySection({
|
||||
onSave: SaveHandler;
|
||||
}) {
|
||||
const tunnelsQuery = useQuery({
|
||||
queryKey: ['tunnels'],
|
||||
queryFn: getTunnels,
|
||||
queryKey: ['nodes'],
|
||||
queryFn: getNodes,
|
||||
});
|
||||
|
||||
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
|
||||
|
||||
const form = useForm<ReverseProxyValues>({
|
||||
resolver: zodResolver(reverseProxySchema),
|
||||
@@ -689,7 +691,7 @@ function ReverseProxySection({
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{(tunnelsQuery.data ?? []).map((tunnel) => (
|
||||
{(tunnelClients).map((tunnel) => (
|
||||
<option key={tunnel.id} value={tunnel.id}>
|
||||
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
|
||||
</option>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
parseOriginUrls,
|
||||
validateDomains,
|
||||
} from '@/features/proxy-routes/helpers';
|
||||
import { getTunnels } from '@/features/tunnels/api/tunnels';
|
||||
import { getNodes } from '@/features/nodes/api/nodes';
|
||||
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
||||
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
||||
import {
|
||||
@@ -173,10 +173,11 @@ export function ProxyRouteCreateDrawer({
|
||||
enabled: open,
|
||||
});
|
||||
const tunnelsQuery = useQuery({
|
||||
queryKey: ['tunnels'],
|
||||
queryFn: getTunnels,
|
||||
queryKey: ['nodes'],
|
||||
queryFn: getNodes,
|
||||
enabled: open,
|
||||
});
|
||||
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
|
||||
|
||||
const combinedDomainSuggestions = useMemo(
|
||||
() => [
|
||||
@@ -387,7 +388,7 @@ export function ProxyRouteCreateDrawer({
|
||||
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||
>
|
||||
<option value="">请选择...</option>
|
||||
{(tunnelsQuery.data ?? []).map((tunnel) => (
|
||||
{(tunnelClients).map((tunnel) => (
|
||||
<option key={tunnel.id} value={tunnel.id}>
|
||||
{tunnel.name} ({tunnel.status === 'online' ? '在线' : '离线'})
|
||||
</option>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
|
||||
|
||||
export function getTunnels() {
|
||||
return apiRequest<TunnelItem[]>('/tunnels/');
|
||||
}
|
||||
|
||||
export function createTunnel(payload: TunnelMutationPayload) {
|
||||
return apiRequest<TunnelItem>('/tunnels/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTunnel(id: number, payload: TunnelMutationPayload) {
|
||||
return apiRequest<TunnelItem>(`/tunnels/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTunnel(id: number) {
|
||||
return apiRequest<void>(`/tunnels/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function rotateTunnelToken(id: number) {
|
||||
return apiRequest<TunnelItem>(`/tunnels/${id}/rotate-token`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import type { TunnelItem } from '@/features/tunnels/types';
|
||||
import {
|
||||
CodeBlock,
|
||||
PrimaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getServerUrl } from '@/features/nodes/utils';
|
||||
|
||||
export function TunnelDeploymentModal({
|
||||
isOpen,
|
||||
tunnel,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
tunnel?: TunnelItem | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && !serverUrl) {
|
||||
setServerUrl(window.location.origin);
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
if (!tunnel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedServerUrl = getServerUrl(serverUrl);
|
||||
const image = 'ghcr.io/rain-kl/openflare-flared:latest';
|
||||
|
||||
const dockerRunCmd = [
|
||||
`docker pull ${image}`,
|
||||
`docker rm -f openflare-flared 2>/dev/null || true`,
|
||||
`docker run -d --name openflare-flared --net host --restart unless-stopped \\`,
|
||||
` -v /var/run/docker.sock:/var/run/docker.sock \\`,
|
||||
` -v openflare_flared_data:/app/data \\`,
|
||||
` -e OPENFLARE_SERVER_URL=${normalizedServerUrl} \\`,
|
||||
` -e OPENFLARE_TUNNEL_TOKEN=${tunnel.tunnel_token} \\`,
|
||||
` ${image}`,
|
||||
].join('\n');
|
||||
|
||||
const dockerComposeYaml = `version: '3.8'
|
||||
services:
|
||||
openflare-flared:
|
||||
image: ${image}
|
||||
container_name: openflare-flared
|
||||
network_mode: "host"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- openflare_flared_data:/app/data
|
||||
environment:
|
||||
- OPENFLARE_SERVER_URL=${normalizedServerUrl}
|
||||
- OPENFLARE_TUNNEL_TOKEN=${tunnel.tunnel_token}
|
||||
|
||||
volumes:
|
||||
openflare_flared_data:
|
||||
`;
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="隧道客户端部署"
|
||||
description="请在需要穿透的内网服务器上部署 Flared 客户端。"
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<PrimaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
方式一:Docker Run
|
||||
</p>
|
||||
<CodeBlock className="whitespace-pre-wrap">{dockerRunCmd}</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
||||
方式二:Docker Compose (docker-compose.yml)
|
||||
</p>
|
||||
<CodeBlock className="whitespace-pre-wrap">{dockerComposeYaml}</CodeBlock>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
隧道 Token (仅供高级用法)
|
||||
</p>
|
||||
<p className="mt-1 break-all text-xs text-[var(--foreground-secondary)]">
|
||||
{tunnel.tunnel_token}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const tunnelEditorSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入隧道名称')
|
||||
.max(128, '隧道名称不能超过 128 个字符'),
|
||||
remark: z.string().trim().max(255, '备注不能超过 255 个字符').optional().default(''),
|
||||
});
|
||||
|
||||
type TunnelEditorValues = z.infer<typeof tunnelEditorSchema>;
|
||||
|
||||
const defaultValues: TunnelEditorValues = {
|
||||
name: '',
|
||||
remark: '',
|
||||
};
|
||||
|
||||
function buildFormValues(tunnel?: Partial<TunnelItem> | null): TunnelEditorValues {
|
||||
if (!tunnel) {
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
return {
|
||||
name: tunnel.name ?? '',
|
||||
remark: tunnel.remark ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function TunnelEditorModal({
|
||||
isOpen,
|
||||
tunnel,
|
||||
isSubmitting,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
tunnel?: Partial<TunnelItem> | null;
|
||||
isSubmitting: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
submitLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: TunnelMutationPayload) => void;
|
||||
}) {
|
||||
const form = useForm<TunnelEditorValues>({
|
||||
resolver: zodResolver(tunnelEditorSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
form.reset(buildFormValues(tunnel));
|
||||
}, [form, isOpen, tunnel]);
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
onSubmit({
|
||||
name: values.name.trim(),
|
||||
remark: values.remark.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
description={description}
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="tunnel-editor-form"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '保存中...' : submitLabel}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="tunnel-editor-form" className="space-y-5" onSubmit={handleSubmit}>
|
||||
<ResourceField
|
||||
label="隧道名称"
|
||||
hint="示例:home-nas-tunnel"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="home-nas-tunnel"
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField
|
||||
label="备注 (可选)"
|
||||
hint="用于描述该隧道的用途。"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="家庭内网穿透"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
|
||||
import {
|
||||
createTunnel,
|
||||
deleteTunnel,
|
||||
getTunnels,
|
||||
updateTunnel,
|
||||
rotateTunnelToken,
|
||||
} from '@/features/tunnels/api/tunnels';
|
||||
import { TunnelEditorModal } from '@/features/tunnels/components/tunnel-editor-modal';
|
||||
import { TunnelDeploymentModal } from '@/features/tunnels/components/tunnel-deployment-modal';
|
||||
import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types';
|
||||
import { PrimaryButton } from '@/features/shared/components/resource-primitives';
|
||||
import { formatRelativeTime } from '@/lib/utils/date';
|
||||
import { isMeaningfulTime } from '@/features/nodes/utils';
|
||||
|
||||
const tunnelsQueryKey = ['tunnels'];
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'info' | 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function TunnelsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
|
||||
const [editingTunnel, setEditingTunnel] = useState<TunnelItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const [deploymentTunnel, setDeploymentTunnel] = useState<TunnelItem | null>(null);
|
||||
const [isDeploymentOpen, setIsDeploymentOpen] = useState(false);
|
||||
|
||||
const tunnelsQuery = useQuery({
|
||||
queryKey: tunnelsQueryKey,
|
||||
queryFn: getTunnels,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (payload: TunnelMutationPayload) => {
|
||||
return editingTunnel
|
||||
? updateTunnel(editingTunnel.id, payload)
|
||||
: createTunnel(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: editingTunnel ? '隧道已更新。' : '隧道已创建。',
|
||||
});
|
||||
setEditingTunnel(null);
|
||||
setIsEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTunnel,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '隧道已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: rotateTunnelToken,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '隧道 Token 已轮转,客户端需要重新部署连接。' });
|
||||
await queryClient.invalidateQueries({ queryKey: tunnelsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||
},
|
||||
});
|
||||
|
||||
const tunnels = useMemo(() => tunnelsQuery.data ?? [], [tunnelsQuery.data]);
|
||||
|
||||
const handleResetEditor = () => {
|
||||
setFeedback(null);
|
||||
setEditingTunnel(null);
|
||||
setIsEditorOpen(false);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFeedback(null);
|
||||
setEditingTunnel(null);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (tunnel: TunnelItem) => {
|
||||
setFeedback(null);
|
||||
setEditingTunnel(tunnel);
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleDeployment = (tunnel: TunnelItem) => {
|
||||
setFeedback(null);
|
||||
setDeploymentTunnel(tunnel);
|
||||
setIsDeploymentOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (tunnelId: number, tunnelName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除隧道“${tunnelName}”吗?删除后该隧道的连接将立即中断。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(tunnelId);
|
||||
};
|
||||
|
||||
const handleRotateToken = (tunnelId: number, tunnelName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认重置隧道“${tunnelName}”的 Token 吗?已连接的客户端将被断开,并且需要使用新的 Token 重新部署。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
rotateTokenMutation.mutate(tunnelId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="内网穿透隧道"
|
||||
description="管理你的内网穿透隧道。"
|
||||
action={
|
||||
<PrimaryButton type="button" onClick={handleCreate}>
|
||||
新建 Tunnel
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage
|
||||
tone={feedback.tone}
|
||||
message={feedback.message}
|
||||
onClear={() => setFeedback(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="隧道列表"
|
||||
description="列表每 5 秒自动刷新一次。"
|
||||
>
|
||||
{tunnelsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : tunnelsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="隧道列表加载失败"
|
||||
description={getErrorMessage(tunnelsQuery.error)}
|
||||
/>
|
||||
) : tunnels.length === 0 ? (
|
||||
<EmptyState
|
||||
title="当前还没有隧道"
|
||||
description="点击右上角的“新建 Tunnel”按钮创建你的第一个隧道。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">名称 / 隧道ID</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">版本 / 校验</th>
|
||||
<th className="px-3 py-3 font-medium">已连接 Relay</th>
|
||||
<th className="px-3 py-3 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{tunnels.map((tunnel) => (
|
||||
<tr key={tunnel.id} className="align-top">
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-[var(--foreground-primary)]">
|
||||
{tunnel.name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
{tunnel.tunnel_id.slice(0, 8)}...
|
||||
</p>
|
||||
{tunnel.remark ? (
|
||||
<p className="text-xs text-[var(--foreground-muted)] line-clamp-1 max-w-xs">
|
||||
{tunnel.remark}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
<StatusBadge
|
||||
label={tunnel.status === 'online' ? '在线' : '离线'}
|
||||
variant={tunnel.status === 'online' ? 'success' : 'danger'}
|
||||
/>
|
||||
{tunnel.status === 'online' && isMeaningfulTime(tunnel.last_seen_at) ? (
|
||||
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||
活跃:{formatRelativeTime(tunnel.last_seen_at)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<div className="space-y-1 text-[var(--foreground-secondary)]">
|
||||
<p>
|
||||
版本:{tunnel.current_version || '未知'}
|
||||
</p>
|
||||
<p className="text-xs" title={tunnel.current_checksum}>
|
||||
{tunnel.current_checksum
|
||||
? `签名:${tunnel.current_checksum.slice(0, 12)}...`
|
||||
: '签名:未知'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{tunnel.connected_relays && tunnel.connected_relays.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{tunnel.connected_relays.map((relay, index) => (
|
||||
<p key={index} className="text-xs">
|
||||
{relay}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs">暂无</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-right">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeployment(tunnel)}
|
||||
className="text-sm font-medium text-blue-600 transition hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
部署命令
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(tunnel)}
|
||||
className="text-sm font-medium text-[var(--foreground-primary)] transition hover:text-blue-600 dark:hover:text-blue-500"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(tunnel.id, tunnel.name)}
|
||||
className="text-sm font-medium text-red-600 transition hover:text-red-700 dark:text-red-500 dark:hover:text-red-400"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRotateToken(tunnel.id, tunnel.name)}
|
||||
className="text-xs font-medium text-amber-600 transition hover:text-amber-700 dark:text-amber-500 dark:hover:text-amber-400"
|
||||
>
|
||||
重置 Token
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<TunnelEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
tunnel={editingTunnel}
|
||||
isSubmitting={saveMutation.isPending}
|
||||
title={editingTunnel ? '编辑隧道' : '新建隧道'}
|
||||
description={
|
||||
editingTunnel
|
||||
? '修改此隧道的名称和备注。'
|
||||
: '创建一个内网穿透隧道,稍后你将获得用于客户端连接的 Token 和部署命令。'
|
||||
}
|
||||
submitLabel={editingTunnel ? '保存修改' : '确认创建'}
|
||||
onClose={handleResetEditor}
|
||||
onSubmit={(payload) => {
|
||||
setFeedback(null);
|
||||
saveMutation.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TunnelDeploymentModal
|
||||
isOpen={isDeploymentOpen}
|
||||
tunnel={deploymentTunnel}
|
||||
onClose={() => setIsDeploymentOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export interface TunnelItem {
|
||||
id: number;
|
||||
tunnel_id: string;
|
||||
name: string;
|
||||
tunnel_token: string;
|
||||
status: 'online' | 'offline';
|
||||
client_version: string;
|
||||
frp_version: string;
|
||||
last_seen_at?: string | null;
|
||||
last_error: string;
|
||||
current_version: string;
|
||||
current_checksum: string;
|
||||
connected_relays: string[];
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TunnelMutationPayload {
|
||||
name: string;
|
||||
remark: string;
|
||||
}
|
||||
+2
-10
@@ -10,24 +10,16 @@ COPY openflare_server/ ../openflare_server/
|
||||
COPY openflared/ .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o flared ./cmd/flared
|
||||
|
||||
# Extract frpc binary from official image
|
||||
FROM fatedier/frpc:v0.69.0 AS frpc
|
||||
|
||||
# Final runtime image
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
FROM fatedier/frpc:v0.69.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy openflared binary
|
||||
COPY --from=builder /build/flared .
|
||||
|
||||
# Copy frpc binary from official image
|
||||
COPY --from=frpc /usr/bin/frpc /usr/local/bin/frpc
|
||||
|
||||
ENV OPENFLARE_DATA_DIR=/app/data
|
||||
ENV OPENFLARE_FRPC_PATH=/usr/local/bin/frpc
|
||||
ENV OPENFLARE_FRPC_PATH=/usr/bin/frpc
|
||||
|
||||
ENTRYPOINT ["/app/flared"]
|
||||
CMD []
|
||||
|
||||
Reference in New Issue
Block a user