diff --git a/openflare_relay/Dockerfile b/openflare_relay/Dockerfile index 67b0402b..5ba2580c 100644 --- a/openflare_relay/Dockerfile +++ b/openflare_relay/Dockerfile @@ -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"] diff --git a/openflare_server/controller/agent.go b/openflare_server/controller/agent.go index 2f8a3b2e..5ceaa341 100644 --- a/openflare_server/controller/agent.go +++ b/openflare_server/controller/agent.go @@ -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, "当前没有激活版本") diff --git a/openflare_server/controller/flared.go b/openflare_server/controller/flared.go deleted file mode 100644 index b0d08a5a..00000000 --- a/openflare_server/controller/flared.go +++ /dev/null @@ -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 - } - } - } -} diff --git a/openflare_server/controller/tunnel.go b/openflare_server/controller/tunnel.go deleted file mode 100644 index 6a8bb2ec..00000000 --- a/openflare_server/controller/tunnel.go +++ /dev/null @@ -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) -} diff --git a/openflare_server/middleware/tunnel-auth.go b/openflare_server/middleware/tunnel-auth.go deleted file mode 100644 index 9f8d69bc..00000000 --- a/openflare_server/middleware/tunnel-auth.go +++ /dev/null @@ -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() - } -} diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index 233de054..e5e1f4cb 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -34,7 +34,7 @@ func registeredModels() []any { &ProxyRoute{}, &ConfigVersion{}, &Node{}, - &Tunnel{}, + &NodeSystemProfile{}, &ApplyLog{}, &NodeMetricSnapshot{}, diff --git a/openflare_server/model/migrate/v19.go b/openflare_server/model/migrate/v19.go new file mode 100644 index 00000000..ea6d28fb --- /dev/null +++ b/openflare_server/model/migrate/v19.go @@ -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 +} diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index 70b2f7cd..0be1b9a2 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -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") { diff --git a/openflare_server/model/proxy_route.go b/openflare_server/model/proxy_route.go index 26cadc86..158fb34b 100644 --- a/openflare_server/model/proxy_route.go +++ b/openflare_server/model/proxy_route.go @@ -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 diff --git a/openflare_server/model/tunnel.go b/openflare_server/model/tunnel.go deleted file mode 100644 index 362f3340..00000000 --- a/openflare_server/model/tunnel.go +++ /dev/null @@ -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 -} diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index ea371c89..8d81709c 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -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) - } + } } diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 94b2e784..638441df 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -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, diff --git a/openflare_server/service/flared_ws.go b/openflare_server/service/flared_ws.go deleted file mode 100644 index 54e5decf..00000000 --- a/openflare_server/service/flared_ws.go +++ /dev/null @@ -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 -} diff --git a/openflare_server/service/node.go b/openflare_server/service/node.go index 2045b358..cd2b8765 100644 --- a/openflare_server/service/node.go +++ b/openflare_server/service/node.go @@ -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" } diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index 8f7abf7f..04dc342f 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -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, diff --git a/openflare_server/service/relay.go b/openflare_server/service/relay.go index f1d77987..579e2296 100644 --- a/openflare_server/service/relay.go +++ b/openflare_server/service/relay.go @@ -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, diff --git a/openflare_server/service/tunnel.go b/openflare_server/service/tunnel.go deleted file mode 100644 index 06389168..00000000 --- a/openflare_server/service/tunnel.go +++ /dev/null @@ -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 -} diff --git a/openflare_server/web/app/(dashboard)/tunnel/page.tsx b/openflare_server/web/app/(dashboard)/tunnel/page.tsx deleted file mode 100644 index 963b24b3..00000000 --- a/openflare_server/web/app/(dashboard)/tunnel/page.tsx +++ /dev/null @@ -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 ; -} diff --git a/openflare_server/web/features/nodes/components/node-detail-page.tsx b/openflare_server/web/features/nodes/components/node-detail-page.tsx index 31bfe155..cf2c9cdf 100644 --- a/openflare_server/web/features/nodes/components/node-detail-page.tsx +++ b/openflare_server/web/features/nodes/components/node-detail-page.tsx @@ -746,39 +746,77 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { : '当前没有活动中的健康事件' } /> - - - + {node.node_type === 'tunnel_relay' ? ( + <> + + + + + ) : ( + <> + + + + + )}
@@ -940,7 +978,8 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { )} - + {node.node_type === 'edge_node' ? ( + {observabilityQuery.isLoading ? ( ) : observabilityQuery.isError ? ( @@ -1054,9 +1093,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { /> )} + ) : null}
-
+ {node.node_type === 'edge_node' ? ( + <> +
- -
+ +
+ + ) : null} + + {node.node_type === 'tunnel_relay' ? ( +
+ + {observability?.relay_dashboard?.proxies ? ( +
+ + + + + + + + + + + + {observability.relay_dashboard.proxies.map((proxy) => ( + + + + + + + + ))} + +
代理名称类型状态客户端地址最近连接
+ {proxy.name} + {proxy.type} + + {proxy.client_addr} + {isMeaningfulTime(proxy.last_start_time) + ? formatRelativeTime(proxy.last_start_time) + : '—'} +
+
+ ) : ( + + )} +
+
+ ) : null}
-
@@ -1273,6 +1367,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
+ ) : null} - @@ -1569,6 +1665,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) { + ) : null}
| 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({ + @@ -386,6 +391,14 @@ export function NodeEditorModal({ + + + + 节点 状态 - Agent / OpenResty + Version 运行健康 当前版本 @@ -318,6 +318,10 @@ export function NodesPage() { Relay + ) : node.node_type === 'tunnel_client' ? ( + + Client + ) : ( Edge @@ -339,9 +343,8 @@ export function NodesPage() { variant={getNodeStatusVariant(node.status)} /> - - {node.agent_version || 'unknown'} /{' '} - {node.nginx_version || 'unknown'} + + {node.agent_version || 'unknown'}
@@ -350,6 +353,8 @@ export function NodesPage() { label={getRelayStatusLabel(node.relay_status)} variant={getRelayStatusVariant(node.relay_status)} /> + ) : node.node_type === 'tunnel_client' ? ( + - ) : ( node.node_type === 'tunnel_client'); const form = useForm({ 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)]" > - {(tunnelsQuery.data ?? []).map((tunnel) => ( + {(tunnelClients).map((tunnel) => ( diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-create-drawer.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-create-drawer.tsx index 7ae34345..bee296a8 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-route-create-drawer.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-route-create-drawer.tsx @@ -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)]" > - {(tunnelsQuery.data ?? []).map((tunnel) => ( + {(tunnelClients).map((tunnel) => ( diff --git a/openflare_server/web/features/tunnels/api/tunnels.ts b/openflare_server/web/features/tunnels/api/tunnels.ts deleted file mode 100644 index ad6f6235..00000000 --- a/openflare_server/web/features/tunnels/api/tunnels.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { apiRequest } from '@/lib/api/client'; - -import type { TunnelItem, TunnelMutationPayload } from '@/features/tunnels/types'; - -export function getTunnels() { - return apiRequest('/tunnels/'); -} - -export function createTunnel(payload: TunnelMutationPayload) { - return apiRequest('/tunnels/', { - method: 'POST', - body: JSON.stringify(payload), - }); -} - -export function updateTunnel(id: number, payload: TunnelMutationPayload) { - return apiRequest(`/tunnels/${id}/update`, { - method: 'POST', - body: JSON.stringify(payload), - }); -} - -export function deleteTunnel(id: number) { - return apiRequest(`/tunnels/${id}/delete`, { - method: 'POST', - }); -} - -export function rotateTunnelToken(id: number) { - return apiRequest(`/tunnels/${id}/rotate-token`, { - method: 'POST', - }); -} diff --git a/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx b/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx deleted file mode 100644 index 72c0fa8a..00000000 --- a/openflare_server/web/features/tunnels/components/tunnel-deployment-modal.tsx +++ /dev/null @@ -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 ( - - - 关闭 - -
- } - > -
-
-

- 方式一:Docker Run -

- {dockerRunCmd} -
-
-

- 方式二:Docker Compose (docker-compose.yml) -

- {dockerComposeYaml} -
-
-

- 隧道 Token (仅供高级用法) -

-

- {tunnel.tunnel_token} -

-
-
- - ); -} diff --git a/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx b/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx deleted file mode 100644 index 802bf3a5..00000000 --- a/openflare_server/web/features/tunnels/components/tunnel-editor-modal.tsx +++ /dev/null @@ -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; - -const defaultValues: TunnelEditorValues = { - name: '', - remark: '', -}; - -function buildFormValues(tunnel?: Partial | 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 | null; - isSubmitting: boolean; - title: string; - description: string; - submitLabel: string; - onClose: () => void; - onSubmit: (payload: TunnelMutationPayload) => void; -}) { - const form = useForm({ - 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 ( - - - 取消 - - - {isSubmitting ? '保存中...' : submitLabel} - -
- } - > -
- - - - - - - -
- - ); -} diff --git a/openflare_server/web/features/tunnels/components/tunnels-page.tsx b/openflare_server/web/features/tunnels/components/tunnels-page.tsx deleted file mode 100644 index aaf585f8..00000000 --- a/openflare_server/web/features/tunnels/components/tunnels-page.tsx +++ /dev/null @@ -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(null); - - const [editingTunnel, setEditingTunnel] = useState(null); - const [isEditorOpen, setIsEditorOpen] = useState(false); - - const [deploymentTunnel, setDeploymentTunnel] = useState(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 ( - <> -
- - 新建 Tunnel - - } - /> - - {feedback ? ( - setFeedback(null)} - /> - ) : null} - - - {tunnelsQuery.isLoading ? ( - - ) : tunnelsQuery.isError ? ( - - ) : tunnels.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - {tunnels.map((tunnel) => ( - - - - - - - - ))} - -
名称 / 隧道ID状态版本 / 校验已连接 Relay操作
-
-

- {tunnel.name} -

-

- {tunnel.tunnel_id.slice(0, 8)}... -

- {tunnel.remark ? ( -

- {tunnel.remark} -

- ) : null} -
-
-
- - {tunnel.status === 'online' && isMeaningfulTime(tunnel.last_seen_at) ? ( -

- 活跃:{formatRelativeTime(tunnel.last_seen_at)} -

- ) : null} -
-
-
-

- 版本:{tunnel.current_version || '未知'} -

-

- {tunnel.current_checksum - ? `签名:${tunnel.current_checksum.slice(0, 12)}...` - : '签名:未知'} -

-
-
- {tunnel.connected_relays && tunnel.connected_relays.length > 0 ? ( -
- {tunnel.connected_relays.map((relay, index) => ( -

- {relay} -

- ))} -
- ) : ( -

暂无

- )} -
-
-
- - - -
- -
-
-
- )} -
-
- - { - setFeedback(null); - saveMutation.mutate(payload); - }} - /> - - setIsDeploymentOpen(false)} - /> - - ); -} diff --git a/openflare_server/web/features/tunnels/types.ts b/openflare_server/web/features/tunnels/types.ts deleted file mode 100644 index 6c265065..00000000 --- a/openflare_server/web/features/tunnels/types.ts +++ /dev/null @@ -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; -} diff --git a/openflared/Dockerfile b/openflared/Dockerfile index 09b36ed0..78249ce1 100644 --- a/openflared/Dockerfile +++ b/openflared/Dockerfile @@ -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 []