[优化] Phase2

This commit is contained in:
ryan
2026-06-01 08:26:45 +08:00
parent 78047d1b38
commit 4196343ad3
26 changed files with 1814 additions and 350 deletions
+58 -7
View File
@@ -2,7 +2,9 @@
你会学到:OpenFlare 的整体架构、Server、Agent、OpenResty 与管理端前端的职责边界,以及一次配置发布从管理端到节点生效的请求流。
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。
OpenFlare 由 Server、Agent、节点本地 OpenResty 和管理端前端组成。Server 是控制面,Agent 是节点侧唯一受控落地入口,OpenResty 是实际数据面。内网穿透场景中,Relay(frps 管理器)和 OpenFlared(frpc 管理器)扩展了数据面流量路径。
### 标准反代流量路径
```text
Browser
@@ -24,14 +26,38 @@ OpenResty binary
Origin
```
### 内网穿透流量路径
```text
Browser
|
| HTTPS request
v
OpenResty (Agent, TLS/WAF) <-- TunnelRelay 节点
|
| proxy_pass http://localhost:vhost_port (Host header preserved)
v
OpenFlareRelay (frps) <-- TunnelRelay 节点,与 Agent 同机部署
|
| frp tunnel protocol (HTTP Vhost routing by Host header)
v
OpenFlared (frpc) <-- 内网服务器
|
| HTTP/HTTPS forward
v
Internal Service (192.168.x.x)
```
## 组件职责
| 组件 | 职责 |
| --------- | ---------------------------------------------------------------------- |
| Server | 管理端 UI、管理 API、Agent API、配置渲染、版本发布、数据存储与聚合查询 |
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
| Frontend | 管理网站配置、WAF、源站、证书、节点、版本、用户、设置与观测页面 |
| 组件 | 职责 |
| --------------- | ---------------------------------------------------------------------- |
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
## Server
@@ -97,6 +123,27 @@ Agent 执行 OpenResty 校验与 reload
Agent 上报应用结果
```
### Relay 同步流
```text
Relay HTTP heartbeat -> Server 返回 frps 配置
Relay 生成 frps.toml 并启动/重启 frps
Relay 定期上报 frps 状态
```
Relay 使用与 Agent 相同的 `agent_token` 认证(同一节点),通过 `/api/relay/*` 端点通信。frps 配置相对静态(端口、认证 Token),通过心跳下发,不纳入版本化发布流。
### OpenFlared 同步流
```text
Client HTTP heartbeat -> Server 返回 tunnel 配置版本摘要
Client 发现新版本 -> 拉取 tunnel 路由配置(relay 列表 + proxy 定义)
Client 为每个 Relay 生成 frpc.toml 并启动/重载 frpc 进程
Client 上报应用结果
```
OpenFlared 使用独立的 `tunnel_token` 认证,通过 `/api/flared/*` 端点通信。Tunnel 路由配置随发布流程版本化同步,配置变更时优先使用 `frpc reload` 热重载。
**WebSocket 升级流程**(可选,通过 `AgentWebsocketUpgradeEnabled` 选项控制):
当启用 WebSocket 升级时:
@@ -126,6 +173,7 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
* `origins`
* `config_versions`
* `nodes`
* `tunnels`
* `auth_sources`
* `external_accounts`
* `node_system_profiles`
@@ -152,6 +200,9 @@ WAF 在 OpenResty `access_by_lua_file` 阶段执行。规则来自当前激活
| 全局单激活版本 | 降低 MVP 复杂度,保证所有节点默认一致;支持版本预览、历史查询与一键回滚 |
| 网站配置聚合多域名 | 支持一个业务站点共享站点级策略,同时允许按域名绑定证书 |
| 观测数据服务端聚合 | 避免前端临时统计造成口径不一致 |
| 内网穿透基于 frp 整合 | 复用成熟隧道协议,避免自研隧道的稳定性风险;frps HTTP Vhost 路由天然适配 |
| Relay/Client 独立二进制 | 职责分离,Relay 管理 frps,Client 管理 frpc,各自独立升级和部署 |
| Tunnel 与 Node 体系分离 | Tunnel 客户端在内网运行,与公网节点概念不同,使用独立的注册和认证体系 |
## 贡献者阅读建议
+38 -2
View File
@@ -31,12 +31,15 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
| 管理端前端 | 基于 Next.js 的正式管理端 |
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
| 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 |
默认工作方式:
* 所有节点消费同一份全局激活版本。
* Server 保存配置与状态,不直接 SSH 管理节点。
* Agent 是节点侧唯一受控落地入口。
* TunnelRelay 节点同时运行 Agent(OpenResty)和 Relay(frps),提供内网穿透中继。
* OpenFlared 客户端在内网运行,管理 frpc 进程连接 Relay,将流量转发到内网服务。
## 典型使用场景
@@ -48,6 +51,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
| 快速回滚 | 重新激活旧版本,让 Agent 拉取并应用 |
| 证书托管 | 为不同域名绑定 TLS 证书 |
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
## 网站配置约束
@@ -71,13 +75,45 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
上游约束:
* `proxy_routes` 至少包含一个上游地址。
* `proxy_routes` 至少包含一个上游地址(直连类型),或关联一个 Tunnel(内网穿透类型)。
* `proxy_routes.upstream_type` 区分上游类型:`direct`(默认,直连)或 `tunnel`(内网穿透)。
* 为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡。
* 上游统一渲染为带 keepalive 的 named `upstream`。
* 单上游可附带 base path 或 query 并在 `proxy_pass` 中追加。
* 多上游限定为纯 `scheme://host[:port]`。
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
* 所有上游地址都必须为合法 `http://` 或 `https://`。
* 所有直连类型上游地址都必须为合法 `http://` 或 `https://`。
* 内网穿透类型上游必须关联 `tunnel_id`,并指定内网目标地址与协议。
## 内网穿透约束
OpenFlare 通过 TunnelRelay 节点与 OpenFlared 客户端实现内网穿透,底层基于 frp 构建。
节点类型:
* `nodes.node_type` 区分节点类型:`edge_node`(边缘节点,默认)和 `tunnel_relay`(隧道中继)。
* TunnelRelay 节点同时运行 Agent(管理 OpenResty)和 Relay(管理 frps),共享同一个 `agent_token`。
* Agent 负责 HTTPS 终结、WAF 防护等,Relay 负责隧道流量中继。
Tunnel 实体:
* `tunnels` 表存储内网穿透客户端注册信息,与 `nodes` 体系独立。
* 每个 Tunnel 拥有唯一的 `tunnel_id`(格式 `tun-<32hex>`)和 `tunnel_token`。
* OpenFlared 客户端使用 `tunnel_token` 认证,通过 `/api/flared/*` 端点通信。
流量路径:
* 数据面:浏览器 → Agent(OpenResty,TLS/WAF)→ Relay(frps,HTTP Vhost 路由)→ 隧道 → Client(frpc)→ 内网服务。
* frps 使用 HTTP Vhost 单端口复用,通过 Host 头将请求路由到对应 frpc,无需为每个隧道分配端口。
* Relay 配置(frps 端口、认证 Token)通过心跳下发,相对静态。
* Tunnel 路由配置(frpc 代理定义)随发布流程版本化同步。
当前阶段约束:
* 仅支持 HTTP 协议隧道流量,保留未来 TCP 隧道扩展性。
* Tunnel 类型上游的域名 DNS 应仅解析到 TunnelRelay 节点,EdgeNode 上对应请求会因 frps 不可达返回 502。
* 一个 OpenFlared 客户端可连接多个 Relay(每个 Relay 对应一个 frpc 进程)。
## HTTPS 约束
+16 -21
View File
@@ -191,31 +191,26 @@ func agentWSReadTimeout() time.Duration {
return timeout
}
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.AgentWSClient) {
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
func agentWSWriteTimeout() time.Duration {
return 10 * time.Second
}
func streamAgentWSMessages(c *gin.Context, conn *websocket.Conn, client *service.WSClient) {
for {
select {
case message := <-client.Messages():
slog.Debug("agent ws sending message", "node_id", client.NodeID(), "type", message.Type)
if err := websocket.JSON.Send(conn, message); err != nil {
slog.Debug("agent ws send failed", "node_id", client.NodeID(), "type", message.Type, "error", err)
client.Close()
return
}
case <-pingTicker.C:
message := service.AgentWSOutboundMessage{Type: service.AgentWSMessageTypePing}
slog.Debug("agent ws sending ping", "node_id", client.NodeID())
if err := websocket.JSON.Send(conn, message); err != nil {
slog.Debug("agent ws ping failed", "node_id", client.NodeID(), "error", err)
client.Close()
return
}
case <-c.Request.Context().Done():
return
case <-client.Done():
return
case <-c.Request.Context().Done():
client.Close()
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("agent ws send failed", "node_id", client.ID(), "error", err)
return
}
}
}
}
+171
View File
@@ -0,0 +1,171 @@
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
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
package controller
import (
"log/slog"
"net"
"openflare/model"
"openflare/service"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/net/websocket"
)
// RelayHeartbeat godoc
// @Summary Report relay heartbeat
// @Tags Relay
// @Accept json
// @Produce json
// @Security AgentTokenAuth
// @Param payload body service.RelayHeartbeatPayload true "Relay heartbeat payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/relay/heartbeat [post]
func RelayHeartbeat(c *gin.Context) {
var payload service.RelayHeartbeatPayload
if !bindJSON(c, &payload) {
return
}
authNode, ok := c.Get("relay_node")
if !ok {
respondUnauthorized(c, "无权进行此操作")
return
}
node := authNode.(*model.Node)
result, err := service.HeartbeatRelay(node, payload)
if err != nil {
respondFailure(c, err.Error())
return
}
respondSuccess(c, result)
}
// RelayWebSocket godoc
// @Summary Upgrade relay connection to websocket
// @Tags Relay
// @Security AgentTokenAuth
// @Router /api/relay/ws [get]
func RelayWebSocket(c *gin.Context) {
authNode, ok := c.Get("relay_node")
if !ok {
respondUnauthorized(c, "无权进行此操作")
return
}
node := authNode.(*model.Node)
slog.Debug("relay ws upgrade requested", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
websocket.Handler(func(conn *websocket.Conn) {
client := service.RegisterRelayWSClient(node.NodeID)
defer service.UnregisterRelayWSClient(client)
defer func() {
_ = conn.Close()
slog.Debug("relay ws connection closed", "node_id", node.NodeID)
}()
slog.Debug("relay ws upgrade succeeded", "node_id", node.NodeID, "remote", c.Request.RemoteAddr)
go func() {
<-client.Done()
_ = conn.Close()
}()
go streamRelayWSMessages(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("relay ws receive timeout", "node_id", node.NodeID)
return
}
slog.Debug("relay ws receive failed", "node_id", node.NodeID, "error", err)
return
}
slog.Debug("relay ws message received", "node_id", node.NodeID, "type", message.Type)
switch message.Type {
case "ping":
if !service.SendRelayWSPong(node.NodeID) {
slog.Debug("relay ws pong enqueue failed", "node_id", node.NodeID)
}
case "pong":
slog.Debug("relay ws pong received", "node_id", node.NodeID)
default:
slog.Debug("relay ws unsupported message type", "node_id", node.NodeID, "type", message.Type)
}
}
}).ServeHTTP(c.Writer, c.Request)
}
func streamRelayWSMessages(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("relay ws send failed", "node_id", client.ID(), "error", err)
return
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
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)
}
+34
View File
@@ -0,0 +1,34 @@
package middleware
import (
"github.com/gin-gonic/gin"
"net/http"
"openflare/service"
)
// RelayAuth authenticates Relay requests using the shared agent token,
// and verifies the node is a tunnel_relay type.
func RelayAuth() func(c *gin.Context) {
return func(c *gin.Context) {
token := c.GetHeader("X-Agent-Token")
node, err := service.AuthenticateAgentToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "无权进行此操作,Agent Token 无效",
})
c.Abort()
return
}
if node.NodeType != "tunnel_relay" {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "此节点不是 TunnelRelay 类型",
})
c.Abort()
return
}
c.Set("relay_node", node)
c.Next()
}
}
@@ -0,0 +1,25 @@
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()
}
}
+1
View File
@@ -34,6 +34,7 @@ func registeredModels() []any {
&ProxyRoute{},
&ConfigVersion{},
&Node{},
&Tunnel{},
&NodeSystemProfile{},
&ApplyLog{},
&NodeMetricSnapshot{},
+82
View File
@@ -0,0 +1,82 @@
// v16 升级内容:新增 Tunnel 隧道表,为 nodes 增加 TunnelRelay 相关字段,为 proxy_routes 增加隧道上游支持字段。
// 背景说明:引入 Tunnel 组件后,边缘节点可通过隧道中继回源,需要区分节点类型(edge_node/relay_node)、记录中继绑定端口,
// 并在代理路由中支持 tunnel 上游类型以替代直连回源,同时需要独立的 tunnels 表管理隧道实例。
package migrate
import (
"fmt"
"gorm.io/gorm"
)
type nodeV16 struct {
NodeType string `gorm:"column:node_type;not null;default:'edge_node'"`
RelayBindPort int `gorm:"column:relay_bind_port"`
}
type tunnelV16 struct{}
type proxyRouteV16 struct {
UpstreamType string `gorm:"column:upstream_type;not null;default:'direct'"`
TunnelID *uint `gorm:"column:tunnel_id"`
}
func init() {
Register(V16())
}
func V16() Migration {
return Migration{
FromVersion: 15,
ToVersion: 16,
Migrate: migrateV16,
Validate: validateV16,
}
}
func (nodeV16) TableName() string {
return "nodes"
}
func (tunnelV16) TableName() string {
return "tunnels"
}
func (proxyRouteV16) TableName() string {
return "proxy_routes"
}
func migrateV16(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
return err
}
if err := db.Exec("UPDATE nodes SET node_type = 'edge_node' WHERE node_type = '' OR node_type IS NULL").Error; err != nil {
return fmt.Errorf("backfill nodes.node_type: %w", err)
}
if err := db.Exec("UPDATE proxy_routes SET upstream_type = 'direct' WHERE upstream_type = '' OR upstream_type IS NULL").Error; err != nil {
return fmt.Errorf("backfill proxy_routes.upstream_type: %w", err)
}
return nil
}
func validateV16(ctx Context, db *gorm.DB, backend string) error {
if err := ctx.ValidateDatabaseSchemaVersion(db, backend, 15); err != nil {
return err
}
if db == nil || !db.Migrator().HasTable(&tunnelV16{}) {
return fmt.Errorf("table tunnels is missing")
}
if !db.Migrator().HasColumn(&nodeV16{}, "node_type") {
return fmt.Errorf("column nodes.node_type is missing")
}
if !db.Migrator().HasColumn(&proxyRouteV16{}, "upstream_type") {
return fmt.Errorf("column proxy_routes.upstream_type is missing")
}
if !db.Migrator().HasColumn(&nodeV16{}, "relay_bind_port") {
return fmt.Errorf("column nodes.relay_bind_port is missing")
}
if !db.Migrator().HasColumn(&proxyRouteV16{}, "tunnel_id") {
return fmt.Errorf("column proxy_routes.tunnel_id is missing")
}
return nil
}
+30
View File
@@ -74,6 +74,10 @@ func (databaseSchemaMigrationContext) ValidateDatabaseSchemaVersion(db *gorm.DB,
return validateDatabaseSchemaV13(db, backend)
case 14:
return validateDatabaseSchemaV14(db, backend)
case 15:
return validateDatabaseSchemaV15(db, backend)
case 16:
return validateDatabaseSchemaV16(db, backend)
default:
return fmt.Errorf("database schema validation for v%d is not defined", version)
}
@@ -1140,6 +1144,32 @@ func validateDatabaseSchemaV14(db *gorm.DB, backend string) error {
return nil
}
func validateDatabaseSchemaV15(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV14(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&Node{}, "ip_manual_override") {
return fmt.Errorf("column nodes.ip_manual_override is missing")
}
return nil
}
func validateDatabaseSchemaV16(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV15(db, backend); err != nil {
return err
}
if !db.Migrator().HasTable(&Tunnel{}) {
return fmt.Errorf("table tunnels is missing")
}
if !db.Migrator().HasColumn(&Node{}, "node_type") {
return fmt.Errorf("column nodes.node_type is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "upstream_type") {
return fmt.Errorf("column proxy_routes.upstream_type is missing")
}
return nil
}
func databaseSchemaMigrations() []databaseSchemaMigration {
ctx := databaseSchemaMigrationContext{}
migrations := []databaseSchemaMigration{}
+17
View File
@@ -28,6 +28,18 @@ type Node struct {
LastError string `json:"last_error" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Node type: edge_node (default) | tunnel_relay
NodeType string `json:"node_type" gorm:"size:32;not null;default:'edge_node'"`
// TunnelRelay specific fields
RelayBindPort int `json:"relay_bind_port" gorm:"not null;default:0"`
RelayVhostHTTPPort int `json:"relay_vhost_http_port" gorm:"not null;default:0"`
RelayAuthToken string `json:"-" gorm:"size:128"`
RelayAgentAccessAddr string `json:"relay_agent_access_addr" gorm:"size:255"`
RelayClientAccessAddr string `json:"relay_client_access_addr" gorm:"size:255"`
RelayClientProxyURL string `json:"relay_client_proxy_url" gorm:"size:512"`
RelayStatus string `json:"relay_status" gorm:"size:16;not null;default:'unknown'"`
RelayFrpVersion string `json:"relay_frp_version" gorm:"size:64"`
RelayVersion string `json:"relay_version" gorm:"size:64"`
}
func ListNodes() (nodes []*Node, err error) {
@@ -72,3 +84,8 @@ func (node *Node) Update() error {
func (node *Node) Delete() error {
return DB.Delete(node).Error
}
func ListNodesByType(nodeType string) (nodes []*Node, err error) {
err = DB.Where("node_type = ?", nodeType).Order("id desc").Find(&nodes).Error
return nodes, err
}
+63 -55
View File
@@ -3,35 +3,39 @@ package model
import "time"
type ProxyRoute struct {
ID uint `json:"id" gorm:"primaryKey"`
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
OriginID *uint `json:"origin_id" gorm:"index"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
BasicAuthPassword string `json:"basic_auth_password" gorm:"size:255;not null;default:''"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
OriginID *uint `json:"origin_id" gorm:"index"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
BasicAuthEnabled bool `json:"basic_auth_enabled" gorm:"not null;default:false"`
BasicAuthUsername string `json:"basic_auth_username" gorm:"size:255;not null;default:''"`
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"`
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListProxyRoutes() (routes []*ProxyRoute, err error) {
@@ -61,32 +65,36 @@ func (route *ProxyRoute) Insert() error {
func (route *ProxyRoute) Update() error {
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
"site_name": route.SiteName,
"domain": route.Domain,
"domains": route.Domains,
"origin_id": route.OriginID,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
"enabled": route.Enabled,
"enable_https": route.EnableHTTPS,
"cert_id": route.CertID,
"cert_ids": route.CertIDs,
"domain_cert_ids": route.DomainCertIDs,
"redirect_http": route.RedirectHTTP,
"limit_conn_per_server": route.LimitConnPerServer,
"limit_conn_per_ip": route.LimitConnPerIP,
"limit_rate": route.LimitRate,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"pow_enabled": route.PoWEnabled,
"pow_config": route.PoWConfig,
"basic_auth_enabled": route.BasicAuthEnabled,
"basic_auth_username": route.BasicAuthUsername,
"basic_auth_password": route.BasicAuthPassword,
"remark": route.Remark,
"site_name": route.SiteName,
"domain": route.Domain,
"domains": route.Domains,
"origin_id": route.OriginID,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
"enabled": route.Enabled,
"enable_https": route.EnableHTTPS,
"cert_id": route.CertID,
"cert_ids": route.CertIDs,
"domain_cert_ids": route.DomainCertIDs,
"redirect_http": route.RedirectHTTP,
"limit_conn_per_server": route.LimitConnPerServer,
"limit_conn_per_ip": route.LimitConnPerIP,
"limit_rate": route.LimitRate,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"pow_enabled": route.PoWEnabled,
"pow_config": route.PoWConfig,
"basic_auth_enabled": route.BasicAuthEnabled,
"basic_auth_username": route.BasicAuthUsername,
"basic_auth_password": route.BasicAuthPassword,
"remark": route.Remark,
"upstream_type": route.UpstreamType,
"tunnel_id": route.TunnelID,
"tunnel_target_addr": route.TunnelTargetAddr,
"tunnel_target_protocol": route.TunnelTargetProtocol,
}).Error
}
+56
View File
@@ -0,0 +1,56 @@
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
}
+24
View File
@@ -191,6 +191,16 @@ 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())
{
@@ -217,5 +227,19 @@ func SetApiRouter(router *gin.Engine) {
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
}
}
relayRoute := apiRouter.Group("/relay")
relayRoute.Use(middleware.RelayAuth())
{
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)
}
}
}
+10
View File
@@ -142,6 +142,16 @@ type NodeView struct {
LatestApplyAt *time.Time `json:"latest_apply_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// TunnelRelay fields
NodeType string `json:"node_type"`
RelayBindPort int `json:"relay_bind_port"`
RelayVhostHTTPPort int `json:"relay_vhost_http_port"`
RelayAgentAccessAddr string `json:"relay_agent_access_addr"`
RelayClientAccessAddr string `json:"relay_client_access_addr"`
RelayClientProxyURL string `json:"relay_client_proxy_url"`
RelayStatus string `json:"relay_status"`
RelayFrpVersion string `json:"relay_frp_version"`
RelayVersion string `json:"relay_version"`
}
func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatResponse, error) {
+23 -156
View File
@@ -3,7 +3,6 @@ package service
import (
"encoding/json"
"log/slog"
"sync"
)
const (
@@ -22,11 +21,6 @@ type AgentWSInboundMessage struct {
Payload json.RawMessage `json:"payload,omitempty"`
}
type AgentWSOutboundMessage struct {
Type string `json:"type"`
Payload any `json:"payload,omitempty"`
}
type AgentWSBroadcastResult struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
@@ -35,133 +29,29 @@ type AgentWSBroadcastResult struct {
FailedNodes []string `json:"failed_nodes"`
}
type AgentWSClient struct {
nodeID string
send chan AgentWSOutboundMessage
done chan struct{}
once sync.Once
var DefaultAgentWSHub = NewWSHub("agent")
func RegisterAgentWSClient(nodeID string) *WSClient {
return DefaultAgentWSHub.Register(nodeID)
}
func (client *AgentWSClient) NodeID() string {
if client == nil {
return ""
}
return client.nodeID
}
func (client *AgentWSClient) Messages() <-chan AgentWSOutboundMessage {
if client == nil {
return nil
}
return client.send
}
func (client *AgentWSClient) Done() <-chan struct{} {
if client == nil {
return nil
}
return client.done
}
func (client *AgentWSClient) Send(message AgentWSOutboundMessage) bool {
if client == nil {
return false
}
select {
case <-client.done:
return false
case client.send <- message:
return true
default:
return false
}
}
func (client *AgentWSClient) Close() {
if client == nil {
return
}
client.once.Do(func() {
close(client.done)
})
}
type agentWSHub struct {
mu sync.RWMutex
clients map[string]*AgentWSClient
}
var defaultAgentWSHub = &agentWSHub{
clients: make(map[string]*AgentWSClient),
}
func RegisterAgentWSClient(nodeID string) *AgentWSClient {
client := &AgentWSClient{
nodeID: nodeID,
send: make(chan AgentWSOutboundMessage, 16),
done: make(chan struct{}),
}
defaultAgentWSHub.mu.Lock()
if existing := defaultAgentWSHub.clients[nodeID]; existing != nil {
slog.Debug("agent ws replacing existing connection", "node_id", nodeID)
existing.Close()
}
defaultAgentWSHub.clients[nodeID] = client
count := len(defaultAgentWSHub.clients)
defaultAgentWSHub.mu.Unlock()
slog.Debug("agent ws connection registered", "node_id", nodeID, "client_count", count)
return client
}
func UnregisterAgentWSClient(client *AgentWSClient) {
if client == nil {
return
}
defaultAgentWSHub.mu.Lock()
if current := defaultAgentWSHub.clients[client.nodeID]; current == client {
delete(defaultAgentWSHub.clients, client.nodeID)
}
count := len(defaultAgentWSHub.clients)
defaultAgentWSHub.mu.Unlock()
client.Close()
slog.Debug("agent ws connection unregistered", "node_id", client.nodeID, "client_count", count)
func UnregisterAgentWSClient(client *WSClient) {
DefaultAgentWSHub.Unregister(client)
}
func DisconnectAgentWSClient(nodeID string) {
defaultAgentWSHub.mu.Lock()
client := defaultAgentWSHub.clients[nodeID]
if client != nil {
delete(defaultAgentWSHub.clients, nodeID)
}
count := len(defaultAgentWSHub.clients)
defaultAgentWSHub.mu.Unlock()
if client != nil {
client.Close()
slog.Debug("agent ws connection forcefully disconnected", "node_id", nodeID, "client_count", count)
}
DefaultAgentWSHub.Disconnect(nodeID)
}
func IsAgentWSConnected(nodeID string) bool {
defaultAgentWSHub.mu.RLock()
client := defaultAgentWSHub.clients[nodeID]
defaultAgentWSHub.mu.RUnlock()
if client == nil {
return false
}
select {
case <-client.done:
return false
default:
return true
}
return DefaultAgentWSHub.IsConnected(nodeID)
}
func SendAgentWSSettings(nodeID string, settings *AgentSettings) bool {
if settings == nil {
return false
}
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
Type: AgentWSMessageTypeSettings,
Payload: settings,
})
@@ -171,7 +61,7 @@ func SendAgentWSActiveConfig(nodeID string, activeConfig *ActiveConfigMeta) bool
if activeConfig == nil {
return false
}
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
Type: AgentWSMessageTypeActiveConfig,
Payload: activeConfig,
})
@@ -181,60 +71,37 @@ func SendAgentWSForceSyncConfig(nodeID string, activeConfig *ActiveConfigMeta) b
if activeConfig == nil {
return false
}
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
Type: AgentWSMessageTypeForceSyncConfig,
Payload: activeConfig,
})
}
func SendAgentWSPong(nodeID string) bool {
return sendAgentWSMessage(nodeID, AgentWSOutboundMessage{
return DefaultAgentWSHub.SendMessage(nodeID, WSMessage{
Type: AgentWSMessageTypePong,
})
}
func sendAgentWSMessage(nodeID string, message AgentWSOutboundMessage) bool {
defaultAgentWSHub.mu.RLock()
client := defaultAgentWSHub.clients[nodeID]
defaultAgentWSHub.mu.RUnlock()
if client == nil {
return false
}
ok := client.Send(message)
if !ok {
slog.Debug("agent ws send queued message failed", "node_id", nodeID, "type", message.Type)
}
return ok
}
func BroadcastAgentWSActiveConfig(activeConfig *ActiveConfigMeta) AgentWSBroadcastResult {
result := AgentWSBroadcastResult{}
if activeConfig == nil {
slog.Debug("agent ws broadcast skipped because active config is nil")
return result
return AgentWSBroadcastResult{}
}
result.Version = activeConfig.Version
result.Checksum = activeConfig.Checksum
defaultAgentWSHub.mu.RLock()
clients := make([]*AgentWSClient, 0, len(defaultAgentWSHub.clients))
for _, client := range defaultAgentWSHub.clients {
clients = append(clients, client)
}
defaultAgentWSHub.mu.RUnlock()
result.ClientCount = len(clients)
message := AgentWSOutboundMessage{
res := DefaultAgentWSHub.Broadcast(WSMessage{
Type: AgentWSMessageTypeActiveConfig,
Payload: activeConfig,
})
result := AgentWSBroadcastResult{
Version: activeConfig.Version,
Checksum: activeConfig.Checksum,
ClientCount: res.ClientCount,
SuccessCount: res.SuccessCount,
FailedNodes: res.FailedIDs,
}
for _, client := range clients {
if client.Send(message) {
result.SuccessCount++
continue
}
result.FailedNodes = append(result.FailedNodes, client.NodeID())
}
slog.Debug("agent ws broadcast active config",
"version", result.Version,
"checksum", result.Checksum,
+4 -2
View File
@@ -340,10 +340,12 @@ func PublishConfigVersion(createdBy string, force bool) (*ReleaseResult, error)
}
return nil, err
}
BroadcastAgentWSActiveConfig(&ActiveConfigMeta{
activeConfig := &ActiveConfigMeta{
Version: record.Version,
Checksum: record.Checksum,
})
}
BroadcastAgentWSActiveConfig(activeConfig)
BroadcastFlaredWSActiveConfig(activeConfig)
return &ReleaseResult{
Version: record,
Routes: bundle.Routes,
+54
View File
@@ -0,0 +1,54 @@
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
}
@@ -61,7 +61,7 @@ func TestCreateTLSCertificateAndRenderHTTPSConfig(t *testing.T) {
if !strings.Contains(result.Version.MainConfig, "error_log __OPENFLARE_ERROR_LOG__ warn;") {
t.Fatal("expected main config to include managed error log placeholder")
}
if !strings.Contains(result.Version.MainConfig, "log_by_lua_file __OPENFLARE_LUA_DIR__/log.lua;") {
if !strings.Contains(result.Version.MainConfig, "log_by_lua_file __OPENFLARE_LUA_DIR__/observability/log.lua;") {
t.Fatal("expected main config to include managed openresty lua log hook")
}
if !strings.Contains(result.Version.MainConfig, "listen __OPENFLARE_OBSERVABILITY_LISTEN__;") {
@@ -893,7 +893,7 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
if !strings.Contains(preview.MainConfig, "include __OPENFLARE_ROUTE_CONFIG__;") {
t.Fatal("expected preview main config to include managed route config placeholder")
}
if !strings.Contains(preview.MainConfig, "log_by_lua_file __OPENFLARE_LUA_DIR__/log.lua;") {
if !strings.Contains(preview.MainConfig, "log_by_lua_file __OPENFLARE_LUA_DIR__/observability/log.lua;") {
t.Fatal("expected preview main config to include managed openresty lua log hook")
}
if !strings.Contains(preview.RenderedConfig, `proxy_set_header X-Release "candidate";`) {
+58
View File
@@ -24,6 +24,13 @@ type NodeInput struct {
GeoLatitude *float64 `json:"geo_latitude"`
GeoLongitude *float64 `json:"geo_longitude"`
GeoManualOverride bool `json:"geo_manual_override"`
// TunnelRelay fields
NodeType string `json:"node_type"`
RelayBindPort int `json:"relay_bind_port"`
RelayVhostHTTPPort int `json:"relay_vhost_http_port"`
RelayAgentAccessAddr string `json:"relay_agent_access_addr"`
RelayClientAccessAddr string `json:"relay_client_access_addr"`
RelayClientProxyURL string `json:"relay_client_proxy_url"`
}
type NodeAgentUpdateInput struct {
@@ -73,6 +80,7 @@ func CreateNode(input NodeInput) (*NodeView, error) {
NginxVersion: "",
Status: NodeStatusPending,
AutoUpdateEnabled: input.AutoUpdateEnabled,
NodeType: normalizeNodeType(input.NodeType),
}
node.NodeID, err = newServerNodeID()
if err != nil {
@@ -82,6 +90,17 @@ func CreateNode(input NodeInput) (*NodeView, error) {
if err != nil {
return nil, err
}
if node.NodeType == "tunnel_relay" {
node.RelayBindPort = normalizeRelayPort(input.RelayBindPort, 7000)
node.RelayVhostHTTPPort = normalizeRelayPort(input.RelayVhostHTTPPort, 8080)
node.RelayAuthToken, err = newRandomToken()
if err != nil {
return nil, err
}
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
}
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, node.IP)
}
@@ -114,6 +133,17 @@ func UpdateNode(id uint, input NodeInput) (*NodeView, error) {
node.GeoLongitude = geoLongitude
node.GeoManualOverride = geoManualOverride
node.AutoUpdateEnabled = input.AutoUpdateEnabled
if node.NodeType == "tunnel_relay" {
node.RelayAgentAccessAddr = strings.TrimSpace(input.RelayAgentAccessAddr)
node.RelayClientAccessAddr = strings.TrimSpace(input.RelayClientAccessAddr)
node.RelayClientProxyURL = strings.TrimSpace(input.RelayClientProxyURL)
if input.RelayBindPort > 0 {
node.RelayBindPort = input.RelayBindPort
}
if input.RelayVhostHTTPPort > 0 {
node.RelayVhostHTTPPort = input.RelayVhostHTTPPort
}
}
if !node.GeoManualOverride {
applyGeoInfoFromIP(node, strings.TrimSpace(node.IP))
}
@@ -313,6 +343,18 @@ func buildNodeView(node *model.Node) *NodeView {
if view.UpdateChannel == "" {
view.UpdateChannel = ReleaseChannelStable.String()
}
view.NodeType = node.NodeType
if view.NodeType == "" {
view.NodeType = "edge_node"
}
view.RelayBindPort = node.RelayBindPort
view.RelayVhostHTTPPort = node.RelayVhostHTTPPort
view.RelayAgentAccessAddr = node.RelayAgentAccessAddr
view.RelayClientAccessAddr = node.RelayClientAccessAddr
view.RelayClientProxyURL = node.RelayClientProxyURL
view.RelayStatus = node.RelayStatus
view.RelayFrpVersion = node.RelayFrpVersion
view.RelayVersion = node.RelayVersion
return view
}
@@ -587,3 +629,19 @@ func newServerNodeID() (string, error) {
}
return "node-" + token, nil
}
func normalizeNodeType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "tunnel_relay":
return "tunnel_relay"
default:
return "edge_node"
}
}
func normalizeRelayPort(port int, defaultPort int) int {
if port <= 0 || port > 65535 {
return defaultPort
}
return port
}
+156 -105
View File
@@ -31,73 +31,81 @@ type ProxyRouteCustomHeaderInput struct {
}
type ProxyRouteInput struct {
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Domains []string `json:"domains"`
OriginID *uint `json:"origin_id"`
OriginURL string `json:"origin_url"`
OriginScheme string `json:"origin_scheme"`
OriginAddress string `json:"origin_address"`
OriginPort string `json:"origin_port"`
OriginURI string `json:"origin_uri"`
OriginHost string `json:"origin_host"`
Upstreams []string `json:"upstreams"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id"`
CertIDs []uint `json:"cert_ids"`
DomainCertIDs []uint `json:"domain_cert_ids"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig string `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
Remark string `json:"remark"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Domains []string `json:"domains"`
OriginID *uint `json:"origin_id"`
OriginURL string `json:"origin_url"`
OriginScheme string `json:"origin_scheme"`
OriginAddress string `json:"origin_address"`
OriginPort string `json:"origin_port"`
OriginURI string `json:"origin_uri"`
OriginHost string `json:"origin_host"`
Upstreams []string `json:"upstreams"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id"`
CertIDs []uint `json:"cert_ids"`
DomainCertIDs []uint `json:"domain_cert_ids"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig string `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
}
type ProxyRouteView struct {
ID uint `json:"id"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Domains []string `json:"domains"`
PrimaryDomain string `json:"primary_domain"`
DomainCount int `json:"domain_count"`
OriginID *uint `json:"origin_id"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host"`
Upstreams string `json:"upstreams"`
UpstreamList []string `json:"upstream_list"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id"`
CertIDs []uint `json:"cert_ids"`
DomainCertIDs []uint `json:"domain_cert_ids"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules string `json:"cache_rules"`
CacheRuleList []string `json:"cache_rule_list"`
CustomHeaders string `json:"custom_headers"`
CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id"`
SiteName string `json:"site_name"`
Domain string `json:"domain"`
Domains []string `json:"domains"`
PrimaryDomain string `json:"primary_domain"`
DomainCount int `json:"domain_count"`
OriginID *uint `json:"origin_id"`
OriginURL string `json:"origin_url"`
OriginHost string `json:"origin_host"`
Upstreams string `json:"upstreams"`
UpstreamList []string `json:"upstream_list"`
Enabled bool `json:"enabled"`
EnableHTTPS bool `json:"enable_https"`
CertID *uint `json:"cert_id"`
CertIDs []uint `json:"cert_ids"`
DomainCertIDs []uint `json:"domain_cert_ids"`
RedirectHTTP bool `json:"redirect_http"`
LimitConnPerServer int `json:"limit_conn_per_server"`
LimitConnPerIP int `json:"limit_conn_per_ip"`
LimitRate string `json:"limit_rate"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy"`
CacheRules string `json:"cache_rules"`
CacheRuleList []string `json:"cache_rule_list"`
CustomHeaders string `json:"custom_headers"`
CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config"`
BasicAuthEnabled bool `json:"basic_auth_enabled"`
BasicAuthUsername string `json:"basic_auth_username"`
BasicAuthPassword string `json:"basic_auth_password"`
Remark string `json:"remark"`
UpstreamType string `json:"upstream_type"`
TunnelID *uint `json:"tunnel_id"`
TunnelTargetAddr string `json:"tunnel_target_addr"`
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListProxyRoutes() ([]*ProxyRouteView, error) {
@@ -164,16 +172,27 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
domain := domains[0]
siteName := normalizeProxyRouteSiteNameInput(route, input.SiteName, domain)
originURL, originID, err := resolveProxyRoutePrimaryOrigin(input)
if err != nil {
return nil, err
upstreamType := normalizeUpstreamType(input.UpstreamType)
var originURL string
var originID *uint
var upstreams []string
if upstreamType == "tunnel" {
// Tunnel type: origin URL is auto-filled during config rendering
originURL = "http://127.0.0.1"
upstreams = []string{originURL}
} else {
originURL, originID, err = resolveProxyRoutePrimaryOrigin(input)
if err != nil {
return nil, err
}
upstreams, err = normalizeUpstreams(originURL, input.Upstreams)
if err != nil {
return nil, err
}
}
originHost := strings.TrimSpace(input.OriginHost)
remark := strings.TrimSpace(input.Remark)
upstreams, err := normalizeUpstreams(originURL, input.Upstreams)
if err != nil {
return nil, err
}
cachePolicy := strings.TrimSpace(input.CachePolicy)
cacheRules, err := normalizeCacheRules(input.CacheEnabled, cachePolicy, input.CacheRules)
if err != nil {
@@ -306,6 +325,16 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route.BasicAuthUsername = input.BasicAuthUsername
route.BasicAuthPassword = input.BasicAuthPassword
route.Remark = remark
route.UpstreamType = upstreamType
if upstreamType == "tunnel" {
route.TunnelID = input.TunnelID
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
} else {
route.TunnelID = nil
route.TunnelTargetAddr = ""
route.TunnelTargetProtocol = ""
}
return route, nil
}
@@ -359,40 +388,44 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
}
primaryDomain := domains[0]
return &ProxyRouteView{
ID: route.ID,
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, primaryDomain),
Domain: primaryDomain,
Domains: domains,
PrimaryDomain: primaryDomain,
DomainCount: len(domains),
OriginID: route.OriginID,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: route.Upstreams,
UpstreamList: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: certID,
CertIDs: certIDs,
DomainCertIDs: domainCertIDs,
RedirectHTTP: route.RedirectHTTP,
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: route.CacheRules,
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
CustomHeaderList: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
Remark: route.Remark,
CreatedAt: route.CreatedAt,
UpdatedAt: route.UpdatedAt,
ID: route.ID,
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, primaryDomain),
Domain: primaryDomain,
Domains: domains,
PrimaryDomain: primaryDomain,
DomainCount: len(domains),
OriginID: route.OriginID,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: route.Upstreams,
UpstreamList: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: certID,
CertIDs: certIDs,
DomainCertIDs: domainCertIDs,
RedirectHTTP: route.RedirectHTTP,
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: route.CacheRules,
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
CustomHeaderList: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
BasicAuthEnabled: route.BasicAuthEnabled,
BasicAuthUsername: route.BasicAuthUsername,
BasicAuthPassword: route.BasicAuthPassword,
Remark: route.Remark,
UpstreamType: route.UpstreamType,
TunnelID: route.TunnelID,
TunnelTargetAddr: route.TunnelTargetAddr,
TunnelTargetProtocol: route.TunnelTargetProtocol,
CreatedAt: route.CreatedAt,
UpdatedAt: route.UpdatedAt,
}, nil
}
@@ -1208,3 +1241,21 @@ func decodeStoredPoWConfig(enabled bool, raw string) (*ProxyRoutePoWConfig, erro
}
return &cfg, nil
}
func normalizeUpstreamType(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "tunnel":
return "tunnel"
default:
return "direct"
}
}
func normalizeTunnelTargetProtocol(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "https":
return "https"
default:
return "http"
}
}
+294
View File
@@ -0,0 +1,294 @@
package service
import (
"encoding/json"
"fmt"
"log/slog"
"openflare/common"
"openflare/model"
"strings"
"time"
)
// RelayHeartbeatPayload is the payload sent by OpenFlareRelay in each heartbeat.
type RelayHeartbeatPayload struct {
RelayVersion string `json:"relay_version"`
FrpVersion string `json:"frp_version"`
RelayStatus string `json:"relay_status"`
FrpsConnCount int `json:"frps_connections"`
FrpsProxyCount int `json:"frps_proxy_count"`
}
// RelayConfig is the frps configuration sent to the Relay.
type RelayConfig struct {
BindPort int `json:"bind_port"`
VhostHTTPPort int `json:"vhost_http_port"`
AuthToken string `json:"auth_token"`
LogLevel string `json:"log_level"`
}
// RelaySettings contains runtime settings for the Relay.
type RelaySettings struct {
HeartbeatInterval int `json:"heartbeat_interval"`
WebsocketUpgradeEnabled bool `json:"websocket_upgrade_enabled"`
}
// RelayHeartbeatResponse is the response returned to the Relay from a heartbeat.
type RelayHeartbeatResponse struct {
RelayConfig *RelayConfig `json:"relay_config"`
RelaySettings *RelaySettings `json:"relay_settings"`
}
// HeartbeatRelay processes a relay heartbeat, updates node status, and returns config.
func HeartbeatRelay(node *model.Node, payload RelayHeartbeatPayload) (*RelayHeartbeatResponse, error) {
if node == nil {
return nil, fmt.Errorf("relay node is nil")
}
slog.Debug("relay heartbeat received", "node_id", node.NodeID)
payload.RelayVersion = strings.TrimSpace(payload.RelayVersion)
payload.FrpVersion = strings.TrimSpace(payload.FrpVersion)
payload.RelayStatus = normalizeRelayStatus(payload.RelayStatus)
changes := make(map[string]any)
appendRelayChange := func(key string, before any, after any) {
if before != after {
changes[key] = after
}
}
now := time.Now()
appendRelayChange("relay_version", node.RelayVersion, payload.RelayVersion)
appendRelayChange("relay_frp_version", node.RelayFrpVersion, payload.FrpVersion)
appendRelayChange("relay_status", node.RelayStatus, payload.RelayStatus)
if !node.LastSeenAt.Equal(now) {
changes["last_seen_at"] = now
}
changes["status"] = NodeStatusOnline
node.RelayVersion = payload.RelayVersion
node.RelayFrpVersion = payload.FrpVersion
node.RelayStatus = payload.RelayStatus
node.LastSeenAt = now
node.Status = NodeStatusOnline
if len(changes) > 0 {
if err := model.DB.Model(node).Updates(changes).Error; err != nil {
return nil, fmt.Errorf("update relay heartbeat: %w", err)
}
}
refreshAgentTokenCache(node)
return &RelayHeartbeatResponse{
RelayConfig: buildRelayConfig(node),
RelaySettings: buildRelaySettings(),
}, nil
}
func buildRelayConfig(node *model.Node) *RelayConfig {
if node == nil {
return nil
}
return &RelayConfig{
BindPort: node.RelayBindPort,
VhostHTTPPort: node.RelayVhostHTTPPort,
AuthToken: node.RelayAuthToken,
LogLevel: "info",
}
}
func buildRelaySettings() *RelaySettings {
return &RelaySettings{
HeartbeatInterval: common.AgentHeartbeatInterval,
WebsocketUpgradeEnabled: common.AgentWebsocketUpgradeEnabled,
}
}
func normalizeRelayStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "healthy":
return "healthy"
case "unhealthy":
return "unhealthy"
default:
return "unknown"
}
}
// FlaredHeartbeatPayload is the payload sent by OpenFlared in each heartbeat.
type FlaredHeartbeatPayload struct {
ClientVersion string `json:"client_version"`
FrpVersion string `json:"frp_version"`
TunnelStatus string `json:"tunnel_status"`
ConnectedRelays []FlaredConnectedRelay `json:"connected_relays"`
CurrentVersion string `json:"current_version"`
CurrentChecksum string `json:"current_checksum"`
}
// FlaredConnectedRelay describes the status of a relay connection from a client.
type FlaredConnectedRelay struct {
RelayNodeID string `json:"relay_node_id"`
Status string `json:"status"`
ProxyCount int `json:"proxy_count"`
}
// FlaredHeartbeatResponse is the response returned to the OpenFlared client.
type FlaredHeartbeatResponse struct {
ActiveConfig *ActiveConfigMeta `json:"active_config"`
TunnelSettings *RelaySettings `json:"tunnel_settings"`
}
// FlaredTunnelConfigResponse is the full tunnel routing config sent to the client.
type FlaredTunnelConfigResponse struct {
Version string `json:"version"`
Checksum string `json:"checksum"`
Relays []FlaredRelayInfo `json:"relays"`
Proxies []FlaredProxyEntry `json:"proxies"`
}
// FlaredRelayInfo describes a relay that the client should connect to.
type FlaredRelayInfo struct {
RelayNodeID string `json:"relay_node_id"`
Address string `json:"address"`
AuthToken string `json:"auth_token"`
ProxyURL string `json:"proxy_url"`
}
// FlaredProxyEntry describes a single frpc proxy definition.
type FlaredProxyEntry struct {
Name string `json:"name"`
Type string `json:"type"`
LocalAddr string `json:"local_addr"`
LocalPort int `json:"local_port"`
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")
}
activeVersion, err := model.GetActiveConfigVersion()
if err != nil {
return nil, fmt.Errorf("no active config version: %w", err)
}
// Get all enabled proxy routes with tunnel upstream targeting this tunnel
routes, err := model.GetEnabledProxyRoutes()
if err != nil {
return nil, fmt.Errorf("get proxy routes: %w", err)
}
// Get all online tunnel relay nodes
relayNodes, err := model.ListNodesByType("tunnel_relay")
if err != nil {
return nil, fmt.Errorf("get relay nodes: %w", err)
}
// Build relay info
relays := make([]FlaredRelayInfo, 0, len(relayNodes))
for _, node := range relayNodes {
if node.RelayStatus == "healthy" || node.Status == NodeStatusOnline {
addr := strings.TrimSpace(node.RelayClientAccessAddr)
if addr == "" {
addr = fmt.Sprintf("%s:%d", strings.TrimSpace(node.IP), node.RelayBindPort)
}
relays = append(relays, FlaredRelayInfo{
RelayNodeID: node.NodeID,
Address: addr,
AuthToken: node.RelayAuthToken,
ProxyURL: strings.TrimSpace(node.RelayClientProxyURL),
})
}
}
// Build proxy entries from routes
proxies := make([]FlaredProxyEntry, 0)
for _, route := range routes {
if route.UpstreamType != "tunnel" || route.TunnelID == nil || *route.TunnelID != tunnel.ID {
continue
}
if !route.Enabled {
continue
}
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
continue
}
localAddr, localPort := parseTunnelTargetAddr(route.TunnelTargetAddr)
proxies = append(proxies, FlaredProxyEntry{
Name: fmt.Sprintf("%s-%s", tunnel.TunnelID, sanitizeProxyName(domains[0])),
Type: "http",
LocalAddr: localAddr,
LocalPort: localPort,
CustomDomains: domains,
})
}
return &FlaredTunnelConfigResponse{
Version: activeVersion.Version,
Checksum: activeVersion.Checksum,
Relays: relays,
Proxies: proxies,
}, nil
}
func parseTunnelTargetAddr(addr string) (string, int) {
addr = strings.TrimSpace(addr)
if addr == "" {
return "127.0.0.1", 80
}
host, portStr, err := splitHostPort(addr)
if err != nil {
return addr, 80
}
port := 80
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
port = 80
}
return host, port
}
func splitHostPort(addr string) (string, string, error) {
lastColon := strings.LastIndex(addr, ":")
if lastColon < 0 {
return addr, "", fmt.Errorf("no port")
}
return addr[:lastColon], addr[lastColon+1:], nil
}
func sanitizeProxyName(domain string) string {
return strings.ReplaceAll(strings.ReplaceAll(domain, ".", "-"), "*", "wildcard")
}
+37
View File
@@ -0,0 +1,37 @@
package service
var DefaultRelayWSHub = NewWSHub("relay")
func RegisterRelayWSClient(nodeID string) *WSClient {
return DefaultRelayWSHub.Register(nodeID)
}
func UnregisterRelayWSClient(client *WSClient) {
DefaultRelayWSHub.Unregister(client)
}
func IsRelayWSConnected(nodeID string) bool {
return DefaultRelayWSHub.IsConnected(nodeID)
}
func SendRelayWSPing(nodeID string) bool {
return DefaultRelayWSHub.SendMessage(nodeID, WSMessage{
Type: "ping",
})
}
func SendRelayWSPong(nodeID string) bool {
return DefaultRelayWSHub.SendMessage(nodeID, WSMessage{
Type: "pong",
})
}
func SendRelayWSConfig(nodeID string, config *RelayConfig) bool {
if config == nil {
return false
}
return DefaultRelayWSHub.SendMessage(nodeID, WSMessage{
Type: "relay_config",
Payload: config,
})
}
+183
View File
@@ -0,0 +1,183 @@
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
}
+177
View File
@@ -0,0 +1,177 @@
package service
import (
"log/slog"
"sync"
)
type WSMessage struct {
Type string `json:"type"`
Payload any `json:"payload,omitempty"`
}
type WSClient struct {
id string
send chan WSMessage
done chan struct{}
once sync.Once
}
func (client *WSClient) ID() string {
if client == nil {
return ""
}
return client.id
}
func (client *WSClient) Messages() <-chan WSMessage {
if client == nil {
return nil
}
return client.send
}
func (client *WSClient) Done() <-chan struct{} {
if client == nil {
return nil
}
return client.done
}
func (client *WSClient) Send(message WSMessage) bool {
if client == nil {
return false
}
select {
case <-client.done:
return false
case client.send <- message:
return true
default:
return false
}
}
func (client *WSClient) Close() {
if client == nil {
return
}
client.once.Do(func() {
close(client.done)
})
}
type WSHub struct {
name string
mu sync.RWMutex
clients map[string]*WSClient
}
func NewWSHub(name string) *WSHub {
return &WSHub{
name: name,
clients: make(map[string]*WSClient),
}
}
func (h *WSHub) Register(id string) *WSClient {
client := &WSClient{
id: id,
send: make(chan WSMessage, 16),
done: make(chan struct{}),
}
h.mu.Lock()
if existing := h.clients[id]; existing != nil {
slog.Debug("ws replacing existing connection", "hub", h.name, "id", id)
existing.Close()
}
h.clients[id] = client
count := len(h.clients)
h.mu.Unlock()
slog.Debug("ws connection registered", "hub", h.name, "id", id, "client_count", count)
return client
}
func (h *WSHub) Unregister(client *WSClient) {
if client == nil {
return
}
h.mu.Lock()
if current := h.clients[client.id]; current == client {
delete(h.clients, client.id)
}
count := len(h.clients)
h.mu.Unlock()
client.Close()
slog.Debug("ws connection unregistered", "hub", h.name, "id", client.id, "client_count", count)
}
func (h *WSHub) Disconnect(id string) {
h.mu.Lock()
client := h.clients[id]
if client != nil {
delete(h.clients, id)
}
count := len(h.clients)
h.mu.Unlock()
if client != nil {
client.Close()
slog.Debug("ws connection forcefully disconnected", "hub", h.name, "id", id, "client_count", count)
}
}
func (h *WSHub) IsConnected(id string) bool {
h.mu.RLock()
client := h.clients[id]
h.mu.RUnlock()
if client == nil {
return false
}
select {
case <-client.done:
return false
default:
return true
}
}
func (h *WSHub) SendMessage(id string, message WSMessage) bool {
h.mu.RLock()
client := h.clients[id]
h.mu.RUnlock()
if client == nil {
return false
}
ok := client.Send(message)
if !ok {
slog.Debug("ws send queued message failed", "hub", h.name, "id", id, "type", message.Type)
}
return ok
}
type WSBroadcastResult struct {
ClientCount int `json:"client_count"`
SuccessCount int `json:"success_count"`
FailedIDs []string `json:"failed_ids"`
}
func (h *WSHub) Broadcast(message WSMessage) WSBroadcastResult {
h.mu.RLock()
clients := make([]*WSClient, 0, len(h.clients))
for _, client := range h.clients {
clients = append(clients, client)
}
h.mu.RUnlock()
var result WSBroadcastResult
result.ClientCount = len(clients)
for _, client := range clients {
if client.Send(message) {
result.SuccessCount++
continue
}
result.FailedIDs = append(result.FailedIDs, client.ID())
}
return result
}